diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 80291c73e61..87fed908c6e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,6 @@ name: Report an issue with Home Assistant Core description: Report an issue with Home Assistant Core. +type: Bug body: - type: markdown attributes: diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ed5005584bd..9b76f3550fd 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -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.6.1 + uses: actions/upload-artifact@v4.6.2 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@v8 + uses: dawidd6/action-download-artifact@v9 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@v8 + uses: dawidd6/action-download-artifact@v9 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package @@ -116,7 +116,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: name: translations @@ -190,14 +190,14 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2025.02.0 + uses: home-assistant/builder@2025.03.0 with: args: | $BUILD_ARGS \ @@ -256,14 +256,14 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2025.02.0 + uses: home-assistant/builder@2025.03.0 with: args: | $BUILD_ARGS \ @@ -324,20 +324,20 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.8.1 + uses: sigstore/cosign-installer@v3.8.2 with: cosign-release: "v2.2.3" - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -457,12 +457,12 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: name: translations @@ -502,14 +502,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Login to GitHub Container Registry - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -522,7 +522,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.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@f9eaf234fc1c2e333c1eca18177db0f44fa6ba52 # v2.2.1 + uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.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 97986f26ee3..af0bdc5c2df 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,10 +37,10 @@ on: type: boolean env: - CACHE_VERSION: 11 + CACHE_VERSION: 2 UV_CACHE_VERSION: 1 - MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.4" + MYPY_CACHE_VERSION: 1 + HA_SHORT_VERSION: "2025.6" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version @@ -89,6 +89,7 @@ jobs: test_groups: ${{ steps.info.outputs.test_groups }} tests_glob: ${{ steps.info.outputs.tests_glob }} tests: ${{ steps.info.outputs.tests }} + lint_only: ${{ steps.info.outputs.lint_only }} skip_coverage: ${{ steps.info.outputs.skip_coverage }} runs-on: ubuntu-24.04 steps: @@ -142,6 +143,7 @@ jobs: test_group_count=10 tests="[]" tests_glob="" + lint_only="" skip_coverage="" if [[ "${{ steps.integrations.outputs.changes }}" != "[]" ]]; @@ -192,6 +194,17 @@ jobs: test_full_suite="true" fi + if [[ "${{ github.event.inputs.lint-only }}" == "true" ]] \ + || [[ "${{ github.event.inputs.pylint-only }}" == "true" ]] \ + || [[ "${{ github.event.inputs.mypy-only }}" == "true" ]] \ + || [[ "${{ github.event.inputs.audit-licenses-only }}" == "true" ]] \ + || [[ "${{ github.event_name }}" == "push" \ + && "${{ github.event.repository.full_name }}" != "home-assistant/core" ]]; + then + lint_only="true" + skip_coverage="true" + fi + if [[ "${{ github.event.inputs.skip-coverage }}" == "true" ]] \ || [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-skip-coverage') }}" == "true" ]]; then @@ -217,6 +230,8 @@ jobs: echo "tests=${tests}" >> $GITHUB_OUTPUT echo "tests_glob: ${tests_glob}" echo "tests_glob=${tests_glob}" >> $GITHUB_OUTPUT + echo "lint_only": ${lint_only} + echo "lint_only=${lint_only}" >> $GITHUB_OUTPUT echo "skip_coverage: ${skip_coverage}" echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT @@ -234,17 +249,17 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.3 with: path: venv key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' @@ -256,12 +271,12 @@ 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.2.1 + uses: actions/cache@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - name: Install pre-commit dependencies if: steps.cache-precommit.outputs.cache-hit != 'true' @@ -279,28 +294,28 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - name: Run ruff-format run: | @@ -319,28 +334,28 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - name: Run ruff run: | @@ -359,28 +374,28 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - name: Register yamllint problem matcher @@ -469,7 +484,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -482,22 +497,22 @@ 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.2.1 + uses: actions/cache@v4.2.3 with: path: venv key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ 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.2.1 + uses: actions/cache@v4.2.3 with: path: ${{ env.UV_CACHE_DIR }} key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ steps.generate-uv-key.outputs.key }} restore-keys: | - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-uv-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{ env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{ env.HA_SHORT_VERSION }}- - name: Install additional OS dependencies @@ -537,7 +552,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -572,18 +587,18 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Run hassfest run: | @@ -605,24 +620,43 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Run gen_requirements_all.py run: | . venv/bin/activate python -m script.gen_requirements_all validate + dependency-review: + name: Dependency review + runs-on: ubuntu-24.04 + needs: + - info + - base + if: | + github.event.inputs.pylint-only != 'true' + && github.event.inputs.mypy-only != 'true' + && needs.info.outputs.requirements == 'true' + && github.event_name == 'pull_request' + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.2.2 + - name: Dependency review + uses: actions/dependency-review-action@v4.7.1 + with: + license-check: false # We use our own license audit checks + audit-licenses: name: Audit licenses runs-on: ubuntu-24.04 @@ -643,25 +677,25 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Extract license data run: | . venv/bin/activate python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json - name: Upload licenses - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -686,18 +720,18 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register pylint problem matcher run: | @@ -733,18 +767,18 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register pylint problem matcher run: | @@ -778,7 +812,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -791,22 +825,22 @@ 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.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.3 with: path: .mypy_cache key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ steps.generate-mypy-key.outputs.key }} restore-keys: | - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-mypy-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-mypy-${{ env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}-${{ env.HA_SHORT_VERSION }}- - name: Register mypy problem matcher @@ -829,11 +863,7 @@ jobs: prepare-pytest-full: runs-on: ubuntu-24.04 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.test_full_suite == 'true' needs: - info @@ -859,25 +889,25 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Run split_tests.py run: | . venv/bin/activate python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: pytest_buckets path: pytest_buckets.txt @@ -886,11 +916,7 @@ jobs: pytest-full: runs-on: ubuntu-24.04 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.test_full_suite == 'true' needs: - info @@ -918,22 +944,24 @@ jobs: bluez \ ffmpeg \ libturbojpeg \ - libgammu-dev + libgammu-dev \ + libxml2-utils - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register Python problem matcher run: | @@ -942,7 +970,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: name: pytest_buckets - name: Compile English translations @@ -962,6 +990,7 @@ jobs: if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then cov_params+=(--cov="homeassistant") cov_params+=(--cov-report=xml) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi echo "Test group ${{ matrix.group }}: $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt)" @@ -980,18 +1009,30 @@ 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.6.1 + uses: actions/upload-artifact@v4.6.2 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.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true + - name: Beautify test results + # For easier identification of parsing errors + if: needs.info.outputs.skip_coverage != 'true' + run: | + xmllint --format "junit.xml" > "junit.xml-tmp" + mv "junit.xml-tmp" "junit.xml" + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() + uses: actions/upload-artifact@v4.6.2 + with: + name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} + path: junit.xml - name: Remove pytest_buckets run: rm pytest_buckets.txt - name: Check dirty @@ -1009,11 +1050,7 @@ jobs: MYSQL_ROOT_PASSWORD: password options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.mariadb_groups != '[]' needs: - info @@ -1040,22 +1077,24 @@ jobs: bluez \ ffmpeg \ libturbojpeg \ - libmariadb-dev-compat + libmariadb-dev-compat \ + libxml2-utils - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register Python problem matcher run: | @@ -1088,6 +1127,7 @@ jobs: cov_params+=(--cov="homeassistant.components.recorder") cov_params+=(--cov-report=xml) cov_params+=(--cov-report=term-missing) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi python3 -b -X dev -m pytest \ @@ -1108,7 +1148,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.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1116,12 +1156,25 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} path: coverage.xml overwrite: true + - name: Beautify test results + # For easier identification of parsing errors + if: needs.info.outputs.skip_coverage != 'true' + run: | + xmllint --format "junit.xml" > "junit.xml-tmp" + mv "junit.xml-tmp" "junit.xml" + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() + uses: actions/upload-artifact@v4.6.2 + with: + name: test-results-mariadb-${{ matrix.python-version }}-${{ + steps.pytest-partial.outputs.mariadb }} + path: junit.xml - name: Check dirty run: | ./script/check_dirty @@ -1137,11 +1190,7 @@ jobs: POSTGRES_PASSWORD: password options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.postgresql_groups != '[]' needs: - info @@ -1167,7 +1216,8 @@ jobs: sudo apt-get -y install \ bluez \ ffmpeg \ - libturbojpeg + libturbojpeg \ + libxml2-utils sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y sudo apt-get -y install \ postgresql-server-dev-14 @@ -1175,17 +1225,18 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register Python problem matcher run: | @@ -1218,6 +1269,7 @@ jobs: cov_params+=(--cov="homeassistant.components.recorder") cov_params+=(--cov-report=xml) cov_params+=(--cov-report=term-missing) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi python3 -b -X dev -m pytest \ @@ -1239,7 +1291,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.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1247,12 +1299,25 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} path: coverage.xml overwrite: true + - name: Beautify test results + # For easier identification of parsing errors + if: needs.info.outputs.skip_coverage != 'true' + run: | + xmllint --format "junit.xml" > "junit.xml-tmp" + mv "junit.xml-tmp" "junit.xml" + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() + uses: actions/upload-artifact@v4.6.2 + with: + name: test-results-postgres-${{ matrix.python-version }}-${{ + steps.pytest-partial.outputs.postgresql }} + path: junit.xml - name: Check dirty run: | ./script/check_dirty @@ -1271,12 +1336,12 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.4.0 + uses: codecov/codecov-action@v5.4.3 with: fail_ci_if_error: true flags: full-suite @@ -1285,11 +1350,7 @@ jobs: pytest-partial: runs-on: ubuntu-24.04 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.tests_glob && needs.info.outputs.test_full_suite == 'false' needs: @@ -1317,22 +1378,24 @@ jobs: bluez \ ffmpeg \ libturbojpeg \ - libgammu-dev + libgammu-dev \ + libxml2-utils - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Register Python problem matcher run: | @@ -1365,6 +1428,7 @@ jobs: cov_params+=(--cov="homeassistant.components.${{ matrix.group }}") cov_params+=(--cov-report=xml) cov_params+=(--cov-report=term-missing) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi python3 -b -X dev -m pytest \ @@ -1382,18 +1446,30 @@ 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.6.1 + uses: actions/upload-artifact@v4.6.2 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.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true + - name: Beautify test results + # For easier identification of parsing errors + if: needs.info.outputs.skip_coverage != 'true' + run: | + xmllint --format "junit.xml" > "junit.xml-tmp" + mv "junit.xml-tmp" "junit.xml" + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() + uses: actions/upload-artifact@v4.6.2 + with: + name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} + path: junit.xml - name: Check dirty run: | ./script/check_dirty @@ -1410,12 +1486,37 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.4.0 + uses: codecov/codecov-action@v5.4.3 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} + + upload-test-results: + name: Upload test results to Codecov + # codecov/test-results-action currently doesn't support tokenless uploads + # therefore we can't run it on forks + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.info.outputs.skip_coverage != 'true' && !cancelled() }} + runs-on: ubuntu-24.04 + needs: + - info + - pytest-partial + - pytest-full + - pytest-postgres + - pytest-mariadb + timeout-minutes: 10 + steps: + - name: Download all coverage artifacts + uses: actions/download-artifact@v4.3.0 + with: + pattern: test-results-* + - name: Upload test results to Codecov + uses: codecov/test-results-action@v1 + with: + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4bdddf50c25..818aa813208 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.28.10 + uses: github/codeql-action/init@v3.28.18 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.10 + uses: github/codeql-action/analyze@v3.28.18 with: category: "/language:python" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 619d83aef51..8a668d548d3 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 7c02c8d97cd..ea02b249dc9 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -91,7 +91,7 @@ jobs: ) > build_constraints.txt - name: Upload env_file - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: env_file path: ./.env_file @@ -99,14 +99,14 @@ jobs: overwrite: true - name: Upload build_constraints - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: build_constraints path: ./build_constraints.txt overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: requirements_diff path: ./requirements_diff.txt @@ -118,7 +118,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt @@ -138,17 +138,17 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: name: requirements_diff @@ -159,7 +159,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2024.11.0 + uses: home-assistant/wheels@2025.03.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -187,22 +187,22 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.3.0 with: name: requirements_all_wheels @@ -218,16 +218,8 @@ jobs: sed -i "/uv/d" requirements.txt sed -i "/uv/d" requirements_diff.txt - - name: Split requirements all - run: | - # We split requirements all into multiple files. - # This is to prevent the build from running out of memory when - # resolving packages on 32-bits systems (like armhf, armv7). - - split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt - - - name: Build wheels (part 1) - uses: home-assistant/wheels@2024.11.0 + - name: Build wheels + uses: home-assistant/wheels@2025.03.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -238,32 +230,4 @@ jobs: skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtaa" - - - name: Build wheels (part 2) - uses: home-assistant/wheels@2024.11.0 - 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;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" - requirements: "requirements_all.txtab" - - - name: Build wheels (part 3) - uses: home-assistant/wheels@2024.11.0 - 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;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" - requirements: "requirements_all.txtac" + requirements: "requirements_all.txt" diff --git a/.gitignore b/.gitignore index 241255253c5..5aa51c9d762 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ test-reports/ test-results.xml test-output.xml pytest-*.txt +junit.xml # Translations *.mo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b701b21b9e..42e05a869c3 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.9.7 + rev: v0.11.0 hooks: - id: ruff args: diff --git a/.strict-typing b/.strict-typing index 1df49300b1e..1ae56cd74d8 100644 --- a/.strict-typing +++ b/.strict-typing @@ -119,6 +119,7 @@ homeassistant.components.bluetooth_adapters.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* homeassistant.components.bond.* +homeassistant.components.bosch_alarm.* homeassistant.components.braviatv.* homeassistant.components.bring.* homeassistant.components.brother.* @@ -136,6 +137,7 @@ homeassistant.components.clicksend.* homeassistant.components.climate.* homeassistant.components.cloud.* homeassistant.components.co2signal.* +homeassistant.components.comelit.* homeassistant.components.command_line.* homeassistant.components.config.* homeassistant.components.configurator.* @@ -268,6 +270,7 @@ homeassistant.components.image_processing.* homeassistant.components.image_upload.* homeassistant.components.imap.* homeassistant.components.imgw_pib.* +homeassistant.components.immich.* homeassistant.components.incomfort.* homeassistant.components.input_button.* homeassistant.components.input_select.* @@ -289,6 +292,7 @@ homeassistant.components.kaleidescape.* homeassistant.components.knocki.* homeassistant.components.knx.* homeassistant.components.kraken.* +homeassistant.components.kulersky.* homeassistant.components.lacrosse.* homeassistant.components.lacrosse_view.* homeassistant.components.lamarzocco.* @@ -329,6 +333,7 @@ homeassistant.components.media_player.* homeassistant.components.media_source.* homeassistant.components.met_eireann.* homeassistant.components.metoffice.* +homeassistant.components.miele.* homeassistant.components.mikrotik.* homeassistant.components.min_max.* homeassistant.components.minecraft_server.* @@ -360,8 +365,10 @@ homeassistant.components.no_ip.* homeassistant.components.nordpool.* homeassistant.components.notify.* homeassistant.components.notion.* +homeassistant.components.ntfy.* homeassistant.components.number.* homeassistant.components.nut.* +homeassistant.components.ohme.* homeassistant.components.onboarding.* homeassistant.components.oncue.* homeassistant.components.onedrive.* @@ -381,6 +388,7 @@ homeassistant.components.pandora.* homeassistant.components.panel_custom.* homeassistant.components.peblar.* homeassistant.components.peco.* +homeassistant.components.pegel_online.* homeassistant.components.persistent_notification.* homeassistant.components.person.* homeassistant.components.pi_hole.* @@ -396,6 +404,7 @@ homeassistant.components.pure_energie.* homeassistant.components.purpleair.* homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* +homeassistant.components.pyload.* homeassistant.components.python_script.* homeassistant.components.qbus.* homeassistant.components.qnap_qsw.* @@ -410,6 +419,7 @@ homeassistant.components.recollect_waste.* homeassistant.components.recorder.* homeassistant.components.remember_the_milk.* homeassistant.components.remote.* +homeassistant.components.remote_calendar.* homeassistant.components.renault.* homeassistant.components.reolink.* homeassistant.components.repairs.* @@ -425,7 +435,6 @@ homeassistant.components.roku.* 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.* @@ -455,6 +464,7 @@ homeassistant.components.slack.* homeassistant.components.sleepiq.* homeassistant.components.smhi.* homeassistant.components.smlight.* +homeassistant.components.smtp.* homeassistant.components.snooz.* homeassistant.components.solarlog.* homeassistant.components.sonarr.* @@ -528,6 +538,7 @@ homeassistant.components.vallox.* homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* +homeassistant.components.vodafone_station.* homeassistant.components.wake_on_lan.* homeassistant.components.wake_word.* homeassistant.components.wallbox.* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b699ed44b96..09c1d374299 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Run Home Assistant Core", "type": "shell", - "command": "hass -c ./config", + "command": "${command:python.interpreterPath} -m homeassistant -c ./config", "group": "test", "presentation": { "reveal": "always", diff --git a/CODEOWNERS b/CODEOWNERS index 3366bfb0885..bbbfb9394e2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -46,8 +46,8 @@ build.json @home-assistant/supervisor /tests/components/accuweather/ @bieniu /homeassistant/components/acmeda/ @atmurray /tests/components/acmeda/ @atmurray -/homeassistant/components/adax/ @danielhiversen -/tests/components/adax/ @danielhiversen +/homeassistant/components/adax/ @danielhiversen @lazytarget +/tests/components/adax/ @danielhiversen @lazytarget /homeassistant/components/adguard/ @frenck /tests/components/adguard/ @frenck /homeassistant/components/ads/ @mrpasztoradam @@ -171,6 +171,8 @@ build.json @home-assistant/supervisor /homeassistant/components/avea/ @pattyland /homeassistant/components/awair/ @ahayworth @danielsjf /tests/components/awair/ @ahayworth @danielsjf +/homeassistant/components/aws_s3/ @tomasbedrich +/tests/components/aws_s3/ @tomasbedrich /homeassistant/components/axis/ @Kane610 /tests/components/axis/ @Kane610 /homeassistant/components/azure_data_explorer/ @kaareseras @@ -216,6 +218,8 @@ build.json @home-assistant/supervisor /tests/components/bmw_connected_drive/ @gerard33 @rikroe /homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto /tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto +/homeassistant/components/bosch_alarm/ @mag1024 @sanjay900 +/tests/components/bosch_alarm/ @mag1024 @sanjay900 /homeassistant/components/bosch_shc/ @tschamm /tests/components/bosch_shc/ @tschamm /homeassistant/components/braviatv/ @bieniu @Drafteed @@ -430,7 +434,7 @@ build.json @home-assistant/supervisor /homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie -/homeassistant/components/ephember/ @ttroy50 +/homeassistant/components/ephember/ @ttroy50 @roberty99 /homeassistant/components/epic_games_store/ @hacf-fr @Quentame /tests/components/epic_games_store/ @hacf-fr @Quentame /homeassistant/components/epion/ @lhgravendeel @@ -451,8 +455,8 @@ build.json @home-assistant/supervisor /tests/components/evil_genius_labs/ @balloob /homeassistant/components/evohome/ @zxdavb /tests/components/evohome/ @zxdavb -/homeassistant/components/ezviz/ @RenierM26 @baqs -/tests/components/ezviz/ @RenierM26 @baqs +/homeassistant/components/ezviz/ @RenierM26 +/tests/components/ezviz/ @RenierM26 /homeassistant/components/faa_delays/ @ntilley905 /tests/components/faa_delays/ @ntilley905 /homeassistant/components/fan/ @home-assistant/core @@ -570,8 +574,8 @@ build.json @home-assistant/supervisor /tests/components/google_cloud/ @lufton @tronikos /homeassistant/components/google_drive/ @tronikos /tests/components/google_drive/ @tronikos -/homeassistant/components/google_generative_ai_conversation/ @tronikos -/tests/components/google_generative_ai_conversation/ @tronikos +/homeassistant/components/google_generative_ai_conversation/ @tronikos @ivanlh +/tests/components/google_generative_ai_conversation/ @tronikos @ivanlh /homeassistant/components/google_mail/ @tkdrob /tests/components/google_mail/ @tkdrob /homeassistant/components/google_photos/ @allenporter @@ -702,8 +706,12 @@ build.json @home-assistant/supervisor /tests/components/image_upload/ @home-assistant/core /homeassistant/components/imap/ @jbouwh /tests/components/imap/ @jbouwh +/homeassistant/components/imeon_inverter/ @Imeon-Energy +/tests/components/imeon_inverter/ @Imeon-Energy /homeassistant/components/imgw_pib/ @bieniu /tests/components/imgw_pib/ @bieniu +/homeassistant/components/immich/ @mib1185 +/tests/components/immich/ @mib1185 /homeassistant/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @jbouwh @@ -933,6 +941,8 @@ build.json @home-assistant/supervisor /tests/components/metoffice/ @MrHarcombe @avee87 /homeassistant/components/microbees/ @microBeesTech /tests/components/microbees/ @microBeesTech +/homeassistant/components/miele/ @astrandb +/tests/components/miele/ @astrandb /homeassistant/components/mikrotik/ @engrbm87 /tests/components/mikrotik/ @engrbm87 /homeassistant/components/mill/ @danielhiversen @@ -1045,6 +1055,8 @@ build.json @home-assistant/supervisor /tests/components/nsw_fuel_station/ @nickw444 /homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte /tests/components/nsw_rural_fire_service_feed/ @exxamalte +/homeassistant/components/ntfy/ @tr4nt0r +/tests/components/ntfy/ @tr4nt0r /homeassistant/components/nuheat/ @tstabrawa /tests/components/nuheat/ @tstabrawa /homeassistant/components/nuki/ @pschmitt @pvizeli @pree @@ -1073,8 +1085,6 @@ build.json @home-assistant/supervisor /homeassistant/components/ombi/ @larssont /homeassistant/components/onboarding/ @home-assistant/core /tests/components/onboarding/ @home-assistant/core -/homeassistant/components/oncue/ @bdraco @peterager -/tests/components/oncue/ @bdraco @peterager /homeassistant/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP /homeassistant/components/onedrive/ @zweckj @@ -1103,8 +1113,8 @@ build.json @home-assistant/supervisor /tests/components/opentherm_gw/ @mvn23 /homeassistant/components/openuv/ @bachya /tests/components/openuv/ @bachya -/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi -/tests/components/openweathermap/ @fabaff @freekode @nzapponi +/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck +/tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck /homeassistant/components/opnsense/ @mtreinish /tests/components/opnsense/ @mtreinish /homeassistant/components/opower/ @tronikos @@ -1183,6 +1193,8 @@ build.json @home-assistant/supervisor /tests/components/prusalink/ @balloob /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 +/homeassistant/components/pterodactyl/ @elmurato +/tests/components/pterodactyl/ @elmurato /homeassistant/components/pure_energie/ @klaasnicolaas /tests/components/pure_energie/ @klaasnicolaas /homeassistant/components/purpleair/ @bachya @@ -1250,8 +1262,12 @@ build.json @home-assistant/supervisor /tests/components/recovery_mode/ @home-assistant/core /homeassistant/components/refoss/ @ashionky /tests/components/refoss/ @ashionky +/homeassistant/components/rehlko/ @bdraco @peterager +/tests/components/rehlko/ @bdraco @peterager /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core +/homeassistant/components/remote_calendar/ @Thomas55555 +/tests/components/remote_calendar/ @Thomas55555 /homeassistant/components/renault/ @epenet /tests/components/renault/ @epenet /homeassistant/components/renson/ @jimmyd-be @@ -1293,8 +1309,6 @@ build.json @home-assistant/supervisor /tests/components/rpi_power/ @shenxn @swetoast /homeassistant/components/rss_feed_template/ @home-assistant/core /tests/components/rss_feed_template/ @home-assistant/core -/homeassistant/components/rtsp_to_webrtc/ @allenporter -/tests/components/rtsp_to_webrtc/ @allenporter /homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /homeassistant/components/russound_rio/ @noahhusby @@ -1381,7 +1395,6 @@ build.json @home-assistant/supervisor /homeassistant/components/siren/ @home-assistant/core @raman325 /tests/components/siren/ @home-assistant/core @raman325 /homeassistant/components/sisyphus/ @jkeljo -/homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/sky_remote/ @dunnmj @saty9 /tests/components/sky_remote/ @dunnmj @saty9 /homeassistant/components/skybell/ @tkdrob @@ -1428,8 +1441,8 @@ build.json @home-assistant/supervisor /tests/components/solarlog/ @Ernst79 @dontinelli /homeassistant/components/solax/ @squishykid @Darsstar /tests/components/solax/ @squishykid @Darsstar -/homeassistant/components/soma/ @ratsept @sebfortier2288 -/tests/components/soma/ @ratsept @sebfortier2288 +/homeassistant/components/soma/ @ratsept +/tests/components/soma/ @ratsept /homeassistant/components/sonarr/ @ctalkington /tests/components/sonarr/ @ctalkington /homeassistant/components/songpal/ @rytilahti @shenxn @@ -1461,7 +1474,8 @@ build.json @home-assistant/supervisor /tests/components/steam_online/ @tkdrob /homeassistant/components/steamist/ @bdraco /tests/components/steamist/ @bdraco -/homeassistant/components/stiebel_eltron/ @fucm +/homeassistant/components/stiebel_eltron/ @fucm @ThyMYthOS +/tests/components/stiebel_eltron/ @fucm @ThyMYthOS /homeassistant/components/stookwijzer/ @fwestenberg /tests/components/stookwijzer/ @fwestenberg /homeassistant/components/stream/ @hunterjm @uvjustin @allenporter @@ -1472,10 +1486,8 @@ build.json @home-assistant/supervisor /tests/components/subaru/ @G-Two /homeassistant/components/suez_water/ @ooii @jb101010-2 /tests/components/suez_water/ @ooii @jb101010-2 -/homeassistant/components/sun/ @Swamp-Ig -/tests/components/sun/ @Swamp-Ig -/homeassistant/components/sunweg/ @rokam -/tests/components/sunweg/ @rokam +/homeassistant/components/sun/ @home-assistant/core +/tests/components/sun/ @home-assistant/core /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen @@ -1488,8 +1500,8 @@ build.json @home-assistant/supervisor /tests/components/switch_as_x/ @home-assistant/core /homeassistant/components/switchbee/ @jafar-atili /tests/components/switchbee/ @jafar-atili -/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski -/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski +/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang +/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang /homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur /tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur /homeassistant/components/switcher_kis/ @thecode @YogevBokobza @@ -1529,8 +1541,8 @@ build.json @home-assistant/supervisor /tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike -/homeassistant/components/template/ @PhracturedBlue @home-assistant/core -/tests/components/template/ @PhracturedBlue @home-assistant/core +/homeassistant/components/template/ @Petro31 @PhracturedBlue @home-assistant/core +/tests/components/template/ @Petro31 @PhracturedBlue @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_wall_connector/ @einarhauks @@ -1666,8 +1678,8 @@ build.json @home-assistant/supervisor /tests/components/vlc_telnet/ @rodripf @MartinHjelmare /homeassistant/components/vodafone_station/ @paoloantinori @chemelli74 /tests/components/vodafone_station/ @paoloantinori @chemelli74 -/homeassistant/components/voip/ @balloob @synesthesiam -/tests/components/voip/ @balloob @synesthesiam +/homeassistant/components/voip/ @balloob @synesthesiam @jaminh +/tests/components/voip/ @balloob @synesthesiam @jaminh /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund /homeassistant/components/volvooncall/ @molobrakos @@ -1784,6 +1796,8 @@ build.json @home-assistant/supervisor /tests/components/zeversolar/ @kvanzuijlen /homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES +/homeassistant/components/zimi/ @markhannon +/tests/components/zimi/ @markhannon /homeassistant/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core diff --git a/Dockerfile b/Dockerfile index 3ab0bb37b9a..549837ddef0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,13 +25,13 @@ RUN \ "armv7") go2rtc_suffix='arm' ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \ esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && chmod +x /bin/go2rtc \ # Verify go2rtc can be executed && go2rtc --version # Install uv -RUN pip3 install uv==0.6.1 +RUN pip3 install uv==0.7.1 WORKDIR /usr/src diff --git a/build.yaml b/build.yaml index cd54e410493..00df4196523 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:2025.02.1 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.02.1 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.02.1 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.02.1 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.02.1 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.05.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io @@ -19,4 +19,4 @@ labels: org.opencontainers.image.authors: The Home Assistant Authors org.opencontainers.image.url: https://www.home-assistant.io/ org.opencontainers.image.documentation: https://www.home-assistant.io/docs/ - org.opencontainers.image.licenses: Apache License 2.0 + org.opencontainers.image.licenses: Apache-2.0 diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index d224b0b151d..eb81268434b 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -178,6 +178,15 @@ _BLOCKING_CALLS: tuple[BlockingCall, ...] = ( strict_core=False, skip_for_tests=True, ), + BlockingCall( + original_func=SSLContext.set_default_verify_paths, + object=SSLContext, + function="set_default_verify_paths", + check_allowed=None, + strict=False, + strict_core=False, + skip_for_tests=True, + ), BlockingCall( original_func=Path.open, object=Path, diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index e25bfbe358c..f88912478a7 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -53,6 +53,7 @@ from .components import ( logbook as logbook_pre_import, # noqa: F401 lovelace as lovelace_pre_import, # noqa: F401 onboarding as onboarding_pre_import, # noqa: F401 + person as person_pre_import, # noqa: F401 recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements repairs as repairs_pre_import, # noqa: F401 search as search_pre_import, # noqa: F401 @@ -81,6 +82,7 @@ from .helpers import ( entity, entity_registry, floor_registry, + frame, issue_registry, label_registry, recorder, @@ -92,6 +94,7 @@ 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 from .helpers.typing import ConfigType +from .loader import Integration from .setup import ( # _setup_started is marked as protected to make it clear # that it is not part of the public API and should not be used @@ -298,14 +301,6 @@ async def async_setup_hass( return hass - async def stop_hass(hass: core.HomeAssistant) -> None: - """Stop hass.""" - # Ask integrations to shut down. It's messy but we can't - # do a clean stop without knowing what is broken - with contextlib.suppress(TimeoutError): - async with hass.timeout.async_timeout(10): - await hass.async_stop() - hass = await create_hass() if runtime_config.skip_pip or runtime_config.skip_pip_packages: @@ -344,7 +339,7 @@ async def async_setup_hass( if config_dict is None: recovery_mode = True - await stop_hass(hass) + await hass.async_stop(force=True) hass = await create_hass() elif not basic_setup_success: @@ -352,7 +347,7 @@ async def async_setup_hass( "Unable to set up core integrations. Activating recovery mode" ) recovery_mode = True - await stop_hass(hass) + await hass.async_stop(force=True) hass = await create_hass() elif any( @@ -367,7 +362,7 @@ async def async_setup_hass( old_logging = hass.data.get(DATA_LOGGING) recovery_mode = True - await stop_hass(hass) + await hass.async_stop(force=True) hass = await create_hass() if old_logging: @@ -441,9 +436,10 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None: if DATA_REGISTRIES_LOADED in hass.data: return hass.data[DATA_REGISTRIES_LOADED] = None - translation.async_setup(hass) entity.async_setup(hass) + frame.async_setup(hass) template.async_setup(hass) + translation.async_setup(hass) await asyncio.gather( create_eager_task(get_internal_store_manager(hass).async_initialize()), create_eager_task(area_registry.async_load(hass)), @@ -664,11 +660,10 @@ def _create_log_file( err_handler = _RotatingFileHandlerWithoutShouldRollOver( err_log_path, backupCount=1 ) - - try: - err_handler.doRollover() - except OSError as err: - _LOGGER.error("Error rolling over log file: %s", err) + try: + err_handler.doRollover() + except OSError as err: + _LOGGER.error("Error rolling over log file: %s", err) return err_handler @@ -718,20 +713,25 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: return domains -async def _async_resolve_domains_to_setup( +async def _async_resolve_domains_and_preload( hass: core.HomeAssistant, config: dict[str, Any] -) -> tuple[set[str], dict[str, loader.Integration]]: - """Resolve all dependencies and return list of domains to set up.""" +) -> tuple[dict[str, Integration], dict[str, Integration]]: + """Resolve all dependencies and return integrations to set up. + + The return value is a tuple of two dictionaries: + - The first dictionary contains integrations + specified by the configuration (including config entries). + - The second dictionary contains the same integrations as the first dictionary + together with all their dependencies. + """ domains_to_setup = _get_domains(hass, config) - needed_requirements: set[str] = set() platform_integrations = conf_util.extract_platform_integrations( config, BASE_PLATFORMS ) - # Ensure base platforms that have platform integrations are added to - # to `domains_to_setup so they can be setup first instead of - # discovering them when later when a config entry setup task - # notices its needed and there is already a long line to use - # the import executor. + # Ensure base platforms that have platform integrations are added to `domains`, + # so they can be setup first instead of discovering them later when a config + # entry setup task notices that it's needed and there is already a long line + # to use the import executor. # # For example if we have # sensor: @@ -747,111 +747,78 @@ async def _async_resolve_domains_to_setup( # so this will be less of a problem in the future. domains_to_setup.update(platform_integrations) - # Load manifests for base platforms and platform based integrations - # that are defined under base platforms right away since we do not require - # the manifest to list them as dependencies and we want to avoid the lock - # contention when multiple integrations try to load them at once - additional_manifests_to_load = { + # Additionally process base platforms since we do not require the manifest + # to list them as dependencies. + # We want to later avoid lock contention when multiple integrations try to load + # their manifests at once. + # Also process integrations that are defined under base platforms + # to speed things up. + additional_domains_to_process = { *BASE_PLATFORMS, *chain.from_iterable(platform_integrations.values()), } - translations_to_load = additional_manifests_to_load.copy() - # Resolve all dependencies so we know all integrations # that will have to be loaded and start right-away - integration_cache: dict[str, loader.Integration] = {} - to_resolve: set[str] = domains_to_setup - while to_resolve or additional_manifests_to_load: - old_to_resolve: set[str] = to_resolve - to_resolve = set() + integrations_or_excs = await loader.async_get_integrations( + hass, {*domains_to_setup, *additional_domains_to_process} + ) + # Eliminate those missing or with invalid manifest + integrations_to_process = { + domain: itg + for domain, itg in integrations_or_excs.items() + if isinstance(itg, Integration) + } + integrations_dependencies = await loader.resolve_integrations_dependencies( + hass, integrations_to_process.values() + ) + # Eliminate those without valid dependencies + integrations_to_process = { + domain: integrations_to_process[domain] for domain in integrations_dependencies + } - if additional_manifests_to_load: - to_get = {*old_to_resolve, *additional_manifests_to_load} - additional_manifests_to_load.clear() - else: - to_get = old_to_resolve + integrations_to_setup = { + domain: itg + for domain, itg in integrations_to_process.items() + if domain in domains_to_setup + } + all_integrations_to_setup = integrations_to_setup.copy() + all_integrations_to_setup.update( + (dep, loader.async_get_loaded_integration(hass, dep)) + for domain in integrations_to_setup + for dep in integrations_dependencies[domain].difference( + all_integrations_to_setup + ) + ) - manifest_deps: set[str] = set() - resolve_dependencies_tasks: list[asyncio.Task[bool]] = [] - integrations_to_process: list[loader.Integration] = [] - - for domain, itg in (await loader.async_get_integrations(hass, to_get)).items(): - if not isinstance(itg, loader.Integration): - continue - integration_cache[domain] = itg - needed_requirements.update(itg.requirements) - - # Make sure manifests for dependencies are loaded in the next - # loop to try to group as many as manifest loads in a single - # call to avoid the creating one-off executor jobs later in - # the setup process - additional_manifests_to_load.update( - dep - for dep in chain(itg.dependencies, itg.after_dependencies) - if dep not in integration_cache - ) - - if domain not in old_to_resolve: - continue - - integrations_to_process.append(itg) - manifest_deps.update(itg.dependencies) - manifest_deps.update(itg.after_dependencies) - if not itg.all_dependencies_resolved: - resolve_dependencies_tasks.append( - create_eager_task( - itg.resolve_dependencies(), - name=f"resolve dependencies {domain}", - loop=hass.loop, - ) - ) - - if unseen_deps := manifest_deps - integration_cache.keys(): - # If there are dependencies, try to preload all - # the integrations manifest at once and add them - # to the list of requirements we need to install - # so we can try to check if they are already installed - # in a single call below which avoids each integration - # having to wait for the lock to do it individually - deps = await loader.async_get_integrations(hass, unseen_deps) - for dependant_domain, dependant_itg in deps.items(): - if isinstance(dependant_itg, loader.Integration): - integration_cache[dependant_domain] = dependant_itg - needed_requirements.update(dependant_itg.requirements) - - if resolve_dependencies_tasks: - await asyncio.gather(*resolve_dependencies_tasks) - - for itg in integrations_to_process: - try: - all_deps = itg.all_dependencies - except RuntimeError: - # Integration.all_dependencies raises RuntimeError if - # dependencies could not be resolved - continue - for dep in all_deps: - if dep in domains_to_setup: - continue - domains_to_setup.add(dep) - to_resolve.add(dep) - - _LOGGER.info("Domains to be set up: %s", domains_to_setup) + # Gather requirements for all integrations, + # their dependencies and after dependencies. + # To gather all the requirements we must ignore exceptions here. + # The exceptions will be detected and handled later in the bootstrap process. + integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, integrations_to_process.values(), ignore_exceptions=True + ) + ) + integrations_requirements = { + domain: itg.requirements for domain, itg in integrations_to_process.items() + } + integrations_requirements.update( + (dep, loader.async_get_loaded_integration(hass, dep).requirements) + for deps in integrations_after_dependencies.values() + for dep in deps.difference(integrations_requirements) + ) + all_requirements = set(chain.from_iterable(integrations_requirements.values())) # Optimistically check if requirements are already installed # ahead of setting up the integrations so we can prime the cache - # We do not wait for this since its an optimization only + # We do not wait for this since it's an optimization only hass.async_create_background_task( - requirements.async_load_installed_versions(hass, needed_requirements), + requirements.async_load_installed_versions(hass, all_requirements), "check installed requirements", eager_start=True, ) - # - # Only add the domains_to_setup after we finish resolving - # as new domains are likely to added in the process - # - translations_to_load.update(domains_to_setup) # Start loading translations for all integrations we are going to set up # in the background so they are ready when we need them. This avoids a # lot of waiting for the translation load lock and a thundering herd of @@ -862,6 +829,7 @@ async def _async_resolve_domains_to_setup( # hold the translation load lock and if anything is fast enough to # wait for the translation load lock, loading will be done by the # time it gets to it. + translations_to_load = {*all_integrations_to_setup, *additional_domains_to_process} hass.async_create_background_task( translation.async_load_integrations(hass, translations_to_load), "load translations", @@ -873,13 +841,13 @@ async def _async_resolve_domains_to_setup( # in the setup process. hass.async_create_background_task( get_internal_store_manager(hass).async_preload( - [*PRELOAD_STORAGE, *domains_to_setup] + [*PRELOAD_STORAGE, *all_integrations_to_setup] ), "preload storage", eager_start=True, ) - return domains_to_setup, integration_cache + return integrations_to_setup, all_integrations_to_setup async def _async_set_up_integrations( @@ -889,69 +857,84 @@ async def _async_set_up_integrations( watcher = _WatchPendingSetups(hass, _setup_started(hass)) watcher.async_start() - domains_to_setup, integration_cache = await _async_resolve_domains_to_setup( + integrations, all_integrations = await _async_resolve_domains_and_preload( hass, config ) - stage_2_domains = domains_to_setup.copy() + # Detect all cycles + integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, all_integrations.values(), set(all_integrations) + ) + ) + all_domains = set(integrations_after_dependencies) + domains = set(integrations) & all_domains + + _LOGGER.info( + "Domains to be set up: %s | %s", + domains, + all_domains - domains, + ) + + async_set_domains_to_be_loaded(hass, all_domains) # Initialize recorder - if "recorder" in domains_to_setup: + if "recorder" in all_domains: recorder.async_initialize_recorder(hass) # Initialize backup - if "backup" in domains_to_setup: + if "backup" in all_domains: backup.async_initialize_backup(hass) - stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [ + stages: list[tuple[str, set[str], int | None]] = [ *( - (name, domain_group & domains_to_setup, timeout) + (name, domain_group, timeout) for name, domain_group, timeout in STAGE_0_INTEGRATIONS ), - ("stage 1", STAGE_1_INTEGRATIONS & domains_to_setup, STAGE_1_TIMEOUT), + ("1", STAGE_1_INTEGRATIONS, STAGE_1_TIMEOUT), + ("2", domains, STAGE_2_TIMEOUT), ] - _LOGGER.info("Setting up stage 0 and 1") - for name, domain_group, timeout in stage_0_and_1_domains: - if not domain_group: + _LOGGER.info("Setting up stage 0") + for name, domain_group, timeout in stages: + stage_domains_unfiltered = domain_group & all_domains + if not stage_domains_unfiltered: + _LOGGER.info("Nothing to set up in stage %s: %s", name, domain_group) continue - _LOGGER.info("Setting up %s: %s", name, domain_group) - to_be_loaded = domain_group.copy() - to_be_loaded.update( + stage_domains = stage_domains_unfiltered - hass.config.components + if not stage_domains: + _LOGGER.info("Already set up stage %s: %s", name, stage_domains_unfiltered) + continue + + stage_dep_domains_unfiltered = { dep - for domain in domain_group - if (integration := integration_cache.get(domain)) is not None - for dep in integration.all_dependencies + for domain in stage_domains + for dep in integrations_after_dependencies[domain] + if dep not in stage_domains + } + stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components + + stage_all_domains = stage_domains | stage_dep_domains + + _LOGGER.info( + "Setting up stage %s: %s | %s\nDependencies: %s | %s", + name, + stage_domains, + stage_domains_unfiltered - stage_domains, + stage_dep_domains, + stage_dep_domains_unfiltered - stage_dep_domains, ) - async_set_domains_to_be_loaded(hass, to_be_loaded) - stage_2_domains -= to_be_loaded if timeout is None: - await _async_setup_multi_components(hass, domain_group, config) - else: - try: - async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME): - await _async_setup_multi_components(hass, domain_group, config) - except TimeoutError: - _LOGGER.warning( - "Setup timed out for %s waiting on %s - moving forward", - name, - hass._active_tasks, # noqa: SLF001 - ) - - # Add after dependencies when setting up stage 2 domains - async_set_domains_to_be_loaded(hass, stage_2_domains) - - if stage_2_domains: - _LOGGER.info("Setting up stage 2: %s", stage_2_domains) + await _async_setup_multi_components(hass, stage_all_domains, config) + continue try: - async with hass.timeout.async_timeout( - STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME - ): - await _async_setup_multi_components(hass, stage_2_domains, config) + async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME): + await _async_setup_multi_components(hass, stage_all_domains, config) except TimeoutError: _LOGGER.warning( - "Setup timed out for stage 2 waiting on %s - moving forward", + "Setup timed out for stage %s waiting on %s - moving forward", + name, hass._active_tasks, # noqa: SLF001 ) @@ -1053,8 +1036,6 @@ async def _async_setup_multi_components( config: dict[str, Any], ) -> None: """Set up multiple domains. Log on failure.""" - # Avoid creating tasks for domains that were setup in a previous stage - domains_not_yet_setup = domains - hass.config.components # Create setup tasks for base platforms first since everything will have # to wait to be imported, and the sooner we can get the base platforms # loaded the sooner we can start loading the rest of the integrations. @@ -1064,9 +1045,7 @@ async def _async_setup_multi_components( f"setup component {domain}", eager_start=True, ) - for domain in sorted( - domains_not_yet_setup, key=SETUP_ORDER_SORT_KEY, reverse=True - ) + for domain in sorted(domains, key=SETUP_ORDER_SORT_KEY, reverse=True) } results = await asyncio.gather(*futures.values(), return_exceptions=True) for idx, domain in enumerate(futures): diff --git a/homeassistant/brands/amazon.json b/homeassistant/brands/amazon.json index a7caea2b932..624a8a17b7d 100644 --- a/homeassistant/brands/amazon.json +++ b/homeassistant/brands/amazon.json @@ -1,5 +1,12 @@ { "domain": "amazon", "name": "Amazon", - "integrations": ["alexa", "amazon_polly", "aws", "fire_tv", "route53"] + "integrations": [ + "alexa", + "amazon_polly", + "aws", + "aws_s3", + "fire_tv", + "route53" + ] } diff --git a/homeassistant/brands/bosch.json b/homeassistant/brands/bosch.json new file mode 100644 index 00000000000..090cc2af7c3 --- /dev/null +++ b/homeassistant/brands/bosch.json @@ -0,0 +1,5 @@ +{ + "domain": "bosch", + "name": "Bosch", + "integrations": ["bosch_alarm", "bosch_shc", "home_connect"] +} diff --git a/homeassistant/brands/eve.json b/homeassistant/brands/eve.json new file mode 100644 index 00000000000..f27c8b3d849 --- /dev/null +++ b/homeassistant/brands/eve.json @@ -0,0 +1,5 @@ +{ + "domain": "eve", + "name": "Eve", + "iot_standards": ["matter"] +} diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 872cfc0aac5..2da0e2426f5 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -6,6 +6,7 @@ "google_assistant_sdk", "google_cloud", "google_drive", + "google_gemini", "google_generative_ai_conversation", "google_mail", "google_maps", diff --git a/homeassistant/brands/motionblinds.json b/homeassistant/brands/motionblinds.json index 67013e75966..5a48b573b4d 100644 --- a/homeassistant/brands/motionblinds.json +++ b/homeassistant/brands/motionblinds.json @@ -1,5 +1,6 @@ { "domain": "motionblinds", "name": "Motionblinds", - "integrations": ["motion_blinds", "motionblinds_ble"] + "integrations": ["motion_blinds", "motionblinds_ble"], + "iot_standards": ["matter"] } diff --git a/homeassistant/brands/nuki.json b/homeassistant/brands/nuki.json new file mode 100644 index 00000000000..f5fe075889b --- /dev/null +++ b/homeassistant/brands/nuki.json @@ -0,0 +1,6 @@ +{ + "domain": "nuki", + "name": "Nuki", + "integrations": ["nuki"], + "iot_standards": ["matter"] +} diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 1bbf5a36187..e1dc4a9abcb 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -24,7 +24,7 @@ from homeassistant.components.weather import ( API_METRIC: Final = "Metric" ATTRIBUTION: Final = "Data provided by AccuWeather" -ATTR_CATEGORY: Final = "Category" +ATTR_CATEGORY_VALUE = "CategoryValue" ATTR_DIRECTION: Final = "Direction" ATTR_ENGLISH: Final = "English" ATTR_LEVEL: Final = "level" @@ -55,5 +55,19 @@ CONDITION_MAP = { for cond_ha, cond_codes in CONDITION_CLASSES.items() for cond_code in cond_codes } +AIR_QUALITY_CATEGORY_MAP = { + 1: "good", + 2: "moderate", + 3: "unhealthy", + 4: "very_unhealthy", + 5: "hazardous", +} +POLLEN_CATEGORY_MAP = { + 1: "low", + 2: "moderate", + 3: "high", + 4: "very_high", + 5: "extreme", +} UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py index 40ff3ad2c87..780c977f930 100644 --- a/homeassistant/components/accuweather/coordinator.py +++ b/homeassistant/components/accuweather/coordinator.py @@ -75,7 +75,11 @@ class AccuWeatherObservationDataUpdateCoordinator( async with timeout(10): result = await self.accuweather.async_get_current_conditions() except EXCEPTIONS as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="current_conditions_update_error", + translation_placeholders={"error": repr(error)}, + ) from error _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) @@ -117,9 +121,15 @@ class AccuWeatherDailyForecastDataUpdateCoordinator( """Update data via library.""" try: async with timeout(10): - result = await self.accuweather.async_get_daily_forecast() + result = await self.accuweather.async_get_daily_forecast( + language=self.hass.config.language + ) except EXCEPTIONS as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="forecast_update_error", + translation_placeholders={"error": repr(error)}, + ) from error _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 5a019ef968e..810557519eb 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], - "requirements": ["accuweather==4.1.0"], + "requirements": ["accuweather==4.2.0"], "single_config_entry": true } diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index f14584cf08c..415df402d55 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -29,8 +29,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + AIR_QUALITY_CATEGORY_MAP, API_METRIC, - ATTR_CATEGORY, + ATTR_CATEGORY_VALUE, ATTR_DIRECTION, ATTR_ENGLISH, ATTR_LEVEL, @@ -38,6 +39,7 @@ from .const import ( ATTR_VALUE, ATTRIBUTION, MAX_FORECAST_DAYS, + POLLEN_CATEGORY_MAP, ) from .coordinator import ( AccuWeatherConfigEntry, @@ -59,9 +61,9 @@ class AccuWeatherSensorDescription(SensorEntityDescription): FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( AccuWeatherSensorDescription( key="AirQuality", - value_fn=lambda data: cast(str, data[ATTR_CATEGORY]), + value_fn=lambda data: AIR_QUALITY_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]], device_class=SensorDeviceClass.ENUM, - options=["good", "hazardous", "high", "low", "moderate", "unhealthy"], + options=list(AIR_QUALITY_CATEGORY_MAP.values()), translation_key="air_quality", ), AccuWeatherSensorDescription( @@ -83,7 +85,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="grass_pollen", ), AccuWeatherSensorDescription( @@ -107,7 +111,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="mold_pollen", ), AccuWeatherSensorDescription( @@ -115,7 +121,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="ragweed_pollen", ), AccuWeatherSensorDescription( @@ -181,14 +189,18 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="tree_pollen", ), AccuWeatherSensorDescription( key="UVIndex", native_unit_of_measurement=UV_INDEX, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="uv_index_forecast", ), AccuWeatherSensorDescription( diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index d0250a382e9..19e52be1ce3 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -26,10 +26,20 @@ "state": { "good": "Good", "hazardous": "Hazardous", - "high": "High", - "low": "Low", "moderate": "Moderate", - "unhealthy": "Unhealthy" + "unhealthy": "Unhealthy", + "very_unhealthy": "Very unhealthy" + }, + "state_attributes": { + "options": { + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]", + "very_unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::very_unhealthy%]" + } + } } }, "apparent_temperature": { @@ -62,12 +72,11 @@ "level": { "name": "Level", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "extreme": "Extreme", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "moderate": "Moderate", + "very_high": "[%key:common::state::very_high%]" } } } @@ -81,12 +90,11 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -100,6 +108,15 @@ "steady": "Steady", "rising": "Rising", "falling": "Falling" + }, + "state_attributes": { + "options": { + "state": { + "falling": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::falling%]", + "rising": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::rising%]", + "steady": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::steady%]" + } + } } }, "ragweed_pollen": { @@ -108,12 +125,11 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -154,12 +170,11 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -170,12 +185,11 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -186,12 +200,11 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "extreme": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::extreme%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -222,6 +235,14 @@ } } }, + "exceptions": { + "current_conditions_update_error": { + "message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}" + }, + "forecast_update_error": { + "message": "An error occurred while retrieving weather forecast data from the AccuWeather API: {error}" + } + }, "system_health": { "info": { "can_reach_server": "Reach AccuWeather server", diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py index d4fe13ee4f6..d7c1097d54b 100644 --- a/homeassistant/components/adax/__init__.py +++ b/homeassistant/components/adax/__init__.py @@ -2,25 +2,38 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from .const import CONNECTION_TYPE, LOCAL +from .coordinator import AdaxCloudCoordinator, AdaxConfigEntry, AdaxLocalCoordinator + PLATFORMS = [Platform.CLIMATE] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AdaxConfigEntry) -> bool: """Set up Adax from a config entry.""" + if entry.data.get(CONNECTION_TYPE) == LOCAL: + local_coordinator = AdaxLocalCoordinator(hass, entry) + entry.runtime_data = local_coordinator + else: + cloud_coordinator = AdaxCloudCoordinator(hass, entry) + entry.runtime_data = cloud_coordinator + + await entry.runtime_data.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: AdaxConfigEntry) -> bool: """Unload a config entry.""" 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: AdaxConfigEntry +) -> bool: """Migrate old entry.""" # convert title and unique_id to string if config_entry.version == 1: diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 078640cd367..b41a4432437 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -12,57 +12,42 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_TOKEN, CONF_UNIQUE_ID, PRECISION_WHOLE, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL +from . import AdaxConfigEntry +from .const import CONNECTION_TYPE, DOMAIN, LOCAL +from .coordinator import AdaxCloudCoordinator, AdaxLocalCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AdaxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Adax thermostat with config flow.""" if entry.data.get(CONNECTION_TYPE) == LOCAL: - adax_data_handler = AdaxLocal( - entry.data[CONF_IP_ADDRESS], - entry.data[CONF_TOKEN], - websession=async_get_clientsession(hass, verify_ssl=False), - ) + local_coordinator = cast(AdaxLocalCoordinator, entry.runtime_data) async_add_entities( - [LocalAdaxDevice(adax_data_handler, entry.data[CONF_UNIQUE_ID])], True + [LocalAdaxDevice(local_coordinator, entry.data[CONF_UNIQUE_ID])], + ) + else: + cloud_coordinator = cast(AdaxCloudCoordinator, entry.runtime_data) + async_add_entities( + AdaxDevice(cloud_coordinator, device_id) + for device_id in cloud_coordinator.data ) - return - - adax_data_handler = Adax( - entry.data[ACCOUNT_ID], - entry.data[CONF_PASSWORD], - websession=async_get_clientsession(hass), - ) - - async_add_entities( - ( - AdaxDevice(room, adax_data_handler) - for room in await adax_data_handler.get_rooms() - ), - True, - ) -class AdaxDevice(ClimateEntity): +class AdaxDevice(CoordinatorEntity[AdaxCloudCoordinator], ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] @@ -76,20 +61,37 @@ class AdaxDevice(ClimateEntity): _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: + def __init__( + self, + coordinator: AdaxCloudCoordinator, + device_id: str, + ) -> None: """Initialize the heater.""" - self._device_id = heater_data["id"] - self._adax_data_handler = adax_data_handler + super().__init__(coordinator) + self._adax_data_handler: Adax = coordinator.adax_data_handler + self._device_id = device_id - self._attr_unique_id = f"{heater_data['homeId']}_{heater_data['id']}" + self._attr_name = self.room["name"] + self._attr_unique_id = f"{self.room['homeId']}_{self._device_id}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, heater_data["id"])}, + identifiers={(DOMAIN, device_id)}, # Instead of setting the device name to the entity name, adax # should be updated to set has_entity_name = True, and set the entity # name to None name=cast(str | None, self.name), manufacturer="Adax", ) + self._apply_data(self.room) + + @property + def available(self) -> bool: + """Whether the entity is available or not.""" + return super().available and self._device_id in self.coordinator.data + + @property + def room(self) -> dict[str, Any]: + """Gets the data for this particular device.""" + return self.coordinator.data[self._device_id] async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" @@ -104,7 +106,9 @@ class AdaxDevice(ClimateEntity): ) else: return - await self._adax_data_handler.update() + + # Request data refresh from source to verify that update was successful + await self.coordinator.async_request_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -114,28 +118,31 @@ class AdaxDevice(ClimateEntity): self._device_id, temperature, True ) - async def async_update(self) -> None: - """Get the latest data.""" - for room in await self._adax_data_handler.get_rooms(): - if room["id"] != self._device_id: - continue - self._attr_name = room["name"] - self._attr_current_temperature = room.get("temperature") - self._attr_target_temperature = room.get("targetTemperature") - if room["heatingEnabled"]: - self._attr_hvac_mode = HVACMode.HEAT - self._attr_icon = "mdi:radiator" - else: - self._attr_hvac_mode = HVACMode.OFF - self._attr_icon = "mdi:radiator-off" - return + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if room := self.room: + self._apply_data(room) + super()._handle_coordinator_update() + + def _apply_data(self, room: dict[str, Any]) -> None: + """Update the appropriate attributues based on received data.""" + self._attr_current_temperature = room.get("temperature") + self._attr_target_temperature = room.get("targetTemperature") + if room["heatingEnabled"]: + self._attr_hvac_mode = HVACMode.HEAT + self._attr_icon = "mdi:radiator" + else: + self._attr_hvac_mode = HVACMode.OFF + self._attr_icon = "mdi:radiator-off" -class LocalAdaxDevice(ClimateEntity): +class LocalAdaxDevice(CoordinatorEntity[AdaxLocalCoordinator], ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_hvac_mode = HVACMode.HEAT + _attr_hvac_mode = HVACMode.OFF + _attr_icon = "mdi:radiator-off" _attr_max_temp = 35 _attr_min_temp = 5 _attr_supported_features = ( @@ -146,9 +153,10 @@ class LocalAdaxDevice(ClimateEntity): _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, adax_data_handler: AdaxLocal, unique_id: str) -> None: + def __init__(self, coordinator: AdaxLocalCoordinator, unique_id: str) -> None: """Initialize the heater.""" - self._adax_data_handler = adax_data_handler + super().__init__(coordinator) + self._adax_data_handler: AdaxLocal = coordinator.adax_data_handler self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, @@ -169,17 +177,20 @@ class LocalAdaxDevice(ClimateEntity): return await self._adax_data_handler.set_target_temperature(temperature) - async def async_update(self) -> None: - """Get the latest data.""" - data = await self._adax_data_handler.get_status() - self._attr_current_temperature = data["current_temperature"] - self._attr_available = self._attr_current_temperature is not None - if (target_temp := data["target_temperature"]) == 0: - self._attr_hvac_mode = HVACMode.OFF - self._attr_icon = "mdi:radiator-off" - if target_temp == 0: - self._attr_target_temperature = self._attr_min_temp - else: - self._attr_hvac_mode = HVACMode.HEAT - self._attr_icon = "mdi:radiator" - self._attr_target_temperature = target_temp + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if data := self.coordinator.data: + self._attr_current_temperature = data["current_temperature"] + self._attr_available = self._attr_current_temperature is not None + if (target_temp := data["target_temperature"]) == 0: + self._attr_hvac_mode = HVACMode.OFF + self._attr_icon = "mdi:radiator-off" + if target_temp == 0: + self._attr_target_temperature = self._attr_min_temp + else: + self._attr_hvac_mode = HVACMode.HEAT + self._attr_icon = "mdi:radiator" + self._attr_target_temperature = target_temp + + super()._handle_coordinator_update() diff --git a/homeassistant/components/adax/const.py b/homeassistant/components/adax/const.py index 306dd52e657..3461df8aa63 100644 --- a/homeassistant/components/adax/const.py +++ b/homeassistant/components/adax/const.py @@ -1,5 +1,6 @@ """Constants for the Adax integration.""" +import datetime from typing import Final ACCOUNT_ID: Final = "account_id" @@ -9,3 +10,5 @@ DOMAIN: Final = "adax" LOCAL = "Local" WIFI_SSID = "wifi_ssid" WIFI_PSWD = "wifi_pswd" + +SCAN_INTERVAL = datetime.timedelta(seconds=60) diff --git a/homeassistant/components/adax/coordinator.py b/homeassistant/components/adax/coordinator.py new file mode 100644 index 00000000000..d3dd819bea4 --- /dev/null +++ b/homeassistant/components/adax/coordinator.py @@ -0,0 +1,71 @@ +"""DataUpdateCoordinator for the Adax component.""" + +import logging +from typing import Any, cast + +from adax import Adax +from adax_local import Adax as AdaxLocal + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_TOKEN +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 ACCOUNT_ID, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +type AdaxConfigEntry = ConfigEntry[AdaxCloudCoordinator | AdaxLocalCoordinator] + + +class AdaxCloudCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Coordinator for updating data to and from Adax (cloud).""" + + def __init__(self, hass: HomeAssistant, entry: AdaxConfigEntry) -> None: + """Initialize the Adax coordinator used for Cloud mode.""" + super().__init__( + hass, + config_entry=entry, + logger=_LOGGER, + name="AdaxCloud", + update_interval=SCAN_INTERVAL, + ) + + self.adax_data_handler = Adax( + entry.data[ACCOUNT_ID], + entry.data[CONF_PASSWORD], + websession=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch data from the Adax.""" + rooms = await self.adax_data_handler.get_rooms() or [] + return {r["id"]: r for r in rooms} + + +class AdaxLocalCoordinator(DataUpdateCoordinator[dict[str, Any] | None]): + """Coordinator for updating data to and from Adax (local).""" + + def __init__(self, hass: HomeAssistant, entry: AdaxConfigEntry) -> None: + """Initialize the Adax coordinator used for Local mode.""" + super().__init__( + hass, + config_entry=entry, + logger=_LOGGER, + name="AdaxLocal", + update_interval=SCAN_INTERVAL, + ) + + self.adax_data_handler = AdaxLocal( + entry.data[CONF_IP_ADDRESS], + entry.data[CONF_TOKEN], + websession=async_get_clientsession(hass, verify_ssl=False), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the Adax.""" + if result := await self.adax_data_handler.get_status(): + return cast(dict[str, Any], result) + raise UpdateFailed("Got invalid status from device") diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json index 2742180333b..efbc611f9d3 100644 --- a/homeassistant/components/adax/manifest.json +++ b/homeassistant/components/adax/manifest.json @@ -1,7 +1,7 @@ { "domain": "adax", "name": "Adax", - "codeowners": ["@danielhiversen"], + "codeowners": ["@danielhiversen", "@lazytarget"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/adax", "iot_class": "local_polling", diff --git a/homeassistant/components/adax/strings.json b/homeassistant/components/adax/strings.json index 6157b7dfc91..9ba497a9aca 100644 --- a/homeassistant/components/adax/strings.json +++ b/homeassistant/components/adax/strings.json @@ -5,14 +5,14 @@ "data": { "connection_type": "Select connection type" }, - "description": "Select connection type. Local requires heaters with bluetooth" + "description": "Select connection type. Local requires heaters with Bluetooth" }, "local": { "data": { "wifi_ssid": "Wi-Fi SSID", - "wifi_pswd": "Wi-Fi Password" + "wifi_pswd": "Wi-Fi password" }, - "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue led starts blinking before pressing Submit. Configuring heater might take some minutes." + "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue LED starts blinking before pressing Submit. Configuring heater might take some minutes." }, "cloud": { "data": { diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index c023d4cf8f3..1d593c5c3c8 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations +from decimal import Decimal import logging from typing import Any @@ -14,6 +15,7 @@ from homeassistant.components.climate import ( FAN_MEDIUM, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature @@ -49,6 +51,14 @@ ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled" ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp" ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp" ADVANTAGE_AIR_MYFAN = "autoAA" +ADVANTAGE_AIR_MYAUTO_MODE_SET = "myAutoModeCurrentSetMode" + +HVAC_ACTIONS = { + "cool": HVACAction.COOLING, + "heat": HVACAction.HEATING, + "vent": HVACAction.FAN, + "dry": HVACAction.DRYING, +} HVAC_MODES = [ HVACMode.OFF, @@ -175,6 +185,17 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): return ADVANTAGE_AIR_HVAC_MODES.get(self._ac["mode"]) return HVACMode.OFF + @property + def hvac_action(self) -> HVACAction | None: + """Return the current running HVAC action.""" + if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF: + return HVACAction.OFF + if self._ac["mode"] == "myauto": + return HVAC_ACTIONS.get( + self._ac.get(ADVANTAGE_AIR_MYAUTO_MODE_SET, HVACAction.OFF) + ) + return HVAC_ACTIONS.get(self._ac["mode"]) + @property def fan_mode(self) -> str | None: """Return the current fan modes.""" @@ -273,6 +294,22 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): return HVACMode.HEAT_COOL return HVACMode.OFF + @property + def hvac_action(self) -> HVACAction | None: + """Return the HVAC action, inheriting from master AC if zone is open but idle if air is <= 5%.""" + if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF: + return HVACAction.OFF + master_action = HVAC_ACTIONS.get(self._ac["mode"], HVACAction.OFF) + if self._ac["mode"] == "myauto": + master_action = HVAC_ACTIONS.get( + str(self._ac.get(ADVANTAGE_AIR_MYAUTO_MODE_SET)), HVACAction.OFF + ) + if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: + if self._zone["value"] <= Decimal(5): + return HVACAction.IDLE + return master_action + return HVACAction.OFF + @property def current_temperature(self) -> float | None: """Return the current temperature.""" diff --git a/homeassistant/components/advantage_air/const.py b/homeassistant/components/advantage_air/const.py index 6ae0a0e06d5..103ca57f6ef 100644 --- a/homeassistant/components/advantage_air/const.py +++ b/homeassistant/components/advantage_air/const.py @@ -7,3 +7,4 @@ ADVANTAGE_AIR_STATE_CLOSE = "close" ADVANTAGE_AIR_STATE_ON = "on" ADVANTAGE_AIR_STATE_OFF = "off" ADVANTAGE_AIR_AUTOFAN_ENABLED = "aaAutoFanModeEnabled" +ADVANTAGE_AIR_NIGHT_MODE_ENABLED = "quietNightModeEnabled" diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index b5b982597f0..e764d484128 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -41,7 +41,7 @@ async def async_setup_entry( entities.append( AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND) ) - elif thing["channelDipState"] == 3: # 3 = "Garage door" + elif thing["channelDipState"] in [3, 10]: # 3 & 10 = "Garage door" entities.append( AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE) ) diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index ffd502663b0..9708adbc1f7 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry -from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN +from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN from .entity import AdvantageAirEntity, AdvantageAirThingEntity from .models import AdvantageAirData @@ -52,8 +52,8 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): self._id: str = light["id"] self._attr_unique_id += f"-{self._id}" self._attr_device_info = DeviceInfo( - identifiers={(ADVANTAGE_AIR_DOMAIN, self._attr_unique_id)}, - via_device=(ADVANTAGE_AIR_DOMAIN, self.coordinator.data["system"]["rid"]), + identifiers={(DOMAIN, self._attr_unique_id)}, + via_device=(DOMAIN, self.coordinator.data["system"]["rid"]), manufacturer="Advantage Air", model=light.get("moduleType"), name=light["name"], diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 5c4528b44c6..8560c9a9138 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -9,6 +9,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry from .const import ( ADVANTAGE_AIR_AUTOFAN_ENABLED, + ADVANTAGE_AIR_NIGHT_MODE_ENABLED, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, ) @@ -32,6 +33,8 @@ async def async_setup_entry( entities.append(AdvantageAirFreshAir(instance, ac_key)) if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]: entities.append(AdvantageAirMyFan(instance, ac_key)) + if ADVANTAGE_AIR_NIGHT_MODE_ENABLED in ac_device["info"]: + entities.append(AdvantageAirNightMode(instance, ac_key)) if things := instance.coordinator.data.get("myThings"): entities.extend( AdvantageAirRelay(instance, thing) @@ -93,6 +96,32 @@ class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity): await self.async_update_ac({ADVANTAGE_AIR_AUTOFAN_ENABLED: False}) +class AdvantageAirNightMode(AdvantageAirAcEntity, SwitchEntity): + """Representation of Advantage 'MySleep$aver' Mode control.""" + + _attr_icon = "mdi:weather-night" + _attr_name = "MySleep$aver" + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + """Initialize an Advantage Air Night Mode control.""" + super().__init__(instance, ac_key) + self._attr_unique_id += "-nightmode" + + @property + def is_on(self) -> bool: + """Return the Night Mode status.""" + return self._ac[ADVANTAGE_AIR_NIGHT_MODE_ENABLED] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn Night Mode on.""" + await self.async_update_ac({ADVANTAGE_AIR_NIGHT_MODE_ENABLED: True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn Night Mode off.""" + await self.async_update_ac({ADVANTAGE_AIR_NIGHT_MODE_ENABLED: False}) + + class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity): """Representation of Advantage Air Thing.""" diff --git a/homeassistant/components/advantage_air/update.py b/homeassistant/components/advantage_air/update.py index 92a162303dd..68df31142e3 100644 --- a/homeassistant/components/advantage_air/update.py +++ b/homeassistant/components/advantage_air/update.py @@ -6,7 +6,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry -from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN +from .const import DOMAIN from .entity import AdvantageAirEntity from .models import AdvantageAirData @@ -32,9 +32,7 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity): """Initialize the Advantage Air App.""" super().__init__(instance) self._attr_device_info = DeviceInfo( - identifiers={ - (ADVANTAGE_AIR_DOMAIN, self.coordinator.data["system"]["rid"]) - }, + identifiers={(DOMAIN, self.coordinator.data["system"]["rid"])}, manufacturer="Advantage Air", model=self.coordinator.data["system"]["sysType"], name=self.coordinator.data["system"]["name"], diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json index ace8eb6d2d3..c3817a0cd24 100644 --- a/homeassistant/components/aftership/strings.json +++ b/homeassistant/components/aftership/strings.json @@ -51,7 +51,7 @@ "issues": { "deprecated_yaml_import_issue_cannot_connect": { "title": "The {integration_title} YAML configuration import failed", - "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again 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/agent_dvr/__init__.py b/homeassistant/components/agent_dvr/__init__.py index 2cb32b6c80e..d504568869c 100644 --- a/homeassistant/components/agent_dvr/__init__.py +++ b/homeassistant/components/agent_dvr/__init__.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN as AGENT_DOMAIN, SERVER_URL +from .const import DOMAIN, SERVER_URL ATTRIBUTION = "ispyconnect.com" DEFAULT_BRAND = "Agent DVR by ispyconnect.com" @@ -46,7 +46,7 @@ async def async_setup_entry( device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(AGENT_DOMAIN, agent_client.unique)}, + identifiers={(DOMAIN, agent_client.unique)}, manufacturer="iSpyConnect", name=f"Agent {agent_client.name}", model="Agent DVR", diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 1ac808c87ad..0d9267e7739 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -12,7 +12,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AgentDVRConfigEntry -from .const import DOMAIN as AGENT_DOMAIN +from .const import DOMAIN CONF_HOME_MODE_NAME = "home" CONF_AWAY_MODE_NAME = "away" @@ -47,7 +47,7 @@ class AgentBaseStation(AlarmControlPanelEntity): self._client = client self._attr_unique_id = f"{client.unique}_CP" self._attr_device_info = DeviceInfo( - identifiers={(AGENT_DOMAIN, client.unique)}, + identifiers={(DOMAIN, client.unique)}, name=f"{client.name} {CONST_ALARM_CONTROL_PANEL_NAME}", manufacturer="Agent", model=CONST_ALARM_CONTROL_PANEL_NAME, diff --git a/homeassistant/components/airgradient/entity.py b/homeassistant/components/airgradient/entity.py index 51256051259..1d5430e5403 100644 --- a/homeassistant/components/airgradient/entity.py +++ b/homeassistant/components/airgradient/entity.py @@ -6,6 +6,7 @@ from typing import Any, Concatenate from airgradient import AirGradientConnectionError, AirGradientError, get_model_name 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 @@ -29,6 +30,7 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]): model_id=measures.model, serial_number=coordinator.serial_number, sw_version=measures.firmware_version, + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.serial_number)}, ) diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index 4cf3a6a34ea..cef4db57358 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -11,7 +11,7 @@ } }, "discovery_confirm": { - "description": "Do you want to setup {model}?" + "description": "Do you want to set up {model}?" } }, "abort": { @@ -68,8 +68,8 @@ "led_bar_mode": { "name": "LED bar mode", "state": { - "off": "Off", - "co2": "Carbon dioxide", + "off": "[%key:common::state::off%]", + "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "pm": "Particulate matter" } }, @@ -143,8 +143,8 @@ "led_bar_mode": { "name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]", "state": { - "off": "[%key:component::airgradient::entity::select::led_bar_mode::state::off%]", - "co2": "[%key:component::airgradient::entity::select::led_bar_mode::state::co2%]", + "off": "[%key:common::state::off%]", + "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]" } }, diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py index b255c5f078f..668cabdae63 100644 --- a/homeassistant/components/airly/coordinator.py +++ b/homeassistant/components/airly/coordinator.py @@ -105,7 +105,14 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i try: await measurements.update() except (AirlyError, ClientConnectorError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(error), + }, + ) from error _LOGGER.debug( "Requests remaining: %s/%s", @@ -126,7 +133,11 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i standards = measurements.current["standards"] if index["description"] == NO_AIRLY_SENSORS: - raise UpdateFailed("Can't retrieve data: no Airly sensors in this area") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_station", + translation_placeholders={"entry": self.config_entry.title}, + ) for value in values: data[value["name"]] = value["value"] for standard in standards: diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 33ee8bbe4c9..fe4ccbb4745 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -36,5 +36,13 @@ "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" } } + }, + "exceptions": { + "update_error": { + "message": "An error occurred while retrieving data from the Airly API for {entry}: {error}" + }, + "no_station": { + "message": "An error occurred while retrieving data from the Airly API for {entry}: no measuring stations in this area" + } } } diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index ee5bf4a1dd7..1e73bc7551e 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -8,7 +8,7 @@ from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from pyairnow import WebServiceAPI from pyairnow.conv import aqi_to_concentration -from pyairnow.errors import AirNowError +from pyairnow.errors import AirNowError, InvalidJsonError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -79,7 +79,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): distance=self.distance, ) - except (AirNowError, ClientConnectorError) as error: + except (AirNowError, ClientConnectorError, InvalidJsonError) as error: raise UpdateFailed(error) from error if not obs: diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index d5fb22106f9..a69f67948cb 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -7,7 +7,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", - "radius": "Station Radius (miles; optional)" + "radius": "Station radius (miles; optional)" } } }, @@ -25,7 +25,7 @@ "step": { "init": { "data": { - "radius": "Station Radius (miles)" + "radius": "Station radius (miles)" } } } diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 26b944467e6..9c16975a3ab 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -91,7 +91,7 @@ "name": "Hydrogen fluoride" }, "health_index": { - "name": "Health Index" + "name": "Health index" }, "absolute_humidity": { "name": "Absolute humidity" @@ -112,10 +112,10 @@ "name": "Oxygen" }, "performance_index": { - "name": "Performance Index" + "name": "Performance index" }, "hydrogen_phosphide": { - "name": "Hydrogen Phosphide" + "name": "Hydrogen phosphide" }, "relative_pressure": { "name": "Relative pressure" @@ -127,22 +127,22 @@ "name": "Refrigerant" }, "silicon_hydride": { - "name": "Silicon Hydride" + "name": "Silicon hydride" }, "noise": { "name": "Noise" }, "maximum_noise": { - "name": "Noise (Maximum)" + "name": "Noise (maximum)" }, "radon": { "name": "Radon" }, "industrial_volatile_organic_compounds": { - "name": "VOCs (Industrial)" + "name": "VOCs (industrial)" }, "virus_index": { - "name": "Virus Index" + "name": "Virus index" } } } diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json index 67057ff09f5..5204d7a4ba8 100644 --- a/homeassistant/components/airthings/manifest.json +++ b/homeassistant/components/airthings/manifest.json @@ -3,6 +3,19 @@ "name": "Airthings", "codeowners": ["@danielhiversen", "@LaStrada"], "config_flow": true, + "dhcp": [ + { + "hostname": "airthings-view" + }, + { + "hostname": "airthings-hub", + "macaddress": "D0141190*" + }, + { + "hostname": "airthings-hub", + "macaddress": "70B3D52A0*" + } + ], "documentation": "https://www.home-assistant.io/integrations/airthings", "iot_class": "cloud_polling", "loggers": ["airthings"], diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index a0d9c97c8c8..f2bf8e071f7 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory, @@ -78,6 +79,12 @@ SENSORS: dict[str, SensorEntityDescription] = { translation_key="light", state_class=SensorStateClass.MEASUREMENT, ), + "lux": SensorEntityDescription( + key="lux", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + ), "virusRisk": SensorEntityDescription( key="virusRisk", translation_key="virus_risk", diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index 3e7b659bff1..2d32fa6e7df 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -102,7 +102,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._get_device_data(discovery_info) except AirthingsDeviceUpdateError: return self.async_abort(reason="cannot_connect") - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unknown error occurred") return self.async_abort(reason="unknown") name = get_name(device) @@ -160,7 +161,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._get_device_data(discovery_info) except AirthingsDeviceUpdateError: return self.async_abort(reason="cannot_connect") - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unknown error occurred") return self.async_abort(reason="unknown") name = get_name(device) self._discovered_devices[address] = Discovery(name, discovery_info, device) diff --git a/homeassistant/components/airtouch5/config_flow.py b/homeassistant/components/airtouch5/config_flow.py index d96aaed96b7..38c85e45fb8 100644 --- a/homeassistant/components/airtouch5/config_flow.py +++ b/homeassistant/components/airtouch5/config_flow.py @@ -32,7 +32,8 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN): client = Airtouch5SimpleClient(user_input[CONF_HOST]) try: await client.test_connection() - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors = {"base": "cannot_connect"} else: await self.async_set_unique_id(user_input[CONF_HOST]) diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json index 148b1368a19..9d53be4dee7 100644 --- a/homeassistant/components/airvisual/strings.json +++ b/homeassistant/components/airvisual/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "geography_by_coords": { - "title": "Configure a Geography", + "title": "Configure a geography", "description": "Use the AirVisual cloud API to monitor a latitude/longitude.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", @@ -16,8 +16,8 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "city": "City", - "country": "Country", - "state": "State" + "state": "State", + "country": "[%key:common::config_flow::data::country%]" } }, "reauth_confirm": { @@ -56,12 +56,12 @@ "sensor": { "pollutant_label": { "state": { - "co": "Carbon Monoxide", - "n2": "Nitrogen Dioxide", - "o3": "Ozone", - "p1": "PM10", - "p2": "PM2.5", - "s2": "Sulfur Dioxide" + "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "o3": "[%key:component::sensor::entity_component::ozone::name%]", + "p1": "[%key:component::sensor::entity_component::pm10::name%]", + "p2": "[%key:component::sensor::entity_component::pm25::name%]", + "s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" } }, "pollutant_level": { diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 95ed9d200f4..1b636de0a47 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.9"] + "requirements": ["aioairzone==1.0.0"] } diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index f76eb1466a3..66657836b74 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -9,6 +9,8 @@ from aioairzone.const import ( AZD_HUMIDITY, AZD_TEMP, AZD_TEMP_UNIT, + AZD_THERMOSTAT_BATTERY, + AZD_THERMOSTAT_SIGNAL, AZD_WEBSERVER, AZD_WIFI_RSSI, AZD_ZONES, @@ -73,6 +75,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + device_class=SensorDeviceClass.BATTERY, + key=AZD_THERMOSTAT_BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_THERMOSTAT_SIGNAL, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="thermostat_signal", + ), ) diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index cd313b821aa..c7d9701aa83 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -76,6 +76,9 @@ "sensor": { "rssi": { "name": "RSSI" + }, + "thermostat_signal": { + "name": "Signal strength" } } } diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 0e21e57ec52..ecc9634f36a 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.10"] + "requirements": ["aioairzone-cloud==0.6.12"] } diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json index 6e0f9adcd66..5481bfbc984 100644 --- a/homeassistant/components/airzone_cloud/strings.json +++ b/homeassistant/components/airzone_cloud/strings.json @@ -32,9 +32,9 @@ "air_quality": { "name": "Air Quality mode", "state": { - "off": "Off", - "on": "On", - "auto": "Auto" + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "auto": "[%key:common::state::auto%]" } }, "modes": { diff --git a/homeassistant/components/alarmdecoder/strings.json b/homeassistant/components/alarmdecoder/strings.json index ccf1d965855..3bc8363b90d 100644 --- a/homeassistant/components/alarmdecoder/strings.json +++ b/homeassistant/components/alarmdecoder/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Choose AlarmDecoder Protocol", + "title": "Choose AlarmDecoder protocol", "data": { "protocol": "Protocol" } @@ -12,8 +12,8 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", - "device_baudrate": "Device Baud Rate", - "device_path": "Device Path" + "device_baudrate": "Device baud rate", + "device_path": "Device path" }, "data_description": { "host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.", @@ -44,36 +44,36 @@ "arm_settings": { "title": "[%key:component::alarmdecoder::options::step::init::title%]", "data": { - "auto_bypass": "Auto Bypass on Arm", - "code_arm_required": "Code Required for Arming", - "alt_night_mode": "Alternative Night Mode" + "auto_bypass": "Auto-bypass on arm", + "code_arm_required": "Code required for arming", + "alt_night_mode": "Alternative night mode" } }, "zone_select": { "title": "[%key:component::alarmdecoder::options::step::init::title%]", "description": "Enter the zone number you'd like to to add, edit, or remove.", "data": { - "zone_number": "Zone Number" + "zone_number": "Zone number" } }, "zone_details": { "title": "[%key:component::alarmdecoder::options::step::init::title%]", - "description": "Enter details for zone {zone_number}. To delete zone {zone_number}, leave Zone Name blank.", + "description": "Enter details for zone {zone_number}. To delete zone {zone_number}, leave 'Zone name' blank.", "data": { - "zone_name": "Zone Name", - "zone_type": "Zone Type", - "zone_rfid": "RF Serial", - "zone_loop": "RF Loop", - "zone_relayaddr": "Relay Address", - "zone_relaychan": "Relay Channel" + "zone_name": "Zone name", + "zone_type": "Zone type", + "zone_rfid": "RF serial", + "zone_loop": "RF loop", + "zone_relayaddr": "Relay address", + "zone_relaychan": "Relay channel" } } }, "error": { - "relay_inclusive": "Relay Address and Relay Channel are codependent and must be included together.", + "relay_inclusive": "'Relay address' and 'Relay channel' are codependent and must be included together.", "int": "The field below must be an integer.", - "loop_rfid": "RF Loop cannot be used without RF Serial.", - "loop_range": "RF Loop must be an integer between 1 and 4." + "loop_rfid": "'RF loop' cannot be used without 'RF serial'.", + "loop_range": "'RF loop' must be an integer between 1 and 4." } }, "services": { diff --git a/homeassistant/components/alert/entity.py b/homeassistant/components/alert/entity.py index 629047b15ba..a11b281428f 100644 --- a/homeassistant/components/alert/entity.py +++ b/homeassistant/components/alert/entity.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.exceptions import ServiceNotFound, ServiceValidationError from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( async_track_point_in_time, @@ -195,7 +195,8 @@ class AlertEntity(Entity): async def async_turn_off(self, **kwargs: Any) -> None: """Async Acknowledge alert.""" - LOGGER.debug("Acknowledged Alert: %s", self._attr_name) + if not self._can_ack: + raise ServiceValidationError("This alert cannot be acknowledged") self._ack = True self.async_write_ha_state() diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index e70055c20b1..897037987a7 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1438,7 +1438,7 @@ class AlexaModeController(AlexaCapability): # Fan preset_mode if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": mode = self.entity.attributes.get(fan.ATTR_PRESET_MODE, None) - if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, None): + if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, ()): return f"{fan.ATTR_PRESET_MODE}.{mode}" # Humidifier mode diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 6a0b1830b7e..7088b624e21 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -719,7 +719,7 @@ class LockCapabilities(AlexaEntity): yield Alexa(self.entity) -@ENTITY_ADAPTERS.register(media_player.const.DOMAIN) +@ENTITY_ADAPTERS.register(media_player.DOMAIN) class MediaPlayerCapabilities(AlexaEntity): """Class to represent MediaPlayer capabilities.""" @@ -757,9 +757,7 @@ class MediaPlayerCapabilities(AlexaEntity): if supported & media_player.MediaPlayerEntityFeature.SELECT_SOURCE: inputs = AlexaInputController.get_valid_inputs( - self.entity.attributes.get( - media_player.const.ATTR_INPUT_SOURCE_LIST, [] - ) + self.entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST, []) ) if len(inputs) > 0: yield AlexaInputController(self.entity) @@ -776,8 +774,7 @@ class MediaPlayerCapabilities(AlexaEntity): and domain != "denonavr" ): inputs = AlexaEqualizerController.get_valid_inputs( - self.entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST) - or [] + self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) or [] ) if len(inputs) > 0: yield AlexaEqualizerController(self.entity) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 8bd393e2d11..747cbd85adb 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -566,7 +566,7 @@ async def async_api_set_volume( data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, } await hass.services.async_call( @@ -589,7 +589,7 @@ async def async_api_select_input( # Attempt to map the ALL UPPERCASE payload name to a source. # Strips trailing 1 to match single input devices. - source_list = entity.attributes.get(media_player.const.ATTR_INPUT_SOURCE_LIST) or [] + source_list = entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or [] for source in source_list: formatted_source = ( source.lower().replace("-", "").replace("_", "").replace(" ", "") @@ -611,7 +611,7 @@ async def async_api_select_input( data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_INPUT_SOURCE: media_input, + media_player.ATTR_INPUT_SOURCE: media_input, } await hass.services.async_call( @@ -636,7 +636,7 @@ async def async_api_adjust_volume( volume_delta = int(directive.payload["volume"]) entity = directive.entity - current_level = entity.attributes[media_player.const.ATTR_MEDIA_VOLUME_LEVEL] + current_level = entity.attributes[media_player.ATTR_MEDIA_VOLUME_LEVEL] # read current state try: @@ -648,7 +648,7 @@ async def async_api_adjust_volume( data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, } await hass.services.async_call( @@ -709,7 +709,7 @@ async def async_api_set_mute( entity = directive.entity data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute, + media_player.ATTR_MEDIA_VOLUME_MUTED: mute, } await hass.services.async_call( @@ -1708,15 +1708,13 @@ async def async_api_changechannel( data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, - media_player.const.ATTR_MEDIA_CONTENT_ID: channel, - media_player.const.ATTR_MEDIA_CONTENT_TYPE: ( - media_player.const.MEDIA_TYPE_CHANNEL - ), + media_player.ATTR_MEDIA_CONTENT_ID: channel, + media_player.ATTR_MEDIA_CONTENT_TYPE: (media_player.MediaType.CHANNEL), } await hass.services.async_call( entity.domain, - media_player.const.SERVICE_PLAY_MEDIA, + media_player.SERVICE_PLAY_MEDIA, data, blocking=False, context=context, @@ -1825,13 +1823,13 @@ async def async_api_set_eq_mode( context: ha.Context, ) -> AlexaResponse: """Process a SetMode request for EqualizerController.""" - mode = directive.payload["mode"] + mode: str = directive.payload["mode"] entity = directive.entity data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} - sound_mode_list = entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST) + sound_mode_list = entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) if sound_mode_list and mode.lower() in sound_mode_list: - data[media_player.const.ATTR_SOUND_MODE] = mode.lower() + data[media_player.ATTR_SOUND_MODE] = mode.lower() else: msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}" raise AlexaInvalidValueError(msg) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 20e3ef1d7c7..e3181ee1405 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -3,10 +3,10 @@ from __future__ import annotations from asyncio import timeout +from collections.abc import Mapping from http import HTTPStatus import json import logging -from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 @@ -260,10 +260,10 @@ async def async_enable_proactive_mode( def extra_significant_check( hass: HomeAssistant, old_state: str, - old_attrs: dict[Any, Any] | MappingProxyType[Any, Any], + old_attrs: Mapping[Any, Any], old_extra_arg: Any, new_state: str, - new_attrs: dict[str, Any] | MappingProxyType[Any, Any], + new_attrs: Mapping[Any, Any], new_extra_arg: Any, ) -> bool: """Check if the serialized data has changed.""" diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json index e7fbf8edc74..f684292d9a2 100644 --- a/homeassistant/components/amazon_polly/manifest.json +++ b/homeassistant/components/amazon_polly/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], "quality_scale": "legacy", - "requirements": ["boto3==1.34.131"] + "requirements": ["boto3==1.37.1"] } diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py index 9ec6db6ff45..b96da9863a1 100644 --- a/homeassistant/components/ambient_network/sensor.py +++ b/homeassistant/components/ambient_network/sensor.py @@ -240,6 +240,7 @@ SENSOR_DESCRIPTIONS = ( suggested_display_precision=0, entity_registry_enabled_default=False, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key=TYPE_WINDGUSTMPH, diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 730b798bd15..1b4334774d4 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -609,6 +609,7 @@ SENSOR_DESCRIPTIONS = ( translation_key="wind_direction", native_unit_of_measurement=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key=TYPE_WINDDIR_AVG10M, diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index da77a35f789..b2648f7c13c 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -8,7 +8,7 @@ from python_homeassistant_analytics import ( HomeassistantAnalyticsClient, HomeassistantAnalyticsConnectionError, ) -from python_homeassistant_analytics.models import IntegrationType +from python_homeassistant_analytics.models import Environment, IntegrationType import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow @@ -81,7 +81,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ) try: addons = await client.get_addons() - integrations = await client.get_integrations() + integrations = await client.get_integrations(Environment.NEXT) custom_integrations = await client.get_custom_integrations() except HomeassistantAnalyticsConnectionError: LOGGER.exception("Error connecting to Home Assistant analytics") @@ -165,7 +165,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): ) try: addons = await client.get_addons() - integrations = await client.get_integrations() + integrations = await client.get_integrations(Environment.NEXT) custom_integrations = await client.get_custom_integrations() except HomeassistantAnalyticsConnectionError: LOGGER.exception("Error connecting to Home Assistant analytics") diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index 10d3c19a2f6..222906efa0b 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -3,12 +3,12 @@ "step": { "user": { "data": { - "tracked_addons": "Addons", + "tracked_addons": "Add-ons", "tracked_integrations": "Integrations", "tracked_custom_integrations": "Custom integrations" }, "data_description": { - "tracked_addons": "Select the addons you want to track", + "tracked_addons": "Select the add-ons you want to track", "tracked_integrations": "Select the integrations you want to track", "tracked_custom_integrations": "Select the custom integrations you want to track" } diff --git a/homeassistant/components/android_ip_webcam/manifest.json b/homeassistant/components/android_ip_webcam/manifest.json index 57af567ec51..d7a9f8ad97a 100644 --- a/homeassistant/components/android_ip_webcam/manifest.json +++ b/homeassistant/components/android_ip_webcam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/android_ip_webcam", "iot_class": "local_polling", - "requirements": ["pydroid-ipcam==2.0.0"] + "requirements": ["pydroid-ipcam==3.0.0"] } diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index 44b2d2a5f20..bf146a11e13 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -73,7 +73,7 @@ class AndroidTVRemoteBaseEntity(Entity): self._api.send_key_command(key_code, direction) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc def _send_launch_app_command(self, app_link: str) -> None: @@ -85,5 +85,5 @@ class AndroidTVRemoteBaseEntity(Entity): self._api.send_launch_app_command(app_link) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 1c45e825359..89cc0fc3965 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], - "requirements": ["androidtvremote2==0.2.0"], + "requirements": ["androidtvremote2==0.2.1"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index 3d3a97092bc..5bc205b32df 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AndroidTVRemoteConfigEntry -from .const import CONF_APP_ICON, CONF_APP_NAME +from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN from .entity import AndroidTVRemoteBaseEntity PARALLEL_UPDATES = 0 @@ -233,5 +233,5 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt await asyncio.sleep(delay_secs) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index e41cbcf9a76..106cac3a63d 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -54,5 +54,10 @@ } } } + }, + "exceptions": { + "connection_closed": { + "message": "Connection to the Android TV device is closed" + } } } diff --git a/homeassistant/components/anova/config_flow.py b/homeassistant/components/anova/config_flow.py index bc4723b1dba..f382606baba 100644 --- a/homeassistant/components/anova/config_flow.py +++ b/homeassistant/components/anova/config_flow.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from anova_wifi import AnovaApi, InvalidLogin import voluptuous as vol @@ -11,8 +13,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) -class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): + +class AnovaConfigFlow(ConfigFlow, domain=DOMAIN): """Sets up a config flow for Anova.""" VERSION = 1 @@ -35,7 +39,8 @@ class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): await api.authenticate() except InvalidLogin: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry( diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index cfbd3c29547..317498e96b5 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -22,6 +22,7 @@ from . import AnthemavConfigEntry from .const import ANTHEMAV_UPDATE_SIGNAL, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) +VOLUME_STEP = 0.01 async def async_setup_entry( @@ -60,6 +61,7 @@ class AnthemAVR(MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE ) + _attr_volume_step = VOLUME_STEP def __init__( self, diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 84c9054b476..a9745d1a6a5 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from .const import DOMAIN, LOGGER +from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -26,12 +26,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY]) ) try: - await client.messages.create( - model="claude-3-haiku-20240307", - max_tokens=1, - messages=[{"role": "user", "content": "Hi"}], - timeout=10.0, - ) + model_id = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model = await client.models.retrieve(model_id=model_id, timeout=10.0) + LOGGER.debug("Anthropic model: %s", model.display_name) except anthropic.AuthenticationError as err: LOGGER.error("Invalid API key: %s", err) return False diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 63a70f31fea..ebad206af61 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from functools import partial import logging from types import MappingProxyType @@ -34,10 +35,12 @@ from .const import ( CONF_PROMPT, CONF_RECOMMENDED, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TEMPERATURE, + RECOMMENDED_THINKING_BUDGET, ) _LOGGER = logging.getLogger(__name__) @@ -50,7 +53,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( RECOMMENDED_OPTIONS = { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, } @@ -63,12 +66,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: client = await hass.async_add_executor_job( partial(anthropic.AsyncAnthropic, api_key=data[CONF_API_KEY]) ) - await client.messages.create( - model="claude-3-haiku-20240307", - max_tokens=1, - messages=[{"role": "user", "content": "Hi"}], - timeout=10.0, - ) + await client.models.list(timeout=10.0) class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): @@ -133,25 +131,36 @@ class AnthropicOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + errors: dict[str, str] = {} if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) - return self.async_create_entry(title="", data=user_input) + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + if user_input.get( + CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET + ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): + errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" - # Re-render the options again, now with the recommended options shown/hidden - self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + if not errors: + return self.async_create_entry(title="", data=user_input) + else: + # Re-render the options again, now with the recommended options shown/hidden + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], - } + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), + } suggested_values = options.copy() if not suggested_values.get(CONF_PROMPT): suggested_values[CONF_PROMPT] = llm.DEFAULT_INSTRUCTIONS_PROMPT + if ( + suggested_llm_apis := suggested_values.get(CONF_LLM_HASS_API) + ) and isinstance(suggested_llm_apis, str): + suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis] schema = self.add_suggested_values_to_schema( vol.Schema(anthropic_config_option_schema(self.hass, options)), @@ -161,33 +170,28 @@ class AnthropicOptionsFlow(OptionsFlow): return self.async_show_form( step_id="init", data_schema=schema, + errors=errors or None, ) def anthropic_config_option_schema( hass: HomeAssistant, - options: dict[str, Any] | MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> dict: """Return a schema for Anthropic completion options.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) + ] schema = { vol.Optional(CONF_PROMPT): TemplateSelector(), - vol.Optional(CONF_LLM_HASS_API, default="none"): SelectSelector( - SelectSelectorConfig(options=hass_apis) - ), + vol.Optional( + CONF_LLM_HASS_API, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, @@ -210,6 +214,10 @@ def anthropic_config_option_schema( CONF_TEMPERATURE, default=RECOMMENDED_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_THINKING_BUDGET, + default=RECOMMENDED_THINKING_BUDGET, + ): int, } ) return schema diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 0dbf9c51ac1..38e4270e6e1 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -13,3 +13,8 @@ CONF_MAX_TOKENS = "max_tokens" RECOMMENDED_MAX_TOKENS = 1024 CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 +CONF_THINKING_BUDGET = "thinking_budget" +RECOMMENDED_THINKING_BUDGET = 0 +MIN_THINKING_BUDGET = 1024 + +THINKING_MODELS = ["claude-3-7-sonnet-20250219", "claude-3-7-sonnet-latest"] diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 5511119d377..7e1fda467a8 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -1,27 +1,39 @@ """Conversation support for Anthropic.""" -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Iterable import json -from typing import Any, Literal +from typing import Any, Literal, cast import anthropic from anthropic import AsyncStream from anthropic._types import NOT_GIVEN from anthropic.types import ( InputJSONDelta, - Message, + MessageDeltaUsage, MessageParam, MessageStreamEvent, RawContentBlockDeltaEvent, RawContentBlockStartEvent, RawContentBlockStopEvent, + RawMessageDeltaEvent, + RawMessageStartEvent, + RawMessageStopEvent, + RedactedThinkingBlock, + RedactedThinkingBlockParam, + SignatureDelta, TextBlock, TextBlockParam, TextDelta, + ThinkingBlock, + ThinkingBlockParam, + ThinkingConfigDisabledParam, + ThinkingConfigEnabledParam, + ThinkingDelta, ToolParam, ToolResultBlockParam, ToolUseBlock, ToolUseBlockParam, + Usage, ) from voluptuous_openapi import convert @@ -30,7 +42,7 @@ 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 -from homeassistant.helpers import chat_session, device_registry as dr, intent, llm +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AnthropicConfigEntry @@ -39,11 +51,15 @@ from .const import ( CONF_MAX_TOKENS, CONF_PROMPT, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, DOMAIN, LOGGER, + MIN_THINKING_BUDGET, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TEMPERATURE, + RECOMMENDED_THINKING_BUDGET, + THINKING_MODELS, ) # Max number of back and forth with the LLM to generate a response @@ -71,73 +87,102 @@ def _format_tool( ) -def _message_convert( - message: Message, -) -> MessageParam: - """Convert from class to TypedDict.""" - param_content: list[TextBlockParam | ToolUseBlockParam] = [] +def _convert_content( + chat_content: Iterable[conversation.Content], +) -> list[MessageParam]: + """Transform HA chat_log content into Anthropic API format.""" + messages: list[MessageParam] = [] - for message_content in message.content: - if isinstance(message_content, TextBlock): - param_content.append(TextBlockParam(type="text", text=message_content.text)) - elif isinstance(message_content, ToolUseBlock): - param_content.append( - ToolUseBlockParam( - type="tool_use", - id=message_content.id, - name=message_content.name, - input=message_content.input, - ) + for content in chat_content: + if isinstance(content, conversation.ToolResultContent): + tool_result_block = ToolResultBlockParam( + type="tool_result", + tool_use_id=content.tool_call_id, + content=json.dumps(content.tool_result), ) - - return MessageParam(role=message.role, content=param_content) - - -def _convert_content(chat_content: conversation.Content) -> MessageParam: - """Create tool response content.""" - if isinstance(chat_content, conversation.ToolResultContent): - return MessageParam( - role="user", - content=[ - ToolResultBlockParam( - type="tool_result", - tool_use_id=chat_content.tool_call_id, - content=json.dumps(chat_content.tool_result), - ) - ], - ) - if isinstance(chat_content, conversation.AssistantContent): - return MessageParam( - role="assistant", - content=[ - TextBlockParam(type="text", text=chat_content.content or ""), - *[ - ToolUseBlockParam( - type="tool_use", - id=tool_call.id, - name=tool_call.tool_name, - input=tool_call.tool_args, + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=[tool_result_block], ) - for tool_call in chat_content.tool_calls or () - ], - ], - ) - if isinstance(chat_content, conversation.UserContent): - return MessageParam( - role="user", - content=chat_content.content, - ) - # Note: We don't pass SystemContent here as its passed to the API as the prompt - raise ValueError(f"Unexpected content type: {type(chat_content)}") + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + tool_result_block, + ] + else: + messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] + elif isinstance(content, conversation.UserContent): + # Combine consequent user messages + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=content.content, + ) + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + TextBlockParam(type="text", text=content.content), + ] + else: + messages[-1]["content"].append( # type: ignore[attr-defined] + TextBlockParam(type="text", text=content.content) + ) + elif isinstance(content, conversation.AssistantContent): + # Combine consequent assistant messages + if not messages or messages[-1]["role"] != "assistant": + messages.append( + MessageParam( + role="assistant", + content=[], + ) + ) + + if content.content: + messages[-1]["content"].append( # type: ignore[union-attr] + TextBlockParam(type="text", text=content.content) + ) + if content.tool_calls: + messages[-1]["content"].extend( # type: ignore[union-attr] + [ + ToolUseBlockParam( + type="tool_use", + id=tool_call.id, + name=tool_call.tool_name, + input=tool_call.tool_args, + ) + for tool_call in content.tool_calls + ] + ) + else: + # Note: We don't pass SystemContent here as its passed to the API as the prompt + raise TypeError(f"Unexpected content type: {type(content)}") + + return messages -async def _transform_stream( +async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place + chat_log: conversation.ChatLog, result: AsyncStream[MessageStreamEvent], + messages: list[MessageParam], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform the response stream into HA format. A typical stream of responses might look something like the following: - RawMessageStartEvent with no content + - RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled) + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - ... + - RawContentBlockDeltaEvent with a SignatureDelta + - RawContentBlockStopEvent + - RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally) + - RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta) - RawContentBlockStartEvent with an empty TextBlock - RawContentBlockDeltaEvent with a TextDelta - RawContentBlockDeltaEvent with a TextDelta @@ -151,44 +196,127 @@ async def _transform_stream( - RawContentBlockStopEvent - RawMessageDeltaEvent with a stop_reason='tool_use' - RawMessageStopEvent(type='message_stop') + + Each message could contain multiple blocks of the same type. """ if result is None: raise TypeError("Expected a stream of messages") - current_tool_call: dict | None = None + current_message: MessageParam | None = None + current_block: ( + TextBlockParam + | ToolUseBlockParam + | ThinkingBlockParam + | RedactedThinkingBlockParam + | None + ) = None + current_tool_args: str + input_usage: Usage | None = None async for response in result: LOGGER.debug("Received response: %s", response) - if isinstance(response, RawContentBlockStartEvent): + if isinstance(response, RawMessageStartEvent): + if response.message.role != "assistant": + raise ValueError("Unexpected message role") + current_message = MessageParam(role=response.message.role, content=[]) + input_usage = response.message.usage + elif isinstance(response, RawContentBlockStartEvent): if isinstance(response.content_block, ToolUseBlock): - current_tool_call = { - "id": response.content_block.id, - "name": response.content_block.name, - "input": "", - } + current_block = ToolUseBlockParam( + type="tool_use", + id=response.content_block.id, + name=response.content_block.name, + input="", + ) + current_tool_args = "" elif isinstance(response.content_block, TextBlock): + current_block = TextBlockParam( + type="text", text=response.content_block.text + ) yield {"role": "assistant"} + if response.content_block.text: + yield {"content": response.content_block.text} + elif isinstance(response.content_block, ThinkingBlock): + current_block = ThinkingBlockParam( + type="thinking", + thinking=response.content_block.thinking, + signature=response.content_block.signature, + ) + elif isinstance(response.content_block, RedactedThinkingBlock): + current_block = RedactedThinkingBlockParam( + type="redacted_thinking", data=response.content_block.data + ) + LOGGER.debug( + "Some of Claude’s internal reasoning has been automatically " + "encrypted for safety reasons. This doesn’t affect the quality of " + "responses" + ) elif isinstance(response, RawContentBlockDeltaEvent): + if current_block is None: + raise ValueError("Unexpected delta without a block") if isinstance(response.delta, InputJSONDelta): - if current_tool_call is None: - raise ValueError("Unexpected delta without a tool call") - current_tool_call["input"] += response.delta.partial_json + current_tool_args += response.delta.partial_json elif isinstance(response.delta, TextDelta): - LOGGER.debug("yielding delta: %s", response.delta.text) + text_block = cast(TextBlockParam, current_block) + text_block["text"] += response.delta.text yield {"content": response.delta.text} + elif isinstance(response.delta, ThinkingDelta): + thinking_block = cast(ThinkingBlockParam, current_block) + thinking_block["thinking"] += response.delta.thinking + elif isinstance(response.delta, SignatureDelta): + thinking_block = cast(ThinkingBlockParam, current_block) + thinking_block["signature"] += response.delta.signature elif isinstance(response, RawContentBlockStopEvent): - if current_tool_call: + if current_block is None: + raise ValueError("Unexpected stop event without a current block") + if current_block["type"] == "tool_use": + # tool block + tool_args = json.loads(current_tool_args) if current_tool_args else {} + current_block["input"] = tool_args yield { "tool_calls": [ llm.ToolInput( - id=current_tool_call["id"], - tool_name=current_tool_call["name"], - tool_args=json.loads(current_tool_call["input"]), + id=current_block["id"], + tool_name=current_block["name"], + tool_args=tool_args, ) ] } - current_tool_call = None + elif current_block["type"] == "thinking": + # thinking block + LOGGER.debug("Thinking: %s", current_block["thinking"]) + + if current_message is None: + raise ValueError("Unexpected stop event without a current message") + current_message["content"].append(current_block) # type: ignore[union-attr] + current_block = None + elif isinstance(response, RawMessageDeltaEvent): + if (usage := response.usage) is not None: + chat_log.async_trace(_create_token_stats(input_usage, usage)) + elif isinstance(response, RawMessageStopEvent): + if current_message is not None: + messages.append(current_message) + current_message = None + + +def _create_token_stats( + input_usage: Usage | None, response_usage: MessageDeltaUsage +) -> dict[str, Any]: + """Create token stats for conversation agent tracing.""" + input_tokens = 0 + cached_input_tokens = 0 + if input_usage: + input_tokens = input_usage.input_tokens + cached_input_tokens = input_usage.cache_creation_input_tokens or 0 + output_tokens = response_usage.output_tokens + return { + "stats": { + "input_tokens": input_tokens, + "cached_input_tokens": cached_input_tokens, + "output_tokens": output_tokens, + } + } class AnthropicConversationEntity( @@ -226,18 +354,6 @@ class AnthropicConversationEntity( self.entry.add_update_listener(self._async_entry_update_listener) ) - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) - async def _async_handle_message( self, user_input: conversation.ConversationInput, @@ -266,34 +382,51 @@ class AnthropicConversationEntity( system = chat_log.content[0] if not isinstance(system, conversation.SystemContent): raise TypeError("First message must be a system message") - messages = [_convert_content(content) for content in chat_log.content[1:]] + messages = _convert_content(chat_log.content[1:]) client = self.entry.runtime_data + thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - try: - stream = await client.messages.create( - model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), - messages=messages, - tools=tools or NOT_GIVEN, - max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - system=system.content, - temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - stream=True, + model_args = { + "model": model, + "messages": messages, + "tools": tools or NOT_GIVEN, + "max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + "system": system.content, + "stream": True, + } + if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET: + model_args["thinking"] = ThinkingConfigEnabledParam( + type="enabled", budget_tokens=thinking_budget ) + else: + model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") + model_args["temperature"] = options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ) + + try: + stream = await client.messages.create(**model_args) except anthropic.AnthropicError as err: raise HomeAssistantError( f"Sorry, I had a problem talking to Anthropic: {err}" ) from err messages.extend( - [ - _convert_content(content) - async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(stream) - ) - ] + _convert_content( + [ + content + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, + _transform_stream(chat_log, stream, messages), + ) + if not isinstance(content, conversation.AssistantContent) + ] + ) ) if not chat_log.unresponded_tool_results: @@ -305,7 +438,9 @@ class AnthropicConversationEntity( intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_speech(response_content.content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=chat_log.conversation_id + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, ) async def _async_entry_update_listener( diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 9550a1a6672..c2caf3a6666 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -23,12 +23,17 @@ "max_tokens": "Maximum tokens to return in response", "temperature": "Temperature", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings" + "recommended": "Recommended model settings", + "thinking_budget_tokens": "Thinking budget" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking." } } + }, + "error": { + "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget." } } } diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index f3829b41f61..dfeb56c8d06 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -53,10 +53,8 @@ class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity): """Initialize the APCUPSd binary device.""" super().__init__(coordinator, context=description.key.upper()) - # Set up unique id and device info if serial number is available. - if (serial_no := coordinator.data.serial_no) is not None: - self._attr_unique_id = f"{serial_no}_{description.key}" self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" self._attr_device_info = coordinator.device_info @property diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index e2c1af50cee..505543e0936 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -85,11 +85,16 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]): self._host = host self._port = port + @property + def unique_device_id(self) -> str: + """Return a unique ID of the device, which is the serial number (if available) or the config entry ID.""" + return self.data.serial_no or self.config_entry.entry_id + @property def device_info(self) -> DeviceInfo: """Return the DeviceInfo of this APC UPS, if serial number is available.""" return DeviceInfo( - identifiers={(DOMAIN, self.data.serial_no or self.config_entry.entry_id)}, + identifiers={(DOMAIN, self.unique_device_id)}, model=self.data.model, manufacturer="APC", name=self.data.name or "APC UPS", @@ -108,4 +113,7 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]): data = await aioapcaccess.request_status(self._host, self._port) return APCUPSdData(data) except (OSError, asyncio.IncompleteReadError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from error diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 02016efa4ca..a3faf6b0268 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -458,11 +458,8 @@ class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity): """Initialize the sensor.""" super().__init__(coordinator=coordinator, context=description.key.upper()) - # Set up unique id and device info if serial number is available. - if (serial_no := coordinator.data.serial_no) is not None: - self._attr_unique_id = f"{serial_no}_{description.key}" - self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}" self._attr_device_info = coordinator.device_info # Initial update of attributes. diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index 93102ac1393..27a620491d1 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -57,7 +57,7 @@ "name": "Status date" }, "dip_switch_settings": { - "name": "Dip switch settings" + "name": "DIP switch settings" }, "low_battery_signal": { "name": "Low battery signal" @@ -93,7 +93,7 @@ "name": "Internal temperature" }, "last_self_test": { - "name": "Last self test" + "name": "Last self-test" }, "last_transfer": { "name": "Last transfer" @@ -177,7 +177,7 @@ "name": "Restore requirement" }, "self_test_result": { - "name": "Self test result" + "name": "Self-test result" }, "sensitivity": { "name": "Sensitivity" @@ -195,7 +195,7 @@ "name": "Status" }, "self_test_interval": { - "name": "Self test interval" + "name": "Self-test interval" }, "time_left": { "name": "Time left" @@ -219,5 +219,10 @@ "name": "Transfer to battery" } } + }, + "exceptions": { + "cannot_connect": { + "message": "Cannot connect to APC UPS Daemon." + } } } diff --git a/homeassistant/components/apollo_automation/__init__.py b/homeassistant/components/apollo_automation/__init__.py new file mode 100644 index 00000000000..7815b17818f --- /dev/null +++ b/homeassistant/components/apollo_automation/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Apollo Automation.""" diff --git a/homeassistant/components/apollo_automation/manifest.json b/homeassistant/components/apollo_automation/manifest.json new file mode 100644 index 00000000000..8e4c58f3f3d --- /dev/null +++ b/homeassistant/components/apollo_automation/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "apollo_automation", + "name": "Apollo Automation", + "integration_type": "virtual", + "supported_by": "esphome" +} diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 76c4681a30d..b026da33231 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -20,6 +20,7 @@ import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_IGNORE, + SOURCE_REAUTH, SOURCE_ZEROCONF, ConfigEntry, ConfigFlow, @@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): CONF_IDENTIFIERS: list(combined_identifiers), }, ) - if entry.source != SOURCE_IGNORE: + # Don't reload ignored entries or in the middle of reauth, + # e.g. if the user is entering a new PIN + if entry.source != SOURCE_IGNORE and self.source != SOURCE_REAUTH: self.hass.config_entries.async_schedule_reload(entry.entry_id) if not allow_exist: raise DeviceAlreadyConfigured diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index b68d74e6115..b6d451a9ea0 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -120,6 +120,7 @@ class AppleTvMediaPlayer( """Initialize the Apple TV media player.""" super().__init__(name, identifier, manager) self._playing: Playing | None = None + self._playing_last_updated: datetime | None = None self._app_list: dict[str, str] = {} @callback @@ -209,6 +210,7 @@ class AppleTvMediaPlayer( This is a callback function from pyatv.interface.PushListener. """ self._playing = playstatus + self._playing_last_updated = dt_util.utcnow() self.async_write_ha_state() @callback @@ -316,7 +318,7 @@ class AppleTvMediaPlayer( def media_position_updated_at(self) -> datetime | None: """Last valid time of media position.""" if self.state in {MediaPlayerState.PLAYING, MediaPlayerState.PAUSED}: - return dt_util.utcnow() + return self._playing_last_updated return None async def async_play_media( diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json index 577de8ae88d..b40460dd61b 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.7"] + "requirements": ["pyaprilaire==0.8.1"] } diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py index ca423055176..f7f1039b8a4 100644 --- a/homeassistant/components/apsystems/coordinator.py +++ b/homeassistant/components/apsystems/coordinator.py @@ -43,6 +43,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): config_entry: ApSystemsConfigEntry device_version: str + battery_system: bool def __init__( self, @@ -68,6 +69,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): self.api.max_power = device_info.maxPower self.api.min_power = device_info.minPower self.device_version = device_info.devVer + self.battery_system = device_info.isBatterySystem async def _async_update_data(self) -> ApSystemsSensorData: try: diff --git a/homeassistant/components/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json index a58530b05e2..eb1acb40d17 100644 --- a/homeassistant/components/apsystems/manifest.json +++ b/homeassistant/components/apsystems/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/apsystems", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["apsystems-ez1==2.4.0"] + "loggers": ["APsystemsEZ1"], + "requirements": ["apsystems-ez1==2.6.0"] } diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json index b3a10ca49a7..bdcd464ee9c 100644 --- a/homeassistant/components/apsystems/strings.json +++ b/homeassistant/components/apsystems/strings.json @@ -21,7 +21,7 @@ "entity": { "binary_sensor": { "off_grid_status": { - "name": "Off grid status" + "name": "Off-grid status" }, "dc_1_short_circuit_error_status": { "name": "DC 1 short circuit error status" diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py index e1017f95448..5451f2885fe 100644 --- a/homeassistant/components/apsystems/switch.py +++ b/homeassistant/components/apsystems/switch.py @@ -36,6 +36,8 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity): super().__init__(data) self._api = data.coordinator.api self._attr_unique_id = f"{data.device_id}_inverter_status" + if data.coordinator.battery_system: + self._attr_available = False async def async_update(self) -> None: """Update switch status and availability.""" diff --git a/homeassistant/components/aquacell/config_flow.py b/homeassistant/components/aquacell/config_flow.py index 1ee89035d93..277cb742486 100644 --- a/homeassistant/components/aquacell/config_flow.py +++ b/homeassistant/components/aquacell/config_flow.py @@ -60,7 +60,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except AuthenticationFailed: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/aquacell/strings.json b/homeassistant/components/aquacell/strings.json index 53304d04804..e07adf3c199 100644 --- a/homeassistant/components/aquacell/strings.json +++ b/homeassistant/components/aquacell/strings.json @@ -36,9 +36,9 @@ "wi_fi_strength": { "name": "Wi-Fi strength", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } diff --git a/homeassistant/components/aranet/strings.json b/homeassistant/components/aranet/strings.json index f786f4b2d4d..bb2ea3b2887 100644 --- a/homeassistant/components/aranet/strings.json +++ b/homeassistant/components/aranet/strings.json @@ -26,7 +26,7 @@ "sensor": { "threshold": { "state": { - "error": "Error", + "error": "[%key:common::state::error%]", "green": "Green", "yellow": "Yellow", "red": "Red" diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index a31156bbba6..4cc4feed2d4 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -6,7 +6,11 @@ import logging from typing import Any from homeassistant.components import mqtt -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -98,6 +102,7 @@ def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | DEGREE, "mdi:compass", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), ] return None @@ -178,6 +183,7 @@ class ArwnSensor(SensorEntity): units: str, icon: str | None = None, device_class: SensorDeviceClass | None = None, + state_class: SensorStateClass | None = None, ) -> None: """Initialize the sensor.""" self.entity_id = _slug(name) @@ -188,6 +194,7 @@ class ArwnSensor(SensorEntity): self._attr_native_unit_of_measurement = units self._attr_icon = icon self._attr_device_class = device_class + self._attr_state_class = state_class def set_event(self, event: dict[str, Any]) -> None: """Update the sensor with the most recent event.""" diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 9a32821e3a0..59bd987d90e 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -117,7 +117,7 @@ async def async_pipeline_from_audio_stream( """ with chat_session.async_get_chat_session(hass, conversation_id) as session: pipeline_input = PipelineInput( - conversation_id=session.conversation_id, + session=session, device_id=device_id, stt_metadata=stt_metadata, stt_stream=stt_stream, diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 788a207b83a..a205db4e615 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -19,14 +19,7 @@ import wave import hass_nabucasa import voluptuous as vol -from homeassistant.components import ( - conversation, - media_source, - stt, - tts, - wake_word, - websocket_api, -) +from homeassistant.components import conversation, stt, tts, wake_word, websocket_api from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) @@ -96,6 +89,9 @@ ENGINE_LANGUAGE_PAIRS = ( ) KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN) +KEY_PIPELINE_CONVERSATION_DATA: HassKey[dict[str, PipelineConversationData]] = HassKey( + "pipeline_conversation_data" +) def validate_language(data: dict[str, Any]) -> Any: @@ -129,7 +125,7 @@ SAVE_DELAY = 10 @callback def _async_local_fallback_intent_filter(result: RecognizeResult) -> bool: """Filter out intents that are not local fallback.""" - return result.intent.name in (intent.INTENT_GET_STATE, intent.INTENT_NEVERMIND) + return result.intent.name in (intent.INTENT_GET_STATE) @callback @@ -566,8 +562,7 @@ class PipelineRun: id: str = field(default_factory=ulid_util.ulid_now) stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False) - tts_engine: str = field(init=False, repr=False) - tts_options: dict | None = field(init=False, default=None) + tts_stream: tts.ResultStream | None = field(init=False, default=None) wake_word_entity_id: str | None = field(init=False, default=None, repr=False) wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False, repr=False) @@ -590,6 +585,12 @@ class PipelineRun: _device_id: str | None = None """Optional device id set during run start.""" + _conversation_data: PipelineConversationData | None = None + """Data tied to the conversation ID.""" + + _intent_agent_only = False + """If request should only be handled by agent, ignoring sentence triggers and local processing.""" + def __post_init__(self) -> None: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language @@ -639,13 +640,19 @@ class PipelineRun: self._device_id = device_id self._start_debug_recording_thread() - data = { + data: dict[str, Any] = { "pipeline": self.pipeline.id, "language": self.language, "conversation_id": conversation_id, } if self.runner_data is not None: data["runner_data"] = self.runner_data + if self.tts_stream: + data["tts_output"] = { + "token": self.tts_stream.token, + "url": self.tts_stream.url, + "mime_type": self.tts_stream.content_type, + } self.process_event(PipelineEvent(PipelineEventType.RUN_START, data)) @@ -1007,19 +1014,36 @@ class PipelineRun: yield chunk.audio - async def prepare_recognize_intent(self) -> None: + async def prepare_recognize_intent(self, session: chat_session.ChatSession) -> None: """Prepare recognizing an intent.""" - agent_info = conversation.async_get_agent_info( - self.hass, - self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT, + self._conversation_data = async_get_pipeline_conversation_data( + self.hass, session ) - if agent_info is None: - engine = self.pipeline.conversation_engine or "default" - raise IntentRecognitionError( - code="intent-not-supported", - message=f"Intent recognition engine {engine} is not found", + if self._conversation_data.continue_conversation_agent is not None: + agent_info = conversation.async_get_agent_info( + self.hass, self._conversation_data.continue_conversation_agent ) + self._conversation_data.continue_conversation_agent = None + if agent_info is None: + raise IntentRecognitionError( + code="intent-agent-not-found", + message=f"Intent recognition engine {self._conversation_data.continue_conversation_agent} asked for follow-up but is no longer found", + ) + self._intent_agent_only = True + + else: + agent_info = conversation.async_get_agent_info( + self.hass, + self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT, + ) + + if agent_info is None: + engine = self.pipeline.conversation_engine or "default" + raise IntentRecognitionError( + code="intent-not-supported", + message=f"Intent recognition engine {engine} is not found", + ) self.intent_agent = agent_info.id @@ -1031,7 +1055,7 @@ class PipelineRun: conversation_extra_system_prompt: str | None, ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" - if self.intent_agent is None: + if self.intent_agent is None or self._conversation_data is None: raise RuntimeError("Recognize intent was not prepared") if self.pipeline.conversation_language == MATCH_ALL: @@ -1078,7 +1102,7 @@ class PipelineRun: agent_id = self.intent_agent processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT intent_response: intent.IntentResponse | None = None - if not processed_locally: + if not processed_locally and not self._intent_agent_only: # Sentence triggers override conversation agent if ( trigger_response_text @@ -1103,12 +1127,16 @@ class PipelineRun: ) & conversation.ConversationEntityFeature.CONTROL: intent_filter = _async_local_fallback_intent_filter - # Try local intents first, if preferred. - elif self.pipeline.prefer_local_intents and ( - intent_response := await conversation.async_handle_intents( - self.hass, - user_input, - intent_filter=intent_filter, + # Try local intents + if ( + intent_response is None + and self.pipeline.prefer_local_intents + and ( + intent_response := await conversation.async_handle_intents( + self.hass, + user_input, + intent_filter=intent_filter, + ) ) ): # Local intent matched @@ -1191,6 +1219,9 @@ class PipelineRun: ) ) + if conversation_result.continue_conversation: + self._conversation_data.continue_conversation_agent = agent_id + return speech async def prepare_text_to_speech(self) -> None: @@ -1213,36 +1244,31 @@ class PipelineRun: tts_options[tts.ATTR_PREFERRED_SAMPLE_BYTES] = SAMPLE_WIDTH try: - options_supported = await tts.async_support_options( - self.hass, - engine, - self.pipeline.tts_language, - tts_options, + self.tts_stream = tts.async_create_stream( + hass=self.hass, + engine=engine, + language=self.pipeline.tts_language, + options=tts_options, ) except HomeAssistantError as err: - raise TextToSpeechError( - code="tts-not-supported", - message=f"Text-to-speech engine '{engine}' not found", - ) from err - if not options_supported: raise TextToSpeechError( code="tts-not-supported", message=( f"Text-to-speech engine {engine} " - f"does not support language {self.pipeline.tts_language} or options {tts_options}" + f"does not support language {self.pipeline.tts_language} or options {tts_options}:" + f" {err}" ), - ) - - self.tts_engine = engine - self.tts_options = tts_options + ) from err async def text_to_speech(self, tts_input: str) -> None: """Run text-to-speech portion of pipeline.""" + assert self.tts_stream is not None + self.process_event( PipelineEvent( PipelineEventType.TTS_START, { - "engine": self.tts_engine, + "engine": self.tts_stream.engine, "language": self.pipeline.tts_language, "voice": self.pipeline.tts_voice, "tts_input": tts_input, @@ -1255,14 +1281,9 @@ class PipelineRun: tts_media_id = tts_generate_media_source_id( self.hass, tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, + engine=self.tts_stream.engine, + language=self.tts_stream.language, + options=self.tts_stream.options, ) except Exception as src_error: _LOGGER.exception("Unexpected error during text-to-speech") @@ -1271,10 +1292,13 @@ class PipelineRun: message="Unexpected error during text-to-speech", ) from src_error - _LOGGER.debug("TTS result %s", tts_media) + self.tts_stream.async_set_message(tts_input) + tts_output = { "media_id": tts_media_id, - **asdict(tts_media), + "token": self.tts_stream.token, + "url": self.tts_stream.url, + "mime_type": self.tts_stream.content_type, } self.process_event( @@ -1454,8 +1478,8 @@ class PipelineInput: run: PipelineRun - conversation_id: str - """Identifier for the conversation.""" + session: chat_session.ChatSession + """Session for the conversation.""" stt_metadata: stt.SpeechMetadata | None = None """Metadata of stt input audio. Required when start_stage = stt.""" @@ -1480,7 +1504,9 @@ class PipelineInput: async def execute(self) -> None: """Run pipeline.""" - self.run.start(conversation_id=self.conversation_id, device_id=self.device_id) + self.run.start( + conversation_id=self.session.conversation_id, device_id=self.device_id + ) current_stage: PipelineStage | None = self.run.start_stage stt_audio_buffer: list[EnhancedAudioChunk] = [] stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None @@ -1564,7 +1590,7 @@ class PipelineInput: assert intent_input is not None tts_input = await self.run.recognize_intent( intent_input, - self.conversation_id, + self.session.conversation_id, self.device_id, self.conversation_extra_system_prompt, ) @@ -1648,7 +1674,7 @@ class PipelineInput: <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT) <= end_stage_index ): - prepare_tasks.append(self.run.prepare_recognize_intent()) + prepare_tasks.append(self.run.prepare_recognize_intent(self.session)) if ( start_stage_index @@ -1927,7 +1953,7 @@ class PipelineRunDebug: class PipelineStore(Store[SerializedPipelineStorageCollection]): - """Store entity registry data.""" + """Store pipeline data.""" async def _async_migrate_func( self, @@ -2009,3 +2035,37 @@ async def async_run_migrations(hass: HomeAssistant) -> None: for pipeline, attr_updates in updates: await async_update_pipeline(hass, pipeline, **attr_updates) + + +@dataclass +class PipelineConversationData: + """Hold data for the duration of a conversation.""" + + continue_conversation_agent: str | None = None + """The agent that requested the conversation to be continued.""" + + +@callback +def async_get_pipeline_conversation_data( + hass: HomeAssistant, session: chat_session.ChatSession +) -> PipelineConversationData: + """Get the pipeline data for a specific conversation.""" + all_conversation_data = hass.data.get(KEY_PIPELINE_CONVERSATION_DATA) + if all_conversation_data is None: + all_conversation_data = {} + hass.data[KEY_PIPELINE_CONVERSATION_DATA] = all_conversation_data + + data = all_conversation_data.get(session.conversation_id) + + if data is not None: + return data + + @callback + def do_cleanup() -> None: + """Handle cleanup.""" + all_conversation_data.pop(session.conversation_id) + + session.async_on_cleanup(do_cleanup) + + data = all_conversation_data[session.conversation_id] = PipelineConversationData() + return data diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index d2d54a1b7c3..937b3a0ea45 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -239,7 +239,7 @@ async def websocket_run( with chat_session.async_get_chat_session( hass, msg.get("conversation_id") ) as session: - input_args["conversation_id"] = session.conversation_id + input_args["session"] = session pipeline_input = PipelineInput(**input_args) try: diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 038ff517264..3338f223bc9 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -1,9 +1,11 @@ """Base class for assist satellite entities.""" import logging +from pathlib import Path import voluptuous as vol +from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -15,6 +17,8 @@ from .const import ( CONNECTION_TEST_DATA, DATA_COMPONENT, DOMAIN, + PREANNOUNCE_FILENAME, + PREANNOUNCE_URL, AssistSatelliteEntityFeature, ) from .entity import ( @@ -56,6 +60,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("message"): str, vol.Optional("media_id"): str, + vol.Optional("preannounce"): bool, + vol.Optional("preannounce_media_id"): str, } ), cv.has_at_least_one_key("message", "media_id"), @@ -70,6 +76,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("start_message"): str, vol.Optional("start_media_id"): str, + vol.Optional("preannounce"): bool, + vol.Optional("preannounce_media_id"): str, vol.Optional("extra_system_prompt"): str, } ), @@ -82,6 +90,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) + # Default preannounce sound + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + PREANNOUNCE_URL, str(Path(__file__).parent / PREANNOUNCE_FILENAME) + ) + ] + ) + return True diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index f7ac7e524b4..7fca88f3b12 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -20,6 +20,9 @@ CONNECTION_TEST_DATA: HassKey[dict[str, asyncio.Event]] = HassKey( f"{DOMAIN}_connection_tests" ) +PREANNOUNCE_FILENAME = "preannounce.mp3" +PREANNOUNCE_URL = f"/api/assist_satellite/static/{PREANNOUNCE_FILENAME}" + class AssistSatelliteEntityFeature(IntFlag): """Supported features of Assist satellite entity.""" diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 8c63525294c..dc20c7650d7 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -23,15 +23,12 @@ from homeassistant.components.assist_pipeline import ( vad, ) from homeassistant.components.media_player import async_process_play_media_url -from homeassistant.components.tts import ( - generate_media_source_id as tts_generate_media_source_id, -) from homeassistant.core import Context, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, entity from homeassistant.helpers.entity import EntityDescription -from .const import AssistSatelliteEntityFeature +from .const import PREANNOUNCE_URL, AssistSatelliteEntityFeature from .errors import AssistSatelliteError, SatelliteBusyError _LOGGER = logging.getLogger(__name__) @@ -98,9 +95,15 @@ class AssistSatelliteAnnouncement: original_media_id: str """The raw media ID before processing.""" + tts_token: str | None + """The TTS token of the media.""" + media_id_source: Literal["url", "media_id", "tts"] """Source of the media ID.""" + preannounce_media_id: str | None = None + """Media ID to be played before announcement.""" + class AssistSatelliteEntity(entity.Entity): """Entity encapsulating the state and functionality of an Assist satellite.""" @@ -177,6 +180,8 @@ class AssistSatelliteEntity(entity.Entity): self, message: str | None = None, media_id: str | None = None, + preannounce: bool = True, + preannounce_media_id: str = PREANNOUNCE_URL, ) -> None: """Play and show an announcement on the satellite. @@ -186,6 +191,9 @@ class AssistSatelliteEntity(entity.Entity): If media_id is provided, it is played directly. It is possible to omit the message and the satellite will not show any text. + If preannounce is True, a sound is played before the announcement. + If preannounce_media_id is provided, it overrides the default sound. + Calls async_announce with message and media id. """ await self._cancel_running_pipeline() @@ -193,7 +201,11 @@ class AssistSatelliteEntity(entity.Entity): if message is None: message = "" - announcement = await self._resolve_announcement_media_id(message, media_id) + announcement = await self._resolve_announcement_media_id( + message, + media_id, + preannounce_media_id=preannounce_media_id if preannounce else None, + ) if self._is_announcing: raise SatelliteBusyError @@ -220,6 +232,8 @@ class AssistSatelliteEntity(entity.Entity): start_message: str | None = None, start_media_id: str | None = None, extra_system_prompt: str | None = None, + preannounce: bool = True, + preannounce_media_id: str = PREANNOUNCE_URL, ) -> None: """Start a conversation from the satellite. @@ -229,6 +243,9 @@ class AssistSatelliteEntity(entity.Entity): If start_media_id is provided, it is played directly. It is possible to omit the message and the satellite will not show any text. + If preannounce is True, a sound is played before the start message or media. + If preannounce_media_id is provided, it overrides the default sound. + Calls async_start_conversation. """ await self._cancel_running_pipeline() @@ -244,13 +261,17 @@ class AssistSatelliteEntity(entity.Entity): start_message = "" announcement = await self._resolve_announcement_media_id( - start_message, start_media_id + start_message, + start_media_id, + preannounce_media_id=preannounce_media_id if preannounce else None, ) if self._is_announcing: raise SatelliteBusyError self._is_announcing = True + self._set_state(AssistSatelliteState.RESPONDING) + # Provide our start info to the LLM so it understands context of incoming message if extra_system_prompt is not None: self._extra_system_prompt = extra_system_prompt @@ -280,6 +301,7 @@ class AssistSatelliteEntity(entity.Entity): raise finally: self._is_announcing = False + self._set_state(AssistSatelliteState.IDLE) async def async_start_conversation( self, start_announcement: AssistSatelliteAnnouncement @@ -470,20 +492,27 @@ class AssistSatelliteEntity(entity.Entity): return vad.VadSensitivity.to_seconds(vad_sensitivity) async def _resolve_announcement_media_id( - self, message: str, media_id: str | None + self, + message: str, + media_id: str | None, + preannounce_media_id: str | None = None, ) -> AssistSatelliteAnnouncement: """Resolve the media ID.""" media_id_source: Literal["url", "media_id", "tts"] | None = None + tts_token: str | 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) + engine = tts.async_resolve_engine(self.hass, pipeline.tts_engine) + if engine is None: + raise HomeAssistantError(f"TTS engine {pipeline.tts_engine} not found") + tts_options: dict[str, Any] = {} if pipeline.tts_voice is not None: tts_options[tts.ATTR_VOICE] = pipeline.tts_voice @@ -491,14 +520,23 @@ class AssistSatelliteEntity(entity.Entity): if self.tts_options is not None: tts_options.update(self.tts_options) - media_id = tts_generate_media_source_id( + stream = tts.async_create_stream( self.hass, - message, - engine=pipeline.tts_engine, + engine=engine, + language=pipeline.tts_language, + options=tts_options, + ) + stream.async_set_message(message) + + tts_token = stream.token + media_id = stream.url + original_media_id = tts.generate_media_source_id( + self.hass, + message, + engine=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: @@ -516,6 +554,26 @@ class AssistSatelliteEntity(entity.Entity): # Resolve to full URL media_id = async_process_play_media_url(self.hass, media_id) + # Resolve preannounce media id + if preannounce_media_id: + if media_source.is_media_source_id(preannounce_media_id): + preannounce_media = await media_source.async_resolve_media( + self.hass, + preannounce_media_id, + None, + ) + preannounce_media_id = preannounce_media.url + + # Resolve to full URL + preannounce_media_id = async_process_play_media_url( + self.hass, preannounce_media_id + ) + return AssistSatelliteAnnouncement( - message, media_id, original_media_id, media_id_source + message=message, + media_id=media_id, + original_media_id=original_media_id, + tts_token=tts_token, + media_id_source=media_id_source, + preannounce_media_id=preannounce_media_id, ) diff --git a/homeassistant/components/assist_satellite/preannounce.mp3 b/homeassistant/components/assist_satellite/preannounce.mp3 new file mode 100644 index 00000000000..6e2fa0aba3e Binary files /dev/null and b/homeassistant/components/assist_satellite/preannounce.mp3 differ diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 89a20ada6f3..d88710c4c4e 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -8,12 +8,22 @@ announce: message: required: false example: "Time to wake up!" + default: "" selector: text: media_id: required: false selector: text: + preannounce: + required: false + default: true + selector: + boolean: + preannounce_media_id: + required: false + selector: + text: start_conversation: target: entity: @@ -24,6 +34,7 @@ start_conversation: start_message: required: false example: "You left the lights on in the living room. Turn them off?" + default: "" selector: text: start_media_id: @@ -34,3 +45,12 @@ start_conversation: required: false selector: text: + preannounce: + required: false + default: true + selector: + boolean: + preannounce_media_id: + required: false + selector: + text: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index fa2dc984ab7..b69711c7106 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -23,6 +23,14 @@ "media_id": { "name": "Media ID", "description": "The media ID to announce instead of using text-to-speech." + }, + "preannounce": { + "name": "Preannounce", + "description": "Play a sound before the announcement." + }, + "preannounce_media_id": { + "name": "Preannounce media ID", + "description": "Custom media ID to play before the announcement." } } }, @@ -41,6 +49,14 @@ "extra_system_prompt": { "name": "Extra system prompt", "description": "Provide background information to the AI about the request." + }, + "preannounce": { + "name": "Preannounce", + "description": "Play a sound before the start message or media." + }, + "preannounce_media_id": { + "name": "Preannounce media ID", + "description": "Custom media ID to play before the start message or media." } } } diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 4fc1708b866..6f8b3d723ad 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -198,7 +198,8 @@ async def websocket_test_connection( hass.async_create_background_task( satellite.async_internal_announce( - media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}" + media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}", + preannounce=False, ), f"assist_satellite_connection_test_{msg['entity_id']}", ) diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 330c4bcfb67..a34f191b7a7 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging -from types import MappingProxyType from typing import Any from pyasuswrt import AsusWrtError @@ -363,7 +362,7 @@ class AsusWrtRouter: """Add a function to call when router is closed.""" self._on_close.append(func) - def update_options(self, new_options: MappingProxyType[str, Any]) -> bool: + def update_options(self, new_options: Mapping[str, Any]) -> bool: """Update router options.""" req_reload = False for name, new_opt in new_options.items(): diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index 9d50f50c7e9..cac37c0cfd0 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -66,28 +66,28 @@ "name": "Upload" }, "load_avg_1m": { - "name": "Average load (1m)" + "name": "Average load (1 min)" }, "load_avg_5m": { - "name": "Average load (5m)" + "name": "Average load (5 min)" }, "load_avg_15m": { - "name": "Average load (15m)" + "name": "Average load (15 min)" }, "24ghz_temperature": { - "name": "2.4GHz Temperature" + "name": "2.4GHz temperature" }, "5ghz_temperature": { - "name": "5GHz Temperature" + "name": "5GHz temperature" }, "cpu_temperature": { - "name": "CPU Temperature" + "name": "CPU temperature" }, "5ghz_2_temperature": { - "name": "5GHz Temperature (Radio 2)" + "name": "5GHz temperature (Radio 2)" }, "6ghz_temperature": { - "name": "6GHz Temperature" + "name": "6GHz temperature" }, "cpu_usage": { "name": "CPU usage" diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 5e16a22af76..a6b2961c2a0 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.7"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.6.0"] } diff --git a/homeassistant/components/august/strings.json b/homeassistant/components/august/strings.json index e3c97535a55..fbc746e939e 100644 --- a/homeassistant/components/august/strings.json +++ b/homeassistant/components/august/strings.json @@ -18,7 +18,7 @@ }, "step": { "validation": { - "title": "Two factor authentication", + "title": "Two-factor authentication", "data": { "verification_code": "Verification code" }, diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index 6b28d9d8c1c..96e7ac7bcd7 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -4,8 +4,8 @@ "user": { "description": "The inverter must be connected via an RS485 adaptor, please select serial port and the inverter's address as configured on the LCD panel", "data": { - "port": "RS485 or USB-RS485 Adaptor Port", - "address": "Inverter Address" + "port": "RS485 or USB-RS485 adaptor port", + "address": "Inverter address" } } }, @@ -16,7 +16,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_serial_ports": "No com ports found. Need a valid RS485 device to communicate." + "no_serial_ports": "No com ports found. The integration needs a valid RS485 device to communicate." } }, "entity": { diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index c8622880f0f..b1e80d716d8 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -5,7 +5,7 @@ "step": { "init": { "title": "Set up two-factor authentication using TOTP", - "description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**." + "description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**." } }, "error": { @@ -13,7 +13,7 @@ } }, "notify": { - "title": "Notify One-Time Password", + "title": "Notify one-time password", "step": { "init": { "title": "Set up one-time password delivered by notify component", diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 856060f8c75..6243c11a791 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, ATTR_NAME, + CONF_ACTIONS, CONF_ALIAS, CONF_CONDITIONS, CONF_DEVICE_ID, @@ -27,6 +28,7 @@ from homeassistant.const import ( CONF_MODE, CONF_PATH, CONF_PLATFORM, + CONF_TRIGGERS, CONF_VARIABLES, CONF_ZONE, EVENT_HOMEASSISTANT_STARTED, @@ -86,11 +88,9 @@ from homeassistant.util.hass_dict import HassKey from .config import AutomationConfig, ValidationStatus from .const import ( - CONF_ACTIONS, CONF_INITIAL_STATE, CONF_TRACE, CONF_TRIGGER_VARIABLES, - CONF_TRIGGERS, DEFAULT_INITIAL_STATE, DOMAIN, LOGGER, diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index fe74865ca92..23ae10eea2b 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -14,11 +14,15 @@ from homeassistant.components import blueprint from homeassistant.components.trace import TRACE_CONFIG_SCHEMA from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( + CONF_ACTION, + CONF_ACTIONS, CONF_ALIAS, CONF_CONDITION, CONF_CONDITIONS, CONF_DESCRIPTION, CONF_ID, + CONF_TRIGGER, + CONF_TRIGGERS, CONF_VARIABLES, ) from homeassistant.core import HomeAssistant @@ -30,14 +34,10 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.yaml.input import UndefinedSubstitution from .const import ( - CONF_ACTION, - CONF_ACTIONS, CONF_HIDE_ENTITY, CONF_INITIAL_STATE, CONF_TRACE, - CONF_TRIGGER, CONF_TRIGGER_VARIABLES, - CONF_TRIGGERS, DOMAIN, LOGGER, ) @@ -58,34 +58,9 @@ _MINIMAL_PLATFORM_SCHEMA = vol.Schema( def _backward_compat_schema(value: Any | None) -> Any: """Backward compatibility for automations.""" - if not isinstance(value, dict): - return value - - # `trigger` has been renamed to `triggers` - if CONF_TRIGGER in value: - if CONF_TRIGGERS in value: - raise vol.Invalid( - "Cannot specify both 'trigger' and 'triggers'. Please use 'triggers' only." - ) - value[CONF_TRIGGERS] = value.pop(CONF_TRIGGER) - - # `condition` has been renamed to `conditions` - if CONF_CONDITION in value: - if CONF_CONDITIONS in value: - raise vol.Invalid( - "Cannot specify both 'condition' and 'conditions'. Please use 'conditions' only." - ) - value[CONF_CONDITIONS] = value.pop(CONF_CONDITION) - - # `action` has been renamed to `actions` - if CONF_ACTION in value: - if CONF_ACTIONS in value: - raise vol.Invalid( - "Cannot specify both 'action' and 'actions'. Please use 'actions' only." - ) - value[CONF_ACTIONS] = value.pop(CONF_ACTION) - - return value + value = cv.renamed(CONF_TRIGGER, CONF_TRIGGERS)(value) + value = cv.renamed(CONF_ACTION, CONF_ACTIONS)(value) + return cv.renamed(CONF_CONDITION, CONF_CONDITIONS)(value) PLATFORM_SCHEMA = vol.All( diff --git a/homeassistant/components/automation/const.py b/homeassistant/components/automation/const.py index c4ac636282e..f9d2fc1b77f 100644 --- a/homeassistant/components/automation/const.py +++ b/homeassistant/components/automation/const.py @@ -2,10 +2,6 @@ import logging -CONF_ACTION = "action" -CONF_ACTIONS = "actions" -CONF_TRIGGER = "trigger" -CONF_TRIGGERS = "triggers" CONF_TRIGGER_VARIABLES = "trigger_variables" DOMAIN = "automation" diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index 12149e4388a..92ae37c857b 100644 --- a/homeassistant/components/aws/manifest.json +++ b/homeassistant/components/aws/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["aiobotocore", "botocore"], "quality_scale": "legacy", - "requirements": ["aiobotocore==2.13.1", "botocore==1.34.131"] + "requirements": ["aiobotocore==2.21.1", "botocore==1.37.1"] } diff --git a/homeassistant/components/aws_s3/__init__.py b/homeassistant/components/aws_s3/__init__.py new file mode 100644 index 00000000000..b709595ae4a --- /dev/null +++ b/homeassistant/components/aws_s3/__init__.py @@ -0,0 +1,82 @@ +"""The AWS S3 integration.""" + +from __future__ import annotations + +import logging +from typing import cast + +from aiobotocore.client import AioBaseClient as S3Client +from aiobotocore.session import AioSession +from botocore.exceptions import ClientError, ConnectionError, ParamValidationError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import ( + CONF_ACCESS_KEY_ID, + CONF_BUCKET, + CONF_ENDPOINT_URL, + CONF_SECRET_ACCESS_KEY, + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) + +type S3ConfigEntry = ConfigEntry[S3Client] + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool: + """Set up S3 from a config entry.""" + + data = cast(dict, entry.data) + try: + session = AioSession() + # pylint: disable-next=unnecessary-dunder-call + client = await session.create_client( + "s3", + endpoint_url=data.get(CONF_ENDPOINT_URL), + aws_secret_access_key=data[CONF_SECRET_ACCESS_KEY], + aws_access_key_id=data[CONF_ACCESS_KEY_ID], + ).__aenter__() + await client.head_bucket(Bucket=data[CONF_BUCKET]) + except ClientError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_credentials", + ) from err + except ParamValidationError as err: + if "Invalid bucket name" in str(err): + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_bucket_name", + ) from err + except ValueError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_endpoint_url", + ) from err + except ConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + entry.runtime_data = client + + def notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(notify_backup_listeners)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool: + """Unload a config entry.""" + client = entry.runtime_data + await client.__aexit__(None, None, None) + return True diff --git a/homeassistant/components/aws_s3/backup.py b/homeassistant/components/aws_s3/backup.py new file mode 100644 index 00000000000..7ef1289132d --- /dev/null +++ b/homeassistant/components/aws_s3/backup.py @@ -0,0 +1,330 @@ +"""Backup platform for the AWS S3 integration.""" + +from collections.abc import AsyncIterator, Callable, Coroutine +import functools +import json +import logging +from time import time +from typing import Any + +from botocore.exceptions import BotoCoreError + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback + +from . import S3ConfigEntry +from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) +CACHE_TTL = 300 + +# S3 part size requirements: 5 MiB to 5 GiB per part +# https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html +# We set the threshold to 20 MiB to avoid too many parts. +# Note that each part is allocated in the memory. +MULTIPART_MIN_PART_SIZE_BYTES = 20 * 2**20 + + +def handle_boto_errors[T]( + func: Callable[..., Coroutine[Any, Any, T]], +) -> Callable[..., Coroutine[Any, Any, T]]: + """Handle BotoCoreError exceptions by converting them to BackupAgentError.""" + + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> T: + """Catch BotoCoreError and raise BackupAgentError.""" + try: + return await func(*args, **kwargs) + except BotoCoreError as err: + error_msg = f"Failed during {func.__name__}" + raise BackupAgentError(error_msg) from err + + return wrapper + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[S3ConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + return [S3BackupAgent(hass, entry) for entry in entries] + + +@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. + + :return: A function to unregister the listener. + """ + 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) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Return the suggested filenames for the backup and metadata files.""" + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return f"{base_name}.tar", f"{base_name}.metadata.json" + + +class S3BackupAgent(BackupAgent): + """Backup agent for the S3 integration.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: S3ConfigEntry) -> None: + """Initialize the S3 agent.""" + super().__init__() + self._client = entry.runtime_data + self._bucket: str = entry.data[CONF_BUCKET] + self.name = entry.title + self.unique_id = entry.entry_id + self._backup_cache: dict[str, AgentBackup] = {} + self._cache_expiration = time() + + @handle_boto_errors + 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. + """ + backup = await self._find_backup_by_id(backup_id) + tar_filename, _ = suggested_filenames(backup) + + response = await self._client.get_object(Bucket=self._bucket, Key=tar_filename) + return response["Body"].iter_chunks() + + 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. + """ + tar_filename, metadata_filename = suggested_filenames(backup) + + try: + if backup.size < MULTIPART_MIN_PART_SIZE_BYTES: + await self._upload_simple(tar_filename, open_stream) + else: + await self._upload_multipart(tar_filename, open_stream) + + # Upload the metadata file + metadata_content = json.dumps(backup.as_dict()) + await self._client.put_object( + Bucket=self._bucket, + Key=metadata_filename, + Body=metadata_content, + ) + except BotoCoreError as err: + raise BackupAgentError("Failed to upload backup") from err + else: + # Reset cache after successful upload + self._cache_expiration = time() + + async def _upload_simple( + self, + tar_filename: str, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + ) -> None: + """Upload a small file using simple upload. + + :param tar_filename: The target filename for the backup. + :param open_stream: A function returning an async iterator that yields bytes. + """ + _LOGGER.debug("Starting simple upload for %s", tar_filename) + stream = await open_stream() + file_data = bytearray() + async for chunk in stream: + file_data.extend(chunk) + + await self._client.put_object( + Bucket=self._bucket, + Key=tar_filename, + Body=bytes(file_data), + ) + + async def _upload_multipart( + self, + tar_filename: str, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + ): + """Upload a large file using multipart upload. + + :param tar_filename: The target filename for the backup. + :param open_stream: A function returning an async iterator that yields bytes. + """ + _LOGGER.debug("Starting multipart upload for %s", tar_filename) + multipart_upload = await self._client.create_multipart_upload( + Bucket=self._bucket, + Key=tar_filename, + ) + upload_id = multipart_upload["UploadId"] + try: + parts = [] + part_number = 1 + buffer_size = 0 # bytes + buffer: list[bytes] = [] + + stream = await open_stream() + async for chunk in stream: + buffer_size += len(chunk) + buffer.append(chunk) + + # If buffer size meets minimum part size, upload it as a part + if buffer_size >= MULTIPART_MIN_PART_SIZE_BYTES: + _LOGGER.debug( + "Uploading part number %d, size %d", part_number, buffer_size + ) + part = await self._client.upload_part( + Bucket=self._bucket, + Key=tar_filename, + PartNumber=part_number, + UploadId=upload_id, + Body=b"".join(buffer), + ) + parts.append({"PartNumber": part_number, "ETag": part["ETag"]}) + part_number += 1 + buffer_size = 0 + buffer = [] + + # Upload the final buffer as the last part (no minimum size requirement) + if buffer: + _LOGGER.debug( + "Uploading final part number %d, size %d", part_number, buffer_size + ) + part = await self._client.upload_part( + Bucket=self._bucket, + Key=tar_filename, + PartNumber=part_number, + UploadId=upload_id, + Body=b"".join(buffer), + ) + parts.append({"PartNumber": part_number, "ETag": part["ETag"]}) + + await self._client.complete_multipart_upload( + Bucket=self._bucket, + Key=tar_filename, + UploadId=upload_id, + MultipartUpload={"Parts": parts}, + ) + + except BotoCoreError: + try: + await self._client.abort_multipart_upload( + Bucket=self._bucket, + Key=tar_filename, + UploadId=upload_id, + ) + except BotoCoreError: + _LOGGER.exception("Failed to abort multipart upload") + raise + + @handle_boto_errors + 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. + """ + backup = await self._find_backup_by_id(backup_id) + tar_filename, metadata_filename = suggested_filenames(backup) + + # Delete both the backup file and its metadata file + await self._client.delete_object(Bucket=self._bucket, Key=tar_filename) + await self._client.delete_object(Bucket=self._bucket, Key=metadata_filename) + + # Reset cache after successful deletion + self._cache_expiration = time() + + @handle_boto_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups = await self._list_backups() + return list(backups.values()) + + @handle_boto_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup: + """Return a backup.""" + return await self._find_backup_by_id(backup_id) + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup: + """Find a backup by its backup ID.""" + backups = await self._list_backups() + if backup := backups.get(backup_id): + return backup + + raise BackupNotFound(f"Backup {backup_id} not found") + + async def _list_backups(self) -> dict[str, AgentBackup]: + """List backups, using a cache if possible.""" + if time() <= self._cache_expiration: + return self._backup_cache + + backups = {} + response = await self._client.list_objects_v2(Bucket=self._bucket) + + # Filter for metadata files only + metadata_files = [ + obj + for obj in response.get("Contents", []) + if obj["Key"].endswith(".metadata.json") + ] + + for metadata_file in metadata_files: + try: + # Download and parse metadata file + metadata_response = await self._client.get_object( + Bucket=self._bucket, Key=metadata_file["Key"] + ) + metadata_content = await metadata_response["Body"].read() + metadata_json = json.loads(metadata_content) + except (BotoCoreError, json.JSONDecodeError) as err: + _LOGGER.warning( + "Failed to process metadata file %s: %s", + metadata_file["Key"], + err, + ) + continue + backup = AgentBackup.from_dict(metadata_json) + backups[backup.backup_id] = backup + + self._backup_cache = backups + self._cache_expiration = time() + CACHE_TTL + + return self._backup_cache diff --git a/homeassistant/components/aws_s3/config_flow.py b/homeassistant/components/aws_s3/config_flow.py new file mode 100644 index 00000000000..a4de192e513 --- /dev/null +++ b/homeassistant/components/aws_s3/config_flow.py @@ -0,0 +1,101 @@ +"""Config flow for the AWS S3 integration.""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +from aiobotocore.session import AioSession +from botocore.exceptions import ClientError, ConnectionError, ParamValidationError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import ( + AWS_DOMAIN, + CONF_ACCESS_KEY_ID, + CONF_BUCKET, + CONF_ENDPOINT_URL, + CONF_SECRET_ACCESS_KEY, + DEFAULT_ENDPOINT_URL, + DESCRIPTION_AWS_S3_DOCS_URL, + DESCRIPTION_BOTO3_DOCS_URL, + DOMAIN, +) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ACCESS_KEY_ID): cv.string, + vol.Required(CONF_SECRET_ACCESS_KEY): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Required(CONF_BUCKET): cv.string, + vol.Required(CONF_ENDPOINT_URL, default=DEFAULT_ENDPOINT_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + } +) + + +class S3ConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match( + { + CONF_BUCKET: user_input[CONF_BUCKET], + CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL], + } + ) + + if not urlparse(user_input[CONF_ENDPOINT_URL]).hostname.endswith( + AWS_DOMAIN + ): + errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url" + else: + try: + session = AioSession() + async with session.create_client( + "s3", + endpoint_url=user_input.get(CONF_ENDPOINT_URL), + aws_secret_access_key=user_input[CONF_SECRET_ACCESS_KEY], + aws_access_key_id=user_input[CONF_ACCESS_KEY_ID], + ) as client: + await client.head_bucket(Bucket=user_input[CONF_BUCKET]) + except ClientError: + errors["base"] = "invalid_credentials" + except ParamValidationError as err: + if "Invalid bucket name" in str(err): + errors[CONF_BUCKET] = "invalid_bucket_name" + except ValueError: + errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url" + except ConnectionError: + errors[CONF_ENDPOINT_URL] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[CONF_BUCKET], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + description_placeholders={ + "aws_s3_docs_url": DESCRIPTION_AWS_S3_DOCS_URL, + "boto3_docs_url": DESCRIPTION_BOTO3_DOCS_URL, + }, + ) diff --git a/homeassistant/components/aws_s3/const.py b/homeassistant/components/aws_s3/const.py new file mode 100644 index 00000000000..a6863e6c38a --- /dev/null +++ b/homeassistant/components/aws_s3/const.py @@ -0,0 +1,23 @@ +"""Constants for the AWS S3 integration.""" + +from collections.abc import Callable +from typing import Final + +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "aws_s3" + +CONF_ACCESS_KEY_ID = "access_key_id" +CONF_SECRET_ACCESS_KEY = "secret_access_key" +CONF_ENDPOINT_URL = "endpoint_url" +CONF_BUCKET = "bucket" + +AWS_DOMAIN = "amazonaws.com" +DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/" + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) + +DESCRIPTION_AWS_S3_DOCS_URL = "https://docs.aws.amazon.com/general/latest/gr/s3.html" +DESCRIPTION_BOTO3_DOCS_URL = "https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html" diff --git a/homeassistant/components/aws_s3/manifest.json b/homeassistant/components/aws_s3/manifest.json new file mode 100644 index 00000000000..8ab65b5883a --- /dev/null +++ b/homeassistant/components/aws_s3/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "aws_s3", + "name": "AWS S3", + "codeowners": ["@tomasbedrich"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aws_s3", + "integration_type": "service", + "iot_class": "cloud_push", + "loggers": ["aiobotocore"], + "quality_scale": "bronze", + "requirements": ["aiobotocore==2.21.1"] +} diff --git a/homeassistant/components/aws_s3/quality_scale.yaml b/homeassistant/components/aws_s3/quality_scale.yaml new file mode 100644 index 00000000000..11093f4430f --- /dev/null +++ b/homeassistant/components/aws_s3/quality_scale.yaml @@ -0,0 +1,112 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: This 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: 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: + status: exempt + comment: This integration does not have entities. + has-entity-name: + status: exempt + comment: This integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom 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: + status: exempt + comment: This integration does not have entities. + integration-owner: done + log-when-unavailable: todo + parallel-updates: + status: exempt + comment: This integration does not poll. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: + status: exempt + comment: This integration does not have entities. + diagnostics: todo + discovery-update-info: + status: exempt + comment: S3 is a cloud service that is not discovered on the network. + discovery: + status: exempt + comment: S3 is a cloud service that is not discovered on the network. + docs-data-update: + status: exempt + comment: This integration does not poll. + docs-examples: + status: exempt + comment: The integration extends core functionality and does not require examples. + docs-known-limitations: + status: exempt + comment: No known limitations. + docs-supported-devices: + status: exempt + comment: This integration does not support physical devices. + docs-supported-functions: done + docs-troubleshooting: + status: exempt + comment: There are no more detailed troubleshooting instructions available than what is already included in strings.json. + docs-use-cases: done + dynamic-devices: + status: exempt + comment: This integration does not have devices. + entity-category: + status: exempt + comment: This integration does not have entities. + entity-device-class: + status: exempt + comment: This integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: This integration does not have entities. + entity-translations: + status: exempt + comment: This integration does not have entities. + exception-translations: done + icon-translations: + status: exempt + comment: This integration does not use icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: There are no issues which can be repaired. + stale-devices: + status: exempt + comment: This integration does not have devices. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/aws_s3/strings.json b/homeassistant/components/aws_s3/strings.json new file mode 100644 index 00000000000..84a7f68c850 --- /dev/null +++ b/homeassistant/components/aws_s3/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_key_id": "Access key ID", + "secret_access_key": "Secret access key", + "bucket": "Bucket name", + "endpoint_url": "Endpoint URL" + }, + "data_description": { + "access_key_id": "Access key ID to connect to AWS S3 API", + "secret_access_key": "Secret access key to connect to AWS S3 API", + "bucket": "Bucket must already exist and be writable by the provided credentials.", + "endpoint_url": "Endpoint URL provided to [Boto3 Session]({boto3_docs_url}). Region-specific [AWS S3 endpoints]({aws_s3_docs_url}) are available in their docs." + }, + "title": "Add AWS S3 bucket" + } + }, + "error": { + "cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]", + "invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]", + "invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]", + "invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "exceptions": { + "cannot_connect": { + "message": "Cannot connect to endpoint" + }, + "invalid_bucket_name": { + "message": "Invalid bucket name" + }, + "invalid_credentials": { + "message": "Bucket cannot be accessed using provided combination of access key ID and secret access key." + } + } +} diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 9f801882387..388e360040e 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Mapping from ipaddress import ip_address -from types import MappingProxyType from typing import Any from urllib.parse import urlsplit @@ -48,7 +47,7 @@ from .const import ( CONF_VIDEO_SOURCE, DEFAULT_STREAM_PROFILE, DEFAULT_VIDEO_SOURCE, - DOMAIN as AXIS_DOMAIN, + DOMAIN, ) from .errors import AuthenticationRequired, CannotConnect from .hub import AxisHub, get_axis_api @@ -59,7 +58,7 @@ DEFAULT_PROTOCOL = "https" PROTOCOL_CHOICES = ["https", "http"] -class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): +class AxisFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Axis config flow.""" VERSION = 3 @@ -88,7 +87,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): if user_input is not None: try: - api = await get_axis_api(self.hass, MappingProxyType(user_input)) + api = await get_axis_api(self.hass, user_input) except AuthenticationRequired: errors["base"] = "invalid_auth" @@ -147,7 +146,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): model = self.config[CONF_MODEL] same_model = [ entry.data[CONF_NAME] - for entry in self.hass.config_entries.async_entries(AXIS_DOMAIN) + for entry in self.hass.config_entries.async_entries(DOMAIN) if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model ] diff --git a/homeassistant/components/axis/entity.py b/homeassistant/components/axis/entity.py index b952000cca8..596d07de40f 100644 --- a/homeassistant/components/axis/entity.py +++ b/homeassistant/components/axis/entity.py @@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, EntityDescription -from .const import DOMAIN as AXIS_DOMAIN +from .const import DOMAIN if TYPE_CHECKING: from .hub import AxisHub @@ -61,7 +61,7 @@ class AxisEntity(Entity): self.hub = hub self._attr_device_info = DeviceInfo( - identifiers={(AXIS_DOMAIN, hub.unique_id)}, + identifiers={(DOMAIN, hub.unique_id)}, serial_number=hub.unique_id, ) diff --git a/homeassistant/components/axis/hub/api.py b/homeassistant/components/axis/hub/api.py index 8e5d7533631..f33e925929c 100644 --- a/homeassistant/components/axis/hub/api.py +++ b/homeassistant/components/axis/hub/api.py @@ -1,7 +1,7 @@ """Axis network device abstraction.""" from asyncio import timeout -from types import MappingProxyType +from collections.abc import Mapping from typing import Any import axis @@ -23,7 +23,7 @@ from ..errors import AuthenticationRequired, CannotConnect async def get_axis_api( hass: HomeAssistant, - config: MappingProxyType[str, Any], + config: Mapping[str, Any], ) -> axis.AxisDevice: """Create a Axis device API.""" session = get_async_client(hass, verify_ssl=False) diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json index f5fe5cd06a7..611a8b9a758 100644 --- a/homeassistant/components/azure_devops/strings.json +++ b/homeassistant/components/azure_devops/strings.json @@ -14,7 +14,7 @@ "personal_access_token": "Personal Access Token (PAT)" }, "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", - "title": "Add Azure DevOps Project" + "title": "Add Azure DevOps project" }, "reauth_confirm": { "data": { @@ -32,7 +32,7 @@ "entity": { "sensor": { "build_id": { - "name": "{definition_name} latest build id" + "name": "{definition_name} latest build ID" }, "finish_time": { "name": "{definition_name} latest build finish time" @@ -59,7 +59,7 @@ "name": "{definition_name} latest build start time" }, "url": { - "name": "{definition_name} latest build url" + "name": "{definition_name} latest build URL" }, "work_item_count": { "name": "{item_type} {item_state} work items" @@ -68,7 +68,7 @@ }, "exceptions": { "authentication_failed": { - "message": "Could not authorize with Azure DevOps for {title}. You will need to update your personal access token." + "message": "Could not authorize with Azure DevOps for {title}. You will need to update your Personal Access Token." } } } diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index abe6cdfe15f..6a035e664d4 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -3,11 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime import json import logging -from types import MappingProxyType from typing import Any from azure.eventhub import EventData, EventDataBatch @@ -179,7 +178,7 @@ class AzureEventHub: await self.async_send(None) await self._queue.join() - def update_options(self, new_options: MappingProxyType[str, Any]) -> None: + def update_options(self, new_options: Mapping[str, Any]) -> None: """Update options.""" self._send_interval = new_options[CONF_SEND_INTERVAL] diff --git a/homeassistant/components/azure_storage/__init__.py b/homeassistant/components/azure_storage/__init__.py index f22e7b70c12..00e419fd3c9 100644 --- a/homeassistant/components/azure_storage/__init__.py +++ b/homeassistant/components/azure_storage/__init__.py @@ -39,11 +39,20 @@ async def async_setup_entry( session = async_create_clientsession( hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60) ) - container_client = ContainerClient( - account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", - container_name=entry.data[CONF_CONTAINER_NAME], - credential=entry.data[CONF_STORAGE_ACCOUNT_KEY], - transport=AioHttpTransport(session=session), + + def create_container_client() -> ContainerClient: + """Create a ContainerClient.""" + + return ContainerClient( + account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", + container_name=entry.data[CONF_CONTAINER_NAME], + credential=entry.data[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=session), + ) + + # has a blocking call to open in cpython + container_client: ContainerClient = await hass.async_add_executor_job( + create_container_client ) try: diff --git a/homeassistant/components/azure_storage/backup.py b/homeassistant/components/azure_storage/backup.py index 6f39295761d..4a9254213dc 100644 --- a/homeassistant/components/azure_storage/backup.py +++ b/homeassistant/components/azure_storage/backup.py @@ -141,7 +141,7 @@ class AzureStorageBackupAgent(BackupAgent): """Delete a backup file.""" blob = await self._find_blob_by_backup_id(backup_id) if blob is None: - return + raise BackupNotFound(f"Backup {backup_id} not found") await self._client.delete_blob(blob.name) @handle_backup_errors @@ -163,11 +163,11 @@ class AzureStorageBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" blob = await self._find_blob_by_backup_id(backup_id) if blob is None: - return None + raise BackupNotFound(f"Backup {backup_id} not found") return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"])) @@ -175,7 +175,8 @@ class AzureStorageBackupAgent(BackupAgent): """Find a blob by backup id.""" async for blob in self._client.list_blobs(include="metadata"): if ( - backup_id == blob.metadata.get("backup_id", "") + blob.metadata is not None + and backup_id == blob.metadata.get("backup_id", "") and blob.metadata.get("metadata_version") == METADATA_VERSION ): return blob diff --git a/homeassistant/components/azure_storage/config_flow.py b/homeassistant/components/azure_storage/config_flow.py index c98576af5d1..25bd39a6608 100644 --- a/homeassistant/components/azure_storage/config_flow.py +++ b/homeassistant/components/azure_storage/config_flow.py @@ -27,9 +27,25 @@ _LOGGER = logging.getLogger(__name__) class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for azure storage.""" - def get_account_url(self, account_name: str) -> str: - """Get the account URL.""" - return f"https://{account_name}.blob.core.windows.net/" + async def get_container_client( + self, account_name: str, container_name: str, storage_account_key: str + ) -> ContainerClient: + """Get the container client. + + ContainerClient has a blocking call to open in cpython + """ + + session = async_get_clientsession(self.hass) + + def create_container_client() -> ContainerClient: + return ContainerClient( + account_url=f"https://{account_name}.blob.core.windows.net/", + container_name=container_name, + credential=storage_account_key, + transport=AioHttpTransport(session=session), + ) + + return await self.hass.async_add_executor_job(create_container_client) async def validate_config( self, container_client: ContainerClient @@ -58,11 +74,10 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match( {CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]} ) - container_client = ContainerClient( - account_url=self.get_account_url(user_input[CONF_ACCOUNT_NAME]), + container_client = await self.get_container_client( + account_name=user_input[CONF_ACCOUNT_NAME], container_name=user_input[CONF_CONTAINER_NAME], - credential=user_input[CONF_STORAGE_ACCOUNT_KEY], - transport=AioHttpTransport(session=async_get_clientsession(self.hass)), + storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY], ) errors = await self.validate_config(container_client) @@ -99,12 +114,12 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() if user_input is not None: - container_client = ContainerClient( - account_url=self.get_account_url(reauth_entry.data[CONF_ACCOUNT_NAME]), + container_client = await self.get_container_client( + account_name=reauth_entry.data[CONF_ACCOUNT_NAME], container_name=reauth_entry.data[CONF_CONTAINER_NAME], - credential=user_input[CONF_STORAGE_ACCOUNT_KEY], - transport=AioHttpTransport(session=async_get_clientsession(self.hass)), + storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY], ) + errors = await self.validate_config(container_client) if not errors: return self.async_update_reload_and_abort( @@ -120,3 +135,38 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + container_client = await self.get_container_client( + account_name=reconfigure_entry.data[CONF_ACCOUNT_NAME], + container_name=user_input[CONF_CONTAINER_NAME], + storage_account_key=user_input[CONF_STORAGE_ACCOUNT_KEY], + ) + errors = await self.validate_config(container_client) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data={**reconfigure_entry.data, **user_input}, + ) + return self.async_show_form( + data_schema=vol.Schema( + { + vol.Required( + CONF_CONTAINER_NAME, + default=reconfigure_entry.data[CONF_CONTAINER_NAME], + ): str, + vol.Required( + CONF_STORAGE_ACCOUNT_KEY, + default=reconfigure_entry.data[CONF_STORAGE_ACCOUNT_KEY], + ): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/azure_storage/manifest.json b/homeassistant/components/azure_storage/manifest.json index 8f2d8aeaca7..729334f851d 100644 --- a/homeassistant/components/azure_storage/manifest.json +++ b/homeassistant/components/azure_storage/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["azure-storage-blob"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["azure-storage-blob==12.24.0"] } diff --git a/homeassistant/components/azure_storage/quality_scale.yaml b/homeassistant/components/azure_storage/quality_scale.yaml index 5b147dfe0e4..6199ba514a3 100644 --- a/homeassistant/components/azure_storage/quality_scale.yaml +++ b/homeassistant/components/azure_storage/quality_scale.yaml @@ -121,7 +121,7 @@ rules: status: exempt comment: | This integration does not have entities. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: done stale-devices: status: exempt diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json index 5d39b54b8db..e9053f113cc 100644 --- a/homeassistant/components/azure_storage/strings.json +++ b/homeassistant/components/azure_storage/strings.json @@ -29,11 +29,24 @@ }, "description": "Provide a new storage account key.", "title": "Reauthenticate Azure storage account" + }, + "reconfigure": { + "data": { + "container_name": "[%key:component::azure_storage::config::step::user::data::container_name%]", + "storage_account_key": "[%key:component::azure_storage::config::step::user::data::storage_account_key%]" + }, + "data_description": { + "container_name": "[%key:component::azure_storage::config::step::user::data_description::container_name%]", + "storage_account_key": "[%key:component::azure_storage::config::step::user::data_description::storage_account_key%]" + }, + "description": "Change the settings of the Azure storage integration.", + "title": "Reconfigure Azure storage account" } }, "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%]" } }, "issues": { diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index d9d1c3cc2fe..124ce8b872c 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -1,7 +1,9 @@ """The Backup integration.""" +from homeassistant.config_entries import SOURCE_SYSTEM +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType @@ -18,10 +20,12 @@ from .agent import ( ) from .config import BackupConfig, CreateBackupParametersDict from .const import DATA_MANAGER, DOMAIN +from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator from .http import async_register_http_views from .manager import ( BackupManager, BackupManagerError, + BackupPlatformEvent, BackupPlatformProtocol, BackupReaderWriter, BackupReaderWriterError, @@ -52,6 +56,7 @@ __all__ = [ "BackupConfig", "BackupManagerError", "BackupNotFound", + "BackupPlatformEvent", "BackupPlatformProtocol", "BackupReaderWriter", "BackupReaderWriterError", @@ -74,6 +79,8 @@ __all__ = [ "suggested_filename_from_name_date", ] +PLATFORMS = [Platform.SENSOR] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -128,4 +135,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_http_views(hass) + discovery_flow.async_create_flow( + hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool: + """Set up a config entry.""" + backup_manager: BackupManager = hass.data[DATA_MANAGER] + coordinator = BackupDataUpdateCoordinator(hass, entry, backup_manager) + await coordinator.async_config_entry_first_refresh() + + entry.async_on_unload(coordinator.async_unsubscribe) + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 9530f386c7b..8093ac88338 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -41,6 +41,8 @@ class BackupAgent(abc.ABC): ) -> AsyncIterator[bytes]: """Download a backup file. + Raises BackupNotFound if the backup does not exist. + :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ @@ -67,6 +69,8 @@ class BackupAgent(abc.ABC): ) -> None: """Delete a backup file. + Raises BackupNotFound if the backup does not exist. + :param backup_id: The ID of the backup that was returned in async_list_backups. """ @@ -79,8 +83,11 @@ class BackupAgent(abc.ABC): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: - """Return a backup.""" + ) -> AgentBackup: + """Return a backup. + + Raises BackupNotFound if the backup does not exist. + """ class LocalBackupAgent(BackupAgent): diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index c3a46a6ab1f..de2cfecb1a5 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -88,13 +88,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" if not self._loaded_backups: await self._load_backups() if backup_id not in self._backups: - return None + raise BackupNotFound(f"Backup {backup_id} not found") backup, backup_path = self._backups[backup_id] if not await self._hass.async_add_executor_job(backup_path.exists): @@ -107,7 +107,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): backup_path, ) self._backups.pop(backup_id) - return None + raise BackupNotFound(f"Backup {backup_id} not found") return backup @@ -130,10 +130,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): if not self._loaded_backups: await self._load_backups() - try: - backup_path = self.get_backup_path(backup_id) - except BackupNotFound: - 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 index f4fa2e8bac6..0c8a5c82f7c 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections import defaultdict from dataclasses import dataclass, field, replace import datetime as dt from datetime import datetime, timedelta @@ -87,12 +88,26 @@ class BackupConfigData: else: time = None days = [Day(day) for day in data["schedule"]["days"]] + agents = {} + for agent_id, agent_data in data["agents"].items(): + protected = agent_data["protected"] + stored_retention = agent_data["retention"] + agent_retention: AgentRetentionConfig | None + if stored_retention: + agent_retention = AgentRetentionConfig( + copies=stored_retention["copies"], + days=stored_retention["days"], + ) + else: + agent_retention = None + agent_config = AgentConfig( + protected=protected, + retention=agent_retention, + ) + agents[agent_id] = agent_config return cls( - agents={ - agent_id: AgentConfig(protected=agent_data["protected"]) - for agent_id, agent_data in data["agents"].items() - }, + agents=agents, automatic_backups_configured=data["automatic_backups_configured"], create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], @@ -176,12 +191,36 @@ class BackupConfig: """Update config.""" if agents is not UNDEFINED: for agent_id, agent_config in agents.items(): - if agent_id not in self.data.agents: - self.data.agents[agent_id] = AgentConfig(**agent_config) + agent_retention = agent_config.get("retention") + if agent_retention is None: + new_agent_retention = None else: - self.data.agents[agent_id] = replace( - self.data.agents[agent_id], **agent_config + new_agent_retention = AgentRetentionConfig( + copies=agent_retention.get("copies"), + days=agent_retention.get("days"), ) + if agent_id not in self.data.agents: + old_agent_retention = None + self.data.agents[agent_id] = AgentConfig( + protected=agent_config.get("protected", True), + retention=new_agent_retention, + ) + else: + new_agent_config = self.data.agents[agent_id] + old_agent_retention = new_agent_config.retention + if "protected" in agent_config: + new_agent_config = replace( + new_agent_config, protected=agent_config["protected"] + ) + if "retention" in agent_config: + new_agent_config = replace( + new_agent_config, retention=new_agent_retention + ) + self.data.agents[agent_id] = new_agent_config + if new_agent_retention != old_agent_retention: + # There's a single retention application method + # for both global and agent retention settings. + self.data.retention.apply(self._manager) if automatic_backups_configured is not UNDEFINED: self.data.automatic_backups_configured = automatic_backups_configured if create_backup is not UNDEFINED: @@ -207,11 +246,24 @@ class AgentConfig: """Represent the config for an agent.""" protected: bool + """Agent protected configuration. + + If True, the agent backups are password protected. + """ + retention: AgentRetentionConfig | None = None + """Agent retention configuration. + + If None, the global retention configuration is used. + If not None, the global retention configuration is ignored for this agent. + If an agent retention configuration is set and both copies and days are None, + backups will be kept forever for that agent. + """ def to_dict(self) -> StoredAgentConfig: """Convert agent config to a dict.""" return { "protected": self.protected, + "retention": self.retention.to_dict() if self.retention else None, } @@ -219,24 +271,46 @@ class StoredAgentConfig(TypedDict): """Represent the stored config for an agent.""" protected: bool + retention: StoredRetentionConfig | None class AgentParametersDict(TypedDict, total=False): """Represent the parameters for an agent.""" protected: bool + retention: RetentionParametersDict | None @dataclass(kw_only=True) -class RetentionConfig: - """Represent the backup retention configuration.""" +class BaseRetentionConfig: + """Represent the base backup retention configuration.""" copies: int | None = None days: int | None = None + def to_dict(self) -> StoredRetentionConfig: + """Convert backup retention configuration to a dict.""" + return StoredRetentionConfig( + copies=self.copies, + days=self.days, + ) + + +@dataclass(kw_only=True) +class RetentionConfig(BaseRetentionConfig): + """Represent the backup retention configuration.""" + def apply(self, manager: BackupManager) -> None: """Apply backup retention configuration.""" - if self.days is not None: + agents_retention = { + agent_id: agent_config.retention + for agent_id, agent_config in manager.config.data.agents.items() + } + + if self.days is not None or any( + agent_retention and agent_retention.days is not None + for agent_retention in agents_retention.values() + ): LOGGER.debug( "Scheduling next automatic delete of backups older than %s in 1 day", self.days, @@ -246,13 +320,6 @@ class RetentionConfig: 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, @@ -271,16 +338,81 @@ class RetentionConfig: """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 + agents_retention = { + agent_id: agent_config.retention + for agent_id, agent_config in manager.config.data.agents.items() } + has_agents_retention = any( + agent_retention for agent_retention in agents_retention.values() + ) + has_agents_retention_days = any( + agent_retention and agent_retention.days is not None + for agent_retention in agents_retention.values() + ) + if (global_days := self.days) is None and not has_agents_retention_days: + # No global retention days and no agent retention days + return {} + + now = dt_util.utcnow() + if global_days is not None and not has_agents_retention: + # Return early to avoid the longer filtering below. + return { + backup_id: backup + for backup_id, backup in backups.items() + if dt_util.parse_datetime(backup.date, raise_on_error=True) + + timedelta(days=global_days) + < now + } + + # If there are any agent retention settings, we need to check + # the retention settings, for every backup and agent combination. + + backups_to_delete = {} + + for backup_id, backup in backups.items(): + backup_date = dt_util.parse_datetime( + backup.date, raise_on_error=True + ) + delete_from_agents = set(backup.agents) + for agent_id in backup.agents: + agent_retention = agents_retention.get(agent_id) + if agent_retention is None: + # This agent does not have a retention setting, + # so the global retention setting should be used. + if global_days is None: + # This agent does not have a retention setting + # and the global retention days setting is None, + # so this backup should not be deleted. + delete_from_agents.discard(agent_id) + continue + days = global_days + elif (agent_days := agent_retention.days) is None: + # This agent has a retention setting + # where days is set to None, + # so the backup should not be deleted. + delete_from_agents.discard(agent_id) + continue + else: + # This agent has a retention setting + # where days is set to a number, + # so that setting should be used. + days = agent_days + if backup_date + timedelta(days=days) >= now: + # This backup is not older than the retention days, + # so this agent should not be deleted. + delete_from_agents.discard(agent_id) + + filtered_backup = replace( + backup, + agents={ + agent_id: agent_backup_status + for agent_id, agent_backup_status in backup.agents.items() + if agent_id in delete_from_agents + }, + ) + backups_to_delete[backup_id] = filtered_backup + + return backups_to_delete await manager.async_delete_filtered_backups( include_filter=_automatic_backups_filter, delete_filter=_delete_filter @@ -312,6 +444,10 @@ class RetentionParametersDict(TypedDict, total=False): days: int | None +class AgentRetentionConfig(BaseRetentionConfig): + """Represent an agent retention configuration.""" + + class StoredBackupSchedule(TypedDict): """Represent the stored backup schedule configuration.""" @@ -554,16 +690,87 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return oldest backups more numerous than copies to delete.""" + agents_retention = { + agent_id: agent_config.retention + for agent_id, agent_config in manager.config.data.agents.items() + } + has_agents_retention = any( + agent_retention for agent_retention in agents_retention.values() + ) + has_agents_retention_copies = any( + agent_retention and agent_retention.copies is not None + for agent_retention in agents_retention.values() + ) # we need to check here since we await before # this filter is applied - if manager.config.data.retention.copies is None: + if ( + global_copies := manager.config.data.retention.copies + ) is None and not has_agents_retention_copies: + # No global retention copies and no agent retention copies return {} - return dict( - sorted( - backups.items(), - key=lambda backup_item: backup_item[1].date, - )[: max(len(backups) - manager.config.data.retention.copies, 0)] + if global_copies is not None and not has_agents_retention: + # Return early to avoid the longer filtering below. + return dict( + sorted( + backups.items(), + key=lambda backup_item: backup_item[1].date, + )[: max(len(backups) - global_copies, 0)] + ) + + backups_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(dict) + for backup_id, backup in backups.items(): + for agent_id in backup.agents: + backups_by_agent[agent_id][backup_id] = backup + + backups_to_delete_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict( + dict ) + for agent_id, agent_backups in backups_by_agent.items(): + agent_retention = agents_retention.get(agent_id) + if agent_retention is None: + # This agent does not have a retention setting, + # so the global retention setting should be used. + if global_copies is None: + # This agent does not have a retention setting + # and the global retention copies setting is None, + # so backups should not be deleted. + continue + # The global retention setting will be used. + copies = global_copies + elif (agent_copies := agent_retention.copies) is None: + # This agent has a retention setting + # where copies is set to None, + # so backups should not be deleted. + continue + else: + # This agent retention setting will be used. + copies = agent_copies + + backups_to_delete_by_agent[agent_id] = dict( + sorted( + agent_backups.items(), + key=lambda backup_item: backup_item[1].date, + )[: max(len(agent_backups) - copies, 0)] + ) + + backup_ids_to_delete: dict[str, set[str]] = defaultdict(set) + for agent_id, to_delete in backups_to_delete_by_agent.items(): + for backup_id in to_delete: + backup_ids_to_delete[backup_id].add(agent_id) + backups_to_delete: dict[str, ManagerBackup] = {} + for backup_id, agent_ids in backup_ids_to_delete.items(): + backup = backups[backup_id] + # filter the backup to only include the agents that should be deleted + filtered_backup = replace( + backup, + agents={ + agent_id: agent_backup_status + for agent_id, agent_backup_status in backup.agents.items() + if agent_id in agent_ids + }, + ) + backups_to_delete[backup_id] = filtered_backup + return backups_to_delete await manager.async_delete_filtered_backups( include_filter=_automatic_backups_filter, delete_filter=_delete_filter diff --git a/homeassistant/components/backup/config_flow.py b/homeassistant/components/backup/config_flow.py new file mode 100644 index 00000000000..ab1f884ea86 --- /dev/null +++ b/homeassistant/components/backup/config_flow.py @@ -0,0 +1,21 @@ +"""Config flow for Home Assistant Backup integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import DOMAIN + + +class BackupConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Home Assistant Backup.""" + + VERSION = 1 + + async def async_step_system( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + return self.async_create_entry(title="Backup", data={}) diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index c2070a37b2d..773deaef174 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -16,8 +16,8 @@ DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN) LOGGER = getLogger(__package__) EXCLUDE_FROM_BACKUP = [ - "__pycache__/*", - ".DS_Store", + "**/__pycache__/*", + "**/.DS_Store", ".HA_RESTORE", "*.db-shm", "*.log.*", diff --git a/homeassistant/components/backup/coordinator.py b/homeassistant/components/backup/coordinator.py new file mode 100644 index 00000000000..dba05ba0225 --- /dev/null +++ b/homeassistant/components/backup/coordinator.py @@ -0,0 +1,83 @@ +"""Coordinator for Home Assistant Backup integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.backup import ( + async_subscribe_events, + async_subscribe_platform_events, +) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER +from .manager import ( + BackupManager, + BackupManagerState, + BackupPlatformEvent, + ManagerStateEvent, +) + +type BackupConfigEntry = ConfigEntry[BackupDataUpdateCoordinator] + + +@dataclass +class BackupCoordinatorData: + """Class to hold backup data.""" + + backup_manager_state: BackupManagerState + last_attempted_automatic_backup: datetime | None + last_successful_automatic_backup: datetime | None + next_scheduled_automatic_backup: datetime | None + + +class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]): + """Class to retrieve backup status.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + backup_manager: BackupManager, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=None, + ) + self.unsubscribe: list[Callable[[], None]] = [ + async_subscribe_events(hass, self._on_event), + async_subscribe_platform_events(hass, self._on_event), + ] + + self.backup_manager = backup_manager + + @callback + def _on_event(self, event: ManagerStateEvent | BackupPlatformEvent) -> None: + """Handle new event.""" + LOGGER.debug("Received backup event: %s", event) + self.config_entry.async_create_task(self.hass, self.async_refresh()) + + async def _async_update_data(self) -> BackupCoordinatorData: + """Update backup manager data.""" + return BackupCoordinatorData( + self.backup_manager.state, + self.backup_manager.config.data.last_attempted_automatic_backup, + self.backup_manager.config.data.last_completed_automatic_backup, + self.backup_manager.config.data.schedule.next_automatic_backup, + ) + + @callback + def async_unsubscribe(self) -> None: + """Unsubscribe from events.""" + for unsub in self.unsubscribe: + unsub() diff --git a/homeassistant/components/backup/diagnostics.py b/homeassistant/components/backup/diagnostics.py new file mode 100644 index 00000000000..9c3e28bde5b --- /dev/null +++ b/homeassistant/components/backup/diagnostics.py @@ -0,0 +1,27 @@ +"""Diagnostics support for Home Assistant Backup 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 + +from .coordinator import BackupConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: BackupConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + return { + "backup_agents": [ + {"name": agent.name, "agent_id": agent.agent_id} + for agent in coordinator.backup_manager.backup_agents.values() + ], + "backup_config": async_redact_data( + coordinator.backup_manager.config.data.to_dict(), [CONF_PASSWORD] + ), + } diff --git a/homeassistant/components/backup/entity.py b/homeassistant/components/backup/entity.py new file mode 100644 index 00000000000..ff7c7889dc5 --- /dev/null +++ b/homeassistant/components/backup/entity.py @@ -0,0 +1,36 @@ +"""Base for backup entities.""" + +from __future__ import annotations + +from homeassistant.const import __version__ as HA_VERSION +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import BackupDataUpdateCoordinator + + +class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]): + """Base entity for backup manager.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BackupDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = entity_description.key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, "backup_manager")}, + manufacturer="Home Assistant", + model="Home Assistant Backup", + sw_version=HA_VERSION, + name="Backup", + entry_type=DeviceEntryType.SERVICE, + configuration_url="homeassistant://config/backup", + ) diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 58f44d4a449..11d8199bdc5 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -15,13 +15,14 @@ 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.helpers import frame from homeassistant.util import slugify from . import util from .agent import BackupAgent from .const import DATA_MANAGER from .manager import BackupManager -from .models import BackupNotFound +from .models import AgentBackup, BackupNotFound @callback @@ -59,11 +60,19 @@ class DownloadBackupView(HomeAssistantView): 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) + try: + backup = await agent.async_get_backup(backup_id) + except BackupNotFound: + return Response(status=HTTPStatus.NOT_FOUND) - # We don't need to check if the path exists, aiohttp.FileResponse will handle - # that - if backup is None: + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 + if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) return Response(status=HTTPStatus.NOT_FOUND) headers = { @@ -76,7 +85,15 @@ class DownloadBackupView(HomeAssistantView): 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 + hass, + backup, + request, + headers, + backup_id, + agent_id, + password, + agent, + manager, ) except BackupNotFound: return Response(status=HTTPStatus.NOT_FOUND) @@ -92,6 +109,8 @@ class DownloadBackupView(HomeAssistantView): ) -> StreamResponse | FileResponse | Response: if agent_id in manager.local_backup_agents: local_agent = manager.local_backup_agents[agent_id] + # We don't need to check if the path exists, aiohttp.FileResponse will + # handle that path = local_agent.get_backup_path(backup_id) return FileResponse(path=path.as_posix(), headers=headers) @@ -105,6 +124,7 @@ class DownloadBackupView(HomeAssistantView): async def _send_backup_with_password( self, hass: HomeAssistant, + backup: AgentBackup, request: Request, headers: dict[istr, str], backup_id: str, @@ -133,7 +153,8 @@ class DownloadBackupView(HomeAssistantView): stream = util.AsyncIteratorWriter(hass) worker = threading.Thread( - target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []] + target=util.decrypt_backup, + args=[backup, reader, stream, password, on_done, 0, []], ) try: worker.start() diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 317de85b823..43a7be6db8d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -14,6 +14,7 @@ from itertools import chain import json from pathlib import Path, PurePath import shutil +import sys import tarfile import time from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast @@ -29,6 +30,7 @@ from homeassistant.backup_restore import ( from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( + frame, instance_id, integration_platform, issue_registry as ir, @@ -63,6 +65,7 @@ from .models import ( AgentBackup, BackupError, BackupManagerError, + BackupNotFound, BackupReaderWriterError, BaseBackup, Folder, @@ -117,6 +120,7 @@ class BackupManagerState(StrEnum): IDLE = "idle" CREATE_BACKUP = "create_backup" + BLOCKED = "blocked" RECEIVE_BACKUP = "receive_backup" RESTORE_BACKUP = "restore_backup" @@ -225,6 +229,20 @@ class RestoreBackupEvent(ManagerStateEvent): state: RestoreBackupState +@dataclass(frozen=True, kw_only=True, slots=True) +class BackupPlatformEvent: + """Backup platform class.""" + + domain: str + + +@dataclass(frozen=True, kw_only=True, slots=True) +class BlockedEvent(ManagerStateEvent): + """Backup manager blocked, Home Assistant is starting.""" + + manager_state: BackupManagerState = BackupManagerState.BLOCKED + + class BackupPlatformProtocol(Protocol): """Define the format that backup platforms can have.""" @@ -308,6 +326,12 @@ class DecryptOnDowloadNotSupported(BackupManagerError): _message = "On-the-fly decryption is not supported for this backup." +class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup): + """Raised when multiple exceptions occur.""" + + error_code = "multiple_errors" + + class BackupManager: """Define the format that backup managers can have.""" @@ -333,11 +357,14 @@ class BackupManager: self.remove_next_delete_event: Callable[[], None] | None = None # Latest backup event and backup event subscribers - self.last_event: ManagerStateEvent = IdleEvent() - self.last_non_idle_event: ManagerStateEvent | None = None + self.last_event: ManagerStateEvent = BlockedEvent() + self.last_action_event: ManagerStateEvent | None = None self._backup_event_subscriptions = hass.data[ DATA_BACKUP ].backup_event_subscriptions + self._backup_platform_event_subscriptions = hass.data[ + DATA_BACKUP + ].backup_platform_event_subscriptions async def async_setup(self) -> None: """Set up the backup manager.""" @@ -347,10 +374,19 @@ class BackupManager: self.known_backups.load(stored["backups"]) await self._reader_writer.async_validate_config(config=self.config) + await self._reader_writer.async_resume_restore_progress_after_restart( on_progress=self.async_on_backup_event ) + async def set_manager_idle_after_start(hass: HomeAssistant) -> None: + """Set manager to idle after start.""" + self.async_on_backup_event(IdleEvent()) + + if self.state == BackupManagerState.BLOCKED: + # If we're not finishing a restore job, set the manager to idle after start + start.async_at_started(self.hass, set_manager_idle_after_start) + await self.load_platforms() @property @@ -439,6 +475,9 @@ class BackupManager: 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)) + event = BackupPlatformEvent(domain=integration_domain) + for subscription in self._backup_platform_event_subscriptions: + subscription(event) async def async_pre_backup_actions(self) -> None: """Perform pre backup actions.""" @@ -641,6 +680,8 @@ class BackupManager: ) for idx, result in enumerate(get_backup_results): agent_id = agent_ids[idx] + if isinstance(result, BackupNotFound): + continue if isinstance(result, BackupAgentError): agent_errors[agent_id] = result continue @@ -652,7 +693,14 @@ class BackupManager: continue if isinstance(result, BaseException): raise result # unexpected error + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 if not result: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) continue if backup is None: if known_backup := self.known_backups.get(backup_id): @@ -716,6 +764,8 @@ class BackupManager: ) for idx, result in enumerate(delete_backup_results): agent_id = agent_ids[idx] + if isinstance(result, BackupNotFound): + continue if isinstance(result, BackupAgentError): agent_errors[agent_id] = result continue @@ -825,7 +875,7 @@ class BackupManager: agent_errors = { backup_id: error for backup_id, error in zip(backup_ids, delete_results, strict=True) - if error + if error and not isinstance(error, BackupNotFound) } if agent_errors: LOGGER.error( @@ -1257,7 +1307,20 @@ class BackupManager: ) -> None: """Initiate restoring a backup.""" agent = self.backup_agents[agent_id] - if not await agent.async_get_backup(backup_id): + try: + backup = await agent.async_get_backup(backup_id) + except BackupNotFound as err: + raise BackupManagerError( + f"Backup {backup_id} not found in agent {agent_id}" + ) from err + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 + if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) @@ -1286,8 +1349,8 @@ class BackupManager: if (current_state := self.state) != (new_state := event.manager_state): LOGGER.debug("Backup state: %s -> %s", current_state, new_state) self.last_event = event - if not isinstance(event, IdleEvent): - self.last_non_idle_event = event + if not isinstance(event, (BlockedEvent, IdleEvent)): + self.last_action_event = event for subscription in self._backup_event_subscriptions: subscription(event) @@ -1345,7 +1408,20 @@ class BackupManager: 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): + try: + backup = await agent.async_get_backup(backup_id) + except BackupNotFound as err: + raise BackupManagerError( + f"Backup {backup_id} not found in agent {agent_id}" + ) from err + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 + if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) @@ -1605,10 +1681,24 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) finally: # Inform integrations the backup is done + # If there's an unhandled exception, we keep it so we can rethrow it in case + # the post backup actions also fail. + unhandled_exc = sys.exception() try: - await manager.async_post_backup_actions() - except BackupManagerError as err: - raise BackupReaderWriterError(str(err)) from err + try: + await manager.async_post_backup_actions() + except BackupManagerError as err: + raise BackupReaderWriterError(str(err)) from err + except Exception as err: + if not unhandled_exc: + raise + # If there's an unhandled exception, we wrap both that and the exception + # from the post backup actions in an ExceptionGroup so the caller is + # aware of both exceptions. + raise BackupManagerExceptionGroup( + f"Multiple errors when creating backup: {unhandled_exc}, {err}", + [unhandled_exc, err], + ) from None def _mkdir_and_generate_backup_contents( self, @@ -1636,7 +1726,9 @@ class CoreBackupReaderWriter(BackupReaderWriter): """Filter to filter excludes.""" for exclude in excludes: - if not path.match(exclude): + # The home assistant core configuration directory is added as "data" + # in the tar file, so we need to prefix that path to the filters. + if not path.full_match(f"data/{exclude}"): continue LOGGER.debug("Ignoring %s because of %s", path, exclude) return True diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index db0719983b1..3c7b1e5e014 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -5,8 +5,9 @@ "codeowners": ["@home-assistant/core"], "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/backup", - "integration_type": "system", + "integration_type": "service", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2025.2.1"] + "requirements": ["cronsim==2.6", "securetar==2025.2.1"], + "single_config_entry": true } diff --git a/homeassistant/components/backup/onboarding.py b/homeassistant/components/backup/onboarding.py new file mode 100644 index 00000000000..ad7027c988c --- /dev/null +++ b/homeassistant/components/backup/onboarding.py @@ -0,0 +1,136 @@ +"""Backup onboarding views.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from functools import wraps +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, Concatenate + +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized +import voluptuous as vol + +from homeassistant.components.http import KEY_HASS +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.onboarding import ( + BaseOnboardingView, + NoAuthBaseOnboardingView, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager + +from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http + +if TYPE_CHECKING: + from homeassistant.components.onboarding import OnboardingStoreData + + +async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: + """Set up the backup views.""" + + hass.http.register_view(BackupInfoView(data)) + hass.http.register_view(RestoreBackupView(data)) + hass.http.register_view(UploadBackupView(data)) + + +def with_backup_manager[_ViewT: BaseOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, BackupManager, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and inject manager.""" + + @wraps(func) + async def with_backup( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check admin and call function.""" + if self._data["done"]: + raise HTTPUnauthorized + + manager = await async_get_backup_manager(request.app[KEY_HASS]) + return await func(self, manager, request, *args, **kwargs) + + return with_backup + + +class BackupInfoView(NoAuthBaseOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/backup/info" + name = "api:onboarding:backup:info" + + @with_backup_manager + async def get(self, manager: BackupManager, request: web.Request) -> web.Response: + """Return backup info.""" + backups, _ = await manager.async_get_backups() + return self.json( + { + "backups": list(backups.values()), + "state": manager.state, + "last_action_event": manager.last_action_event, + } + ) + + +class RestoreBackupView(NoAuthBaseOnboardingView): + """Restore backup view.""" + + url = "/api/onboarding/backup/restore" + name = "api:onboarding:backup:restore" + + @RequestDataValidator( + vol.Schema( + { + 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)], + } + ) + ) + @with_backup_manager + async def post( + self, manager: BackupManager, request: web.Request, data: dict[str, Any] + ) -> web.Response: + """Restore a backup.""" + try: + await manager.async_restore_backup( + data["backup_id"], + agent_id=data["agent_id"], + password=data.get("password"), + restore_addons=data.get("restore_addons"), + restore_database=data["restore_database"], + restore_folders=data.get("restore_folders"), + restore_homeassistant=True, + ) + except IncorrectPasswordError: + return self.json( + {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST + ) + except HomeAssistantError as err: + return self.json( + {"code": "restore_failed", "message": str(err)}, + status_code=HTTPStatus.BAD_REQUEST, + ) + return web.Response(status=HTTPStatus.OK) + + +class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView): + """Upload backup view.""" + + url = "/api/onboarding/backup/upload" + name = "api:onboarding:backup:upload" + + @with_backup_manager + async def post(self, manager: BackupManager, request: web.Request) -> web.Response: + """Upload a backup file.""" + return await self._post(request) diff --git a/homeassistant/components/backup/sensor.py b/homeassistant/components/backup/sensor.py new file mode 100644 index 00000000000..08e7ec49e3d --- /dev/null +++ b/homeassistant/components/backup/sensor.py @@ -0,0 +1,81 @@ +"""Sensor platform for Home Assistant Backup integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import BackupConfigEntry, BackupCoordinatorData +from .entity import BackupManagerEntity +from .manager import BackupManagerState + + +@dataclass(kw_only=True, frozen=True) +class BackupSensorEntityDescription(SensorEntityDescription): + """Description for Home Assistant Backup sensor entities.""" + + value_fn: Callable[[BackupCoordinatorData], str | datetime | None] + + +BACKUP_MANAGER_DESCRIPTIONS = ( + BackupSensorEntityDescription( + key="backup_manager_state", + translation_key="backup_manager_state", + device_class=SensorDeviceClass.ENUM, + options=[state.value for state in BackupManagerState], + value_fn=lambda data: data.backup_manager_state, + ), + BackupSensorEntityDescription( + key="next_scheduled_automatic_backup", + translation_key="next_scheduled_automatic_backup", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.next_scheduled_automatic_backup, + ), + BackupSensorEntityDescription( + key="last_successful_automatic_backup", + translation_key="last_successful_automatic_backup", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.last_successful_automatic_backup, + ), + BackupSensorEntityDescription( + key="last_attempted_automatic_backup", + translation_key="last_attempted_automatic_backup", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.last_attempted_automatic_backup, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BackupConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Sensor set up for backup config entry.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + BackupManagerSensor(coordinator, description) + for description in BACKUP_MANAGER_DESCRIPTIONS + ) + + +class BackupManagerSensor(BackupManagerEntity, SensorEntity): + """Sensor to track backup manager state.""" + + entity_description: BackupSensorEntityDescription + + @property + def native_value(self) -> str | datetime | None: + """Return native value of entity.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 883447853e6..6472f8ae151 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 5 +STORAGE_VERSION_MINOR = 6 class StoredBackupData(TypedDict): @@ -72,6 +72,10 @@ class _BackupStore(Store[StoredBackupData]): data["config"]["automatic_backups_configured"] = ( data["config"]["create_backup"]["password"] is not None ) + if old_minor_version < 6: + # Version 1.6 adds agent retention settings + for agent in data["config"]["agents"]: + data["config"]["agents"][agent]["retention"] = None # Note: We allow reading data with major version 2. # Reject if major version is higher than 2. diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index c3047d3a4ac..37adf9e9faf 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -22,5 +22,27 @@ "name": "Create automatic backup", "description": "Creates a new backup with automatic backup settings." } + }, + "entity": { + "sensor": { + "backup_manager_state": { + "name": "Backup Manager state", + "state": { + "idle": "[%key:common::state::idle%]", + "create_backup": "Creating a backup", + "receive_backup": "Receiving a backup", + "restore_backup": "Restoring a backup" + } + }, + "next_scheduled_automatic_backup": { + "name": "Next scheduled automatic backup" + }, + "last_attempted_automatic_backup": { + "name": "Last attempted automatic backup" + }, + "last_successful_automatic_backup": { + "name": "Last successful automatic backup" + } + } } } diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index bd77880738e..1a32c938a54 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -295,13 +295,26 @@ def validate_password_stream( raise BackupEmpty +def _get_expected_archives(backup: AgentBackup) -> set[str]: + """Get the expected archives in the backup.""" + expected_archives = set() + if backup.homeassistant_included: + expected_archives.add("homeassistant") + for addon in backup.addons: + expected_archives.add(addon.slug) + for folder in backup.folders: + expected_archives.add(folder.value) + return expected_archives + + def decrypt_backup( + backup: AgentBackup, input_stream: IO[bytes], output_stream: IO[bytes], password: str | None, on_done: Callable[[Exception | None], None], minimum_size: int, - nonces: list[bytes], + nonces: NonceGenerator, ) -> None: """Decrypt a backup.""" error: Exception | None = None @@ -315,10 +328,13 @@ def decrypt_backup( fileobj=output_stream, mode="w|", bufsize=BUF_SIZE ) as output_tar, ): - _decrypt_backup(input_tar, output_tar, password) + _decrypt_backup(backup, input_tar, output_tar, password) except (DecryptError, SecureTarError, tarfile.TarError) as err: LOGGER.warning("Error decrypting backup: %s", err) error = err + except Exception as err: # noqa: BLE001 + LOGGER.exception("Unexpected error when decrypting backup: %s", err) + error = err else: # Pad the output stream to the requested minimum size padding = max(minimum_size - output_stream.tell(), 0) @@ -333,15 +349,18 @@ def decrypt_backup( def _decrypt_backup( + backup: AgentBackup, input_tar: tarfile.TarFile, output_tar: tarfile.TarFile, password: str | None, ) -> None: """Decrypt a backup.""" + expected_archives = _get_expected_archives(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"): + object_path = PurePath(obj.name) + if object_path == PurePath("backup.json"): # Rewrite the backup.json file to indicate that the backup is decrypted if not (reader := input_tar.extractfile(obj)): raise DecryptError @@ -352,7 +371,13 @@ def _decrypt_backup( 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")): + prefix, _, suffix = object_path.name.partition(".") + if suffix not in ("tar", "tgz", "tar.gz"): + LOGGER.debug("Unknown file %s will not be decrypted", obj.name) + output_tar.addfile(obj, input_tar.extractfile(obj)) + continue + if prefix not in expected_archives: + LOGGER.debug("Unknown inner tar file %s will not be decrypted", obj.name) output_tar.addfile(obj, input_tar.extractfile(obj)) continue istf = SecureTarFile( @@ -371,12 +396,13 @@ def _decrypt_backup( def encrypt_backup( + backup: AgentBackup, input_stream: IO[bytes], output_stream: IO[bytes], password: str | None, on_done: Callable[[Exception | None], None], minimum_size: int, - nonces: list[bytes], + nonces: NonceGenerator, ) -> None: """Encrypt a backup.""" error: Exception | None = None @@ -390,10 +416,13 @@ def encrypt_backup( fileobj=output_stream, mode="w|", bufsize=BUF_SIZE ) as output_tar, ): - _encrypt_backup(input_tar, output_tar, password, nonces) + _encrypt_backup(backup, input_tar, output_tar, password, nonces) except (EncryptError, SecureTarError, tarfile.TarError) as err: LOGGER.warning("Error encrypting backup: %s", err) error = err + except Exception as err: # noqa: BLE001 + LOGGER.exception("Unexpected error when decrypting backup: %s", err) + error = err else: # Pad the output stream to the requested minimum size padding = max(minimum_size - output_stream.tell(), 0) @@ -408,17 +437,20 @@ def encrypt_backup( def _encrypt_backup( + backup: AgentBackup, input_tar: tarfile.TarFile, output_tar: tarfile.TarFile, password: str | None, - nonces: list[bytes], + nonces: NonceGenerator, ) -> None: """Encrypt a backup.""" inner_tar_idx = 0 + expected_archives = _get_expected_archives(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"): + object_path = PurePath(obj.name) + if object_path == PurePath("backup.json"): # Rewrite the backup.json file to indicate that the backup is encrypted if not (reader := input_tar.extractfile(obj)): raise EncryptError @@ -429,16 +461,21 @@ def _encrypt_backup( 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")): + prefix, _, suffix = object_path.name.partition(".") + if suffix not in ("tar", "tgz", "tar.gz"): + LOGGER.debug("Unknown file %s will not be encrypted", obj.name) output_tar.addfile(obj, input_tar.extractfile(obj)) continue + if prefix not in expected_archives: + LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name) + 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), - nonce=nonces[inner_tar_idx], + nonce=nonces.get(inner_tar_idx), ) inner_tar_idx += 1 with istf.encrypt(obj) as encrypted: @@ -456,17 +493,33 @@ class _CipherWorkerStatus: writer: AsyncIteratorWriter +class NonceGenerator: + """Generate nonces for encryption.""" + + def __init__(self) -> None: + """Initialize the generator.""" + self._nonces: dict[int, bytes] = {} + + def get(self, index: int) -> bytes: + """Get a nonce for the given index.""" + if index not in self._nonces: + # Generate a new nonce for the given index + self._nonces[index] = os.urandom(16) + return self._nonces[index] + + class _CipherBackupStreamer: """Encrypt or decrypt a backup.""" _cipher_func: Callable[ [ + AgentBackup, IO[bytes], IO[bytes], str | None, Callable[[Exception | None], None], int, - list[bytes], + NonceGenerator, ], None, ] @@ -484,7 +537,7 @@ class _CipherBackupStreamer: self._hass = hass self._open_stream = open_stream self._password = password - self._nonces: list[bytes] = [] + self._nonces = NonceGenerator() def size(self) -> int: """Return the maximum size of the decrypted or encrypted backup.""" @@ -508,7 +561,15 @@ class _CipherBackupStreamer: writer = AsyncIteratorWriter(self._hass) worker = threading.Thread( target=self._cipher_func, - args=[reader, writer, self._password, on_done, self.size(), self._nonces], + args=[ + self._backup, + reader, + writer, + self._password, + on_done, + self.size(), + self._nonces, + ], ) worker_status = _CipherWorkerStatus( done=asyncio.Event(), reader=reader, thread=worker, writer=writer @@ -538,17 +599,6 @@ class DecryptedBackupStreamer(_CipherBackupStreamer): class EncryptedBackupStreamer(_CipherBackupStreamer): """Encrypt a backup.""" - def __init__( - self, - hass: HomeAssistant, - backup: AgentBackup, - open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - password: str | None, - ) -> None: - """Initialize.""" - super().__init__(hass, backup, open_stream, password) - self._nonces = [os.urandom(16) for _ in range(self._num_tar_files())] - _cipher_func = staticmethod(encrypt_backup) def backup(self) -> AgentBackup: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 8b5f35287dd..080b5bb18a8 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -55,7 +55,7 @@ async def handle_info( "backups": list(backups.values()), "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, - "last_non_idle_event": manager.last_non_idle_event, + "last_action_event": manager.last_action_event, "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, "state": manager.state, @@ -346,7 +346,28 @@ async def handle_config_info( @websocket_api.websocket_command( { vol.Required("type"): "backup/config/update", - vol.Optional("agents"): vol.Schema({str: {"protected": bool}}), + vol.Optional("agents"): vol.Schema( + { + str: { + vol.Optional("protected"): bool, + vol.Optional("retention"): vol.Any( + vol.Schema( + { + # Note: We can't use cv.positive_int because it allows 0 even + # though 0 is not positive. + vol.Optional("copies"): vol.Any( + vol.All(int, vol.Range(min=1)), None + ), + vol.Optional("days"): vol.Any( + vol.All(int, vol.Range(min=1)), None + ), + }, + ), + None, + ), + } + } + ), vol.Optional("automatic_backups_configured"): bool, vol.Optional("create_backup"): vol.Schema( { diff --git a/homeassistant/components/baf/strings.json b/homeassistant/components/baf/strings.json index e2f02a6095e..629a3041df5 100644 --- a/homeassistant/components/baf/strings.json +++ b/homeassistant/components/baf/strings.json @@ -23,7 +23,7 @@ "entity": { "climate": { "auto_comfort": { - "name": "Auto comfort" + "name": "Auto Comfort" } }, "fan": { @@ -31,7 +31,7 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]" + "auto": "[%key:common::state::auto%]" } } } @@ -39,25 +39,25 @@ }, "number": { "comfort_min_speed": { - "name": "Auto Comfort Minimum Speed" + "name": "Auto Comfort minimum speed" }, "comfort_max_speed": { - "name": "Auto Comfort Maximum Speed" + "name": "Auto Comfort maximum speed" }, "comfort_heat_assist_speed": { - "name": "Auto Comfort Heat Assist Speed" + "name": "Auto Comfort Heat Assist speed" }, "return_to_auto_timeout": { - "name": "Return to Auto Timeout" + "name": "Return to Auto timeout" }, "motion_sense_timeout": { - "name": "Motion Sense Timeout" + "name": "Motion sense timeout" }, "light_return_to_auto_timeout": { - "name": "Light Return to Auto Timeout" + "name": "Light return to Auto timeout" }, "light_auto_motion_timeout": { - "name": "Light Motion Sense Timeout" + "name": "Light motion sense timeout" } }, "sensor": { @@ -76,10 +76,10 @@ }, "switch": { "legacy_ir_remote_enable": { - "name": "Legacy IR Remote" + "name": "Legacy IR remote" }, "led_indicators_enable": { - "name": "Led Indicators" + "name": "LED indicators" }, "comfort_heat_assist_enable": { "name": "Auto Comfort Heat Assist" @@ -88,10 +88,10 @@ "name": "Beep" }, "eco_enable": { - "name": "Eco Mode" + "name": "Eco mode" }, "motion_sense_enable": { - "name": "Motion Sense" + "name": "Motion sense" }, "return_to_auto_enable": { "name": "Return to Auto" @@ -103,7 +103,7 @@ "name": "Dim to Warm" }, "light_return_to_auto_enable": { - "name": "Light Return to Auto" + "name": "Light return to Auto" } } } diff --git a/homeassistant/components/balay/__init__.py b/homeassistant/components/balay/__init__.py new file mode 100644 index 00000000000..e7fa8bba86d --- /dev/null +++ b/homeassistant/components/balay/__init__.py @@ -0,0 +1 @@ +"""Balay virtual integration.""" diff --git a/homeassistant/components/balay/manifest.json b/homeassistant/components/balay/manifest.json new file mode 100644 index 00000000000..98e4f521c7a --- /dev/null +++ b/homeassistant/components/balay/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "balay", + "name": "Balay", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 207826d136e..54ae569bb78 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.EVENT, Platform.FAN, Platform.LIGHT, Platform.SELECT, @@ -28,7 +29,6 @@ PLATFORMS = [ Platform.TIME, ] - KEEP_ALIVE_INTERVAL = timedelta(minutes=1) SYNC_TIME_INTERVAL = timedelta(hours=1) diff --git a/homeassistant/components/balboa/event.py b/homeassistant/components/balboa/event.py new file mode 100644 index 00000000000..57263c34783 --- /dev/null +++ b/homeassistant/components/balboa/event.py @@ -0,0 +1,91 @@ +"""Support for Balboa events.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from pybalboa import EVENT_UPDATE, SpaClient + +from homeassistant.components.event import EventEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval + +from . import BalboaConfigEntry +from .entity import BalboaEntity + +FAULT = "fault" +FAULT_DATE = "fault_date" +REQUEST_FAULT_LOG_INTERVAL = timedelta(minutes=5) + +FAULT_MESSAGE_CODE_MAP: dict[int, str] = { + 15: "sensor_out_of_sync", + 16: "low_flow", + 17: "flow_failed", + 18: "settings_reset", + 19: "priming_mode", + 20: "clock_failed", + 21: "settings_reset", + 22: "memory_failure", + 26: "service_sensor_sync", + 27: "heater_dry", + 28: "heater_may_be_dry", + 29: "water_too_hot", + 30: "heater_too_hot", + 31: "sensor_a_fault", + 32: "sensor_b_fault", + 34: "pump_stuck", + 35: "hot_fault", + 36: "gfci_test_failed", + 37: "standby_mode", +} +FAULT_EVENT_TYPES = sorted(set(FAULT_MESSAGE_CODE_MAP.values())) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BalboaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the spa's events.""" + async_add_entities([BalboaEventEntity(entry.runtime_data)]) + + +class BalboaEventEntity(BalboaEntity, EventEntity): + """Representation of a Balboa event entity.""" + + _attr_event_types = FAULT_EVENT_TYPES + _attr_translation_key = FAULT + + def __init__(self, spa: SpaClient) -> None: + """Initialize a Balboa event entity.""" + super().__init__(spa, FAULT) + + @callback + def _async_handle_event(self) -> None: + """Handle the fault event.""" + if not (fault := self._client.fault): + return + fault_date = fault.fault_datetime.isoformat() + if self.state_attributes.get(FAULT_DATE) != fault_date: + self._trigger_event( + FAULT_MESSAGE_CODE_MAP.get(fault.message_code, fault.message), + {FAULT_DATE: fault_date, "code": fault.message_code}, + ) + self.async_write_ha_state() + + 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._client.on(EVENT_UPDATE, self._async_handle_event)) + + async def request_fault_log(now: datetime | None = None) -> None: + """Request the most recent fault log.""" + await self._client.request_fault_log() + + await request_fault_log() + self.async_on_remove( + async_track_time_interval( + self.hass, request_fault_log, REQUEST_FAULT_LOG_INTERVAL + ) + ) diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 9779984b182..8297e2e3b9f 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -57,6 +57,35 @@ } } }, + "event": { + "fault": { + "name": "Fault", + "state_attributes": { + "event_type": { + "state": { + "sensor_out_of_sync": "Sensors are out of sync", + "low_flow": "The water flow is low", + "flow_failed": "The water flow has failed", + "settings_reset": "The settings have been reset", + "priming_mode": "Priming mode", + "clock_failed": "The clock has failed", + "memory_failure": "Program memory failure", + "service_sensor_sync": "Sensors are out of sync -- call for service", + "heater_dry": "The heater is dry", + "heater_may_be_dry": "The heater may be dry", + "water_too_hot": "The water is too hot", + "heater_too_hot": "The heater is too hot", + "sensor_a_fault": "Sensor A fault", + "sensor_b_fault": "Sensor B fault", + "pump_stuck": "A pump may be stuck on", + "hot_fault": "Hot fault", + "gfci_test_failed": "The GFCI test failed", + "standby_mode": "Standby mode (hold mode)" + } + } + } + } + }, "fan": { "pump": { "name": "Pump {index}" @@ -74,8 +103,8 @@ "temperature_range": { "name": "Temperature range", "state": { - "low": "Low", - "high": "High" + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" } } }, diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 57ab828f9fb..422dc4be567 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -29,7 +29,7 @@ "description": "Manually configure your Bang & Olufsen device." }, "zeroconf_confirm": { - "title": "Setup Bang & Olufsen device", + "title": "Set up Bang & Olufsen device", "description": "Confirm the configuration of the {model}-{serial_number} @ {host}." } } @@ -197,11 +197,11 @@ "services": { "beolink_allstandby": { "name": "Beolink all standby", - "description": "Set all Connected Beolink devices to standby." + "description": "Sets all connected Beolink devices to standby." }, "beolink_expand": { "name": "Beolink expand", - "description": "Expand current Beolink experience.", + "description": "Adds devices to the current Beolink experience.", "fields": { "all_discovered": { "name": "All discovered", @@ -221,7 +221,7 @@ }, "beolink_join": { "name": "Beolink join", - "description": "Join a Beolink experience.", + "description": "Joins a Beolink experience.", "fields": { "beolink_jid": { "name": "Beolink JID", @@ -241,11 +241,11 @@ }, "beolink_leave": { "name": "Beolink leave", - "description": "Leave a Beolink experience." + "description": "Leaves a Beolink experience." }, "beolink_unexpand": { "name": "Beolink unexpand", - "description": "Unexpand from current Beolink experience.", + "description": "Removes devices from the current Beolink experience.", "fields": { "beolink_jids": { "name": "Beolink JIDs", @@ -274,7 +274,7 @@ "message": "An error occurred while attempting to play {media_type}: {error_message}." }, "invalid_grouping_entity": { - "message": "Entity with id: {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?" + "message": "Entity with ID {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?" }, "invalid_sound_mode": { "message": "{invalid_sound_mode} is an invalid sound mode. Valid values are: {valid_sound_modes}." diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index b86a6374f28..ea897ed1c49 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -124,15 +124,15 @@ "battery": { "name": "Battery", "state": { - "off": "Normal", - "on": "Low" + "off": "[%key:common::state::normal%]", + "on": "[%key:common::state::low%]" } }, "battery_charging": { "name": "Charging", "state": { "off": "Not charging", - "on": "Charging" + "on": "[%key:common::state::charging%]" } }, "carbon_monoxide": { @@ -145,7 +145,7 @@ "cold": { "name": "Cold", "state": { - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "off": "[%key:common::state::normal%]", "on": "Cold" } }, @@ -180,7 +180,7 @@ "heat": { "name": "Heat", "state": { - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "off": "[%key:common::state::normal%]", "on": "Hot" } }, diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 74f8ae1cb28..8f8df125aab 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Sign-in with Blink account", + "title": "Sign in with Blink account", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -30,7 +30,7 @@ "step": { "simple_options": { "data": { - "scan_interval": "Scan Interval (seconds)" + "scan_interval": "Scan interval (seconds)" }, "title": "Blink options", "description": "Configure Blink integration" @@ -93,7 +93,7 @@ }, "config_entry_id": { "name": "Integration ID", - "description": "The Blink Integration ID." + "description": "The Blink integration ID." } } } diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 6d0ccd7b6db..775ca16a12a 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -24,7 +24,7 @@ from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE type BlueCurrentConfigEntry = ConfigEntry[Connector] -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.SENSOR] CHARGE_POINTS = "CHARGE_POINTS" DATA = "data" DELAY = 5 diff --git a/homeassistant/components/blue_current/button.py b/homeassistant/components/blue_current/button.py new file mode 100644 index 00000000000..9d2cde547ca --- /dev/null +++ b/homeassistant/components/blue_current/button.py @@ -0,0 +1,89 @@ +"""Support for Blue Current buttons.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from bluecurrent_api.client import Client + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BlueCurrentConfigEntry, Connector +from .entity import ChargepointEntity + + +@dataclass(kw_only=True, frozen=True) +class ChargePointButtonEntityDescription(ButtonEntityDescription): + """Describes a Blue Current button entity.""" + + function: Callable[[Client, str], Coroutine[Any, Any, None]] + + +CHARGE_POINT_BUTTONS = ( + ChargePointButtonEntityDescription( + key="reset", + translation_key="reset", + function=lambda client, evse_id: client.reset(evse_id), + device_class=ButtonDeviceClass.RESTART, + ), + ChargePointButtonEntityDescription( + key="reboot", + translation_key="reboot", + function=lambda client, evse_id: client.reboot(evse_id), + device_class=ButtonDeviceClass.RESTART, + ), + ChargePointButtonEntityDescription( + key="stop_charge_session", + translation_key="stop_charge_session", + function=lambda client, evse_id: client.stop_session(evse_id), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BlueCurrentConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Blue Current buttons.""" + connector: Connector = entry.runtime_data + async_add_entities( + ChargePointButton( + connector, + button, + evse_id, + ) + for evse_id in connector.charge_points + for button in CHARGE_POINT_BUTTONS + ) + + +class ChargePointButton(ChargepointEntity, ButtonEntity): + """Define a charge point button.""" + + has_value = True + entity_description: ChargePointButtonEntityDescription + + def __init__( + self, + connector: Connector, + description: ChargePointButtonEntityDescription, + evse_id: str, + ) -> None: + """Initialize the button.""" + super().__init__(connector, evse_id) + + self.entity_description = description + self._attr_unique_id = f"{description.key}_{evse_id}" + + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.function(self.connector.client, self.evse_id) diff --git a/homeassistant/components/blue_current/entity.py b/homeassistant/components/blue_current/entity.py index cae7d420c99..426b7c06845 100644 --- a/homeassistant/components/blue_current/entity.py +++ b/homeassistant/components/blue_current/entity.py @@ -1,7 +1,5 @@ """Entity representing a Blue Current charge point.""" -from abc import abstractmethod - from homeassistant.const import ATTR_NAME from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -17,12 +15,12 @@ class BlueCurrentEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False + has_value = False def __init__(self, connector: Connector, signal: str) -> None: """Initialize the entity.""" self.connector = connector self.signal = signal - self.has_value = False async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -43,7 +41,6 @@ class BlueCurrentEntity(Entity): return self.connector.connected and self.has_value @callback - @abstractmethod def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json index b5a5f2be81e..ce936902e91 100644 --- a/homeassistant/components/blue_current/icons.json +++ b/homeassistant/components/blue_current/icons.json @@ -19,6 +19,17 @@ "current_left": { "default": "mdi:gauge" } + }, + "button": { + "reset": { + "default": "mdi:restart" + }, + "reboot": { + "default": "mdi:restart-alert" + }, + "stop_charge_session": { + "default": "mdi:stop" + } } } } diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 2e48d768a74..28eb20fa912 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -30,18 +30,18 @@ "available": "Available", "charging": "[%key:common::state::charging%]", "unavailable": "Unavailable", - "error": "Error", + "error": "[%key:common::state::error%]", "offline": "Offline" } }, "vehicle_status": { "name": "Vehicle status", "state": { - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "vehicle_detected": "Detected", "ready": "Ready", "no_power": "No power", - "vehicle_error": "Error" + "vehicle_error": "[%key:common::state::error%]" } }, "actual_v1": { @@ -113,6 +113,17 @@ "grid_max_current": { "name": "Max grid current" } + }, + "button": { + "stop_charge_session": { + "name": "Stop charge session" + }, + "reboot": { + "name": "Reboot" + }, + "reset": { + "name": "Reset" + } } } } diff --git a/homeassistant/components/bluemaestro/manifest.json b/homeassistant/components/bluemaestro/manifest.json index 8d2ff3b96f9..887b27239ef 100644 --- a/homeassistant/components/bluemaestro/manifest.json +++ b/homeassistant/components/bluemaestro/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bluemaestro", "iot_class": "local_push", - "requirements": ["bluemaestro-ble==0.2.3"] + "requirements": ["bluemaestro-ble==0.4.1"] } diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index 37e83ce2c47..d5dfbb4b582 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -21,6 +21,7 @@ from .coordinator import ( CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ + Platform.BUTTON, Platform.MEDIA_PLAYER, ] diff --git a/homeassistant/components/bluesound/button.py b/homeassistant/components/bluesound/button.py new file mode 100644 index 00000000000..4c9d363fa5f --- /dev/null +++ b/homeassistant/components/bluesound/button.py @@ -0,0 +1,128 @@ +"""Button entities for Bluesound.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pyblu import Player + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import BluesoundCoordinator +from .media_player import DEFAULT_PORT +from .utils import format_unique_id + +if TYPE_CHECKING: + from . import BluesoundConfigEntry + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BluesoundConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Bluesound entry.""" + + async_add_entities( + BluesoundButton( + config_entry.runtime_data.coordinator, + config_entry.runtime_data.player, + config_entry.data[CONF_PORT], + description, + ) + for description in BUTTON_DESCRIPTIONS + ) + + +@dataclass(kw_only=True, frozen=True) +class BluesoundButtonEntityDescription(ButtonEntityDescription): + """Description for Bluesound button entities.""" + + press_fn: Callable[[Player], Awaitable[None]] + + +async def clear_sleep_timer(player: Player) -> None: + """Clear the sleep timer.""" + sleep = -1 + while sleep != 0: + sleep = await player.sleep_timer() + + +async def set_sleep_timer(player: Player) -> None: + """Set the sleep timer.""" + await player.sleep_timer() + + +BUTTON_DESCRIPTIONS = [ + BluesoundButtonEntityDescription( + key="set_sleep_timer", + translation_key="set_sleep_timer", + entity_registry_enabled_default=False, + press_fn=set_sleep_timer, + ), + BluesoundButtonEntityDescription( + key="clear_sleep_timer", + translation_key="clear_sleep_timer", + entity_registry_enabled_default=False, + press_fn=clear_sleep_timer, + ), +] + + +class BluesoundButton(CoordinatorEntity[BluesoundCoordinator], ButtonEntity): + """Base class for Bluesound buttons.""" + + _attr_has_entity_name = True + entity_description: BluesoundButtonEntityDescription + + def __init__( + self, + coordinator: BluesoundCoordinator, + player: Player, + port: int, + description: BluesoundButtonEntityDescription, + ) -> None: + """Initialize the Bluesound button.""" + super().__init__(coordinator) + sync_status = coordinator.data.sync_status + + self.entity_description = description + self._player = player + self._attr_unique_id = ( + f"{description.key}-{format_unique_id(sync_status.mac, port)}" + ) + + if port == DEFAULT_PORT: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, format_mac(sync_status.mac))}, + connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))}, + name=sync_status.name, + manufacturer=sync_status.brand, + model=sync_status.model_name, + model_id=sync_status.model, + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, format_unique_id(sync_status.mac, port))}, + name=sync_status.name, + manufacturer=sync_status.brand, + model=sync_status.model_name, + model_id=sync_status.model, + via_device=(DOMAIN, format_mac(sync_status.mac)), + ) + + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.press_fn(self._player) diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py index 2f002b70e1d..cfb6646d829 100644 --- a/homeassistant/components/bluesound/config_flow.py +++ b/homeassistant/components/bluesound/config_flow.py @@ -75,6 +75,9 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" + # the player can have an ipv6 address, but the api is only available on ipv4 + if discovery_info.ip_address.version != 4: + return self.async_abort(reason="no_ipv4_address") if discovery_info.port is not None: self._port = discovery_info.port diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 151c1512b74..caf5cc7541d 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==2.0.0"], + "requirements": ["pyblu==2.0.1"], "zeroconf": [ { "type": "_musc._tcp.local." diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 135d1b5d27e..2662562f575 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -22,7 +22,11 @@ from homeassistant.components.media_player import ( 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, entity_platform +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + issue_registry as ir, +) from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, @@ -34,7 +38,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, slugify from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN from .coordinator import BluesoundCoordinator @@ -330,7 +334,12 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity if self._status.input_id is not None: for input_ in self._inputs: - if input_.id == self._status.input_id: + # the input might not have an id => also try to match on the stream_url/url + # we have to use both because neither matches all the time + if ( + input_.id == self._status.input_id + or input_.url == self._status.stream_url + ): return input_.text for preset in self._presets: @@ -483,10 +492,36 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity async def async_increase_timer(self) -> int: """Increase sleep time on player.""" + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_service_{SERVICE_SET_TIMER}", + is_fixable=False, + breaks_in_ha_version="2025.12.0", + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_service_set_sleep_timer", + translation_placeholders={ + "name": slugify(self.sync_status.name), + }, + ) return await self._player.sleep_timer() async def async_clear_timer(self) -> None: """Clear sleep timer on player.""" + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_service_{SERVICE_CLEAR_TIMER}", + is_fixable=False, + breaks_in_ha_version="2025.12.0", + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_service_clear_sleep_timer", + translation_placeholders={ + "name": slugify(self.sync_status.name), + }, + ) sleep = 1 while sleep > 0: sleep = await self._player.sleep_timer() @@ -501,18 +536,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity return # presets and inputs might have the same name; presets have priority - url: str | None = None for input_ in self._inputs: if input_.text == source: - url = input_.url + await self._player.play_url(input_.url) + return for preset in self._presets: if preset.name == source: - url = preset.url + await self._player.load_preset(preset.id) + return - if url is None: - raise ServiceValidationError(f"Source {source} not found") - - await self._player.play_url(url) + raise ServiceValidationError(f"Source {source} not found") async def async_clear_playlist(self) -> None: """Clear players playlist.""" diff --git a/homeassistant/components/bluesound/strings.json b/homeassistant/components/bluesound/strings.json index b50c01a11bf..236113a835b 100644 --- a/homeassistant/components/bluesound/strings.json +++ b/homeassistant/components/bluesound/strings.json @@ -19,12 +19,23 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "no_ipv4_address": "No IPv4 address found." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, + "issues": { + "deprecated_service_set_sleep_timer": { + "title": "Detected use of deprecated action bluesound.set_sleep_timer", + "description": "Use `button.{name}_set_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts." + }, + "deprecated_service_clear_sleep_timer": { + "title": "Detected use of deprecated action bluesound.clear_sleep_timer", + "description": "Use `button.{name}_clear_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts." + } + }, "services": { "join": { "name": "Join", @@ -70,5 +81,15 @@ } } } + }, + "entity": { + "button": { + "set_sleep_timer": { + "name": "Set sleep timer" + }, + "clear_sleep_timer": { + "name": "Clear sleep timer" + } + } } } diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c46ef22803e..7abc929fde5 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -311,11 +311,24 @@ async def async_update_device( update the device with the new location so they can figure out where the adapter is. """ + address = details[ADAPTER_ADDRESS] + connections = {(dr.CONNECTION_BLUETOOTH, address)} device_registry = dr.async_get(hass) + # We only have one device for the config entry + # so if the address has been corrected, make + # sure the device entry reflects the correct + # address + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + for conn_type, conn_value in device.connections: + if conn_type == dr.CONNECTION_BLUETOOTH and conn_value != address: + device_registry.async_update_device( + device.id, new_connections=connections + ) + break device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]), - connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])}, + name=adapter_human_name(adapter, address), + connections=connections, manufacturer=details[ADAPTER_MANUFACTURER], model=adapter_model(details), sw_version=details.get(ADAPTER_SW_VERSION), @@ -342,9 +355,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) ) + return True 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})" diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index e76277306f5..328707bd722 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -186,16 +186,28 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by an external scanner.""" source = user_input[CONF_SOURCE] await self.async_set_unique_id(source) + source_config_entry_id = user_input[CONF_SOURCE_CONFIG_ENTRY_ID] 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], + CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID], } self._abort_if_unique_id_configured(updates=data) - manager = get_manager() - scanner = manager.async_scanner_by_source(source) + for entry in self._async_current_entries(include_ignore=False): + # If the mac address needs to be corrected, migrate + # the config entry to the new mac address + if ( + entry.data.get(CONF_SOURCE_CONFIG_ENTRY_ID) == source_config_entry_id + and entry.unique_id != source + ): + self.hass.config_entries.async_update_entry( + entry, unique_id=source, data={**entry.data, **data} + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + scanner = get_manager().async_scanner_by_source(source) assert scanner is not None return self.async_create_entry(title=scanner.name, data=data) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 8eeb4d67109..f9377443296 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,9 +18,9 @@ "bleak==0.22.3", "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", - "bluetooth-auto-recovery==1.4.4", - "bluetooth-data-tools==1.23.4", - "dbus-fast==2.33.0", - "habluetooth==3.24.0" + "bluetooth-auto-recovery==1.5.1", + "bluetooth-data-tools==1.28.1", + "dbus-fast==2.43.0", + "habluetooth==3.48.2" ] } diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 8f66a3582ea..09e953a8676 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -374,6 +374,27 @@ class PassiveBluetoothProcessorCoordinator[_DataT](BasePassiveBluetoothCoordinat self.logger.exception("Unexpected error updating %s data", self.name) return + self._process_update(update, was_available) + + @callback + def async_set_updated_data(self, update: _DataT) -> None: + """Manually update the processor with new data. + + If the data comes in via a different method, like a + notification, this method can be used to update the + processor with the new data. + + This is useful for devices that retrieve + some of their data via notifications. + """ + was_available = self._available + self._available = True + self._process_update(update, was_available) + + def _process_update( + self, update: _DataT, was_available: bool | None = None + ) -> None: + """Process the update from the bluetooth device.""" if not self.last_update_success: self.last_update_success = True self.logger.info("Coordinator %s recovered", self.name) diff --git a/homeassistant/components/bluetooth/storage.py b/homeassistant/components/bluetooth/storage.py index 369db4a7760..3222eaef2c5 100644 --- a/homeassistant/components/bluetooth/storage.py +++ b/homeassistant/components/bluetooth/storage.py @@ -2,7 +2,7 @@ from __future__ import annotations -from bluetooth_adapters import ( +from habluetooth import ( DiscoveredDeviceAdvertisementData, DiscoveredDeviceAdvertisementDataDict, DiscoveryStorageType, diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index f8980201f3f..726c3ff3f6e 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry from .entity import BMWBaseEntity if TYPE_CHECKING: @@ -111,7 +111,7 @@ class BMWButton(BMWBaseEntity, ButtonEntity): await self.entity_description.remote_function(self.vehicle) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index b54d9245bbd..73e19ca7af5 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -22,13 +22,7 @@ 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 as BMW_DOMAIN, - SCAN_INTERVALS, -) +from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS _LOGGER = logging.getLogger(__name__) @@ -63,7 +57,7 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): hass, _LOGGER, config_entry=config_entry, - name=f"{BMW_DOMAIN}-{config_entry.data[CONF_USERNAME]}", + name=f"{DOMAIN}-{config_entry.data[CONF_USERNAME]}", update_interval=timedelta( seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]] ), @@ -81,26 +75,26 @@ 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=BMW_DOMAIN, + translation_domain=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( - translation_domain=BMW_DOMAIN, + translation_domain=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( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_auth", ) from err except (MyBMWAPIError, RequestError) as err: raise UpdateFailed( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="update_failed", translation_placeholders={"exception": str(err)}, ) from err diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index 9d8965d6ebf..149647a3397 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -71,7 +71,7 @@ class BMWLock(BMWBaseEntity, LockEntity): self._attr_is_locked = None self.async_write_ha_state() raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex @@ -95,7 +95,7 @@ class BMWLock(BMWBaseEntity, LockEntity): self._attr_is_locked = None self.async_write_ha_state() raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py index dfa0939e81f..2a94cf42853 100644 --- a/homeassistant/components/bmw_connected_drive/notify.py +++ b/homeassistant/components/bmw_connected_drive/notify.py @@ -20,7 +20,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry PARALLEL_UPDATES = 1 @@ -92,7 +92,7 @@ class BMWNotificationService(BaseNotificationService): except (vol.Invalid, TypeError, ValueError) as ex: raise ServiceValidationError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_poi", translation_placeholders={ "poi_exception": str(ex), @@ -107,7 +107,7 @@ class BMWNotificationService(BaseNotificationService): await vehicle.remote_services.trigger_send_poi(poi) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=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 8361306ba9d..a30775caf60 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -110,7 +110,7 @@ class BMWNumber(BMWBaseEntity, NumberEntity): await self.entity_description.remote_service(self.vehicle, value) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index f144d3a71df..81e01b2bfad 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -124,7 +124,7 @@ class BMWSelect(BMWBaseEntity, SelectEntity): await self.entity_description.remote_service(self.vehicle, option) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 4b16b719d8d..3b8b6fc5ff0 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -6,7 +6,7 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "region": "ConnectedDrive Region" + "region": "ConnectedDrive region" }, "data_description": { "username": "The email address of your MyBMW/MINI Connected account.", @@ -69,7 +69,7 @@ "name": "Door lock state" }, "condition_based_services": { - "name": "Condition based services" + "name": "Condition-based services" }, "check_control_messages": { "name": "Check control messages" @@ -81,7 +81,7 @@ "name": "Connection status" }, "is_pre_entry_climatization_enabled": { - "name": "Pre entry climatization" + "name": "Pre-entry climatization" } }, "button": { @@ -113,10 +113,10 @@ }, "select": { "ac_limit": { - "name": "AC Charging Limit" + "name": "AC charging limit" }, "charging_mode": { - "name": "Charging Mode", + "name": "Charging mode", "state": { "immediate_charging": "Immediate charging", "delayed_charging": "Delayed charging", @@ -139,7 +139,7 @@ "state": { "default": "Default", "charging": "[%key:common::state::charging%]", - "error": "Error", + "error": "[%key:common::state::error%]", "complete": "Complete", "fully_charged": "Fully charged", "finished_fully_charged": "Finished, fully charged", @@ -181,7 +181,7 @@ "cooling": "Cooling", "heating": "Heating", "inactive": "Inactive", - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "ventilation": "Ventilation" } }, diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index f46969f3e9b..cedcf2a7364 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry +from . import DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator from .entity import BMWBaseEntity @@ -112,7 +112,7 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity): await self.entity_description.remote_service_on(self.vehicle) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex @@ -124,7 +124,7 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity): await self.entity_description.remote_service_off(self.vehicle) except MyBMWAPIError as ex: raise HomeAssistantError( - translation_domain=BMW_DOMAIN, + translation_domain=DOMAIN, translation_key="remote_service_error", translation_placeholders={"exception": str(ex)}, ) from ex diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index eb28bebdb06..00b8c8a0e13 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -5,7 +5,7 @@ import logging from typing import Any from aiohttp import ClientError, ClientResponseError, ClientTimeout -from bond_async import Bond, BPUPSubscriptions, start_bpup +from bond_async import Bond, BPUPSubscriptions, RequestorUUID, start_bpup from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -49,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BondConfigEntry) -> bool token=token, timeout=ClientTimeout(total=_API_TIMEOUT), session=async_get_clientsession(hass), + requestor_uuid=RequestorUUID.HOME_ASSISTANT, ) hub = BondHub(bond, host) try: diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 38abd63186a..9fcfbd342d8 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -8,7 +8,7 @@ import logging from typing import Any from aiohttp import ClientConnectionError, ClientResponseError -from bond_async import Bond +from bond_async import Bond, RequestorUUID import voluptuous as vol from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult @@ -16,6 +16,7 @@ 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.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -33,7 +34,12 @@ TOKEN_SCHEMA = vol.Schema({}) async def async_get_token(hass: HomeAssistant, host: str) -> str | None: """Try to fetch the token from the bond device.""" - bond = Bond(host, "", session=async_get_clientsession(hass)) + bond = Bond( + host, + "", + session=async_get_clientsession(hass), + requestor_uuid=RequestorUUID.HOME_ASSISTANT, + ) response: dict[str, str] = {} with contextlib.suppress(ClientConnectionError): response = await bond.token() @@ -44,7 +50,10 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[st """Validate the user input allows us to connect.""" bond = Bond( - data[CONF_HOST], data[CONF_ACCESS_TOKEN], session=async_get_clientsession(hass) + data[CONF_HOST], + data[CONF_ACCESS_TOKEN], + session=async_get_clientsession(hass), + requestor_uuid=RequestorUUID.HOME_ASSISTANT, ) try: hub = BondHub(bond, data[CONF_HOST]) @@ -91,11 +100,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered[CONF_ACCESS_TOKEN] = token try: - _, hub_name = await _validate_input(self.hass, self._discovered) + bond_id, hub_name = await _validate_input(self.hass, self._discovered) except InputValidationError: return + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._discovered[CONF_NAME] = hub_name + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by dhcp discovery.""" + host = discovery_info.ip + bond_id = discovery_info.hostname.partition("-")[2].upper() + await self.async_set_unique_id(bond_id) + return await self.async_step_any_discovery(bond_id, host) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: @@ -104,11 +124,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): host: str = discovery_info.host bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) + return await self.async_step_any_discovery(bond_id, host) + + async def async_step_any_discovery( + self, bond_id: str, host: str + ) -> ConfigFlowResult: + """Handle a flow initialized by discovery.""" for entry in self._async_current_entries(): if entry.unique_id != bond_id: continue updates = {CONF_HOST: host} - if entry.state == ConfigEntryState.SETUP_ERROR and ( + if entry.state is ConfigEntryState.SETUP_ERROR and ( token := await async_get_token(self.hass, host) ): updates[CONF_ACCESS_TOKEN] = token @@ -153,10 +179,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self._discovered[CONF_HOST], } try: - _, hub_name = await _validate_input(self.hass, data) + bond_id, hub_name = await _validate_input(self.hass, data) except InputValidationError as error: errors["base"] = error.base else: + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._discovered[CONF_HOST]} + ) return self.async_create_entry( title=hub_name, data=data, @@ -185,8 +215,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): except InputValidationError as error: errors["base"] = error.base else: - await self.async_set_unique_id(bond_id) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(bond_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) return self.async_create_entry(title=hub_name, data=user_input) return self.async_show_form( diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 1d4c110f4fd..704b9934970 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,6 +3,16 @@ "name": "Bond", "codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"], "config_flow": true, + "dhcp": [ + { + "hostname": "bond-*", + "macaddress": "3C6A2C1*" + }, + { + "hostname": "bond-*", + "macaddress": "F44E38*" + } + ], "documentation": "https://www.home-assistant.io/integrations/bond", "iot_class": "local_push", "loggers": ["bond_async"], diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py new file mode 100644 index 00000000000..410adbd8d51 --- /dev/null +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -0,0 +1,76 @@ +"""The Bosch Alarm integration.""" + +from __future__ import annotations + +from ssl import SSLError + +from bosch_alarm_mode2 import Panel + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN + +PLATFORMS: list[Platform] = [ + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, +] + +type BoschAlarmConfigEntry = ConfigEntry[Panel] + + +async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool: + """Set up Bosch Alarm from a config entry.""" + + panel = Panel( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + automation_code=entry.data.get(CONF_PASSWORD), + installer_or_user_code=entry.data.get( + CONF_INSTALLER_CODE, entry.data.get(CONF_USER_CODE) + ), + ) + try: + await panel.connect() + except (PermissionError, ValueError) as err: + await panel.disconnect() + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err: + await panel.disconnect() + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + entry.runtime_data = panel + + device_registry = dr.async_get(hass) + + mac = entry.data.get(CONF_MAC) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(), + identifiers={(DOMAIN, entry.unique_id or entry.entry_id)}, + name=f"Bosch {panel.model}", + manufacturer="Bosch Security Systems", + model=panel.model, + sw_version=panel.firmware_version, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.disconnect() + return unload_ok diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py new file mode 100644 index 00000000000..7115bae415a --- /dev/null +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -0,0 +1,83 @@ +"""Support for Bosch Alarm Panel.""" + +from __future__ import annotations + +from bosch_alarm_mode2 import Panel + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + AlarmControlPanelState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschAlarmConfigEntry +from .entity import BoschAlarmAreaEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up control panels for each area.""" + panel = config_entry.runtime_data + + async_add_entities( + AreaAlarmControlPanel( + panel, + area_id, + config_entry.unique_id or config_entry.entry_id, + ) + for area_id in panel.areas + ) + + +PARALLEL_UPDATES = 0 + + +class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity): + """An alarm control panel entity for a bosch alarm panel.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + ) + _attr_code_arm_required = False + _attr_name = None + + def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: + """Initialise a Bosch Alarm control panel entity.""" + super().__init__(panel, area_id, unique_id, False, False, True) + self._attr_unique_id = self._area_unique_id + + @property + def alarm_state(self) -> AlarmControlPanelState | None: + """Return the state of the alarm.""" + if self._area.is_triggered(): + return AlarmControlPanelState.TRIGGERED + if self._area.is_disarmed(): + return AlarmControlPanelState.DISARMED + if self._area.is_arming(): + return AlarmControlPanelState.ARMING + if self._area.is_pending(): + return AlarmControlPanelState.PENDING + if self._area.is_part_armed(): + return AlarmControlPanelState.ARMED_HOME + if self._area.is_all_armed(): + return AlarmControlPanelState.ARMED_AWAY + return None + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Disarm this panel.""" + await self.panel.area_disarm(self._area_id) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self.panel.area_arm_part(self._area_id) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + await self.panel.area_arm_all(self._area_id) diff --git a/homeassistant/components/bosch_alarm/binary_sensor.py b/homeassistant/components/bosch_alarm/binary_sensor.py new file mode 100644 index 00000000000..ced97f04686 --- /dev/null +++ b/homeassistant/components/bosch_alarm/binary_sensor.py @@ -0,0 +1,220 @@ +"""Support for Bosch Alarm Panel binary sensors.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from bosch_alarm_mode2 import Panel +from bosch_alarm_mode2.const import ALARM_PANEL_FAULTS + +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 AddConfigEntryEntitiesCallback + +from . import BoschAlarmConfigEntry +from .entity import BoschAlarmAreaEntity, BoschAlarmEntity, BoschAlarmPointEntity + + +@dataclass(kw_only=True, frozen=True) +class BoschAlarmFaultEntityDescription(BinarySensorEntityDescription): + """Describes Bosch Alarm sensor entity.""" + + fault: int + + +FAULT_TYPES = [ + BoschAlarmFaultEntityDescription( + key="panel_fault_battery_low", + entity_registry_enabled_default=True, + device_class=BinarySensorDeviceClass.BATTERY, + fault=ALARM_PANEL_FAULTS.BATTERY_LOW, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_battery_mising", + translation_key="panel_fault_battery_mising", + entity_registry_enabled_default=True, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.BATTERY_MISING, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_ac_fail", + translation_key="panel_fault_ac_fail", + entity_registry_enabled_default=True, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.AC_FAIL, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_phone_line_failure", + translation_key="panel_fault_phone_line_failure", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + fault=ALARM_PANEL_FAULTS.PHONE_LINE_FAILURE, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_parameter_crc_fail_in_pif", + translation_key="panel_fault_parameter_crc_fail_in_pif", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.PARAMETER_CRC_FAIL_IN_PIF, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_communication_fail_since_rps_hang_up", + translation_key="panel_fault_communication_fail_since_rps_hang_up", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.COMMUNICATION_FAIL_SINCE_RPS_HANG_UP, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_sdi_fail_since_rps_hang_up", + translation_key="panel_fault_sdi_fail_since_rps_hang_up", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.SDI_FAIL_SINCE_RPS_HANG_UP, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_user_code_tamper_since_rps_hang_up", + translation_key="panel_fault_user_code_tamper_since_rps_hang_up", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.USER_CODE_TAMPER_SINCE_RPS_HANG_UP, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_fail_to_call_rps_since_rps_hang_up", + translation_key="panel_fault_fail_to_call_rps_since_rps_hang_up", + entity_registry_enabled_default=False, + fault=ALARM_PANEL_FAULTS.FAIL_TO_CALL_RPS_SINCE_RPS_HANG_UP, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_point_bus_fail_since_rps_hang_up", + translation_key="panel_fault_point_bus_fail_since_rps_hang_up", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.POINT_BUS_FAIL_SINCE_RPS_HANG_UP, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_log_overflow", + translation_key="panel_fault_log_overflow", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.LOG_OVERFLOW, + ), + BoschAlarmFaultEntityDescription( + key="panel_fault_log_threshold", + translation_key="panel_fault_log_threshold", + entity_registry_enabled_default=False, + device_class=BinarySensorDeviceClass.PROBLEM, + fault=ALARM_PANEL_FAULTS.LOG_THRESHOLD, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up binary sensors for alarm points and the connection status.""" + panel = config_entry.runtime_data + + entities: list[BinarySensorEntity] = [ + PointSensor(panel, point_id, config_entry.unique_id or config_entry.entry_id) + for point_id in panel.points + ] + + entities.extend( + PanelFaultsSensor( + panel, + config_entry.unique_id or config_entry.entry_id, + fault_type, + ) + for fault_type in FAULT_TYPES + ) + + entities.extend( + AreaReadyToArmSensor( + panel, area_id, config_entry.unique_id or config_entry.entry_id, "away" + ) + for area_id in panel.areas + ) + + entities.extend( + AreaReadyToArmSensor( + panel, area_id, config_entry.unique_id or config_entry.entry_id, "home" + ) + for area_id in panel.areas + ) + + async_add_entities(entities) + + +PARALLEL_UPDATES = 0 + + +class PanelFaultsSensor(BoschAlarmEntity, BinarySensorEntity): + """A binary sensor entity for each fault type in a bosch alarm panel.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + entity_description: BoschAlarmFaultEntityDescription + + def __init__( + self, + panel: Panel, + unique_id: str, + entity_description: BoschAlarmFaultEntityDescription, + ) -> None: + """Set up a binary sensor entity for each fault type in a bosch alarm panel.""" + super().__init__(panel, unique_id, True) + self.entity_description = entity_description + self._fault_type = entity_description.fault + self._attr_unique_id = f"{unique_id}_fault_{entity_description.key}" + + @property + def is_on(self) -> bool: + """Return if this fault has occurred.""" + return self._fault_type in self.panel.panel_faults_ids + + +class AreaReadyToArmSensor(BoschAlarmAreaEntity, BinarySensorEntity): + """A binary sensor entity showing if a panel is ready to arm.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, panel: Panel, area_id: int, unique_id: str, arm_type: str + ) -> None: + """Set up a binary sensor entity for the arming status in a bosch alarm panel.""" + super().__init__(panel, area_id, unique_id, False, False, True) + self.panel = panel + self._arm_type = arm_type + self._attr_translation_key = f"area_ready_to_arm_{arm_type}" + self._attr_unique_id = f"{self._area_unique_id}_ready_to_arm_{arm_type}" + + @property + def is_on(self) -> bool: + """Return if this panel is ready to arm.""" + if self._arm_type == "away": + return self._area.all_ready + if self._arm_type == "home": + return self._area.all_ready or self._area.part_ready + return False + + +class PointSensor(BoschAlarmPointEntity, BinarySensorEntity): + """A binary sensor entity for a point in a bosch alarm panel.""" + + _attr_name = None + + def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None: + """Set up a binary sensor entity for a point in a bosch alarm panel.""" + super().__init__(panel, point_id, unique_id) + self._attr_unique_id = self._point_unique_id + + @property + def is_on(self) -> bool: + """Return if this point sensor is on.""" + return self._point.is_open() diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py new file mode 100644 index 00000000000..71e15f5959a --- /dev/null +++ b/homeassistant/components/bosch_alarm/config_flow.py @@ -0,0 +1,311 @@ +"""Config flow for Bosch Alarm integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +import logging +import ssl +from typing import Any, Self + +from bosch_alarm_mode2 import Panel +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_DHCP, + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import ( + CONF_CODE, + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_PASSWORD, + CONF_PORT, +) +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 CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=7700): cv.positive_int, + } +) + +STEP_AUTH_DATA_SCHEMA_SOLUTION = vol.Schema( + { + vol.Required(CONF_USER_CODE): str, + } +) + +STEP_AUTH_DATA_SCHEMA_AMAX = vol.Schema( + { + vol.Required(CONF_INSTALLER_CODE): str, + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_AUTH_DATA_SCHEMA_BG = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_INIT_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_CODE): str}) + + +async def try_connect( + data: dict[str, Any], load_selector: int = 0 +) -> tuple[str, int | None]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + panel = Panel( + host=data[CONF_HOST], + port=data[CONF_PORT], + automation_code=data.get(CONF_PASSWORD), + installer_or_user_code=data.get(CONF_INSTALLER_CODE, data.get(CONF_USER_CODE)), + ) + + try: + await panel.connect(load_selector) + finally: + await panel.disconnect() + + return (panel.model, panel.serial_number) + + +class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Bosch Alarm.""" + + def __init__(self) -> None: + """Init config flow.""" + + self._data: dict[str, Any] = {} + self.mac: str | None = None + self.host: str | None = None + + def is_matching(self, other_flow: Self) -> bool: + """Return True if other_flow is matching this flow.""" + return self.mac == other_flow.mac or self.host == other_flow.host + + 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: + self.host = user_input[CONF_HOST] + if self.source == SOURCE_USER: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + try: + # Use load_selector = 0 to fetch the panel model without authentication. + (model, _) = await try_connect(user_input, 0) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + asyncio.exceptions.TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self._data = user_input + self._data[CONF_MODEL] = model + + if self.source == SOURCE_RECONFIGURE: + if ( + self._get_reconfigure_entry().data[CONF_MODEL] + != self._data[CONF_MODEL] + ): + return self.async_abort(reason="device_mismatch") + return await self.async_step_auth() + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + self.mac = format_mac(discovery_info.macaddress) + self.host = discovery_info.ip + if self.hass.config_entries.flow.async_has_matching_flow(self): + return self.async_abort(reason="already_in_progress") + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_MAC] == self.mac: + result = self.hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_HOST: discovery_info.ip, + }, + ) + if result: + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + try: + # Use load_selector = 0 to fetch the panel model without authentication. + (model, _) = await try_connect( + {CONF_HOST: discovery_info.ip, CONF_PORT: 7700}, 0 + ) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + asyncio.exceptions.TimeoutError, + ): + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + self.context["title_placeholders"] = { + "model": model, + "host": discovery_info.ip, + } + self._data = { + CONF_HOST: discovery_info.ip, + CONF_MAC: self.mac, + CONF_MODEL: model, + CONF_PORT: 7700, + } + + return await self.async_step_auth() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reconfigure step.""" + return await self.async_step_user() + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the auth step.""" + errors: dict[str, str] = {} + + # Each model variant requires a different authentication flow + if "Solution" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_SOLUTION + elif "AMAX" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_AMAX + else: + schema = STEP_AUTH_DATA_SCHEMA_BG + + if user_input is not None: + self._data.update(user_input) + try: + (model, serial_number) = await try_connect( + self._data, Panel.LOAD_EXTENDED_INFO + ) + except (PermissionError, ValueError) as e: + errors["base"] = "invalid_auth" + _LOGGER.error("Authentication Error: %s", e) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if serial_number: + await self.async_set_unique_id(str(serial_number)) + if self.source in (SOURCE_USER, SOURCE_DHCP): + if serial_number: + self._abort_if_unique_id_configured() + else: + self._async_abort_entries_match( + {CONF_HOST: self._data[CONF_HOST]} + ) + return self.async_create_entry( + title=f"Bosch {model}", data=self._data + ) + if serial_number: + self._abort_if_unique_id_mismatch(reason="device_mismatch") + + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data=self._data, + ) + + return self.async_show_form( + step_id="auth", + data_schema=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an authentication error.""" + self._data = 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 the reauth step.""" + errors: dict[str, str] = {} + + # Each model variant requires a different authentication flow + if "Solution" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_SOLUTION + elif "AMAX" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_AMAX + else: + schema = STEP_AUTH_DATA_SCHEMA_BG + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + self._data.update(user_input) + try: + (_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO) + except (PermissionError, ValueError) as e: + errors["base"] = "invalid_auth" + _LOGGER.error("Authentication Error: %s", e) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + 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(schema, user_input), + errors=errors, + ) diff --git a/homeassistant/components/bosch_alarm/const.py b/homeassistant/components/bosch_alarm/const.py new file mode 100644 index 00000000000..7205831391c --- /dev/null +++ b/homeassistant/components/bosch_alarm/const.py @@ -0,0 +1,6 @@ +"""Constants for the Bosch Alarm integration.""" + +DOMAIN = "bosch_alarm" +HISTORY_ATTR = "history" +CONF_INSTALLER_CODE = "installer_code" +CONF_USER_CODE = "user_code" diff --git a/homeassistant/components/bosch_alarm/diagnostics.py b/homeassistant/components/bosch_alarm/diagnostics.py new file mode 100644 index 00000000000..2e93052ea95 --- /dev/null +++ b/homeassistant/components/bosch_alarm/diagnostics.py @@ -0,0 +1,73 @@ +"""Diagnostics for bosch alarm.""" + +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 BoschAlarmConfigEntry +from .const import CONF_INSTALLER_CODE, CONF_USER_CODE + +TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: BoschAlarmConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "data": { + "model": entry.runtime_data.model, + "serial_number": entry.runtime_data.serial_number, + "protocol_version": entry.runtime_data.protocol_version, + "firmware_version": entry.runtime_data.firmware_version, + "areas": [ + { + "id": area_id, + "name": area.name, + "all_ready": area.all_ready, + "part_ready": area.part_ready, + "faults": area.faults, + "alarms": area.alarms, + "disarmed": area.is_disarmed(), + "arming": area.is_arming(), + "pending": area.is_pending(), + "part_armed": area.is_part_armed(), + "all_armed": area.is_all_armed(), + "armed": area.is_armed(), + "triggered": area.is_triggered(), + } + for area_id, area in entry.runtime_data.areas.items() + ], + "points": [ + { + "id": point_id, + "name": point.name, + "open": point.is_open(), + "normal": point.is_normal(), + } + for point_id, point in entry.runtime_data.points.items() + ], + "doors": [ + { + "id": door_id, + "name": door.name, + "open": door.is_open(), + "locked": door.is_locked(), + } + for door_id, door in entry.runtime_data.doors.items() + ], + "outputs": [ + { + "id": output_id, + "name": output.name, + "active": output.is_active(), + } + for output_id, output in entry.runtime_data.outputs.items() + ], + "history_events": entry.runtime_data.events, + }, + } diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py new file mode 100644 index 00000000000..537ee412e47 --- /dev/null +++ b/homeassistant/components/bosch_alarm/entity.py @@ -0,0 +1,177 @@ +"""Support for Bosch Alarm Panel History as a sensor.""" + +from __future__ import annotations + +from bosch_alarm_mode2 import Panel + +from homeassistant.components.sensor import Entity +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN + +PARALLEL_UPDATES = 0 + + +class BoschAlarmEntity(Entity): + """A base entity for a bosch alarm panel.""" + + _attr_has_entity_name = True + + def __init__( + self, panel: Panel, unique_id: str, observe_faults: bool = False + ) -> None: + """Set up a entity for a bosch alarm panel.""" + self.panel = panel + self._observe_faults = observe_faults + self._attr_should_poll = False + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=f"Bosch {panel.model}", + manufacturer="Bosch Security Systems", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.panel.connection_status() + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + self.panel.connection_status_observer.attach(self.schedule_update_ha_state) + if self._observe_faults: + self.panel.faults_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + self.panel.connection_status_observer.detach(self.schedule_update_ha_state) + if self._observe_faults: + self.panel.faults_observer.attach(self.schedule_update_ha_state) + + +class BoschAlarmAreaEntity(BoschAlarmEntity): + """A base entity for area related entities within a bosch alarm panel.""" + + def __init__( + self, + panel: Panel, + area_id: int, + unique_id: str, + observe_alarms: bool, + observe_ready: bool, + observe_status: bool, + ) -> None: + """Set up a area related entity for a bosch alarm panel.""" + super().__init__(panel, unique_id) + self._area_id = area_id + self._area_unique_id = f"{unique_id}_area_{area_id}" + self._observe_alarms = observe_alarms + self._observe_ready = observe_ready + self._observe_status = observe_status + self._area = panel.areas[area_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._area_unique_id)}, + name=self._area.name, + manufacturer="Bosch Security Systems", + via_device=(DOMAIN, unique_id), + ) + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + await super().async_added_to_hass() + if self._observe_alarms: + self._area.alarm_observer.attach(self.schedule_update_ha_state) + if self._observe_ready: + self._area.ready_observer.attach(self.schedule_update_ha_state) + if self._observe_status: + self._area.status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + await super().async_added_to_hass() + if self._observe_alarms: + self._area.alarm_observer.detach(self.schedule_update_ha_state) + if self._observe_ready: + self._area.ready_observer.detach(self.schedule_update_ha_state) + if self._observe_status: + self._area.status_observer.detach(self.schedule_update_ha_state) + + +class BoschAlarmPointEntity(BoschAlarmEntity): + """A base entity for point related entities within a bosch alarm panel.""" + + def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None: + """Set up a area related entity for a bosch alarm panel.""" + super().__init__(panel, unique_id) + self._point_id = point_id + self._point_unique_id = f"{unique_id}_point_{point_id}" + self._point = panel.points[point_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._point_unique_id)}, + name=self._point.name, + manufacturer="Bosch Security Systems", + via_device=(DOMAIN, unique_id), + ) + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + await super().async_added_to_hass() + self._point.status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + await super().async_added_to_hass() + self._point.status_observer.detach(self.schedule_update_ha_state) + + +class BoschAlarmDoorEntity(BoschAlarmEntity): + """A base entity for area related entities within a bosch alarm panel.""" + + def __init__(self, panel: Panel, door_id: int, unique_id: str) -> None: + """Set up a area related entity for a bosch alarm panel.""" + super().__init__(panel, unique_id) + self._door_id = door_id + self._door = panel.doors[door_id] + self._door_unique_id = f"{unique_id}_door_{door_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._door_unique_id)}, + name=self._door.name, + manufacturer="Bosch Security Systems", + via_device=(DOMAIN, unique_id), + ) + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + await super().async_added_to_hass() + self._door.status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + await super().async_added_to_hass() + self._door.status_observer.detach(self.schedule_update_ha_state) + + +class BoschAlarmOutputEntity(BoschAlarmEntity): + """A base entity for area related entities within a bosch alarm panel.""" + + def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None: + """Set up a output related entity for a bosch alarm panel.""" + super().__init__(panel, unique_id) + self._output_id = output_id + self._output = panel.outputs[output_id] + self._output_unique_id = f"{unique_id}_output_{output_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._output_unique_id)}, + name=self._output.name, + manufacturer="Bosch Security Systems", + via_device=(DOMAIN, unique_id), + ) + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + await super().async_added_to_hass() + self._output.status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + await super().async_added_to_hass() + self._output.status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/icons.json b/homeassistant/components/bosch_alarm/icons.json new file mode 100644 index 00000000000..b13822fa711 --- /dev/null +++ b/homeassistant/components/bosch_alarm/icons.json @@ -0,0 +1,76 @@ +{ + "entity": { + "sensor": { + "alarms_gas": { + "default": "mdi:alert-circle" + }, + "alarms_fire": { + "default": "mdi:alert-circle" + }, + "alarms_burglary": { + "default": "mdi:alert-circle" + }, + "faulting_points": { + "default": "mdi:alert-circle" + } + }, + "switch": { + "locked": { + "default": "mdi:lock", + "state": { + "off": "mdi:lock-open" + } + }, + "secured": { + "default": "mdi:lock", + "state": { + "off": "mdi:lock-open" + } + }, + "cycling": { + "default": "mdi:lock", + "state": { + "on": "mdi:lock-open" + } + } + }, + "binary_sensor": { + "panel_fault_parameter_crc_fail_in_pif": { + "default": "mdi:alert-circle" + }, + "panel_fault_phone_line_failure": { + "default": "mdi:alert-circle" + }, + "panel_fault_sdi_fail_since_rps_hang_up": { + "default": "mdi:alert-circle" + }, + "panel_fault_user_code_tamper_since_rps_hang_up": { + "default": "mdi:alert-circle" + }, + "panel_fault_fail_to_call_rps_since_rps_hang_up": { + "default": "mdi:alert-circle" + }, + "panel_fault_point_bus_fail_since_rps_hang_up": { + "default": "mdi:alert-circle" + }, + "panel_fault_log_overflow": { + "default": "mdi:alert-circle" + }, + "panel_fault_log_threshold": { + "default": "mdi:alert-circle" + }, + "area_ready_to_arm_away": { + "default": "mdi:shield", + "state": { + "on": "mdi:shield-lock" + } + }, + "area_ready_to_arm_home": { + "default": "mdi:shield", + "state": { + "on": "mdi:shield-home" + } + } + } + } +} diff --git a/homeassistant/components/bosch_alarm/manifest.json b/homeassistant/components/bosch_alarm/manifest.json new file mode 100644 index 00000000000..160d6141959 --- /dev/null +++ b/homeassistant/components/bosch_alarm/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "bosch_alarm", + "name": "Bosch Alarm", + "codeowners": ["@mag1024", "@sanjay900"], + "config_flow": true, + "dhcp": [ + { + "macaddress": "000463*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/bosch_alarm", + "integration_type": "device", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["bosch-alarm-mode2==0.4.6"] +} diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml new file mode 100644 index 00000000000..5bbd1df0ebb --- /dev/null +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions defined + appropriate-polling: + status: exempt + comment: | + No polling + 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: 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 custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: done + 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: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Device type integration + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No repairs + stale-devices: + status: exempt + comment: | + Device type integration + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + Integration does not make any HTTP requests. + strict-typing: done diff --git a/homeassistant/components/bosch_alarm/sensor.py b/homeassistant/components/bosch_alarm/sensor.py new file mode 100644 index 00000000000..479aaa03049 --- /dev/null +++ b/homeassistant/components/bosch_alarm/sensor.py @@ -0,0 +1,122 @@ +"""Support for Bosch Alarm Panel History as a sensor.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from bosch_alarm_mode2 import Panel +from bosch_alarm_mode2.const import ALARM_MEMORY_PRIORITIES +from bosch_alarm_mode2.panel import Area + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschAlarmConfigEntry +from .entity import BoschAlarmAreaEntity + +ALARM_TYPES = { + "burglary": { + ALARM_MEMORY_PRIORITIES.BURGLARY_SUPERVISORY: "supervisory", + ALARM_MEMORY_PRIORITIES.BURGLARY_TROUBLE: "trouble", + ALARM_MEMORY_PRIORITIES.BURGLARY_ALARM: "alarm", + }, + "gas": { + ALARM_MEMORY_PRIORITIES.GAS_SUPERVISORY: "supervisory", + ALARM_MEMORY_PRIORITIES.GAS_TROUBLE: "trouble", + ALARM_MEMORY_PRIORITIES.GAS_ALARM: "alarm", + }, + "fire": { + ALARM_MEMORY_PRIORITIES.FIRE_SUPERVISORY: "supervisory", + ALARM_MEMORY_PRIORITIES.FIRE_TROUBLE: "trouble", + ALARM_MEMORY_PRIORITIES.FIRE_ALARM: "alarm", + }, +} + + +@dataclass(kw_only=True, frozen=True) +class BoschAlarmSensorEntityDescription(SensorEntityDescription): + """Describes Bosch Alarm sensor entity.""" + + value_fn: Callable[[Area], str | int] + observe_alarms: bool = False + observe_ready: bool = False + observe_status: bool = False + + +def priority_value_fn(priority_info: dict[int, str]) -> Callable[[Area], str]: + """Build a value_fn for a given priority type.""" + return lambda area: next( + (key for priority, key in priority_info.items() if priority in area.alarms_ids), + "no_issues", + ) + + +SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [ + *[ + BoschAlarmSensorEntityDescription( + key=f"alarms_{key}", + translation_key=f"alarms_{key}", + value_fn=priority_value_fn(priority_type), + observe_alarms=True, + ) + for key, priority_type in ALARM_TYPES.items() + ], + BoschAlarmSensorEntityDescription( + key="faulting_points", + translation_key="faulting_points", + value_fn=lambda area: area.faults, + observe_ready=True, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up bosch alarm sensors.""" + + panel = config_entry.runtime_data + unique_id = config_entry.unique_id or config_entry.entry_id + + async_add_entities( + BoschAreaSensor(panel, area_id, unique_id, template) + for area_id in panel.areas + for template in SENSOR_TYPES + ) + + +PARALLEL_UPDATES = 0 + + +class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity): + """An area sensor entity for a bosch alarm panel.""" + + entity_description: BoschAlarmSensorEntityDescription + + def __init__( + self, + panel: Panel, + area_id: int, + unique_id: str, + entity_description: BoschAlarmSensorEntityDescription, + ) -> None: + """Set up an area sensor entity for a bosch alarm panel.""" + super().__init__( + panel, + area_id, + unique_id, + entity_description.observe_alarms, + entity_description.observe_ready, + entity_description.observe_status, + ) + self.entity_description = entity_description + self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}" + + @property + def native_value(self) -> str | int: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._area) diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json new file mode 100644 index 00000000000..7a9d291a67f --- /dev/null +++ b/homeassistant/components/bosch_alarm/strings.json @@ -0,0 +1,156 @@ +{ + "config": { + "flow_title": "{model} ({host})", + "step": { + "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 Bosch alarm panel", + "port": "The port used to connect to your Bosch alarm panel. This is usually 7700" + } + }, + "auth": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "installer_code": "Installer code", + "user_code": "User code" + }, + "data_description": { + "password": "The Mode 2 automation code from your panel", + "installer_code": "The installer code from your panel", + "user_code": "The user code from your panel" + } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]" + }, + "data_description": { + "password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]" + } + } + }, + "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_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%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "device_mismatch": "Please ensure you reconfigure against the same device." + } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to panel." + }, + "authentication_failed": { + "message": "Incorrect credentials for panel." + }, + "incorrect_door_state": { + "message": "Door cannot be manipulated while it is momentarily unlocked." + } + }, + "entity": { + "binary_sensor": { + "panel_fault_battery_mising": { + "name": "Battery missing" + }, + "panel_fault_ac_fail": { + "name": "AC Failure" + }, + "panel_fault_parameter_crc_fail_in_pif": { + "name": "CRC failure in panel configuration" + }, + "panel_fault_phone_line_failure": { + "name": "Phone line failure" + }, + "panel_fault_sdi_fail_since_rps_hang_up": { + "name": "SDI failure since RPS hang up" + }, + "panel_fault_user_code_tamper_since_rps_hang_up": { + "name": "User code tamper since RPS hang up" + }, + "panel_fault_fail_to_call_rps_since_rps_hang_up": { + "name": "Failure to call RPS since RPS hang up" + }, + "panel_fault_point_bus_fail_since_rps_hang_up": { + "name": "Point bus failure since RPS hang up" + }, + "panel_fault_log_overflow": { + "name": "Log overflow" + }, + "panel_fault_log_threshold": { + "name": "Log threshold reached" + }, + "area_ready_to_arm_away": { + "name": "Area ready to arm away", + "state": { + "on": "Ready", + "off": "Not ready" + } + }, + "area_ready_to_arm_home": { + "name": "Area ready to arm home", + "state": { + "on": "Ready", + "off": "Not ready" + } + } + }, + "switch": { + "secured": { + "name": "Secured" + }, + "cycling": { + "name": "Momentarily unlocked" + }, + "locked": { + "name": "Locked" + } + }, + "sensor": { + "alarms_gas": { + "name": "Gas alarm issues", + "state": { + "supervisory": "Supervisory", + "trouble": "Trouble", + "alarm": "Alarm", + "no_issues": "No issues" + } + }, + "alarms_fire": { + "name": "Fire alarm issues", + "state": { + "supervisory": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::supervisory%]", + "trouble": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::trouble%]", + "alarm": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::alarm%]", + "no_issues": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::no_issues%]" + } + }, + "alarms_burglary": { + "name": "Burglary alarm issues", + "state": { + "supervisory": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::supervisory%]", + "trouble": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::trouble%]", + "alarm": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::alarm%]", + "no_issues": "[%key:component::bosch_alarm::entity::sensor::alarms_gas::state::no_issues%]" + } + }, + "faulting_points": { + "name": "Faulting points", + "unit_of_measurement": "points" + } + } + } +} diff --git a/homeassistant/components/bosch_alarm/switch.py b/homeassistant/components/bosch_alarm/switch.py new file mode 100644 index 00000000000..9d6e48d591d --- /dev/null +++ b/homeassistant/components/bosch_alarm/switch.py @@ -0,0 +1,150 @@ +"""Support for Bosch Alarm Panel outputs and doors as switches.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from bosch_alarm_mode2 import Panel +from bosch_alarm_mode2.panel import Door + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschAlarmConfigEntry +from .const import DOMAIN +from .entity import BoschAlarmDoorEntity, BoschAlarmOutputEntity + + +@dataclass(kw_only=True, frozen=True) +class BoschAlarmSwitchEntityDescription(SwitchEntityDescription): + """Describes Bosch Alarm door entity.""" + + value_fn: Callable[[Door], bool] + on_fn: Callable[[Panel, int], Coroutine[Any, Any, None]] + off_fn: Callable[[Panel, int], Coroutine[Any, Any, None]] + + +DOOR_SWITCH_TYPES: list[BoschAlarmSwitchEntityDescription] = [ + BoschAlarmSwitchEntityDescription( + key="locked", + translation_key="locked", + value_fn=lambda door: door.is_locked(), + on_fn=lambda panel, door_id: panel.door_relock(door_id), + off_fn=lambda panel, door_id: panel.door_unlock(door_id), + ), + BoschAlarmSwitchEntityDescription( + key="secured", + translation_key="secured", + value_fn=lambda door: door.is_secured(), + on_fn=lambda panel, door_id: panel.door_secure(door_id), + off_fn=lambda panel, door_id: panel.door_unsecure(door_id), + ), + BoschAlarmSwitchEntityDescription( + key="cycling", + translation_key="cycling", + value_fn=lambda door: door.is_cycling(), + on_fn=lambda panel, door_id: panel.door_cycle(door_id), + off_fn=lambda panel, door_id: panel.door_relock(door_id), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switch entities for outputs.""" + + panel = config_entry.runtime_data + entities: list[SwitchEntity] = [ + PanelOutputEntity( + panel, output_id, config_entry.unique_id or config_entry.entry_id + ) + for output_id in panel.outputs + ] + + entities.extend( + PanelDoorEntity( + panel, + door_id, + config_entry.unique_id or config_entry.entry_id, + entity_description, + ) + for door_id in panel.doors + for entity_description in DOOR_SWITCH_TYPES + ) + + async_add_entities(entities) + + +PARALLEL_UPDATES = 0 + + +class PanelDoorEntity(BoschAlarmDoorEntity, SwitchEntity): + """A switch entity for a door on a bosch alarm panel.""" + + entity_description: BoschAlarmSwitchEntityDescription + + def __init__( + self, + panel: Panel, + door_id: int, + unique_id: str, + entity_description: BoschAlarmSwitchEntityDescription, + ) -> None: + """Set up a switch entity for a door on a bosch alarm panel.""" + super().__init__(panel, door_id, unique_id) + self.entity_description = entity_description + self._attr_unique_id = f"{self._door_unique_id}_{entity_description.key}" + + @property + def is_on(self) -> bool: + """Return the value function.""" + return self.entity_description.value_fn(self._door) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Run the on function.""" + # If the door is currently cycling, we can't send it any other commands until it is done + if self._door.is_cycling(): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="incorrect_door_state" + ) + await self.entity_description.on_fn(self.panel, self._door_id) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Run the off function.""" + # If the door is currently cycling, we can't send it any other commands until it is done + if self._door.is_cycling(): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="incorrect_door_state" + ) + await self.entity_description.off_fn(self.panel, self._door_id) + + +class PanelOutputEntity(BoschAlarmOutputEntity, SwitchEntity): + """An output entity for a bosch alarm panel.""" + + _attr_name = None + + def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None: + """Set up an output entity for a bosch alarm panel.""" + super().__init__(panel, output_id, unique_id) + self._attr_unique_id = self._output_unique_id + + @property + def is_on(self) -> bool: + """Check if this entity is on.""" + return self._output.is_active() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on this output.""" + await self.panel.set_output_active(self._output_id) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off this output.""" + await self.panel.set_output_inactive(self._output_id) diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 6dd2d36351c..6c0b34c66f0 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -10,7 +10,12 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import BringConfigEntry, BringDataUpdateCoordinator +from .coordinator import ( + BringActivityCoordinator, + BringConfigEntry, + BringCoordinators, + BringDataUpdateCoordinator, +) PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO] @@ -26,7 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo coordinator = BringDataUpdateCoordinator(hass, entry, bring) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + activity_coordinator = BringActivityCoordinator(hass, entry, coordinator) + await activity_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = BringCoordinators(coordinator, activity_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index e1f9fa45ac8..0a8d980a6aa 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -30,7 +30,15 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] +type BringConfigEntry = ConfigEntry[BringCoordinators] + + +@dataclass +class BringCoordinators: + """Data class holding coordinators.""" + + data: BringDataUpdateCoordinator + activity: BringActivityCoordinator @dataclass(frozen=True) @@ -39,17 +47,28 @@ class BringData(DataClassORJSONMixin): lst: BringList content: BringItemsResponse + + +@dataclass(frozen=True) +class BringActivityData(DataClassORJSONMixin): + """Coordinator data class.""" + activity: BringActivityResponse users: BringUsersResponse -class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): - """A Bring Data Update Coordinator.""" +class BringBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Bring base coordinator.""" config_entry: BringConfigEntry - user_settings: BringUserSettingsResponse lists: list[BringList] + +class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]): + """A Bring Data Update Coordinator.""" + + user_settings: BringUserSettingsResponse + def __init__( self, hass: HomeAssistant, config_entry: BringConfigEntry, bring: Bring ) -> None: @@ -90,16 +109,19 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): current_lists := {lst.listUuid for lst in self.lists} ): self._purge_deleted_lists() + new_lists = current_lists - self.previous_lists self.previous_lists = current_lists list_dict: dict[str, BringData] = {} for lst in self.lists: - if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: + if ( + (ctx := set(self.async_contexts())) + and lst.listUuid not in ctx + and lst.listUuid not in new_lists + ): continue try: items = await self.bring.get_list(lst.listUuid) - activity = await self.bring.get_activity(lst.listUuid) - users = await self.bring.get_list_users(lst.listUuid) except BringRequestException as e: raise UpdateFailed( translation_domain=DOMAIN, @@ -111,7 +133,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): translation_key="setup_parse_exception", ) from e else: - list_dict[lst.listUuid] = BringData(lst, items, activity, users) + list_dict[lst.listUuid] = BringData(lst, items) return list_dict @@ -156,3 +178,60 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): device_reg.async_update_device( device.id, remove_config_entry_id=self.config_entry.entry_id ) + + +class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]]): + """A Bring Activity Data Update Coordinator.""" + + user_settings: BringUserSettingsResponse + + def __init__( + self, + hass: HomeAssistant, + config_entry: BringConfigEntry, + coordinator: BringDataUpdateCoordinator, + ) -> None: + """Initialize the Bring Activity data coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(minutes=10), + ) + + self.coordinator = coordinator + self.lists = coordinator.lists + + async def _async_update_data(self) -> dict[str, BringActivityData]: + """Fetch activity data from bring.""" + + list_dict: dict[str, BringActivityData] = {} + for lst in self.lists: + if ( + ctx := set(self.coordinator.async_contexts()) + ) and lst.listUuid not in ctx: + continue + try: + activity = await self.coordinator.bring.get_activity(lst.listUuid) + users = await self.coordinator.bring.get_list_users(lst.listUuid) + except BringAuthException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={CONF_EMAIL: self.coordinator.bring.mail}, + ) from e + except BringRequestException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e + except BringParseException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e + else: + list_dict[lst.listUuid] = BringActivityData(activity, users) + + return list_dict diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index 6c2f779ef05..2f5a0cae504 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -4,10 +4,14 @@ from __future__ import annotations from typing import Any +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_EMAIL, CONF_NAME from homeassistant.core import HomeAssistant from .coordinator import BringConfigEntry +TO_REDACT = {CONF_NAME, CONF_EMAIL} + async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: BringConfigEntry @@ -15,7 +19,13 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" return { - "data": {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()}, - "lists": [lst.to_dict() for lst in config_entry.runtime_data.lists], - "user_settings": config_entry.runtime_data.user_settings.to_dict(), + "data": { + k: v.to_dict() for k, v in config_entry.runtime_data.data.data.items() + }, + "activity": { + k: async_redact_data(v.to_dict(), TO_REDACT) + for k, v in config_entry.runtime_data.activity.data.items() + }, + "lists": [lst.to_dict() for lst in config_entry.runtime_data.data.lists], + "user_settings": config_entry.runtime_data.data.user_settings.to_dict(), } diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index ee90f22beef..1bb49afeb5d 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -8,17 +8,17 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import BringDataUpdateCoordinator +from .coordinator import BringBaseCoordinator -class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): +class BringBaseEntity(CoordinatorEntity[BringBaseCoordinator]): """Bring base entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: BringDataUpdateCoordinator, + coordinator: BringBaseCoordinator, bring_list: BringList, ) -> None: """Initialize the entity.""" @@ -34,5 +34,7 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): }, manufacturer="Bring! Labs AG", model="Bring! Grocery Shopping List", - configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}", + configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}" + if bring_list in self.coordinator.lists + else None, ) diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py index 08d06b596b8..e9e286dccf0 100644 --- a/homeassistant/components/bring/event.py +++ b/homeassistant/components/bring/event.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BringConfigEntry -from .coordinator import BringDataUpdateCoordinator +from .coordinator import BringActivityCoordinator from .entity import BringBaseEntity PARALLEL_UPDATES = 0 @@ -32,18 +32,18 @@ async def async_setup_entry( """Add event entities.""" nonlocal lists_added - if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + if new_lists := {lst.listUuid for lst in coordinator.data.lists} - lists_added: async_add_entities( BringEventEntity( - coordinator, + coordinator.activity, bring_list, ) - for bring_list in coordinator.lists + for bring_list in coordinator.data.lists if bring_list.listUuid in new_lists ) lists_added |= new_lists - coordinator.async_add_listener(add_entities) + coordinator.activity.async_add_listener(add_entities) add_entities() @@ -51,10 +51,11 @@ class BringEventEntity(BringBaseEntity, EventEntity): """An event entity.""" _attr_translation_key = "activities" + coordinator: BringActivityCoordinator def __init__( self, - coordinator: BringDataUpdateCoordinator, + coordinator: BringActivityCoordinator, bring_list: BringList, ) -> None: """Initialize the entity.""" @@ -77,9 +78,12 @@ class BringEventEntity(BringBaseEntity, EventEntity): attributes = asdict(activity.content) attributes["last_activity_by"] = next( - x.name - for x in bring_list.users.users - if x.publicUuid == activity.content.publicUserUuid + ( + x.name + for x in bring_list.users.users + if x.publicUuid == activity.content.publicUserUuid + ), + None, ) self._trigger_event( diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index f292b10f7dc..b2d42835cce 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["bring_api"], "quality_scale": "platinum", - "requirements": ["bring-api==1.0.2"] + "requirements": ["bring-api==1.1.0"] } diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 2a09d574607..88399ea26f7 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -88,7 +88,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.data lists_added: set[str] = set() @callback @@ -117,6 +117,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity): """A sensor entity.""" entity_description: BringSensorEntityDescription + coordinator: BringDataUpdateCoordinator def __init__( self, diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 1dbe0adbf6c..2c30af5adce 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -13,7 +13,7 @@ }, "data_description": { "email": "The email address associated with your Bring! account.", - "password": "The password to login to your Bring! account." + "password": "The password to log in to your Bring! account." } }, "reauth_confirm": { diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index d1eb9e78341..04902f3e724 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -44,7 +44,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.data lists_added: set[str] = set() @callback @@ -88,6 +88,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): | TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) + coordinator: BringDataUpdateCoordinator def __init__( self, coordinator: BringDataUpdateCoordinator, bring_list: BringList @@ -107,7 +108,9 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): description=item.specification, status=TodoItemStatus.NEEDS_ACTION, ) - for item in self.bring_list.content.items.purchase + for item in sorted( + self.bring_list.content.items.purchase, key=lambda i: i.itemId + ) ), *( TodoItem( diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 464e6629224..1c1768b58fd 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -9,6 +9,7 @@ from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from .const import DOMAIN from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -25,7 +26,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b host, printer_type=printer_type, snmp_engine=snmp_engine ) except (ConnectionError, SnmpError, TimeoutError) as error: - raise ConfigEntryNotReady from error + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "device": entry.title, + "error": repr(error), + }, + ) from error coordinator = BrotherDataUpdateCoordinator(hass, entry, brother) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/brother/coordinator.py b/homeassistant/components/brother/coordinator.py index 4f518ba8a25..a3c337f27f7 100644 --- a/homeassistant/components/brother/coordinator.py +++ b/homeassistant/components/brother/coordinator.py @@ -26,6 +26,7 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): ) -> None: """Initialize.""" self.brother = brother + self.device_name = config_entry.title super().__init__( hass, @@ -41,5 +42,12 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): async with timeout(20): data = await self.brother.async_update() except (ConnectionError, SnmpError, UnsupportedModelError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "device": self.device_name, + "error": repr(error), + }, + ) from error return data diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index b502ed7e3b9..d0714a199c4 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -159,5 +159,13 @@ "name": "Last restart" } } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while connecting to the {device} printer: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the {device} printer: {error}" + } } } diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index a4d39ea07cc..586543de129 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -170,6 +170,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key="pressure", diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index a7267320de3..4d54c95fd6c 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -12,6 +12,7 @@ from buienradar.constants import ( CONDITION, CONTENT, DATA, + FEELTEMPERATURE, FORECAST, HUMIDITY, MESSAGE, @@ -22,6 +23,7 @@ from buienradar.constants import ( TEMPERATURE, VISIBILITY, WINDAZIMUTH, + WINDGUST, WINDSPEED, ) from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url @@ -200,6 +202,14 @@ class BrData: except (ValueError, TypeError): return None + @property + def feeltemperature(self): + """Return the feeltemperature, or None.""" + try: + return float(self.data.get(FEELTEMPERATURE)) + except (ValueError, TypeError): + return None + @property def pressure(self): """Return the pressure, or None.""" @@ -224,6 +234,14 @@ class BrData: except (ValueError, TypeError): return None + @property + def wind_gust(self): + """Return the windgust, or None.""" + try: + return float(self.data.get(WINDGUST)) + except (ValueError, TypeError): + return None + @property def wind_speed(self): """Return the windspeed, or None.""" diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 4b71024c241..568926ef0cd 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -9,6 +9,7 @@ from buienradar.constants import ( MAX_TEMP, MIN_TEMP, RAIN, + RAIN_CHANCE, WINDAZIMUTH, WINDSPEED, ) @@ -33,6 +34,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, Forecast, @@ -153,7 +155,9 @@ class BrWeather(WeatherEntity): ) self._attr_native_pressure = data.pressure self._attr_native_temperature = data.temperature + self._attr_native_apparent_temperature = data.feeltemperature self._attr_native_visibility = data.visibility + self._attr_native_wind_gust_speed = data.wind_gust self._attr_native_wind_speed = data.wind_speed self._attr_wind_bearing = data.wind_bearing @@ -188,6 +192,7 @@ class BrWeather(WeatherEntity): ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP), ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP), ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN), + ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.get(RAIN_CHANCE), ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH), ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED), } diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index fada4693cf0..73f172dabec 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -138,6 +138,8 @@ class WebDavTodoListEntity(TodoListEntity): await self.hass.async_add_executor_job( partial(self._calendar.save_todo, **item_data), ) + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -172,6 +174,8 @@ class WebDavTodoListEntity(TodoListEntity): obj_type="todo", ), ) + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -195,3 +199,5 @@ class WebDavTodoListEntity(TodoListEntity): await self.hass.async_add_executor_job(item.delete) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV delete error: {err}") from err + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 40d6952fa64..96bf717c3ac 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -153,6 +153,27 @@ def _has_min_duration( return validate +def _has_positive_interval( + start_key: str, end_key: str, duration_key: str +) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that the time span between start and end is greater than zero.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + if (duration := obj.get(duration_key)) is not None: + if duration <= datetime.timedelta(seconds=0): + raise vol.Invalid(f"Expected positive duration ({duration})") + return obj + + if (start := obj.get(start_key)) and (end := obj.get(end_key)): + if start >= end: + raise vol.Invalid( + f"Expected end time to be after start time ({start}, {end})" + ) + return obj + + return validate + + def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: """Verify that all values are of the same type.""" @@ -281,6 +302,7 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All( ), } ), + _has_positive_interval(EVENT_START_DATETIME, EVENT_END_DATETIME, EVENT_DURATION), ) @@ -870,6 +892,7 @@ async def async_get_events_service( end = start + service_call.data[EVENT_DURATION] else: end = service_call.data[EVENT_END_DATETIME] + calendar_event_list = await calendar.async_get_events( calendar.hass, dt_util.as_local(start), dt_util.as_local(end) ) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index c0127c20d05..6612ea5209d 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -74,7 +74,7 @@ }, "get_events": { "name": "Get events", - "description": "Get events on a calendar within a time range.", + "description": "Retrieves events on a calendar within a time range.", "fields": { "start_date_time": { "name": "Start time", diff --git a/homeassistant/components/cambridge_audio/icons.json b/homeassistant/components/cambridge_audio/icons.json index b4346a7fe8e..dda7d71e506 100644 --- a/homeassistant/components/cambridge_audio/icons.json +++ b/homeassistant/components/cambridge_audio/icons.json @@ -11,6 +11,13 @@ }, "audio_output": { "default": "mdi:audio-input-stereo-minijack" + }, + "control_bus_mode": { + "default": "mdi:audio-video-off", + "state": { + "amplifier": "mdi:speaker", + "receiver": "mdi:audio-video" + } } }, "switch": { diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index d18898fa916..e8f92c0b25c 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -11,6 +11,7 @@ from aiostreammagic import ( StreamMagicClient, TransportControl, ) +from aiostreammagic.models import ControlBusMode from homeassistant.components.media_player import ( BrowseMedia, @@ -91,6 +92,8 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): features = BASE_FEATURES if self.client.state.pre_amp_mode: features |= PREAMP_FEATURES + if self.client.state.control_bus == ControlBusMode.AMPLIFIER: + features |= MediaPlayerEntityFeature.VOLUME_STEP if TransportControl.PLAY_PAUSE in controls: features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE for control in controls: @@ -142,6 +145,12 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): @property def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" + if ( + not self.client.play_state.metadata.artist + and self.client.state.source == "IR" + ): + # Return channel instead of artist when playing internet radio + return self.client.play_state.metadata.station return self.client.play_state.metadata.artist @property @@ -169,6 +178,11 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): """Last time the media position was updated.""" return self.client.position_last_updated + @property + def media_channel(self) -> str | None: + """Channel currently playing.""" + return self.client.play_state.metadata.station + @property def is_volume_muted(self) -> bool | None: """Volume mute status.""" diff --git a/homeassistant/components/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py index e7d9136711f..cdc163f555d 100644 --- a/homeassistant/components/cambridge_audio/select.py +++ b/homeassistant/components/cambridge_audio/select.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from aiostreammagic import StreamMagicClient -from aiostreammagic.models import DisplayBrightness +from aiostreammagic.models import ControlBusMode, DisplayBrightness from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -76,6 +76,20 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = ( value_fn=_audio_output_value_fn, set_value_fn=_audio_output_set_value_fn, ), + CambridgeAudioSelectEntityDescription( + key="control_bus_mode", + translation_key="control_bus_mode", + options=[ + ControlBusMode.AMPLIFIER.value, + ControlBusMode.RECEIVER.value, + ControlBusMode.OFF.value, + ], + entity_category=EntityCategory.CONFIG, + value_fn=lambda client: client.state.control_bus, + set_value_fn=lambda client, value: client.set_control_bus_mode( + ControlBusMode(value) + ), + ), ) diff --git a/homeassistant/components/cambridge_audio/strings.json b/homeassistant/components/cambridge_audio/strings.json index 6041232fe65..e2c89bcbbb0 100644 --- a/homeassistant/components/cambridge_audio/strings.json +++ b/homeassistant/components/cambridge_audio/strings.json @@ -46,6 +46,14 @@ }, "audio_output": { "name": "Audio output" + }, + "control_bus_mode": { + "name": "Control Bus mode", + "state": { + "amplifier": "Amplifier", + "receiver": "Receiver", + "off": "[%key:common::state::off%]" + } } }, "switch": { diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index aa5d766c874..ee9d1cbc94f 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -55,13 +55,11 @@ from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, all_with_deprecated_constants, check_if_deprecated_constant, - deprecated_function, dir_with_deprecated_constants, ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.network import get_url from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolDictType @@ -86,18 +84,15 @@ from .img_util import scale_jpeg_camera_image from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401 from .webrtc import ( DATA_ICE_SERVERS, - CameraWebRTCLegacyProvider, CameraWebRTCProvider, - WebRTCAnswer, + WebRTCAnswer, # noqa: F401 WebRTCCandidate, # noqa: F401 WebRTCClientConfiguration, - WebRTCError, + WebRTCError, # noqa: F401 WebRTCMessage, # noqa: F401 WebRTCSendMessage, - async_get_supported_legacy_provider, async_get_supported_provider, async_register_ice_servers, - async_register_rtsp_to_web_rtc_provider, # noqa: F401 async_register_webrtc_provider, # noqa: F401 async_register_ws, ) @@ -436,7 +431,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CACHED_PROPERTIES_WITH_ATTR_ = { "brand", "frame_interval", - "frontend_stream_type", "is_on", "is_recording", "is_streaming", @@ -456,8 +450,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Entity Properties _attr_brand: str | None = None _attr_frame_interval: float = MIN_STREAM_INTERVAL - # Deprecated in 2024.12. Remove in 2025.6 - _attr_frontend_stream_type: StreamType | None _attr_is_on: bool = True _attr_is_recording: bool = False _attr_is_streaming: bool = False @@ -480,24 +472,10 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self.async_update_token() self._create_stream_lock: asyncio.Lock | None = None self._webrtc_provider: CameraWebRTCProvider | None = None - self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None - self._supports_native_sync_webrtc = ( - type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer - ) self._supports_native_async_webrtc = ( type(self).async_handle_async_webrtc_offer != Camera.async_handle_async_webrtc_offer ) - self._deprecate_attr_frontend_stream_type_logged = False - if type(self).frontend_stream_type != Camera.frontend_stream_type: - report_usage( - ( - f"is overwriting the 'frontend_stream_type' property in the {type(self).__name__} class," - " which is deprecated and will be removed in Home Assistant 2025.6, " - ), - core_integration_behavior=ReportBehavior.ERROR, - exclude_integrations={DOMAIN}, - ) @cached_property def entity_picture(self) -> str: @@ -559,40 +537,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the interval between frames of the mjpeg stream.""" return self._attr_frame_interval - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera. - - A camera may have a single stream type which is used to inform the - frontend which camera attributes and player to use. The default type - is to use HLS, and components can override to change the type. - """ - # Deprecated in 2024.12. Remove in 2025.6 - # Use the camera_capabilities instead - if hasattr(self, "_attr_frontend_stream_type"): - if not self._deprecate_attr_frontend_stream_type_logged: - report_usage( - ( - f"is setting the '_attr_frontend_stream_type' attribute in the {type(self).__name__} class," - " which is deprecated and will be removed in Home Assistant 2025.6, " - ), - core_integration_behavior=ReportBehavior.ERROR, - exclude_integrations={DOMAIN}, - ) - - self._deprecate_attr_frontend_stream_type_logged = True - return self._attr_frontend_stream_type - if CameraEntityFeature.STREAM not in self.supported_features_compat: - return None - if ( - self._webrtc_provider - or self._legacy_webrtc_provider - or self._supports_native_sync_webrtc - or self._supports_native_async_webrtc - ): - return StreamType.WEB_RTC - return StreamType.HLS - @property def available(self) -> bool: """Return True if entity is available.""" @@ -631,15 +575,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ return None - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: - """Handle the WebRTC offer and return an answer. - - This is used by cameras with CameraEntityFeature.STREAM - and StreamType.WEB_RTC. - - Integrations can override with a native WebRTC implementation. - """ - async def async_handle_async_webrtc_offer( self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage ) -> None: @@ -652,56 +587,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): Integrations can override with a native WebRTC implementation. """ - if self._supports_native_sync_webrtc: - try: - answer = await deprecated_function( - "async_handle_async_webrtc_offer", - breaks_in_ha_version="2025.6", - )(self.async_handle_web_rtc_offer)(offer_sdp) - except ValueError as ex: - _LOGGER.error("Error handling WebRTC offer: %s", ex) - send_message( - WebRTCError( - "webrtc_offer_failed", - str(ex), - ) - ) - except TimeoutError: - # This catch was already here and should stay through the deprecation - _LOGGER.error("Timeout handling WebRTC offer") - send_message( - WebRTCError( - "webrtc_offer_failed", - "Timeout handling WebRTC offer", - ) - ) - else: - if answer: - send_message(WebRTCAnswer(answer)) - else: - _LOGGER.error("Error handling WebRTC offer: No answer") - send_message( - WebRTCError( - "webrtc_offer_failed", - "No answer on WebRTC offer", - ) - ) - return - if self._webrtc_provider: await self._webrtc_provider.async_handle_async_webrtc_offer( self, offer_sdp, session_id, send_message ) return - if self._legacy_webrtc_provider and ( - answer := await self._legacy_webrtc_provider.async_handle_web_rtc_offer( - self, offer_sdp - ) - ): - send_message(WebRTCAnswer(answer)) - else: - raise HomeAssistantError("Camera does not support WebRTC") + raise HomeAssistantError("Camera does not support WebRTC") def camera_image( self, width: int | None = None, height: int | None = None @@ -797,9 +689,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if motion_detection_enabled := self.motion_detection_enabled: attrs["motion_detection"] = motion_detection_enabled - if frontend_stream_type := self.frontend_stream_type: - attrs["frontend_stream_type"] = frontend_stream_type - return attrs @callback @@ -823,28 +712,17 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): providers or inputs to the state attributes change. """ old_provider = self._webrtc_provider - old_legacy_provider = self._legacy_webrtc_provider new_provider = None - new_legacy_provider = None # Skip all providers if the camera has a native WebRTC implementation - if not ( - self._supports_native_sync_webrtc or self._supports_native_async_webrtc - ): + if not self._supports_native_async_webrtc: # Camera doesn't have a native WebRTC implementation new_provider = await self._async_get_supported_webrtc_provider( async_get_supported_provider ) - if new_provider is None: - # Only add the legacy provider if the new provider is not available - new_legacy_provider = await self._async_get_supported_webrtc_provider( - async_get_supported_legacy_provider - ) - - if old_provider != new_provider or old_legacy_provider != new_legacy_provider: + if old_provider != new_provider: self._webrtc_provider = new_provider - self._legacy_webrtc_provider = new_legacy_provider self._invalidate_camera_capabilities_cache() if write_state: self.async_write_ha_state() @@ -869,20 +747,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the WebRTC client configuration and extend it with the registered ice servers.""" config = self._async_get_webrtc_client_configuration() - if not self._supports_native_sync_webrtc: - # Until 2024.11, the frontend was not resolving any ice servers - # The async approach was added 2024.11 and new integrations need to use it - ice_servers = [ - server - for servers in self.hass.data.get(DATA_ICE_SERVERS, []) - for server in servers() - ] - config.configuration.ice_servers.extend(ice_servers) - - config.get_candidates_upfront = ( - self._supports_native_sync_webrtc - or self._legacy_webrtc_provider is not None - ) + ice_servers = [ + server + for servers in self.hass.data.get(DATA_ICE_SERVERS, []) + for server in servers() + ] + config.configuration.ice_servers.extend(ice_servers) return config @@ -912,13 +782,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the camera capabilities.""" frontend_stream_types = set() if CameraEntityFeature.STREAM in self.supported_features_compat: - if self._supports_native_sync_webrtc or self._supports_native_async_webrtc: + if self._supports_native_async_webrtc: # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) else: frontend_stream_types.add(StreamType.HLS) - if self._webrtc_provider or self._legacy_webrtc_provider: + if self._webrtc_provider: frontend_stream_types.add(StreamType.WEB_RTC) return CameraCapabilities(frontend_stream_types) diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index bbe85bf82db..971e6804add 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -2,17 +2,10 @@ from __future__ import annotations -from contextlib import suppress import logging from typing import TYPE_CHECKING, Literal, cast -with suppress(Exception): - # TurboJPEG imports numpy which may or may not work so - # we have to guard the import here. We still want - # to import it at top level so it gets loaded - # in the import executor and not in the event loop. - from turbojpeg import TurboJPEG - +from turbojpeg import TurboJPEG if TYPE_CHECKING: from . import Image diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json index 4a7e9aafc6e..9176c5ad84a 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -46,10 +46,6 @@ } } } - }, - "legacy_webrtc_provider": { - "title": "Detected use of legacy WebRTC provider registered by {legacy_integration}", - "description": "The {legacy_integration} integration has registered a legacy WebRTC provider. Home Assistant prefers using the built-in modern WebRTC provider registered by the {builtin_integration} integration.\n\nBenefits of the built-in integration are:\n\n- The camera stream is started faster.\n- More camera devices are supported.\n\nTo fix this issue, you can either keep using the built-in modern WebRTC provider and remove the {legacy_integration} integration or remove the {builtin_integration} integration to use the legacy provider, and then restart Home Assistant." } }, "services": { diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index 3630acf1cfe..9ad50430f83 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable, Iterable from dataclasses import asdict, dataclass, field from functools import cache, partial, wraps import logging -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any from mashumaro import MissingField import voluptuous as vol @@ -22,8 +22,7 @@ from webrtc_models import ( 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, issue_registry as ir -from homeassistant.helpers.deprecation import deprecated_function +from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey from homeassistant.util.ulid import ulid @@ -39,9 +38,6 @@ _LOGGER = logging.getLogger(__name__) DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey( "camera_webrtc_providers" ) -DATA_WEBRTC_LEGACY_PROVIDERS: HassKey[dict[str, CameraWebRTCLegacyProvider]] = HassKey( - "camera_webrtc_legacy_providers" -) DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey( "camera_webrtc_ice_servers" ) @@ -115,13 +111,11 @@ class WebRTCClientConfiguration: configuration: RTCConfiguration = field(default_factory=RTCConfiguration) data_channel: str | None = None - get_candidates_upfront: bool = False def to_frontend_dict(self) -> dict[str, Any]: """Return a dict that can be used by the frontend.""" data: dict[str, Any] = { "configuration": self.configuration.to_dict(), - "getCandidatesUpfront": self.get_candidates_upfront, } if self.data_channel is not None: data["dataChannel"] = self.data_channel @@ -163,18 +157,6 @@ class CameraWebRTCProvider(ABC): return ## This is an optional method so we need a default here. -class CameraWebRTCLegacyProvider(Protocol): - """WebRTC provider.""" - - async def async_is_supported(self, stream_source: str) -> bool: - """Determine if the provider supports the stream source.""" - - async def async_handle_web_rtc_offer( - self, camera: Camera, offer_sdp: str - ) -> str | None: - """Handle the WebRTC offer and return an answer.""" - - @callback def async_register_webrtc_provider( hass: HomeAssistant, @@ -204,8 +186,6 @@ def async_register_webrtc_provider( async def _async_refresh_providers(hass: HomeAssistant) -> None: """Check all cameras for any state changes for registered providers.""" - _async_check_conflicting_legacy_provider(hass) - component = hass.data[DATA_COMPONENT] await asyncio.gather( *(camera.async_refresh_providers() for camera in component.entities) @@ -380,21 +360,6 @@ async def async_get_supported_provider( return None -async def async_get_supported_legacy_provider( - hass: HomeAssistant, camera: Camera -) -> CameraWebRTCLegacyProvider | None: - """Return the first supported provider for the camera.""" - providers = hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS) - if not providers or not (stream_source := await camera.stream_source()): - return None - - for provider in providers.values(): - if await provider.async_is_supported(stream_source): - return provider - - return None - - @callback def async_register_ice_servers( hass: HomeAssistant, @@ -411,94 +376,3 @@ def async_register_ice_servers( servers.append(get_ice_server_fn) return remove - - -# The following code is legacy code that was introduced with rtsp_to_webrtc and will be deprecated/removed in the future. -# Left it so custom integrations can still use it. - -_RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"} - -# An RtspToWebRtcProvider accepts these inputs: -# stream_source: The RTSP url -# offer_sdp: The WebRTC SDP offer -# stream_id: A unique id for the stream, used to update an existing source -# The output is the SDP answer, or None if the source or offer is not eligible. -# The Callable may throw HomeAssistantError on failure. -type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]] - - -class _CameraRtspToWebRTCProvider(CameraWebRTCLegacyProvider): - def __init__(self, fn: RtspToWebRtcProviderType) -> None: - """Initialize the RTSP to WebRTC provider.""" - self._fn = fn - - async def async_is_supported(self, stream_source: str) -> bool: - """Return if this provider is supports the Camera as source.""" - return any(stream_source.startswith(prefix) for prefix in _RTSP_PREFIXES) - - async def async_handle_web_rtc_offer( - self, camera: Camera, offer_sdp: str - ) -> str | None: - """Handle the WebRTC offer and return an answer.""" - if not (stream_source := await camera.stream_source()): - return None - - return await self._fn(stream_source, offer_sdp, camera.entity_id) - - -@deprecated_function("async_register_webrtc_provider", breaks_in_ha_version="2025.6") -def async_register_rtsp_to_web_rtc_provider( - hass: HomeAssistant, - domain: str, - provider: RtspToWebRtcProviderType, -) -> Callable[[], None]: - """Register an RTSP to WebRTC provider. - - The first provider to satisfy the offer will be used. - """ - if DOMAIN not in hass.data: - raise ValueError("Unexpected state, camera not loaded") - - legacy_providers = hass.data.setdefault(DATA_WEBRTC_LEGACY_PROVIDERS, {}) - - if domain in legacy_providers: - raise ValueError("Provider already registered") - - provider_instance = _CameraRtspToWebRTCProvider(provider) - - @callback - def remove_provider() -> None: - legacy_providers.pop(domain) - hass.async_create_task(_async_refresh_providers(hass)) - - legacy_providers[domain] = provider_instance - hass.async_create_task(_async_refresh_providers(hass)) - - return remove_provider - - -@callback -def _async_check_conflicting_legacy_provider(hass: HomeAssistant) -> None: - """Check if a legacy provider is registered together with the builtin provider.""" - builtin_provider_domain = "go2rtc" - if ( - (legacy_providers := hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS)) - and (providers := hass.data.get(DATA_WEBRTC_PROVIDERS)) - and any(provider.domain == builtin_provider_domain for provider in providers) - ): - for domain in legacy_providers: - ir.async_create_issue( - hass, - DOMAIN, - f"legacy_webrtc_provider_{domain}", - is_fixable=False, - is_persistent=False, - issue_domain=domain, - learn_more_url="https://www.home-assistant.io/integrations/go2rtc/", - severity=ir.IssueSeverity.WARNING, - translation_key="legacy_webrtc_provider", - translation_placeholders={ - "legacy_integration": domain, - "builtin_integration": builtin_provider_domain, - }, - ) diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index b0e59e49a6f..4ea1bf48cf0 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -8,46 +8,18 @@ from typing import Final from canary.api import Api 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 from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform 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 ( - CONF_FFMPEG_ARGUMENTS, - DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_TIMEOUT, - DOMAIN, -) +from .const import CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator _LOGGER: Final = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=30) -CONFIG_SCHEMA: Final = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_TIMEOUT, default=DEFAULT_TIMEOUT - ): cv.positive_int, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - PLATFORMS: Final[list[Platform]] = [ Platform.ALARM_CONTROL_PANEL, Platform.CAMERA, @@ -55,37 +27,6 @@ PLATFORMS: Final[list[Platform]] = [ ] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Canary integration.""" - if hass.config_entries.async_entries(DOMAIN): - return True - - ffmpeg_arguments = DEFAULT_FFMPEG_ARGUMENTS - if CAMERA_DOMAIN in config: - camera_config = next( - (item for item in config[CAMERA_DOMAIN] if item["platform"] == DOMAIN), - None, - ) - - if camera_config: - ffmpeg_arguments = camera_config.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ) - - if DOMAIN in config: - if ffmpeg_arguments != DEFAULT_FFMPEG_ARGUMENTS: - config[DOMAIN][CONF_FFMPEG_ARGUMENTS] = ffmpeg_arguments - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - return True - - async def async_setup_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool: """Set up Canary from a config entry.""" if not entry.options: diff --git a/homeassistant/components/canary/config_flow.py b/homeassistant/components/canary/config_flow.py index 17e660e96ac..390f65904fe 100644 --- a/homeassistant/components/canary/config_flow.py +++ b/homeassistant/components/canary/config_flow.py @@ -54,10 +54,6 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return CanaryOptionsFlowHandler() - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle a flow initiated by configuration file.""" - return await self.async_step_user(import_data) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/canary/strings.json b/homeassistant/components/canary/strings.json index 699e8b25e11..8be11a48b5e 100644 --- a/homeassistant/components/canary/strings.json +++ b/homeassistant/components/canary/strings.json @@ -21,8 +21,8 @@ "step": { "init": { "data": { - "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras", - "timeout": "Request Timeout (seconds)" + "ffmpeg_arguments": "Arguments passed to FFmpeg for cameras", + "timeout": "Request timeout (seconds)" } } } diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index 034cf856023..6c33eac230f 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -16,12 +16,21 @@ 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.selector import SelectSelector, SelectSelectorConfig from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) -KNOWN_HOSTS_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) +KNOWN_HOSTS_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_KNOWN_HOSTS, + ): SelectSelector( + SelectSelectorConfig(custom_value=True, options=[], multiple=True), + ) + } +) WANTED_UUID_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) @@ -30,12 +39,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize flow.""" - self._ignore_cec = set[str]() - self._known_hosts = set[str]() - self._wanted_uuid = set[str]() - @staticmethod @callback def async_get_options_flow( @@ -62,48 +65,31 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm the setup.""" - errors = {} - data = {CONF_KNOWN_HOSTS: self._known_hosts} - if user_input is not None: - bad_hosts = False - known_hosts = user_input[CONF_KNOWN_HOSTS] - known_hosts = [x.strip() for x in known_hosts.split(",") if x.strip()] - try: - known_hosts = KNOWN_HOSTS_SCHEMA(known_hosts) - except vol.Invalid: - errors["base"] = "invalid_known_hosts" - bad_hosts = True - else: - self._known_hosts = known_hosts - data = self._get_data() - if not bad_hosts: - return self.async_create_entry(title="Google Cast", data=data) + known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, [])) + return self.async_create_entry( + title="Google Cast", + data=self._get_data(known_hosts=known_hosts), + ) - fields = {} - fields[vol.Optional(CONF_KNOWN_HOSTS, default="")] = str - - return self.async_show_form( - step_id="config", data_schema=vol.Schema(fields), errors=errors - ) + return self.async_show_form(step_id="config", data_schema=KNOWN_HOSTS_SCHEMA) async def async_step_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm the setup.""" - - data = self._get_data() - if user_input is not None or not onboarding.async_is_onboarded(self.hass): - return self.async_create_entry(title="Google Cast", data=data) + return self.async_create_entry(title="Google Cast", data=self._get_data()) return self.async_show_form(step_id="confirm") - def _get_data(self): + def _get_data( + self, *, known_hosts: list[str] | None = None + ) -> dict[str, list[str]]: return { - CONF_IGNORE_CEC: list(self._ignore_cec), - CONF_KNOWN_HOSTS: list(self._known_hosts), - CONF_UUID: list(self._wanted_uuid), + CONF_IGNORE_CEC: [], + CONF_KNOWN_HOSTS: known_hosts or [], + CONF_UUID: [], } @@ -123,31 +109,24 @@ class CastOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Manage the Google Cast options.""" errors: dict[str, str] = {} - current_config = self.config_entry.data if user_input is not None: - bad_hosts, known_hosts = _string_to_list( - user_input.get(CONF_KNOWN_HOSTS, ""), KNOWN_HOSTS_SCHEMA + known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, [])) + self.updated_config = dict(self.config_entry.data) + self.updated_config[CONF_KNOWN_HOSTS] = known_hosts + + if self.show_advanced_options: + return await self.async_step_advanced_options() + + self.hass.config_entries.async_update_entry( + self.config_entry, data=self.updated_config ) - - if not bad_hosts: - self.updated_config = dict(current_config) - self.updated_config[CONF_KNOWN_HOSTS] = known_hosts - - if self.show_advanced_options: - return await self.async_step_advanced_options() - - self.hass.config_entries.async_update_entry( - self.config_entry, data=self.updated_config - ) - return self.async_create_entry(title="", data={}) - - fields: dict[vol.Marker, type[str]] = {} - suggested_value = _list_to_string(current_config.get(CONF_KNOWN_HOSTS)) - _add_with_suggestion(fields, CONF_KNOWN_HOSTS, suggested_value) + return self.async_create_entry(title="", data={}) return self.async_show_form( step_id="basic_options", - data_schema=vol.Schema(fields), + data_schema=self.add_suggested_values_to_schema( + KNOWN_HOSTS_SCHEMA, self.config_entry.data + ), errors=errors, last_step=not self.show_advanced_options, ) @@ -206,6 +185,10 @@ def _string_to_list(string, schema): return invalid, items +def _trim_items(items: list[str]) -> list[str]: + return [x.strip() for x in items if x.strip()] + + def _add_with_suggestion( fields: dict[vol.Marker, type[str]], key: str, suggested_value: str ) -> None: diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index 056ee054d1d..0a85a0007b3 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, NotRequired, TypedDict from homeassistant.util.signal_type import SignalType @@ -46,3 +46,4 @@ class HomeAssistantControllerData(TypedDict): hass_uuid: str client_id: str | None refresh_token: str + app_id: NotRequired[str] diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 8f4af197b8e..c45bbb4fbbc 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -7,6 +7,7 @@ from dataclasses import dataclass import logging from typing import TYPE_CHECKING, ClassVar from urllib.parse import urlparse +from uuid import UUID import aiohttp import attr @@ -40,7 +41,7 @@ class ChromecastInfo: is_dynamic_group = attr.ib(type=bool | None, default=None) @property - def friendly_name(self) -> str: + def friendly_name(self) -> str | None: """Return the Friendly Name.""" return self.cast_info.friendly_name @@ -50,7 +51,7 @@ class ChromecastInfo: return self.cast_info.cast_type == CAST_TYPE_GROUP @property - def uuid(self) -> bool: + def uuid(self) -> UUID: """Return the UUID.""" return self.cast_info.uuid @@ -80,7 +81,7 @@ class ChromecastInfo: "+label%3A%22integration%3A+cast%22" ) - _LOGGER.debug( + _LOGGER.info( ( "Fetched cast details for unknown model '%s' manufacturer:" " '%s', type: '%s'. Please %s" @@ -111,7 +112,10 @@ class ChromecastInfo: is_dynamic_group = False http_group_status = None http_group_status = dial.get_multizone_status( - None, + # We pass services which will be used for the HTTP request, and we + # don't care about the host in http_group_status.dynamic_groups so + # we pass an empty string to simplify the code. + "", services=self.cast_info.services, zconf=ChromeCastZeroconf.get_zeroconf(), ) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 0650f267544..6c8b0536e2f 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,7 +14,7 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==14.0.5"], + "requirements": ["PyChromecast==14.0.7"], "single_config_entry": true, "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 8ff078dfafd..e17360127b9 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -60,7 +60,7 @@ from .const import ( ADDED_CAST_DEVICES_KEY, CAST_MULTIZONE_MANAGER_KEY, CONF_IGNORE_CEC, - DOMAIN as CAST_DOMAIN, + DOMAIN, SIGNAL_CAST_DISCOVERED, SIGNAL_CAST_REMOVED, SIGNAL_HASS_CAST_SHOW_VIEW, @@ -315,7 +315,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): self._cast_view_remove_handler: CALLBACK_TYPE | None = None self._attr_unique_id = str(cast_info.uuid) self._attr_device_info = DeviceInfo( - identifiers={(CAST_DOMAIN, str(cast_info.uuid).replace("-", ""))}, + identifiers={(DOMAIN, str(cast_info.uuid).replace("-", ""))}, manufacturer=str(cast_info.cast_info.manufacturer), model=cast_info.cast_info.model_name, name=str(cast_info.friendly_name), @@ -591,7 +591,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): """Generate root node.""" children = [] # Add media browsers - for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values(): + for platform in self.hass.data[DOMAIN]["cast_platform"].values(): children.extend( await platform.async_get_media_browser_root_object( self.hass, self._chromecast.cast_type @@ -650,7 +650,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): platform: CastProtocol assert media_content_type is not None - for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values(): + for platform in self.hass.data[DOMAIN]["cast_platform"].values(): browse_media = await platform.async_browse_media( self.hass, media_content_type, @@ -680,7 +680,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) # Handle media supported by a known cast app - if media_type == CAST_DOMAIN: + if media_type == DOMAIN: try: app_data = json.loads(media_id) if metadata := extra.get("metadata"): @@ -712,7 +712,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): return # Try the cast platforms - for platform in self.hass.data[CAST_DOMAIN]["cast_platform"].values(): + for platform in self.hass.data[DOMAIN]["cast_platform"].values(): result = await platform.async_play_media( self.hass, self.entity_id, chromecast, media_type, media_id ) diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml index e2e23ad40a2..45b36f6d983 100644 --- a/homeassistant/components/cast/services.yaml +++ b/homeassistant/components/cast/services.yaml @@ -7,11 +7,11 @@ show_lovelace_view: integration: cast domain: media_player dashboard_path: - required: true example: lovelace-cast selector: text: view_path: + required: true example: downstairs selector: text: diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index 9c49813bd83..aa52d21e05f 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -6,23 +6,27 @@ }, "config": { "title": "Google Cast configuration", - "description": "Known Hosts - A comma-separated list of hostnames or IP-addresses of cast devices, use if mDNS discovery is not working.", "data": { - "known_hosts": "Known hosts" + "known_hosts": "Add known host" + }, + "data_description": { + "known_hosts": "Hostnames or IP addresses of cast devices, use if mDNS discovery is not working" } } }, "error": { - "invalid_known_hosts": "Known hosts must be a comma separated list of hosts." + "invalid_known_hosts": "Known hosts must be a comma-separated list of hosts." } }, "options": { "step": { "basic_options": { "title": "[%key:component::cast::config::step::config::title%]", - "description": "[%key:component::cast::config::step::config::description%]", "data": { "known_hosts": "[%key:component::cast::config::step::config::data::known_hosts%]" + }, + "data_description": { + "known_hosts": "[%key:component::cast::config::step::config::data_description::known_hosts%]" } }, "advanced_options": { @@ -49,7 +53,7 @@ }, "dashboard_path": { "name": "Dashboard path", - "description": "The URL path of the dashboard to show." + "description": "The URL path of the dashboard to show, defaults to lovelace if not specified." }, "view_path": { "name": "View path", diff --git a/homeassistant/components/chacon_dio/config_flow.py b/homeassistant/components/chacon_dio/config_flow.py index 54604b81153..daaf38e0edc 100644 --- a/homeassistant/components/chacon_dio/config_flow.py +++ b/homeassistant/components/chacon_dio/config_flow.py @@ -44,7 +44,7 @@ class ChaconDioConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except DIOChaconInvalidAuthError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/chacon_dio/manifest.json b/homeassistant/components/chacon_dio/manifest.json index edee24444f7..117982a7ab8 100644 --- a/homeassistant/components/chacon_dio/manifest.json +++ b/homeassistant/components/chacon_dio/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/chacon_dio", "iot_class": "cloud_push", "loggers": ["dio_chacon_api"], - "requirements": ["dio-chacon-wifi-api==1.2.1"] + "requirements": ["dio-chacon-wifi-api==1.2.2"] } diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 3ea0f887e76..287a2397121 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -68,7 +68,6 @@ from .const import ( # noqa: F401 FAN_ON, FAN_TOP, HVAC_MODES, - INTENT_GET_TEMPERATURE, INTENT_SET_TEMPERATURE, PRESET_ACTIVITY, PRESET_AWAY, diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index d347ccbbb29..ecc0066cd93 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -126,7 +126,6 @@ DEFAULT_MAX_HUMIDITY = 99 DOMAIN = "climate" -INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" INTENT_SET_TEMPERATURE = "HassClimateSetTemperature" SERVICE_SET_AUX_HEAT = "set_aux_heat" diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 9837a326188..7691a2db0f1 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -1,4 +1,4 @@ -"""Intents for the client integration.""" +"""Intents for the climate integration.""" from __future__ import annotations @@ -11,7 +11,6 @@ from homeassistant.helpers import config_validation as cv, intent from . import ( ATTR_TEMPERATURE, DOMAIN, - INTENT_GET_TEMPERATURE, INTENT_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE, ClimateEntityFeature, @@ -20,49 +19,9 @@ from . import ( async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the climate intents.""" - intent.async_register(hass, GetTemperatureIntent()) intent.async_register(hass, SetTemperatureIntent()) -class GetTemperatureIntent(intent.IntentHandler): - """Handle GetTemperature intents.""" - - intent_type = INTENT_GET_TEMPERATURE - description = "Gets the current temperature of a climate device or entity" - slot_schema = { - vol.Optional("area"): intent.non_empty_string, - vol.Optional("name"): intent.non_empty_string, - } - platforms = {DOMAIN} - - async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: - """Handle the intent.""" - hass = intent_obj.hass - slots = self.async_validate_slots(intent_obj.slots) - - name: str | None = None - if "name" in slots: - name = slots["name"]["value"] - - area: str | None = None - if "area" in slots: - area = slots["area"]["value"] - - match_constraints = intent.MatchTargetsConstraints( - name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant - ) - match_result = intent.async_match_targets(hass, match_constraints) - if not match_result.is_match: - raise intent.MatchFailedError( - result=match_result, constraints=match_constraints - ) - - response = intent_obj.create_response() - response.response_type = intent.IntentResponseType.QUERY_ANSWER - response.async_set_states(matched_states=match_result.states) - return response - - class SetTemperatureIntent(intent.IntentHandler): """Handle SetTemperature intents.""" diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 6d8b2c5449d..250b2a67efe 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -28,10 +28,10 @@ "name": "Thermostat", "state": { "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "heat": "Heat", "cool": "Cool", "heat_cool": "Heat/Cool", - "auto": "Auto", "dry": "Dry", "fan_only": "Fan only" }, @@ -50,10 +50,10 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "auto": "Auto", - "low": "Low", - "medium": "Medium", - "high": "High", + "auto": "[%key:common::state::auto%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "top": "Top", "middle": "Middle", "focus": "Focus", @@ -69,13 +69,13 @@ "hvac_action": { "name": "Current action", "state": { + "off": "[%key:common::state::off%]", + "idle": "[%key:common::state::idle%]", "cooling": "Cooling", "defrosting": "Defrosting", "drying": "Drying", "fan": "Fan", "heating": "Heating", - "idle": "[%key:common::state::idle%]", - "off": "[%key:common::state::off%]", "preheating": "Preheating" } }, @@ -98,13 +98,13 @@ "name": "Preset", "state": { "none": "None", - "eco": "Eco", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", + "activity": "Activity", "boost": "Boost", "comfort": "Comfort", - "home": "[%key:common::state::home%]", - "sleep": "Sleep", - "activity": "Activity" + "eco": "Eco", + "sleep": "Sleep" } }, "preset_modes": { @@ -257,8 +257,8 @@ "selector": { "hvac_mode": { "options": { - "off": "Off", - "auto": "Auto", + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "cool": "Cool", "dry": "Dry", "fan_only": "Fan only", diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 97210b4197c..2c7c6f80d49 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -61,7 +61,6 @@ from .const import ( CONF_RELAYER_SERVER, CONF_REMOTESTATE_SERVER, CONF_SERVICEHANDLERS_SERVER, - CONF_THINGTALK_SERVER, CONF_USER_POOL_ID, DATA_CLOUD, DATA_CLOUD_LOG_HANDLER, @@ -134,7 +133,6 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_CLOUDHOOK_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str, - vol.Optional(CONF_THINGTALK_SERVER): str, vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, } ) diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 851d658f8e0..3c3d944d479 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement flow_id=flow_id, user_input=tokens ) - self.hass.async_create_task(await_tokens()) + # It's a background task because it should be cancelled on shutdown and there's nothing else + # we can do in such case. There's also no need to wait for this during setup. + self.hass.async_create_background_task( + await_tokens(), name="Awaiting OAuth tokens" + ) return authorize_url diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 5b77a02384d..5bd40eb5b83 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -43,7 +43,7 @@ from homeassistant.util.dt import utcnow from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, - DOMAIN as CLOUD_DOMAIN, + DOMAIN, PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, PREF_SHOULD_EXPOSE, @@ -55,7 +55,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" +CLOUD_ALEXA = f"{DOMAIN}.{ALEXA_DOMAIN}" # Time to wait when entity preferences have changed before syncing it to # the cloud. diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index b31fe16fbe9..f4426eabeed 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -4,13 +4,14 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping +from http import HTTPStatus import logging import random from typing import Any -from aiohttp import ClientError +from aiohttp import ClientError, ClientResponseError from hass_nabucasa import Cloud, CloudError -from hass_nabucasa.api import CloudApiNonRetryableError +from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError from hass_nabucasa.cloud_api import ( FilesHandlerListEntry, async_files_delete_file, @@ -18,7 +19,12 @@ from hass_nabucasa.cloud_api import ( ) from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -90,9 +96,7 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ - if not (backup := await self._async_get_backup(backup_id)): - raise BackupAgentError("Backup not found") - + backup = await self._async_get_backup(backup_id) try: content = await self._cloud.files.download( storage_type=StorageType.BACKUP, @@ -117,6 +121,8 @@ class CloudBackupAgent(BackupAgent): """ if not backup.protected: raise BackupAgentError("Cloud backups must be protected") + if self._cloud.subscription_expired: + raise BackupAgentError("Cloud subscription has expired") size = backup.size try: @@ -149,6 +155,13 @@ class CloudBackupAgent(BackupAgent): ) from err raise BackupAgentError(f"Failed to upload backup {err}") from err except CloudError as err: + if ( + isinstance(err, CloudApiError) + and isinstance(err.orig_exc, ClientResponseError) + and err.orig_exc.status == HTTPStatus.FORBIDDEN + and self._cloud.subscription_expired + ): + raise BackupAgentError("Cloud subscription has expired") from err if tries == _RETRY_LIMIT: raise BackupAgentError(f"Failed to upload backup {err}") from err tries += 1 @@ -171,9 +184,7 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - if not (backup := await self._async_get_backup(backup_id)): - return - + backup = await self._async_get_backup(backup_id) try: await async_files_delete_file( self._cloud, @@ -204,16 +215,12 @@ class CloudBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" - if not (backup := await self._async_get_backup(backup_id)): - return None + backup = await self._async_get_backup(backup_id) return AgentBackup.from_dict(backup["Metadata"]) - async def _async_get_backup( - self, - backup_id: str, - ) -> FilesHandlerListEntry | None: + async def _async_get_backup(self, backup_id: str) -> FilesHandlerListEntry: """Return a backup.""" backups = await self._async_list_backups() @@ -221,4 +228,4 @@ class CloudBackupAgent(BackupAgent): if backup["Metadata"]["backup_id"] == backup_id: return backup - return None + raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index ea3d992e8f7..a857185f07f 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -26,7 +26,11 @@ from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.util.aiohttp import MockRequest, serialize_response from . import alexa_config, google_config @@ -36,8 +40,10 @@ from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) VALID_REPAIR_TRANSLATION_KEYS = { + "no_subscription", "warn_bad_custom_domain_configuration", "reset_bad_custom_domain_configuration", + "subscription_expired", } @@ -399,7 +405,12 @@ class CloudClient(Interface): ) -> None: """Create a repair issue.""" if translation_key not in VALID_REPAIR_TRANSLATION_KEYS: - raise ValueError(f"Invalid translation key {translation_key}") + _LOGGER.error( + "Invalid translation key %s for repair issue %s", + translation_key, + identifier, + ) + return async_create_issue( hass=self._hass, domain=DOMAIN, @@ -409,3 +420,7 @@ class CloudClient(Interface): severity=IssueSeverity(severity), is_fixable=False, ) + + async def async_delete_repair_issue(self, identifier: str) -> None: + """Delete a repair issue.""" + async_delete_issue(hass=self._hass, domain=DOMAIN, issue_id=identifier) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index e0c15c74cab..1f154832ef9 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -81,7 +81,6 @@ CONF_ACME_SERVER = "acme_server" CONF_CLOUDHOOK_SERVER = "cloudhook_server" CONF_RELAYER_SERVER = "relayer_server" CONF_REMOTESTATE_SERVER = "remotestate_server" -CONF_THINGTALK_SERVER = "thingtalk_server" CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" MODE_DEV = "development" @@ -93,3 +92,5 @@ STT_ENTITY_UNIQUE_ID = "cloud-speech-to-text" TTS_ENTITY_UNIQUE_ID = "cloud-text-to-speech" LOGIN_MFA_TIMEOUT = 60 + +VOICE_STYLE_SEPERATOR = "||" diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 43dd5279d35..2b6f45ec474 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -41,7 +41,7 @@ from .const import ( CONF_ENTITY_CONFIG, CONF_FILTER, DEFAULT_DISABLE_2FA, - DOMAIN as CLOUD_DOMAIN, + DOMAIN, PREF_DISABLE_2FA, PREF_SHOULD_EXPOSE, ) @@ -52,7 +52,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" +CLOUD_GOOGLE = f"{DOMAIN}.{GOOGLE_DOMAIN}" SUPPORTED_DOMAINS = { diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 73952d80f6c..998f3fcd5bc 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -16,9 +16,9 @@ from typing import Any, Concatenate, cast import aiohttp from aiohttp import web import attr -from hass_nabucasa import AlreadyConnectedError, Cloud, auth, thingtalk +from hass_nabucasa import AlreadyConnectedError, Cloud, auth from hass_nabucasa.const import STATE_DISCONNECTED -from hass_nabucasa.voice import TTS_VOICES +from hass_nabucasa.voice_data import TTS_VOICES import voluptuous as vol from homeassistant.components import websocket_api @@ -57,6 +57,7 @@ from .const import ( PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, + VOICE_STYLE_SEPERATOR, ) from .google_config import CLOUD_GOOGLE from .repairs import async_manage_legacy_subscription_issue @@ -103,7 +104,6 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, alexa_list) websocket_api.async_register_command(hass, alexa_sync) - websocket_api.async_register_command(hass, thingtalk_convert) websocket_api.async_register_command(hass, tts_info) hass.http.register_view(GoogleActionsSyncView) @@ -245,6 +245,10 @@ class CloudLoginView(HomeAssistantView): name = "api:cloud:login" @require_admin + async def post(self, request: web.Request) -> web.Response: + """Handle login request.""" + return await self._post(request) + @_handle_cloud_errors @RequestDataValidator( vol.Schema( @@ -259,7 +263,7 @@ class CloudLoginView(HomeAssistantView): ) ) ) - async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + 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] @@ -316,8 +320,12 @@ class CloudLogoutView(HomeAssistantView): name = "api:cloud:logout" @require_admin - @_handle_cloud_errors async def post(self, request: web.Request) -> web.Response: + """Handle logout request.""" + return await self._post(request) + + @_handle_cloud_errors + async def _post(self, request: web.Request) -> web.Response: """Handle logout request.""" hass = request.app[KEY_HASS] cloud = hass.data[DATA_CLOUD] @@ -400,9 +408,13 @@ class CloudForgotPasswordView(HomeAssistantView): name = "api:cloud:forgot_password" @require_admin + async def post(self, request: web.Request) -> web.Response: + """Handle forgot password request.""" + return await self._post(request) + @_handle_cloud_errors @RequestDataValidator(vol.Schema({vol.Required("email"): str})) - async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle forgot password request.""" hass = request.app[KEY_HASS] cloud = hass.data[DATA_CLOUD] @@ -579,10 +591,21 @@ async def websocket_subscription( def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]: """Validate language and voice.""" language, voice = value + style: str | None + voice, _, style = voice.partition(VOICE_STYLE_SEPERATOR) + if not style: + style = None if language not in TTS_VOICES: raise vol.Invalid(f"Invalid language {language}") - if voice not in TTS_VOICES[language]: + if voice not in (language_info := TTS_VOICES[language]): raise vol.Invalid(f"Invalid voice {voice} for language {language}") + voice_info = language_info[voice] + if style and ( + isinstance(voice_info, str) or style not in voice_info.get("variants", []) + ): + raise vol.Invalid( + f"Invalid style {style} for voice {voice} in language {language}" + ) return value @@ -974,25 +997,6 @@ async def alexa_sync( ) -@websocket_api.websocket_command({"type": "cloud/thingtalk/convert", "query": str}) -@websocket_api.async_response -async def thingtalk_convert( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Convert a query.""" - cloud = hass.data[DATA_CLOUD] - - async with asyncio.timeout(10): - try: - connection.send_result( - msg["id"], await thingtalk.async_convert(cloud, msg["query"]) - ) - except thingtalk.ThingTalkConversionError as err: - connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err)) - - @websocket_api.websocket_command({"type": "cloud/tts/info"}) def tts_info( hass: HomeAssistant, @@ -1000,13 +1004,24 @@ def tts_info( msg: dict[str, Any], ) -> None: """Fetch available tts info.""" - connection.send_result( - msg["id"], - { - "languages": [ - (language, voice) - for language, voices in TTS_VOICES.items() - for voice in voices - ] - }, - ) + result = [] + for language, voices in TTS_VOICES.items(): + for voice_id, voice_info in voices.items(): + if isinstance(voice_info, str): + result.append((language, voice_id, voice_info)) + continue + + name = voice_info["name"] + result.append((language, voice_id, name)) + result.extend( + [ + ( + language, + f"{voice_id}{VOICE_STYLE_SEPERATOR}{variant}", + f"{name} ({variant})", + ) + for variant in voice_info.get("variants", []) + ] + ) + + connection.send_result(msg["id"], {"languages": result}) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 4e99d08afb5..faee244a074 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.92.0"], + "requirements": ["hass-nabucasa==0.101.0"], "single_config_entry": true } diff --git a/homeassistant/components/cloud/onboarding.py b/homeassistant/components/cloud/onboarding.py new file mode 100644 index 00000000000..ab0a0fbe310 --- /dev/null +++ b/homeassistant/components/cloud/onboarding.py @@ -0,0 +1,110 @@ +"""Cloud onboarding views.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from functools import wraps +from typing import TYPE_CHECKING, Any, Concatenate + +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized + +from homeassistant.components.http import KEY_HASS +from homeassistant.components.onboarding import ( + BaseOnboardingView, + NoAuthBaseOnboardingView, +) +from homeassistant.core import HomeAssistant + +from . import http_api as cloud_http +from .const import DATA_CLOUD + +if TYPE_CHECKING: + from homeassistant.components.onboarding import OnboardingStoreData + + +async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: + """Set up the cloud views.""" + + hass.http.register_view(CloudForgotPasswordView(data)) + hass.http.register_view(CloudLoginView(data)) + hass.http.register_view(CloudLogoutView(data)) + hass.http.register_view(CloudStatusView(data)) + + +def ensure_not_done[_ViewT: BaseOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and cloud.""" + + @wraps(func) + async def _ensure_not_done( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check onboarding status, cloud and call function.""" + if self._data["done"]: + # If at least one onboarding step is done, we don't allow accessing + # the cloud onboarding views. + raise HTTPUnauthorized + + return await func(self, request, *args, **kwargs) + + return _ensure_not_done + + +class CloudForgotPasswordView( + NoAuthBaseOnboardingView, cloud_http.CloudForgotPasswordView +): + """View to start Forgot Password flow.""" + + url = "/api/onboarding/cloud/forgot_password" + name = "api:onboarding:cloud:forgot_password" + + @ensure_not_done + async def post(self, request: web.Request) -> web.Response: + """Handle forgot password request.""" + return await super()._post(request) + + +class CloudLoginView(NoAuthBaseOnboardingView, cloud_http.CloudLoginView): + """Login to Home Assistant Cloud.""" + + url = "/api/onboarding/cloud/login" + name = "api:onboarding:cloud:login" + + @ensure_not_done + async def post(self, request: web.Request) -> web.Response: + """Handle login request.""" + return await super()._post(request) + + +class CloudLogoutView(NoAuthBaseOnboardingView, cloud_http.CloudLogoutView): + """Log out of the Home Assistant cloud.""" + + url = "/api/onboarding/cloud/logout" + name = "api:onboarding:cloud:logout" + + @ensure_not_done + async def post(self, request: web.Request) -> web.Response: + """Handle logout request.""" + return await super()._post(request) + + +class CloudStatusView(NoAuthBaseOnboardingView): + """Get cloud status view.""" + + url = "/api/onboarding/cloud/status" + name = "api:onboarding:cloud:status" + + @ensure_not_done + async def get(self, request: web.Request) -> web.Response: + """Return cloud status.""" + hass = request.app[KEY_HASS] + cloud = hass.data[DATA_CLOUD] + return self.json({"logged_in": cloud.is_logged_in}) diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 6380ee9c312..e7d219ff69e 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -62,6 +62,10 @@ } } }, + "no_subscription": { + "title": "No subscription detected", + "description": "You do not have a Home Assistant Cloud subscription. Subscribe at {account_url}." + }, "warn_bad_custom_domain_configuration": { "title": "Detected wrong custom domain configuration", "description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. Please check the DNS configuration of your domain and make sure it points to the correct CNAME." @@ -69,6 +73,10 @@ "reset_bad_custom_domain_configuration": { "title": "Custom domain ignored", "description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. This domain has now been ignored and will not be used for Home Assistant Cloud. If you want to use this domain, please fix the DNS configuration and restart Home Assistant. If you do not need this anymore, you can remove it from the account page." + }, + "subscription_expired": { + "title": "Subscription has expired", + "description": "Your Home Assistant Cloud subscription has expired. Resubscribe at {account_url}." } }, "services": { diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 3ac3f3d1c2d..85ca599fa87 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -6,7 +6,8 @@ import logging from typing import Any from hass_nabucasa import Cloud -from hass_nabucasa.voice import MAP_VOICE, TTS_VOICES, AudioOutput, Gender, VoiceError +from hass_nabucasa.voice import MAP_VOICE, AudioOutput, Gender, VoiceError +from hass_nabucasa.voice_data import TTS_VOICES import voluptuous as vol from homeassistant.components.tts import ( @@ -30,7 +31,13 @@ from homeassistant.setup import async_when_setup from .assist_pipeline import async_migrate_cloud_pipeline_engine from .client import CloudClient -from .const import DATA_CLOUD, DATA_PLATFORMS_SETUP, DOMAIN, TTS_ENTITY_UNIQUE_ID +from .const import ( + DATA_CLOUD, + DATA_PLATFORMS_SETUP, + DOMAIN, + TTS_ENTITY_UNIQUE_ID, + VOICE_STYLE_SEPERATOR, +) from .prefs import CloudPreferences ATTR_GENDER = "gender" @@ -57,6 +64,7 @@ DEFAULT_VOICES = { "ar-SY": "AmanyNeural", "ar-TN": "ReemNeural", "ar-YE": "MaryamNeural", + "as-IN": "PriyomNeural", "az-AZ": "BabekNeural", "bg-BG": "KalinaNeural", "bn-BD": "NabanitaNeural", @@ -126,6 +134,8 @@ DEFAULT_VOICES = { "id-ID": "GadisNeural", "is-IS": "GudrunNeural", "it-IT": "ElsaNeural", + "iu-Cans-CA": "SiqiniqNeural", + "iu-Latn-CA": "SiqiniqNeural", "ja-JP": "NanamiNeural", "jv-ID": "SitiNeural", "ka-GE": "EkaNeural", @@ -147,6 +157,8 @@ DEFAULT_VOICES = { "ne-NP": "HemkalaNeural", "nl-BE": "DenaNeural", "nl-NL": "ColetteNeural", + "or-IN": "SubhasiniNeural", + "pa-IN": "OjasNeural", "pl-PL": "AgnieszkaNeural", "ps-AF": "LatifaNeural", "pt-BR": "FranciscaNeural", @@ -158,6 +170,7 @@ DEFAULT_VOICES = { "sl-SI": "PetraNeural", "so-SO": "UbaxNeural", "sq-AL": "AnilaNeural", + "sr-Latn-RS": "NicholasNeural", "sr-RS": "SophieNeural", "su-ID": "TutiNeural", "sv-SE": "SofieNeural", @@ -177,12 +190,9 @@ DEFAULT_VOICES = { "vi-VN": "HoaiMyNeural", "wuu-CN": "XiaotongNeural", "yue-CN": "XiaoMinNeural", - "zh-CN": "XiaoxiaoNeural", "zh-CN-henan": "YundengNeural", - "zh-CN-liaoning": "XiaobeiNeural", - "zh-CN-shaanxi": "XiaoniNeural", "zh-CN-shandong": "YunxiangNeural", - "zh-CN-sichuan": "YunxiNeural", + "zh-CN": "XiaoxiaoNeural", "zh-HK": "HiuMaanNeural", "zh-TW": "HsiaoChenNeural", "zu-ZA": "ThandoNeural", @@ -191,6 +201,39 @@ DEFAULT_VOICES = { _LOGGER = logging.getLogger(__name__) +@callback +def _prepare_voice_args( + *, + hass: HomeAssistant, + language: str, + voice: str, + gender: str | None, +) -> dict: + """Prepare voice arguments.""" + gender = handle_deprecated_gender(hass, gender) + style: str | None + original_voice, _, style = voice.partition(VOICE_STYLE_SEPERATOR) + if not style: + style = None + updated_voice = handle_deprecated_voice(hass, original_voice) + if updated_voice not in TTS_VOICES[language]: + default_voice = DEFAULT_VOICES[language] + _LOGGER.debug( + "Unsupported voice %s detected, falling back to default %s for %s", + voice, + default_voice, + language, + ) + updated_voice = default_voice + + return { + "language": language, + "voice": updated_voice, + "gender": gender, + "style": style, + } + + def _deprecated_platform(value: str) -> str: """Validate if platform is deprecated.""" if value == DOMAIN: @@ -286,7 +329,7 @@ class CloudTTSEntity(TextToSpeechEntity): return self._language @property - def default_options(self) -> dict[str, Any]: + def default_options(self) -> dict[str, str]: """Return a dict include default options.""" return { ATTR_AUDIO_OUTPUT: AudioOutput.MP3, @@ -328,47 +371,74 @@ class CloudTTSEntity(TextToSpeechEntity): """Return a list of supported voices for a language.""" if not (voices := TTS_VOICES.get(language)): return None - return [Voice(voice, voice) for voice in voices] + + result = [] + + for voice_id, voice_info in voices.items(): + if isinstance(voice_info, str): + result.append( + Voice( + voice_id, + voice_info, + ) + ) + continue + + name = voice_info["name"] + + result.append( + Voice( + voice_id, + name, + ) + ) + result.extend( + [ + Voice( + f"{voice_id}{VOICE_STYLE_SEPERATOR}{variant}", + f"{name} ({variant})", + ) + for variant in voice_info.get("variants", []) + ] + ) + + return result async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS from Home Assistant Cloud.""" - gender: Gender | str | None = options.get(ATTR_GENDER) - gender = handle_deprecated_gender(self.hass, gender) - original_voice: str = options.get( - ATTR_VOICE, - self._voice if language == self._language else DEFAULT_VOICES[language], - ) - voice = handle_deprecated_voice(self.hass, original_voice) - if voice not in TTS_VOICES[language]: - default_voice = DEFAULT_VOICES[language] - _LOGGER.debug( - "Unsupported voice %s detected, falling back to default %s for %s", - voice, - default_voice, - language, - ) - voice = default_voice # Process TTS try: data = await self.cloud.voice.process_tts( text=message, - language=language, - gender=gender, - voice=voice, output=options[ATTR_AUDIO_OUTPUT], + **_prepare_voice_args( + hass=self.hass, + language=language, + voice=options.get( + ATTR_VOICE, + ( + self._voice + if language == self._language + else DEFAULT_VOICES[language] + ), + ), + gender=options.get(ATTR_GENDER), + ), ) except VoiceError as err: _LOGGER.error("Voice error: %s", err) return (None, None) - return (str(options[ATTR_AUDIO_OUTPUT].value), data) + return (options[ATTR_AUDIO_OUTPUT], data) class CloudProvider(Provider): """Home Assistant Cloud speech API provider.""" + has_entity = True + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Initialize cloud provider.""" self.cloud = cloud @@ -401,10 +471,41 @@ class CloudProvider(Provider): """Return a list of supported voices for a language.""" if not (voices := TTS_VOICES.get(language)): return None - return [Voice(voice, voice) for voice in voices] + + result = [] + + for voice_id, voice_info in voices.items(): + if isinstance(voice_info, str): + result.append( + Voice( + voice_id, + voice_info, + ) + ) + continue + + name = voice_info["name"] + + result.append( + Voice( + voice_id, + name, + ) + ) + result.extend( + [ + Voice( + f"{voice_id}{VOICE_STYLE_SEPERATOR}{variant}", + f"{name} ({variant})", + ) + for variant in voice_info.get("variants", []) + ] + ) + + return result @property - def default_options(self) -> dict[str, Any]: + def default_options(self) -> dict[str, str]: """Return a dict include default options.""" return { ATTR_AUDIO_OUTPUT: AudioOutput.MP3, @@ -415,36 +516,28 @@ class CloudProvider(Provider): ) -> TtsAudioType: """Load TTS from Home Assistant Cloud.""" assert self.hass is not None - gender: Gender | str | None = options.get(ATTR_GENDER) - gender = handle_deprecated_gender(self.hass, gender) - original_voice: str = options.get( - ATTR_VOICE, - self._voice if language == self._language else DEFAULT_VOICES[language], - ) - voice = handle_deprecated_voice(self.hass, original_voice) - if voice not in TTS_VOICES[language]: - default_voice = DEFAULT_VOICES[language] - _LOGGER.debug( - "Unsupported voice %s detected, falling back to default %s for %s", - voice, - default_voice, - language, - ) - voice = default_voice # Process TTS try: data = await self.cloud.voice.process_tts( text=message, - language=language, - gender=gender, - voice=voice, output=options[ATTR_AUDIO_OUTPUT], + **_prepare_voice_args( + hass=self.hass, + language=language, + voice=options.get( + ATTR_VOICE, + self._voice + if language == self._language + else DEFAULT_VOICES[language], + ), + gender=options.get(ATTR_GENDER), + ), ) except VoiceError as err: _LOGGER.error("Voice error: %s", err) return (None, None) - return (str(options[ATTR_AUDIO_OUTPUT].value), data) + return options[ATTR_AUDIO_OUTPUT], data @callback diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index c3845a447e4..1fad38c5afc 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -9,7 +9,6 @@ from typing import Any import pycfdns import voluptuous as vol -from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_ZONE from homeassistant.core import HomeAssistant @@ -118,8 +117,6 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - persistent_notification.async_dismiss(self.hass, "cloudflare_setup") - errors: dict[str, str] = {} if user_input is not None: diff --git a/homeassistant/components/cloudflare/strings.json b/homeassistant/components/cloudflare/strings.json index 8c8ec57b074..453135f47a0 100644 --- a/homeassistant/components/cloudflare/strings.json +++ b/homeassistant/components/cloudflare/strings.json @@ -4,19 +4,19 @@ "step": { "user": { "title": "Connect to Cloudflare", - "description": "This integration requires an API Token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.", + "description": "This integration requires an API token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.", "data": { "api_token": "[%key:common::config_flow::data::api_token%]" } }, "zone": { - "title": "Choose the Zone to Update", + "title": "Choose the zone to update", "data": { "zone": "Zone" } }, "records": { - "title": "Choose the Records to Update", + "title": "Choose the records to update", "data": { "records": "Records" } @@ -40,7 +40,7 @@ "services": { "update_records": { "name": "Update records", - "description": "Manually trigger update to Cloudflare records." + "description": "Manually triggers an update of Cloudflare records." } } } diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 530496811d9..00acd2829a6 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -3,11 +3,11 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from aioelectricitymaps import ( ElectricityMaps, - ElectricityMapsError, ElectricityMapsInvalidTokenError, ElectricityMapsNoDataError, ) @@ -36,6 +36,8 @@ TYPE_USE_HOME = "use_home_location" TYPE_SPECIFY_COORDINATES = "specify_coordinates" TYPE_SPECIFY_COUNTRY = "specify_country_code" +_LOGGER = logging.getLogger(__name__) + class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Co2signal.""" @@ -158,7 +160,8 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except ElectricityMapsNoDataError: errors["base"] = "no_data" - except ElectricityMapsError: + except Exception: + _LOGGER.exception("Unexpected error occurred while checking API key") errors["base"] = "unknown" else: if self.source == SOURCE_REAUTH: diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 60a4e40140d..23be67fc1a1 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -12,6 +12,7 @@ from .coordinator import ( ComelitSerialBridge, ComelitVedoSystem, ) +from .utils import async_client_session BRIDGE_PLATFORMS = [ Platform.CLIMATE, @@ -32,6 +33,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b """Set up Comelit platform.""" coordinator: ComelitBaseCoordinator + + session = await async_client_session(hass) + if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: coordinator = ComelitSerialBridge( hass, @@ -39,6 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b entry.data[CONF_HOST], entry.data.get(CONF_PORT, DEFAULT_PORT), entry.data[CONF_PIN], + session, ) platforms = BRIDGE_PLATFORMS else: @@ -48,6 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b entry.data[CONF_HOST], entry.data.get(CONF_PORT, DEFAULT_PORT), entry.data[CONF_PIN], + session, ) platforms = VEDO_PLATFORMS @@ -71,6 +77,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> coordinator = entry.runtime_data if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): await coordinator.api.logout() - await coordinator.api.close() return unload_ok diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index 6ea4e97f12e..53e767b4434 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -6,7 +6,7 @@ import logging from typing import cast from aiocomelit.api import ComelitVedoAreaObject -from aiocomelit.const import ALARM_AREAS, AlarmAreaState +from aiocomelit.const import AlarmAreaState from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -20,6 +20,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitVedoSystem +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) AWAY = "away" @@ -38,6 +41,7 @@ ALARM_ACTIONS: dict[str, str] = { ALARM_AREA_ARMED_STATUS: dict[str, int] = { + DISABLE: 0, HOME_P1: 1, HOME_P2: 2, NIGHT: 3, @@ -56,7 +60,7 @@ async def async_setup_entry( async_add_entities( ComelitAlarmEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[ALARM_AREAS].values() + for device in coordinator.data["alarm_areas"].values() ) @@ -79,7 +83,6 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel config_entry_entry_id: str, ) -> None: """Initialize the alarm panel.""" - self._api = coordinator.api self._area_index = area.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id @@ -92,7 +95,7 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel @property def _area(self) -> ComelitVedoAreaObject: """Return area object.""" - return self.coordinator.data[ALARM_AREAS][self._area_index] + return self.coordinator.data["alarm_areas"][self._area_index] @property def available(self) -> bool: @@ -125,20 +128,46 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel AlarmAreaState.TRIGGERED: AlarmControlPanelState.TRIGGERED, }.get(self._area.human_status) + async def _async_update_state(self, area_state: AlarmAreaState, armed: int) -> None: + """Update state after action.""" + self._area.human_status = area_state + self._area.armed = armed + await self.async_update_ha_state() + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if code != str(self._api.device_pin): + if code != str(self.coordinator.api.device_pin): return - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[DISABLE] + ) + await self._async_update_state( + AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE] + ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[AWAY] + ) + await self._async_update_state( + AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY] + ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[HOME] + ) + await self._async_update_state( + AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1] + ) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[NIGHT] + ) + await self._async_update_state( + AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT] + ) diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index a895f8dc511..e1be330afae 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import cast from aiocomelit import ComelitVedoZoneObject -from aiocomelit.const import ALARM_ZONES from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -17,6 +16,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitVedoSystem +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -29,7 +31,7 @@ async def async_setup_entry( async_add_entities( ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[ALARM_ZONES].values() + for device in coordinator.data["alarm_zones"].values() ) @@ -48,8 +50,7 @@ class ComelitVedoBinarySensorEntity( config_entry_entry_id: str, ) -> None: """Init sensor entity.""" - self._api = coordinator.api - self._zone = zone + self._zone_index = zone.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available @@ -59,4 +60,6 @@ class ComelitVedoBinarySensorEntity( @property def is_on(self) -> bool: """Presence detected.""" - return self.coordinator.data[ALARM_ZONES][self._zone.index].status_api == "0001" + return ( + self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001" + ) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 6906c9bf735..e7890cddff8 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, cast +from typing import Any, TypedDict, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import CLIMATE @@ -16,11 +16,16 @@ from homeassistant.components.climate import ( UnitOfTemperature, ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 class ClimaComelitMode(StrEnum): @@ -42,22 +47,23 @@ class ClimaComelitCommand(StrEnum): AUTO = "auto" -API_STATUS: dict[str, dict[str, Any]] = { - ClimaComelitMode.OFF: { - "action": "off", - "hvac_mode": HVACMode.OFF, - "hvac_action": HVACAction.OFF, - }, - ClimaComelitMode.LOWER: { - "action": "lower", - "hvac_mode": HVACMode.COOL, - "hvac_action": HVACAction.COOLING, - }, - ClimaComelitMode.UPPER: { - "action": "upper", - "hvac_mode": HVACMode.HEAT, - "hvac_action": HVACAction.HEATING, - }, +class ClimaComelitApiStatus(TypedDict): + """Comelit Clima API status.""" + + hvac_mode: HVACMode + hvac_action: HVACAction + + +API_STATUS: dict[str, ClimaComelitApiStatus] = { + ClimaComelitMode.OFF: ClimaComelitApiStatus( + hvac_mode=HVACMode.OFF, hvac_action=HVACAction.OFF + ), + ClimaComelitMode.LOWER: ClimaComelitApiStatus( + hvac_mode=HVACMode.COOL, hvac_action=HVACAction.COOLING + ), + ClimaComelitMode.UPPER: ClimaComelitApiStatus( + hvac_mode=HVACMode.HEAT, hvac_action=HVACAction.HEATING + ), } MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = { @@ -83,7 +89,7 @@ async def async_setup_entry( ) -class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity): +class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity): """Climate device.""" _attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] @@ -96,7 +102,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True _attr_name = None def __init__( @@ -106,77 +111,49 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity config_entry_entry_id: str, ) -> None: """Init light entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) + super().__init__(coordinator, device, config_entry_entry_id) + self._update_attributes() + + def _update_attributes(self) -> None: + """Update class attributes.""" + device = self.coordinator.data[CLIMATE][self._device.index] + if not isinstance(device.val, list): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="invalid_clima_data" + ) - @property - def _clima(self) -> list[Any]: - """Return clima device data.""" # CLIMATE has a 2 item tuple: # - first for Clima # - second for Humidifier - return self.coordinator.data[CLIMATE][self._device.index].val[0] + values = device.val[0] - @property - def _api_mode(self) -> str: - """Return device mode.""" - # Values from API: "O", "L", "U" - return self._clima[2] + _active = values[1] + _mode = values[2] # Values from API: "O", "L", "U" + _automatic = values[3] == ClimaComelitMode.AUTO - @property - def _api_active(self) -> bool: - "Return device active/idle." - return self._clima[1] + self._attr_current_temperature = values[0] / 10 - @property - def _api_automatic(self) -> bool: - """Return device in automatic/manual mode.""" - return self._clima[3] == ClimaComelitMode.AUTO + self._attr_hvac_action = None + if not _active: + self._attr_hvac_action = HVACAction.IDLE + elif _mode in API_STATUS: + self._attr_hvac_action = API_STATUS[_mode]["hvac_action"] - @property - def target_temperature(self) -> float: - """Set target temperature.""" - return self._clima[4] / 10 + self._attr_hvac_mode = None + if _mode == ClimaComelitMode.OFF: + self._attr_hvac_mode = HVACMode.OFF + if _automatic: + self._attr_hvac_mode = HVACMode.AUTO + if _mode in API_STATUS: + self._attr_hvac_mode = API_STATUS[_mode]["hvac_mode"] - @property - def current_temperature(self) -> float: - """Return current temperature.""" - return self._clima[0] / 10 + self._attr_target_temperature = values[4] / 10 - @property - def hvac_mode(self) -> HVACMode | None: - """HVAC current mode.""" - - if self._api_mode == ClimaComelitMode.OFF: - return HVACMode.OFF - - if self._api_automatic: - return HVACMode.AUTO - - if self._api_mode in API_STATUS: - return API_STATUS[self._api_mode]["hvac_mode"] - - return None - - @property - def hvac_action(self) -> HVACAction | None: - """HVAC current action.""" - - if self._api_mode == ClimaComelitMode.OFF: - return HVACAction.OFF - - if not self._api_active: - return HVACAction.IDLE - - if self._api_mode in API_STATUS: - return API_STATUS[self._api_mode]["hvac_action"] - - return None + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attributes() + super()._handle_coordinator_update() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -191,6 +168,8 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, ClimaComelitCommand.SET, target_temp ) + self._attr_target_temperature = target_temp + self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" @@ -202,3 +181,5 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, MODE_TO_ACTION[hvac_mode] ) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index f29cc62136b..10180236f79 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from asyncio.exceptions import TimeoutError from collections.abc import Mapping from typing import Any @@ -21,6 +22,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN +from .utils import async_client_session DEFAULT_HOST = "192.168.1.252" DEFAULT_PIN = 111111 @@ -46,20 +48,31 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, """Validate the user input allows us to connect.""" api: ComelitCommonApi + + session = await async_client_session(hass) if data.get(CONF_TYPE, BRIDGE) == BRIDGE: - api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + api = ComeliteSerialBridgeApi( + data[CONF_HOST], data[CONF_PORT], data[CONF_PIN], session + ) else: - api = ComelitVedoApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + api = ComelitVedoApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN], session) try: await api.login() - except aiocomelit_exceptions.CannotConnect as err: - raise CannotConnect from err + except (aiocomelit_exceptions.CannotConnect, TimeoutError) as err: + raise CannotConnect( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err except aiocomelit_exceptions.CannotAuthenticate as err: - raise InvalidAuth from err + raise InvalidAuth( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + translation_placeholders={"error": repr(err)}, + ) from err finally: await api.logout() - await api.close() return {"title": data[CONF_HOST]} diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index 84d8fbd6315..f52f33fd6da 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -9,3 +9,5 @@ _LOGGER = logging.getLogger(__package__) DOMAIN = "comelit" DEFAULT_PORT = 80 DEVICE_TYPE_LIST = [BRIDGE, VEDO] + +SCAN_INTERVAL = 5 diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index fcb149b21d6..a5a90c07568 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -2,18 +2,20 @@ from abc import abstractmethod from datetime import timedelta -from typing import Any +from typing import TypeVar -from aiocomelit import ( +from aiocomelit.api import ( + AlarmDataObject, + ComelitCommonApi, ComeliteSerialBridgeApi, ComelitSerialBridgeObject, ComelitVedoApi, ComelitVedoAreaObject, ComelitVedoZoneObject, - exceptions, ) -from aiocomelit.api import ComelitCommonApi from aiocomelit.const import BRIDGE, VEDO +from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData +from aiohttp import ClientSession from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,12 +23,18 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import _LOGGER, DOMAIN +from .const import _LOGGER, DOMAIN, SCAN_INTERVAL type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator] -class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): +T = TypeVar( + "T", + bound=dict[str, dict[int, ComelitSerialBridgeObject]] | AlarmDataObject, +) + + +class ComelitBaseCoordinator(DataUpdateCoordinator[T]): """Base coordinator for Comelit Devices.""" _hw_version: str @@ -46,7 +54,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): logger=_LOGGER, config_entry=entry, name=f"{DOMAIN}-{host}-coordinator", - update_interval=timedelta(seconds=5), + update_interval=timedelta(seconds=SCAN_INTERVAL), ) device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( @@ -81,23 +89,32 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): hw_version=self._hw_version, ) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> T: """Update device data.""" _LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host) try: await self.api.login() return await self._async_update_system_data() - except (exceptions.CannotConnect, exceptions.CannotRetrieveData) as err: - raise UpdateFailed(repr(err)) from err - except exceptions.CannotAuthenticate as err: - raise ConfigEntryAuthFailed from err + except (CannotConnect, CannotRetrieveData) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": repr(err)}, + ) from err + except CannotAuthenticate as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + ) from err @abstractmethod - async def _async_update_system_data(self) -> dict[str, Any]: + async def _async_update_system_data(self) -> T: """Class method for updating data.""" -class ComelitSerialBridge(ComelitBaseCoordinator): +class ComelitSerialBridge( + ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]] +): """Queries Comelit Serial Bridge.""" _hw_version = "20003101" @@ -110,17 +127,20 @@ class ComelitSerialBridge(ComelitBaseCoordinator): host: str, port: int, pin: int, + session: ClientSession, ) -> None: """Initialize the scanner.""" - self.api = ComeliteSerialBridgeApi(host, port, pin) + self.api = ComeliteSerialBridgeApi(host, port, pin, session) super().__init__(hass, entry, BRIDGE, host) - async def _async_update_system_data(self) -> dict[str, Any]: + async def _async_update_system_data( + self, + ) -> dict[str, dict[int, ComelitSerialBridgeObject]]: """Specific method for updating data.""" return await self.api.get_all_devices() -class ComelitVedoSystem(ComelitBaseCoordinator): +class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]): """Queries Comelit VEDO system.""" _hw_version = "VEDO IP" @@ -133,11 +153,14 @@ class ComelitVedoSystem(ComelitBaseCoordinator): host: str, port: int, pin: int, + session: ClientSession, ) -> None: """Initialize the scanner.""" - self.api = ComelitVedoApi(host, port, pin) + self.api = ComelitVedoApi(host, port, pin, session) super().__init__(hass, entry, VEDO, host) - async def _async_update_system_data(self) -> dict[str, Any]: + async def _async_update_system_data( + self, + ) -> AlarmDataObject: """Specific method for updating data.""" return await self.api.get_all_areas_and_zones() diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 64412569f95..d4eaa9223df 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -7,13 +7,16 @@ 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.core import HomeAssistant, callback +from homeassistant.components.cover import CoverDeviceClass, CoverEntity +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 async def async_setup_entry( @@ -31,13 +34,10 @@ async def async_setup_entry( ) -class ComelitCoverEntity( - CoordinatorEntity[ComelitSerialBridge], RestoreEntity, CoverEntity -): +class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): """Cover device.""" _attr_device_class = CoverDeviceClass.SHUTTER - _attr_has_entity_name = True _attr_name = None def __init__( @@ -47,13 +47,7 @@ class ComelitCoverEntity( config_entry_entry_id: str, ) -> None: """Init cover entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) + super().__init__(coordinator, device, config_entry_entry_id) # Device doesn't provide a status so we assume UNKNOWN at first startup self._last_action: int | None = None self._last_state: str | None = None @@ -74,16 +68,10 @@ class ComelitCoverEntity( def is_closed(self) -> bool | None: """Return if the cover is closed.""" - if self._last_state in [None, "unknown"]: - return None - - if self.device_status != STATE_COVER.index("stopped"): - return False - if self._last_action: return self._last_action == STATE_COVER.index("closing") - return self._last_state == CoverState.CLOSED + return None @property def is_closing(self) -> bool: @@ -95,13 +83,20 @@ class ComelitCoverEntity( """Return if the cover is opening.""" return self._current_action("opening") + async def _cover_set_state(self, action: int, state: int) -> None: + """Set desired cover state.""" + self._last_state = self.state + await self.coordinator.api.set_device_status(COVER, self._device.index, action) + self.coordinator.data[COVER][self._device.index].status = state + self.async_write_ha_state() + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self._api.set_device_status(COVER, self._device.index, STATE_OFF) + await self._cover_set_state(STATE_OFF, 2) async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" - await self._api.set_device_status(COVER, self._device.index, STATE_ON) + await self._cover_set_state(STATE_ON, 1) async def async_stop_cover(self, **_kwargs: Any) -> None: """Stop the cover.""" @@ -109,13 +104,7 @@ class ComelitCoverEntity( return action = STATE_ON if self.is_closing else STATE_OFF - await self._api.set_device_status(COVER, self._device.index, action) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle device update.""" - self._last_state = self.state - self.async_write_ha_state() + await self._cover_set_state(action, 0) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/comelit/entity.py b/homeassistant/components/comelit/entity.py new file mode 100644 index 00000000000..409cd6a3f42 --- /dev/null +++ b/homeassistant/components/comelit/entity.py @@ -0,0 +1,29 @@ +"""Base entity for Comelit.""" + +from __future__ import annotations + +from aiocomelit import ComelitSerialBridgeObject + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import ComelitSerialBridge + + +class ComelitBridgeBaseEntity(CoordinatorEntity[ComelitSerialBridge]): + """Comelit Bridge base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + ) -> None: + """Init cover entity.""" + self._device = device + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device, device.type) diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index 5daf2297782..816d5c6bb38 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -16,13 +16,16 @@ from homeassistant.components.humidifier import ( HumidifierEntity, HumidifierEntityFeature, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 class HumidifierComelitMode(StrEnum): @@ -89,14 +92,13 @@ async def async_setup_entry( async_add_entities(entities) -class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], HumidifierEntity): +class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity): """Humidifier device.""" _attr_supported_features = HumidifierEntityFeature.MODES _attr_available_modes = [MODE_NORMAL, MODE_AUTO] _attr_min_humidity = 10 _attr_max_humidity = 90 - _attr_has_entity_name = True def __init__( self, @@ -109,78 +111,52 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier device_class: HumidifierDeviceClass, ) -> None: """Init light entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available + super().__init__(coordinator, device, config_entry_entry_id) self._attr_unique_id = f"{config_entry_entry_id}-{device.index}-{device_class}" - self._attr_device_info = coordinator.platform_device_info(device, device_class) self._attr_device_class = device_class self._attr_translation_key = device_class.value self._active_mode = active_mode self._active_action = active_action self._set_command = set_command + self._update_attributes() + + def _update_attributes(self) -> None: + """Update class attributes.""" + device = self.coordinator.data[CLIMATE][self._device.index] + if not isinstance(device.val, list): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="invalid_clima_data" + ) - @property - def _humidifier(self) -> list[Any]: - """Return humidifier device data.""" # CLIMATE has a 2 item tuple: # - first for Clima # - second for Humidifier - return self.coordinator.data[CLIMATE][self._device.index].val[1] + values = device.val[1] - @property - def _api_mode(self) -> str: - """Return device mode.""" - # Values from API: "O", "L", "U" - return self._humidifier[2] + _active = values[1] + _mode = values[2] # Values from API: "O", "L", "U" + _automatic = values[3] == HumidifierComelitMode.AUTO - @property - def _api_active(self) -> bool: - "Return device active/idle." - return self._humidifier[1] + self._attr_action = HumidifierAction.IDLE + if _mode == HumidifierComelitMode.OFF: + self._attr_action = HumidifierAction.OFF + if _active and _mode == self._active_mode: + self._attr_action = self._active_action - @property - def _api_automatic(self) -> bool: - """Return device in automatic/manual mode.""" - return self._humidifier[3] == HumidifierComelitMode.AUTO + self._attr_current_humidity = values[0] / 10 + self._attr_is_on = _mode == self._active_mode + self._attr_mode = MODE_AUTO if _automatic else MODE_NORMAL + self._attr_target_humidity = values[4] / 10 - @property - def target_humidity(self) -> float: - """Set target humidity.""" - return self._humidifier[4] / 10 - - @property - def current_humidity(self) -> float: - """Return current humidity.""" - return self._humidifier[0] / 10 - - @property - def is_on(self) -> bool | None: - """Return true is humidifier is on.""" - return self._api_mode == self._active_mode - - @property - def mode(self) -> str | None: - """Return current mode.""" - return MODE_AUTO if self._api_automatic else MODE_NORMAL - - @property - def action(self) -> HumidifierAction | None: - """Return current action.""" - - if self._api_mode == HumidifierComelitMode.OFF: - return HumidifierAction.OFF - - if self._api_active and self._api_mode == self._active_mode: - return self._active_action - - return HumidifierAction.IDLE + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attributes() + super()._handle_coordinator_update() async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - if self.mode == HumidifierComelitMode.OFF: + if not self._attr_is_on: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="humidity_while_off", @@ -192,21 +168,29 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier await self.coordinator.api.set_humidity_status( self._device.index, HumidifierComelitCommand.SET, humidity ) + self._attr_target_humidity = humidity + self.async_write_ha_state() async def async_set_mode(self, mode: str) -> None: """Set humidifier mode.""" await self.coordinator.api.set_humidity_status( self._device.index, MODE_TO_ACTION[mode] ) + self._attr_mode = mode + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" await self.coordinator.api.set_humidity_status( self._device.index, self._set_command ) + self._attr_is_on = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" await self.coordinator.api.set_humidity_status( self._device.index, HumidifierComelitCommand.OFF ) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 45f4146ece6..27d9a8d57dd 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -4,15 +4,17 @@ from __future__ import annotations 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.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 async def async_setup_entry( @@ -30,33 +32,18 @@ async def async_setup_entry( ) -class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): +class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity): """Light device.""" _attr_color_mode = ColorMode.ONOFF - _attr_has_entity_name = True _attr_name = None _attr_supported_color_modes = {ColorMode.ONOFF} - def __init__( - self, - coordinator: ComelitSerialBridge, - device: ComelitSerialBridgeObject, - config_entry_entry_id: str, - ) -> None: - """Init light entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) - async def _light_set_state(self, state: int) -> None: """Set desired light state.""" await self.coordinator.api.set_device_status(LIGHT, self._device.index, state) - await self.coordinator.async_request_refresh() + self.coordinator.data[LIGHT][self._device.index].status = state + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 238dede8546..bea84c6b805 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.10.1"] + "quality_scale": "bronze", + "requirements": ["aiocomelit==0.12.3"] } diff --git a/homeassistant/components/comelit/quality_scale.yaml b/homeassistant/components/comelit/quality_scale.yaml new file mode 100644 index 00000000000..a74fab22484 --- /dev/null +++ b/homeassistant/components/comelit/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no 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: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: 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: todo + comment: wrap api calls in try block + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: 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: done + discovery-update-info: + status: exempt + comment: device not discoverable + discovery: + status: exempt + comment: device not discoverable + docs-data-update: done + docs-examples: done + docs-known-limitations: + status: exempt + comment: no known limitations, yet + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: todo + comment: missing implementation + entity-category: + status: exempt + comment: no config or diagnostic entities + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: todo + comment: PR in progress + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: todo + comment: missing implementation + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 9200d99262f..a11cac4e1c0 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Final, cast from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject -from aiocomelit.const import ALARM_ZONES, BRIDGE, OTHER, AlarmZoneState +from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState from homeassistant.components.sensor import ( SensorDeviceClass, @@ -19,6 +19,10 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem +from .entity import ComelitBridgeBaseEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 SENSOR_BRIDGE_TYPES: Final = ( SensorEntityDescription( @@ -82,7 +86,7 @@ async def async_setup_vedo_entry( coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) entities: list[ComelitVedoSensorEntity] = [] - for device in coordinator.data[ALARM_ZONES].values(): + for device in coordinator.data["alarm_zones"].values(): entities.extend( ComelitVedoSensorEntity( coordinator, device, config_entry.entry_id, sensor_desc @@ -92,10 +96,9 @@ async def async_setup_vedo_entry( async_add_entities(entities) -class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): +class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity): """Sensor device.""" - _attr_has_entity_name = True _attr_name = None def __init__( @@ -106,22 +109,19 @@ class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEn description: SensorEntityDescription, ) -> None: """Init sensor entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) + super().__init__(coordinator, device, config_entry_entry_id) self.entity_description = description @property def native_value(self) -> StateType: """Sensor value.""" - return getattr( - self.coordinator.data[OTHER][self._device.index], - self.entity_description.key, + return cast( + StateType, + getattr( + self.coordinator.data[OTHER][self._device.index], + self.entity_description.key, + ), ) @@ -138,8 +138,7 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity description: SensorEntityDescription, ) -> None: """Init sensor entity.""" - self._api = coordinator.api - self._zone = zone + self._zone_index = zone.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available @@ -151,7 +150,7 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity @property def _zone_object(self) -> ComelitVedoZoneObject: """Zone object.""" - return self.coordinator.data[ALARM_ZONES][self._zone.index] + return self.coordinator.data["alarm_zones"][self._zone_index] @property def available(self) -> bool: @@ -164,4 +163,4 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN: return None - return status.value + return cast(str, status.value) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 14d947c7323..8f2ae1433e5 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -3,19 +3,25 @@ "flow_title": "{host}", "step": { "reauth_confirm": { - "description": "Please enter the correct PIN for {host}", "data": { "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The PIN of your Comelit device." } }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", - "pin": "[%key:common::config_flow::data::pin%]" + "pin": "[%key:common::config_flow::data::pin%]", + "type": "Device type" }, "data_description": { - "host": "The hostname or IP address of your Comelit device." + "host": "The hostname or IP address of your Comelit device.", + "port": "The port of your Comelit device.", + "pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]", + "type": "The type of your Comelit device." } } }, @@ -36,9 +42,9 @@ "sensor": { "zone_status": { "state": { + "open": "[%key:common::state::open%]", "alarm": "Alarm", "armed": "Armed", - "open": "Open", "excluded": "Excluded", "faulty": "Faulty", "inhibited": "Inhibited", @@ -46,7 +52,9 @@ "rest": "Rest", "sabotated": "Sabotated" } - }, + } + }, + "humidifier": { "humidifier": { "name": "Humidifier" }, @@ -58,6 +66,18 @@ "exceptions": { "humidity_while_off": { "message": "Cannot change humidity while off" + }, + "invalid_clima_data": { + "message": "Invalid 'clima' data" + }, + "cannot_connect": { + "message": "Error connecting: {error}" + }, + "cannot_authenticate": { + "message": "Error authenticating" + }, + "update_failed": { + "message": "Failed to update data: {error}" } } } diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index e89ee74c1be..658f37f70af 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -10,9 +10,12 @@ from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 async def async_setup_entry( @@ -36,10 +39,9 @@ async def async_setup_entry( async_add_entities(entities) -class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): +class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity): """Switch device.""" - _attr_has_entity_name = True _attr_name = None def __init__( @@ -49,13 +51,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): config_entry_entry_id: str, ) -> None: """Init switch entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available + super().__init__(coordinator, device, config_entry_entry_id) self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) if device.type == OTHER: self._attr_device_class = SwitchDeviceClass.OUTLET @@ -64,7 +61,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): await self.coordinator.api.set_device_status( self._device.type, self._device.index, state ) - await self.coordinator.async_request_refresh() + self.coordinator.data[self._device.type][self._device.index].status = state + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" diff --git a/homeassistant/components/comelit/utils.py b/homeassistant/components/comelit/utils.py new file mode 100644 index 00000000000..fe05e2412b0 --- /dev/null +++ b/homeassistant/components/comelit/utils.py @@ -0,0 +1,13 @@ +"""Utils for Comelit.""" + +from aiohttp import ClientSession, CookieJar + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + + +async def async_client_session(hass: HomeAssistant) -> ClientSession: + """Return a new aiohttp session.""" + return aiohttp_client.async_create_clientsession( + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + ) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 1832e83e7dd..b74c79fd842 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -56,7 +56,10 @@ from homeassistant.helpers import config_validation as cv, discovery 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.trigger_template_entity import CONF_AVAILABILITY +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -91,7 +94,9 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional( @@ -108,7 +113,9 @@ COVER_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_ICON): cv.template, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_DEVICE_CLASS): COVER_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, @@ -134,7 +141,9 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_NAME, default=SENSOR_DEFAULT_NAME): cv.string, vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, @@ -150,7 +159,9 @@ SWITCH_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_ON, default="true"): cv.string, vol.Optional(CONF_COMMAND_STATE): cv.string, vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index fab56ae6887..727bf5b86ca 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -18,7 +18,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -50,7 +53,7 @@ async def async_setup_platform( scan_interval: timedelta = binary_sensor_config.get( CONF_SCAN_INTERVAL, SCAN_INTERVAL ) - value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) data = CommandSensorData(hass, command, command_timeout) @@ -86,7 +89,7 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): config: ConfigType, payload_on: str, payload_off: str, - value_template: Template | None, + value_template: ValueTemplate | None, scan_interval: timedelta, ) -> None: """Initialize the Command line binary sensor.""" @@ -133,9 +136,14 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): await self.data.async_update() value = self.data.value + variables = self._template_variables_with_value(value) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._value_template is not None: - value = self._value_template.async_render_with_possible_json_value( - value, None + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) self._attr_is_on = None if value == self._payload_on: @@ -143,7 +151,7 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): elif value == self._payload_off: self._attr_is_on = False - self._process_manual_data(value) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 7f1bc12264c..066f6ae0388 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify @@ -79,7 +82,7 @@ class CommandCover(ManualTriggerEntity, CoverEntity): command_close: str, command_stop: str, command_state: str | None, - value_template: Template | None, + value_template: ValueTemplate | None, timeout: int, scan_interval: timedelta, ) -> None: @@ -164,14 +167,20 @@ class CommandCover(ManualTriggerEntity, CoverEntity): """Update device state.""" if self._command_state: payload = str(await self._async_query_state()) + + variables = self._template_variables_with_value(payload) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._value_template: - payload = self._value_template.async_render_with_possible_json_value( - payload, None + payload = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) self._state = None if payload: self._state = int(payload) - self._process_manual_data(payload) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index b7c36a005fa..5ce50edc4e7 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -23,7 +23,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerSensorEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerSensorEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -57,7 +60,7 @@ async def async_setup_platform( json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) json_attributes_path: str | None = sensor_config.get(CONF_JSON_ATTRIBUTES_PATH) scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) - value_template: Template | None = sensor_config.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = sensor_config.get(CONF_VALUE_TEMPLATE) data = CommandSensorData(hass, command, command_timeout) trigger_entity_config = { @@ -88,7 +91,7 @@ class CommandSensor(ManualTriggerSensorEntity): self, data: CommandSensorData, config: ConfigType, - value_template: Template | None, + value_template: ValueTemplate | None, json_attributes: list[str] | None, json_attributes_path: str | None, scan_interval: timedelta, @@ -144,6 +147,11 @@ class CommandSensor(ManualTriggerSensorEntity): await self.data.async_update() value = self.data.value + variables = self._template_variables_with_value(self.data.value) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._json_attributes: self._attr_extra_state_attributes = {} if value: @@ -168,16 +176,17 @@ class CommandSensor(ManualTriggerSensorEntity): LOGGER.warning("Unable to parse output as JSON: %s", value) else: LOGGER.warning("Empty reply found when expecting JSON data") + if self._value_template is None: self._attr_native_value = None - self._process_manual_data(value) + self._process_manual_data(variables) + self.async_write_ha_state() return self._attr_native_value = None if self._value_template is not None and value is not None: - value = self._value_template.async_render_with_possible_json_value( - value, - None, + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) if self.device_class not in { @@ -190,7 +199,7 @@ class CommandSensor(ManualTriggerSensorEntity): value, self.entity_id, self.device_class ) - self._process_manual_data(value) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 31400048ddc..9d6b84c105f 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -19,7 +19,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify @@ -78,7 +81,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): command_on: str, command_off: str, command_state: str | None, - value_template: Template | None, + value_template: ValueTemplate | None, timeout: int, scan_interval: timedelta, ) -> None: @@ -166,15 +169,21 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): """Update device state.""" if self._command_state: payload = str(await self._async_query_state()) + + variables = self._template_variables_with_value(payload) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + value = None if self._value_template: - value = self._value_template.async_render_with_possible_json_value( - payload, None + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) self._attr_is_on = None if payload or value: self._attr_is_on = (value or payload).lower() == "true" - self._process_manual_data(payload) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 74c9b5a9d0c..d20d4de881f 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -58,7 +58,8 @@ def async_setup(hass: HomeAssistant) -> bool: websocket_api.async_register_command(hass, config_entry_get_single) websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entries_subscribe) - websocket_api.async_register_command(hass, config_entries_progress) + websocket_api.async_register_command(hass, config_entries_flow_progress) + websocket_api.async_register_command(hass, config_entries_flow_subscribe) websocket_api.async_register_command(hass, ignore_config_flow) websocket_api.async_register_command(hass, config_subentry_delete) @@ -164,9 +165,7 @@ class ConfigManagerFlowIndexView( """Not implemented.""" raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission="add") @RequestDataValidator( vol.Schema( { @@ -217,16 +216,12 @@ class ConfigManagerFlowResourceView( url = "/api/config/config_entries/flow/{flow_id}" name = "api:config:config_entries:flow:resource" - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission="add") async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission="add") async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) @@ -261,9 +256,7 @@ class OptionManagerFlowIndexView( url = "/api/config/config_entries/options/flow" name = "api:config:config_entries:option:flow" - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) async def post(self, request: web.Request) -> web.Response: """Handle a POST request. @@ -280,16 +273,12 @@ class OptionManagerFlowResourceView( url = "/api/config/config_entries/options/flow/{flow_id}" name = "api:config:config_entries:options:flow:resource" - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) @@ -303,9 +292,7 @@ class SubentryManagerFlowIndexView( url = "/api/config/config_entries/subentries/flow" name = "api:config:config_entries:subentries:flow" - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) @RequestDataValidator( vol.Schema( { @@ -340,16 +327,12 @@ class SubentryManagerFlowResourceView( url = "/api/config/config_entries/subentries/flow/{flow_id}" name = "api:config:config_entries:subentries:flow:resource" - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) - @require_admin( - error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - ) + @require_admin(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) @@ -357,7 +340,7 @@ class SubentryManagerFlowResourceView( @websocket_api.require_admin @websocket_api.websocket_command({"type": "config_entries/flow/progress"}) -def config_entries_progress( +def config_entries_flow_progress( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], @@ -378,6 +361,66 @@ def config_entries_progress( ) +@websocket_api.require_admin +@websocket_api.websocket_command({"type": "config_entries/flow/subscribe"}) +def config_entries_flow_subscribe( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to non user created flows being initiated or removed. + + When initiating the subscription, the current flows are sent to the client. + + Example of a non-user initiated flow is a discovered Hue hub that + requires user interaction to finish setup. + """ + + @callback + def async_on_flow_init_remove(change_type: str, flow_id: str) -> None: + """Forward config entry state events to websocket.""" + if change_type == "removed": + connection.send_message( + websocket_api.event_message( + msg["id"], + [{"type": change_type, "flow_id": flow_id}], + ) + ) + return + # change_type == "added" + connection.send_message( + websocket_api.event_message( + msg["id"], + [ + { + "type": change_type, + "flow_id": flow_id, + "flow": hass.config_entries.flow.async_get(flow_id), + } + ], + ) + ) + + connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow( + async_on_flow_init_remove + ) + connection.send_message( + websocket_api.event_message( + msg["id"], + [ + {"type": None, "flow_id": flw["flow_id"], "flow": flw} + for flw in hass.config_entries.flow.async_progress() + if flw["context"]["source"] + not in ( + config_entries.SOURCE_RECONFIGURE, + config_entries.SOURCE_USER, + ) + ], + ) + ) + connection.send_result(msg["id"]) + + def send_entry_not_found( connection: websocket_api.ActiveConnection, msg_id: int ) -> None: diff --git a/homeassistant/components/constructa/__init__.py b/homeassistant/components/constructa/__init__.py new file mode 100644 index 00000000000..1b3870860a0 --- /dev/null +++ b/homeassistant/components/constructa/__init__.py @@ -0,0 +1 @@ +"""Constructa virtual integration.""" diff --git a/homeassistant/components/constructa/manifest.json b/homeassistant/components/constructa/manifest.json new file mode 100644 index 00000000000..7b73f2e2ed0 --- /dev/null +++ b/homeassistant/components/constructa/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "constructa", + "name": "Constructa", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 14c5244c18b..25aaf6df290 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable import logging -import re from typing import Literal from hassil.recognize import RecognizeResult @@ -91,8 +90,6 @@ __all__ = [ _LOGGER = logging.getLogger(__name__) -REGEX_TYPE = type(re.compile("")) - SERVICE_PROCESS_SCHEMA = vol.Schema( { vol.Required(ATTR_TEXT): cv.string, diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 1ee5e9965ab..c78f41f3c5c 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -8,7 +8,7 @@ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict, dataclass, field, replace import logging -from typing import Literal, TypedDict +from typing import Any, Literal, TypedDict import voluptuous as vol @@ -49,7 +49,10 @@ def async_get_chat_log( raise RuntimeError( "Cannot attach chat log delta listener unless initial caller" ) - if user_input is not None: + if user_input is not None and ( + (content := chat_log.content[-1]).role != "user" + or content.content != user_input.text + ): chat_log.async_add_user_content(UserContent(content=user_input.text)) yield chat_log @@ -124,7 +127,7 @@ class ConverseError(HomeAssistantError): class SystemContent: """Base class for chat messages.""" - role: str = field(init=False, default="system") + role: Literal["system"] = field(init=False, default="system") content: str @@ -132,7 +135,7 @@ class SystemContent: class UserContent: """Assistant content.""" - role: str = field(init=False, default="user") + role: Literal["user"] = field(init=False, default="user") content: str @@ -140,7 +143,7 @@ class UserContent: class AssistantContent: """Assistant content.""" - role: str = field(init=False, default="assistant") + role: Literal["assistant"] = field(init=False, default="assistant") agent_id: str content: str | None = None tool_calls: list[llm.ToolInput] | None = None @@ -150,7 +153,7 @@ class AssistantContent: class ToolResultContent: """Tool result content.""" - role: str = field(init=False, default="tool_result") + role: Literal["tool_result"] = field(init=False, default="tool_result") agent_id: str tool_call_id: str tool_name: str @@ -179,6 +182,26 @@ class ChatLog: llm_api: llm.APIInstance | None = None delta_listener: Callable[[ChatLog, dict], None] | None = None + @property + def continue_conversation(self) -> bool: + """Return whether the conversation should continue.""" + if not self.content: + return False + + last_msg = self.content[-1] + + return ( + last_msg.role == "assistant" + and last_msg.content is not None + and last_msg.content.strip().endswith( + ( + "?", + ";", # Greek question mark + "?", # Chinese question mark + ) + ) + ) + @property def unresponded_tool_results(self) -> bool: """Return if there are unresponded tool results.""" @@ -332,11 +355,40 @@ class ChatLog: if self.delta_listener: self.delta_listener(self, asdict(tool_result)) + async def _async_expand_prompt_template( + self, + llm_context: llm.LLMContext, + prompt: str, + language: str, + user_name: str | None = None, + ) -> str: + try: + return template.Template(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=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 + async def async_update_llm_data( self, conversing_domain: str, user_input: ConversationInput, - user_llm_hass_api: str | None = None, + user_llm_hass_api: str | list[str] | None = None, user_llm_prompt: str | None = None, ) -> None: """Set the LLM system prompt.""" @@ -387,38 +439,28 @@ class ChatLog: ): 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", + prompt_parts = [] + prompt_parts.append( + await self._async_expand_prompt_template( + llm_context, + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), + user_input.language, + user_name, ) - 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) + prompt_parts.append( + await self._async_expand_prompt_template( + llm_context, + llm.BASE_PROMPT, + user_input.language, + user_name, + ) + ) + if extra_system_prompt := ( # Take new system prompt if one was given user_input.extra_system_prompt or self.extra_system_prompt @@ -434,10 +476,16 @@ class ChatLog: LOGGER.debug("Prompt: %s", self.content) LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None) - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, + self.async_trace( { "messages": self.content, "tools": self.llm_api.tools if self.llm_api else None, - }, + } + ) + + def async_trace(self, agent_details: dict[str, Any]) -> None: + """Append agent specific details to the conversation trace.""" + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, + agent_details, ) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 3a7aa0c26e8..bed4b4c0dd6 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -42,7 +42,6 @@ from homeassistant.components.homeassistant.exposed_entities import ( from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.helpers import ( area_registry as ar, - chat_session, device_registry as dr, entity_registry as er, floor_registry as fr, @@ -56,7 +55,7 @@ from homeassistant.helpers.event import async_track_state_added_domain from homeassistant.util import language as language_util from homeassistant.util.json import JsonObjectType, json_loads_object -from .chat_log import AssistantContent, async_get_chat_log +from .chat_log import AssistantContent, ChatLog from .const import ( DATA_DEFAULT_ENTITY, DEFAULT_EXPOSED_ATTRIBUTES, @@ -332,49 +331,46 @@ class DefaultAgent(ConversationEntity): return result - async def async_process(self, user_input: ConversationInput) -> ConversationResult: - """Process a sentence.""" + async def _async_handle_message( + self, + user_input: ConversationInput, + chat_log: ChatLog, + ) -> ConversationResult: + """Handle a message.""" response: intent.IntentResponse | None = None - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - # 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_log.async_add_assistant_content_without_tools( - AssistantContent( - agent_id=user_input.agent_id, - content=speech, - ) + # 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 ) - return ConversationResult( - response=response, conversation_id=session.conversation_id + # 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_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id=user_input.agent_id, + content=speech, + ) + ) + + return ConversationResult( + response=response, conversation_id=chat_log.conversation_id + ) async def _async_process_intent_result( self, @@ -654,7 +650,14 @@ class DefaultAgent(ConversationEntity): if ( (maybe_result is None) # first result - or (num_matched_entities > best_num_matched_entities) + or ( + # More literal text matched + result.text_chunks_matched > maybe_result.text_chunks_matched + ) + or ( + # More entities matched + num_matched_entities > best_num_matched_entities + ) or ( # Fewer unmatched entities (num_matched_entities == best_num_matched_entities) @@ -666,16 +669,6 @@ class DefaultAgent(ConversationEntity): and (num_unmatched_entities == best_num_unmatched_entities) and (num_unmatched_ranges > best_num_unmatched_ranges) ) - or ( - # More literal text matched - (num_matched_entities == best_num_matched_entities) - and (num_unmatched_entities == best_num_unmatched_entities) - and (num_unmatched_ranges == best_num_unmatched_ranges) - and ( - result.text_chunks_matched - > maybe_result.text_chunks_matched - ) - ) or ( # Prefer match failures with entities (result.text_chunks_matched == maybe_result.text_chunks_matched) diff --git a/homeassistant/components/conversation/entity.py b/homeassistant/components/conversation/entity.py index d9598dee7eb..ca4d18ab9f5 100644 --- a/homeassistant/components/conversation/entity.py +++ b/homeassistant/components/conversation/entity.py @@ -4,9 +4,11 @@ from abc import abstractmethod from typing import Literal, final from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.helpers.chat_session import async_get_chat_session from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util +from .chat_log import ChatLog, async_get_chat_log from .const import ConversationEntityFeature from .models import ConversationInput, ConversationResult @@ -51,9 +53,21 @@ class ConversationEntity(RestoreEntity): def supported_languages(self) -> list[str] | Literal["*"]: """Return a list of supported languages.""" - @abstractmethod async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" + with ( + async_get_chat_session(self.hass, user_input.conversation_id) as session, + async_get_chat_log(self.hass, session, user_input) as chat_log, + ): + return await self._async_handle_message(user_input, chat_log) + + async def _async_handle_message( + self, + user_input: ConversationInput, + chat_log: ChatLog, + ) -> ConversationResult: + """Call the API.""" + raise NotImplementedError async def async_prepare(self, language: str | None = None) -> None: """Load intents for a language.""" diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 4d8526a4fd4..efcdcb8d69b 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -3,11 +3,13 @@ from __future__ import annotations from collections.abc import Iterable +from dataclasses import asdict from typing import Any from aiohttp import web from hassil.recognize import MISSING_ENTITY, RecognizeResult from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity +from home_assistant_intents import get_language_scores import voluptuous as vol from homeassistant.components import http, websocket_api @@ -38,6 +40,7 @@ def async_setup(hass: HomeAssistant) -> None: 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) + websocket_api.async_register_command(hass, websocket_hass_agent_language_scores) @websocket_api.websocket_command( @@ -336,6 +339,36 @@ def _get_unmatched_slots( return unmatched_slots +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/agent/homeassistant/language_scores", + vol.Optional("language"): str, + vol.Optional("country"): str, + } +) +@websocket_api.async_response +async def websocket_hass_agent_language_scores( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get support scores per language.""" + language = msg.get("language", hass.config.language) + country = msg.get("country", hass.config.country) + + scores = await hass.async_add_executor_job(get_language_scores) + matching_langs = language_util.matches(language, scores.keys(), country=country) + preferred_lang = matching_langs[0] if matching_langs else language + result = { + "languages": { + lang_key: asdict(lang_scores) for lang_key, lang_scores in scores.items() + }, + "preferred_language": preferred_lang, + } + + connection.send_result(msg["id"], result) + + class ConversationProcessView(http.HomeAssistantView): """View to process text.""" diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index c4f1860eed6..2955bb96833 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.2.3", "home-assistant-intents==2025.2.26"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.5.7"] } diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 08a68fa0164..7bdd13afc01 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -62,12 +62,14 @@ class ConversationResult: response: intent.IntentResponse conversation_id: str | None = None + continue_conversation: bool = False def as_dict(self) -> dict[str, Any]: """Return result as a dict.""" return { "response": self.response.as_dict(), "conversation_id": self.conversation_id, + "continue_conversation": self.continue_conversation, } diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py deleted file mode 100644 index 4326c95cb66..00000000000 --- a/homeassistant/components/conversation/util.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Util for Conversation.""" - -from __future__ import annotations - -import re - - -def create_matcher(utterance: str) -> re.Pattern[str]: - """Create a regex that matches the utterance.""" - # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL - # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} - parts = re.split(r"({\w+}|\[[\w\s]+\] *)", utterance) - # Pattern to extract name from GROUP part. Matches {name} - group_matcher = re.compile(r"{(\w+)}") - # Pattern to extract text from OPTIONAL part. Matches [the color] - optional_matcher = re.compile(r"\[([\w ]+)\] *") - - pattern = ["^"] - for part in parts: - group_match = group_matcher.match(part) - optional_match = optional_matcher.match(part) - - # Normal part - if group_match is None and optional_match is None: - pattern.append(part) - continue - - # Group part - if group_match is not None: - pattern.append(rf"(?P<{group_match.groups()[0]}>[\w ]+?)\s*") - - # Optional part - elif optional_match is not None: - pattern.append(rf"(?:{optional_match.groups()[0]} *)?") - - pattern.append("$") - return re.compile("".join(pattern), re.IGNORECASE) diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index ae384fb6635..52f99133546 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -6,7 +6,7 @@ "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" }, "data_description": { "email": "Email used to access your {cookidoo} account.", diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py index 307fe5f11bd..927e725460c 100644 --- a/homeassistant/components/cover/reproduce_state.py +++ b/homeassistant/components/cover/reproduce_state.py @@ -3,12 +3,14 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Coroutine, Iterable +from functools import partial import logging -from typing import Any +from typing import Any, Final from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -16,7 +18,8 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, ) -from homeassistant.core import Context, HomeAssistant, State +from homeassistant.core import Context, HomeAssistant, ServiceResponse, State +from homeassistant.util.enum import try_parse_enum from . import ( ATTR_CURRENT_POSITION, @@ -24,17 +27,142 @@ from . import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, + CoverEntityFeature, CoverState, ) _LOGGER = logging.getLogger(__name__) -VALID_STATES = { - CoverState.CLOSED, - CoverState.CLOSING, - CoverState.OPEN, - CoverState.OPENING, -} + +OPENING_STATES = {CoverState.OPENING, CoverState.OPEN} +CLOSING_STATES = {CoverState.CLOSING, CoverState.CLOSED} +VALID_STATES: set[CoverState] = OPENING_STATES | CLOSING_STATES + +FULL_OPEN: Final = 100 +FULL_CLOSE: Final = 0 + + +def _determine_features(current_attrs: dict[str, Any]) -> CoverEntityFeature: + """Determine supported features based on current attributes.""" + features = CoverEntityFeature(0) + if ATTR_CURRENT_POSITION in current_attrs: + features |= ( + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + ) + if ATTR_CURRENT_TILT_POSITION in current_attrs: + features |= ( + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + ) + if features == CoverEntityFeature(0): + features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + return features + + +async def _async_set_position( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + target_position: int, +) -> bool: + """Set the position of the cover. + + Returns True if the position was set, False if there is no + supported method for setting the position. + """ + if CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: target_position} + ) + elif target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features: + await service_call(SERVICE_CLOSE_COVER, service_data) + elif target_position == FULL_OPEN and CoverEntityFeature.OPEN in features: + await service_call(SERVICE_OPEN_COVER, service_data) + else: + # Requested a position but the cover doesn't support it + return False + return True + + +async def _async_set_tilt_position( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + target_tilt_position: int, +) -> bool: + """Set the tilt position of the cover. + + Returns True if the tilt position was set, False if there is no + supported method for setting the tilt position. + """ + if CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: target_tilt_position}, + ) + elif ( + target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features + ): + await service_call(SERVICE_CLOSE_COVER_TILT, service_data) + elif target_tilt_position == FULL_OPEN and CoverEntityFeature.OPEN_TILT in features: + await service_call(SERVICE_OPEN_COVER_TILT, service_data) + else: + # Requested a tilt position but the cover doesn't support it + return False + return True + + +async def _async_close_cover( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + set_position: bool, + set_tilt: bool, +) -> None: + """Close the cover if it was not closed by setting the position.""" + if not set_position: + if CoverEntityFeature.CLOSE in features: + await service_call(SERVICE_CLOSE_COVER, service_data) + elif CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: FULL_CLOSE} + ) + if not set_tilt: + if CoverEntityFeature.CLOSE_TILT in features: + await service_call(SERVICE_CLOSE_COVER_TILT, service_data) + elif CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: FULL_CLOSE}, + ) + + +async def _async_open_cover( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + set_position: bool, + set_tilt: bool, +) -> None: + """Open the cover if it was not opened by setting the position.""" + if not set_position: + if CoverEntityFeature.OPEN in features: + await service_call(SERVICE_OPEN_COVER, service_data) + elif CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: FULL_OPEN} + ) + if not set_tilt: + if CoverEntityFeature.OPEN_TILT in features: + await service_call(SERVICE_OPEN_COVER_TILT, service_data) + elif CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: FULL_OPEN}, + ) async def _async_reproduce_state( @@ -45,74 +173,64 @@ async def _async_reproduce_state( reproduce_options: dict[str, Any] | None = None, ) -> None: """Reproduce a single state.""" - if (cur_state := hass.states.get(state.entity_id)) is None: - _LOGGER.warning("Unable to find entity %s", state.entity_id) + entity_id = state.entity_id + if (cur_state := hass.states.get(entity_id)) is None: + _LOGGER.warning("Unable to find entity %s", entity_id) return - if state.state not in VALID_STATES: - _LOGGER.warning( - "Invalid state specified for %s: %s", state.entity_id, state.state - ) + if (target_state := state.state) not in VALID_STATES: + _LOGGER.warning("Invalid state specified for %s: %s", entity_id, target_state) return + current_attrs = cur_state.attributes + target_attrs = state.attributes + + current_position: int | None = current_attrs.get(ATTR_CURRENT_POSITION) + target_position: int | None = target_attrs.get(ATTR_CURRENT_POSITION) + position_matches = current_position == target_position + + current_tilt_position: int | None = current_attrs.get(ATTR_CURRENT_TILT_POSITION) + target_tilt_position: int | None = target_attrs.get(ATTR_CURRENT_TILT_POSITION) + tilt_position_matches = current_tilt_position == target_tilt_position + + state_matches = cur_state.state == target_state # Return if we are already at the right state. - if ( - cur_state.state == state.state - and cur_state.attributes.get(ATTR_CURRENT_POSITION) - == state.attributes.get(ATTR_CURRENT_POSITION) - and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) - == state.attributes.get(ATTR_CURRENT_TILT_POSITION) - ): + if state_matches and position_matches and tilt_position_matches: return - service_data = {ATTR_ENTITY_ID: state.entity_id} - service_data_tilting = {ATTR_ENTITY_ID: state.entity_id} + features = try_parse_enum( + CoverEntityFeature, current_attrs.get(ATTR_SUPPORTED_FEATURES) + ) + if features is None: + # Backwards compatibility for integrations that + # don't set supported features since it previously + # worked without it. + _LOGGER.warning("Supported features is not set for %s", entity_id) + features = _determine_features(current_attrs) - if not ( - cur_state.state == state.state - and cur_state.attributes.get(ATTR_CURRENT_POSITION) - == state.attributes.get(ATTR_CURRENT_POSITION) - ): - # Open/Close - if state.state in [CoverState.CLOSED, CoverState.CLOSING]: - service = SERVICE_CLOSE_COVER - elif state.state in [CoverState.OPEN, CoverState.OPENING]: - if ( - ATTR_CURRENT_POSITION in cur_state.attributes - and ATTR_CURRENT_POSITION in state.attributes - ): - service = SERVICE_SET_COVER_POSITION - service_data[ATTR_POSITION] = state.attributes[ATTR_CURRENT_POSITION] - else: - service = SERVICE_OPEN_COVER + service_call = partial( + hass.services.async_call, + DOMAIN, + context=context, + blocking=True, + ) + service_data = {ATTR_ENTITY_ID: entity_id} - await hass.services.async_call( - DOMAIN, service, service_data, context=context, blocking=True + set_position = target_position is not None and await _async_set_position( + service_call, service_data, features, target_position + ) + set_tilt = target_tilt_position is not None and await _async_set_tilt_position( + service_call, service_data, features, target_tilt_position + ) + + if target_state in CLOSING_STATES: + await _async_close_cover( + service_call, service_data, features, set_position, set_tilt ) - if ( - ATTR_CURRENT_TILT_POSITION in state.attributes - and ATTR_CURRENT_TILT_POSITION in cur_state.attributes - and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) - != state.attributes.get(ATTR_CURRENT_TILT_POSITION) - ): - # Tilt position - if state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100: - service_tilting = SERVICE_OPEN_COVER_TILT - elif state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 0: - service_tilting = SERVICE_CLOSE_COVER_TILT - else: - service_tilting = SERVICE_SET_COVER_TILT_POSITION - service_data_tilting[ATTR_TILT_POSITION] = state.attributes[ - ATTR_CURRENT_TILT_POSITION - ] - - await hass.services.async_call( - DOMAIN, - service_tilting, - service_data_tilting, - context=context, - blocking=True, + elif target_state in OPENING_STATES: + await _async_open_cover( + service_call, service_data, features, set_position, set_tilt ) diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 0afef8a200f..6ca8b50620f 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -38,10 +38,10 @@ "name": "[%key:component::cover::title%]", "state": { "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "closed": "[%key:common::state::closed%]", - "closing": "Closing", - "stopped": "Stopped" + "closing": "[%key:common::state::closing%]", + "stopped": "[%key:common::state::stopped%]" }, "state_attributes": { "current_position": { diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 0eaffa39ee9..88a7b71e3ed 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -21,6 +21,7 @@ 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 homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.util.ssl import client_context_no_verify from .const import KEY_MAC, TIMEOUT from .coordinator import DaikinConfigEntry, DaikinCoordinator @@ -48,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo key=entry.data.get(CONF_API_KEY), uuid=entry.data.get(CONF_UUID), password=entry.data.get(CONF_PASSWORD), + ssl_context=client_context_no_verify(), ) _LOGGER.debug("Connection to %s successful", host) except TimeoutError as err: diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index cc25a88ae39..f5febafc4dc 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -18,6 +18,7 @@ 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 homeassistant.util.ssl import client_context_no_verify from .const import DOMAIN, KEY_MAC, TIMEOUT @@ -90,6 +91,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): key=key, uuid=uuid, password=password, + ssl_context=client_context_no_verify(), ) except (TimeoutError, ClientError): self.host = None diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index f794d97a9ba..947fe514747 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.8"], + "requirements": ["pydaikin==2.15.0"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/danfoss_air/binary_sensor.py b/homeassistant/components/danfoss_air/binary_sensor.py index 358d6ca07ab..736604d7ea1 100644 --- a/homeassistant/components/danfoss_air/binary_sensor.py +++ b/homeassistant/components/danfoss_air/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DANFOSS_AIR_DOMAIN +from . import DOMAIN def setup_platform( @@ -22,7 +22,7 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the available Danfoss Air sensors etc.""" - data = hass.data[DANFOSS_AIR_DOMAIN] + data = hass.data[DOMAIN] sensors = [ [ diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index 85b4e89d434..569ba21b234 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.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 DANFOSS_AIR_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,7 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the available Danfoss Air sensors etc.""" - data = hass.data[DANFOSS_AIR_DOMAIN] + data = hass.data[DOMAIN] sensors = [ [ diff --git a/homeassistant/components/danfoss_air/switch.py b/homeassistant/components/danfoss_air/switch.py index dc3277078b0..5e7c5728d81 100644 --- a/homeassistant/components/danfoss_air/switch.py +++ b/homeassistant/components/danfoss_air/switch.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DANFOSS_AIR_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -24,7 +24,7 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Danfoss Air HRV switch platform.""" - data = hass.data[DANFOSS_AIR_DOMAIN] + data = hass.data[DOMAIN] switches = [ [ diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 078af8c67a5..0b9f8ea55f5 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.11"] + "requirements": ["debugpy==1.8.14"] } diff --git a/homeassistant/components/deconz/entity.py b/homeassistant/components/deconz/entity.py index f45c35ada44..fef973d612c 100644 --- a/homeassistant/components/deconz/entity.py +++ b/homeassistant/components/deconz/entity.py @@ -13,7 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DOMAIN as DECONZ_DOMAIN +from .const import DOMAIN from .hub import DeconzHub from .util import serial_from_unique_id @@ -59,12 +59,12 @@ class DeconzBase[_DeviceT: _DeviceType]: return DeviceInfo( connections={(CONNECTION_ZIGBEE, self.serial)}, - identifiers={(DECONZ_DOMAIN, self.serial)}, + identifiers={(DOMAIN, self.serial)}, manufacturer=self._device.manufacturer, model=self._device.model_id, name=self._device.name, sw_version=self._device.software_version, - via_device=(DECONZ_DOMAIN, self.hub.api.config.bridge_id), + via_device=(DOMAIN, self.hub.api.config.bridge_id), ) @@ -176,9 +176,9 @@ class DeconzSceneMixin(DeconzDevice[PydeconzScene]): def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" return DeviceInfo( - identifiers={(DECONZ_DOMAIN, self._group_identifier)}, + identifiers={(DOMAIN, self._group_identifier)}, manufacturer="Dresden Elektronik", model="deCONZ group", name=self.group.name, - via_device=(DECONZ_DOMAIN, self.hub.api.config.bridge_id), + via_device=(DOMAIN, self.hub.api.config.bridge_id), ) diff --git a/homeassistant/components/deconz/hub/hub.py b/homeassistant/components/deconz/hub/hub.py index 3020d624f97..f82f1d857fd 100644 --- a/homeassistant/components/deconz/hub/hub.py +++ b/homeassistant/components/deconz/hub/hub.py @@ -17,12 +17,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send -from ..const import ( - CONF_MASTER_GATEWAY, - DOMAIN as DECONZ_DOMAIN, - HASSIO_CONFIGURATION_URL, - PLATFORMS, -) +from ..const import CONF_MASTER_GATEWAY, DOMAIN, HASSIO_CONFIGURATION_URL, PLATFORMS from .config import DeconzConfig if TYPE_CHECKING: @@ -193,7 +188,7 @@ class DeconzHub: config_entry_id=self.config_entry.entry_id, configuration_url=configuration_url, entry_type=dr.DeviceEntryType.SERVICE, - identifiers={(DECONZ_DOMAIN, self.api.config.bridge_id)}, + identifiers={(DOMAIN, self.api.config.bridge_id)}, manufacturer="Dresden Elektronik", model=self.api.config.model_id, name=self.api.config.name, diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index b61a1d39333..1eb827f85d6 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -38,7 +38,7 @@ from homeassistant.util.color import ( ) from . import DeconzConfigEntry -from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS +from .const import DOMAIN, POWER_PLUGS from .entity import DeconzDevice from .hub import DeconzHub @@ -395,11 +395,11 @@ class DeconzGroup(DeconzBaseLight[Group]): def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" return DeviceInfo( - identifiers={(DECONZ_DOMAIN, self.unique_id)}, + identifiers={(DOMAIN, self.unique_id)}, manufacturer="Dresden Elektronik", model="deCONZ group", name=self._device.name, - via_device=(DECONZ_DOMAIN, self.hub.api.config.bridge_id), + via_device=(DOMAIN, self.hub.api.config.bridge_id), ) @property diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 93ae8e392c8..5664e6abc8a 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pydeconz"], - "requirements": ["pydeconz==118"], + "requirements": ["pydeconz==120"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 52059aa8785..a64bdd5050e 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -73,7 +73,7 @@ "remote_moved_any_side": "Device moved with any side up", "remote_double_tap_any_side": "Device double tapped on any side", "remote_turned_clockwise": "Device turned clockwise", - "remote_turned_counter_clockwise": "Device turned counter clockwise", + "remote_turned_counter_clockwise": "Device turned counterclockwise", "remote_rotate_from_side_1": "Device rotated from \"side 1\" to \"{subtype}\"", "remote_rotate_from_side_2": "Device rotated from \"side 2\" to \"{subtype}\"", "remote_rotate_from_side_3": "Device rotated from \"side 3\" to \"{subtype}\"", diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index 19afe26e8f9..78eced64c7c 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from ssl import SSLError from typing import Any @@ -21,6 +22,8 @@ from .const import ( DOMAIN, ) +_LOGGER = logging.getLogger(__name__) + class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Deluge.""" @@ -86,7 +89,8 @@ class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job(api.connect) except (ConnectionRefusedError, TimeoutError, SSLError): return "cannot_connect" - except Exception as ex: # noqa: BLE001 + except Exception as ex: + _LOGGER.exception("Unexpected error") if type(ex).__name__ == "BadLoginError": return "invalid_auth" return "unknown" diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index 6adde8ef7df..ddea78b315f 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls", + "description": "To be able to use this integration, you have to enable the following option in Deluge settings: Daemon > Allow remote controls", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 9314fc211de..dbc65119bfa 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -48,6 +48,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.TIME, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, Platform.WEATHER, ] diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json index eafcbb9161a..9a076f47a2d 100644 --- a/homeassistant/components/demo/icons.json +++ b/homeassistant/components/demo/icons.json @@ -45,6 +45,17 @@ } } }, + "light": { + "bed_light": { + "state_attributes": { + "effect": { + "state": { + "rainbow": "mdi:looks" + } + } + } + } + }, "number": { "volume": { "default": "mdi:volume-high" diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index c00f2b42828..25a7b46bfb6 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -15,6 +15,7 @@ from homeassistant.components.light import ( ATTR_WHITE, DEFAULT_MAX_KELVIN, DEFAULT_MIN_KELVIN, + EFFECT_OFF, ColorMode, LightEntity, LightEntityFeature, @@ -28,7 +29,7 @@ from . import DOMAIN LIGHT_COLORS = [(56, 86), (345, 75)] -LIGHT_EFFECT_LIST = ["rainbow", "none"] +LIGHT_EFFECT_LIST = ["rainbow", EFFECT_OFF] LIGHT_TEMPS = [4166, 2631] @@ -48,6 +49,7 @@ async def async_setup_entry( available=True, effect_list=LIGHT_EFFECT_LIST, effect=LIGHT_EFFECT_LIST[0], + translation_key="bed_light", device_name="Bed Light", state=False, unique_id="light_1", @@ -119,8 +121,10 @@ class DemoLight(LightEntity): rgbw_color: tuple[int, int, int, int] | None = None, rgbww_color: tuple[int, int, int, int, int] | None = None, supported_color_modes: set[ColorMode] | None = None, + translation_key: str | None = None, ) -> None: """Initialize the light.""" + self._attr_translation_key = translation_key self._available = True self._brightness = brightness self._ct = ct or random.choice(LIGHT_TEMPS) diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index de2a2cb3937..ad7ddcba285 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -6,12 +6,16 @@ from datetime import datetime from typing import Any from homeassistant.components.media_player import ( + BrowseMedia, + MediaClass, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, RepeatMode, + SearchMedia, + SearchMediaQuery, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -41,6 +45,7 @@ async def async_setup_entry( DemoTVShowPlayer(), DemoBrowsePlayer("Browse"), DemoGroupPlayer("Group"), + DemoSearchPlayer("Search"), ] ) @@ -95,6 +100,8 @@ NETFLIX_PLAYER_SUPPORT = ( BROWSE_PLAYER_SUPPORT = MediaPlayerEntityFeature.BROWSE_MEDIA +SEARCH_PLAYER_SUPPORT = MediaPlayerEntityFeature.SEARCH_MEDIA + class AbstractDemoPlayer(MediaPlayerEntity): """A demo media players.""" @@ -398,3 +405,24 @@ class DemoGroupPlayer(AbstractDemoPlayer): | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.TURN_OFF ) + + +class DemoSearchPlayer(AbstractDemoPlayer): + """A Demo media player that supports searching.""" + + _attr_supported_features = SEARCH_PLAYER_SUPPORT + + async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia: + """Demo implementation of search media.""" + return SearchMedia( + result=[ + BrowseMedia( + title="Search result", + media_class=MediaClass.MOVIE, + media_content_type=MediaType.MOVIE, + media_content_id="search_result_id", + can_play=True, + can_expand=False, + ) + ] + ) diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index da72b33d3ca..e22b4c413d5 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -28,10 +28,10 @@ "state_attributes": { "fan_mode": { "state": { - "auto_high": "Auto High", - "auto_low": "Auto Low", - "on_high": "On High", - "on_low": "On Low" + "auto_high": "Auto high", + "auto_low": "Auto low", + "on_high": "On high", + "on_low": "On low" } }, "swing_mode": { @@ -39,14 +39,14 @@ "1": "1", "2": "2", "3": "3", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "off": "[%key:common::state::off%]" } }, "swing_horizontal_mode": { "state": { "rangefull": "Full range", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "off": "[%key:common::state::off%]" } } @@ -58,7 +58,7 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", + "auto": "[%key:common::state::auto%]", "sleep": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "smart": "Smart", "on": "[%key:common::state::on%]" @@ -78,12 +78,23 @@ } } }, + "light": { + "bed_light": { + "state_attributes": { + "effect": { + "state": { + "rainbow": "Rainbow" + } + } + } + } + }, "select": { "speed": { "state": { - "light_speed": "Light Speed", - "ludicrous_speed": "Ludicrous Speed", - "ridiculous_speed": "Ridiculous Speed" + "light_speed": "Light speed", + "ludicrous_speed": "Ludicrous speed", + "ridiculous_speed": "Ridiculous speed" } } }, @@ -102,7 +113,7 @@ "model_s": { "state_attributes": { "cleaned_area": { - "name": "Cleaned Area" + "name": "Cleaned area" } } } diff --git a/homeassistant/components/demo/valve.py b/homeassistant/components/demo/valve.py new file mode 100644 index 00000000000..03f0123dd96 --- /dev/null +++ b/homeassistant/components/demo/valve.py @@ -0,0 +1,89 @@ +"""Demo valve platform that implements valves.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + async_add_entities( + [ + DemoValve("Front Garden", ValveState.OPEN), + DemoValve("Orchard", ValveState.CLOSED), + ] + ) + + +class DemoValve(ValveEntity): + """Representation of a Demo valve.""" + + _attr_should_poll = False + + def __init__( + self, + name: str, + state: str, + moveable: bool = True, + ) -> None: + """Initialize the valve.""" + self._attr_name = name + if moveable: + self._attr_supported_features = ( + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + ) + self._state = state + self._moveable = moveable + + @property + def is_open(self) -> bool: + """Return true if valve is open.""" + return self._state == ValveState.OPEN + + @property + def is_opening(self) -> bool: + """Return true if valve is opening.""" + return self._state == ValveState.OPENING + + @property + def is_closing(self) -> bool: + """Return true if valve is closing.""" + return self._state == ValveState.CLOSING + + @property + def is_closed(self) -> bool: + """Return true if valve is closed.""" + return self._state == ValveState.CLOSED + + @property + def reports_position(self) -> bool: + """Return True if entity reports position, False otherwise.""" + return False + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + self._state = ValveState.OPENING + self.async_write_ha_state() + await asyncio.sleep(OPEN_CLOSE_DELAY) + self._state = ValveState.OPEN + self.async_write_ha_state() + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close the valve.""" + self._state = ValveState.CLOSING + self.async_write_ha_state() + await asyncio.sleep(OPEN_CLOSE_DELAY) + self._state = ValveState.CLOSED + self.async_write_ha_state() diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 328ab504bd1..c5a1b9aeb63 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.1"], + "requirements": ["denonavr==1.1.1"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 90f8a95919d..f6c2b45ef9c 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -24,7 +24,14 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTime, ) -from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.core import ( + Event, + EventStateChangedData, + EventStateReportedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo @@ -32,7 +39,10 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_state_report_event, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -200,13 +210,33 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _LOGGER.warning("Could not restore last state: %s", err) @callback - def calc_derivative(event: Event[EventStateChangedData]) -> None: + def on_state_reported(event: Event[EventStateReportedData]) -> None: + """Handle constant sensor state.""" + if self._attr_native_value == Decimal(0): + # If the derivative is zero, and the source sensor hasn't + # changed state, then we know it will still be zero. + return + new_state = event.data["new_state"] + if new_state is not None: + calc_derivative( + new_state, new_state.state, event.data["old_last_reported"] + ) + + @callback + def on_state_changed(event: Event[EventStateChangedData]) -> None: + """Handle changed sensor state.""" + new_state = event.data["new_state"] + old_state = event.data["old_state"] + if new_state is not None and old_state is not None: + calc_derivative(new_state, old_state.state, old_state.last_reported) + + def calc_derivative( + new_state: State, old_value: str, old_last_reported: datetime + ) -> None: """Handle the sensor state changes.""" - if ( - (old_state := event.data["old_state"]) is None - or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) - or (new_state := event.data["new_state"]) is None - or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, ): return @@ -220,15 +250,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity): self._state_list = [ (time_start, time_end, state) for time_start, time_end, state in self._state_list - if (new_state.last_updated - time_end).total_seconds() + if (new_state.last_reported - time_end).total_seconds() < self._time_window ] try: elapsed_time = ( - new_state.last_updated - old_state.last_updated + new_state.last_reported - old_last_reported ).total_seconds() - delta_value = Decimal(new_state.state) - Decimal(old_state.state) + delta_value = Decimal(new_state.state) - Decimal(old_value) new_derivative = ( delta_value / Decimal(elapsed_time) @@ -240,7 +270,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _LOGGER.warning("While calculating derivative: %s", err) except DecimalException as err: _LOGGER.warning( - "Invalid state (%s > %s): %s", old_state.state, new_state.state, err + "Invalid state (%s > %s): %s", old_value, new_state.state, err ) except AssertionError as err: _LOGGER.error("Could not calculate derivative: %s", err) @@ -257,7 +287,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): # add latest derivative to the window list self._state_list.append( - (old_state.last_updated, new_state.last_updated, new_derivative) + (old_last_reported, new_state.last_reported, new_derivative) ) def calculate_weight( @@ -277,13 +307,19 @@ class DerivativeSensor(RestoreSensor, SensorEntity): else: derivative = Decimal("0.00") for start, end, value in self._state_list: - weight = calculate_weight(start, end, new_state.last_updated) + weight = calculate_weight(start, end, new_state.last_reported) derivative = derivative + (value * Decimal(weight)) self._attr_native_value = round(derivative, self._round_digits) self.async_write_ha_state() self.async_on_remove( async_track_state_change_event( - self.hass, self._sensor_source_id, calc_derivative + self.hass, self._sensor_source_id, on_state_changed + ) + ) + + self.async_on_remove( + async_track_state_report_event( + self.hass, self._sensor_source_id, on_state_reported ) ) diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index bfdf861a019..f1b7375ae07 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -15,7 +15,7 @@ }, "data_description": { "round": "Controls the number of decimal digits in the output.", - "time_window": "If set, the sensor's value is a time weighted moving average of derivatives within this window.", + "time_window": "If set, the sensor's value is a time-weighted moving average of derivatives within this window.", "unit_prefix": "The output will be scaled according to the selected metric prefix and time unit of the derivative." } } diff --git a/homeassistant/components/device_automation/trigger.py b/homeassistant/components/device_automation/trigger.py index cc8c4d4d52e..071b8236086 100644 --- a/homeassistant/components/device_automation/trigger.py +++ b/homeassistant/components/device_automation/trigger.py @@ -8,11 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import ( - TriggerActionType, - TriggerInfo, - TriggerProtocol, -) +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import ( @@ -25,13 +21,28 @@ from .helpers import async_validate_device_automation_config TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -class DeviceAutomationTriggerProtocol(TriggerProtocol, Protocol): +class DeviceAutomationTriggerProtocol(Protocol): """Define the format of device_trigger modules. - Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config - from TriggerProtocol. + Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config. """ + TRIGGER_SCHEMA: vol.Schema + + async def async_validate_trigger_config( + self, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + + async def async_attach_trigger( + self, + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, + ) -> CALLBACK_TYPE: + """Attach a trigger.""" + async def async_get_trigger_capabilities( self, hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index db33d5038fc..b82cf0352a7 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -218,7 +218,7 @@ class TrackerEntity( entity_description: TrackerEntityDescription _attr_latitude: float | None = None - _attr_location_accuracy: int = 0 + _attr_location_accuracy: float = 0 _attr_location_name: str | None = None _attr_longitude: float | None = None _attr_source_type: SourceType = SourceType.GPS @@ -234,7 +234,7 @@ class TrackerEntity( return not self.should_poll @cached_property - def location_accuracy(self) -> int: + def location_accuracy(self) -> float: """Return the location accuracy of the device. Value in meters. diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index e86b7b753c8..b8dc948913f 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from functools import partial -from types import MappingProxyType from typing import Any from devolo_home_control_api.exceptions.gateway import GatewayOfflineError @@ -97,7 +97,7 @@ async def async_remove_config_entry_device( return True -def configure_mydevolo(conf: dict[str, Any] | MappingProxyType[str, Any]) -> Mydevolo: +def configure_mydevolo(conf: Mapping[str, Any]) -> Mydevolo: """Configure mydevolo.""" mydevolo = Mydevolo() mydevolo.user = conf[CONF_USERNAME] diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index a9715fffa84..983b2a33452 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -8,6 +8,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["devolo_home_control_api"], - "requirements": ["devolo-home-control-api==0.18.3"], + "requirements": ["devolo-home-control-api==0.19.0"], "zeroconf": ["_dvl-deviceapi._tcp.local."] } diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py index bd2f23d602f..ad21289ff28 100644 --- a/homeassistant/components/devolo_home_network/config_flow.py +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -7,7 +7,7 @@ import logging from typing import Any from devolo_plc_api.device import Device -from devolo_plc_api.exceptions.device import DeviceNotFound +from devolo_plc_api.exceptions.device import DeviceNotFound, DevicePasswordProtected import voluptuous as vol from homeassistant.components import zeroconf @@ -22,7 +22,9 @@ from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_IP_ADDRESS): str}) +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_IP_ADDRESS): str, vol.Optional(CONF_PASSWORD): str} +) STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_PASSWORD): str}) @@ -36,7 +38,16 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, device = Device(data[CONF_IP_ADDRESS], zeroconf_instance=zeroconf_instance) + device.password = data[CONF_PASSWORD] + await device.async_connect(session_instance=async_client) + + # Try a password protected, non-writing device API call that raises, if the password is wrong. + # If only the plcnet API is available, we can continue without trying a password as the plcnet + # API does not require a password. + if device.device: + await device.device.async_uptime() + await device.async_disconnect() return { @@ -59,23 +70,22 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict = {} - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors - ) - - try: - info = await validate_input(self.hass, user_input) - except DeviceNotFound: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(info[SERIAL_NUMBER], raise_on_progress=False) - self._abort_if_unique_id_configured() - user_input[CONF_PASSWORD] = "" - return self.async_create_entry(title=info[TITLE], data=user_input) + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except DeviceNotFound: + errors["base"] = "cannot_connect" + except DevicePasswordProtected: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + info[SERIAL_NUMBER], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info[TITLE], data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors @@ -106,15 +116,27 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a flow initiated by zeroconf.""" title = self.context["title_placeholders"][CONF_NAME] + errors: dict = {} + data_schema: vol.Schema | None = None + if user_input is not None: data = { CONF_IP_ADDRESS: self.host, - CONF_PASSWORD: "", + CONF_PASSWORD: user_input.get(CONF_PASSWORD, ""), } - return self.async_create_entry(title=title, data=data) + try: + await validate_input(self.hass, data) + except DevicePasswordProtected: + errors = {"base": "invalid_auth"} + data_schema = STEP_REAUTH_DATA_SCHEMA + else: + return self.async_create_entry(title=title, data=data) + return self.async_show_form( step_id="zeroconf_confirm", + data_schema=data_schema, description_placeholders={"host_name": title}, + errors=errors, ) async def async_step_reauth( @@ -134,14 +156,21 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by reauthentication.""" - if user_input is None: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=STEP_REAUTH_DATA_SCHEMA, - ) + errors: dict = {} + if user_input is not None: + data = { + CONF_IP_ADDRESS: self.host, + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + try: + await validate_input(self.hass, data) + except DevicePasswordProtected: + errors = {"base": "invalid_auth"} + else: + return self.async_update_reload_and_abort(self._reauth_entry, data=data) - data = { - CONF_IP_ADDRESS: self.host, - CONF_PASSWORD: user_input[CONF_PASSWORD], - } - return self.async_update_reload_and_abort(self._reauth_entry, data=data) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index c5862738bd1..cb726e5954c 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -88,6 +88,8 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module ): """Representation of a devolo device tracker.""" + _attr_translation_key = "device_tracker" + def __init__( self, coordinator: DevoloDataUpdateCoordinator[list[ConnectedStationInfo]], @@ -123,13 +125,6 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module ) return attrs - @property - def icon(self) -> str: - """Return device icon.""" - if self.is_connected: - return "mdi:lan-connect" - return "mdi:lan-disconnect" - @property def is_connected(self) -> bool: """Return true if the device is connected to the network.""" diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 93ec1b9a3a2..64d8ff131e8 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -8,6 +8,7 @@ from devolo_plc_api.device_api import ( WifiGuestAccessGet, ) from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork +from yarl import URL from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -43,7 +44,7 @@ class DevoloEntity(Entity): self.entry = entry self._attr_device_info = DeviceInfo( - configuration_url=f"http://{self.device.ip}", + configuration_url=URL.build(scheme="http", host=self.device.ip), identifiers={(DOMAIN, str(self.device.serial_number))}, manufacturer="devolo", model=self.device.product, diff --git a/homeassistant/components/devolo_home_network/icons.json b/homeassistant/components/devolo_home_network/icons.json index 816d0e36d03..752e5aa3f36 100644 --- a/homeassistant/components/devolo_home_network/icons.json +++ b/homeassistant/components/devolo_home_network/icons.json @@ -13,6 +13,14 @@ "default": "mdi:wifi-plus" } }, + "device_tracker": { + "device_tracker": { + "default": "mdi:lan-disconnect", + "state": { + "home": "mdi:lan-connect" + } + } + }, "sensor": { "connected_plc_devices": { "default": "mdi:lan" diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index 9b1e181d7c0..31f3a51ebeb 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["devolo_plc_api"], - "requirements": ["devolo-plc-api==1.4.1"], + "requirements": ["devolo-plc-api==1.5.1"], "zeroconf": [ { "type": "_dvl-deviceapi._tcp.local.", diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index d9a6f3f1110..cec1ecc8a81 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -138,7 +138,7 @@ async def async_setup_entry( SENSOR_TYPES[CONNECTED_PLC_DEVICES], ) ) - network = await device.plcnet.async_get_network_overview() + network: LogicalNetwork = coordinators[CONNECTED_PLC_DEVICES].data peers = [ peer.mac_address for peer in network.devices if peer.topology == REMOTE ] diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 4b683b5d2fa..50177a9b13b 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -5,10 +5,12 @@ "user": { "description": "[%key:common::config_flow::description::confirm_setup%]", "data": { - "ip_address": "[%key:common::config_flow::data::ip%]" + "ip_address": "[%key:common::config_flow::data::ip%]", + "password": "[%key:common::config_flow::data::password%]" }, "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." + "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard.", + "password": "Password you protected the device with." } }, "reauth_confirm": { @@ -16,16 +18,23 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "password": "Password you protected the device with." + "password": "[%key:component::devolo_home_network::config::step::user::data_description::password%]" } }, "zeroconf_confirm": { "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", - "title": "Discovered devolo home network device" + "title": "Discovered devolo home network device", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::devolo_home_network::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": { diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index 0271270fa09..b57305a7a77 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -114,9 +114,14 @@ class DevoloSwitchEntity[_DataT: _DataType]( translation_key="password_protected", translation_placeholders={"title": self.entry.title}, ) from ex - except DeviceUnavailable: - pass # The coordinator will handle this - await self.coordinator.async_request_refresh() + except DeviceUnavailable as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_response", + translation_placeholders={"title": self.entry.title}, + ) from ex + finally: + await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" @@ -129,6 +134,11 @@ class DevoloSwitchEntity[_DataT: _DataType]( translation_key="password_protected", translation_placeholders={"title": self.entry.title}, ) from ex - except DeviceUnavailable: - pass # The coordinator will handle this - await self.coordinator.async_request_refresh() + except DeviceUnavailable as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_response", + translation_placeholders={"title": self.entry.title}, + ) from ex + finally: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py index 90917e0ce2c..ed6dc94e764 100644 --- a/homeassistant/components/dexcom/config_flow.py +++ b/homeassistant/components/dexcom/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from pydexcom import AccountError, Dexcom, SessionError @@ -12,6 +13,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import CONF_SERVER, DOMAIN, SERVER_OUS, SERVER_US +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -43,7 +46,8 @@ class DexcomConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except AccountError: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected error") errors["base"] = "unknown" if "base" not in errors: diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index a11a0b262b0..70340c81f2f 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -4,10 +4,10 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from dataclasses import dataclass from datetime import timedelta from fnmatch import translate from functools import lru_cache, partial +from ipaddress import IPv4Address import itertools import logging import re @@ -23,6 +23,7 @@ from aiodiscover.discovery import ( from cached_ipaddress import cached_ip_addresses from homeassistant import config_entries +from homeassistant.components import network from homeassistant.components.device_tracker import ( ATTR_HOST_NAME, ATTR_IP, @@ -66,13 +67,12 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo as _DhcpServ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import DHCPMatcher, async_get_dhcp -from .const import DOMAIN +from . import websocket_api +from .const import DOMAIN, HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from .models import DATA_DHCP, DHCPAddressData, DHCPData, DhcpMatchers CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -HOSTNAME: Final = "hostname" -MAC_ADDRESS: Final = "macaddress" -IP_ADDRESS: Final = "ip" REGISTERED_DEVICES: Final = "registered_devices" SCAN_INTERVAL = timedelta(minutes=60) @@ -87,15 +87,6 @@ _DEPRECATED_DhcpServiceInfo = DeprecatedConstant( ) -@dataclass(slots=True) -class DhcpMatchers: - """Prepared info from dhcp entries.""" - - registered_devices_domains: set[str] - no_oui_matchers: dict[str, list[DHCPMatcher]] - oui_matchers: dict[str, list[DHCPMatcher]] - - def async_index_integration_matchers( integration_matchers: list[DHCPMatcher], ) -> DhcpMatchers: @@ -133,36 +124,34 @@ def async_index_integration_matchers( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the dhcp component.""" - watchers: list[WatcherBase] = [] - address_data: dict[str, dict[str, str]] = {} integration_matchers = async_index_integration_matchers(await async_get_dhcp(hass)) + dhcp_data = DHCPData(integration_matchers=integration_matchers) + hass.data[DATA_DHCP] = dhcp_data + websocket_api.async_setup(hass) + watchers: list[WatcherBase] = [] # For the passive classes we need to start listening # for state changes and connect the dispatchers before # everything else starts up or we will miss events - device_watcher = DeviceTrackerWatcher(hass, address_data, integration_matchers) + device_watcher = DeviceTrackerWatcher(hass, dhcp_data) device_watcher.async_start() watchers.append(device_watcher) - device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher( - hass, address_data, integration_matchers - ) + device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher(hass, dhcp_data) device_tracker_registered_watcher.async_start() watchers.append(device_tracker_registered_watcher) async def _async_initialize(event: Event) -> None: await aiodhcpwatcher.async_init() - network_watcher = NetworkWatcher(hass, address_data, integration_matchers) + network_watcher = NetworkWatcher(hass, dhcp_data) network_watcher.async_start() watchers.append(network_watcher) - dhcp_watcher = DHCPWatcher(hass, address_data, integration_matchers) + dhcp_watcher = DHCPWatcher(hass, dhcp_data) await dhcp_watcher.async_start() watchers.append(dhcp_watcher) - rediscovery_watcher = RediscoveryWatcher( - hass, address_data, integration_matchers - ) + rediscovery_watcher = RediscoveryWatcher(hass, dhcp_data) rediscovery_watcher.async_start() watchers.append(rediscovery_watcher) @@ -180,18 +169,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class WatcherBase: """Base class for dhcp and device tracker watching.""" - def __init__( - self, - hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: DhcpMatchers, - ) -> None: + def __init__(self, hass: HomeAssistant, dhcp_data: DHCPData) -> None: """Initialize class.""" super().__init__() - self.hass = hass - self._integration_matchers = integration_matchers - self._address_data = address_data + self._callbacks = dhcp_data.callbacks + self._integration_matchers = dhcp_data.integration_matchers + self._address_data = dhcp_data.address_data self._unsub: Callable[[], None] | None = None @callback @@ -230,18 +214,18 @@ class WatcherBase: mac_address = formatted_mac.replace(":", "") compressed_ip_address = made_ip_address.compressed - data = self._address_data.get(mac_address) + current_data = self._address_data.get(mac_address) if ( not force - and data - and data[IP_ADDRESS] == compressed_ip_address - and data[HOSTNAME].startswith(hostname) + and current_data + and current_data[IP_ADDRESS] == compressed_ip_address + and current_data[HOSTNAME].startswith(hostname) ): # If the address data is the same no need # to process it return - data = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname} + data: DHCPAddressData = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname} self._address_data[mac_address] = data lowercase_hostname = hostname.lower() @@ -287,9 +271,19 @@ class WatcherBase: _LOGGER.debug("Matched %s against %s", data, matcher) matched_domains.add(domain) - if not matched_domains: - return # avoid creating DiscoveryKey if there are no matches + if self._callbacks: + address_data = {mac_address: data} + for callback_ in self._callbacks: + callback_(address_data) + service_info: _DhcpServiceInfo | None = None + if not matched_domains: + return + service_info = _DhcpServiceInfo( + ip=ip_address, + hostname=lowercase_hostname, + macaddress=mac_address, + ) discovery_key = DiscoveryKey( domain=DOMAIN, key=mac_address, @@ -300,11 +294,7 @@ class WatcherBase: self.hass, domain, {"source": config_entries.SOURCE_DHCP}, - _DhcpServiceInfo( - ip=ip_address, - hostname=lowercase_hostname, - macaddress=mac_address, - ), + service_info, discovery_key=discovery_key, ) @@ -315,11 +305,10 @@ class NetworkWatcher(WatcherBase): def __init__( self, hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: DhcpMatchers, + dhcp_data: DHCPData, ) -> None: """Initialize class.""" - super().__init__(hass, address_data, integration_matchers) + super().__init__(hass, dhcp_data) self._discover_hosts: DiscoverHosts | None = None self._discover_task: asyncio.Task | None = None @@ -434,9 +423,33 @@ class DHCPWatcher(WatcherBase): response.ip_address, response.hostname, response.mac_address ) + async def async_get_adapter_indexes(self) -> list[int] | None: + """Get the adapter indexes.""" + adapters = await network.async_get_adapters(self.hass) + if network.async_only_default_interface_enabled(adapters): + return None + return [ + adapter["index"] + for adapter in adapters + if ( + adapter["enabled"] + and adapter["index"] is not None + and adapter["ipv4"] + and ( + addresses := [IPv4Address(ip["address"]) for ip in adapter["ipv4"]] + ) + and any( + ip for ip in addresses if not ip.is_loopback and not ip.is_global + ) + ) + ] + async def async_start(self) -> None: """Start watching for dhcp packets.""" - self._unsub = await aiodhcpwatcher.async_start(self._async_process_dhcp_request) + self._unsub = await aiodhcpwatcher.async_start( + self._async_process_dhcp_request, + await self.async_get_adapter_indexes(), + ) class RediscoveryWatcher(WatcherBase): diff --git a/homeassistant/components/dhcp/const.py b/homeassistant/components/dhcp/const.py index c28a699c64c..c3bf8c512db 100644 --- a/homeassistant/components/dhcp/const.py +++ b/homeassistant/components/dhcp/const.py @@ -1,3 +1,8 @@ """Constants for the dhcp integration.""" +from typing import Final + DOMAIN = "dhcp" +HOSTNAME: Final = "hostname" +MAC_ADDRESS: Final = "macaddress" +IP_ADDRESS: Final = "ip" diff --git a/homeassistant/components/dhcp/helpers.py b/homeassistant/components/dhcp/helpers.py new file mode 100644 index 00000000000..e5ab767ee71 --- /dev/null +++ b/homeassistant/components/dhcp/helpers.py @@ -0,0 +1,37 @@ +"""The dhcp integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import partial + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback + +from .models import DATA_DHCP, DHCPAddressData + + +@callback +def async_register_dhcp_callback_internal( + hass: HomeAssistant, + callback_: Callable[[dict[str, DHCPAddressData]], None], +) -> CALLBACK_TYPE: + """Register a dhcp callback. + + For internal use only. + This is not intended for use by integrations. + """ + callbacks = hass.data[DATA_DHCP].callbacks + callbacks.add(callback_) + return partial(callbacks.remove, callback_) + + +@callback +def async_get_address_data_internal( + hass: HomeAssistant, +) -> dict[str, DHCPAddressData]: + """Get the address data. + + For internal use only. + This is not intended for use by integrations. + """ + return hass.data[DATA_DHCP].address_data diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 5b3a5abd26f..ea2a4f4f820 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -2,6 +2,7 @@ "domain": "dhcp", "name": "DHCP Discovery", "codeowners": ["@bdraco"], + "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/dhcp", "integration_type": "system", "iot_class": "local_push", @@ -14,8 +15,8 @@ ], "quality_scale": "internal", "requirements": [ - "aiodhcpwatcher==1.1.1", - "aiodiscover==2.6.1", - "cached-ipaddress==0.9.2" + "aiodhcpwatcher==1.2.0", + "aiodiscover==2.7.0", + "cached-ipaddress==0.10.0" ] } diff --git a/homeassistant/components/dhcp/models.py b/homeassistant/components/dhcp/models.py new file mode 100644 index 00000000000..d26993e7f0f --- /dev/null +++ b/homeassistant/components/dhcp/models.py @@ -0,0 +1,43 @@ +"""The dhcp integration.""" + +from __future__ import annotations + +from collections.abc import Callable +import dataclasses +from dataclasses import dataclass +from typing import TypedDict + +from homeassistant.loader import DHCPMatcher +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + + +@dataclass(slots=True) +class DhcpMatchers: + """Prepared info from dhcp entries.""" + + registered_devices_domains: set[str] + no_oui_matchers: dict[str, list[DHCPMatcher]] + oui_matchers: dict[str, list[DHCPMatcher]] + + +class DHCPAddressData(TypedDict): + """Typed dict for DHCP address data.""" + + hostname: str + ip: str + + +@dataclasses.dataclass(slots=True) +class DHCPData: + """Data for the dhcp component.""" + + integration_matchers: DhcpMatchers + callbacks: set[Callable[[dict[str, DHCPAddressData]], None]] = dataclasses.field( + default_factory=set + ) + address_data: dict[str, DHCPAddressData] = dataclasses.field(default_factory=dict) + + +DATA_DHCP: HassKey[DHCPData] = HassKey(DOMAIN) diff --git a/homeassistant/components/dhcp/websocket_api.py b/homeassistant/components/dhcp/websocket_api.py new file mode 100644 index 00000000000..e6682de2158 --- /dev/null +++ b/homeassistant/components/dhcp/websocket_api.py @@ -0,0 +1,63 @@ +"""The dhcp integration websocket apis.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.json import json_bytes + +from .const import HOSTNAME, IP_ADDRESS +from .helpers import ( + async_get_address_data_internal, + async_register_dhcp_callback_internal, +) +from .models import DHCPAddressData + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the DHCP websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_discovery) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "dhcp/subscribe_discovery", + } +) +@websocket_api.async_response +async def ws_subscribe_discovery( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe discovery websocket command.""" + ws_msg_id: int = msg["id"] + + def _async_send(address_data: dict[str, DHCPAddressData]) -> None: + connection.send_message( + json_bytes( + websocket_api.event_message( + ws_msg_id, + { + "add": [ + { + "mac_address": dr.format_mac(mac_address).upper(), + "hostname": data[HOSTNAME], + "ip_address": data[IP_ADDRESS], + } + for mac_address, data in address_data.items() + ] + }, + ) + ) + ) + + unsub = async_register_dhcp_callback_internal(hass, _async_send) + connection.subscriptions[ws_msg_id] = unsub + connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id))) + _async_send(async_get_address_data_internal(hass)) diff --git a/homeassistant/components/dialogflow/strings.json b/homeassistant/components/dialogflow/strings.json index 4e59e53ca8c..dab13e31b0c 100644 --- a/homeassistant/components/dialogflow/strings.json +++ b/homeassistant/components/dialogflow/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up the Dialogflow Webhook", + "title": "Set up the Dialogflow webhook", "description": "Are you sure you want to set up Dialogflow?" } }, @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to set up [webhook integration of Dialogflow]({dialogflow_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details." + "default": "To send events to Home Assistant, you will need to set up the [webhook service of Dialogflow]({dialogflow_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details." } } } diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 9cf63176de6..0a8b7422f84 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -9,7 +9,7 @@ import pydiscovergy.error as discovergyError from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client from .coordinator import DiscovergyConfigEntry, DiscovergyUpdateCoordinator @@ -21,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) - client = Discovergy( email=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], - httpx_client=get_async_client(hass), + httpx_client=create_async_httpx_client(hass), authentication=BasicAuth(), ) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 82541476a02..119d1d31d52 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.43.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 17fc3dc27e8..0289d5100d6 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.43.0"], + "requirements": ["async-upnp-client==0.44.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 9e98178e718..e7b60d5bd6f 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -68,7 +68,7 @@ async def async_validate_hostname( result = False with contextlib.suppress(DNSError): result = bool( - await aiodns.DNSResolver( + await aiodns.DNSResolver( # type: ignore[call-overload] nameservers=[resolver], udp_port=port, tcp_port=port ).query(hostname, qtype) ) diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index d25459b95b7..e004b386e02 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dnsip", "iot_class": "cloud_polling", - "requirements": ["aiodns==3.2.0"] + "requirements": ["aiodns==3.4.0"] } diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 6708baefe8c..6cdb67dd80d 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -106,7 +106,7 @@ class WanIpSensor(SensorEntity): async def async_update(self) -> None: """Get the current DNS IP address for hostname.""" try: - response = await self.resolver.query(self.hostname, self.querytype) + response = await self.resolver.query(self.hostname, self.querytype) # type: ignore[call-overload] except DNSError as err: _LOGGER.warning("Exception while resolving host: %s", err) response = None diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 2c672dd4abb..cb31c7d6314 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.1.0"] + "requirements": ["pydoods==1.0.2", "Pillow==11.2.1"] } diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index 090ba4f161f..285b544e465 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -3,10 +3,10 @@ "step": { "init": { "data": { - "events": "Comma separated list of events." + "events": "Comma-separated list of events." }, "data_description": { - "events": "Add an comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion" + "events": "Add a comma-separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion" } } } diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 0d23b822231..369accb83d8 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -57,7 +57,7 @@ class DormkabaConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_info = self._discovered_devices[address] return await self.async_step_associate() - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery in async_discovered_service_info(self.hass): if ( discovery.address in current_addresses diff --git a/homeassistant/components/dovado/notify.py b/homeassistant/components/dovado/notify.py index 556848bf89f..0b74f97d06f 100644 --- a/homeassistant/components/dovado/notify.py +++ b/homeassistant/components/dovado/notify.py @@ -8,7 +8,7 @@ from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DOVADO_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -19,7 +19,7 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> DovadoSMSNotificationService: """Get the Dovado Router SMS notification service.""" - return DovadoSMSNotificationService(hass.data[DOVADO_DOMAIN].client) + return DovadoSMSNotificationService(hass.data[DOMAIN].client) class DovadoSMSNotificationService(BaseNotificationService): diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index e35fdeb2dc0..0129b990435 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DOVADO_DOMAIN +from . import DOMAIN MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) @@ -90,7 +90,7 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Dovado sensor platform.""" - dovado = hass.data[DOVADO_DOMAIN] + dovado = hass.data[DOMAIN] sensors = config[CONF_SENSORS] entities = [ diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 1a45886879a..c4fc8d2f500 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -2,32 +2,13 @@ from __future__ import annotations -from http import HTTPStatus import os -import re -import threading - -import requests -import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.service import async_register_admin_service -from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path +from homeassistant.core import HomeAssistant -from .const import ( - _LOGGER, - ATTR_FILENAME, - ATTR_OVERWRITE, - ATTR_SUBDIR, - ATTR_URL, - CONF_DOWNLOAD_DIR, - DOMAIN, - DOWNLOAD_COMPLETED_EVENT, - DOWNLOAD_FAILED_EVENT, - SERVICE_DOWNLOAD_FILE, -) +from .const import _LOGGER, CONF_DOWNLOAD_DIR +from .services import register_services async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -44,127 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - def download_file(service: ServiceCall) -> None: - """Start thread to download file specified in the URL.""" - - def do_download() -> None: - """Download the file.""" - try: - url = service.data[ATTR_URL] - - subdir = service.data.get(ATTR_SUBDIR) - - filename = service.data.get(ATTR_FILENAME) - - overwrite = service.data.get(ATTR_OVERWRITE) - - if subdir: - # Check the path - raise_if_invalid_path(subdir) - - final_path = None - - req = requests.get(url, stream=True, timeout=10) - - if req.status_code != HTTPStatus.OK: - _LOGGER.warning( - "Downloading '%s' failed, status_code=%d", url, req.status_code - ) - hass.bus.fire( - f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", - {"url": url, "filename": filename}, - ) - - else: - if filename is None and "content-disposition" in req.headers: - match = re.findall( - r"filename=(\S+)", req.headers["content-disposition"] - ) - - if match: - filename = match[0].strip("'\" ") - - if not filename: - filename = os.path.basename(url).strip() - - if not filename: - filename = "ha_download" - - # Check the filename - raise_if_invalid_filename(filename) - - # Do we want to download to subdir, create if needed - if subdir: - subdir_path = os.path.join(download_path, subdir) - - # Ensure subdir exist - os.makedirs(subdir_path, exist_ok=True) - - final_path = os.path.join(subdir_path, filename) - - else: - final_path = os.path.join(download_path, filename) - - path, ext = os.path.splitext(final_path) - - # If file exist append a number. - # We test filename, filename_2.. - if not overwrite: - tries = 1 - final_path = path + ext - while os.path.isfile(final_path): - tries += 1 - - final_path = f"{path}_{tries}.{ext}" - - _LOGGER.debug("%s -> %s", url, final_path) - - with open(final_path, "wb") as fil: - for chunk in req.iter_content(1024): - fil.write(chunk) - - _LOGGER.debug("Downloading of %s done", url) - hass.bus.fire( - f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}", - {"url": url, "filename": filename}, - ) - - except requests.exceptions.ConnectionError: - _LOGGER.exception("ConnectionError occurred for %s", url) - hass.bus.fire( - f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", - {"url": url, "filename": filename}, - ) - - # Remove file if we started downloading but failed - if final_path and os.path.isfile(final_path): - os.remove(final_path) - except ValueError: - _LOGGER.exception("Invalid value") - hass.bus.fire( - f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", - {"url": url, "filename": filename}, - ) - - # Remove file if we started downloading but failed - if final_path and os.path.isfile(final_path): - os.remove(final_path) - - threading.Thread(target=do_download).start() - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_DOWNLOAD_FILE, - download_file, - schema=vol.Schema( - { - vol.Optional(ATTR_FILENAME): cv.string, - vol.Optional(ATTR_SUBDIR): cv.string, - vol.Required(ATTR_URL): cv.url, - vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean, - } - ), - ) + register_services(hass) return True diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py new file mode 100644 index 00000000000..a8bcba605d9 --- /dev/null +++ b/homeassistant/components/downloader/services.py @@ -0,0 +1,159 @@ +"""Support for functionality to download files.""" + +from __future__ import annotations + +from http import HTTPStatus +import os +import re +import threading + +import requests +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_register_admin_service +from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path + +from .const import ( + _LOGGER, + ATTR_FILENAME, + ATTR_OVERWRITE, + ATTR_SUBDIR, + ATTR_URL, + CONF_DOWNLOAD_DIR, + DOMAIN, + DOWNLOAD_COMPLETED_EVENT, + DOWNLOAD_FAILED_EVENT, + SERVICE_DOWNLOAD_FILE, +) + + +def download_file(service: ServiceCall) -> None: + """Start thread to download file specified in the URL.""" + + entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0] + download_path = entry.data[CONF_DOWNLOAD_DIR] + + def do_download() -> None: + """Download the file.""" + try: + url = service.data[ATTR_URL] + + subdir = service.data.get(ATTR_SUBDIR) + + filename = service.data.get(ATTR_FILENAME) + + overwrite = service.data.get(ATTR_OVERWRITE) + + if subdir: + # Check the path + raise_if_invalid_path(subdir) + + final_path = None + + req = requests.get(url, stream=True, timeout=10) + + if req.status_code != HTTPStatus.OK: + _LOGGER.warning( + "Downloading '%s' failed, status_code=%d", url, req.status_code + ) + service.hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", + {"url": url, "filename": filename}, + ) + + else: + if filename is None and "content-disposition" in req.headers: + match = re.findall( + r"filename=(\S+)", req.headers["content-disposition"] + ) + + if match: + filename = match[0].strip("'\" ") + + if not filename: + filename = os.path.basename(url).strip() + + if not filename: + filename = "ha_download" + + # Check the filename + raise_if_invalid_filename(filename) + + # Do we want to download to subdir, create if needed + if subdir: + subdir_path = os.path.join(download_path, subdir) + + # Ensure subdir exist + os.makedirs(subdir_path, exist_ok=True) + + final_path = os.path.join(subdir_path, filename) + + else: + final_path = os.path.join(download_path, filename) + + path, ext = os.path.splitext(final_path) + + # If file exist append a number. + # We test filename, filename_2.. + if not overwrite: + tries = 1 + final_path = path + ext + while os.path.isfile(final_path): + tries += 1 + + final_path = f"{path}_{tries}.{ext}" + + _LOGGER.debug("%s -> %s", url, final_path) + + with open(final_path, "wb") as fil: + for chunk in req.iter_content(1024): + fil.write(chunk) + + _LOGGER.debug("Downloading of %s done", url) + service.hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}", + {"url": url, "filename": filename}, + ) + + except requests.exceptions.ConnectionError: + _LOGGER.exception("ConnectionError occurred for %s", url) + service.hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", + {"url": url, "filename": filename}, + ) + + # Remove file if we started downloading but failed + if final_path and os.path.isfile(final_path): + os.remove(final_path) + except ValueError: + _LOGGER.exception("Invalid value") + service.hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", + {"url": url, "filename": filename}, + ) + + # Remove file if we started downloading but failed + if final_path and os.path.isfile(final_path): + os.remove(final_path) + + threading.Thread(target=do_download).start() + + +def register_services(hass: HomeAssistant) -> None: + """Register the services for the downloader component.""" + async_register_admin_service( + hass, + DOMAIN, + SERVICE_DOWNLOAD_FILE, + download_file, + schema=vol.Schema( + { + vol.Optional(ATTR_FILENAME): cv.string, + vol.Optional(ATTR_SUBDIR): cv.string, + vol.Required(ATTR_URL): cv.url, + vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean, + } + ), + ) diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json index 93df4dc3310..6093f2e8100 100644 --- a/homeassistant/components/drop_connect/strings.json +++ b/homeassistant/components/drop_connect/strings.json @@ -38,8 +38,8 @@ "protect_mode": { "name": "Protect mode", "state": { - "away": "Away", - "home": "Home", + "away": "[%key:common::state::not_home%]", + "home": "[%key:common::state::home%]", "schedule": "Schedule" } } diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 561f06d1bbe..f9e78ac616f 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.4.2"] + "requirements": ["dsmr-parser==1.4.3"] } diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 871dd382f2b..e95e9ae870a 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -51,8 +51,8 @@ "electricity_active_tariff": { "name": "Active tariff", "state": { - "low": "Low", - "normal": "Normal" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]" } }, "electricity_delivered_tariff_1": { diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index 90cf0533a72..d405898a393 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -140,8 +140,8 @@ "electricity_tariff": { "name": "Electricity tariff", "state": { - "low": "Low", - "high": "High" + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" } }, "power_failure_count": { diff --git a/homeassistant/components/duke_energy/config_flow.py b/homeassistant/components/duke_energy/config_flow.py index e06940b0fba..2ec92ff4c12 100644 --- a/homeassistant/components/duke_energy/config_flow.py +++ b/homeassistant/components/duke_energy/config_flow.py @@ -50,10 +50,10 @@ class DukeEnergyConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - username = auth["cdp_internal_user_id"].lower() + username = auth["internalUserID"].lower() await self.async_set_unique_id(username) self._abort_if_unique_id_configured() - email = auth["email"].lower() + email = auth["loginEmailAddress"].lower() data = { CONF_EMAIL: email, CONF_USERNAME: username, diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py index 12a2f5fd6ae..a70c94e6fee 100644 --- a/homeassistant/components/duke_energy/coordinator.py +++ b/homeassistant/components/duke_energy/coordinator.py @@ -8,7 +8,11 @@ from aiodukeenergy import DukeEnergy from aiohttp import ClientError from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -137,7 +141,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}" ) consumption_metadata = StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{name_prefix} Consumption", source=DOMAIN, @@ -175,22 +179,18 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): one = timedelta(days=1) if start_time is None: # Max 3 years of data - agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"]) - if agreement_date is None: - start = dt_util.now(tz) - timedelta(days=3 * 365) - else: - start = max( - agreement_date.replace(tzinfo=tz), - dt_util.now(tz) - timedelta(days=3 * 365), - ) + start = dt_util.now(tz) - timedelta(days=3 * 365) else: start = datetime.fromtimestamp(start_time, tz=tz) - lookback + agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"]) + if agreement_date is not None: + start = max(agreement_date.replace(tzinfo=tz), start) start = start.replace(hour=0, minute=0, second=0, microsecond=0) end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one _LOGGER.debug("Data lookup range: %s - %s", start, end) - start_step = end - lookback + start_step = max(end - lookback, start) end_step = end usage: dict[datetime, dict[str, float | int]] = {} while True: diff --git a/homeassistant/components/duke_energy/manifest.json b/homeassistant/components/duke_energy/manifest.json index ece18d7ad2a..ad64fdd5cc4 100644 --- a/homeassistant/components/duke_energy/manifest.json +++ b/homeassistant/components/duke_energy/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/duke_energy", "iot_class": "cloud_polling", - "requirements": ["aiodukeenergy==0.2.2"] + "requirements": ["aiodukeenergy==0.3.0"] } diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 0e491281619..162d1167e81 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable -from types import MappingProxyType +from collections.abc import Callable, Mapping from typing import Any from dynalite_devices_lib.dynalite_devices import ( @@ -50,7 +49,7 @@ class DynaliteBridge: LOGGER.debug("Setting up bridge - host %s", self.host) return await self.dynalite_devices.async_setup() - def reload_config(self, config: MappingProxyType[str, Any]) -> None: + def reload_config(self, config: Mapping[str, Any]) -> None: """Reconfigure a bridge when config changes.""" LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) self.dynalite_devices.configure(convert_config(config)) diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py index 00edc26f1ab..e37ce93ece4 100644 --- a/homeassistant/components/dynalite/convert_config.py +++ b/homeassistant/components/dynalite/convert_config.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import Any from dynalite_devices_lib import const as dyn_const @@ -138,9 +138,7 @@ def convert_template(config: dict[str, Any]) -> dict[str, Any]: return convert_with_map(config, my_map) -def convert_config( - config: dict[str, Any] | MappingProxyType[str, Any], -) -> dict[str, Any]: +def convert_config(config: Mapping[str, Any]) -> dict[str, Any]: """Convert a config dict by replacing component consts with library consts.""" my_map = { CONF_NAME: dyn_const.CONF_NAME, diff --git a/homeassistant/components/dynalite/strings.json b/homeassistant/components/dynalite/strings.json index 468cdebf0b1..4f73f91113b 100644 --- a/homeassistant/components/dynalite/strings.json +++ b/homeassistant/components/dynalite/strings.json @@ -36,7 +36,7 @@ }, "request_channel_level": { "name": "Request channel level", - "description": "Requests Dynalite to report the level of a specific channel.", + "description": "Requests Dynalite to report the brightness level of a specific channel.", "fields": { "host": { "name": "[%key:common::config_flow::data::host%]", @@ -48,7 +48,7 @@ }, "channel": { "name": "Channel", - "description": "Channel to request the level for." + "description": "Channel to request the brightness level for." } } } diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 2b44c45edef..bc61cb444c1 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -25,7 +25,7 @@ "state_attributes": { "preset_mode": { "state": { - "away_indefinitely": "Away Indefinitely" + "away_indefinitely": "Away indefinitely" } } } @@ -55,7 +55,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to create the vacation." + "description": "ecobee thermostat on which to create the vacation." }, "vacation_name": { "name": "Vacation name", @@ -91,7 +91,7 @@ }, "fan_min_on_time": { "name": "Fan minimum on time", - "description": "Minimum number of minutes to run the fan each hour (0 to 60) during the vacation." + "description": "Minimum number of minutes to run the fan each hour during the vacation." } } }, @@ -101,7 +101,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to delete the vacation." + "description": "ecobee thermostat on which to delete the vacation." }, "vacation_name": { "name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]", @@ -125,7 +125,7 @@ }, "set_fan_min_on_time": { "name": "Set fan minimum on time", - "description": "Sets the minimum fan on time.", + "description": "Sets the minimum amount of time that the fan will run per hour.", "fields": { "entity_id": { "name": "Entity", @@ -133,7 +133,7 @@ }, "fan_min_on_time": { "name": "[%key:component::ecobee::services::create_vacation::fields::fan_min_on_time::name%]", - "description": "New value of fan min on time." + "description": "Minimum number of minutes to run the fan each hour." } } }, @@ -149,7 +149,7 @@ }, "set_mic_mode": { "name": "Set mic mode", - "description": "Enables/disables Alexa microphone (only for Ecobee 4).", + "description": "Enables/disables Alexa microphone (only for ecobee 4).", "fields": { "mic_enabled": { "name": "Mic enabled", @@ -177,7 +177,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to set active sensors." + "description": "ecobee thermostat on which to set active sensors." }, "preset_mode": { "name": "Climate Name", @@ -203,12 +203,12 @@ }, "issues": { "migrate_aux_heat": { - "title": "Migration of Ecobee set_aux_heat action", + "title": "Migration of ecobee set_aux_heat action", "fix_flow": { "step": { "confirm": { - "description": "The Ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", - "title": "Disable legacy Ecobee set_aux_heat action" + "description": "The ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", + "title": "Disable legacy ecobee set_aux_heat action" } } } diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index c1d4aca6f0c..d0e4c17abe1 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -132,7 +132,7 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( ), EcoforestSensorEntityDescription( key="convecto_air_flow", - translation_key="convecto_air_flow", + translation_key="convector_air_flow", native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=False, value_fn=lambda data: data.convecto_air_flow, diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index 1094e10ada3..d0e807b5f2a 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -78,8 +78,8 @@ "extractor": { "name": "Extractor" }, - "convecto_air_flow": { - "name": "Convecto air flow" + "convector_air_flow": { + "name": "Convector air flow" } }, "number": { diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index e7ccec33310..69ca3a827ec 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -23,10 +23,8 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from . import EconetConfigEntry -from .const import DOMAIN from .entity import EcoNetEntity ECONET_STATE_TO_HA = { @@ -150,11 +148,6 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): if target_temp_low or target_temp_high: self._econet.set_set_point(None, target_temp_high, target_temp_low) - @property - def is_aux_heat(self) -> bool: - """Return true if aux heater.""" - return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT - @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool, mode. @@ -212,34 +205,6 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): """Set the fan mode.""" self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode]) - def turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2025.4.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - self._econet.set_mode(ThermostatOperationMode.EMERGENCY_HEAT) - - def turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2025.4.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - self._econet.set_mode(ThermostatOperationMode.HEATING) - @property def min_temp(self): """Return the minimum temperature.""" diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index fb74ae8b4a5..f93ad7f8872 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -91,15 +91,15 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity): def operation_list(self) -> list[str]: """List of available operation modes.""" econet_modes = self.water_heater.modes - op_list = [] + operation_modes = set() for mode in econet_modes: if ( mode is not WaterHeaterOperationMode.UNKNOWN and mode is not WaterHeaterOperationMode.VACATION ): ha_mode = ECONET_STATE_TO_HA[mode] - op_list.append(ha_mode) - return op_list + operation_modes.add(ha_mode) + return list(operation_modes) @property def supported_features(self) -> WaterHeaterEntityFeature: diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index 552a8152cc5..7c85a63cc78 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Generic from deebot_client.capabilities import CapabilityEvent -from deebot_client.events.water_info import WaterInfoEvent +from deebot_client.events.water_info import MopAttachedEvent from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT -from .util import get_supported_entitites +from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -32,9 +32,9 @@ class EcovacsBinarySensorEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = ( - EcovacsBinarySensorEntityDescription[WaterInfoEvent]( - capability_fn=lambda caps: caps.water, - value_fn=lambda e: e.mop_attached, + EcovacsBinarySensorEntityDescription[MopAttachedEvent]( + capability_fn=lambda caps: caps.water.mop_attached if caps.water else None, + value_fn=lambda e: e.value, key="water_mop_attached", translation_key="water_mop_attached", entity_category=EntityCategory.DIAGNOSTIC, @@ -49,7 +49,7 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" async_add_entities( - get_supported_entitites( + get_supported_entities( config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS ) ) diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py index 04eb0af02e6..ba1a0847408 100644 --- a/homeassistant/components/ecovacs/button.py +++ b/homeassistant/components/ecovacs/button.py @@ -16,13 +16,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS +from .const import SUPPORTED_LIFESPANS from .entity import ( EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, ) -from .util import get_supported_entitites +from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -62,7 +62,7 @@ STATION_ENTITY_DESCRIPTIONS = tuple( key=f"station_action_{action.name.lower()}", translation_key=f"station_action_{action.name.lower()}", ) - for action in SUPPORTED_STATION_ACTIONS + for action in StationAction ) @@ -85,7 +85,7 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data - entities: list[EcovacsEntity] = get_supported_entitites( + entities: list[EcovacsEntity] = get_supported_entities( controller, EcovacsButtonEntity, ENTITY_DESCRIPTIONS ) entities.extend( diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py index f8a89b0cfa0..b1c2f0075f1 100644 --- a/homeassistant/components/ecovacs/image.py +++ b/homeassistant/components/ecovacs/image.py @@ -1,8 +1,11 @@ """Ecovacs image entities.""" +from typing import cast + from deebot_client.capabilities import CapabilityMap from deebot_client.device import Device from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent +from deebot_client.map import Map from homeassistant.components.image import ImageEntity from homeassistant.core import HomeAssistant @@ -47,6 +50,7 @@ class EcovacsMap( """Initialize entity.""" super().__init__(device, capability, hass=hass) self._attr_extra_state_attributes = {} + self._map = cast(Map, self._device.map) entity_description = EntityDescription( key="map", @@ -55,7 +59,7 @@ class EcovacsMap( def image(self) -> bytes | None: """Return bytes of image or None.""" - if svg := self._device.map.get_svg_map(): + if svg := self._map.get_svg_map(): return svg.encode() return None @@ -80,4 +84,4 @@ class EcovacsMap( Only used by the generic entity update service. """ await super().async_update() - self._device.map.refresh() + self._map.refresh() diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index b31fa7f347d..b1674e123fa 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==12.2.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==13.2.0"] } diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py index 7a74b02ceca..1fbf65aec65 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -25,7 +25,7 @@ from .entity import ( EcovacsEntity, EventT, ) -from .util import get_supported_entitites +from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -87,7 +87,7 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data - entities: list[EcovacsEntity] = get_supported_entitites( + entities: list[EcovacsEntity] = get_supported_entities( controller, EcovacsNumberEntity, ENTITY_DESCRIPTIONS ) if entities: diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index a7b9baf1c4a..deddb7e252a 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -6,7 +6,8 @@ from typing import Any, Generic from deebot_client.capabilities import CapabilitySetTypes from deebot_client.device import Device -from deebot_client.events import WaterInfoEvent, WorkModeEvent +from deebot_client.events import WorkModeEvent +from deebot_client.events.water_info import WaterAmountEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -15,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT -from .util import get_name_key, get_supported_entitites +from .util import get_name_key, get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -31,9 +32,9 @@ class EcovacsSelectEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( - EcovacsSelectEntityDescription[WaterInfoEvent]( - capability_fn=lambda caps: caps.water, - current_option_fn=lambda e: get_name_key(e.amount), + EcovacsSelectEntityDescription[WaterAmountEvent]( + capability_fn=lambda caps: caps.water.amount if caps.water else None, + current_option_fn=lambda e: get_name_key(e.value), options_fn=lambda water: [get_name_key(amount) for amount in water.types], key="water_amount", translation_key="water_amount", @@ -58,7 +59,7 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data - entities = get_supported_entitites( + entities = get_supported_entities( controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS ) if entities: diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 1c0452b4ac4..67556606f3a 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -6,7 +6,8 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any, Generic -from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan +from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType +from deebot_client.device import Device from deebot_client.events import ( BatteryEvent, ErrorEvent, @@ -34,7 +35,7 @@ from homeassistant.const import ( UnitOfArea, UnitOfTime, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -47,7 +48,7 @@ from .entity import ( EcovacsLegacyEntity, EventT, ) -from .util import get_name_key, get_options, get_supported_entitites +from .util import get_name_key, get_options, get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -59,6 +60,15 @@ class EcovacsSensorEntityDescription( """Ecovacs sensor entity description.""" value_fn: Callable[[EventT], StateType] + native_unit_of_measurement_fn: Callable[[DeviceType], str | None] | None = None + + +@callback +def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None: + """Get the area native unit of measurement based on device type.""" + if device_type is DeviceType.MOWER: + return UnitOfArea.SQUARE_CENTIMETERS + return UnitOfArea.SQUARE_METERS ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( @@ -69,7 +79,8 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( value_fn=lambda e: e.area, translation_key="stats_area", device_class=SensorDeviceClass.AREA, - native_unit_of_measurement=UnitOfArea.SQUARE_METERS, + native_unit_of_measurement_fn=get_area_native_unit_of_measurement, + suggested_unit_of_measurement=UnitOfArea.SQUARE_METERS, ), EcovacsSensorEntityDescription[StatsEvent]( key="stats_time", @@ -87,8 +98,9 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( key="total_stats_area", translation_key="total_stats_area", device_class=SensorDeviceClass.AREA, - native_unit_of_measurement=UnitOfArea.SQUARE_METERS, + native_unit_of_measurement_fn=get_area_native_unit_of_measurement, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_unit_of_measurement=UnitOfArea.SQUARE_METERS, ), EcovacsSensorEntityDescription[TotalStatsEvent]( capability_fn=lambda caps: caps.stats.total, @@ -199,7 +211,7 @@ async def async_setup_entry( """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data - entities: list[EcovacsEntity] = get_supported_entitites( + entities: list[EcovacsEntity] = get_supported_entities( controller, EcovacsSensor, ENTITY_DESCRIPTIONS ) entities.extend( @@ -251,6 +263,27 @@ class EcovacsSensor( entity_description: EcovacsSensorEntityDescription + def __init__( + self, + device: Device, + capability: CapabilityEvent, + entity_description: EcovacsSensorEntityDescription, + **kwargs: Any, + ) -> None: + """Initialize entity.""" + super().__init__(device, capability, entity_description, **kwargs) + if ( + entity_description.native_unit_of_measurement_fn + and ( + native_unit_of_measurement + := entity_description.native_unit_of_measurement_fn( + device.capabilities.device_type + ) + ) + is not None + ): + self._attr_native_unit_of_measurement = native_unit_of_measurement + async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index e3147ab7d52..1913bb5630d 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -14,7 +14,7 @@ "step": { "auth": { "data": { - "country": "Country", + "country": "[%key:common::config_flow::data::country%]", "override_rest_url": "REST URL", "override_mqtt_url": "MQTT URL", "password": "[%key:common::config_flow::data::password%]", @@ -180,9 +180,9 @@ "water_amount": { "name": "Water flow level", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "ultrahigh": "Ultrahigh" } }, @@ -233,9 +233,9 @@ "state_attributes": { "fan_speed": { "state": { + "normal": "[%key:common::state::normal%]", "max": "Max", "max_plus": "Max+", - "normal": "Normal", "quiet": "Quiet" } }, diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py index dd379dbb199..d151b55ca1c 100644 --- a/homeassistant/components/ecovacs/switch.py +++ b/homeassistant/components/ecovacs/switch.py @@ -17,7 +17,7 @@ from .entity import ( EcovacsDescriptionEntity, EcovacsEntity, ) -from .util import get_supported_entitites +from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) @@ -109,7 +109,7 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data - entities: list[EcovacsEntity] = get_supported_entitites( + entities: list[EcovacsEntity] = get_supported_entities( controller, EcovacsSwitchEntity, ENTITY_DESCRIPTIONS ) if entities: diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py index 0cfbf1e8f91..968ab92851b 100644 --- a/homeassistant/components/ecovacs/util.py +++ b/homeassistant/components/ecovacs/util.py @@ -32,7 +32,7 @@ def get_client_device_id(hass: HomeAssistant, self_hosted: bool) -> str: ) -def get_supported_entitites( +def get_supported_entities( controller: EcovacsController, entity_class: type[EcovacsDescriptionEntity], descriptions: tuple[EcovacsCapabilityEntityDescription, ...], diff --git a/homeassistant/components/ecowitt/binary_sensor.py b/homeassistant/components/ecowitt/binary_sensor.py index a2ed279f601..1d36f5232db 100644 --- a/homeassistant/components/ecowitt/binary_sensor.py +++ b/homeassistant/components/ecowitt/binary_sensor.py @@ -26,6 +26,9 @@ ECOWITT_BINARYSENSORS_MAPPING: Final = { device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), + EcoWittSensorTypes.RAIN_STATE: BinarySensorEntityDescription( + key="RAIN_STATE", device_class=BinarySensorDeviceClass.MOISTURE + ), } diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index 175960ab57d..3ce66f48f95 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2024.2.1"] + "requirements": ["aioecowitt==2025.3.1"] } diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 6968acdfa4f..7d37aa40b86 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -68,6 +68,7 @@ ECOWITT_SENSORS_MAPPING: Final = { key="DEGREE", native_unit_of_measurement=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), EcoWittSensorTypes.WATT_METERS_SQUARED: SensorEntityDescription( key="WATT_METERS_SQUARED", diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index 26e6bea4d4a..bc8bbded186 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -9,7 +9,15 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] +PLATFORMS = [ + Platform.CLIMATE, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, +] async def async_setup_entry( diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py index c6535608b0c..b0432267c8e 100644 --- a/homeassistant/components/eheimdigital/config_flow.py +++ b/homeassistant/components/eheimdigital/config_flow.py @@ -62,6 +62,7 @@ class EheimDigitalConfigFlow(ConfigFlow, domain=DOMAIN): except (ClientError, TimeoutError): return self.async_abort(reason="cannot_connect") except Exception: # noqa: BLE001 + LOGGER.exception("Unknown exception occurred") 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}) diff --git a/homeassistant/components/eheimdigital/icons.json b/homeassistant/components/eheimdigital/icons.json new file mode 100644 index 00000000000..cbe2613dd97 --- /dev/null +++ b/homeassistant/components/eheimdigital/icons.json @@ -0,0 +1,57 @@ +{ + "entity": { + "number": { + "manual_speed": { + "default": "mdi:pump" + }, + "day_speed": { + "default": "mdi:weather-sunny" + }, + "night_speed": { + "default": "mdi:moon-waning-crescent" + }, + "temperature_offset": { + "default": "mdi:thermometer" + }, + "night_temperature_offset": { + "default": "mdi:thermometer" + }, + "system_led": { + "default": "mdi:led-on", + "state": { + "0": "mdi:led-off" + } + } + }, + "sensor": { + "current_speed": { + "default": "mdi:pump" + }, + "service_hours": { + "default": "mdi:wrench-clock" + }, + "error_code": { + "default": "mdi:alert-octagon", + "state": { + "no_error": "mdi:check-circle" + } + } + }, + "switch": { + "filter_active": { + "default": "mdi:pump", + "state": { + "off": "mdi:pump-off" + } + } + }, + "time": { + "day_start_time": { + "default": "mdi:weather-sunny" + }, + "night_start_time": { + "default": "mdi:moon-waning-crescent" + } + } + } +} diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 1d1ca6f84c7..c3c8a251300 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "bronze", - "requirements": ["eheimdigital==1.0.6"], + "requirements": ["eheimdigital==1.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } ] diff --git a/homeassistant/components/eheimdigital/number.py b/homeassistant/components/eheimdigital/number.py new file mode 100644 index 00000000000..7fd0c6b6de7 --- /dev/null +++ b/homeassistant/components/eheimdigital/number.py @@ -0,0 +1,195 @@ +"""EHEIM Digital numbers.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Generic, TypeVar, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.heater import EheimDigitalHeater +from eheimdigital.types import HeaterUnit + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import ( + PERCENTAGE, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + EntityCategory, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalNumberDescription(NumberEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital sensor entities.""" + + value_fn: Callable[[_DeviceT_co], float | None] + set_value_fn: Callable[[_DeviceT_co, float], Awaitable[None]] + uom_fn: Callable[[_DeviceT_co], str] | None = None + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalNumberDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalNumberDescription[EheimDigitalClassicVario]( + key="manual_speed", + translation_key="manual_speed", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.manual_speed, + set_value_fn=lambda device, value: device.set_manual_speed(int(value)), + ), + EheimDigitalNumberDescription[EheimDigitalClassicVario]( + key="day_speed", + translation_key="day_speed", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.day_speed, + set_value_fn=lambda device, value: device.set_day_speed(int(value)), + ), + EheimDigitalNumberDescription[EheimDigitalClassicVario]( + key="night_speed", + translation_key="night_speed", + entity_category=EntityCategory.CONFIG, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.night_speed, + set_value_fn=lambda device, value: device.set_night_speed(int(value)), + ), +) + +HEATER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalHeater], ...] = ( + EheimDigitalNumberDescription[EheimDigitalHeater]( + key="temperature_offset", + translation_key="temperature_offset", + entity_category=EntityCategory.CONFIG, + native_min_value=-3, + native_max_value=3, + native_step=PRECISION_TENTHS, + device_class=NumberDeviceClass.TEMPERATURE, + uom_fn=lambda device: ( + UnitOfTemperature.CELSIUS + if device.temperature_unit is HeaterUnit.CELSIUS + else UnitOfTemperature.FAHRENHEIT + ), + value_fn=lambda device: device.temperature_offset, + set_value_fn=lambda device, value: device.set_temperature_offset(value), + ), + EheimDigitalNumberDescription[EheimDigitalHeater]( + key="night_temperature_offset", + translation_key="night_temperature_offset", + entity_category=EntityCategory.CONFIG, + native_min_value=-5, + native_max_value=5, + native_step=PRECISION_HALVES, + device_class=NumberDeviceClass.TEMPERATURE, + uom_fn=lambda device: ( + UnitOfTemperature.CELSIUS + if device.temperature_unit is HeaterUnit.CELSIUS + else UnitOfTemperature.FAHRENHEIT + ), + value_fn=lambda device: device.night_temperature_offset, + set_value_fn=lambda device, value: device.set_night_temperature_offset(value), + ), +) + +GENERAL_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalDevice], ...] = ( + EheimDigitalNumberDescription[EheimDigitalDevice]( + key="system_led", + translation_key="system_led", + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=100, + native_step=PRECISION_WHOLE, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.sys_led, + set_value_fn=lambda device, value: device.set_sys_led(int(value)), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so numbers can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the number entities for one or multiple devices.""" + entities: list[EheimDigitalNumber[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.extend( + EheimDigitalNumber[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ) + if isinstance(device, EheimDigitalHeater): + entities.extend( + EheimDigitalNumber[EheimDigitalHeater]( + coordinator, device, description + ) + for description in HEATER_DESCRIPTIONS + ) + entities.extend( + EheimDigitalNumber[EheimDigitalDevice](coordinator, device, description) + for description in GENERAL_DESCRIPTIONS + ) + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalNumber( + EheimDigitalEntity[_DeviceT_co], NumberEntity, Generic[_DeviceT_co] +): + """Represent a EHEIM Digital number entity.""" + + entity_description: EheimDigitalNumberDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalNumberDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital number entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @override + async def async_set_native_value(self, value: float) -> None: + return await self.entity_description.set_value_fn(self._device, value) + + @override + def _async_update_attrs(self) -> None: + self._attr_native_value = self.entity_description.value_fn(self._device) + self._attr_native_unit_of_measurement = ( + self.entity_description.uom_fn(self._device) + if self.entity_description.uom_fn + else self.entity_description.native_unit_of_measurement + ) diff --git a/homeassistant/components/eheimdigital/select.py b/homeassistant/components/eheimdigital/select.py new file mode 100644 index 00000000000..9311eb01ecc --- /dev/null +++ b/homeassistant/components/eheimdigital/select.py @@ -0,0 +1,102 @@ +"""EHEIM Digital select entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Generic, TypeVar, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.types import FilterMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalSelectDescription(SelectEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital select entities.""" + + value_fn: Callable[[_DeviceT_co], str | None] + set_value_fn: Callable[[_DeviceT_co, str], Awaitable[None]] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalSelectDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalSelectDescription[EheimDigitalClassicVario]( + key="filter_mode", + translation_key="filter_mode", + value_fn=( + lambda device: device.filter_mode.name.lower() + if device.filter_mode is not None + else None + ), + set_value_fn=( + lambda device, value: device.set_filter_mode(FilterMode[value.upper()]) + ), + options=[name.lower() for name in FilterMode.__members__], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so select entities can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the number entities for one or multiple devices.""" + entities: list[EheimDigitalSelect[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.extend( + EheimDigitalSelect[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ) + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalSelect( + EheimDigitalEntity[_DeviceT_co], SelectEntity, Generic[_DeviceT_co] +): + """Represent an EHEIM Digital select entity.""" + + entity_description: EheimDigitalSelectDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalSelectDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital select entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @override + async def async_select_option(self, option: str) -> None: + return await self.entity_description.set_value_fn(self._device, option) + + @override + def _async_update_attrs(self) -> None: + self._attr_current_option = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/eheimdigital/sensor.py b/homeassistant/components/eheimdigital/sensor.py new file mode 100644 index 00000000000..3d809cc14dc --- /dev/null +++ b/homeassistant/components/eheimdigital/sensor.py @@ -0,0 +1,114 @@ +"""EHEIM Digital sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, TypeVar, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.types import FilterErrorCode + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor.const import SensorDeviceClass +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital sensor entities.""" + + value_fn: Callable[[_DeviceT_co], float | str | None] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalSensorDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="current_speed", + translation_key="current_speed", + value_fn=lambda device: device.current_speed, + native_unit_of_measurement=PERCENTAGE, + ), + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="service_hours", + translation_key="service_hours", + value_fn=lambda device: device.service_hours, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + suggested_unit_of_measurement=UnitOfTime.DAYS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="error_code", + translation_key="error_code", + value_fn=( + lambda device: device.error_code.name.lower() + if device.error_code is not None + else None + ), + device_class=SensorDeviceClass.ENUM, + options=[name.lower() for name in FilterErrorCode._member_names_], + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so lights can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the light entities for one or multiple devices.""" + entities: list[EheimDigitalSensor[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities += [ + EheimDigitalSensor[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ] + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalSensor( + EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co] +): + """Represent a EHEIM Digital sensor entity.""" + + entity_description: EheimDigitalSensorDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalSensorDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital number entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @override + def _async_update_attrs(self) -> None: + self._attr_native_value = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index ef6f6b10d0a..89f802c9d6d 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -46,6 +46,60 @@ } } } + }, + "number": { + "manual_speed": { + "name": "Manual speed" + }, + "day_speed": { + "name": "Day speed" + }, + "night_speed": { + "name": "Night speed" + }, + "temperature_offset": { + "name": "Temperature offset" + }, + "night_temperature_offset": { + "name": "Night temperature offset" + }, + "system_led": { + "name": "System LED brightness" + } + }, + "select": { + "filter_mode": { + "name": "Filter mode", + "state": { + "manual": "Manual", + "pulse": "Pulse", + "bio": "Bio" + } + } + }, + "sensor": { + "current_speed": { + "name": "Current speed" + }, + "service_hours": { + "name": "Remaining hours until service" + }, + "error_code": { + "name": "Error code", + "state": { + "no_error": "No error", + "rotor_stuck": "Rotor stuck", + "air_in_filter": "Air in filter" + } + } + }, + "time": { + "day_start_time": { + "name": "Day start time" + }, + "night_start_time": { + "name": "Night start time" + } } } } diff --git a/homeassistant/components/eheimdigital/switch.py b/homeassistant/components/eheimdigital/switch.py new file mode 100644 index 00000000000..de23feff322 --- /dev/null +++ b/homeassistant/components/eheimdigital/switch.py @@ -0,0 +1,70 @@ +"""EHEIM Digital switches.""" + +from typing import Any, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so switches can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the switch entities for one or multiple devices.""" + entities: list[SwitchEntity] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.append(EheimDigitalClassicVarioSwitch(coordinator, device)) # noqa: PERF401 + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalClassicVarioSwitch( + EheimDigitalEntity[EheimDigitalClassicVario], SwitchEntity +): + """Represent an EHEIM Digital classicVARIO switch entity.""" + + _attr_translation_key = "filter_active" + _attr_name = None + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: EheimDigitalClassicVario, + ) -> None: + """Initialize an EHEIM Digital classicVARIO switch entity.""" + super().__init__(coordinator, device) + self._attr_unique_id = device.mac_address + self._async_update_attrs() + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + await self._device.set_active(active=False) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + await self._device.set_active(active=True) + + @override + def _async_update_attrs(self) -> None: + self._attr_is_on = self._device.is_active diff --git a/homeassistant/components/eheimdigital/time.py b/homeassistant/components/eheimdigital/time.py new file mode 100644 index 00000000000..ae64fad0c92 --- /dev/null +++ b/homeassistant/components/eheimdigital/time.py @@ -0,0 +1,132 @@ +"""EHEIM Digital time entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import time +from typing import Generic, TypeVar, final, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.heater import EheimDigitalHeater + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalTimeDescription(TimeEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital time entities.""" + + value_fn: Callable[[_DeviceT_co], time | None] + set_value_fn: Callable[[_DeviceT_co, time], Awaitable[None]] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalTimeDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalTimeDescription[EheimDigitalClassicVario]( + key="day_start_time", + translation_key="day_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.day_start_time, + set_value_fn=lambda device, value: device.set_day_start_time(value), + ), + EheimDigitalTimeDescription[EheimDigitalClassicVario]( + key="night_start_time", + translation_key="night_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.night_start_time, + set_value_fn=lambda device, value: device.set_night_start_time(value), + ), +) + +HEATER_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalHeater], ...] = ( + EheimDigitalTimeDescription[EheimDigitalHeater]( + key="day_start_time", + translation_key="day_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.day_start_time, + set_value_fn=lambda device, value: device.set_day_start_time(value), + ), + EheimDigitalTimeDescription[EheimDigitalHeater]( + key="night_start_time", + translation_key="night_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda device: device.night_start_time, + set_value_fn=lambda device, value: device.set_night_start_time(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so times can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the time entities for one or multiple devices.""" + entities: list[EheimDigitalTime[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities.extend( + EheimDigitalTime[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ) + if isinstance(device, EheimDigitalHeater): + entities.extend( + EheimDigitalTime[EheimDigitalHeater]( + coordinator, device, description + ) + for description in HEATER_DESCRIPTIONS + ) + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +@final +class EheimDigitalTime( + EheimDigitalEntity[_DeviceT_co], TimeEntity, Generic[_DeviceT_co] +): + """Represent an EHEIM Digital time entity.""" + + entity_description: EheimDigitalTimeDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalTimeDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital time entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{device.mac_address}_{description.key}" + + @override + async def async_set_value(self, value: time) -> None: + """Change the time.""" + return await self.entity_description.set_value_fn(self._device, value) + + @override + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_native_value = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/electrasmart/strings.json b/homeassistant/components/electrasmart/strings.json index 06c7dfd6bed..485bf766534 100644 --- a/homeassistant/components/electrasmart/strings.json +++ b/homeassistant/components/electrasmart/strings.json @@ -3,12 +3,12 @@ "step": { "user": { "data": { - "phone_number": "Phone Number" + "phone_number": "Phone number" } }, "one_time_password": { "data": { - "one_time_password": "One Time Password" + "one_time_password": "One-time password" } } }, diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json index b346f94a963..8b0205a9e9a 100644 --- a/homeassistant/components/elevenlabs/strings.json +++ b/homeassistant/components/elevenlabs/strings.json @@ -6,7 +6,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "Your Elevenlabs API key." + "api_key": "Your ElevenLabs API key." } } }, diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index efcadb3f440..61850837075 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any from elevenlabs import AsyncElevenLabs @@ -43,7 +43,7 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings: +def to_voice_settings(options: Mapping[str, Any]) -> VoiceSettings: """Return voice settings.""" return VoiceSettings( stability=options.get(CONF_STABILITY, DEFAULT_STABILITY), diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 5286b7ad66f..0fe2df09bc5 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import logging import re -from types import MappingProxyType from typing import Any from elkm1_lib.elements import Element @@ -101,9 +100,11 @@ def hostname_from_url(url: str) -> str: def _host_validator(config: dict[str, str]) -> dict[str, str]: """Validate that a host is properly configured.""" - if config[CONF_HOST].startswith("elks://"): + if config[CONF_HOST].startswith(("elks://", "elksv1_2://")): if CONF_USERNAME not in config or CONF_PASSWORD not in config: - raise vol.Invalid("Specify username and password for elks://") + raise vol.Invalid( + "Specify username and password for elks:// or elksv1_2://" + ) elif not config[CONF_HOST].startswith("elk://") and not config[ CONF_HOST ].startswith("serial://"): @@ -233,7 +234,7 @@ def _async_find_matching_config_entry( async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> bool: """Set up Elk-M1 Control from a config entry.""" - conf: MappingProxyType[str, Any] = entry.data + conf = entry.data host = hostname_from_url(entry.data[CONF_HOST]) diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 8113a4d99a6..393845f65ff 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -105,6 +105,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION ) _element: Area @@ -204,7 +205,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): ArmedStatus.ARMED_STAY_INSTANT: AlarmControlPanelState.ARMED_HOME, ArmedStatus.ARMED_TO_NIGHT: AlarmControlPanelState.ARMED_NIGHT, ArmedStatus.ARMED_TO_NIGHT_INSTANT: AlarmControlPanelState.ARMED_NIGHT, - ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_AWAY, + ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_VACATION, } if self._element.alarm_state is None: diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 55af0cfa29c..59d3aa9605a 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -20,10 +20,8 @@ from homeassistant.components.climate import ( from homeassistant.const import PRECISION_WHOLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from . import ElkM1ConfigEntry -from .const import DOMAIN from .entity import ElkEntity, create_elk_entities SUPPORT_HVAC = [ @@ -78,7 +76,6 @@ class ElkThermostat(ElkEntity, ClimateEntity): _attr_precision = PRECISION_WHOLE _attr_supported_features = ( ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON @@ -128,11 +125,6 @@ class ElkThermostat(ElkEntity, ClimateEntity): """Return the current humidity.""" return self._element.humidity - @property - def is_aux_heat(self) -> bool: - """Return if aux heater is on.""" - return self._element.mode == ThermostatMode.EMERGENCY_HEAT - @property def fan_mode(self) -> str | None: """Return the fan setting.""" @@ -151,34 +143,6 @@ class ElkThermostat(ElkEntity, ClimateEntity): thermostat_mode, fan_mode = HASS_TO_ELK_HVAC_MODES[hvac_mode] self._elk_set(thermostat_mode, fan_mode) - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - async_create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2025.4.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - self._elk_set(ThermostatMode.EMERGENCY_HEAT, None) - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - async_create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2025.4.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - self._elk_set(ThermostatMode.HEAT, None) - async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" thermostat_mode, elk_fan_mode = HASS_TO_ELK_FAN_MODES[fan_mode] diff --git a/homeassistant/components/elkm1/entity.py b/homeassistant/components/elkm1/entity.py index d9967d93967..ce717578eae 100644 --- a/homeassistant/components/elkm1/entity.py +++ b/homeassistant/components/elkm1/entity.py @@ -100,7 +100,11 @@ class ElkEntity(Entity): return {"index": self._element.index + 1} def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: - pass + """Handle changes to the element. + + This method is called when the element changes. It should be + overridden by subclasses to handle the changes. + """ @callback def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None: @@ -111,7 +115,7 @@ class ElkEntity(Entity): async def async_added_to_hass(self) -> None: """Register callback for ElkM1 changes and update entity state.""" self._element.add_callback(self._element_callback) - self._element_callback(self._element, {}) + self._element_changed(self._element, {}) @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index f184483646d..19967612b0f 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -53,7 +53,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to authorize the bypass of the alarm control panel." + "description": "Alarm code to authorize the bypass of the alarm control panel." } } }, @@ -63,7 +63,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to authorize the bypass clear of the alarm control panel." + "description": "Alarm code to authorize the bypass clear of the alarm control panel." } } }, @@ -73,7 +73,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to arm the alarm control panel." + "description": "Alarm code to arm the alarm control panel." } } }, @@ -181,7 +181,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to authorize the bypass of the zone." + "description": "Alarm code to authorize the bypass of the zone." } } }, @@ -189,18 +189,5 @@ "name": "Sensor zone trigger", "description": "Triggers zone." } - }, - "issues": { - "migrate_aux_heat": { - "title": "Migration of Elk-M1 set_aux_heat action", - "fix_flow": { - "step": { - "confirm": { - "description": "The Elk-M1 `set_aux_heat` action has been migrated. A new emergency heat switch entity is available for each thermostat.\n\nUpdate any automations to use the new emergency heat switch entity. When this is done, select **Submit** to fix this issue.", - "title": "[%key:component::elkm1::issues::migrate_aux_heat::title%]" - } - } - } - } } } diff --git a/homeassistant/components/elmax/strings.json b/homeassistant/components/elmax/strings.json index 2ba74f5fc8f..5bc7eb292a2 100644 --- a/homeassistant/components/elmax/strings.json +++ b/homeassistant/components/elmax/strings.json @@ -4,12 +4,12 @@ "choose_mode": { "description": "Please choose the connection mode to Elmax panels.", "menu_options": { - "cloud": "Connect to Elmax Panel via Elmax Cloud APIs", - "direct": "Connect to Elmax Panel via local/direct IP" + "cloud": "Connect to Elmax panel via Elmax Cloud APIs", + "direct": "Connect to Elmax panel via local/direct IP" } }, "cloud": { - "description": "Please login to the Elmax cloud using your credentials", + "description": "Please log in to the Elmax cloud using your credentials", "data": { "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" @@ -28,7 +28,7 @@ "direct": { "description": "Specify the Elmax panel connection parameters below.", "data": { - "panel_api_host": "Panel API Hostname or IP", + "panel_api_host": "Panel API hostname or IP", "panel_api_port": "Panel API port", "use_ssl": "Use SSL", "panel_pin": "Panel PIN code" @@ -40,7 +40,7 @@ "panels": { "description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.", "data": { - "panel_name": "Panel Name", + "panel_name": "Panel name", "panel_id": "Panel ID", "panel_pin": "[%key:common::config_flow::data::pin%]" } diff --git a/homeassistant/components/elvia/importer.py b/homeassistant/components/elvia/importer.py index 4e8b7f716ef..caca787237c 100644 --- a/homeassistant/components/elvia/importer.py +++ b/homeassistant/components/elvia/importer.py @@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, cast from elvia import Elvia, error as ElviaError -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -144,7 +148,7 @@ class ElviaImporter: async_add_external_statistics( hass=self.hass, metadata=StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{self.metering_point_id} Consumption", source=DOMAIN, diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index e0d4d0d03e9..8b3067b2cf4 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -17,7 +17,6 @@ from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import selector -from homeassistant.helpers.typing import ConfigType from .const import ( CONF_MESSAGE, @@ -27,7 +26,6 @@ from .const import ( FEED_ID, FEED_NAME, FEED_TAG, - LOGGER, ) @@ -153,24 +151,6 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_info: ConfigType) -> ConfigFlowResult: - """Import config from yaml.""" - url = import_info[CONF_URL] - api_key = import_info[CONF_API_KEY] - include_only_feeds = None - if import_info.get(CONF_ONLY_INCLUDE_FEEDID) is not None: - include_only_feeds = list(map(str, import_info[CONF_ONLY_INCLUDE_FEEDID])) - config = { - CONF_API_KEY: api_key, - CONF_ONLY_INCLUDE_FEEDID: include_only_feeds, - CONF_URL: url, - } - LOGGER.debug(config) - result = await self.async_step_user(config) - if errors := result.get("errors"): - return self.async_abort(reason=errors["base"]) - return result - class EmoncmsOptionsFlow(OptionsFlow): """Emoncms Options flow handler.""" diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 6321ccfafcd..c5a25104549 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -4,24 +4,16 @@ from __future__ import annotations from typing import Any -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 ( 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, @@ -36,22 +28,15 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumeFlowRate, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import template +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .config_flow import sensor_name from .const import ( CONF_EXCLUDE_FEEDID, CONF_ONLY_INCLUDE_FEEDID, - DOMAIN, FEED_ID, FEED_NAME, FEED_TAG, @@ -205,88 +190,7 @@ ATTR_LASTUPDATETIMESTR = "LastUpdatedStr" ATTR_SIZE = "Size" ATTR_TAG = "Tag" ATTR_USERID = "UserId" -CONF_SENSOR_NAMES = "sensor_names" DECIMALS = 2 -DEFAULT_UNIT = UnitOfPower.WATT - -ONLY_INCL_EXCL_NONE = "only_include_exclude_or_none" - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_URL): cv.string, - vol.Required(CONF_ID): cv.positive_int, - vol.Exclusive(CONF_ONLY_INCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Exclusive(CONF_EXCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_SENSOR_NAMES): vol.All( - {cv.positive_int: vol.All(cv.string, vol.Length(min=1))} - ), - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=DEFAULT_UNIT): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import config from yaml.""" - if CONF_VALUE_TEMPLATE in config: - async_create_issue( - hass, - DOMAIN, - f"remove_{CONF_VALUE_TEMPLATE}_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.ERROR, - translation_key=f"remove_{CONF_VALUE_TEMPLATE}", - translation_placeholders={ - "domain": DOMAIN, - "parameter": CONF_VALUE_TEMPLATE, - }, - ) - return - if CONF_ONLY_INCLUDE_FEEDID not in config: - async_create_issue( - hass, - DOMAIN, - f"missing_{CONF_ONLY_INCLUDE_FEEDID}_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"missing_{CONF_ONLY_INCLUDE_FEEDID}", - translation_placeholders={ - "domain": DOMAIN, - }, - ) - 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.3.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "emoncms", - }, - ) async def async_setup_entry( diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 384dd3556a9..3e9d6c81881 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.5"] + "requirements": ["sense-energy==0.13.8"] } diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py index d4466f47ef2..e8c3a00f098 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -46,6 +46,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type EmulatedRokuConfigEntry = ConfigEntry[EmulatedRoku] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the emulated roku component.""" @@ -65,22 +67,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: EmulatedRokuConfigEntry +) -> bool: """Set up an emulated roku server from a config entry.""" - config = config_entry.data - - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - - name = config[CONF_NAME] - listen_port = config[CONF_LISTEN_PORT] - host_ip = config.get(CONF_HOST_IP) or await async_get_source_ip(hass) - advertise_ip = config.get(CONF_ADVERTISE_IP) - advertise_port = config.get(CONF_ADVERTISE_PORT) - upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST) + config = entry.data + name: str = config[CONF_NAME] + listen_port: int = config[CONF_LISTEN_PORT] + host_ip: str = config.get(CONF_HOST_IP) or await async_get_source_ip(hass) + advertise_ip: str | None = config.get(CONF_ADVERTISE_IP) + advertise_port: int | None = config.get(CONF_ADVERTISE_PORT) + upnp_bind_multicast: bool | None = config.get(CONF_UPNP_BIND_MULTICAST) server = EmulatedRoku( hass, + entry.entry_id, name, host_ip, listen_port, @@ -88,14 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b advertise_port, upnp_bind_multicast, ) - - hass.data[DOMAIN][name] = server - + entry.runtime_data = server return await server.setup() -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: EmulatedRokuConfigEntry +) -> bool: """Unload a config entry.""" - name = entry.data[CONF_NAME] - server = hass.data[DOMAIN].pop(name) - return await server.unload() + return await entry.runtime_data.unload() diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py index a84db4bd77b..6d8d9c4014f 100644 --- a/homeassistant/components/emulated_roku/binding.py +++ b/homeassistant/components/emulated_roku/binding.py @@ -5,7 +5,13 @@ import logging from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CoreState, EventOrigin +from homeassistant.core import ( + CALLBACK_TYPE, + CoreState, + Event, + EventOrigin, + HomeAssistant, +) LOGGER = logging.getLogger(__package__) @@ -27,16 +33,18 @@ class EmulatedRoku: def __init__( self, - hass, - name, - host_ip, - listen_port, - advertise_ip, - advertise_port, - upnp_bind_multicast, - ): + hass: HomeAssistant, + entry_id: str, + name: str, + host_ip: str, + listen_port: int, + advertise_ip: str | None, + advertise_port: int | None, + upnp_bind_multicast: bool | None, + ) -> None: """Initialize the properties.""" self.hass = hass + self.entry_id = entry_id self.roku_usn = name self.host_ip = host_ip @@ -47,21 +55,21 @@ class EmulatedRoku: self.bind_multicast = upnp_bind_multicast - self._api_server = None + self._api_server: EmulatedRokuServer | None = None - self._unsub_start_listener = None - self._unsub_stop_listener = None + self._unsub_start_listener: CALLBACK_TYPE | None = None + self._unsub_stop_listener: CALLBACK_TYPE | None = None - async def setup(self): + async def setup(self) -> bool: """Start the emulated_roku server.""" class EventCommandHandler(EmulatedRokuCommandHandler): """emulated_roku command handler to turn commands into events.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: self.hass = hass - def on_keydown(self, roku_usn, key): + def on_keydown(self, roku_usn: str, key: str) -> None: """Handle keydown event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -73,7 +81,7 @@ class EmulatedRoku: EventOrigin.local, ) - def on_keyup(self, roku_usn, key): + def on_keyup(self, roku_usn: str, key: str) -> None: """Handle keyup event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -85,7 +93,7 @@ class EmulatedRoku: EventOrigin.local, ) - def on_keypress(self, roku_usn, key): + def on_keypress(self, roku_usn: str, key: str) -> None: """Handle keypress event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -97,7 +105,7 @@ class EmulatedRoku: EventOrigin.local, ) - def launch(self, roku_usn, app_id): + def launch(self, roku_usn: str, app_id: str) -> None: """Handle launch event.""" self.hass.bus.async_fire( EVENT_ROKU_COMMAND, @@ -129,17 +137,19 @@ class EmulatedRoku: bind_multicast=self.bind_multicast, ) - async def emulated_roku_stop(event): + async def emulated_roku_stop(event: Event | None) -> None: """Wrap the call to emulated_roku.close.""" LOGGER.debug("Stopping emulated_roku %s", self.roku_usn) self._unsub_stop_listener = None + assert self._api_server is not None await self._api_server.close() - async def emulated_roku_start(event): + async def emulated_roku_start(event: Event | None) -> None: """Wrap the call to emulated_roku.start.""" try: LOGGER.debug("Starting emulated_roku %s", self.roku_usn) self._unsub_start_listener = None + assert self._api_server is not None await self._api_server.start() except OSError: LOGGER.exception( @@ -165,7 +175,7 @@ class EmulatedRoku: return True - async def unload(self): + async def unload(self) -> bool: """Unload the emulated_roku server.""" LOGGER.debug("Unloading emulated_roku %s", self.roku_usn) @@ -177,6 +187,7 @@ class EmulatedRoku: self._unsub_stop_listener() self._unsub_stop_listener = None + assert self._api_server is not None await self._api_server.close() return True diff --git a/homeassistant/components/emulated_roku/strings.json b/homeassistant/components/emulated_roku/strings.json index fe5f603b04a..e740f6d8f53 100644 --- a/homeassistant/components/emulated_roku/strings.json +++ b/homeassistant/components/emulated_roku/strings.json @@ -7,12 +7,12 @@ "step": { "user": { "data": { - "advertise_ip": "Advertise IP Address", - "advertise_port": "Advertise Port", - "host_ip": "Host IP Address", - "listen_port": "Listen Port", + "advertise_ip": "Advertise IP address", + "advertise_port": "Advertise port", + "host_ip": "Host IP address", + "listen_port": "Listen port", "name": "[%key:common::config_flow::data::name%]", - "upnp_bind_multicast": "Bind multicast (True/False)" + "upnp_bind_multicast": "Bind multicast" }, "title": "Define server configuration" } diff --git a/homeassistant/components/energenie_power_sockets/strings.json b/homeassistant/components/energenie_power_sockets/strings.json index 4e4e49c68fb..bd536568d2c 100644 --- a/homeassistant/components/energenie_power_sockets/strings.json +++ b/homeassistant/components/energenie_power_sockets/strings.json @@ -1,5 +1,5 @@ { - "title": "Energenie Power Sockets Integration.", + "title": "Energenie Power Sockets", "config": { "step": { "user": { diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index ff86177cf41..442aedf23b0 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -139,6 +139,10 @@ class DeviceConsumption(TypedDict): # An optional custom name for display in energy graphs name: str | None + # An optional statistic_id identifying a device + # that includes this device's consumption in its total + included_in_stat: str | None + class EnergyPreferences(TypedDict): """Dictionary holding the energy data.""" @@ -291,6 +295,7 @@ DEVICE_CONSUMPTION_SCHEMA = vol.Schema( { vol.Required("stat_consumption"): str, vol.Optional("name"): str, + vol.Optional("included_in_stat"): str, } ) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index eec92c32f98..3dc857d75d9 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -25,6 +25,7 @@ from homeassistant.core import ( split_entity_id, valid_entity_id, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -51,6 +52,7 @@ VALID_ENERGY_UNITS_GAS = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS, + UnitOfVolume.LITERS, *VALID_ENERGY_UNITS, } VALID_VOLUME_UNITS_WATER: set[str] = { @@ -122,6 +124,10 @@ SOURCE_ADAPTERS: Final = ( ) +class EntityNotFoundError(HomeAssistantError): + """When a referenced entity was not found.""" + + class SensorManager: """Class to handle creation/removal of sensor data.""" @@ -311,43 +317,25 @@ class EnergyCostSensor(SensorEntity): except ValueError: return - # Determine energy price - if self._config["entity_energy_price"] is not None: - energy_price_state = self.hass.states.get( - self._config["entity_energy_price"] + try: + energy_price, energy_price_unit = self._get_energy_price( + valid_units, default_price_unit ) - - if energy_price_state is None: - return - - try: - energy_price = float(energy_price_state.state) - except ValueError: - if self._last_energy_sensor_state is None: - # Initialize as it's the first time all required entities except - # price are in place. This means that the cost will update the first - # time the energy is updated after the price entity is in place. - self._reset(energy_state) - return - - energy_price_unit: str | None = energy_price_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, "" - ).partition("/")[2] - - # For backwards compatibility we don't validate the unit of the price - # If it is not valid, we assume it's our default price unit. - if energy_price_unit not in valid_units: - energy_price_unit = default_price_unit - - else: - energy_price = cast(float, self._config["number_energy_price"]) - energy_price_unit = default_price_unit + except EntityNotFoundError: + return + except ValueError: + energy_price = None if self._last_energy_sensor_state is None: - # Initialize as it's the first time all required entities are in place. + # Initialize as it's the first time all required entities are in place or + # only the price is missing. In the later case, cost will update the first + # time the energy is updated after the price entity is in place. self._reset(energy_state) return + if energy_price is None: + return + energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if energy_unit is None or energy_unit not in valid_units: @@ -383,20 +371,9 @@ class EnergyCostSensor(SensorEntity): old_energy_value = float(self._last_energy_sensor_state.state) cur_value = cast(float, self._attr_native_value) - if energy_price_unit is None: - converted_energy_price = energy_price - else: - converter: Callable[[float, str, str], float] - if energy_unit in VALID_ENERGY_UNITS: - converter = unit_conversion.EnergyConverter.convert - else: - converter = unit_conversion.VolumeConverter.convert - - converted_energy_price = converter( - energy_price, - energy_unit, - energy_price_unit, - ) + converted_energy_price = self._convert_energy_price( + energy_price, energy_price_unit, energy_unit + ) self._attr_native_value = ( cur_value + (energy - old_energy_value) * converted_energy_price @@ -404,6 +381,52 @@ class EnergyCostSensor(SensorEntity): self._last_energy_sensor_state = energy_state + def _get_energy_price( + self, valid_units: set[str], default_unit: str | None + ) -> tuple[float, str | None]: + """Get the energy price. + + Raises: + EntityNotFoundError: When the energy price entity is not found. + ValueError: When the entity state is not a valid float. + + """ + + if self._config["entity_energy_price"] is None: + return cast(float, self._config["number_energy_price"]), default_unit + + energy_price_state = self.hass.states.get(self._config["entity_energy_price"]) + if energy_price_state is None: + raise EntityNotFoundError + + energy_price = float(energy_price_state.state) + + energy_price_unit: str | None = energy_price_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, "" + ).partition("/")[2] + + # For backwards compatibility we don't validate the unit of the price + # If it is not valid, we assume it's our default price unit. + if energy_price_unit not in valid_units: + energy_price_unit = default_unit + + return energy_price, energy_price_unit + + def _convert_energy_price( + self, energy_price: float, energy_price_unit: str | None, energy_unit: str + ) -> float: + """Convert the energy price to the correct unit.""" + if energy_price_unit is None: + return energy_price + + converter: Callable[[float, str, str], float] + if energy_unit in VALID_ENERGY_UNITS: + converter = unit_conversion.EnergyConverter.convert + else: + converter = unit_conversion.VolumeConverter.convert + + return converter(energy_price, energy_unit, energy_price_unit) + async def async_added_to_hass(self) -> None: """Register callbacks.""" energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key]) diff --git a/homeassistant/components/energy/strings.json b/homeassistant/components/energy/strings.json index e9d72247319..5eb2c93161e 100644 --- a/homeassistant/components/energy/strings.json +++ b/homeassistant/components/energy/strings.json @@ -7,7 +7,7 @@ }, "recorder_untracked": { "title": "Entity not tracked", - "description": "The recorder has been configured to exclude these configured entities:" + "description": "Home Assistant Recorder has been configured to exclude these configured entities:" }, "entity_unavailable": { "title": "Entity unavailable", diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index cfacbe48b97..0f46678994f 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -50,6 +50,7 @@ GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS, + UnitOfVolume.LITERS, ), } GAS_PRICE_UNITS = tuple( diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 5f48a99133d..d9d36deb03e 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -293,9 +293,9 @@ async def ws_get_fossil_energy_consumption( if statistics_id not in statistic_ids: continue for period in stat: - if period["change"] is None: + if (change := period.get("change")) is None: continue - result[period["start"]] += period["change"] + result[period["start"]] += change return {key: result[key] for key in sorted(result)} diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index 7788f4d4d8e..48682ab31ee 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -54,10 +54,10 @@ "services": { "get_gas_prices": { "name": "Get gas prices", - "description": "Request gas prices from EnergyZero.", + "description": "Requests gas prices from EnergyZero.", "fields": { "config_entry": { - "name": "Config Entry", + "name": "Config entry", "description": "The config entry to use for this action." }, "incl_vat": { @@ -76,7 +76,7 @@ }, "get_energy_prices": { "name": "Get energy prices", - "description": "Request energy prices from EnergyZero.", + "description": "Requests energy prices from EnergyZero.", "fields": { "config_entry": { "name": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::name%]", diff --git a/homeassistant/components/enigma2/config_flow.py b/homeassistant/components/enigma2/config_flow.py index b0649a8368d..876d55128cf 100644 --- a/homeassistant/components/enigma2/config_flow.py +++ b/homeassistant/components/enigma2/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Enigma2.""" +import logging from typing import Any, cast from aiohttp.client_exceptions import ClientError @@ -63,6 +64,8 @@ CONFIG_SCHEMA = vol.Schema( } ) +_LOGGER = logging.getLogger(__name__) + async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: """Get the options schema.""" @@ -130,7 +133,8 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): errors = {"base": "invalid_auth"} except ClientError: errors = {"base": "cannot_connect"} - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors = {"base": "unknown"} else: unique_id = about["info"]["ifaces"][0]["mac"] or self.unique_id diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 654e2262730..5ee81dd8315 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -16,7 +16,13 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + 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 @@ -40,6 +46,13 @@ CONF_SERIAL = "serial" INSTALLER_AUTH_USERNAME = "installer" +AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN} + + +def without_avoid_reflect_keys(dictionary: Mapping[str, Any]) -> dict[str, Any]: + """Return a dictionary without AVOID_REFLECT_KEYS.""" + return {k: v for k, v in dictionary.items() if k not in AVOID_REFLECT_KEYS} + async def validate_input( hass: HomeAssistant, @@ -205,7 +218,10 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders["serial"] = serial return self.async_show_form( step_id="reauth_confirm", - data_schema=self._async_generate_schema(), + data_schema=self.add_suggested_values_to_schema( + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or reauth_entry.data), + ), description_placeholders=description_placeholders, errors=errors, ) @@ -259,10 +275,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SERIAL: self.unique_id, CONF_HOST: host, } - return self.async_show_form( step_id="user", - data_schema=self._async_generate_schema(), + data_schema=self.add_suggested_values_to_schema( + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or {}), + ), description_placeholders=description_placeholders, errors=errors, ) @@ -306,11 +324,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): } description_placeholders["serial"] = serial - suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( - self._async_generate_schema(), suggested_values + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or reconfigure_entry.data), ), description_placeholders=description_placeholders, errors=errors, diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index b8cda03a451..40c690b29ec 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -9,12 +9,14 @@ import logging from typing import Any from pyenphase import Envoy, EnvoyError, EnvoyTokenAuth +from pyenphase.models.home import EnvoyInterfaceInformation from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -26,7 +28,7 @@ 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) - +MAC_VERIFICATION_DELAY = timedelta(seconds=34) _LOGGER = logging.getLogger(__name__) @@ -39,6 +41,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): envoy_serial_number: str envoy_firmware: str config_entry: EnphaseConfigEntry + interface: EnvoyInterfaceInformation | None def __init__( self, hass: HomeAssistant, envoy: Envoy, entry: EnphaseConfigEntry @@ -50,8 +53,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.password = entry_data[CONF_PASSWORD] self._setup_complete = False self.envoy_firmware = "" + self.interface = None self._cancel_token_refresh: CALLBACK_TYPE | None = None self._cancel_firmware_refresh: CALLBACK_TYPE | None = None + self._cancel_mac_verification: CALLBACK_TYPE | None = None super().__init__( hass, _LOGGER, @@ -121,6 +126,66 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.hass.config_entries.async_reload(self.config_entry.entry_id) ) + def _schedule_mac_verification( + self, delay: timedelta = MAC_VERIFICATION_DELAY + ) -> None: + """Schedule one time job to verify envoy mac address.""" + self.async_cancel_mac_verification() + self._cancel_mac_verification = async_call_later( + self.hass, + delay, + self._async_verify_mac, + ) + + @callback + def _async_verify_mac(self, now: datetime.datetime) -> None: + """Verify Envoy active interface mac address in background.""" + self.hass.async_create_background_task( + self._async_fetch_and_compare_mac(), "{name} verify envoy mac address" + ) + + async def _async_fetch_and_compare_mac(self) -> None: + """Get Envoy interface information and update mac in device connections.""" + interface: ( + EnvoyInterfaceInformation | None + ) = await self.envoy.interface_settings() + if interface is None: + _LOGGER.debug("%s: interface information returned None", self.name) + return + # remember interface information so diagnostics can include in report + self.interface = interface + + # Add to or update device registry connections as needed + device_registry = dr.async_get(self.hass) + envoy_device = device_registry.async_get_device( + identifiers={ + ( + DOMAIN, + self.envoy_serial_number, + ) + } + ) + if envoy_device is None: + _LOGGER.error( + "No envoy device found in device registry: %s %s", + DOMAIN, + self.envoy_serial_number, + ) + return + + connection = (dr.CONNECTION_NETWORK_MAC, interface.mac) + if connection in envoy_device.connections: + _LOGGER.debug( + "connection verified as existing: %s in %s", connection, self.name + ) + return + + device_registry.async_update_device( + device_id=envoy_device.id, + new_connections={connection}, + ) + _LOGGER.debug("added connection: %s to %s", connection, self.name) + @callback def _async_mark_setup_complete(self) -> None: """Mark setup as complete and setup firmware checks and token refresh if needed.""" @@ -132,6 +197,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): FIRMWARE_REFRESH_INTERVAL, cancel_on_shutdown=True, ) + self._schedule_mac_verification() self.async_cancel_token_refresh() if not isinstance(self.envoy.auth, EnvoyTokenAuth): return @@ -252,3 +318,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): if self._cancel_firmware_refresh: self._cancel_firmware_refresh() self._cancel_firmware_refresh = None + + @callback + def async_cancel_mac_verification(self) -> None: + """Cancel mac verification.""" + if self._cancel_mac_verification: + self._cancel_mac_verification() + self._cancel_mac_verification = None diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index d5b3880cf24..97079255876 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +from datetime import datetime from typing import TYPE_CHECKING, Any from attr import asdict @@ -63,19 +64,23 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]: "/ivp/ensemble/generator", "/ivp/meters", "/ivp/meters/readings", + "/home", ] for end_point in end_points: - response = await envoy.request(end_point) - fixture_data[end_point] = response.text.replace("\n", "").replace( - serial, CLEAN_TEXT - ) - fixture_data[f"{end_point}_log"] = json_dumps( - { - "headers": dict(response.headers.items()), - "code": response.status_code, - } - ) + try: + response = await envoy.request(end_point) + fixture_data[end_point] = response.text.replace("\n", "").replace( + serial, CLEAN_TEXT + ) + fixture_data[f"{end_point}_log"] = json_dumps( + { + "headers": dict(response.headers.items()), + "code": response.status_code, + } + ) + except EnvoyError as err: + fixture_data[f"{end_point}_log"] = {"Error": repr(err)} return fixture_data @@ -143,11 +148,25 @@ async def async_get_config_entry_diagnostics( "inverters": envoy_data.inverters, "tariff": envoy_data.tariff, } + # Add Envoy active interface information to report + active_interface: dict[str, Any] = {} + if coordinator.interface: + active_interface = { + "name": (interface := coordinator.interface).primary_interface, + "interface type": interface.interface_type, + "mac": interface.mac, + "uses dhcp": interface.dhcp, + "firmware build date": datetime.fromtimestamp( + interface.software_build_epoch + ).strftime("%Y-%m-%d %H:%M:%S"), + "envoy timezone": interface.timezone, + } envoy_properties: dict[str, Any] = { "envoy_firmware": envoy.firmware, "part_number": envoy.part_number, "envoy_model": envoy.envoy_model, + "active interface": active_interface, "supported_features": [feature.name for feature in envoy.supported_features], "phase_mode": envoy.phase_mode, "phase_count": envoy.phase_count, @@ -160,10 +179,7 @@ async def async_get_config_entry_diagnostics( fixture_data: dict[str, Any] = {} if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False): - try: - fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial) - except EnvoyError as err: - fixture_data["Error"] = repr(err) + fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial) diagnostic_data: dict[str, Any] = { "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index e51a7427504..e978ded7321 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,8 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.25.1"], + "quality_scale": "platinum", + "requirements": ["pyenphase==1.26.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/quality_scale.yaml b/homeassistant/components/enphase_envoy/quality_scale.yaml index 4431a298c8c..78ff6de4297 100644 --- a/homeassistant/components/enphase_envoy/quality_scale.yaml +++ b/homeassistant/components/enphase_envoy/quality_scale.yaml @@ -1,31 +1,19 @@ rules: # Bronze action-setup: - status: done + status: exempt comment: only actions implemented are platform native ones. - appropriate-polling: - status: done - comment: fixed 1 minute cycle based on Enphase Envoy device characteristics + appropriate-polling: done 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. + 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 @@ -34,24 +22,14 @@ rules: 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 + action-exceptions: done 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 + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: done - comment: pending https://github.com/home-assistant/core/pull/132373 + parallel-updates: done reauthentication-flow: done test-coverage: done @@ -60,22 +38,14 @@ rules: 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 + 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 @@ -86,7 +56,7 @@ rules: repair-issues: status: exempt comment: no general issues or repair.py - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index b498c59e0d3..e45c746869d 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -128,7 +128,7 @@ "storage_mode": { "name": "Storage mode", "state": { - "self_consumption": "Self consumption", + "self_consumption": "Self-consumption", "backup": "Full backup", "savings": "Savings mode" } @@ -187,13 +187,13 @@ "name": "Lifetime energy consumption {phase_name}" }, "balanced_net_consumption": { - "name": "balanced net power consumption" + "name": "Balanced net power consumption" }, "lifetime_balanced_net_consumption": { "name": "Lifetime balanced net energy consumption" }, "balanced_net_consumption_phase": { - "name": "balanced net power consumption {phase_name}" + "name": "Balanced net power consumption {phase_name}" }, "lifetime_balanced_net_consumption_phase": { "name": "Lifetime balanced net energy consumption {phase_name}" @@ -217,7 +217,7 @@ "name": "Net consumption CT current" }, "net_ct_powerfactor": { - "name": "Powerfactor net consumption CT" + "name": "Power factor net consumption CT" }, "net_ct_metering_status": { "name": "Metering status net consumption CT" @@ -235,7 +235,7 @@ "name": "Production CT current" }, "production_ct_powerfactor": { - "name": "powerfactor production CT" + "name": "Power factor production CT" }, "production_ct_metering_status": { "name": "Metering status production CT" @@ -262,7 +262,7 @@ "name": "Storage CT current" }, "storage_ct_powerfactor": { - "name": "Powerfactor storage CT" + "name": "Power factor storage CT" }, "storage_ct_metering_status": { "name": "Metering status storage CT" @@ -289,7 +289,7 @@ "name": "Net consumption CT current {phase_name}" }, "net_ct_powerfactor_phase": { - "name": "Powerfactor net consumption CT {phase_name}" + "name": "Power factor net consumption CT {phase_name}" }, "net_ct_metering_status_phase": { "name": "Metering status net consumption CT {phase_name}" @@ -307,7 +307,7 @@ "name": "Production CT current {phase_name}" }, "production_ct_powerfactor_phase": { - "name": "Powerfactor production CT {phase_name}" + "name": "Power factor production CT {phase_name}" }, "production_ct_metering_status_phase": { "name": "Metering status production CT {phase_name}" @@ -334,7 +334,7 @@ "name": "Storage CT current {phase_name}" }, "storage_ct_powerfactor_phase": { - "name": "Powerfactor storage CT {phase_name}" + "name": "Power factor storage CT {phase_name}" }, "storage_ct_metering_status_phase": { "name": "Metering status storage CT {phase_name}" @@ -393,7 +393,7 @@ }, "exceptions": { "unexpected_device": { - "message": "Unexpected Envoy serial-number found at {host}; expected {expected_serial}, found {actual_serial}" + "message": "Unexpected Envoy serial number found at {host}; expected {expected_serial}, found {actual_serial}" }, "authentication_error": { "message": "Envoy authentication failure on {host}: {args}" diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index c4fd16f9522..debe1c5ae43 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -35,7 +35,7 @@ async def validate_input(data): lon = weather_data.lon return { - CONF_TITLE: weather_data.metadata.get("location"), + CONF_TITLE: weather_data.metadata.location, CONF_STATION: weather_data.station_id, CONF_LATITUDE: lat, CONF_LONGITUDE: lon, diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py index e31e847cd2d..89fc92b462e 100644 --- a/homeassistant/components/environment_canada/coordinator.py +++ b/homeassistant/components/environment_canada/coordinator.py @@ -7,7 +7,7 @@ from datetime import timedelta import logging import xml.etree.ElementTree as ET -from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc +from env_canada import ECAirQuality, ECRadar, ECWeather, ECWeatherUpdateFailed, ec_exc from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -65,6 +65,6 @@ class ECDataUpdateCoordinator[DataT: ECDataType](DataUpdateCoordinator[DataT]): """Fetch data from EC.""" try: await self.ec_data.update() - except (ET.ParseError, ec_exc.UnknownStationId) as ex: + except (ET.ParseError, ECWeatherUpdateFailed, ec_exc.UnknownStationId) as ex: raise UpdateFailed(f"Error fetching {self.name} data: {ex}") from ex return self.ec_data diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 76534662ff7..da0be245fcd 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.7.2"] + "requirements": ["env-canada==0.10.2"] } diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 3a789289c74..d27da132a35 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -145,7 +145,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( key="timestamp", translation_key="timestamp", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: data.metadata.get("timestamp"), + value_fn=lambda data: data.metadata.timestamp, ), ECSensorEntityDescription( key="uv_index", @@ -168,6 +168,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, value_fn=lambda data: data.conditions.get("wind_bearing", {}).get("value"), device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), ECSensorEntityDescription( key="wind_chill", @@ -288,7 +289,7 @@ class ECBaseSensorEntity[DataT: ECDataType]( super().__init__(coordinator) self.entity_description = description self._ec_data = coordinator.ec_data - self._attr_attribution = self._ec_data.metadata["attribution"] + self._attr_attribution = self._ec_data.metadata.attribution self._attr_unique_id = f"{coordinator.config_entry.title}-{description.key}" self._attr_device_info = coordinator.device_info @@ -312,8 +313,8 @@ class ECSensorEntity[DataT: ECDataType](ECBaseSensorEntity[DataT]): """Initialize the sensor.""" super().__init__(coordinator, description) self._attr_extra_state_attributes = { - ATTR_LOCATION: self._ec_data.metadata.get("location"), - ATTR_STATION: self._ec_data.metadata.get("station"), + ATTR_LOCATION: self._ec_data.metadata.location, + ATTR_STATION: self._ec_data.metadata.station, } @@ -328,8 +329,8 @@ class ECAlertSensorEntity(ECBaseSensorEntity[ECWeather]): return None extra_state_attrs = { - ATTR_LOCATION: self._ec_data.metadata.get("location"), - ATTR_STATION: self._ec_data.metadata.get("station"), + ATTR_LOCATION: self._ec_data.metadata.location, + ATTR_STATION: self._ec_data.metadata.station, } for index, alert in enumerate(value, start=1): extra_state_attrs[f"alert_{index}"] = alert.get("title") diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index 1ccff145bb3..b0b04f73879 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -86,7 +86,7 @@ "name": "AQHI" }, "advisories": { - "name": "Advisory" + "name": "Advisories" }, "endings": { "name": "Endings" diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index dd7632032ec..a5acb224bd0 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -115,7 +115,7 @@ class ECWeatherEntity( """Initialize Environment Canada weather.""" super().__init__(coordinator) self.ec_data = coordinator.ec_data - self._attr_attribution = self.ec_data.metadata["attribution"] + self._attr_attribution = self.ec_data.metadata.attribution self._attr_translation_key = "forecast" self._attr_unique_id = _calculate_unique_id( coordinator.config_entry.unique_id, False diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index f92be005db6..efdd106b34b 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -6,13 +6,12 @@ from datetime import timedelta import logging from typing import Any -from pyephember.pyephember import ( +from pyephember2.pyephember2 import ( EphEmber, ZoneMode, zone_current_temperature, zone_is_active, - zone_is_boost_active, - zone_is_hot_water, + zone_is_hotwater, zone_mode, zone_name, zone_target_temperature, @@ -69,14 +68,18 @@ def setup_platform( try: ember = EphEmber(username, password) - zones = ember.get_zones() - for zone in zones: - add_entities([EphEmberThermostat(ember, zone)]) except RuntimeError: - _LOGGER.error("Cannot connect to EphEmber") + _LOGGER.error("Cannot login to EphEmber") + + try: + homes = ember.get_zones() + except RuntimeError: + _LOGGER.error("Fail to get zones") return - return + add_entities( + EphEmberThermostat(ember, zone) for home in homes for zone in home["zones"] + ) class EphEmberThermostat(ClimateEntity): @@ -85,33 +88,35 @@ class EphEmberThermostat(ClimateEntity): _attr_hvac_modes = OPERATION_LIST _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, ember, zone): + def __init__(self, ember, zone) -> None: """Initialize the thermostat.""" self._ember = ember self._zone_name = zone_name(zone) self._zone = zone - self._hot_water = zone_is_hot_water(zone) + self._attr_unique_id = zone["zoneid"] + + # hot water = true, is immersive device without target temperature control. + self._hot_water = zone_is_hotwater(zone) self._attr_name = self._zone_name - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.AUX_HEAT - ) - self._attr_target_temperature_step = 0.5 if self._hot_water: - self._attr_supported_features = ClimateEntityFeature.AUX_HEAT self._attr_target_temperature_step = None - self._attr_supported_features |= ( - ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON - ) + else: + self._attr_target_temperature_step = 0.5 + self._attr_supported_features = ( + ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TARGET_TEMPERATURE + ) @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return zone_current_temperature(self._zone) @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return zone_target_temperature(self._zone) @@ -133,26 +138,10 @@ class EphEmberThermostat(ClimateEntity): """Set the operation mode.""" mode = self.map_mode_hass_eph(hvac_mode) if mode is not None: - self._ember.set_mode_by_name(self._zone_name, mode) + self._ember.set_zone_mode(self._zone["zoneid"], mode) else: _LOGGER.error("Invalid operation mode provided %s", hvac_mode) - @property - def is_aux_heat(self): - """Return true if aux heater.""" - - return zone_is_boost_active(self._zone) - - def turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - self._ember.activate_boost_by_name( - self._zone_name, zone_target_temperature(self._zone) - ) - - def turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - self._ember.deactivate_boost_by_name(self._zone_name) - def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: @@ -167,7 +156,7 @@ class EphEmberThermostat(ClimateEntity): if temperature > self.max_temp or temperature < self.min_temp: return - self._ember.set_target_temperture_by_name(self._zone_name, temperature) + self._ember.set_zone_target_temperature(self._zone["zoneid"], temperature) @property def min_temp(self): @@ -188,7 +177,8 @@ class EphEmberThermostat(ClimateEntity): def update(self) -> None: """Get the latest data.""" - self._zone = self._ember.get_zone(self._zone_name) + self._ember.get_zones() + self._zone = self._ember.get_zone(self._zone["zoneid"]) @staticmethod def map_mode_hass_eph(operation_mode): diff --git a/homeassistant/components/ephember/manifest.json b/homeassistant/components/ephember/manifest.json index 547ab2918f5..7d78149d068 100644 --- a/homeassistant/components/ephember/manifest.json +++ b/homeassistant/components/ephember/manifest.json @@ -1,10 +1,10 @@ { "domain": "ephember", "name": "EPH Controls", - "codeowners": ["@ttroy50"], + "codeowners": ["@ttroy50", "@roberty99"], "documentation": "https://www.home-assistant.io/integrations/ephember", "iot_class": "local_polling", - "loggers": ["pyephember"], + "loggers": ["pyephember2"], "quality_scale": "legacy", - "requirements": ["pyephember==0.3.1"] + "requirements": ["pyephember2==0.4.12"] } diff --git a/homeassistant/components/epic_games_store/strings.json b/homeassistant/components/epic_games_store/strings.json index 58a87a55f81..ab4562a72ad 100644 --- a/homeassistant/components/epic_games_store/strings.json +++ b/homeassistant/components/epic_games_store/strings.json @@ -3,8 +3,8 @@ "step": { "user": { "data": { - "language": "Language", - "country": "Country" + "language": "[%key:common::config_flow::data::language%]", + "country": "[%key:common::config_flow::data::country%]" } } }, diff --git a/homeassistant/components/epson/config_flow.py b/homeassistant/components/epson/config_flow.py index c54bff2eea9..077b9cc31f7 100644 --- a/homeassistant/components/epson/config_flow.py +++ b/homeassistant/components/epson/config_flow.py @@ -72,5 +72,7 @@ class EpsonConfigFlow(ConfigFlow, domain=DOMAIN): if projector: projector.close() return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input), + errors=errors, ) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 0bc3ae55236..1f619b2017c 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==2.7.1"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.15.1"] } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index fee2531fa20..f621c74642b 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from aioesphomeapi import APIClient -from homeassistant.components import ffmpeg, zeroconf +from homeassistant.components import zeroconf from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.const import ( CONF_HOST, @@ -14,16 +14,14 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType -from .const import CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN -from .dashboard import async_setup as async_setup_dashboard +from . import dashboard, ffmpeg_proxy +from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN from .domain_data import DomainData - -# Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData -from .ffmpeg_proxy import FFmpegProxyData, FFmpegProxyView -from .manager import ESPHomeManager, cleanup_instance +from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -32,12 +30,8 @@ CLIENT_INFO = f"Home Assistant {ha_version}" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the esphome component.""" - proxy_data = hass.data[DATA_FFMPEG_PROXY] = FFmpegProxyData() - - await async_setup_dashboard(hass) - hass.http.register_view( - FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data) - ) + ffmpeg_proxy.async_setup(hass) + await dashboard.async_setup(hass) return True @@ -79,7 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool: """Unload an esphome config entry.""" - entry_data = await cleanup_instance(hass, entry) + entry_data = await cleanup_instance(entry) return await hass.config_entries.async_unload_platforms( entry, entry_data.loaded_platforms ) @@ -87,6 +81,9 @@ 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()) + if bluetooth_mac_address := entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS): + async_remove_scanner(hass, bluetooth_mac_address.upper()) + async_delete_issue( + hass, DOMAIN, DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) + ) await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index 8f1b5ae8b1a..ad455e620bb 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -29,6 +29,8 @@ from .entity import ( ) from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[ ESPHomeAlarmControlPanelState, AlarmControlPanelState ] = EsphomeEnumMapper( @@ -48,7 +50,7 @@ _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[ class EspHomeACPFeatures(APIIntEnum): - """ESPHome AlarmCintolPanel feature numbers.""" + """ESPHome AlarmControlPanel feature numbers.""" ARM_HOME = 1 ARM_AWAY = 2 diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 016b1c3494d..073a1ec8ae9 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -35,18 +35,19 @@ from homeassistant.components.intent import ( async_register_timer_handler, ) from homeassistant.components.media_player import async_process_play_media_url -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .entity import EsphomeAssistEntity -from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +from .entity import EsphomeAssistEntity, convert_api_error_ha_error +from .entry_data import ESPHomeConfigEntry from .enum_mapper import EsphomeEnumMapper from .ffmpeg_proxy import async_create_proxy_url +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ @@ -95,7 +96,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)]) class EsphomeAssistSatellite( @@ -107,17 +108,12 @@ class EsphomeAssistSatellite( key="assist_satellite", translation_key="assist_satellite" ) - def __init__( - self, - config_entry: ConfigEntry, - entry_data: RuntimeEntryData, - ) -> None: + def __init__(self, entry: ESPHomeConfigEntry) -> None: """Initialize satellite.""" - super().__init__(entry_data) + super().__init__(entry.runtime_data) - self.config_entry = config_entry - self.entry_data = entry_data - self.cli = self.entry_data.client + self.config_entry = entry + self.cli = self._entry_data.client self._is_running: bool = True self._pipeline_task: asyncio.Task | None = None @@ -133,23 +129,23 @@ class EsphomeAssistSatellite( @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" - assert self.entry_data.device_info is not None + assert self._entry_data.device_info is not None ent_reg = er.async_get(self.hass) return ent_reg.async_get_entity_id( Platform.SELECT, DOMAIN, - f"{self.entry_data.device_info.mac_address}-pipeline", + f"{self._entry_data.device_info.mac_address}-pipeline", ) @property def vad_sensitivity_entity_id(self) -> str | None: """Return the entity ID of the VAD sensitivity to use for the next conversation.""" - assert self.entry_data.device_info is not None + assert self._entry_data.device_info is not None ent_reg = er.async_get(self.hass) return ent_reg.async_get_entity_id( Platform.SELECT, DOMAIN, - f"{self.entry_data.device_info.mac_address}-vad_sensitivity", + f"{self._entry_data.device_info.mac_address}-vad_sensitivity", ) @callback @@ -195,16 +191,16 @@ class EsphomeAssistSatellite( _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) + 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() - assert self.entry_data.device_info is not None + assert self._entry_data.device_info is not None feature_flags = ( - self.entry_data.device_info.voice_assistant_feature_flags_compat( - self.entry_data.api_version + self._entry_data.device_info.voice_assistant_feature_flags_compat( + self._entry_data.api_version ) ) if feature_flags & VoiceAssistantFeature.API_AUDIO: @@ -253,9 +249,14 @@ class EsphomeAssistSatellite( # Will use media player for TTS/announcements self._update_tts_format() + if feature_flags & VoiceAssistantFeature.START_CONVERSATION: + self._attr_supported_features |= ( + assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + # Update wake word select when config is updated self.async_on_remove( - self.entry_data.async_register_assist_satellite_set_wake_word_callback( + self._entry_data.async_register_assist_satellite_set_wake_word_callback( self.async_set_wake_word ) ) @@ -277,14 +278,17 @@ class EsphomeAssistSatellite( data_to_send: dict[str, Any] = {} if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: - self.entry_data.async_set_assist_pipeline_state(True) + self._entry_data.async_set_assist_pipeline_state(True) elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: assert event.data is not None data_to_send = {"text": event.data["stt_output"]["text"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: assert event.data is not None data_to_send = { - "conversation_id": event.data["intent_output"]["conversation_id"] or "", + "conversation_id": event.data["intent_output"]["conversation_id"], + "continue_conversation": str( + int(event.data["intent_output"]["continue_conversation"]) + ), } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: assert event.data is not None @@ -296,18 +300,19 @@ class EsphomeAssistSatellite( url = async_process_play_media_url(self.hass, path) data_to_send = {"url": url} - assert self.entry_data.device_info is not None + assert self._entry_data.device_info is not None feature_flags = ( - self.entry_data.device_info.voice_assistant_feature_flags_compat( - self.entry_data.api_version + self._entry_data.device_info.voice_assistant_feature_flags_compat( + self._entry_data.api_version ) ) - if feature_flags & VoiceAssistantFeature.SPEAKER: - media_id = tts_output["media_id"] + if feature_flags & VoiceAssistantFeature.SPEAKER and ( + stream := tts.async_get_stream(self.hass, tts_output["token"]) + ): self._tts_streaming_task = ( self.config_entry.async_create_background_task( self.hass, - self._stream_tts_audio(media_id), + self._stream_tts_audio(stream), "esphome_voice_assistant_tts", ) ) @@ -325,13 +330,20 @@ class EsphomeAssistSatellite( "code": event.data["code"], "message": event.data["message"], } + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: + assert event.data is not None + if tts_output := event.data["tts_output"]: + path = tts_output["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: if self._tts_streaming_task is None: # No TTS - self.entry_data.async_set_assist_pipeline_state(False) + self._entry_data.async_set_assist_pipeline_state(False) self.cli.send_voice_assistant_event(event_type, data_to_send) + @convert_api_error_ha_error async def async_announce( self, announcement: assist_satellite.AssistSatelliteAnnouncement ) -> None: @@ -339,17 +351,37 @@ class EsphomeAssistSatellite( Should block until the announcement is done playing. """ + await self._do_announce(announcement, run_pipeline_after=False) + + @convert_api_error_ha_error + async def async_start_conversation( + self, start_announcement: assist_satellite.AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + await self._do_announce(start_announcement, run_pipeline_after=True) + + async def _do_announce( + self, + announcement: assist_satellite.AssistSatelliteAnnouncement, + run_pipeline_after: bool, + ) -> None: + """Announce media on the satellite. + + Optionally run a voice pipeline after the announcement has finished. + """ _LOGGER.debug( "Waiting for announcement to finished (message=%s, media_id=%s)", announcement.message, announcement.media_id, ) media_id = announcement.media_id - if announcement.media_id_source != "tts": - # Route non-TTS media through the proxy + is_media_tts = announcement.media_id_source == "tts" + preannounce_media_id = announcement.preannounce_media_id + if (not is_media_tts) or preannounce_media_id: + # Route media through the proxy format_to_use: MediaPlayerSupportedFormat | None = None for supported_format in chain( - *self.entry_data.media_player_formats.values() + *self._entry_data.media_player_formats.values() ): if supported_format.purpose == MediaPlayerFormatPurpose.ANNOUNCEMENT: format_to_use = supported_format @@ -359,19 +391,33 @@ class EsphomeAssistSatellite( assert (self.registry_entry is not None) and ( self.registry_entry.device_id is not None ) - proxy_url = async_create_proxy_url( - self.hass, - self.registry_entry.device_id, - media_id, + + make_proxy_url = partial( + async_create_proxy_url, + hass=self.hass, + device_id=self.registry_entry.device_id, media_format=format_to_use.format, rate=format_to_use.sample_rate or None, channels=format_to_use.num_channels or None, width=format_to_use.sample_bytes or None, ) - media_id = async_process_play_media_url(self.hass, proxy_url) + + if not is_media_tts: + media_id = async_process_play_media_url( + self.hass, make_proxy_url(media_url=media_id) + ) + + if preannounce_media_id: + preannounce_media_id = async_process_play_media_url( + self.hass, make_proxy_url(media_url=preannounce_media_id) + ) await self.cli.send_voice_assistant_announcement_await_response( - media_id, _ANNOUNCEMENT_TIMEOUT_SEC, announcement.message + media_id, + _ANNOUNCEMENT_TIMEOUT_SEC, + announcement.message, + start_conversation=run_pipeline_after, + preannounce_media_id=preannounce_media_id or "", ) async def handle_pipeline_start( @@ -393,10 +439,10 @@ class EsphomeAssistSatellite( # API or UDP output audio port: int = 0 - assert self.entry_data.device_info is not None + assert self._entry_data.device_info is not None feature_flags = ( - self.entry_data.device_info.voice_assistant_feature_flags_compat( - self.entry_data.api_version + self._entry_data.device_info.voice_assistant_feature_flags_compat( + self._entry_data.api_version ) ) if (feature_flags & VoiceAssistantFeature.SPEAKER) and not ( @@ -497,7 +543,7 @@ class EsphomeAssistSatellite( 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()): + for supported_format in chain(*self._entry_data.media_player_formats.values()): # Find first announcement format if supported_format.purpose == MediaPlayerFormatPurpose.ANNOUNCEMENT: self._attr_tts_options = { @@ -523,7 +569,7 @@ class EsphomeAssistSatellite( async def _stream_tts_audio( self, - media_id: str, + tts_result: tts.ResultStream, sample_rate: int = 16000, sample_width: int = 2, sample_channels: int = 1, @@ -538,15 +584,14 @@ class EsphomeAssistSatellite( if not self._is_running: return - extension, data = await tts.async_get_media_source_audio( - self.hass, - media_id, - ) - - if extension != "wav": - _LOGGER.error("Only WAV audio can be streamed, got %s", extension) + if tts_result.extension != "wav": + _LOGGER.error( + "Only WAV audio can be streamed, got %s", tts_result.extension + ) return + data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: if ( (wav_file.getframerate() != sample_rate) @@ -584,7 +629,7 @@ class EsphomeAssistSatellite( # State change self.tts_response_finished() - self.entry_data.async_set_assist_pipeline_state(False) + self._entry_data.async_set_assist_pipeline_state(False) async def _wrap_audio_stream(self) -> AsyncIterable[bytes]: """Yield audio chunks from the queue until None.""" diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 02b13748fb6..deccb6cc7da 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -2,46 +2,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from functools import partial from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, - BinarySensorEntityDescription, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.core import callback from homeassistant.util.enum import try_parse_enum -from .const import DOMAIN -from .entity import EsphomeAssistEntity, EsphomeEntity, platform_async_setup_entry -from .entry_data import ESPHomeConfigEntry +from .entity import EsphomeEntity, platform_async_setup_entry - -async def async_setup_entry( - hass: HomeAssistant, - entry: ESPHomeConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up ESPHome binary sensors based on a config entry.""" - await platform_async_setup_entry( - hass, - entry, - async_add_entities, - info_type=BinarySensorInfo, - entity_type=EsphomeBinarySensor, - state_type=BinarySensorState, - ) - - entry_data = entry.runtime_data - assert entry_data.device_info is not None - if entry_data.device_info.voice_assistant_feature_flags_compat( - entry_data.api_version - ): - async_add_entities([EsphomeAssistInProgressBinarySensor(entry_data)]) +PARALLEL_UPDATES = 0 class EsphomeBinarySensor( @@ -74,50 +48,9 @@ class EsphomeBinarySensor( return self._static_info.is_status_binary_sensor or super().available -class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntity): - """A binary sensor implementation for ESPHome for use with assist_pipeline.""" - - entity_description = BinarySensorEntityDescription( - entity_registry_enabled_default=False, - key="assist_in_progress", - translation_key="assist_in_progress", - ) - - async def async_added_to_hass(self) -> None: - """Create issue.""" - await super().async_added_to_hass() - if TYPE_CHECKING: - assert self.registry_entry is not None - ir.async_create_issue( - self.hass, - DOMAIN, - f"assist_in_progress_deprecated_{self.registry_entry.id}", - breaks_in_ha_version="2025.4", - data={ - "entity_id": self.entity_id, - "entity_uuid": self.registry_entry.id, - "integration_name": "ESPHome", - }, - is_fixable=True, - severity=ir.IssueSeverity.WARNING, - translation_key="assist_in_progress_deprecated", - translation_placeholders={ - "integration_name": "ESPHome", - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Remove issue.""" - await super().async_will_remove_from_hass() - if TYPE_CHECKING: - assert self.registry_entry is not None - ir.async_delete_issue( - self.hass, - DOMAIN, - f"assist_in_progress_deprecated_{self.registry_entry.id}", - ) - - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self._entry_data.assist_pipeline_state +async_setup_entry = partial( + platform_async_setup_entry, + info_type=BinarySensorInfo, + entity_type=EsphomeBinarySensor, + state_type=BinarySensorState, +) diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index f13fa65ede1..31121d98ff7 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -16,6 +16,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): """A button implementation for ESPHome.""" diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 6038bf52e06..e2213153092 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -16,6 +16,8 @@ from homeassistant.core import callback from .entity import EsphomeEntity, platform_async_setup_entry +PARALLEL_UPDATES = 0 + class EsphomeCamera(Camera, EsphomeEntity[CameraInfo, CameraState]): """A camera implementation for ESPHome.""" diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 478ce9bae2c..667d5d00154 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -3,6 +3,7 @@ from __future__ import annotations from functools import partial +from math import isfinite from typing import Any, cast from aioesphomeapi import ( @@ -64,6 +65,8 @@ from .entity import ( ) from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + FAN_QUIET = "quiet" @@ -177,13 +180,13 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti def _get_precision(self) -> float: """Return the precision of the climate device.""" - precicions = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + precisions = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] static_info = self._static_info if static_info.visual_current_temperature_step != 0: step = static_info.visual_current_temperature_step else: step = static_info.visual_target_temperature_step - for prec in precicions: + for prec in precisions: if step >= prec: return prec # Fall back to highest precision, tenths @@ -238,9 +241,13 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti @esphome_state_property def current_humidity(self) -> int | None: """Return the current humidity.""" - if not self._static_info.supports_current_humidity: + if ( + not self._static_info.supports_current_humidity + or (val := self._state.current_humidity) is None + or not isfinite(val) + ): return None - return round(self._state.current_humidity) + return round(val) @property @esphome_float_state_property diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 955a93cd2b7..75408246e78 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -22,7 +22,9 @@ import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ( + SOURCE_IGNORE, SOURCE_REAUTH, + SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -30,6 +32,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo @@ -44,16 +47,19 @@ from .const import ( CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + DEFAULT_PORT, DOMAIN, ) from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info +from .entry_data import ESPHomeConfigEntry +from .manager import async_replace_device ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" -ESPHOME_URL = "https://esphome.io/" _LOGGER = logging.getLogger(__name__) ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" +DEFAULT_NAME = "ESPHome" class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -62,6 +68,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 _reauth_entry: ConfigEntry + _reconfig_entry: ConfigEntry def __init__(self) -> None: """Initialize flow.""" @@ -74,6 +81,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._device_info: DeviceInfo | None = None # The ESPHome name as per its config self._device_name: str | None = None + self._device_mac: str | None = None + self._entry_with_name_conflict: ConfigEntry | None = None async def _async_step_user_base( self, user_input: dict[str, Any] | None = None, error: str | None = None @@ -85,7 +94,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): fields: dict[Any, type] = OrderedDict() fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str - fields[vol.Optional(CONF_PORT, default=self._port or 6053)] = int + fields[vol.Optional(CONF_PORT, default=self._port or DEFAULT_PORT)] = int errors = {} if error is not None: @@ -95,7 +104,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema(fields), errors=errors, - description_placeholders={"esphome_url": ESPHOME_URL}, ) async def async_step_user( @@ -112,8 +120,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._host = entry_data[CONF_HOST] self._port = entry_data[CONF_PORT] self._password = entry_data[CONF_PASSWORD] - self._name = self._reauth_entry.title self._device_name = entry_data.get(CONF_DEVICE_NAME) + self._name = self._reauth_entry.title # Device without encryption allows fetching device info. We can then check # if the device is no longer using a password. If we did try with a password, @@ -128,8 +136,23 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._password = "" return await self._async_authenticate_or_add() + if error is None and entry_data.get(CONF_NOISE_PSK): + return await self.async_step_reauth_encryption_removed_confirm() return await self.async_step_reauth_confirm() + async def async_step_reauth_encryption_removed_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthorization flow when encryption was removed.""" + if user_input is not None: + self._noise_psk = None + return await self._async_validated_connection() + + return self.async_show_form( + step_id="reauth_encryption_removed_confirm", + description_placeholders={"name": self._async_get_human_readable_name()}, + ) + async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -152,17 +175,31 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), errors=errors, - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by a reconfig request.""" + self._reconfig_entry = self._get_reconfigure_entry() + data = self._reconfig_entry.data + self._host = data[CONF_HOST] + self._port = data.get(CONF_PORT, DEFAULT_PORT) + self._noise_psk = data.get(CONF_NOISE_PSK) + self._device_name = data.get(CONF_DEVICE_NAME) + return await self._async_step_user_base() + @property def _name(self) -> str: - return self.__name or "ESPHome" + return self.__name or DEFAULT_NAME @_name.setter def _name(self, value: str) -> None: self.__name = value - self.context["title_placeholders"] = {"name": self._name} + self.context["title_placeholders"] = { + "name": self._async_get_human_readable_name() + } async def _async_try_fetch_device_info(self) -> ConfigFlowResult: """Try to fetch device info and return any errors.""" @@ -213,7 +250,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_authenticate() self._password = "" - return self._async_get_entry() + return await self._async_validated_connection() async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None @@ -222,7 +259,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: return await self._async_try_fetch_device_info() return self.async_show_form( - step_id="discovery_confirm", description_placeholders={"name": self._name} + step_id="discovery_confirm", + description_placeholders={"name": self._async_get_human_readable_name()}, ) async def async_step_zeroconf( @@ -242,20 +280,75 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Hostname is format: livingroom.local. device_name = discovery_info.hostname.removesuffix(".local.") - self._name = discovery_info.properties.get("friendly_name", device_name) self._device_name = device_name + self._name = discovery_info.properties.get("friendly_name", device_name) self._host = discovery_info.host self._port = discovery_info.port self._noise_required = bool(discovery_info.properties.get("api_encryption")) # Check if already configured await self.async_set_unique_id(mac_address) - self._abort_if_unique_id_configured( - updates={CONF_HOST: self._host, CONF_PORT: self._port} + await self._async_validate_mac_abort_configured( + mac_address, self._host, self._port ) - return await self.async_step_discovery_confirm() + async def _async_validate_mac_abort_configured( + self, formatted_mac: str, host: str, port: int | None + ) -> None: + """Validate if the MAC address is already configured.""" + assert self.unique_id is not None + if not ( + entry := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, formatted_mac + ) + ): + return + if entry.source == SOURCE_IGNORE: + # Don't call _fetch_device_info() for ignored entries + raise AbortFlow("already_configured") + configured_host: str | None = entry.data.get(CONF_HOST) + configured_port: int | None = entry.data.get(CONF_PORT) + if configured_host == host and configured_port == port: + # Don't probe to verify the mac is correct since + # the host and port matches. + raise AbortFlow("already_configured") + configured_psk: str | None = entry.data.get(CONF_NOISE_PSK) + await self._fetch_device_info(host, port or configured_port, configured_psk) + updates: dict[str, Any] = {} + if self._device_mac == formatted_mac: + updates[CONF_HOST] = host + if port is not None: + updates[CONF_PORT] = port + self._abort_unique_id_configured_with_details(updates=updates) + + @callback + def _abort_unique_id_configured_with_details(self, updates: dict[str, Any]) -> None: + """Abort if unique_id is already configured with details.""" + assert self.unique_id is not None + if not ( + conflict_entry := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, self.unique_id + ) + ): + return + assert conflict_entry.unique_id is not None + if self.source == SOURCE_RECONFIGURE: + error = "reconfigure_already_configured" + elif updates: + error = "already_configured_updates" + else: + error = "already_configured_detailed" + self._abort_if_unique_id_configured( + updates=updates, + error=error, + description_placeholders={ + "title": conflict_entry.title, + "name": conflict_entry.data.get(CONF_DEVICE_NAME, "unknown"), + "mac": format_mac(conflict_entry.unique_id), + }, + ) + async def async_step_mqtt( self, discovery_info: MqttServiceInfo ) -> ConfigFlowResult: @@ -289,7 +382,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Check if already configured await self.async_set_unique_id(mac_address) - self._abort_if_unique_id_configured( + self._abort_unique_id_configured_with_details( updates={CONF_HOST: self._host, CONF_PORT: self._port} ) @@ -299,8 +392,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" - await self.async_set_unique_id(format_mac(discovery_info.macaddress)) - self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + mac_address = format_mac(discovery_info.macaddress) + await self.async_set_unique_id(format_mac(mac_address)) + await self._async_validate_mac_abort_configured( + mac_address, discovery_info.ip, None + ) # This should never happen since we only listen to DHCP requests # for configured devices. return self.async_abort(reason="already_configured") @@ -317,9 +413,84 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="service_received") + async def async_step_name_conflict( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle name conflict resolution.""" + assert self._entry_with_name_conflict is not None + assert self._entry_with_name_conflict.unique_id is not None + assert self.unique_id is not None + assert self._device_name is not None + return self.async_show_menu( + step_id="name_conflict", + menu_options=["name_conflict_migrate", "name_conflict_overwrite"], + description_placeholders={ + "existing_mac": format_mac(self._entry_with_name_conflict.unique_id), + "existing_title": self._entry_with_name_conflict.title, + "mac": format_mac(self.unique_id), + "name": self._device_name, + }, + ) + + async def async_step_name_conflict_migrate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle migration of existing entry.""" + assert self._entry_with_name_conflict is not None + assert self._entry_with_name_conflict.unique_id is not None + assert self.unique_id is not None + assert self._device_name is not None + assert self._host is not None + old_mac = format_mac(self._entry_with_name_conflict.unique_id) + new_mac = format_mac(self.unique_id) + entry_id = self._entry_with_name_conflict.entry_id + self.hass.config_entries.async_update_entry( + self._entry_with_name_conflict, + data={ + **self._entry_with_name_conflict.data, + CONF_HOST: self._host, + CONF_PORT: self._port or DEFAULT_PORT, + CONF_PASSWORD: self._password or "", + CONF_NOISE_PSK: self._noise_psk or "", + }, + ) + await async_replace_device(self.hass, entry_id, old_mac, new_mac) + self.hass.config_entries.async_schedule_reload(entry_id) + return self.async_abort( + reason="name_conflict_migrated", + description_placeholders={ + "existing_mac": old_mac, + "mac": new_mac, + "name": self._device_name, + }, + ) + + async def async_step_name_conflict_overwrite( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle creating a new entry by removing the old one and creating new.""" + assert self._entry_with_name_conflict is not None + await self.hass.config_entries.async_remove( + self._entry_with_name_conflict.entry_id + ) + return self._async_create_entry() + @callback - def _async_get_entry(self) -> ConfigFlowResult: - config_data = { + def _async_create_entry(self) -> ConfigFlowResult: + """Create the config entry.""" + assert self._name is not None + return self.async_create_entry( + title=self._name, + data=self._async_make_config_data(), + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + }, + ) + + @callback + def _async_make_config_data(self) -> dict[str, Any]: + """Return config data for the entry.""" + return { CONF_HOST: self._host, CONF_PORT: self._port, # The API uses protobuf, so empty string denotes absence @@ -327,19 +498,99 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): CONF_NOISE_PSK: self._noise_psk or "", CONF_DEVICE_NAME: self._device_name, } - config_options = { - CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, - } - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._reauth_entry, data=self._reauth_entry.data | config_data - ) - assert self._name is not None - return self.async_create_entry( - title=self._name, - data=config_data, - options=config_options, + async def _async_validated_connection(self) -> ConfigFlowResult: + """Handle validated connection.""" + if self.source == SOURCE_RECONFIGURE: + return await self._async_reconfig_validated_connection() + if self.source == SOURCE_REAUTH: + return await self._async_reauth_validated_connection() + for entry in self._async_current_entries(include_ignore=False): + if entry.data.get(CONF_DEVICE_NAME) == self._device_name: + self._entry_with_name_conflict = entry + return await self.async_step_name_conflict() + return self._async_create_entry() + + async def _async_reauth_validated_connection(self) -> ConfigFlowResult: + """Handle reauth validated connection.""" + assert self._reauth_entry.unique_id is not None + if self.unique_id == self._reauth_entry.unique_id: + return self.async_update_reload_and_abort( + self._reauth_entry, + data=self._reauth_entry.data | self._async_make_config_data(), + ) + assert self._host is not None + self._abort_unique_id_configured_with_details( + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NOISE_PSK: self._noise_psk, + } + ) + # Reauth was triggered a while ago, and since than + # a new device resides at the same IP address. + assert self._device_name is not None + return self.async_abort( + reason="reauth_unique_id_changed", + description_placeholders={ + "name": self._reauth_entry.data.get( + CONF_DEVICE_NAME, self._reauth_entry.title + ), + "host": self._host, + "expected_mac": format_mac(self._reauth_entry.unique_id), + "unexpected_mac": format_mac(self.unique_id), + "unexpected_device_name": self._device_name, + }, + ) + + async def _async_reconfig_validated_connection(self) -> ConfigFlowResult: + """Handle reconfigure validated connection.""" + assert self._reconfig_entry.unique_id is not None + assert self._host is not None + assert self._device_name is not None + if not ( + unique_id_matches := (self.unique_id == self._reconfig_entry.unique_id) + ): + self._abort_unique_id_configured_with_details( + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NOISE_PSK: self._noise_psk, + } + ) + for entry in self._async_current_entries(include_ignore=False): + if ( + entry.entry_id != self._reconfig_entry.entry_id + and entry.data.get(CONF_DEVICE_NAME) == self._device_name + ): + return self.async_abort( + reason="reconfigure_name_conflict", + description_placeholders={ + "name": self._reconfig_entry.data[CONF_DEVICE_NAME], + "host": self._host, + "expected_mac": format_mac(self._reconfig_entry.unique_id), + "existing_title": entry.title, + }, + ) + if unique_id_matches: + return self.async_update_reload_and_abort( + self._reconfig_entry, + data=self._reconfig_entry.data | self._async_make_config_data(), + ) + if self._reconfig_entry.data.get(CONF_DEVICE_NAME) == self._device_name: + self._entry_with_name_conflict = self._reconfig_entry + return await self.async_step_name_conflict() + return self.async_abort( + reason="reconfigure_unique_id_changed", + description_placeholders={ + "name": self._reconfig_entry.data.get( + CONF_DEVICE_NAME, self._reconfig_entry.title + ), + "host": self._host, + "expected_mac": format_mac(self._reconfig_entry.unique_id), + "unexpected_mac": format_mac(self.unique_id), + "unexpected_device_name": self._device_name, + }, ) async def async_step_encryption_key( @@ -358,9 +609,30 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="encryption_key", data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), errors=errors, - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, ) + @callback + def _async_get_human_readable_name(self) -> str: + """Return a human readable name for the entry.""" + entry: ConfigEntry | None = None + if self.source == SOURCE_REAUTH: + entry = self._reauth_entry + elif self.source == SOURCE_RECONFIGURE: + entry = self._reconfig_entry + friendly_name = self._name + device_name = self._device_name + if ( + device_name + and friendly_name in (DEFAULT_NAME, device_name) + and entry + and entry.title != friendly_name + ): + friendly_name = entry.title + if not device_name or friendly_name == device_name: + return friendly_name + return f"{friendly_name} ({device_name})" + async def async_step_authenticate( self, user_input: dict[str, Any] | None = None, error: str | None = None ) -> ConfigFlowResult: @@ -370,7 +642,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): error = await self.try_login() if error: return await self.async_step_authenticate(error=error) - return self._async_get_entry() + return await self._async_validated_connection() errors = {} if error is not None: @@ -379,23 +651,22 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="authenticate", data_schema=vol.Schema({vol.Required("password"): str}), - description_placeholders={"name": self._name}, + description_placeholders={"name": self._async_get_human_readable_name()}, errors=errors, ) - async def fetch_device_info(self) -> str | None: + async def _fetch_device_info( + self, host: str, port: int | None, noise_psk: str | None + ) -> str | None: """Fetch device info from API and return any errors.""" zeroconf_instance = await zeroconf.async_get_instance(self.hass) - assert self._host is not None - assert self._port is not None cli = APIClient( - self._host, - self._port, + host, + port or DEFAULT_PORT, "", zeroconf_instance=zeroconf_instance, - noise_psk=self._noise_psk, + noise_psk=noise_psk, ) - try: await cli.connect() self._device_info = await cli.device_info() @@ -403,8 +674,12 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return ERROR_REQUIRES_ENCRYPTION_KEY except InvalidEncryptionKeyAPIError as ex: if ex.received_name: + device_name_changed = self._device_name != ex.received_name self._device_name = ex.received_name - self._name = ex.received_name + if ex.received_mac: + self._device_mac = format_mac(ex.received_mac) + if not self._name or device_name_changed: + self._name = ex.received_name return ERROR_INVALID_ENCRYPTION_KEY except ResolveAPIError: return "resolve_error" @@ -412,14 +687,29 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return "connection_error" finally: await cli.disconnect(force=True) - - self._name = self._device_info.friendly_name or self._device_info.name + self._device_mac = format_mac(self._device_info.mac_address) self._device_name = self._device_info.name + self._name = self._device_info.friendly_name or self._device_info.name + return None + + async def fetch_device_info(self) -> str | None: + """Fetch device info from API and return any errors.""" + assert self._host is not None + assert self._port is not None + if error := await self._fetch_device_info( + self._host, self._port, self._noise_psk + ): + return error + assert self._device_info is not None mac_address = format_mac(self._device_info.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) - if self.source != SOURCE_REAUTH: - self._abort_if_unique_id_configured( - updates={CONF_HOST: self._host, CONF_PORT: self._port} + if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + self._abort_unique_id_configured_with_details( + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NOISE_PSK: self._noise_psk, + } ) return None @@ -485,7 +775,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: ESPHomeConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index eb5f03c4495..f793fd16bfe 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -1,5 +1,7 @@ """ESPHome constants.""" +from typing import Final + from awesomeversion import AwesomeVersion DOMAIN = "esphome" @@ -8,16 +10,18 @@ CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" CONF_SUBSCRIBE_LOGS = "subscribe_logs" CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" +CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address" DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False +DEFAULT_PORT: Final = 6053 -STABLE_BLE_VERSION_STR = "2025.2.1" +STABLE_BLE_VERSION_STR = "2025.2.2" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", } -DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" - -DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy" +# ESPHome always uses .0 for the changelog URL +STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0" +DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" diff --git a/homeassistant/components/esphome/coordinator.py b/homeassistant/components/esphome/coordinator.py index b31a74dcf3f..99ae6d38a9d 100644 --- a/homeassistant/components/esphome/coordinator.py +++ b/homeassistant/components/esphome/coordinator.py @@ -5,43 +5,38 @@ from __future__ import annotations from datetime import timedelta import logging -import aiohttp from awesomeversion import AwesomeVersion from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") +REFRESH_INTERVAL = timedelta(minutes=5) class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): """Class to interact with the ESPHome dashboard.""" - def __init__( - self, - hass: HomeAssistant, - addon_slug: str, - url: str, - session: aiohttp.ClientSession, - ) -> None: - """Initialize.""" + def __init__(self, hass: HomeAssistant, addon_slug: str, url: str) -> None: + """Initialize the dashboard coordinator.""" super().__init__( hass, _LOGGER, config_entry=None, name="ESPHome Dashboard", - update_interval=timedelta(minutes=5), + update_interval=REFRESH_INTERVAL, always_update=False, ) self.addon_slug = addon_slug self.url = url - self.api = ESPHomeDashboardAPI(url, session) + self.api = ESPHomeDashboardAPI(url, async_get_clientsession(hass)) self.supports_update: bool | None = None - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, ConfiguredDevice]: """Fetch device data.""" devices = await self.api.get_devices() configured_devices = devices["configured"] diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 83c749f89ca..4426724e3f4 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -24,6 +24,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): """A cover implementation for ESPHome.""" diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 290feec1e2a..5f879edf005 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.util.hass_dict import HassKey @@ -60,11 +60,26 @@ class ESPHomeDashboardManager: async def async_setup(self) -> None: """Restore the dashboard from storage.""" self._data = await self._store.async_load() - if (data := self._data) and (info := data.get("info")): - await self.async_set_dashboard_info( - info["addon_slug"], info["host"], info["port"] + if not (data := self._data) or not (info := data.get("info")): + return + if is_hassio(self._hass): + from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel + get_addons_info, ) + if (addons := get_addons_info(self._hass)) is not None and info[ + "addon_slug" + ] not in addons: + # The addon is not installed anymore, but it make come back + # so we don't want to remove the dashboard, but for now + # we don't want to use it. + _LOGGER.debug("Addon %s is no longer installed", info["addon_slug"]) + return + + await self.async_set_dashboard_info( + info["addon_slug"], info["host"], info["port"] + ) + @callback def async_get(self) -> ESPHomeDashboardCoordinator | None: """Get the current dashboard.""" @@ -88,9 +103,7 @@ class ESPHomeDashboardManager: self._cancel_shutdown = None self._current_dashboard = None - dashboard = ESPHomeDashboardCoordinator( - hass, addon_slug, url, async_get_clientsession(hass) - ) + dashboard = ESPHomeDashboardCoordinator(hass, addon_slug, url) await dashboard.async_request_refresh() self._current_dashboard = dashboard diff --git a/homeassistant/components/esphome/date.py b/homeassistant/components/esphome/date.py index 28bc532918a..ef446cceac6 100644 --- a/homeassistant/components/esphome/date.py +++ b/homeassistant/components/esphome/date.py @@ -11,6 +11,8 @@ from homeassistant.components.date import DateEntity from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +PARALLEL_UPDATES = 0 + class EsphomeDate(EsphomeEntity[DateInfo, DateState], DateEntity): """A date implementation for esphome.""" diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py index d1bb0bb77ff..3ea285fa849 100644 --- a/homeassistant/components/esphome/datetime.py +++ b/homeassistant/components/esphome/datetime.py @@ -12,6 +12,8 @@ from homeassistant.util import dt as dt_util from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +PARALLEL_UPDATES = 0 + class EsphomeDateTime(EsphomeEntity[DateTimeInfo, DateTimeState], DateTimeEntity): """A datetime implementation for esphome.""" diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index 58c9a8fe666..c59fca26b90 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -10,12 +10,18 @@ from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from . import CONF_NOISE_PSK +from .const import CONF_DEVICE_NAME from .dashboard import async_get_dashboard from .entry_data import ESPHomeConfigEntry -CONF_MAC_ADDRESS = "mac_address" - -REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, CONF_MAC_ADDRESS} +REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, "mac_address", "bluetooth_mac_address"} +CONFIGURED_DEVICE_KEYS = ( + "configuration", + "current_version", + "deployed_version", + "loaded_integrations", + "target_platform", +) async def async_get_config_entry_diagnostics( @@ -27,13 +33,20 @@ async def async_get_config_entry_diagnostics( diag["config"] = config_entry.as_dict() entry_data = config_entry.runtime_data + device_info = entry_data.device_info + device_name: str | None = ( + device_info.name if device_info else config_entry.data.get(CONF_DEVICE_NAME) + ) if (storage_data := await entry_data.store.async_load()) is not None: diag["storage_data"] = storage_data if ( - config_entry.unique_id - and (scanner := async_scanner_by_source(hass, config_entry.unique_id.upper())) + device_info + and ( + scanner_mac := device_info.bluetooth_mac_address or device_info.mac_address + ) + and (scanner := async_scanner_by_source(hass, scanner_mac.upper())) and (bluetooth_device := entry_data.bluetooth_device) ): diag["bluetooth"] = { @@ -43,7 +56,19 @@ async def async_get_config_entry_diagnostics( "scanner": await scanner.async_diagnostics(), } + diag_dashboard: dict[str, Any] = {"configured": False} + diag["dashboard"] = diag_dashboard if dashboard := async_get_dashboard(hass): - diag["dashboard"] = dashboard.addon_slug + diag_dashboard["configured"] = True + diag_dashboard["supports_update"] = dashboard.supports_update + diag_dashboard["last_update_success"] = dashboard.last_update_success + diag_dashboard["last_exception"] = dashboard.last_exception + diag_dashboard["addon"] = dashboard.addon_slug + if device_name and dashboard.data: + diag_dashboard["has_matching_name"] = device_name in dashboard.data + if data := dashboard.data.get(device_name): + diag_dashboard["device"] = { + key: data.get(key) for key in CONFIGURED_DEVICE_KEYS + } return async_redact_data(diag, REDACT_KEYS) diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index ed307b46fd6..2a323d47a06 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -17,15 +17,12 @@ STORAGE_VERSION = 1 @dataclass(slots=True) class DomainData: - """Define a class that stores global esphome data in hass.data[DOMAIN].""" + """Define a class that stores global esphome data.""" _stores: dict[str, ESPHomeStorage] = field(default_factory=dict) def get_entry_data(self, entry: ESPHomeConfigEntry) -> RuntimeEntryData: - """Return the runtime entry data associated with this config entry. - - Raises KeyError if the entry isn't loaded yet. - """ + """Return the runtime entry data associated with this config entry.""" return entry.runtime_data def get_or_create_store( diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index ff08e5f578a..15ea54422d4 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar, cast from aioesphomeapi import ( APIConnectionError, + DeviceInfo as EsphomeDeviceInfo, EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, @@ -28,6 +29,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import DOMAIN + # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData from .enum_mapper import EsphomeEnumMapper @@ -131,6 +134,22 @@ def esphome_state_property[_R, _EntityT: EsphomeEntity[Any, Any]]( return _wrapper +def async_esphome_state_property[_R, _EntityT: EsphomeEntity[Any, Any]]( + func: Callable[[_EntityT], Awaitable[_R | None]], +) -> Callable[[_EntityT], Coroutine[Any, Any, _R | None]]: + """Wrap a state property of an esphome entity. + + This checks if the state object in the entity is set + and returns None if it is not set. + """ + + @functools.wraps(func) + async def _wrapper(self: _EntityT) -> _R | None: + return await func(self) if self._has_state else None + + return _wrapper + + def esphome_float_state_property[_EntityT: EsphomeEntity[Any, Any]]( func: Callable[[_EntityT], float | None], ) -> Callable[[_EntityT], float | None]: @@ -153,7 +172,7 @@ def esphome_float_state_property[_EntityT: EsphomeEntity[Any, Any]]( return _wrapper -def convert_api_error_ha_error[**_P, _R, _EntityT: EsphomeEntity[Any, Any]]( +def convert_api_error_ha_error[**_P, _R, _EntityT: EsphomeBaseEntity]( func: Callable[Concatenate[_EntityT, _P], Awaitable[None]], ) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: """Decorate ESPHome command calls that send commands/make changes to the device. @@ -167,7 +186,12 @@ def convert_api_error_ha_error[**_P, _R, _EntityT: EsphomeEntity[Any, Any]]( return await func(self, *args, **kwargs) except APIConnectionError as error: raise HomeAssistantError( - f"Error communicating with device: {error}" + translation_domain=DOMAIN, + translation_key="error_communicating_with_device", + translation_placeholders={ + "device_name": self._device_info.name, + "error": str(error), + }, ) from error return handler @@ -187,10 +211,18 @@ ENTITY_CATEGORIES: EsphomeEnumMapper[EsphomeEntityCategory, EntityCategory | Non ) -class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): +class EsphomeBaseEntity(Entity): """Define a base esphome entity.""" + _attr_has_entity_name = True _attr_should_poll = False + _device_info: EsphomeDeviceInfo + device_entry: dr.DeviceEntry + + +class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): + """Define an esphome entity.""" + _static_info: _InfoT _state: _StateT _has_state: bool @@ -207,7 +239,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._states = cast(dict[int, _StateT], entry_data.state[state_type]) assert entry_data.device_info is not None device_info = entry_data.device_info - self._device_info = device_info self._on_entry_data_changed() self._key = entity_info.key self._state_type = state_type @@ -215,25 +246,16 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) - # - # If `friendly_name` is set, we use the Friendly naming rules, if - # `friendly_name` is not set we make an exception to the naming rules for - # backwards compatibility and use the Legacy naming rules. - # - # Friendly naming - # - Friendly name is prepended to entity names - # - Device Name is prepended to entity ids - # - Entity id is constructed from device name and object id - # - # Legacy naming - # - Device name is not prepended to entity names - # - Device name is not prepended to entity ids - # - Entity id is constructed from entity name - # - if not device_info.friendly_name: - return - self._attr_has_entity_name = True - self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" + if entity_info.name: + self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" + else: + # https://github.com/home-assistant/core/issues/132532 + # If name is not set, ESPHome will use the sanitized friendly name + # as the name, however we want to use the original object_id + # as the entity_id before it is sanitized since the sanitizer + # is not utf-8 aware. In this case, its always going to be + # an empty string so we drop the object_id. + self.entity_id = f"{domain}.{device_info.name}" async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -269,7 +291,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._static_info = static_info self._attr_unique_id = build_unique_id(device_info.mac_address, static_info) self._attr_entity_registry_enabled_default = not static_info.disabled_by_default - self._attr_name = static_info.name + # https://github.com/home-assistant/core/issues/132532 + # If the name is "", we need to set it to None since otherwise + # the friendly_name will be "{friendly_name} " with a trailing + # space. ESPHome uses protobuf under the hood, and an empty field + # gets a default value of "". + self._attr_name = static_info.name if static_info.name else None if entity_category := static_info.entity_category: self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category) else: @@ -299,6 +326,11 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): @callback def _on_entry_data_changed(self) -> None: entry_data = self._entry_data + # Update the device info since it can change + # when the device is reconnected + if TYPE_CHECKING: + assert entry_data.device_info is not None + self._device_info = entry_data.device_info self._api_version = entry_data.api_version self._client = entry_data.client if self._device_info.has_deep_sleep: @@ -320,15 +352,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self.async_write_ha_state() -class EsphomeAssistEntity(Entity): +class EsphomeAssistEntity(EsphomeBaseEntity): """Define a base entity for Assist Pipeline entities.""" - _attr_has_entity_name = True - _attr_should_poll = False - def __init__(self, entry_data: RuntimeEntryData) -> None: """Initialize the binary sensor.""" - self._entry_data: RuntimeEntryData = entry_data + self._entry_data = entry_data assert entry_data.device_info is not None device_info = entry_data.device_info self._device_info = device_info diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index fc41ee99a00..1e6375d8caf 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass, field from functools import partial import logging +from operator import delitem from typing import TYPE_CHECKING, Any, Final, TypedDict, cast from aioesphomeapi import ( @@ -183,18 +184,7 @@ class RuntimeEntryData: """Register to receive callbacks when static info changes for an EntityInfo type.""" callbacks = self.entity_info_callbacks.setdefault(entity_info_type, []) callbacks.append(callback_) - return partial( - self._async_unsubscribe_register_static_info, callbacks, callback_ - ) - - @callback - def _async_unsubscribe_register_static_info( - self, - callbacks: list[Callable[[list[EntityInfo]], None]], - callback_: Callable[[list[EntityInfo]], None], - ) -> None: - """Unsubscribe to when static info is registered.""" - callbacks.remove(callback_) + return partial(callbacks.remove, callback_) @callback def async_register_key_static_info_updated_callback( @@ -206,18 +196,7 @@ class RuntimeEntryData: callback_key = (type(static_info), static_info.key) callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, []) callbacks.append(callback_) - return partial( - self._async_unsubscribe_static_key_info_updated, callbacks, callback_ - ) - - @callback - def _async_unsubscribe_static_key_info_updated( - self, - callbacks: list[Callable[[EntityInfo], None]], - callback_: Callable[[EntityInfo], None], - ) -> None: - """Unsubscribe to when static info is updated .""" - callbacks.remove(callback_) + return partial(callbacks.remove, callback_) @callback def async_set_assist_pipeline_state(self, state: bool) -> None: @@ -232,14 +211,7 @@ class RuntimeEntryData: ) -> CALLBACK_TYPE: """Subscribe to assist pipeline updates.""" self.assist_pipeline_update_callbacks.append(update_callback) - return partial(self._async_unsubscribe_assist_pipeline_update, update_callback) - - @callback - def _async_unsubscribe_assist_pipeline_update( - self, update_callback: CALLBACK_TYPE - ) -> None: - """Unsubscribe to assist pipeline updates.""" - self.assist_pipeline_update_callbacks.remove(update_callback) + return partial(self.assist_pipeline_update_callbacks.remove, update_callback) @callback def async_remove_entities( @@ -282,15 +254,18 @@ class RuntimeEntryData: ) -> None: """Distribute an update of static infos to all platforms.""" # First, load all platforms - needed_platforms = set() - if async_get_dashboard(hass): - needed_platforms.add(Platform.UPDATE) + needed_platforms: set[Platform] = set() - if self.device_info and self.device_info.voice_assistant_feature_flags_compat( - self.api_version - ): - needed_platforms.add(Platform.BINARY_SENSOR) - needed_platforms.add(Platform.SELECT) + if self.device_info: + if async_get_dashboard(hass): + # Only load the update platform if the device_info is set + # When we restore the entry, the device_info may not be set yet + # and we don't want to load the update platform since it needs + # a complete device_info. + needed_platforms.add(Platform.UPDATE) + if self.device_info.voice_assistant_feature_flags_compat(self.api_version): + needed_platforms.add(Platform.BINARY_SENSOR) + needed_platforms.add(Platform.SELECT) ent_reg = er.async_get(hass) registry_get_entity = ent_reg.async_get_entity_id @@ -312,18 +287,19 @@ class RuntimeEntryData: # Make a dict of the EntityInfo by type and send # them to the listeners for each specific EntityInfo type - infos_by_type: dict[type[EntityInfo], list[EntityInfo]] = {} + infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict( + list + ) for info in infos: - info_type = type(info) - if info_type not in infos_by_type: - infos_by_type[info_type] = [] - infos_by_type[info_type].append(info) + infos_by_type[type(info)].append(info) - callbacks_by_type = self.entity_info_callbacks - for type_, entity_infos in infos_by_type.items(): - if callbacks_ := callbacks_by_type.get(type_): - for callback_ in callbacks_: - callback_(entity_infos) + for type_, callbacks in self.entity_info_callbacks.items(): + # If all entities for a type are removed, we + # still need to call the callbacks with an empty list + # to make sure the entities are removed. + entity_infos = infos_by_type.get(type_, []) + for callback_ in callbacks: + callback_(entity_infos) # Finally update static info subscriptions for callback_ in self.static_info_update_subscriptions: @@ -333,12 +309,7 @@ class RuntimeEntryData: def async_subscribe_device_updated(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE: """Subscribe to state updates.""" self.device_update_subscriptions.add(callback_) - return partial(self._async_unsubscribe_device_update, callback_) - - @callback - def _async_unsubscribe_device_update(self, callback_: CALLBACK_TYPE) -> None: - """Unsubscribe to device updates.""" - self.device_update_subscriptions.remove(callback_) + return partial(self.device_update_subscriptions.remove, callback_) @callback def async_subscribe_static_info_updated( @@ -346,14 +317,7 @@ class RuntimeEntryData: ) -> CALLBACK_TYPE: """Subscribe to static info updates.""" self.static_info_update_subscriptions.add(callback_) - return partial(self._async_unsubscribe_static_info_updated, callback_) - - @callback - def _async_unsubscribe_static_info_updated( - self, callback_: Callable[[list[EntityInfo]], None] - ) -> None: - """Unsubscribe to static info updates.""" - self.static_info_update_subscriptions.remove(callback_) + return partial(self.static_info_update_subscriptions.remove, callback_) @callback def async_subscribe_state_update( @@ -365,14 +329,7 @@ class RuntimeEntryData: """Subscribe to state updates.""" subscription_key = (state_type, state_key) self.state_subscriptions[subscription_key] = entity_callback - return partial(self._async_unsubscribe_state_update, subscription_key) - - @callback - def _async_unsubscribe_state_update( - self, subscription_key: tuple[type[EntityState], int] - ) -> None: - """Unsubscribe to state updates.""" - self.state_subscriptions.pop(subscription_key) + return partial(delitem, self.state_subscriptions, subscription_key) @callback def async_update_state(self, state: EntityState) -> None: @@ -519,7 +476,7 @@ class RuntimeEntryData: ) -> 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_) + return partial(self.assist_satellite_config_update_callbacks.remove, callback_) @callback def async_assist_satellite_config_updated( @@ -536,7 +493,7 @@ class RuntimeEntryData: ) -> 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_) + return partial(self.assist_satellite_set_wake_word_callbacks.remove, callback_) @callback def async_assist_satellite_set_wake_word(self, wake_word_id: str) -> None: diff --git a/homeassistant/components/esphome/event.py b/homeassistant/components/esphome/event.py index 11a5d0cfb33..4437292c5b4 100644 --- a/homeassistant/components/esphome/event.py +++ b/homeassistant/components/esphome/event.py @@ -12,6 +12,8 @@ from homeassistant.util.enum import try_parse_enum from .entity import EsphomeEntity, platform_async_setup_entry +PARALLEL_UPDATES = 0 + class EsphomeEvent(EsphomeEntity[EventInfo, Event], EventEntity): """An event implementation for ESPHome.""" @@ -33,6 +35,16 @@ class EsphomeEvent(EsphomeEntity[EventInfo, Event], EventEntity): self._trigger_event(self._state.event_type) self.async_write_ha_state() + @callback + def _on_device_update(self) -> None: + """Call when device updates or entry data changes.""" + super()._on_device_update() + if self._entry_data.available: + # Event entities should go available directly + # when the device comes online and not wait + # for the next data push. + self.async_write_ha_state() + async_setup_entry = partial( platform_async_setup_entry, diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index c09145c17b5..a4d840845a6 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -30,6 +30,8 @@ from .entity import ( ) from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH] @@ -61,7 +63,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): if self._supports_speed_levels: data["speed_level"] = math.ceil( percentage_to_ranged_value( - (1, self._static_info.supported_speed_levels), percentage + (1, self._static_info.supported_speed_count), percentage ) ) else: @@ -104,7 +106,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): @property @esphome_state_property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return true if the entity is on.""" return self._state.state @@ -119,12 +121,12 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): ) return ranged_value_to_percentage( - (1, self._static_info.supported_speed_levels), self._state.speed_level + (1, self._static_info.supported_speed_count), self._state.speed_level ) @property @esphome_state_property - def oscillating(self) -> bool | None: + def oscillating(self) -> bool: """Return the oscillation state.""" return self._state.oscillating @@ -136,7 +138,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): @property @esphome_state_property - def preset_mode(self) -> str | None: + def preset_mode(self) -> str: """Return the current fan preset mode.""" return self._state.preset_mode @@ -162,7 +164,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): if not supports_speed_levels: self._attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) else: - self._attr_speed_count = static_info.supported_speed_levels + self._attr_speed_count = static_info.supported_speed_count async_setup_entry = partial( diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py index 9484d1e7593..b57a6762148 100644 --- a/homeassistant/components/esphome/ffmpeg_proxy.py +++ b/homeassistant/components/esphome/ffmpeg_proxy.py @@ -11,17 +11,20 @@ from typing import Final from aiohttp import web from aiohttp.abc import AbstractStreamWriter, BaseRequest +from homeassistant.components import ffmpeg from homeassistant.components.ffmpeg import FFmpegManager from homeassistant.components.http import HomeAssistantView -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.util.hass_dict import HassKey -from .const import DATA_FFMPEG_PROXY +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) _MAX_CONVERSIONS_PER_DEVICE: Final[int] = 2 +@callback def async_create_proxy_url( hass: HomeAssistant, device_id: str, @@ -32,7 +35,7 @@ def async_create_proxy_url( width: int | None = None, ) -> str: """Create a use proxy URL that automatically converts the media.""" - data: FFmpegProxyData = hass.data[DATA_FFMPEG_PROXY] + data = hass.data[DATA_FFMPEG_PROXY] return data.async_create_proxy_url( device_id, media_url, media_format, rate, channels, width ) @@ -313,3 +316,16 @@ class FFmpegProxyView(HomeAssistantView): assert writer is not None await resp.transcode(request, writer) return resp + + +DATA_FFMPEG_PROXY: HassKey[FFmpegProxyData] = HassKey(f"{DOMAIN}.ffmpeg_proxy") + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the ffmpeg proxy.""" + proxy_data = FFmpegProxyData() + hass.data[DATA_FFMPEG_PROXY] = proxy_data + hass.http.register_view( + FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data) + ) diff --git a/homeassistant/components/esphome/icons.json b/homeassistant/components/esphome/icons.json new file mode 100644 index 00000000000..fc0595b028e --- /dev/null +++ b/homeassistant/components/esphome/icons.json @@ -0,0 +1,20 @@ +{ + "entity": { + "binary_sensor": { + "assist_in_progress": { + "default": "mdi:timer-sand" + } + }, + "select": { + "pipeline": { + "default": "mdi:filter-outline" + }, + "vad_sensitivity": { + "default": "mdi:volume-high" + }, + "wake_word": { + "default": "mdi:microphone" + } + } + } +} diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 8fecf34862b..3e278b5b2d6 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -3,10 +3,12 @@ from __future__ import annotations from functools import lru_cache, partial +from operator import methodcaller from typing import TYPE_CHECKING, Any, cast from aioesphomeapi import ( APIVersion, + ColorMode as ESPHomeColorMode, EntityInfo, LightColorCapability, LightInfo, @@ -38,6 +40,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} @@ -103,15 +107,15 @@ def _mired_to_kelvin(mired_temperature: float) -> int: @lru_cache -def _color_mode_to_ha(mode: int) -> str: +def _color_mode_to_ha(mode: ESPHomeColorMode) -> ColorMode: """Convert an esphome color mode to a HA color mode constant. - Choses the color mode that best matches the feature-set. + Choose the color mode that best matches the feature-set. """ - candidates = [] + candidates: list[tuple[ColorMode, LightColorCapability]] = [] for ha_mode, cap_lists in _COLOR_MODE_MAPPING.items(): for caps in cap_lists: - if caps == mode: + if caps.value == mode: # exact match return ha_mode if (mode & caps) == caps: @@ -128,8 +132,8 @@ def _color_mode_to_ha(mode: int) -> str: @lru_cache def _filter_color_modes( - supported: list[int], features: LightColorCapability -) -> tuple[int, ...]: + supported: list[ESPHomeColorMode], features: LightColorCapability +) -> tuple[ESPHomeColorMode, ...]: """Filter the given supported color modes. Excluding all values that don't have the requested features. @@ -146,19 +150,19 @@ def _least_complex_color_mode(color_modes: tuple[int, ...]) -> int: # popcount with bin() function because it appears # to be the best way: https://stackoverflow.com/a/9831671 color_modes_list = list(color_modes) - color_modes_list.sort(key=lambda mode: (mode).bit_count()) + color_modes_list.sort(key=methodcaller("bit_count")) return color_modes_list[0] class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """A light implementation for ESPHome.""" - _native_supported_color_modes: tuple[int, ...] + _native_supported_color_modes: tuple[ESPHomeColorMode, ...] _supports_color_mode = False @property @esphome_state_property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return true if the light is on.""" return self._state.state @@ -290,13 +294,13 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property @esphome_state_property - def brightness(self) -> int | None: + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return round(self._state.brightness * 255) @property @esphome_state_property - def color_mode(self) -> str | None: + def color_mode(self) -> str: """Return the color mode of the light.""" if not self._supports_color_mode: supported_color_modes = self.supported_color_modes @@ -308,7 +312,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property @esphome_state_property - def rgb_color(self) -> tuple[int, int, int] | None: + def rgb_color(self) -> tuple[int, int, int]: """Return the rgb color value [int, int, int].""" state = self._state if not self._supports_color_mode: @@ -326,7 +330,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property @esphome_state_property - def rgbw_color(self) -> tuple[int, int, int, int] | None: + def rgbw_color(self) -> tuple[int, int, int, int]: """Return the rgbw color value [int, int, int, int].""" white = round(self._state.white * 255) rgb = cast("tuple[int, int, int]", self.rgb_color) @@ -334,7 +338,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property @esphome_state_property - def rgbww_color(self) -> tuple[int, int, int, int, int] | None: + def rgbww_color(self) -> tuple[int, int, int, int, int]: """Return the rgbww color value [int, int, int, int, int].""" state = self._state rgb = cast("tuple[int, int, int]", self.rgb_color) @@ -370,7 +374,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): @property @esphome_state_property - def effect(self) -> str | None: + def effect(self) -> str: """Return the current effect.""" return self._state.effect diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index 502cd361277..cfb9af614dd 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -18,6 +18,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): """A lock implementation for ESPHome.""" @@ -38,25 +40,25 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): @property @esphome_state_property - def is_locked(self) -> bool | None: + def is_locked(self) -> bool: """Return true if the lock is locked.""" return self._state.state is LockState.LOCKED @property @esphome_state_property - def is_locking(self) -> bool | None: + def is_locking(self) -> bool: """Return true if the lock is locking.""" return self._state.state is LockState.LOCKING @property @esphome_state_property - def is_unlocking(self) -> bool | None: + def is_unlocking(self) -> bool: """Return true if the lock is unlocking.""" return self._state.state is LockState.UNLOCKING @property @esphome_state_property - def is_jammed(self) -> bool | None: + def is_jammed(self) -> bool: """Return true if the lock is jammed (incomplete locking).""" return self._state.state is LockState.JAMMED diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index e32bb7d6ded..1b0e4fc8986 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -13,6 +13,7 @@ from aioesphomeapi import ( APIConnectionError, APIVersion, DeviceInfo as EsphomeDeviceInfo, + EncryptionPlaintextAPIError, EntityInfo, HomeassistantServiceCall, InvalidAuthAPIError, @@ -43,10 +44,12 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, + entity_registry as er, + issue_registry as ir, template, ) from homeassistant.helpers.device_registry import format_mac @@ -63,6 +66,7 @@ from homeassistant.util.async_ import create_eager_task from .bluetooth import async_connect_scanner from .const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, @@ -78,6 +82,8 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}" + if TYPE_CHECKING: from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] SubscribeLogsResponse, @@ -210,7 +216,7 @@ class ESPHomeManager: async def on_stop(self, event: Event) -> None: """Cleanup the socket client on HA close.""" - await cleanup_instance(self.hass, self.entry) + await cleanup_instance(self.entry) @property def services_issue(self) -> str: @@ -371,7 +377,7 @@ class ESPHomeManager: async def on_connect(self) -> None: """Subscribe to states and list entities on successful API login.""" try: - await self._on_connnect() + await self._on_connect() except APIConnectionError as err: _LOGGER.warning( "Error getting setting up connection for %s: %s", self.host, err @@ -407,7 +413,7 @@ class ESPHomeManager: self._async_on_log, self._log_level ) - async def _on_connnect(self) -> None: + async def _on_connect(self) -> None: """Subscribe to states and list entities on successful API login.""" entry = self.entry unique_id = entry.unique_id @@ -416,7 +422,7 @@ class ESPHomeManager: assert reconnect_logic is not None, "Reconnect logic must be set" hass = self.hass cli = self.cli - stored_device_name = entry.data.get(CONF_DEVICE_NAME) + stored_device_name: str | None = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id if entry.options.get(CONF_SUBSCRIBE_LOGS): self._async_subscribe_logs(self._async_get_equivalent_log_level()) @@ -431,6 +437,13 @@ class ESPHomeManager: device_mac = format_mac(device_info.mac_address) mac_address_matches = unique_id == device_mac + if ( + bluetooth_mac_address := device_info.bluetooth_mac_address + ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address}, + ) # # Migrate config entry to new unique ID if the current # unique id is not a mac address. @@ -439,12 +452,36 @@ class ESPHomeManager: if not mac_address_matches and not unique_id_is_mac_address: hass.config_entries.async_update_entry(entry, unique_id=device_mac) + issue = DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) if not mac_address_matches and unique_id_is_mac_address: # If the unique id is a mac address # and does not match we have the wrong device and we need # to abort the connection. This can happen if the DHCP # server changes the IP address of the device and we end up # connecting to the wrong device. + if stored_device_name == device_info.name: + # If the device name matches it might be a device replacement + # or they made a mistake and flashed the same firmware on + # multiple devices. In this case we start a repair flow + # to ask them if its a mistake, or if they want to migrate + # the config entry to the replacement hardware. + shared_data = { + "name": device_info.name, + "mac": format_mac(device_mac), + "stored_mac": format_mac(unique_id), + "model": device_info.model, + "ip": self.host, + } + async_create_issue( + hass, + DOMAIN, + issue, + is_fixable=True, + severity=IssueSeverity.ERROR, + translation_key="device_conflict", + translation_placeholders=shared_data, + data={**shared_data, "entry_id": entry.entry_id}, + ) _LOGGER.error( "Unexpected device found at %s; " "expected `%s` with mac address `%s`, " @@ -466,6 +503,7 @@ class ESPHomeManager: # flow. return + async_delete_issue(hass, DOMAIN, issue) # Make sure we have the correct device name stored # so we can map the device to ESPHome Dashboard config # If we got here, we know the mac address matches or we @@ -483,6 +521,15 @@ class ESPHomeManager: if device_info.name: reconnect_logic.name = device_info.name + if not device_info.friendly_name: + _LOGGER.info( + "No `friendly_name` set in the `esphome:` section of the " + "YAML config for device '%s' (MAC: %s); It's recommended " + "to add one for easier identification and better alignment " + "with Home Assistant naming conventions", + device_info.name, + device_mac, + ) self.device_id = _async_setup_device_registry(hass, entry, entry_data) entry_data.async_update_device_state() @@ -498,7 +545,9 @@ class ESPHomeManager: ) ) else: - bluetooth.async_remove_scanner(hass, device_info.mac_address) + bluetooth.async_remove_scanner( + hass, device_info.bluetooth_mac_address or device_info.mac_address + ) if device_info.voice_assistant_feature_flags_compat(api_version) and ( Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms @@ -557,15 +606,45 @@ class ESPHomeManager: async def on_connect_error(self, err: Exception) -> None: """Start reauth flow if appropriate connect error type.""" - if isinstance( + if not isinstance( err, ( + EncryptionPlaintextAPIError, RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError, InvalidAuthAPIError, ), ): - self.entry.async_start_reauth(self.hass) + return + if isinstance(err, InvalidEncryptionKeyAPIError): + if ( + (received_name := err.received_name) + and (received_mac := err.received_mac) + and (unique_id := self.entry.unique_id) + and ":" in unique_id + ): + formatted_received_mac = format_mac(received_mac) + formatted_expected_mac = format_mac(unique_id) + if formatted_received_mac != formatted_expected_mac: + _LOGGER.error( + "Unexpected device found at %s; " + "expected `%s` with mac address `%s`, " + "found `%s` with mac address `%s`", + self.host, + self.entry.data.get(CONF_DEVICE_NAME), + formatted_expected_mac, + received_name, + formatted_received_mac, + ) + # If the device comes back online, discovery + # will update the config entry with the new IP address + # and reload which will try again to connect to the device. + # In the mean time we stop the reconnect logic + # so we don't keep trying to connect to the wrong device. + if self.reconnect_logic: + await self.reconnect_logic.stop() + return + self.entry.async_start_reauth(self.hass) @callback def _async_handle_logging_changed(self, _event: Event) -> None: @@ -576,6 +655,30 @@ class ESPHomeManager: ): self._async_subscribe_logs(new_log_level) + @callback + def _async_cleanup(self) -> None: + """Cleanup stale issues and entities.""" + assert self.entry_data.device_info is not None + ent_reg = er.async_get(self.hass) + # Cleanup stale assist_in_progress entity and issue, + # Remove this after 2026.4 + if not ( + stale_entry_entity_id := ent_reg.async_get_entity_id( + DOMAIN, + Platform.BINARY_SENSOR, + f"{self.entry_data.device_info.mac_address}-assist_in_progress", + ) + ): + return + stale_entry = ent_reg.async_get(stale_entry_entity_id) + assert stale_entry is not None + ent_reg.async_remove(stale_entry_entity_id) + issue_reg = ir.async_get(self.hass) + if issue := issue_reg.async_get_issue( + DOMAIN, f"assist_in_progress_deprecated_{stale_entry.id}" + ): + issue_reg.async_delete(DOMAIN, issue.issue_id) + async def async_start(self) -> None: """Start the esphome connection manager.""" hass = self.hass @@ -617,11 +720,23 @@ class ESPHomeManager: ) _setup_services(hass, entry_data, services) - if entry_data.device_info is not None and entry_data.device_info.name: - reconnect_logic.name = entry_data.device_info.name + if (device_info := entry_data.device_info) is not None: + self._async_cleanup() + if device_info.name: + reconnect_logic.name = device_info.name + if ( + bluetooth_mac_address := device_info.bluetooth_mac_address + ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address, + }, + ) if entry.unique_id is None: hass.config_entries.async_update_entry( - entry, unique_id=format_mac(entry_data.device_info.mac_address) + entry, unique_id=format_mac(device_info.mac_address) ) await reconnect_logic.start() @@ -676,7 +791,7 @@ def _async_setup_device_registry( config_entry_id=entry.entry_id, configuration_url=configuration_url, connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, - name=entry_data.friendly_name, + name=entry_data.friendly_name or entry_data.name, manufacturer=manufacturer, model=model, sw_version=sw_version, @@ -747,7 +862,18 @@ def execute_service( entry_data: RuntimeEntryData, service: UserService, call: ServiceCall ) -> None: """Execute a service on a node.""" - entry_data.client.execute_service(service, call.data) + try: + entry_data.client.execute_service(service, call.data) + except APIConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_call_failed", + translation_placeholders={ + "call_name": service.name, + "device_name": entry_data.name, + "error": str(err), + }, + ) from err def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str: @@ -839,9 +965,7 @@ def _setup_services( _async_register_service(hass, entry_data, device_info, service) -async def cleanup_instance( - hass: HomeAssistant, entry: ESPHomeConfigEntry -) -> RuntimeEntryData: +async def cleanup_instance(entry: ESPHomeConfigEntry) -> RuntimeEntryData: """Cleanup the esphome client if it exists.""" data = entry.runtime_data data.async_on_disconnect() @@ -850,3 +974,40 @@ async def cleanup_instance( await data.async_cleanup() await data.client.disconnect() return data + + +async def async_replace_device( + hass: HomeAssistant, + entry_id: str, + old_mac: str, # will be lower case (format_mac) + new_mac: str, # will be lower case (format_mac) +) -> None: + """Migrate an ESPHome entry to replace an existing device.""" + entry = hass.config_entries.async_get_entry(entry_id) + assert entry is not None + hass.config_entries.async_update_entry(entry, unique_id=new_mac) + + dev_reg = dr.async_get(hass) + for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): + dev_reg.async_update_device( + device.id, + new_connections={(dr.CONNECTION_NETWORK_MAC, new_mac)}, + ) + + ent_reg = er.async_get(hass) + upper_mac = new_mac.upper() + old_upper_mac = old_mac.upper() + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id): + # -- + old_unique_id = entity.unique_id.split("-") + new_unique_id = "-".join([upper_mac, *old_unique_id[1:]]) + if entity.unique_id != new_unique_id and entity.unique_id.startswith( + old_upper_mac + ): + ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id) + + domain_data = DomainData.get(hass) + store = domain_data.get_or_create_store(hass, entry) + if data := await store.async_load(): + data["device_info"]["mac_address"] = upper_mac + await store.async_save(data) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b59dd544c49..d5faacfd1b0 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -1,7 +1,7 @@ { "domain": "esphome", "name": "ESPHome", - "after_dependencies": ["zeroconf", "tag"], + "after_dependencies": ["hassio", "zeroconf", "tag"], "codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"], @@ -15,10 +15,11 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], + "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==29.2.0", - "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.7.1" + "aioesphomeapi==31.1.0", + "esphome-dashboard-api==1.3.0", + "bleak-esphome==2.15.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 8a30814aa2c..3af6c0b2049 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -41,6 +41,8 @@ from .entity import ( from .enum_mapper import EsphomeEnumMapper from .ffmpeg_proxy import async_create_proxy_url +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumMapper( @@ -94,7 +96,7 @@ class EsphomeMediaPlayer( @property @esphome_float_state_property - def volume_level(self) -> float | None: + def volume_level(self) -> float: """Volume level of the media player (0..1).""" return self._state.volume @@ -146,10 +148,6 @@ class EsphomeMediaPlayer( announcement: bool, ) -> str | None: """Get URL for ffmpeg proxy.""" - if self.device_entry is None: - # Device id is required - return None - # Choose the first default or announcement supported format format_to_use: MediaPlayerSupportedFormat | None = None for supported_format in supported_formats: diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 2d74dad1bcf..4a6800e1041 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -23,6 +23,8 @@ from .entity import ( ) from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + NUMBER_MODES: EsphomeEnumMapper[EsphomeNumberMode, NumberMode] = EsphomeEnumMapper( { EsphomeNumberMode.AUTO: NumberMode.AUTO, diff --git a/homeassistant/components/esphome/quality_scale.yaml b/homeassistant/components/esphome/quality_scale.yaml new file mode 100644 index 00000000000..9af63cfbb3e --- /dev/null +++ b/homeassistant/components/esphome/quality_scale.yaml @@ -0,0 +1,85 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + Since actions are defined per device, rather than per integration, + they are specific to the device's YAML configuration. Additionally, + ESPHome allows for user-defined actions, making it impossible to + set them up until the device is connected as they vary by device. For more + information, see: https://esphome.io/components/api.html#user-defined-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: | + Since actions are defined per device, rather than per integration, + they are specific to the device's YAML configuration. Additionally, + ESPHome allows for user-defined actions, making it difficult to provide + standard documentation since these actions vary by device. For more + information, see: https://esphome.io/components/api.html#user-defined-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: + status: exempt + comment: | + ESPHome relies on sleepy devices and fast reconnect logic, so we + can't raise `ConfigEntryNotReady`. Instead, we need to utilize the + reconnect logic in `aioesphomeapi` to determine the right moment + to trigger the connection. + 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: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: + status: exempt + comment: | + Since ESPHome is a framework for creating custom devices, the + possibilities are virtually limitless. As a result, example + automations would likely only be relevant to the specific user + of the device and not generally useful to others. + 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: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/esphome/repairs.py b/homeassistant/components/esphome/repairs.py index 31e4b88c689..3cba8730cd6 100644 --- a/homeassistant/components/esphome/repairs.py +++ b/homeassistant/components/esphome/repairs.py @@ -2,11 +2,92 @@ from __future__ import annotations -from homeassistant.components.assist_pipeline.repair_flows import ( - AssistInProgressDeprecatedRepairFlow, -) +from typing import cast + +import voluptuous as vol + +from homeassistant import data_entry_flow from homeassistant.components.repairs import RepairsFlow -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .manager import async_replace_device + + +class ESPHomeRepair(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict[str, str | int | float | None] | None) -> None: + """Initialize.""" + self._data = data + super().__init__() + + @callback + def _async_get_placeholders(self) -> dict[str, str]: + issue_registry = ir.async_get(self.hass) + issue = issue_registry.async_get_issue(self.handler, self.issue_id) + assert issue is not None + return issue.translation_placeholders or {} + + +class DeviceConflictRepair(ESPHomeRepair): + """Handler for an issue fixing device conflict.""" + + @property + def entry_id(self) -> str: + """Return the config entry id.""" + assert isinstance(self._data, dict) + return cast(str, self._data["entry_id"]) + + @property + def mac(self) -> str: + """Return the MAC address of the new device.""" + assert isinstance(self._data, dict) + return cast(str, self._data["mac"]) + + @property + def stored_mac(self) -> str: + """Return the MAC address of the stored device.""" + assert isinstance(self._data, dict) + return cast(str, self._data["stored_mac"]) + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return self.async_show_menu( + step_id="init", + menu_options=["migrate", "manual"], + description_placeholders=self._async_get_placeholders(), + ) + + async def async_step_migrate( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the migrate step of a fix flow.""" + if user_input is None: + return self.async_show_form( + step_id="migrate", + data_schema=vol.Schema({}), + description_placeholders=self._async_get_placeholders(), + ) + entry_id = self.entry_id + await async_replace_device(self.hass, entry_id, self.stored_mac, self.mac) + self.hass.config_entries.async_schedule_reload(entry_id) + return self.async_create_entry(data={}) + + async def async_step_manual( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the manual step of a fix flow.""" + if user_input is None: + return self.async_show_form( + step_id="manual", + data_schema=vol.Schema({}), + description_placeholders=self._async_get_placeholders(), + ) + self.hass.config_entries.async_schedule_reload(self.entry_id) + return self.async_create_entry(data={}) async def async_create_fix_flow( @@ -15,8 +96,8 @@ async def async_create_fix_flow( data: dict[str, str | int | float | None] | None, ) -> RepairsFlow: """Create flow.""" - if issue_id.startswith("assist_in_progress_deprecated"): - return AssistInProgressDeprecatedRepairFlow(data) + if issue_id.startswith("device_conflict"): + return DeviceConflictRepair(data) # If ESPHome adds confirm-only repairs in the future, this should be changed # to return a ConfirmRepairFlow instead of raising a ValueError raise ValueError(f"unknown repair {issue_id}") diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 67bcbbbd221..d5451f69f0f 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -25,6 +25,8 @@ from .entity import ( ) from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -50,7 +52,7 @@ async def async_setup_entry( [ EsphomeAssistPipelineSelect(hass, entry_data), EsphomeVadSensitivitySelect(hass, entry_data), - EsphomeAssistSatelliteWakeWordSelect(hass, entry_data), + EsphomeAssistSatelliteWakeWordSelect(entry_data), ] ) @@ -105,11 +107,10 @@ class EsphomeAssistSatelliteWakeWordSelect( 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: + def __init__(self, entry_data: RuntimeEntryData) -> None: """Initialize a wake word selector.""" EsphomeAssistEntity.__init__(self, entry_data) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 26f33f4fb47..5baa092613b 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -20,19 +20,21 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from .entity import EsphomeEntity, platform_async_setup_entry +from .entry_data import ESPHomeConfigEntry from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ESPHomeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up esphome sensors based on a config entry.""" @@ -86,9 +88,9 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): return if ( state_class == EsphomeSensorStateClass.MEASUREMENT - and static_info.last_reset_type == LastResetType.AUTO + and static_info.legacy_last_reset_type == LastResetType.AUTO ): - # Legacy, last_reset_type auto was the equivalent to the + # Legacy, legacy_last_reset_type auto was the equivalent to the # TOTAL_INCREASING state class self._attr_state_class = SensorStateClass.TOTAL_INCREASING else: diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 1534a49e365..eab88e8df95 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -2,20 +2,29 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured_detailed": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`.", + "already_configured_updates": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`; the existing configuration will be updated with the validated data.", + "reconfigure_already_configured": "A device `{name}` with MAC address `{mac}` is already configured as `{title}`. Reconfiguration was aborted because the new configuration appears to refer to a different device.", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "mdns_missing_mac": "Missing MAC address in MDNS properties.", + "mdns_missing_mac": "Missing MAC address in mDNS properties.", "service_received": "Action received", "mqtt_missing_mac": "Missing MAC address in MQTT properties.", "mqtt_missing_api": "Missing API port in MQTT properties.", "mqtt_missing_ip": "Missing IP address in MQTT properties.", - "mqtt_missing_payload": "Missing MQTT Payload." + "mqtt_missing_payload": "Missing MQTT Payload.", + "name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "reauth_unique_id_changed": "**Re-authentication of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`).", + "reconfigure_name_conflict": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a device named `{name}` (MAC: `{expected_mac}`), which is already in use by another configuration entry: `{existing_title}`.", + "reconfigure_unique_id_changed": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`)." }, "error": { - "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address", - "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", + "resolve_error": "Unable to resolve the address of the ESPHome device. If this issue continues, consider setting a static IP address.", + "connection_error": "Unable to connect to the ESPHome device. Make sure the device’s YAML configuration includes an `api` section.", + "requires_encryption_key": "The ESPHome device requires an encryption key. Enter the key defined in the device’s YAML configuration under `api -> encryption -> key`.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_psk": "The transport encryption key is invalid. Please ensure it matches what you have in your configuration" + "invalid_psk": "The encryption key is invalid. Make sure it matches the value in the device’s YAML configuration under `api -> encryption -> key`." }, "step": { "user": { @@ -23,29 +32,53 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, - "description": "Please enter connection settings of your [ESPHome]({esphome_url}) node." + "data_description": { + "host": "IP address or hostname of the ESPHome device", + "port": "Port that the native API is running on" + }, + "description": "Please enter connection settings of your ESPHome device." }, "authenticate": { "data": { "password": "[%key:common::config_flow::data::password%]" }, - "description": "Please enter the password you set in your configuration for {name}." + "data_description": { + "password": "Passwords are deprecated and will be removed in a future version. Please update your ESPHome device YAML configuration to use an encryption key instead." + }, + "description": "Please enter the password you set in your ESPHome device YAML configuration for `{name}`." }, "encryption_key": { "data": { "noise_psk": "Encryption key" }, - "description": "Please enter the encryption key for {name}. You can find it in the ESPHome Dashboard or in your device configuration." + "data_description": { + "noise_psk": "The encryption key is used to encrypt the connection between Home Assistant and the ESPHome device. You can find this in the api: section of your ESPHome device YAML configuration." + }, + "description": "Please enter the encryption key for `{name}`. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." }, "reauth_confirm": { "data": { "noise_psk": "[%key:component::esphome::config::step::encryption_key::data::noise_psk%]" }, - "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your device configuration." + "data_description": { + "noise_psk": "[%key:component::esphome::config::step::encryption_key::data_description::noise_psk%]" + }, + "description": "The ESPHome device `{name}` enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." + }, + "reauth_encryption_removed_confirm": { + "description": "The ESPHome device `{name}` disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." }, "discovery_confirm": { - "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", - "title": "Discovered ESPHome node" + "description": "Do you want to add the device `{name}` to Home Assistant?", + "title": "Discovered ESPHome device" + }, + "name_conflict": { + "title": "Name conflict", + "description": "**The name `{name}` is already being used by another device: {existing_title} (MAC address: `{existing_mac}`)**\n\nTo continue, please choose one of the following options:\n\n**Migrate configuration to new device:** If this is a replacement, migrate the existing settings to the new device (`{mac}`).\n**Overwrite the existing configuration:** If this is not a replacement, delete the old configuration for `{existing_mac}` and use the new device instead.", + "menu_options": { + "name_conflict_migrate": "Migrate configuration to new device", + "name_conflict_overwrite": "Overwrite the existing configuration" + } } }, "flow_title": "{name}" @@ -55,7 +88,11 @@ "init": { "data": { "allow_service_calls": "Allow the device to perform Home Assistant actions.", - "subscribe_logs": "Subscribe to logs from the device. When enabled, the device will send logs to Home Assistant and you can view them in the logs panel." + "subscribe_logs": "Subscribe to logs from the device." + }, + "data_description": { + "allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions, such as calling services or sending events. Only enable this if you trust the device.", + "subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel." } } } @@ -66,11 +103,6 @@ "name": "[%key:component::assist_satellite::entity_component::_::name%]" } }, - "binary_sensor": { - "assist_in_progress": { - "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" - } - }, "select": { "pipeline": { "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", @@ -127,6 +159,46 @@ "service_calls_not_allowed": { "title": "{name} is not permitted to perform Home Assistant actions", "description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perform Home Assistant action, you can enable this functionality in the options flow." + }, + "device_conflict": { + "title": "Device conflict for {name}", + "fix_flow": { + "step": { + "init": { + "title": "Device conflict for {name}", + "description": "**The device `{name}` (`{model}`) at `{ip}` has reported a MAC address change from `{stored_mac}` to `{mac}`.**\n\nIf you have multiple devices with the same name, please rename or remove the one with MAC address `{mac}` to avoid conflicts.\n\nIf this is a hardware replacement, please confirm that you would like to migrate the Home Assistant configuration to the new device with MAC address `{mac}`.", + "menu_options": { + "migrate": "Migrate configuration to new device", + "manual": "Remove or rename device" + } + }, + "migrate": { + "title": "Confirm device replacement for {name}", + "description": "Are you sure you want to migrate the Home Assistant configuration for `{name}` (`{model}`) at `{ip}` from `{stored_mac}` to `{mac}`?" + }, + "manual": { + "title": "Remove or rename device {name}", + "description": "To resolve the conflict, either remove the device with MAC address `{mac}` from the network and restart the one with MAC address `{stored_mac}`, or re-flash the device with MAC address `{mac}` using a different name than `{name}`. Submit again once done." + } + } + } + } + }, + "exceptions": { + "action_call_failed": { + "message": "Failed to execute the action call {call_name} on {device_name}: {error}" + }, + "error_communicating_with_device": { + "message": "Error communicating with the device {device_name}: {error}" + }, + "error_compiling": { + "message": "Error compiling {configuration}; Try again in ESPHome dashboard for more information." + }, + "error_uploading": { + "message": "Error during OTA (Over-The-Air) of {configuration}; Try again in ESPHome dashboard for more information." + }, + "ota_in_progress": { + "message": "An OTA (Over-The-Air) update is already in progress for {configuration}." } } } diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index c210ae1440b..35edbf678ad 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -18,6 +18,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): """A switch implementation for ESPHome.""" @@ -34,7 +36,7 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): @property @esphome_state_property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return true if the switch is on.""" return self._state.state diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py index 36d77aac4a0..c36621b8f4e 100644 --- a/homeassistant/components/esphome/text.py +++ b/homeassistant/components/esphome/text.py @@ -17,6 +17,8 @@ from .entity import ( ) from .enum_mapper import EsphomeEnumMapper +PARALLEL_UPDATES = 0 + TEXT_MODES: EsphomeEnumMapper[EsphomeTextMode, TextMode] = EsphomeEnumMapper( { EsphomeTextMode.TEXT: TextMode.TEXT, diff --git a/homeassistant/components/esphome/time.py b/homeassistant/components/esphome/time.py index 477c47cf636..b0e586e1792 100644 --- a/homeassistant/components/esphome/time.py +++ b/homeassistant/components/esphome/time.py @@ -11,6 +11,8 @@ from homeassistant.components.time import TimeEntity from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +PARALLEL_UPDATES = 0 + class EsphomeTime(EsphomeEntity[TimeInfo, TimeState], TimeEntity): """A time implementation for esphome.""" diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 60d4989063b..cc886f2ba4c 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -18,7 +18,6 @@ from homeassistant.components.update import ( UpdateEntity, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr @@ -27,16 +26,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.enum import try_parse_enum +from .const import DOMAIN from .coordinator import ESPHomeDashboardCoordinator from .dashboard import async_get_dashboard -from .domain_data import DomainData from .entity import ( EsphomeEntity, + async_esphome_state_property, convert_api_error_ha_error, esphome_state_property, platform_async_setup_entry, ) -from .entry_data import RuntimeEntryData +from .entry_data import ESPHomeConfigEntry, RuntimeEntryData + +PARALLEL_UPDATES = 0 KEY_UPDATE_LOCK = "esphome_update_lock" @@ -45,7 +47,7 @@ NO_FEATURES = UpdateEntityFeature(0) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ESPHomeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ESPHome update based on a config entry.""" @@ -60,7 +62,7 @@ async def async_setup_entry( if (dashboard := async_get_dashboard(hass)) is None: return - entry_data = DomainData.get(hass).get_entry_data(entry) + entry_data = entry.runtime_data assert entry_data.device_info is not None device_name = entry_data.device_info.name unsubs: list[CALLBACK_TYPE] = [] @@ -68,7 +70,6 @@ async def async_setup_entry( @callback def _async_setup_update_entity() -> None: """Set up the update entity.""" - nonlocal unsubs assert dashboard is not None # Keep listening until device is available if not entry_data.available or not dashboard.last_update_success: @@ -93,10 +94,12 @@ async def async_setup_entry( _async_setup_update_entity() return - unsubs = [ - entry_data.async_subscribe_device_updated(_async_setup_update_entity), - dashboard.async_add_listener(_async_setup_update_entity), - ] + unsubs.extend( + [ + entry_data.async_subscribe_device_updated(_async_setup_update_entity), + dashboard.async_add_listener(_async_setup_update_entity), + ] + ) class ESPHomeDashboardUpdateEntity( @@ -107,7 +110,6 @@ class ESPHomeDashboardUpdateEntity( _attr_has_entity_name = True _attr_device_class = UpdateDeviceClass.FIRMWARE _attr_title = "ESPHome" - _attr_name = "Firmware" _attr_release_url = "https://esphome.io/changelog/" _attr_entity_registry_enabled_default = False @@ -124,21 +126,17 @@ class ESPHomeDashboardUpdateEntity( (dr.CONNECTION_NETWORK_MAC, entry_data.device_info.mac_address) } ) + self._install_lock = asyncio.Lock() + self._available_future: asyncio.Future[None] | None = None self._update_attrs() @callback def _update_attrs(self) -> None: """Update the supported features.""" - # If the device has deep sleep, we can't assume we can install updates - # as the ESP will not be connectable (by design). coordinator = self.coordinator device_info = self._device_info # Install support can change at run time - if ( - coordinator.last_update_success - and coordinator.supports_update - and not device_info.has_deep_sleep - ): + if coordinator.last_update_success and coordinator.supports_update: self._attr_supported_features = UpdateEntityFeature.INSTALL else: self._attr_supported_features = NO_FEATURES @@ -177,6 +175,13 @@ class ESPHomeDashboardUpdateEntity( self, static_info: list[EntityInfo] | None = None ) -> None: """Handle updated data from the device.""" + if ( + self._entry_data.available + and self._available_future + and not self._available_future.done() + ): + self._available_future.set_result(None) + self._available_future = None self._update_attrs() self.async_write_ha_state() @@ -191,26 +196,73 @@ class ESPHomeDashboardUpdateEntity( entry_data.async_subscribe_device_updated(self._handle_device_update) ) + async def async_will_remove_from_hass(self) -> None: + """Handle entity about to be removed from Home Assistant.""" + if self._available_future and not self._available_future.done(): + self._available_future.cancel() + self._available_future = None + + async def _async_wait_available(self) -> None: + """Wait until the device is available.""" + # If the device has deep sleep, we need to wait for it to wake up + # and connect to the network to be able to install the update. + if self._entry_data.available: + return + self._available_future = self.hass.loop.create_future() + try: + await self._available_future + finally: + self._available_future = None + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): - coordinator = self.coordinator - api = coordinator.api - device = coordinator.data.get(self._device_info.name) - assert device is not None + if self._install_lock.locked(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ota_in_progress", + translation_placeholders={ + "configuration": self._device_info.name, + }, + ) + + # Ensure only one OTA per device at a time + async with self._install_lock: + # Ensure only one compile at a time for ALL devices + async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): + coordinator = self.coordinator + api = coordinator.api + device = coordinator.data.get(self._device_info.name) + assert device is not None + configuration = device["configuration"] + if not await api.compile(configuration): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_compiling", + translation_placeholders={ + "configuration": configuration, + }, + ) + + # If the device uses deep sleep, there's a small chance it goes + # to sleep right after the dashboard connects but before the OTA + # starts. In that case, the update won't go through, so we try + # again to catch it on its next wakeup. + attempts = 2 if self._device_info.has_deep_sleep else 1 try: - if not await api.compile(device["configuration"]): - raise HomeAssistantError( - f"Error compiling {device['configuration']}; " - "Try again in ESPHome dashboard for more information." - ) - if not await api.upload(device["configuration"], "OTA"): - raise HomeAssistantError( - f"Error updating {device['configuration']} via OTA; " - "Try again in ESPHome dashboard for more information." - ) + for attempt in range(1, attempts + 1): + await self._async_wait_available() + if await api.upload(configuration, "OTA"): + break + if attempt == attempts: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_uploading", + translation_placeholders={ + "configuration": configuration, + }, + ) finally: await self.coordinator.async_request_refresh() @@ -219,7 +271,9 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): """A update implementation for esphome.""" _attr_supported_features = ( - UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES ) @callback @@ -233,7 +287,7 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): @property @esphome_state_property - def installed_version(self) -> str | None: + def installed_version(self) -> str: """Return the installed version.""" return self._state.current_version @@ -249,21 +303,22 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): """Return the latest version.""" return self._state.latest_version - @property - @esphome_state_property - def release_summary(self) -> str | None: - """Return the release summary.""" - return self._state.release_summary + @async_esphome_state_property + async def async_release_notes(self) -> str | None: + """Return the release notes.""" + if self._state.release_summary: + return self._state.release_summary + return None @property @esphome_state_property - def release_url(self) -> str | None: + def release_url(self) -> str: """Return the release URL.""" return self._state.release_url @property @esphome_state_property - def title(self) -> str | None: + def title(self) -> str: """Return the title of the update.""" return self._state.title diff --git a/homeassistant/components/esphome/valve.py b/homeassistant/components/esphome/valve.py index d779a6abb9f..f71a253c1f1 100644 --- a/homeassistant/components/esphome/valve.py +++ b/homeassistant/components/esphome/valve.py @@ -22,6 +22,8 @@ from .entity import ( platform_async_setup_entry, ) +PARALLEL_UPDATES = 0 + class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity): """A valve implementation for ESPHome.""" @@ -63,7 +65,7 @@ class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity): @property @esphome_state_property - def current_valve_position(self) -> int | None: + def current_valve_position(self) -> int: """Return current position of valve. 0 is closed, 100 is open.""" return round(self._state.position * 100.0) diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py index ae159d77240..c153f01e83c 100644 --- a/homeassistant/components/everlights/light.py +++ b/homeassistant/components/everlights/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any +from typing import Any, cast import pyeverlights import voluptuous as vol @@ -84,7 +84,7 @@ class EverLightsLight(LightEntity): api: pyeverlights.EverLights, channel: int, status: dict[str, Any], - effects, + effects: list[str], ) -> None: """Initialize the light.""" self._api = api @@ -106,8 +106,10 @@ class EverLightsLight(LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) - brightness = kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness) + hs_color = cast( + tuple[float, float], kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) + ) + brightness = cast(int, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness)) effect = kwargs.get(ATTR_EFFECT) if effect is not None: @@ -116,7 +118,7 @@ class EverLightsLight(LightEntity): rgb = color_int_to_rgb(colors[0]) hsv = color_util.color_RGB_to_hsv(*rgb) hs_color = hsv[:2] - brightness = hsv[2] / 100 * 255 + brightness = round(hsv[2] / 100 * 255) else: rgb = color_util.color_hsv_to_RGB( diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index b44dc9791b0..40439c1eb02 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -152,7 +152,7 @@ class EvoZone(EvoChild, EvoClimateEntity): super().__init__(coordinator, evo_device) self._evo_id = evo_device.id - if evo_device.model.startswith("VisionProWifi"): + if evo_device.id == evo_device.tcs.id: # this system does not have a distinct ID for the zone self._attr_unique_id = f"{evo_device.id}z" else: diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 7b197f1b643..33af90089a4 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -11,6 +11,7 @@ from typing import Any import evohomeasync as ec1 import evohomeasync2 as ec2 from evohomeasync2.const import ( + SZ_DHW, SZ_GATEWAY_ID, SZ_GATEWAY_INFO, SZ_GATEWAYS, @@ -19,8 +20,9 @@ from evohomeasync2.const import ( SZ_TEMPERATURE_CONTROL_SYSTEMS, SZ_TIME_ZONE, SZ_USE_DAYLIGHT_SAVE_SWITCHING, + SZ_ZONES, ) -from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT +from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant @@ -113,17 +115,19 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator): SZ_USE_DAYLIGHT_SAVE_SWITCHING ], } + tcs_info: EvoTcsConfigResponseT = self.tcs.config # type: ignore[assignment] + tcs_info[SZ_ZONES] = [zone.config for zone in self.tcs.zones] + if self.tcs.hotwater: + tcs_info[SZ_DHW] = self.tcs.hotwater.config gwy_info = { SZ_GATEWAY_ID: self.loc.gateways[0].id, - SZ_TEMPERATURE_CONTROL_SYSTEMS: [ - self.loc.gateways[0].systems[0].config - ], + SZ_TEMPERATURE_CONTROL_SYSTEMS: [tcs_info], } config = { SZ_LOCATION_INFO: loc_info, SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}], } - self.logger.debug("Config = %s", config) + self.logger.debug("Config = %s", [config]) async def call_client_api( self, @@ -203,10 +207,18 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator): async def _update_v2_schedules(self) -> None: for zone in self.tcs.zones: - await zone.get_schedule() + try: + await zone.get_schedule() + except ec2.InvalidScheduleError as err: + self.logger.warning( + "Zone '%s' has an invalid/missing schedule: %r", zone.name, err + ) if dhw := self.tcs.hotwater: - await dhw.get_schedule() + try: + await dhw.get_schedule() + except ec2.InvalidScheduleError as err: + self.logger.warning("DHW has an invalid/missing schedule: %r", err) async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override] """Fetch the latest state of an entire TCC Location. diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index 11215dd47b6..2f93f0fb143 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -6,6 +6,7 @@ import logging from typing import Any import evohomeasync2 as evo +from evohomeasync2.schemas.typedefs import DayOfWeekDhwT from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -102,7 +103,7 @@ class EvoChild(EvoEntity): self._evo_tcs = evo_device.tcs - self._schedule: dict[str, Any] | None = None + self._schedule: list[DayOfWeekDhwT] | None = None self._setpoints: dict[str, Any] = {} @property @@ -123,6 +124,9 @@ class EvoChild(EvoEntity): Only Zones & DHW controllers (but not the TCS) can have schedules. """ + if not self._schedule: + return self._setpoints + this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint @@ -152,10 +156,10 @@ class EvoChild(EvoEntity): self._evo_device, err, ) - self._schedule = {} + self._schedule = [] return else: - self._schedule = schedule or {} # mypy hint + self._schedule = schedule # type: ignore[assignment] _LOGGER.debug("Schedule['%s'] = %s", self.name, schedule) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 823ad7be5df..21c8874135a 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==1.0.2"] + "requirements": ["evohome-async==1.0.5"] } diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 43a71458fb2..a93954b8a9b 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -2,8 +2,8 @@ import logging -from pyezviz.client import EzvizClient -from pyezviz.exceptions import ( +from pyezvizapi.client import EzvizClient +from pyezvizapi.exceptions import ( EzvizAuthTokenExpired, EzvizAuthVerificationCode, HTTPError, diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py index 08fa0a68ee8..f945fcf3667 100644 --- a/homeassistant/components/ezviz/alarm_control_panel.py +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -6,8 +6,8 @@ from dataclasses import dataclass from datetime import timedelta import logging -from pyezviz import PyEzvizError -from pyezviz.constants import DefenseModeType +from pyezvizapi import PyEzvizError +from pyezvizapi.constants import DefenseModeType from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py index 6dbb419c903..52e029dca98 100644 --- a/homeassistant/components/ezviz/button.py +++ b/homeassistant/components/ezviz/button.py @@ -6,9 +6,9 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from pyezviz import EzvizClient -from pyezviz.constants import SupportExt -from pyezviz.exceptions import HTTPError, PyEzvizError +from pyezvizapi import EzvizClient +from pyezvizapi.constants import SupportExt +from pyezvizapi.exceptions import HTTPError, PyEzvizError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index e3d01bef83e..a968543e5b7 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from pyezviz.exceptions import HTTPError, InvalidHost, PyEzvizError +from pyezvizapi.exceptions import HTTPError, InvalidHost, PyEzvizError from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera, CameraEntityFeature diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 845656c1d1d..622f767443d 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -6,15 +6,15 @@ from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any -from pyezviz.client import EzvizClient -from pyezviz.exceptions import ( +from pyezvizapi.client import EzvizClient +from pyezvizapi.exceptions import ( AuthTestResultFailed, EzvizAuthVerificationCode, InvalidHost, InvalidURL, PyEzvizError, ) -from pyezviz.test_cam_rtsp import TestRTSPAuth +from pyezvizapi.test_cam_rtsp import TestRTSPAuth import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index e6de538335c..1d165c7bbe8 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -32,4 +32,4 @@ EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" DEFAULT_TIMEOUT = 25 -DEFAULT_FFMPEG_ARGUMENTS = "" +DEFAULT_FFMPEG_ARGUMENTS = "/Streaming/Channels/102" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index 0830784a501..c43e006ff96 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -4,8 +4,8 @@ import asyncio from datetime import timedelta import logging -from pyezviz.client import EzvizClient -from pyezviz.exceptions import ( +from pyezvizapi.client import EzvizClient +from pyezvizapi.exceptions import ( EzvizAuthTokenExpired, EzvizAuthVerificationCode, HTTPError, diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 28ebc7279e6..6ba1eec462c 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -5,8 +5,8 @@ from __future__ import annotations import logging from propcache.api import cached_property -from pyezviz.exceptions import PyEzvizError -from pyezviz.utils import decrypt_image +from pyezvizapi.exceptions import PyEzvizError +from pyezvizapi.utils import decrypt_image from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription from homeassistant.config_entries import SOURCE_IGNORE diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py index ba398dd3ed4..9c9382a4f3e 100644 --- a/homeassistant/components/ezviz/light.py +++ b/homeassistant/components/ezviz/light.py @@ -4,8 +4,8 @@ from __future__ import annotations from typing import Any -from pyezviz.constants import DeviceCatagories, DeviceSwitchType, SupportExt -from pyezviz.exceptions import HTTPError, PyEzvizError +from pyezvizapi.constants import DeviceCatagories, DeviceSwitchType, SupportExt +from pyezvizapi.exceptions import HTTPError, PyEzvizError from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 53976bf3002..bef054eac27 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -1,11 +1,11 @@ { "domain": "ezviz", "name": "EZVIZ", - "codeowners": ["@RenierM26", "@baqs"], + "codeowners": ["@RenierM26"], "config_flow": true, "dependencies": ["ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/ezviz", "iot_class": "cloud_polling", - "loggers": ["paho_mqtt", "pyezviz"], - "requirements": ["pyezviz==0.2.1.2"] + "loggers": ["paho_mqtt", "pyezvizapi"], + "requirements": ["pyezvizapi==1.0.0.7"] } diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index 9bdd1feb81d..68a184d4972 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -6,8 +6,8 @@ from dataclasses import dataclass from datetime import timedelta import logging -from pyezviz.constants import SupportExt -from pyezviz.exceptions import ( +from pyezvizapi.constants import SupportExt +from pyezvizapi.exceptions import ( EzvizAuthTokenExpired, EzvizAuthVerificationCode, HTTPError, diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index 486564bff6e..44f80ad6cd1 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -4,8 +4,8 @@ from __future__ import annotations from dataclasses import dataclass -from pyezviz.constants import DeviceSwitchType, SoundMode -from pyezviz.exceptions import HTTPError, PyEzvizError +from pyezvizapi.constants import DeviceSwitchType, SoundMode +from pyezvizapi.exceptions import HTTPError, PyEzvizError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py index a2c88f58972..1cbc17ba464 100644 --- a/homeassistant/components/ezviz/siren.py +++ b/homeassistant/components/ezviz/siren.py @@ -6,7 +6,7 @@ from collections.abc import Callable from datetime import datetime, timedelta from typing import Any -from pyezviz import HTTPError, PyEzvizError, SupportExt +from pyezvizapi import HTTPError, PyEzvizError, SupportExt from homeassistant.components.siren import ( SirenEntity, diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index f1653661cdd..cd8bbc9d199 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -54,7 +54,7 @@ "init": { "data": { "timeout": "Request timeout (seconds)", - "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras" + "ffmpeg_arguments": "Arguments passed to FFmpeg for cameras" } } } diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 01f7cac1a55..ae8419367c4 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -5,8 +5,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from pyezviz.constants import DeviceSwitchType, SupportExt -from pyezviz.exceptions import HTTPError, PyEzvizError +from pyezvizapi.constants import DeviceSwitchType, SupportExt +from pyezvizapi.exceptions import HTTPError, PyEzvizError from homeassistant.components.switch import ( SwitchDeviceClass, diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py index c9f8038b336..ffd9a260ce9 100644 --- a/homeassistant/components/ezviz/update.py +++ b/homeassistant/components/ezviz/update.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from pyezviz import HTTPError, PyEzvizError +from pyezvizapi import HTTPError, PyEzvizError from homeassistant.components.update import ( UpdateDeviceClass, diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 31617cb220b..57c58d3a2b1 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -45,7 +45,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) # if this is the last entry, remove the storage if len(entries) == 1: hass.data.pop(MY_KEY) - return await hass.config_entries.async_unload_platforms(entry, Platform.EVENT) + return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT]) async def _async_update_listener( diff --git a/homeassistant/components/feedreader/strings.json b/homeassistant/components/feedreader/strings.json index 3132aadbda8..35022e82bb1 100644 --- a/homeassistant/components/feedreader/strings.json +++ b/homeassistant/components/feedreader/strings.json @@ -36,7 +36,7 @@ "issues": { "import_yaml_error_url_error": { "title": "The Feedreader YAML configuration import failed", - "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." + "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that the URL is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." } } } diff --git a/homeassistant/components/ffmpeg/strings.json b/homeassistant/components/ffmpeg/strings.json index 66c1f19de5b..cac7fcfc48c 100644 --- a/homeassistant/components/ffmpeg/strings.json +++ b/homeassistant/components/ffmpeg/strings.json @@ -2,7 +2,7 @@ "services": { "restart": { "name": "[%key:common::action::restart%]", - "description": "Sends a restart command to a ffmpeg based sensor.", + "description": "Sends a restart command to an FFmpeg-based sensor.", "fields": { "entity_id": { "name": "Entity", @@ -12,7 +12,7 @@ }, "start": { "name": "[%key:common::action::start%]", - "description": "Sends a start command to a ffmpeg based sensor.", + "description": "Sends a start command to an FFmpeg-based sensor.", "fields": { "entity_id": { "name": "Entity", @@ -22,7 +22,7 @@ }, "stop": { "name": "[%key:common::action::stop%]", - "description": "Sends a stop command to a ffmpeg based sensor.", + "description": "Sends a stop command to an FFmpeg-based sensor.", "fields": { "entity_id": { "name": "Entity", diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 8ede0169482..a74656eef11 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -7,21 +7,22 @@ from collections.abc import Callable, Mapping import logging from typing import Any -from pyfibaro.fibaro_client import FibaroClient +from pyfibaro.fibaro_client import ( + FibaroAuthenticationFailed, + FibaroClient, + FibaroConnectFailed, +) +from pyfibaro.fibaro_data_helper import find_master_devices, read_rooms from pyfibaro.fibaro_device import DeviceModel -from pyfibaro.fibaro_room import RoomModel +from pyfibaro.fibaro_device_manager import FibaroDeviceManager +from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel -from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver -from requests.exceptions import HTTPError +from pyfibaro.fibaro_state_resolver import FibaroEvent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo from homeassistant.util import slugify @@ -74,128 +75,47 @@ FIBARO_TYPEMAP = { class FibaroController: """Initiate Fibaro Controller Class.""" - def __init__(self, config: Mapping[str, Any]) -> None: + def __init__( + self, fibaro_client: FibaroClient, info: InfoModel, import_plugins: bool + ) -> None: """Initialize the Fibaro controller.""" + self._client = fibaro_client + self._fibaro_info = info - # The FibaroClient uses the correct API version automatically - self._client = FibaroClient(config[CONF_URL]) - self._client.set_authentication(config[CONF_USERNAME], config[CONF_PASSWORD]) - - # Whether to import devices from plugins - self._import_plugins = config[CONF_IMPORT_PLUGINS] - self._room_map: dict[int, RoomModel] # Mapping roomId to room object + # The fibaro device manager exposes higher level API to access fibaro devices + self._fibaro_device_manager = FibaroDeviceManager(fibaro_client, import_plugins) + # Mapping roomId to room object + self._room_map = read_rooms(fibaro_client) self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict( list ) # List of devices by entity platform # All scenes - self._scenes: list[SceneModel] = [] - self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId - # Event callbacks by device id - self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} - self.hub_serial: str # Unique serial number of the hub - self.hub_name: str # The friendly name of the hub - self.hub_model: str - self.hub_software_version: str - self.hub_api_url: str = config[CONF_URL] + self._scenes = self._client.read_scenes() + # Unique serial number of the hub + self.hub_serial = info.serial_number # Device infos by fibaro device id self._device_infos: dict[int, DeviceInfo] = {} - - def connect(self) -> None: - """Start the communication with the Fibaro controller.""" - - # Return value doesn't need to be checked, - # it is only relevant when connecting without credentials - self._client.connect() - info = self._client.read_info() - self.hub_serial = info.serial_number - self.hub_name = info.hc_name - self.hub_model = info.platform - self.hub_software_version = info.current_version - - self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} self._read_devices() - self._scenes = self._client.read_scenes() - def connect_with_error_handling(self) -> None: - """Translate connect errors to easily differentiate auth and connect failures. + def disconnect(self) -> None: + """Close push channel.""" + self._fibaro_device_manager.close() - When there is a better error handling in the used library this can be improved. - """ - try: - self.connect() - except HTTPError as http_ex: - if http_ex.response.status_code == 403: - raise FibaroAuthFailed from http_ex - - raise FibaroConnectFailed from http_ex - except Exception as ex: - raise FibaroConnectFailed from ex - - def enable_state_handler(self) -> None: - """Start StateHandler thread for monitoring updates.""" - self._client.register_update_handler(self._on_state_change) - - def disable_state_handler(self) -> None: - """Stop StateHandler thread used for monitoring updates.""" - self._client.unregister_update_handler() - - def _on_state_change(self, state: Any) -> None: - """Handle change report received from the HomeCenter.""" - callback_set = set() - for change in state.get("changes", []): - try: - dev_id = change.pop("id") - if dev_id not in self._device_map: - continue - device = self._device_map[dev_id] - for property_name, value in change.items(): - if property_name == "log": - if value and value != "transfer OK": - _LOGGER.debug("LOG %s: %s", device.friendly_name, value) - continue - if property_name == "logTemp": - continue - if property_name in device.properties: - device.properties[property_name] = value - _LOGGER.debug( - "<- %s.%s = %s", device.ha_id, property_name, str(value) - ) - else: - _LOGGER.warning("%s.%s not found", device.ha_id, property_name) - if dev_id in self._callbacks: - callback_set.add(dev_id) - except (ValueError, KeyError): - pass - for item in callback_set: - for callback in self._callbacks[item]: - callback() - - resolver = FibaroStateResolver(state) - for event in resolver.get_events(): - # event does not always have a fibaro id, therefore it is - # essential that we first check for relevant event type - if ( - event.event_type.lower() == "centralsceneevent" - and event.fibaro_id in self._event_callbacks - ): - for callback in self._event_callbacks[event.fibaro_id]: - callback(event) - - def register(self, device_id: int, callback: Any) -> None: + def register( + self, device_id: int, callback: Callable[[DeviceModel], None] + ) -> Callable[[], None]: """Register device with a callback for updates.""" - device_callbacks = self._callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_change_listener(device_id, callback) def register_event( self, device_id: int, callback: Callable[[FibaroEvent], None] - ) -> None: + ) -> Callable[[], None]: """Register device with a callback for central scene events. The callback receives one parameter with the event. """ - device_callbacks = self._event_callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_event_listener(device_id, callback) def get_children(self, device_id: int) -> list[DeviceModel]: """Get a list of child devices.""" @@ -256,35 +176,18 @@ class FibaroController: platform = Platform.LIGHT return platform - def _create_device_info( - self, device: DeviceModel, devices: list[DeviceModel] - ) -> None: - """Create the device info. Unrooted entities are directly shown below the home center.""" + def _create_device_info(self, main_device: DeviceModel) -> None: + """Create the device info for a main device.""" - # The home center is always id 1 (z-wave primary controller) - if device.parent_fibaro_id <= 1: - return - - master_entity: DeviceModel | None = None - if device.parent_fibaro_id == 1: - master_entity = device - else: - for parent in devices: - if parent.fibaro_id == device.parent_fibaro_id: - master_entity = parent - if master_entity is None: - _LOGGER.error("Parent with id %s not found", device.parent_fibaro_id) - return - - if "zwaveCompany" in master_entity.properties: - manufacturer = master_entity.properties.get("zwaveCompany") + if "zwaveCompany" in main_device.properties: + manufacturer = main_device.properties.get("zwaveCompany") else: manufacturer = None - self._device_infos[master_entity.fibaro_id] = DeviceInfo( - identifiers={(DOMAIN, master_entity.fibaro_id)}, + self._device_infos[main_device.fibaro_id] = DeviceInfo( + identifiers={(DOMAIN, main_device.fibaro_id)}, manufacturer=manufacturer, - name=master_entity.name, + name=main_device.name, via_device=(DOMAIN, self.hub_serial), ) @@ -302,40 +205,50 @@ class FibaroController: def get_room_name(self, room_id: int) -> str | None: """Get the room name by room id.""" - assert self._room_map - room = self._room_map.get(room_id) - return room.name if room else None + return self._room_map.get(room_id) def read_scenes(self) -> list[SceneModel]: """Return list of scenes.""" return self._scenes + def get_all_devices(self) -> list[DeviceModel]: + """Return list of all fibaro devices.""" + return self._fibaro_device_manager.get_devices() + + def read_fibaro_info(self) -> InfoModel: + """Return the general info about the hub.""" + return self._fibaro_info + + def get_frontend_url(self) -> str: + """Return the url to the Fibaro hub web UI.""" + return self._client.frontend_url() + def _read_devices(self) -> None: """Read and process the device list.""" - devices = self._client.read_devices() + devices = self._fibaro_device_manager.get_devices() + + for main_device in find_master_devices(devices): + self._create_device_info(main_device) + self._device_map = {} last_climate_parent = None last_endpoint = None for device in devices: try: device.fibaro_controller = self - if device.room_id == 0: + room_name = self.get_room_name(device.room_id) + if not room_name: room_name = "Unknown" - else: - room_name = self._room_map[device.room_id].name device.room_name = room_name device.friendly_name = f"{room_name} {device.name}" device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" ) - if device.enabled and (not device.is_plugin or self._import_plugins): - device.mapped_platform = self._map_device_to_platform(device) - else: - device.mapped_platform = None - if (platform := device.mapped_platform) is None: + + platform = self._map_device_to_platform(device) + if platform is None: continue device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}" - self._create_device_info(device, devices) self._device_map[device.fibaro_id] = device _LOGGER.debug( "%s (%s, %s) -> %s %s", @@ -375,11 +288,17 @@ class FibaroController: pass +def connect_fibaro_client(data: Mapping[str, Any]) -> tuple[InfoModel, FibaroClient]: + """Connect to the fibaro hub and read some basic data.""" + client = FibaroClient(data[CONF_URL]) + info = client.connect_with_credentials(data[CONF_USERNAME], data[CONF_PASSWORD]) + return (info, client) + + def init_controller(data: Mapping[str, Any]) -> FibaroController: - """Validate the user input allows us to connect to fibaro.""" - controller = FibaroController(data) - controller.connect_with_error_handling() - return controller + """Connect to the fibaro hub and init the controller.""" + info, client = connect_fibaro_client(data) + return FibaroController(client, info, data[CONF_IMPORT_PLUGINS]) async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bool: @@ -393,28 +312,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo raise ConfigEntryNotReady( f"Could not connect to controller at {entry.data[CONF_URL]}" ) from connect_ex - except FibaroAuthFailed as auth_ex: + except FibaroAuthenticationFailed as auth_ex: raise ConfigEntryAuthFailed from auth_ex entry.runtime_data = controller # register the hub device info separately as the hub has sometimes no entities + fibaro_info = controller.read_fibaro_info() device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, controller.hub_serial)}, serial_number=controller.hub_serial, - manufacturer="Fibaro", - name=controller.hub_name, - model=controller.hub_model, - sw_version=controller.hub_software_version, - configuration_url=controller.hub_api_url.removesuffix("/api/"), + manufacturer=fibaro_info.manufacturer_name, + name=fibaro_info.hc_name, + model=fibaro_info.model_name, + sw_version=fibaro_info.current_version, + configuration_url=controller.get_frontend_url(), + connections={(dr.CONNECTION_NETWORK_MAC, fibaro_info.mac_address)}, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - controller.enable_state_handler() - return True @@ -423,8 +342,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> b _LOGGER.debug("Shutting down Fibaro connection") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - entry.runtime_data.disable_state_handler() - + entry.runtime_data.disconnect() return unload_ok @@ -443,11 +361,3 @@ async def async_remove_config_entry_device( return False return True - - -class FibaroConnectFailed(HomeAssistantError): - """Error to indicate we cannot connect to fibaro home center.""" - - -class FibaroAuthFailed(HomeAssistantError): - """Error to indicate that authentication failed on fibaro home center.""" diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index d601450a70f..7a8cc3fd2a9 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -129,13 +129,13 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the Fibaro device.""" super().__init__(fibaro_device) - self._temp_sensor_device: FibaroEntity | None = None - self._target_temp_device: FibaroEntity | None = None - self._op_mode_device: FibaroEntity | None = None - self._fan_mode_device: FibaroEntity | None = None + self._temp_sensor_device: DeviceModel | None = None + self._target_temp_device: DeviceModel | None = None + self._op_mode_device: DeviceModel | None = None + self._fan_mode_device: DeviceModel | None = None self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - siblings = fibaro_device.fibaro_controller.get_siblings(fibaro_device) + siblings = self.controller.get_siblings(fibaro_device) _LOGGER.debug("%s siblings: %s", fibaro_device.ha_id, siblings) tempunit = "C" for device in siblings: @@ -147,23 +147,23 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): and (device.value.has_value or device.has_heating_thermostat_setpoint) and device.unit in ("C", "F") ): - self._temp_sensor_device = FibaroEntity(device) + self._temp_sensor_device = device tempunit = device.unit if any( action for action in TARGET_TEMP_ACTIONS if action in device.actions ): - self._target_temp_device = FibaroEntity(device) + self._target_temp_device = device self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE if device.has_unit: tempunit = device.unit if any(action for action in OP_MODE_ACTIONS if action in device.actions): - self._op_mode_device = FibaroEntity(device) + self._op_mode_device = device self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE if "setFanMode" in device.actions: - self._fan_mode_device = FibaroEntity(device) + self._fan_mode_device = device self._attr_supported_features |= ClimateEntityFeature.FAN_MODE if tempunit == "F": @@ -172,7 +172,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): self._attr_temperature_unit = UnitOfTemperature.CELSIUS if self._fan_mode_device: - fan_modes = self._fan_mode_device.fibaro_device.supported_modes + fan_modes = self._fan_mode_device.supported_modes self._attr_fan_modes = [] for mode in fan_modes: if mode not in FANMODES: @@ -184,7 +184,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if self._op_mode_device: self._attr_preset_modes = [] self._attr_hvac_modes: list[HVACMode] = [] - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_supported_thermostat_modes: for mode in device.supported_thermostat_modes: try: @@ -222,15 +222,15 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): "- _fan_mode_device %s" ), self.ha_id, - self._temp_sensor_device.ha_id if self._temp_sensor_device else "None", - self._target_temp_device.ha_id if self._target_temp_device else "None", - self._op_mode_device.ha_id if self._op_mode_device else "None", - self._fan_mode_device.ha_id if self._fan_mode_device else "None", + self._temp_sensor_device.fibaro_id if self._temp_sensor_device else "None", + self._target_temp_device.fibaro_id if self._target_temp_device else "None", + self._op_mode_device.fibaro_id if self._op_mode_device else "None", + self._fan_mode_device.fibaro_id if self._fan_mode_device else "None", ) await super().async_added_to_hass() # Register update callback for child devices - siblings = self.fibaro_device.fibaro_controller.get_siblings(self.fibaro_device) + siblings = self.controller.get_siblings(self.fibaro_device) for device in siblings: if device != self.fibaro_device: self.controller.register(device.fibaro_id, self._update_callback) @@ -240,14 +240,14 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): """Return the fan setting.""" if not self._fan_mode_device: return None - mode = self._fan_mode_device.fibaro_device.mode + mode = self._fan_mode_device.mode return FANMODES[mode] def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if not self._fan_mode_device: return - self._fan_mode_device.action("setFanMode", HA_FANMODES[fan_mode]) + self._fan_mode_device.execute_action("setFanMode", [HA_FANMODES[fan_mode]]) @property def fibaro_op_mode(self) -> str | int: @@ -255,7 +255,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return HA_OPMODES_HVAC[HVACMode.AUTO] - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_operating_mode: return device.operating_mode @@ -281,17 +281,17 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return - if "setOperatingMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setOperatingMode", HA_OPMODES_HVAC[hvac_mode]) - elif "setThermostatMode" in self._op_mode_device.fibaro_device.actions: - device = self._op_mode_device.fibaro_device + device = self._op_mode_device + if "setOperatingMode" in device.actions: + device.execute_action("setOperatingMode", [HA_OPMODES_HVAC[hvac_mode]]) + elif "setThermostatMode" in device.actions: if device.has_supported_thermostat_modes: for mode in device.supported_thermostat_modes: if mode.lower() == hvac_mode: - self._op_mode_device.action("setThermostatMode", mode) + device.execute_action("setThermostatMode", [mode]) break - elif "setMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setMode", HA_OPMODES_HVAC[hvac_mode]) + elif "setMode" in device.actions: + device.execute_action("setMode", [HA_OPMODES_HVAC[hvac_mode]]) @property def hvac_action(self) -> HVACAction | None: @@ -299,7 +299,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return None - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_thermostat_operating_state: with suppress(ValueError): return HVACAction(device.thermostat_operating_state.lower()) @@ -315,15 +315,15 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return None - if self._op_mode_device.fibaro_device.has_thermostat_mode: - mode = self._op_mode_device.fibaro_device.thermostat_mode + if self._op_mode_device.has_thermostat_mode: + mode = self._op_mode_device.thermostat_mode if self.preset_modes is not None and mode in self.preset_modes: return mode return None - if self._op_mode_device.fibaro_device.has_operating_mode: - mode = self._op_mode_device.fibaro_device.operating_mode + if self._op_mode_device.has_operating_mode: + mode = self._op_mode_device.operating_mode else: - mode = self._op_mode_device.fibaro_device.mode + mode = self._op_mode_device.mode if mode not in OPMODES_PRESET: return None @@ -334,20 +334,22 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if self._op_mode_device is None: return - if "setThermostatMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setThermostatMode", preset_mode) - elif "setOperatingMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action( - "setOperatingMode", HA_OPMODES_PRESET[preset_mode] + if "setThermostatMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action("setThermostatMode", [preset_mode]) + elif "setOperatingMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action( + "setOperatingMode", [HA_OPMODES_PRESET[preset_mode]] + ) + elif "setMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action( + "setMode", [HA_OPMODES_PRESET[preset_mode]] ) - elif "setMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setMode", HA_OPMODES_PRESET[preset_mode]) @property def current_temperature(self) -> float | None: """Return the current temperature.""" if self._temp_sensor_device: - device = self._temp_sensor_device.fibaro_device + device = self._temp_sensor_device if device.has_heating_thermostat_setpoint: return device.heating_thermostat_setpoint return device.value.float_value() @@ -357,7 +359,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self._target_temp_device: - device = self._target_temp_device.fibaro_device + device = self._target_temp_device if device.has_heating_thermostat_setpoint_future: return device.heating_thermostat_setpoint_future return device.target_level @@ -368,9 +370,11 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): temperature = kwargs.get(ATTR_TEMPERATURE) target = self._target_temp_device if target is not None and temperature is not None: - if "setThermostatSetpoint" in target.fibaro_device.actions: - target.action("setThermostatSetpoint", self.fibaro_op_mode, temperature) - elif "setHeatingThermostatSetpoint" in target.fibaro_device.actions: - target.action("setHeatingThermostatSetpoint", temperature) + if "setThermostatSetpoint" in target.actions: + target.execute_action( + "setThermostatSetpoint", [self.fibaro_op_mode, temperature] + ) + elif "setHeatingThermostatSetpoint" in target.actions: + target.execute_action("setHeatingThermostatSetpoint", [temperature]) else: - target.action("setTargetLevel", temperature) + target.execute_action("setTargetLevel", [temperature]) diff --git a/homeassistant/components/fibaro/config_flow.py b/homeassistant/components/fibaro/config_flow.py index 0ffd9aaa48f..d941ceab37f 100644 --- a/homeassistant/components/fibaro/config_flow.py +++ b/homeassistant/components/fibaro/config_flow.py @@ -6,6 +6,7 @@ from collections.abc import Mapping import logging from typing import Any +from pyfibaro.fibaro_client import FibaroAuthenticationFailed, FibaroConnectFailed from slugify import slugify import voluptuous as vol @@ -13,7 +14,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import FibaroAuthFailed, FibaroConnectFailed, init_controller +from . import connect_fibaro_client from .const import CONF_IMPORT_PLUGINS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,16 +34,16 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - controller = await hass.async_add_executor_job(init_controller, data) + info, _ = await hass.async_add_executor_job(connect_fibaro_client, data) _LOGGER.debug( "Successfully connected to fibaro home center %s with name %s", - controller.hub_serial, - controller.hub_name, + info.serial_number, + info.hc_name, ) return { - "serial_number": slugify(controller.hub_serial), - "name": controller.hub_name, + "serial_number": slugify(info.serial_number), + "name": info.hc_name, } @@ -75,7 +76,7 @@ class FibaroConfigFlow(ConfigFlow, domain=DOMAIN): info = await _validate_input(self.hass, user_input) except FibaroConnectFailed: errors["base"] = "cannot_connect" - except FibaroAuthFailed: + except FibaroAuthenticationFailed: errors["base"] = "invalid_auth" else: await self.async_set_unique_id(info["serial_number"]) @@ -106,7 +107,7 @@ class FibaroConfigFlow(ConfigFlow, domain=DOMAIN): await _validate_input(self.hass, new_data) except FibaroConnectFailed: errors["base"] = "cannot_connect" - except FibaroAuthFailed: + except FibaroAuthenticationFailed: errors["base"] = "invalid_auth" else: return self.async_update_reload_and_abort( diff --git a/homeassistant/components/fibaro/diagnostics.py b/homeassistant/components/fibaro/diagnostics.py new file mode 100644 index 00000000000..2f1f397a69a --- /dev/null +++ b/homeassistant/components/fibaro/diagnostics.py @@ -0,0 +1,56 @@ +"""Diagnostics support for fibaro integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from pyfibaro.fibaro_device import DeviceModel + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from . import CONF_IMPORT_PLUGINS, FibaroConfigEntry + +TO_REDACT = {"password"} + + +def _create_diagnostics_data( + config_entry: FibaroConfigEntry, devices: list[DeviceModel] +) -> dict[str, Any]: + """Combine diagnostics information and redact sensitive information.""" + return { + "config": {CONF_IMPORT_PLUGINS: config_entry.data.get(CONF_IMPORT_PLUGINS)}, + "fibaro_devices": async_redact_data([d.raw_data for d in devices], TO_REDACT), + } + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: FibaroConfigEntry +) -> Mapping[str, Any]: + """Return diagnostics for a config entry.""" + controller = config_entry.runtime_data + devices = controller.get_all_devices() + return _create_diagnostics_data(config_entry, devices) + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: FibaroConfigEntry, device: DeviceEntry +) -> Mapping[str, Any]: + """Return diagnostics for a device.""" + controller = config_entry.runtime_data + devices = controller.get_all_devices() + + ha_device_id = next(iter(device.identifiers))[1] + if ha_device_id == controller.hub_serial: + # special case where the device is representing the fibaro hub + return _create_diagnostics_data(config_entry, devices) + + # normal devices are represented by a parent / child structure + filtered_devices = [ + device + for device in devices + if ha_device_id in (device.fibaro_id, device.parent_fibaro_id) + ] + return _create_diagnostics_data(config_entry, filtered_devices) diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py index 6a8e12136c8..e8ed5afc500 100644 --- a/homeassistant/components/fibaro/entity.py +++ b/homeassistant/components/fibaro/entity.py @@ -11,6 +11,8 @@ from pyfibaro.fibaro_device import DeviceModel from homeassistant.const import ATTR_ARMED, ATTR_BATTERY_LEVEL from homeassistant.helpers.entity import Entity +from . import FibaroController + _LOGGER = logging.getLogger(__name__) @@ -22,7 +24,7 @@ class FibaroEntity(Entity): def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the device.""" self.fibaro_device = fibaro_device - self.controller = fibaro_device.fibaro_controller + self.controller: FibaroController = fibaro_device.fibaro_controller self.ha_id = fibaro_device.ha_id self._attr_name = fibaro_device.friendly_name self._attr_unique_id = fibaro_device.unique_id_str @@ -34,9 +36,13 @@ class FibaroEntity(Entity): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" - self.controller.register(self.fibaro_device.fibaro_id, self._update_callback) + self.async_on_remove( + self.controller.register( + self.fibaro_device.fibaro_id, self._update_callback + ) + ) - def _update_callback(self) -> None: + def _update_callback(self, fibaro_device: DeviceModel) -> None: """Update the state.""" self.schedule_update_ha_state(True) @@ -54,15 +60,6 @@ class FibaroEntity(Entity): return self.fibaro_device.value_2.int_value() return None - def dont_know_message(self, cmd: str) -> None: - """Make a warning in case we don't know how to perform an action.""" - _LOGGER.warning( - "Not sure how to %s: %s (available actions: %s)", - cmd, - str(self.ha_id), - str(self.fibaro_device.actions), - ) - def set_level(self, level: int) -> None: """Set the level of Fibaro device.""" self.action("setValue", level) @@ -97,11 +94,7 @@ class FibaroEntity(Entity): def action(self, cmd: str, *args: Any) -> None: """Perform an action on the Fibaro HC.""" - if cmd in self.fibaro_device.actions: - self.fibaro_device.execute_action(cmd, args) - _LOGGER.debug("-> %s.%s%s called", str(self.ha_id), str(cmd), str(args)) - else: - self.dont_know_message(cmd) + self.fibaro_device.execute_action(cmd, args) @property def current_binary_state(self) -> bool: diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index 0beea2e336e..ad44719c8be 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -60,11 +60,16 @@ class FibaroEventEntity(FibaroEntity, EventEntity): await super().async_added_to_hass() # Register event callback - self.controller.register_event( - self.fibaro_device.fibaro_id, self._event_callback + self.async_on_remove( + self.controller.register_event( + self.fibaro_device.fibaro_id, self._event_callback + ) ) def _event_callback(self, event: FibaroEvent) -> None: - if event.key_id == self._button: + if ( + event.event_type.lower() == "centralsceneevent" + and event.key_id == self._button + ): self._trigger_event(event.key_event_type) self.schedule_update_ha_state() diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index d2a1186b05b..cd4d1de838c 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.8.0"] + "requirements": ["pyfibaro==0.8.2"] } diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json index bd8f23602e3..02f8c42755b 100644 --- a/homeassistant/components/file/strings.json +++ b/homeassistant/components/file/strings.json @@ -4,13 +4,13 @@ "user": { "description": "Make a choice", "menu_options": { - "sensor": "Set up a file based sensor", + "sensor": "Set up a file-based sensor", "notify": "Set up a notification service" } }, "sensor": { "title": "File sensor", - "description": "Set up a file based sensor", + "description": "[%key:component::file::config::step::user::menu_options::sensor%]", "data": { "file_path": "File path", "value_template": "Value template", diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json index b0403227fd4..689cf730023 100644 --- a/homeassistant/components/filter/strings.json +++ b/homeassistant/components/filter/strings.json @@ -183,7 +183,7 @@ "outlier": "Outlier", "throttle": "Throttle", "time_throttle": "Time throttle", - "time_simple_moving_average": "Moving Average (Time based)" + "time_simple_moving_average": "Moving average (time-based)" } }, "type": { diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 318325dbb09..f5188d5bf21 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections import namedtuple from datetime import timedelta import logging -from typing import Any +from typing import Any, cast from fints.client import FinTS3PinTanClient from fints.models import SEPAAccount @@ -73,7 +73,7 @@ def setup_platform( credentials = BankCredentials( config[CONF_BIN], config[CONF_USERNAME], config[CONF_PIN], config[CONF_URL] ) - fints_name = config.get(CONF_NAME, config[CONF_BIN]) + fints_name = cast(str, config.get(CONF_NAME, config[CONF_BIN])) account_config = { acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_ACCOUNTS] diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 5ed65609dc8..f7414d7e1bd 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -9,7 +9,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN as FIRESERVICEROTA_DOMAIN +from .const import DOMAIN from .coordinator import FireServiceConfigEntry, FireServiceRotaClient _LOGGER = logging.getLogger(__name__) @@ -106,7 +106,7 @@ class IncidentsSensor(RestoreEntity, SensorEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"{FIRESERVICEROTA_DOMAIN}_{self._entry_id}_update", + f"{DOMAIN}_{self._entry_id}_update", self.client_update, ) ) diff --git a/homeassistant/components/fireservicerota/strings.json b/homeassistant/components/fireservicerota/strings.json index 7b4bd583b63..9a23161b7ec 100644 --- a/homeassistant/components/fireservicerota/strings.json +++ b/homeassistant/components/fireservicerota/strings.json @@ -9,7 +9,7 @@ } }, "reauth_confirm": { - "description": "Authentication tokens became invalid, login to recreate them.", + "description": "Authentication tokens became invalid, log in to recreate them.", "data": { "password": "[%key:common::config_flow::data::password%]" } diff --git a/homeassistant/components/fireservicerota/switch.py b/homeassistant/components/fireservicerota/switch.py index d9fe382e4b1..26dc3b27c19 100644 --- a/homeassistant/components/fireservicerota/switch.py +++ b/homeassistant/components/fireservicerota/switch.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as FIRESERVICEROTA_DOMAIN +from .const import DOMAIN from .coordinator import ( FireServiceConfigEntry, FireServiceRotaClient, @@ -122,7 +122,7 @@ class ResponseSwitch(SwitchEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"{FIRESERVICEROTA_DOMAIN}_{self._entry_id}_update", + f"{DOMAIN}_{self._entry_id}_update", self.client_update, ) ) diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 81e61f2554a..4aea43f0bec 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -1,6 +1,5 @@ """The Flipr integration.""" -from collections import Counter import logging from flipr_api import FliprAPIRestClient @@ -8,10 +7,7 @@ from flipr_api import FliprAPIRestClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN from .coordinator import ( FliprConfigEntry, FliprData, @@ -27,9 +23,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: FliprConfigEntry) -> bool: """Set up flipr from a config entry.""" - # Detect invalid old config entry and raise error if found - detect_invalid_old_configuration(hass, entry) - config = entry.data username = config[CONF_EMAIL] @@ -64,47 +57,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def detect_invalid_old_configuration(hass: HomeAssistant, entry: ConfigEntry): - """Detect invalid old configuration and raise error if found.""" - - def find_duplicate_entries(entries): - values = [e.data["email"] for e in entries] - _LOGGER.debug("Detecting duplicates in values : %s", values) - return any(count > 1 for count in Counter(values).values()) - - entries = hass.config_entries.async_entries(DOMAIN) - - if find_duplicate_entries(entries): - ir.async_create_issue( - hass, - DOMAIN, - "duplicate_config", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="duplicate_config", - ) - - raise ConfigEntryError( - "Duplicate entries found for flipr with the same user email. Please remove one of it manually. Multiple fliprs will be automatically detected after restart." - ) - - -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Migrate config entry.""" - _LOGGER.debug("Migration of flipr config from version %s", entry.version) - - if entry.version == 1: - # In version 1, we have flipr device as config entry unique id - # and one device per config entry. - # We need to migrate to a new config entry that may contain multiple devices. - # So we change the entry data to match config_flow evolution. - login = entry.data[CONF_EMAIL] - - hass.config_entries.async_update_entry(entry, version=2, unique_id=login) - - _LOGGER.debug("Migration of flipr config to version 2 successful") - - return True diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json index 631b0ce5488..5c1a55e8b2a 100644 --- a/homeassistant/components/flipr/strings.json +++ b/homeassistant/components/flipr/strings.json @@ -14,7 +14,7 @@ "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%]", - "no_flipr_id_found": "No flipr or hub associated to your account for now. You should verify it is working with the Flipr's mobile app first." + "no_flipr_id_found": "No Flipr or hub associated to your account for now. You should verify it is working with the Flipr mobile app first." } }, "entity": { @@ -44,17 +44,11 @@ "hub_mode": { "name": "Mode", "state": { - "auto": "Automatic", - "manual": "Manual", + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", "planning": "Planning" } } } - }, - "issues": { - "duplicate_config": { - "title": "Multiple flipr configurations with the same account", - "description": "The Flipr integration has been updated to work account based rather than device based. This means that if you have 2 devices, you only need one configuration. For every account you have, please delete all but one configuration and restart Home Assistant for it to set up the devices linked to your account." - } } } diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py index 9f540b230f4..0e50c8c6b03 100644 --- a/homeassistant/components/flo/coordinator.py +++ b/homeassistant/components/flo/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN as FLO_DOMAIN, LOGGER +from .const import DOMAIN, LOGGER type FloConfigEntry = ConfigEntry[FloRuntimeData] @@ -55,7 +55,7 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): hass, LOGGER, config_entry=config_entry, - name=f"{FLO_DOMAIN}-{device_id}", + name=f"{DOMAIN}-{device_id}", update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py index 072afbae4f2..c9717b16059 100644 --- a/homeassistant/components/flo/entity.py +++ b/homeassistant/components/flo/entity.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity -from .const import DOMAIN as FLO_DOMAIN +from .const import DOMAIN from .coordinator import FloDeviceDataUpdateCoordinator @@ -32,7 +32,7 @@ class FloEntity(Entity): """Return a device description for device registry.""" return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, self._device.mac_address)}, - identifiers={(FLO_DOMAIN, self._device.id)}, + identifiers={(DOMAIN, self._device.id)}, serial_number=self._device.serial_number, manufacturer=self._device.manufacturer, model=self._device.model, diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index 3444911fbd4..64e22bedec3 100644 --- a/homeassistant/components/flo/strings.json +++ b/homeassistant/components/flo/strings.json @@ -60,11 +60,11 @@ "fields": { "sleep_minutes": { "name": "Sleep minutes", - "description": "The time to sleep in minutes." + "description": "The duration to sleep in minutes." }, "revert_to_mode": { "name": "Revert to mode", - "description": "The mode to revert to after sleep_minutes has elapsed." + "description": "The mode to revert to after the 'Sleep minutes' duration has elapsed." } } }, @@ -78,7 +78,7 @@ }, "run_health_test": { "name": "Run health test", - "description": "Have the Flo device run a health test." + "description": "Requests the Flo device to run a health test." } } } diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index fcb16c9742b..2c5e1b3839e 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.1.3"] + "requirements": ["flux-led==1.2.0"] } diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 1eb9c98701d..66796a44dc4 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/forecast_solar", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["forecast-solar==4.0.0"] + "requirements": ["forecast-solar==4.2.0"] } diff --git a/homeassistant/components/forked_daapd/__init__.py b/homeassistant/components/forked_daapd/__init__.py index 2172e60ba38..844a6a3eff9 100644 --- a/homeassistant/components/forked_daapd/__init__.py +++ b/homeassistant/components/forked_daapd/__init__.py @@ -1,29 +1,36 @@ """The forked_daapd component.""" -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from pyforked_daapd import ForkedDaapdAPI -from .const import DOMAIN, HASS_DATA_UPDATER_KEY +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import ForkedDaapdConfigEntry, ForkedDaapdUpdater PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ForkedDaapdConfigEntry) -> bool: """Set up forked-daapd from a config entry by forwarding to platform.""" + host: str = entry.data[CONF_HOST] + port: int = entry.data[CONF_PORT] + password: str = entry.data[CONF_PASSWORD] + forked_daapd_api = ForkedDaapdAPI( + async_get_clientsession(hass), host, port, password + ) + forked_daapd_updater = ForkedDaapdUpdater(hass, forked_daapd_api, entry.entry_id) + entry.runtime_data = forked_daapd_updater 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: ForkedDaapdConfigEntry +) -> bool: """Remove forked-daapd component.""" status = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if status and hass.data.get(DOMAIN) and hass.data[DOMAIN].get(entry.entry_id): - if websocket_handler := hass.data[DOMAIN][entry.entry_id][ - HASS_DATA_UPDATER_KEY - ].websocket_handler: + if status: + if websocket_handler := entry.runtime_data.websocket_handler: websocket_handler.cancel() - del hass.data[DOMAIN][entry.entry_id] - if not hass.data[DOMAIN]: - del hass.data[DOMAIN] return status diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index b2b2d498f60..890976c7503 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -7,12 +7,7 @@ from typing import Any from pyforked_daapd import ForkedDaapdAPI 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_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -28,6 +23,7 @@ from .const import ( DEFAULT_TTS_VOLUME, DOMAIN, ) +from .coordinator import ForkedDaapdConfigEntry _LOGGER = logging.getLogger(__name__) @@ -115,7 +111,7 @@ class ForkedDaapdFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: ForkedDaapdConfigEntry, ) -> ForkedDaapdOptionsFlowHandler: """Return options flow handler.""" return ForkedDaapdOptionsFlowHandler() diff --git a/homeassistant/components/forked_daapd/const.py b/homeassistant/components/forked_daapd/const.py index dd7ed1bdf16..effd4c9454c 100644 --- a/homeassistant/components/forked_daapd/const.py +++ b/homeassistant/components/forked_daapd/const.py @@ -30,9 +30,8 @@ DEFAULT_SERVER_NAME = "My Server" DEFAULT_TTS_PAUSE_TIME = 1.2 DEFAULT_TTS_VOLUME = 0.8 DEFAULT_UNMUTE_VOLUME = 0.6 -DOMAIN = "forked_daapd" # key for hass.data +DOMAIN = "forked_daapd" FD_NAME = "OwnTone" -HASS_DATA_UPDATER_KEY = "UPDATER" KNOWN_PIPES = {"librespot-java"} PIPE_FUNCTION_MAP = { "librespot-java": { diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py index 2db0a75c429..0ba339be505 100644 --- a/homeassistant/components/forked_daapd/coordinator.py +++ b/homeassistant/components/forked_daapd/coordinator.py @@ -9,6 +9,7 @@ from typing import Any from pyforked_daapd import ForkedDaapdAPI +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -22,6 +23,8 @@ from .const import ( SIGNAL_UPDATE_QUEUE, ) +type ForkedDaapdConfigEntry = ConfigEntry[ForkedDaapdUpdater] + _LOGGER = logging.getLogger(__name__) WS_NOTIFY_EVENT_TYPES = ["player", "outputs", "volume", "options", "queue", "database"] @@ -39,6 +42,11 @@ class ForkedDaapdUpdater: self._all_output_ids: set[str] = set() self._entry_id = entry_id + @property + def api(self) -> ForkedDaapdAPI: + """Return the API object.""" + return self._api + async def async_init(self) -> None: """Perform async portion of class initialization.""" if not (server_config := await self._api.get_request("config")): diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 8cbf33460aa..fd5390195a6 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -7,7 +7,6 @@ from collections import defaultdict import logging from typing import Any -from pyforked_daapd import ForkedDaapdAPI from pylibrespot_java import LibrespotJavaAPI from homeassistant.components import media_source @@ -28,8 +27,7 @@ from homeassistant.components.spotify import ( resolve_spotify_media_type, spotify_uri_from_media_browser_url, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -55,9 +53,7 @@ from .const import ( DEFAULT_TTS_PAUSE_TIME, DEFAULT_TTS_VOLUME, DEFAULT_UNMUTE_VOLUME, - DOMAIN, FD_NAME, - HASS_DATA_UPDATER_KEY, KNOWN_PIPES, PIPE_FUNCTION_MAP, SIGNAL_ADD_ZONES, @@ -74,23 +70,21 @@ from .const import ( SUPPORTED_FEATURES_ZONE, TTS_TIMEOUT, ) -from .coordinator import ForkedDaapdUpdater +from .coordinator import ForkedDaapdConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ForkedDaapdConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up forked-daapd from a config entry.""" + forked_daapd_updater = config_entry.runtime_data + host: str = config_entry.data[CONF_HOST] - port: int = config_entry.data[CONF_PORT] - password: str = config_entry.data[CONF_PASSWORD] - forked_daapd_api = ForkedDaapdAPI( - async_get_clientsession(hass), host, port, password - ) + forked_daapd_api = forked_daapd_updater.api forked_daapd_master = ForkedDaapdMaster( clientsession=async_get_clientsession(hass), api=forked_daapd_api, @@ -111,20 +105,12 @@ async def async_setup_entry( ) config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) - if not hass.data.get(DOMAIN): - hass.data[DOMAIN] = {config_entry.entry_id: {}} - async_add_entities([forked_daapd_master], False) - forked_daapd_updater = ForkedDaapdUpdater( - hass, forked_daapd_api, config_entry.entry_id - ) - hass.data[DOMAIN][config_entry.entry_id][HASS_DATA_UPDATER_KEY] = ( - forked_daapd_updater - ) + await forked_daapd_updater.async_init() -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: ForkedDaapdConfigEntry) -> None: """Handle options update.""" async_dispatcher_send( hass, SIGNAL_CONFIG_OPTIONS_UPDATE.format(entry.entry_id), entry.options diff --git a/homeassistant/components/frankever/__init__.py b/homeassistant/components/frankever/__init__.py new file mode 100644 index 00000000000..66eeecb1e59 --- /dev/null +++ b/homeassistant/components/frankever/__init__.py @@ -0,0 +1 @@ +"""FrankEver virtual integration.""" diff --git a/homeassistant/components/frankever/manifest.json b/homeassistant/components/frankever/manifest.json new file mode 100644 index 00000000000..37d7be765ef --- /dev/null +++ b/homeassistant/components/frankever/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "frankever", + "name": "FrankEver", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py index 90ebd53048a..94ccae61088 100644 --- a/homeassistant/components/freebox/__init__.py +++ b/homeassistant/components/freebox/__init__.py @@ -1,25 +1,21 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" from datetime import timedelta -import logging from freebox_api.exceptions import HttpRequestError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.event import async_track_time_interval -from .const import DOMAIN, PLATFORMS, SERVICE_REBOOT -from .router import FreeboxRouter, get_api +from .const import PLATFORMS +from .router import FreeboxConfigEntry, FreeboxRouter, get_api SCAN_INTERVAL = timedelta(seconds=30) -_LOGGER = logging.getLogger(__name__) - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> bool: """Set up Freebox entry.""" api = await get_api(hass, entry.data[CONF_HOST]) try: @@ -35,25 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_track_time_interval(hass, router.update_all, SCAN_INTERVAL) ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.unique_id] = router + entry.runtime_data = router await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Services - async def async_reboot(call: ServiceCall) -> None: - """Handle reboot service call.""" - # The Freebox reboot service has been replaced by a - # dedicated button entity and marked as deprecated - _LOGGER.warning( - "The 'freebox.reboot' service is deprecated and " - "replaced by a dedicated reboot button entity; please " - "use that entity to reboot the freebox instead" - ) - await router.reboot() - - hass.services.async_register(DOMAIN, SERVICE_REBOOT, async_reboot) - async def async_close_connection(event: Event) -> None: """Close Freebox connection on HA Stop.""" await router.close() @@ -61,16 +42,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_close_connection) ) + entry.async_on_unload(router.close) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FreeboxConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - router: FreeboxRouter = hass.data[DOMAIN].pop(entry.unique_id) - await router.close() - hass.services.async_remove(DOMAIN, SERVICE_REBOOT) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py index 89462b33a2f..b0242a1b054 100644 --- a/homeassistant/components/freebox/alarm_control_panel.py +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -7,13 +7,12 @@ 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 AddConfigEntryEntitiesCallback -from .const import DOMAIN, FreeboxHomeCategory +from .const import FreeboxHomeCategory from .entity import FreeboxHomeEntity -from .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter FREEBOX_TO_STATUS = { "alarm1_arming": AlarmControlPanelState.ARMING, @@ -29,11 +28,11 @@ FREEBOX_TO_STATUS = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up alarm panel.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data async_add_entities( ( diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index 9fc9929b869..75b7dded36a 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -10,15 +10,14 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry 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 AddConfigEntryEntitiesCallback -from .const import DOMAIN, FreeboxHomeCategory +from .const import FreeboxHomeCategory from .entity import FreeboxHomeEntity -from .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter _LOGGER = logging.getLogger(__name__) @@ -35,11 +34,11 @@ RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data _LOGGER.debug("%s - %s - %s raid(s)", router.name, router.mac, len(router.raids)) diff --git a/homeassistant/components/freebox/button.py b/homeassistant/components/freebox/button.py index 4f676fd46a1..21a7b1c9990 100644 --- a/homeassistant/components/freebox/button.py +++ b/homeassistant/components/freebox/button.py @@ -10,13 +10,11 @@ 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 AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter @dataclass(frozen=True, kw_only=True) @@ -45,11 +43,11 @@ BUTTON_DESCRIPTIONS: tuple[FreeboxButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the buttons.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data entities = [ FreeboxButton(router, description) for description in BUTTON_DESCRIPTIONS ] diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index 45bb5a34063..d997908dd06 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -12,27 +12,26 @@ from homeassistant.components.ffmpeg.camera import ( DEFAULT_ARGUMENTS, FFmpegCamera, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_DETECTION, DOMAIN, FreeboxHomeCategory +from .const import ATTR_DETECTION, FreeboxHomeCategory from .entity import FreeboxHomeEntity -from .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cameras.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data tracked: set[str] = set() @callback diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 13be45926b4..da5ae836be0 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -8,7 +8,6 @@ import socket from homeassistant.const import Platform DOMAIN = "freebox" -SERVICE_REBOOT = "reboot" APP_DESC = { "app_id": "hass", diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index dcb6eb104b2..243f0de315a 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -6,22 +6,21 @@ from datetime import datetime from typing import Any from homeassistant.components.device_tracker import ScannerEntity -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 AddConfigEntryEntitiesCallback -from .const import DEFAULT_DEVICE_NAME, DEVICE_ICONS, DOMAIN -from .router import FreeboxRouter +from .const import DEFAULT_DEVICE_NAME, DEVICE_ICONS +from .router import FreeboxConfigEntry, FreeboxRouter async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Freebox component.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data tracked: set[str] = set() @callback diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index efa96eca5a7..d6c45cd178b 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -38,6 +38,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type FreeboxConfigEntry = ConfigEntry[FreeboxRouter] + def is_json(json_str: str) -> bool: """Validate if a String is a JSON value or not.""" @@ -72,7 +74,11 @@ async def get_hosts_list_if_supported( supports_hosts: bool = True fbx_devices: list[dict[str, Any]] = [] try: - fbx_devices = await fbx_api.lan.get_hosts_list() or [] + fbx_interfaces = await fbx_api.lan.get_interfaces() or [] + for interface in fbx_interfaces: + fbx_devices.extend( + await fbx_api.lan.get_hosts_list(interface["name"]) or [] + ) except HttpRequestError as err: if ( (matcher := re.search(r"Request failed \(APIResponse: (.+)\)", str(err))) @@ -98,7 +104,7 @@ class FreeboxRouter: def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, api: Freepybox, freebox_config: Mapping[str, Any], ) -> None: diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index cc62de9ae0d..7a176ca5fa7 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfDataRate, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -20,7 +19,7 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN from .entity import FreeboxHomeEntity -from .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter _LOGGER = logging.getLogger(__name__) @@ -61,11 +60,11 @@ DISK_PARTITION_SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data entities: list[SensorEntity] = [] _LOGGER.debug( diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index c4618b014bf..9506a87b5fa 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -8,13 +8,11 @@ from typing import Any from freebox_api.exceptions import InsufficientPermissionsError 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 AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .router import FreeboxRouter +from .router import FreeboxConfigEntry, FreeboxRouter _LOGGER = logging.getLogger(__name__) @@ -30,11 +28,11 @@ SWITCH_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FreeboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch.""" - router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + router = entry.runtime_data entities = [ FreeboxSwitch(router, entity_description) for entity_description in SWITCH_DESCRIPTIONS diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 05a2a07707f..9610fe4b34d 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -15,6 +15,8 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_FEATURE_DEVICE_TRACKING, + DEFAULT_CONF_FEATURE_DEVICE_TRACKING, DEFAULT_SSL, DOMAIN, FRITZ_AUTH_EXCEPTIONS, @@ -38,6 +40,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bool: """Set up fritzboxtools from config entry.""" _LOGGER.debug("Setting up FRITZ!Box Tools component") + avm_wrapper = AvmWrapper( hass=hass, config_entry=entry, @@ -46,6 +49,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], use_tls=entry.data.get(CONF_SSL, DEFAULT_SSL), + device_discovery_enabled=entry.options.get( + CONF_FEATURE_DEVICE_TRACKING, DEFAULT_CONF_FEATURE_DEVICE_TRACKING + ), ) try: @@ -62,6 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo raise ConfigEntryAuthFailed("Missing UPnP configuration") await avm_wrapper.async_config_entry_first_refresh() + await avm_wrapper.async_trigger_cleanup() entry.runtime_data = avm_wrapper diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 6bc8bb571d4..2a4eb8c82b5 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class FritzBinarySensorEntityDescription( diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 74e8ab5e43e..926e233d159 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -18,7 +18,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles +from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles from .coordinator import ( FRITZ_DATA_KEY, AvmWrapper, @@ -31,6 +31,9 @@ from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + @dataclass(frozen=True, kw_only=True) class FritzButtonDescription(ButtonEntityDescription): @@ -175,16 +178,6 @@ class FritzBoxWOLButton(FritzDeviceBase, ButtonEntity): self._name = f"{self.hostname} Wake on LAN" self._attr_unique_id = f"{self._mac}_wake_on_lan" self._is_available = True - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._mac)}, - default_manufacturer="AVM", - default_model="FRITZ!Box Tracked device", - default_name=device.hostname, - via_device=( - DOMAIN, - avm_wrapper.unique_id, - ), - ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index fb17f872cb6..2c22a35c4dd 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -35,7 +35,9 @@ from homeassistant.helpers.service_info.ssdp import ( from homeassistant.helpers.typing import VolDictType from .const import ( + CONF_FEATURE_DEVICE_TRACKING, CONF_OLD_DISCOVERY, + DEFAULT_CONF_FEATURE_DEVICE_TRACKING, DEFAULT_CONF_OLD_DISCOVERY, DEFAULT_HOST, DEFAULT_HTTP_PORT, @@ -72,7 +74,8 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): """Initialize FRITZ!Box Tools flow.""" self._name: str = "" self._password: str = "" - self._use_tls: bool = False + self._use_tls: bool = DEFAULT_SSL + self._feature_device_discovery: bool = DEFAULT_CONF_FEATURE_DEVICE_TRACKING self._port: int | None = None self._username: str = "" self._model: str = "" @@ -141,6 +144,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): options={ CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME.total_seconds(), CONF_OLD_DISCOVERY: DEFAULT_CONF_OLD_DISCOVERY, + CONF_FEATURE_DEVICE_TRACKING: self._feature_device_discovery, }, ) @@ -204,6 +208,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): self._username = user_input[CONF_USERNAME] self._password = user_input[CONF_PASSWORD] self._use_tls = user_input[CONF_SSL] + self._feature_device_discovery = user_input[CONF_FEATURE_DEVICE_TRACKING] self._port = self._determine_port(user_input) error = await self.async_fritz_tools_init() @@ -234,6 +239,10 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Required( + CONF_FEATURE_DEVICE_TRACKING, + default=DEFAULT_CONF_FEATURE_DEVICE_TRACKING, + ): bool, } ), errors=errors or {}, @@ -250,6 +259,10 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Required( + CONF_FEATURE_DEVICE_TRACKING, + default=DEFAULT_CONF_FEATURE_DEVICE_TRACKING, + ): bool, } ), description_placeholders={"name": self._name}, @@ -405,7 +418,7 @@ class FritzBoxToolsOptionsFlowHandler(OptionsFlow): """Handle options flow.""" if user_input is not None: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(data=user_input) options = self.config_entry.options data_schema = vol.Schema( @@ -420,6 +433,13 @@ class FritzBoxToolsOptionsFlowHandler(OptionsFlow): CONF_OLD_DISCOVERY, default=options.get(CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY), ): bool, + vol.Optional( + CONF_FEATURE_DEVICE_TRACKING, + default=options.get( + CONF_FEATURE_DEVICE_TRACKING, + DEFAULT_CONF_FEATURE_DEVICE_TRACKING, + ), + ): bool, } ) return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 2237823bc3b..32f52e68458 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -40,6 +40,9 @@ PLATFORMS = [ CONF_OLD_DISCOVERY = "old_discovery" DEFAULT_CONF_OLD_DISCOVERY = False +CONF_FEATURE_DEVICE_TRACKING = "feature_device_tracking" +DEFAULT_CONF_FEATURE_DEVICE_TRACKING = True + DSL_CONNECTION: Literal["dsl"] = "dsl" DEFAULT_DEVICE_NAME = "Unknown device" diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index d60232ec8ad..e22a66d254f 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -2,13 +2,12 @@ from __future__ import annotations -from collections.abc import Callable, ValuesView +from collections.abc import Callable, Mapping, ValuesView from dataclasses import dataclass, field from datetime import datetime, timedelta from functools import partial import logging import re -from types import MappingProxyType from typing import Any, TypedDict, cast from fritzconnection import FritzConnection @@ -40,6 +39,7 @@ from homeassistant.util.hass_dict import HassKey from .const import ( CONF_OLD_DISCOVERY, + DEFAULT_CONF_FEATURE_DEVICE_TRACKING, DEFAULT_CONF_OLD_DISCOVERY, DEFAULT_HOST, DEFAULT_SSL, @@ -176,6 +176,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): username: str = DEFAULT_USERNAME, host: str = DEFAULT_HOST, use_tls: bool = DEFAULT_SSL, + device_discovery_enabled: bool = DEFAULT_CONF_FEATURE_DEVICE_TRACKING, ) -> None: """Initialize FritzboxTools class.""" super().__init__( @@ -187,7 +188,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): ) self._devices: dict[str, FritzDevice] = {} - self._options: MappingProxyType[str, Any] | None = None + self._options: Mapping[str, Any] | None = None self._unique_id: str | None = None self.connection: FritzConnection = None self.fritz_guest_wifi: FritzGuestWLAN = None @@ -203,6 +204,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self.port = port self.username = username self.use_tls = use_tls + self.device_discovery_enabled = device_discovery_enabled self.has_call_deflections: bool = False self._model: str | None = None self._current_firmware: str | None = None @@ -213,9 +215,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): str, Callable[[FritzStatus, StateType], Any] ] = {} - async def async_setup( - self, options: MappingProxyType[str, Any] | None = None - ) -> None: + async def async_setup(self, options: Mapping[str, Any] | None = None) -> None: """Wrap up FritzboxTools class setup.""" self._options = options await self.hass.async_add_executor_job(self.setup) @@ -335,10 +335,15 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): "entity_states": {}, } try: - await self.async_scan_devices() + await self.async_update_device_info() + + if self.device_discovery_enabled: + await self.async_scan_devices() + entity_data["entity_states"] = await self.hass.async_add_executor_job( self._entity_states_update ) + if self.has_call_deflections: entity_data[ "call_deflections" @@ -524,9 +529,9 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): return {} def manage_device_info( - self, dev_info: Device, dev_mac: str, consider_home: bool + self, dev_info: Device, dev_mac: str, consider_home: float ) -> bool: - """Update device lists.""" + """Update device lists and return if device is new.""" _LOGGER.debug("Client dev_info: %s", dev_info) if dev_mac in self._devices: @@ -536,6 +541,16 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): device = FritzDevice(dev_mac, dev_info.name) device.update(dev_info, consider_home) self._devices[dev_mac] = device + + # manually register device entry for new connected device + dr.async_get(self.hass).async_get_or_create( + config_entry_id=self.config_entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, dev_mac)}, + default_manufacturer="AVM", + default_model="FRITZ!Box Tracked device", + default_name=device.hostname, + via_device=(DOMAIN, self.unique_id), + ) return True async def async_send_signal_device_update(self, new_device: bool) -> None: @@ -544,12 +559,8 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): if new_device: async_dispatcher_send(self.hass, self.signal_device_new) - async def async_scan_devices(self, now: datetime | None = None) -> None: - """Scan for new devices and return a list of found device ids.""" - - if self.hass.is_stopping: - _ha_is_stopping("scan devices") - return + async def async_update_device_info(self, now: datetime | None = None) -> None: + """Update own device information.""" _LOGGER.debug("Checking host info for FRITZ!Box device %s", self.host) ( @@ -558,6 +569,13 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self._release_url, ) = await self._async_update_device_info() + async def async_scan_devices(self, now: datetime | None = None) -> None: + """Scan for new network devices.""" + + if self.hass.is_stopping: + _ha_is_stopping("scan devices") + return + _LOGGER.debug("Checking devices for FRITZ!Box device %s", self.host) _default_consider_home = DEFAULT_CONSIDER_HOME.total_seconds() if self._options: @@ -676,7 +694,10 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): async def async_trigger_cleanup(self) -> None: """Trigger device trackers cleanup.""" - device_hosts = await self._async_update_hosts_info() + _LOGGER.debug("Device tracker cleanup triggered") + device_hosts = {self.mac: Device(True, "", "", "", "", None)} + if self.device_discovery_enabled: + device_hosts = await self._async_update_hosts_info() entity_reg: er.EntityRegistry = er.async_get(self.hass) config_entry = self.config_entry diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index e066219342e..618214a1c55 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -22,6 +22,9 @@ from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py index 33eb60d72cf..e8b5c49fd43 100644 --- a/homeassistant/components/fritz/entity.py +++ b/homeassistant/components/fritz/entity.py @@ -26,6 +26,9 @@ class FritzDeviceBase(CoordinatorEntity[AvmWrapper]): self._avm_wrapper = avm_wrapper self._mac: str = device.mac_address self._name: str = device.hostname or DEFAULT_DEVICE_NAME + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)} + ) @property def name(self) -> str: diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index d329ec318c5..1fc70dedc6c 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -18,6 +18,9 @@ from .entity import FritzBoxBaseEntity _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 805705eb4b4..c2d18a0be84 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -4,19 +4,13 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: one coverage miss in line 110 - config-flow: - status: todo - comment: data_description are missing + 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: - status: todo - comment: include the proper docs snippet + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: @@ -31,15 +25,11 @@ rules: action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done - docs-installation-parameters: - status: todo - comment: add the proper configuration_basic block + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: todo - comment: not set at the moment, we use a coordinator + parallel-updates: done reauthentication-flow: done test-coverage: status: todo @@ -50,7 +40,7 @@ rules: diagnostics: done discovery-update-info: todo discovery: done - docs-data-update: todo + docs-data-update: done docs-examples: done docs-known-limitations: status: exempt diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index bcee590460f..65a776b9ad5 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -32,6 +32,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime: """Calculate uptime with deviation.""" @@ -193,7 +196,6 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="max_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_max_kb_s_sent_state, ), FritzSensorEntityDescription( @@ -201,7 +203,6 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="max_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_max_kb_s_received_state, ), FritzSensorEntityDescription( @@ -225,6 +226,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="link_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_link_kb_s_sent_state, ), FritzSensorEntityDescription( @@ -232,12 +234,15 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( translation_key="link_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_link_kb_s_received_state, ), FritzSensorEntityDescription( key="link_noise_margin_sent", translation_key="link_noise_margin_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_noise_margin_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -245,6 +250,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( key="link_noise_margin_received", translation_key="link_noise_margin_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_noise_margin_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -252,6 +259,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( key="link_attenuation_sent", translation_key="link_attenuation_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_attenuation_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -259,6 +268,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( key="link_attenuation_received", translation_key="link_attenuation_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, value_fn=_retrieve_link_attenuation_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 06a07cba79e..ee23a8cfbef 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -1,4 +1,13 @@ { + "common": { + "data_description_host": "The hostname or IP address of your FRITZ!Box router.", + "data_description_port": "Leave empty to use the default port.", + "data_description_username": "Username for the FRITZ!Box.", + "data_description_password": "Password for the FRITZ!Box.", + "data_description_ssl": "Use SSL to connect to the FRITZ!Box.", + "data_description_feature_device_tracking": "Enable or disable the network device tracking feature.", + "data_feature_device_tracking": "Enable network device tracking" + }, "config": { "flow_title": "{name}", "step": { @@ -8,7 +17,14 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]" + "ssl": "[%key:common::config_flow::data::ssl%]", + "feature_device_tracking": "[%key:component::fritz::common::data_feature_device_tracking%]" + }, + "data_description": { + "username": "[%key:component::fritz::common::data_description_username%]", + "password": "[%key:component::fritz::common::data_description_password%]", + "ssl": "[%key:component::fritz::common::data_description_ssl%]", + "feature_device_tracking": "[%key:component::fritz::common::data_description_feature_device_tracking%]" } }, "reauth_confirm": { @@ -17,6 +33,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::fritz::common::data_description_username%]", + "password": "[%key:component::fritz::common::data_description_password%]" } }, "reconfigure": { @@ -28,8 +48,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]" }, "data_description": { - "host": "The hostname or IP address of your FRITZ!Box router.", - "port": "Leave it empty to use the default port." + "host": "[%key:component::fritz::common::data_description_host%]", + "port": "[%key:component::fritz::common::data_description_port%]", + "ssl": "[%key:component::fritz::common::data_description_ssl%]" } }, "user": { @@ -40,11 +61,16 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]" + "ssl": "[%key:common::config_flow::data::ssl%]", + "feature_device_tracking": "[%key:component::fritz::common::data_feature_device_tracking%]" }, "data_description": { - "host": "The hostname or IP address of your FRITZ!Box router.", - "port": "Leave it empty to use the default port." + "host": "[%key:component::fritz::common::data_description_host%]", + "port": "[%key:component::fritz::common::data_description_port%]", + "username": "[%key:component::fritz::common::data_description_username%]", + "password": "[%key:component::fritz::common::data_description_password%]", + "ssl": "[%key:component::fritz::common::data_description_ssl%]", + "feature_device_tracking": "[%key:component::fritz::common::data_description_feature_device_tracking%]" } } }, @@ -69,7 +95,13 @@ "init": { "data": { "consider_home": "Seconds to consider a device at 'home'", - "old_discovery": "Enable old discovery method" + "old_discovery": "Enable old discovery method", + "feature_device_tracking": "[%key:component::fritz::common::data_feature_device_tracking%]" + }, + "data_description": { + "consider_home": "Time in seconds to consider a device at home. Default is 180 seconds.", + "old_discovery": "Enable old discovery method. This is needed for some scenarios.", + "feature_device_tracking": "[%key:component::fritz::common::data_description_feature_device_tracking%]" } } } @@ -169,8 +201,12 @@ "config_entry_not_found": { "message": "Failed to perform action \"{service}\". Config entry for target not found" }, - "service_parameter_unknown": { "message": "Action or parameter unknown" }, - "service_not_supported": { "message": "Action not supported" }, + "service_parameter_unknown": { + "message": "Action or parameter unknown" + }, + "service_not_supported": { + "message": "Action not supported" + }, "error_refresh_hosts_info": { "message": "Error refreshing hosts info" }, diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 8b4816f7451..a033e45fcec 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -38,6 +38,9 @@ from .entity import FritzBoxBaseEntity, FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + async def _async_deflection_entities_list( avm_wrapper: AvmWrapper, device_friendly_name: str @@ -511,16 +514,6 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity): self._name = f"{device.hostname} Internet Access" self._attr_unique_id = f"{self._mac}_internet_access" self._attr_entity_category = EntityCategory.CONFIG - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._mac)}, - default_manufacturer="AVM", - default_model="FRITZ!Box Tracked device", - default_name=device.hostname, - via_device=( - DOMAIN, - avm_wrapper.unique_id, - ), - ) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 5d064dc3035..4e54f4c28d3 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + @dataclass(frozen=True, kw_only=True) class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 75683017cb7..791039add31 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -22,19 +22,14 @@ from .entity import FritzBoxDeviceEntity from .model import FritzEntityDescriptionMixinBase -@dataclass(frozen=True) -class FritzEntityDescriptionMixinBinarySensor(FritzEntityDescriptionMixinBase): - """BinarySensor description mixin for Fritz!Smarthome entities.""" - - is_on: Callable[[FritzhomeDevice], bool | None] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class FritzBinarySensorEntityDescription( - BinarySensorEntityDescription, FritzEntityDescriptionMixinBinarySensor + BinarySensorEntityDescription, FritzEntityDescriptionMixinBase ): """Description for Fritz!Smarthome binary sensor entities.""" + is_on: Callable[[FritzhomeDevice], bool | None] + BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( FritzBinarySensorEntityDescription( @@ -60,6 +55,32 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( suitable=lambda device: device.device_lock is not None, is_on=lambda device: not device.device_lock, ), + FritzBinarySensorEntityDescription( + key="battery_low", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + suitable=lambda device: device.battery_low is not None, + is_on=lambda device: device.battery_low, + entity_registry_enabled_default=False, + ), + FritzBinarySensorEntityDescription( + key="holiday_active", + translation_key="holiday_active", + suitable=lambda device: device.holiday_active is not None, + is_on=lambda device: device.holiday_active, + ), + FritzBinarySensorEntityDescription( + key="summer_active", + translation_key="summer_active", + suitable=lambda device: device.summer_active is not None, + is_on=lambda device: device.summer_active, + ), + FritzBinarySensorEntityDescription( + key="window_open", + translation_key="window_open", + suitable=lambda device: device.window_open is not None, + is_on=lambda device: device.window_open, + ), ) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 118e03c391f..573877fa71b 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -6,6 +6,7 @@ from typing import Any from homeassistant.components.climate import ( ATTR_HVAC_MODE, + PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, ClimateEntity, @@ -38,7 +39,7 @@ from .sensor import value_scheduled_preset HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF] PRESET_HOLIDAY = "holiday" PRESET_SUMMER = "summer" -PRESET_MODES = [PRESET_ECO, PRESET_COMFORT] +PRESET_MODES = [PRESET_ECO, PRESET_COMFORT, PRESET_BOOST] SUPPORTED_FEATURES = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE @@ -52,8 +53,11 @@ MAX_TEMPERATURE = 28 # special temperatures for on/off in Fritz!Box API (modified by pyfritzhome) ON_API_TEMPERATURE = 127.0 OFF_API_TEMPERATURE = 126.5 -ON_REPORT_SET_TEMPERATURE = 30.0 -OFF_REPORT_SET_TEMPERATURE = 0.0 +PRESET_API_HKR_STATE_MAPPING = { + PRESET_COMFORT: "comfort", + PRESET_BOOST: "on", + PRESET_ECO: "eco", +} async def async_setup_entry( @@ -127,29 +131,29 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): return self.data.actual_temperature # type: ignore [no-any-return] @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if self.data.target_temperature == ON_API_TEMPERATURE: - return ON_REPORT_SET_TEMPERATURE - if self.data.target_temperature == OFF_API_TEMPERATURE: - return OFF_REPORT_SET_TEMPERATURE + if self.data.target_temperature in [ON_API_TEMPERATURE, OFF_API_TEMPERATURE]: + return None return self.data.target_temperature # type: ignore [no-any-return] + async def async_set_hkr_state(self, hkr_state: str) -> None: + """Set the state of the climate.""" + await self.hass.async_add_executor_job(self.data.set_hkr_state, hkr_state, True) + await self.coordinator.async_refresh() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is HVACMode.OFF: - await self.async_set_hvac_mode(hvac_mode) + self.check_active_or_lock_mode() + if kwargs.get(ATTR_HVAC_MODE) is HVACMode.OFF: + await self.async_set_hkr_state("off") elif (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None: - if target_temp == OFF_API_TEMPERATURE: - target_temp = OFF_REPORT_SET_TEMPERATURE - elif target_temp == ON_API_TEMPERATURE: - target_temp = ON_REPORT_SET_TEMPERATURE await self.hass.async_add_executor_job( self.data.set_target_temperature, target_temp, True ) + await self.coordinator.async_refresh() else: return - await self.coordinator.async_refresh() @property def hvac_mode(self) -> HVACMode: @@ -158,28 +162,21 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): return HVACMode.HEAT if self.data.summer_active: return HVACMode.OFF - if self.data.target_temperature in ( - OFF_REPORT_SET_TEMPERATURE, - OFF_API_TEMPERATURE, - ): + if self.data.target_temperature == OFF_API_TEMPERATURE: return HVACMode.OFF return HVACMode.HEAT async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" - if self.data.holiday_active or self.data.summer_active: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="change_hvac_while_active_mode", - ) + self.check_active_or_lock_mode() if self.hvac_mode is hvac_mode: LOGGER.debug( "%s is already in requested hvac mode %s", self.name, hvac_mode ) return if hvac_mode is HVACMode.OFF: - await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) + await self.async_set_hkr_state("off") else: if value_scheduled_preset(self.data) == PRESET_ECO: target_temp = self.data.eco_temperature @@ -194,6 +191,8 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): return PRESET_HOLIDAY if self.data.summer_active: return PRESET_SUMMER + if self.data.target_temperature == ON_API_TEMPERATURE: + return PRESET_BOOST if self.data.target_temperature == self.data.comfort_temperature: return PRESET_COMFORT if self.data.target_temperature == self.data.eco_temperature: @@ -202,19 +201,13 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" - if self.data.holiday_active or self.data.summer_active: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="change_preset_while_active_mode", - ) - if preset_mode == PRESET_COMFORT: - await self.async_set_temperature(temperature=self.data.comfort_temperature) - elif preset_mode == PRESET_ECO: - await self.async_set_temperature(temperature=self.data.eco_temperature) + self.check_active_or_lock_mode() + await self.async_set_hkr_state(PRESET_API_HKR_STATE_MAPPING[preset_mode]) @property def extra_state_attributes(self) -> ClimateExtraAttributes: """Return the device specific state attributes.""" + # deprecated with #143394, can be removed in 2025.11 attrs: ClimateExtraAttributes = { ATTR_STATE_BATTERY_LOW: self.data.battery_low, } @@ -230,3 +223,17 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): attrs[ATTR_STATE_WINDOW_OPEN] = self.data.window_open return attrs + + def check_active_or_lock_mode(self) -> None: + """Check if in summer/vacation mode or lock enabled.""" + if self.data.holiday_active or self.data.summer_active: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="change_settings_while_active_mode", + ) + + if self.data.lock: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="change_settings_while_lock_enabled", + ) diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 34df3885deb..8a37ebf63e4 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -77,12 +77,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.configuration_url = self.fritz.get_prefixed_host() await self.async_config_entry_first_refresh() - self.cleanup_removed_devices( - list(self.data.devices) + list(self.data.templates) - ) + self.cleanup_removed_devices(self.data) - def cleanup_removed_devices(self, available_ains: list[str]) -> None: + def cleanup_removed_devices(self, data: FritzboxCoordinatorData) -> None: """Cleanup entity and device registry from removed devices.""" + available_ains = list(data.devices) + list(data.templates) entity_reg = er.async_get(self.hass) for entity in er.async_entries_for_config_entry( entity_reg, self.config_entry.entry_id @@ -91,8 +90,13 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat LOGGER.debug("Removing obsolete entity entry %s", entity.entity_id) entity_reg.async_remove(entity.entity_id) + available_main_ains = [ + ain + for ain, dev in data.devices.items() | data.templates.items() + if dev.device_and_unit_id[1] is None + ] device_reg = dr.async_get(self.hass) - identifiers = {(DOMAIN, ain) for ain in available_ains} + identifiers = {(DOMAIN, ain) for ain in available_main_ains} for device in dr.async_entries_for_config_entry( device_reg, self.config_entry.entry_id ): @@ -165,12 +169,26 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat """Fetch all device data.""" new_data = await self.hass.async_add_executor_job(self._update_fritz_devices) + for device in new_data.devices.values(): + # create device registry entry for new main devices + if ( + device.ain not in self.data.devices + and device.device_and_unit_id[1] is None + ): + dr.async_get(self.hass).async_get_or_create( + config_entry_id=self.config_entry.entry_id, + name=device.name, + identifiers={(DOMAIN, device.ain)}, + manufacturer=device.manufacturer, + model=device.productname, + sw_version=device.fw_version, + configuration_url=self.configuration_url, + ) + if ( self.data.devices.keys() - new_data.devices.keys() or self.data.templates.keys() - new_data.templates.keys() ): - self.cleanup_removed_devices( - list(new_data.devices) + list(new_data.templates) - ) + self.cleanup_removed_devices(new_data) return new_data diff --git a/homeassistant/components/fritzbox/entity.py b/homeassistant/components/fritzbox/entity.py index cd619588bc1..bbc7d9fe276 100644 --- a/homeassistant/components/fritzbox/entity.py +++ b/homeassistant/components/fritzbox/entity.py @@ -58,11 +58,4 @@ class FritzBoxDeviceEntity(FritzBoxEntity): @property def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return DeviceInfo( - name=self.data.name, - identifiers={(DOMAIN, self.ain)}, - manufacturer=self.data.manufacturer, - model=self.data.productname, - sw_version=self.data.fw_version, - configuration_url=self.coordinator.configuration_url, - ) + return DeviceInfo(identifiers={(DOMAIN, self.data.device_and_unit_id[0])}) diff --git a/homeassistant/components/fritzbox/icons.json b/homeassistant/components/fritzbox/icons.json index 5eb819cdde8..4557b23511c 100644 --- a/homeassistant/components/fritzbox/icons.json +++ b/homeassistant/components/fritzbox/icons.json @@ -1,5 +1,28 @@ { "entity": { + "binary_sensor": { + "holiday_active": { + "default": "mdi:bag-suitcase-outline", + "state": { + "on": "mdi:bag-suitcase-outline", + "off": "mdi:bag-suitcase-off-outline" + } + }, + "summer_active": { + "default": "mdi:radiator-off", + "state": { + "on": "mdi:radiator-off", + "off": "mdi:radiator" + } + }, + "window_open": { + "default": "mdi:window-open", + "state": { + "on": "mdi:window-open", + "off": "mdi:window-closed" + } + } + }, "climate": { "thermostat": { "state_attributes": { diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index bed7004bd6a..8e3ab5d6892 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -35,20 +35,14 @@ from .entity import FritzBoxDeviceEntity from .model import FritzEntityDescriptionMixinBase -@dataclass(frozen=True) -class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): - """Sensor description mixin for Fritz!Smarthome entities.""" - - native_value: Callable[[FritzhomeDevice], StateType | datetime] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class FritzSensorEntityDescription( - SensorEntityDescription, FritzEntityDescriptionMixinSensor + SensorEntityDescription, FritzEntityDescriptionMixinBase ): """Description for Fritz!Smarthome sensor entities.""" entity_category_fn: Callable[[FritzhomeDevice], EntityCategory | None] | None = None + native_value: Callable[[FritzhomeDevice], StateType | datetime] def suitable_eco_temperature(device: FritzhomeDevice) -> bool: @@ -137,6 +131,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, suitable=lambda device: device.battery_level is not None, native_value=lambda device: device.battery_level, diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index c7c2439b566..38bc6dc9c39 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -55,7 +55,10 @@ "binary_sensor": { "alarm": { "name": "Alarm" }, "device_lock": { "name": "Button lock via UI" }, - "lock": { "name": "Button lock on device" } + "holiday_active": { "name": "Holiday mode" }, + "lock": { "name": "Button lock on device" }, + "summer_active": { "name": "Summer mode" }, + "window_open": { "name": "Open window detected" } }, "climate": { "thermostat": { @@ -85,11 +88,11 @@ "manual_switching_disabled": { "message": "Can't toggle switch while manual switching is disabled for the device." }, - "change_preset_while_active_mode": { - "message": "Can't change preset while holiday or summer mode is active on the device." + "change_settings_while_lock_enabled": { + "message": "Can't change settings while manual access for telephone, app, or user interface is disabled on the device" }, - "change_hvac_while_active_mode": { - "message": "Can't change hvac mode while holiday or summer mode is active on the device." + "change_settings_while_active_mode": { + "message": "Can't change settings while holiday or summer mode is active on the device." } } } diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 437b218a8e2..35af748ebe7 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -39,9 +39,9 @@ "options": { "step": { "init": { - "title": "Configure Prefixes", + "title": "Configure prefixes", "data": { - "prefixes": "Prefixes (comma separated list)" + "prefixes": "Prefixes (comma-separated list)" } } }, diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 4ba893df85c..8a3d1ebf04c 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -45,7 +45,15 @@ type FroniusConfigEntry = ConfigEntry[FroniusSolarNet] async def async_setup_entry(hass: HomeAssistant, entry: FroniusConfigEntry) -> bool: """Set up fronius from a config entry.""" host = entry.data[CONF_HOST] - fronius = Fronius(async_get_clientsession(hass), host) + fronius = Fronius( + async_get_clientsession( + hass, + # Fronius Gen24 firmware 1.35.4-1 redirects to HTTPS with self-signed + # certificate. See https://github.com/home-assistant/core/issues/138881 + verify_ssl=False, + ), + host, + ) solar_net = FroniusSolarNet(hass, entry, fronius) await solar_net.init_devices() diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py index f35c9ce5bc1..97e040abf98 100644 --- a/homeassistant/components/fronius/config_flow.py +++ b/homeassistant/components/fronius/config_flow.py @@ -35,7 +35,7 @@ async def validate_host( hass: HomeAssistant, host: str ) -> tuple[str, FroniusConfigEntryData]: """Validate the user input allows us to connect.""" - fronius = Fronius(async_get_clientsession(hass), host) + fronius = Fronius(async_get_clientsession(hass, verify_ssl=False), host) try: datalogger_info: dict[str, Any] @@ -149,7 +149,7 @@ class FroniusConfigFlow(ConfigFlow, domain=DOMAIN): unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/fronius/icons.json b/homeassistant/components/fronius/icons.json index a84140617dd..59d5a110449 100644 --- a/homeassistant/components/fronius/icons.json +++ b/homeassistant/components/fronius/icons.json @@ -4,13 +4,13 @@ "current_dc": { "default": "mdi:current-dc" }, - "current_dc_2": { + "current_dc_mppt_no": { "default": "mdi:current-dc" }, "voltage_dc": { "default": "mdi:current-dc" }, - "voltage_dc_2": { + "voltage_dc_mppt_no": { "default": "mdi:current-dc" }, "co2_factor": { diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 94d0f90b0bd..661d808ad23 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -11,6 +11,6 @@ "documentation": "https://www.home-assistant.io/integrations/fronius", "iot_class": "local_polling", "loggers": ["pyfronius"], - "quality_scale": "gold", - "requirements": ["PyFronius==0.7.3"] + "quality_scale": "platinum", + "requirements": ["PyFronius==0.7.7"] } diff --git a/homeassistant/components/fronius/quality_scale.yaml b/homeassistant/components/fronius/quality_scale.yaml index 2c4b892475b..522b8ab571f 100644 --- a/homeassistant/components/fronius/quality_scale.yaml +++ b/homeassistant/components/fronius/quality_scale.yaml @@ -83,7 +83,4 @@ rules: # 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. + strict-typing: done diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index c65f6072ba6..e287786aaa8 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -168,6 +168,26 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + translation_key="current_dc_mppt_no", + translation_placeholders={"mppt_no": "2"}, + ), + FroniusSensorEntityDescription( + key="current_dc_3", + default_value=0, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="current_dc_mppt_no", + translation_placeholders={"mppt_no": "3"}, + ), + FroniusSensorEntityDescription( + key="current_dc_4", + default_value=0, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="current_dc_mppt_no", + translation_placeholders={"mppt_no": "4"}, ), FroniusSensorEntityDescription( key="power_ac", @@ -197,6 +217,26 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_dc_mppt_no", + translation_placeholders={"mppt_no": "2"}, + ), + FroniusSensorEntityDescription( + key="voltage_dc_3", + default_value=0, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_dc_mppt_no", + translation_placeholders={"mppt_no": "3"}, + ), + FroniusSensorEntityDescription( + key="voltage_dc_4", + default_value=0, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_dc_mppt_no", + translation_placeholders={"mppt_no": "4"}, ), # device status entities FroniusSensorEntityDescription( @@ -727,7 +767,7 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn self.response_key = description.response_key or description.key self.solar_net_id = solar_net_id self._attr_native_value = self._get_entity_value() - self._attr_translation_key = description.key + self._attr_translation_key = description.translation_key or description.key def _device_data(self) -> dict[str, Any]: """Extract information for SolarNet device from coordinator data.""" diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index b77f6fec83c..e965e3117c5 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -52,8 +52,8 @@ "current_dc": { "name": "DC current" }, - "current_dc_2": { - "name": "DC current 2" + "current_dc_mppt_no": { + "name": "DC current {mppt_no}" }, "power_ac": { "name": "AC power" @@ -64,8 +64,8 @@ "voltage_dc": { "name": "DC voltage" }, - "voltage_dc_2": { - "name": "DC voltage 2" + "voltage_dc_mppt_no": { + "name": "DC voltage {mppt_no}" }, "inverter_state": { "name": "Inverter state" @@ -82,13 +82,13 @@ "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", + "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", + "dc_module_over_temperature": "DC module overtemperature", + "ac_module_over_temperature": "AC module overtemperature", "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", @@ -107,7 +107,7 @@ "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", + "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", @@ -133,23 +133,23 @@ "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", + "derating_caused_by_over_frequency": "Derating caused by overfrequency", "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", + "grid_voltage_dependent_power_reduction_active": "Grid voltage-dependent power reduction (GVDPR) 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", + "eeprom_reinitialised": "EEPROM has been re-initialized", + "initialisation_error_usb_flash_drive_not_supported": "Initialization error – USB flash drive is not supported", + "initialisation_error_usb_stick_over_current": "Initialization error – Overcurrent 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_not_recognised_or_missing": "Update file not recognized 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", + "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": "Initialization 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", @@ -166,7 +166,7 @@ "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", + "wired_shut_down_triggered": "Wired shutdown 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", @@ -182,10 +182,10 @@ "state": { "startup": "Startup", "running": "Running", - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "bootloading": "Bootloading", - "error": "Error", - "idle": "Idle", + "error": "[%key:common::state::error%]", + "idle": "[%key:common::state::idle%]", "ready": "Ready", "sleeping": "Sleeping" } @@ -317,11 +317,11 @@ "state_message": { "name": "State message", "state": { + "fault": "[%key:common::state::fault%]", + "critical_fault": "Critical fault", "up_and_running": "Up and running", "keep_minimum_temperature": "Keep minimum temperature", "legionella_protection": "Legionella protection", - "critical_fault": "Critical fault", - "fault": "Fault", "boost_mode": "Boost mode" } }, @@ -362,7 +362,7 @@ "name": "Relative autonomy" }, "relative_self_consumption": { - "name": "Relative self consumption" + "name": "Relative self-consumption" }, "capacity_maximum": { "name": "Maximum capacity" diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6184d888004..9a0627f9f42 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -52,10 +52,9 @@ CONF_JS_VERSION = "javascript_version" DEFAULT_THEME_COLOR = "#03A9F4" -DATA_PANELS = "frontend_panels" -DATA_JS_VERSION = "frontend_js_version" -DATA_EXTRA_MODULE_URL = "frontend_extra_module_url" -DATA_EXTRA_JS_URL_ES5 = "frontend_extra_js_url_es5" +DATA_PANELS: HassKey[dict[str, Panel]] = HassKey("frontend_panels") +DATA_EXTRA_MODULE_URL: HassKey[UrlManager] = HassKey("frontend_extra_module_url") +DATA_EXTRA_JS_URL_ES5: HassKey[UrlManager] = HassKey("frontend_extra_js_url_es5") DATA_WS_SUBSCRIBERS: HassKey[set[tuple[websocket_api.ActiveConnection, int]]] = HassKey( "frontend_ws_subscribers" @@ -64,8 +63,8 @@ DATA_WS_SUBSCRIBERS: HassKey[set[tuple[websocket_api.ActiveConnection, int]]] = THEMES_STORAGE_KEY = f"{DOMAIN}_theme" THEMES_STORAGE_VERSION = 1 THEMES_SAVE_DELAY = 60 -DATA_THEMES_STORE = "frontend_themes_store" -DATA_THEMES = "frontend_themes" +DATA_THEMES_STORE: HassKey[Store] = HassKey("frontend_themes_store") +DATA_THEMES: HassKey[dict[str, Any]] = HassKey("frontend_themes") DATA_DEFAULT_THEME = "frontend_default_theme" DATA_DEFAULT_DARK_THEME = "frontend_default_dark_theme" DEFAULT_THEME = "default" @@ -242,7 +241,7 @@ class Panel: sidebar_title: str | None = None # Url to show the panel in the frontend - frontend_url_path: str | None = None + frontend_url_path: str # Config to pass to the webcomponent config: dict[str, Any] | None = None @@ -273,7 +272,7 @@ class Panel: self.config_panel_domain = config_panel_domain @callback - def to_response(self) -> PanelRespons: + def to_response(self) -> PanelResponse: """Panel as dictionary.""" return { "component_name": self.component_name, @@ -631,7 +630,8 @@ class IndexView(web_urldispatcher.AbstractResource): def get_info(self) -> dict[str, list[str]]: # type: ignore[override] """Return a dict with additional info useful for introspection.""" - return {"panels": list(self.hass.data[DATA_PANELS])} + panels = self.hass.data[DATA_PANELS] + return {"panels": list(panels)} def raw_match(self, path: str) -> bool: """Perform a raw match against path.""" @@ -841,13 +841,13 @@ def websocket_subscribe_extra_js( connection.send_message(websocket_api.result_message(msg["id"])) -class PanelRespons(TypedDict): +class PanelResponse(TypedDict): """Represent the panel response type.""" component_name: str icon: str | None title: str | None config: dict[str, Any] | None - url_path: str | None + url_path: str require_admin: bool config_panel_domain: str | None diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7bd361041e1..5c5feca98b7 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250226.0"] + "requirements": ["home-assistant-frontend==20250516.0"] } diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index cbcc3024aa7..11d155dbcb4 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -12,48 +12,80 @@ from homeassistant.components import websocket_api from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store +from homeassistant.util.hass_dict import HassKey -DATA_STORAGE = "frontend_storage" +DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage") STORAGE_VERSION_USER_DATA = 1 -@callback -def _initialize_frontend_storage(hass: HomeAssistant) -> None: - """Set up frontend storage.""" - if DATA_STORAGE in hass.data: - return - hass.data[DATA_STORAGE] = ({}, {}) - - async def async_setup_frontend_storage(hass: HomeAssistant) -> None: """Set up frontend storage.""" - _initialize_frontend_storage(hass) websocket_api.async_register_command(hass, websocket_set_user_data) websocket_api.async_register_command(hass, websocket_get_user_data) + websocket_api.async_register_command(hass, websocket_subscribe_user_data) -async def async_user_store( - hass: HomeAssistant, user_id: str -) -> tuple[Store, dict[str, Any]]: +async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore: """Access a user store.""" - _initialize_frontend_storage(hass) - stores, data = hass.data[DATA_STORAGE] + stores = hass.data.setdefault(DATA_STORAGE, {}) if (store := stores.get(user_id)) is None: - store = stores[user_id] = Store( + store = stores[user_id] = UserStore(hass, user_id) + await store.async_load() + + return store + + +class UserStore: + """User store for frontend data.""" + + def __init__(self, hass: HomeAssistant, user_id: str) -> None: + """Initialize the user store.""" + self._store = _UserStore(hass, user_id) + self.data: dict[str, Any] = {} + self.subscriptions: dict[str | None, list[Callable[[], None]]] = {} + + async def async_load(self) -> None: + """Load the data from the store.""" + self.data = await self._store.async_load() or {} + + async def async_set_item(self, key: str, value: Any) -> None: + """Set an item item and save the store.""" + self.data[key] = value + await self._store.async_save(self.data) + for cb in self.subscriptions.get(None, []): + cb() + for cb in self.subscriptions.get(key, []): + cb() + + @callback + def async_subscribe( + self, key: str | None, on_update_callback: Callable[[], None] + ) -> Callable[[], None]: + """Save the data to the store.""" + self.subscriptions.setdefault(key, []).append(on_update_callback) + + def unsubscribe() -> None: + """Unsubscribe from the store.""" + self.subscriptions[key].remove(on_update_callback) + + return unsubscribe + + +class _UserStore(Store[dict[str, Any]]): + """User store for frontend data.""" + + def __init__(self, hass: HomeAssistant, user_id: str) -> None: + """Initialize the user store.""" + super().__init__( hass, STORAGE_VERSION_USER_DATA, f"frontend.user_data_{user_id}", ) - if user_id not in data: - data[user_id] = await store.async_load() or {} - return store, data[user_id] - - -def with_store( +def with_user_store( orig_func: Callable[ - [HomeAssistant, ActiveConnection, dict[str, Any], Store, dict[str, Any]], + [HomeAssistant, ActiveConnection, dict[str, Any], UserStore], Coroutine[Any, Any, None], ], ) -> Callable[ @@ -62,17 +94,17 @@ def with_store( """Decorate function to provide data.""" @wraps(orig_func) - async def with_store_func( + async def with_user_store_func( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" user_id = connection.user.id - store, user_data = await async_user_store(hass, user_id) + store = await async_user_store(hass, user_id) - await orig_func(hass, connection, msg, store, user_data) + await orig_func(hass, connection, msg, store) - return with_store_func + return with_user_store_func @websocket_api.websocket_command( @@ -83,41 +115,57 @@ def with_store( } ) @websocket_api.async_response -@with_store +@with_user_store async def websocket_set_user_data( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - store: Store, - data: dict[str, Any], + store: UserStore, ) -> None: - """Handle set global data command. - - Async friendly. - """ - data[msg["key"]] = msg["value"] - await store.async_save(data) - connection.send_message(websocket_api.result_message(msg["id"])) + """Handle set user data command.""" + await store.async_set_item(msg["key"], msg["value"]) + connection.send_result(msg["id"]) @websocket_api.websocket_command( {vol.Required("type"): "frontend/get_user_data", vol.Optional("key"): str} ) @websocket_api.async_response -@with_store +@with_user_store async def websocket_get_user_data( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - store: Store, - data: dict[str, Any], + store: UserStore, ) -> None: - """Handle get global data command. - - Async friendly. - """ - connection.send_message( - websocket_api.result_message( - msg["id"], {"value": data.get(msg["key"]) if "key" in msg else data} - ) + """Handle get user data command.""" + data = store.data + connection.send_result( + msg["id"], {"value": data.get(msg["key"]) if "key" in msg else data} ) + + +@websocket_api.websocket_command( + {vol.Required("type"): "frontend/subscribe_user_data", vol.Optional("key"): str} +) +@websocket_api.async_response +@with_user_store +async def websocket_subscribe_user_data( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + store: UserStore, +) -> None: + """Handle subscribe to user data command.""" + key: str | None = msg.get("key") + + def on_data_update() -> None: + """Handle user data update.""" + data = store.data + connection.send_event( + msg["id"], {"value": data.get(key) if key is not None else data} + ) + + connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update) + on_data_update() + connection.send_result(msg["id"]) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index f6514da28ff..dc4f6bea989 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -108,8 +108,8 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) except FSConnectionError: return self.async_abort(reason="cannot_connect") - except Exception as exception: # noqa: BLE001 - _LOGGER.debug(exception) + except Exception: + _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") # try to login with default pin diff --git a/homeassistant/components/fujitsu_fglair/config_flow.py b/homeassistant/components/fujitsu_fglair/config_flow.py index c4b097ff0de..9369fd7b7cd 100644 --- a/homeassistant/components/fujitsu_fglair/config_flow.py +++ b/homeassistant/components/fujitsu_fglair/config_flow.py @@ -62,7 +62,7 @@ class FGLairConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except AylaAuthError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index 330685f89fc..c8fed9b45c9 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.5"] + "requirements": ["ayla-iot-unofficial==1.4.7"] } diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py index 0ad5bec3117..3bb693e1068 100644 --- a/homeassistant/components/fujitsu_fglair/sensor.py +++ b/homeassistant/components/fujitsu_fglair/sensor.py @@ -24,6 +24,7 @@ async def async_setup_entry( async_add_entities( FGLairOutsideTemperature(entry.runtime_data, device) for device in entry.runtime_data.data.values() + if device.outdoor_temperature is not None ) diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index a4b466926f0..fdfdf7910ae 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -1,8 +1,8 @@ { "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." + "data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates." }, "config": { "step": { @@ -151,7 +151,7 @@ } }, "set_config": { - "name": "Set Configuration", + "name": "Set configuration", "description": "Sets a configuration parameter on Fully Kiosk Browser.", "fields": { "key": { @@ -165,7 +165,7 @@ } }, "start_application": { - "name": "Start Application", + "name": "Start application", "description": "Starts an application on the device running Fully Kiosk Browser.", "fields": { "application": { diff --git a/homeassistant/components/fyta/config_flow.py b/homeassistant/components/fyta/config_flow.py index 78cb7647785..9c5ab1de405 100644 --- a/homeassistant/components/fyta/config_flow.py +++ b/homeassistant/components/fyta/config_flow.py @@ -65,8 +65,8 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): return {"base": "invalid_auth"} except FytaPasswordError: return {"base": "invalid_auth", CONF_PASSWORD: "password_error"} - except Exception as e: # noqa: BLE001 - _LOGGER.error(e) + except Exception: + _LOGGER.exception("Unexpected exception") return {"base": "unknown"} finally: await fyta.client.close() diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index ea628f55c6c..615197203a8 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["fyta_cli"], "quality_scale": "platinum", - "requirements": ["fyta_cli==0.7.0"] + "requirements": ["fyta_cli==0.7.2"] } diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 1a25f654e19..a10fa5bfc47 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -9,8 +9,8 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "username": "The email address to login to your FYTA account.", - "password": "The password to login to your FYTA account." + "username": "The email address to log in to your FYTA account.", + "password": "The password to log in to your FYTA account." } }, "reauth_confirm": { @@ -79,9 +79,9 @@ "state": { "no_data": "No data", "too_low": "Too low", - "low": "Low", + "low": "[%key:common::state::low%]", "perfect": "Perfect", - "high": "High", + "high": "[%key:common::state::high%]", "too_high": "Too high" } }, @@ -90,9 +90,9 @@ "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%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -101,9 +101,9 @@ "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%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -112,9 +112,9 @@ "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%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -123,9 +123,9 @@ "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%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, diff --git a/homeassistant/components/gaggenau/__init__.py b/homeassistant/components/gaggenau/__init__.py new file mode 100644 index 00000000000..2c03410c35d --- /dev/null +++ b/homeassistant/components/gaggenau/__init__.py @@ -0,0 +1 @@ +"""Gaggenau virtual integration.""" diff --git a/homeassistant/components/gaggenau/manifest.json b/homeassistant/components/gaggenau/manifest.json new file mode 100644 index 00000000000..9dc38b2e4b3 --- /dev/null +++ b/homeassistant/components/gaggenau/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "gaggenau", + "name": "Gaggenau", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index 4d4bb9f6fb5..7652b4b6f3b 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==6.0.2"] + "requirements": ["odp-amsterdam==6.1.1"] } diff --git a/homeassistant/components/gardena_bluetooth/config_flow.py b/homeassistant/components/gardena_bluetooth/config_flow.py index c7631b62f47..613d0cf21db 100644 --- a/homeassistant/components/gardena_bluetooth/config_flow.py +++ b/homeassistant/components/gardena_bluetooth/config_flow.py @@ -41,6 +41,8 @@ def _is_supported(discovery_info: BluetoothServiceInfo): ProductType.PUMP, ProductType.VALVE, ProductType.WATER_COMPUTER, + ProductType.AUTOMATS, + ProductType.PRESSURE_TANKS, ): _LOGGER.debug("Unsupported device: %s", manufacturer_data) return False diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 28bba1015f5..8c9cda7d998 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.5.0"] + "requirements": ["gardena-bluetooth==1.6.0"] } diff --git a/homeassistant/components/gc100/__init__.py b/homeassistant/components/gc100/__init__.py index a43741b9249..34cbbdbbb1c 100644 --- a/homeassistant/components/gc100/__init__.py +++ b/homeassistant/components/gc100/__init__.py @@ -1,5 +1,7 @@ """Support for controlling Global Cache gc100.""" +from __future__ import annotations + import gc100 import voluptuous as vol @@ -7,13 +9,14 @@ from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey CONF_PORTS = "ports" DEFAULT_PORT = 4998 DOMAIN = "gc100" -DATA_GC100 = "gc100" +DATA_GC100: HassKey[GC100Device] = HassKey("gc100") CONFIG_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/gc100/binary_sensor.py b/homeassistant/components/gc100/binary_sensor.py index cef798935cb..3dcbb355d3a 100644 --- a/homeassistant/components/gc100/binary_sensor.py +++ b/homeassistant/components/gc100/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_PORTS, DATA_GC100 +from . import CONF_PORTS, DATA_GC100, GC100Device _SENSORS_SCHEMA = vol.Schema({cv.string: cv.string}) @@ -31,7 +31,7 @@ def setup_platform( ) -> None: """Set up the GC100 devices.""" binary_sensors = [] - ports = config[CONF_PORTS] + ports: list[dict[str, str]] = config[CONF_PORTS] for port in ports: for port_addr, port_name in port.items(): binary_sensors.append( @@ -43,23 +43,23 @@ def setup_platform( class GC100BinarySensor(BinarySensorEntity): """Representation of a binary sensor from GC100.""" - def __init__(self, name, port_addr, gc100): + def __init__(self, name: str, port_addr: str, gc100: GC100Device) -> None: """Initialize the GC100 binary sensor.""" self._name = name or DEVICE_DEFAULT_NAME self._port_addr = port_addr self._gc100 = gc100 - self._state = None + self._state: bool | None = None # Subscribe to be notified about state changes (PUSH) self._gc100.subscribe(self._port_addr, self.set_state) @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def is_on(self): + def is_on(self) -> bool | None: """Return the state of the entity.""" return self._state @@ -67,7 +67,7 @@ class GC100BinarySensor(BinarySensorEntity): """Update the sensor state.""" self._gc100.read_sensor(self._port_addr, self.set_state) - def set_state(self, state): + def set_state(self, state: int) -> None: """Set the current state.""" self._state = state == 1 self.schedule_update_ha_state() diff --git a/homeassistant/components/gc100/switch.py b/homeassistant/components/gc100/switch.py index 23b178cc647..bb4742bafdf 100644 --- a/homeassistant/components/gc100/switch.py +++ b/homeassistant/components/gc100/switch.py @@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_PORTS, DATA_GC100 +from . import CONF_PORTS, DATA_GC100, GC100Device _SWITCH_SCHEMA = vol.Schema({cv.string: cv.string}) @@ -33,7 +33,7 @@ def setup_platform( ) -> None: """Set up the GC100 devices.""" switches = [] - ports = config[CONF_PORTS] + ports: list[dict[str, str]] = config[CONF_PORTS] for port in ports: for port_addr, port_name in port.items(): switches.append(GC100Switch(port_name, port_addr, hass.data[DATA_GC100])) @@ -43,20 +43,20 @@ def setup_platform( class GC100Switch(SwitchEntity): """Represent a switch/relay from GC100.""" - def __init__(self, name, port_addr, gc100): + def __init__(self, name: str, port_addr: str, gc100: GC100Device) -> None: """Initialize the GC100 switch.""" self._name = name or DEVICE_DEFAULT_NAME self._port_addr = port_addr self._gc100 = gc100 - self._state = None + self._state: bool | None = None @property - def name(self): + def name(self) -> str: """Return the name of the switch.""" return self._name @property - def is_on(self): + def is_on(self) -> bool | None: """Return the state of the entity.""" return self._state @@ -72,7 +72,7 @@ class GC100Switch(SwitchEntity): """Update the sensor state.""" self._gc100.read_sensor(self._port_addr, self.set_state) - def set_state(self, state): + def set_state(self, state: int) -> None: """Set the current state.""" self._state = state == 1 self.schedule_update_ha_state() diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index e96246b70bf..1a8f2fce236 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -25,22 +25,17 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import ( # noqa: F401 - CONF_CATEGORIES, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - FEED, - PLATFORMS, -) +from .const import CONF_CATEGORIES, DEFAULT_SCAN_INTERVAL, PLATFORMS # noqa: F401 _LOGGER = logging.getLogger(__name__) +type GdacsConfigEntry = ConfigEntry[GdacsFeedEntityManager] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, config_entry: GdacsConfigEntry +) -> bool: """Set up the GDACS component as config entry.""" - hass.data.setdefault(DOMAIN, {}) - feeds = hass.data[DOMAIN].setdefault(FEED, {}) - radius = config_entry.data[CONF_RADIUS] if hass.config.units is US_CUSTOMARY_SYSTEM: radius = DistanceConverter.convert( @@ -48,16 +43,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) # Create feed entity manager for all platforms. manager = GdacsFeedEntityManager(hass, config_entry, radius) - feeds[config_entry.entry_id] = manager + config_entry.runtime_data = manager _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) await manager.async_init() return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GdacsConfigEntry) -> bool: """Unload an GDACS component config entry.""" - manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED].pop(entry.entry_id) - await manager.async_stop() + await entry.runtime_data.async_stop() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -65,7 +59,7 @@ class GdacsFeedEntityManager: """Feed Entity Manager for GDACS feed.""" def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, radius_in_km: float + self, hass: HomeAssistant, config_entry: GdacsConfigEntry, radius_in_km: float ) -> None: """Initialize the Feed Entity Manager.""" self._hass = hass diff --git a/homeassistant/components/gdacs/const.py b/homeassistant/components/gdacs/const.py index d1028ed2d08..c040809a357 100644 --- a/homeassistant/components/gdacs/const.py +++ b/homeassistant/components/gdacs/const.py @@ -10,8 +10,6 @@ DOMAIN = "gdacs" PLATFORMS = [Platform.GEO_LOCATION, Platform.SENSOR] -FEED = "feed" - CONF_CATEGORIES = "categories" DEFAULT_ICON = "mdi:alert" diff --git a/homeassistant/components/gdacs/diagnostics.py b/homeassistant/components/gdacs/diagnostics.py index 435e28ca1ae..9501fb29dd2 100644 --- a/homeassistant/components/gdacs/diagnostics.py +++ b/homeassistant/components/gdacs/diagnostics.py @@ -7,26 +7,23 @@ from typing import Any from aio_georss_client.status_update import StatusUpdate 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 . import GdacsFeedEntityManager -from .const import DOMAIN, FEED +from . import GdacsConfigEntry TO_REDACT = {CONF_LATITUDE, CONF_LONGITUDE} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: GdacsConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" data: dict[str, Any] = { "info": async_redact_data(config_entry.data, TO_REDACT), } - manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][config_entry.entry_id] - status_info: StatusUpdate = manager.status_info() + status_info: StatusUpdate = config_entry.runtime_data.status_info() if status_info: data["service"] = { "status": status_info.status, diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index d277ee54f6b..e4057633101 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -10,7 +10,6 @@ from typing import Any from aio_georss_gdacs.feed_entry import GdacsFeedEntry from homeassistant.components.geo_location import GeolocationEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -19,8 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import GdacsFeedEntityManager -from .const import DEFAULT_ICON, DOMAIN, FEED +from . import GdacsConfigEntry, GdacsFeedEntityManager +from .const import DEFAULT_ICON _LOGGER = logging.getLogger(__name__) @@ -53,11 +52,11 @@ SOURCE = "gdacs" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GdacsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GDACS Feed platform.""" - manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id] + manager = entry.runtime_data @callback def async_add_geolocation( diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index a204addd414..f23a02d92b0 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -10,15 +10,14 @@ from typing import Any from aio_georss_client.status_update import StatusUpdate from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from . import GdacsFeedEntityManager -from .const import DOMAIN, FEED +from . import GdacsConfigEntry, GdacsFeedEntityManager +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -38,12 +37,11 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GdacsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GDACS Feed platform.""" - manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id] - sensor = GdacsSensor(entry, manager) + sensor = GdacsSensor(entry, entry.runtime_data) async_add_entities([sensor]) @@ -57,7 +55,7 @@ class GdacsSensor(SensorEntity): _attr_translation_key = "alerts" def __init__( - self, config_entry: ConfigEntry, manager: GdacsFeedEntityManager + self, config_entry: GdacsConfigEntry, manager: GdacsFeedEntityManager ) -> None: """Initialize entity.""" assert config_entry.unique_id diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 35c5ae93b72..b5e25c08851 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==13.1.0", "Pillow==11.1.0"] + "requirements": ["av==13.1.0", "Pillow==11.2.1"] } diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 190caa58b3f..185040f02c9 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -539,10 +539,14 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return assert self._cur_temp is not None and self._target_temp is not None - too_cold = self._target_temp >= self._cur_temp + self._cold_tolerance - too_hot = self._cur_temp >= self._target_temp + self._hot_tolerance + + min_temp = self._target_temp - self._cold_tolerance + max_temp = self._target_temp + self._hot_tolerance + if self._is_device_active: - if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot): + if (self.ac_mode and self._cur_temp <= min_temp) or ( + not self.ac_mode and self._cur_temp >= max_temp + ): _LOGGER.debug("Turning off heater %s", self.heater_entity_id) await self._async_heater_turn_off() elif time is not None: @@ -552,7 +556,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self.heater_entity_id, ) await self._async_heater_turn_on() - elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold): + elif (self.ac_mode and self._cur_temp > max_temp) or ( + not self.ac_mode and self._cur_temp < min_temp + ): _LOGGER.debug("Turning on heater %s", self.heater_entity_id) await self._async_heater_turn_on() elif time is not None: diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index 58280e99543..735e0b0f9e6 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -21,17 +21,17 @@ "heater": "Switch entity used to cool or heat depending on A/C mode.", "target_sensor": "Temperature sensor that reflects the current temperature.", "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.", - "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.", + "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.", "hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5." } }, "presets": { "title": "Temperature presets", "data": { - "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "home_temp": "[%key:common::state::home%]", + "away_temp": "[%key:common::state::not_home%]", "comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "home_temp": "[%key:common::state::home%]", "sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]" } @@ -63,10 +63,10 @@ "presets": { "title": "[%key:component::generic_thermostat::config::step::presets::title%]", "data": { - "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "home_temp": "[%key:common::state::home%]", + "away_temp": "[%key:common::state::not_home%]", "comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "home_temp": "[%key:common::state::home%]", "sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]" } diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json index 42d53c7fa00..79eee2c9a1b 100644 --- a/homeassistant/components/geniushub/strings.json +++ b/homeassistant/components/geniushub/strings.json @@ -45,7 +45,7 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "One of: off, timer or footprint." + "description": "The zone's operating mode." } } }, diff --git a/homeassistant/components/geocaching/__init__.py b/homeassistant/components/geocaching/__init__.py index aa2926df949..144249ac42f 100644 --- a/homeassistant/components/geocaching/__init__.py +++ b/homeassistant/components/geocaching/__init__.py @@ -1,6 +1,5 @@ """The Geocaching integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -8,13 +7,12 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( async_get_config_entry_implementation, ) -from .const import DOMAIN -from .coordinator import GeocachingDataUpdateCoordinator +from .coordinator import GeocachingConfigEntry, GeocachingDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GeocachingConfigEntry) -> bool: """Set up Geocaching from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) @@ -25,15 +23,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GeocachingConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - 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/geocaching/coordinator.py b/homeassistant/components/geocaching/coordinator.py index 41b59d049af..fdf8f1340da 100644 --- a/homeassistant/components/geocaching/coordinator.py +++ b/homeassistant/components/geocaching/coordinator.py @@ -14,14 +14,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, ENVIRONMENT, LOGGER, UPDATE_INTERVAL +type GeocachingConfigEntry = ConfigEntry[GeocachingDataUpdateCoordinator] + class GeocachingDataUpdateCoordinator(DataUpdateCoordinator[GeocachingStatus]): """Class to manage fetching Geocaching data from single endpoint.""" - config_entry: ConfigEntry + config_entry: GeocachingConfigEntry def __init__( - self, hass: HomeAssistant, *, entry: ConfigEntry, session: OAuth2Session + self, + hass: HomeAssistant, + *, + entry: GeocachingConfigEntry, + session: OAuth2Session, ) -> None: """Initialize global Geocaching data updater.""" self.session = session diff --git a/homeassistant/components/geocaching/manifest.json b/homeassistant/components/geocaching/manifest.json index 127519ca5d0..4617bd1c57b 100644 --- a/homeassistant/components/geocaching/manifest.json +++ b/homeassistant/components/geocaching/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/geocaching", "iot_class": "cloud_polling", - "requirements": ["geocachingapi==0.2.1"] + "requirements": ["geocachingapi==0.3.0"] } diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py index c7894afc5ac..a8008229c91 100644 --- a/homeassistant/components/geocaching/sensor.py +++ b/homeassistant/components/geocaching/sensor.py @@ -9,14 +9,13 @@ from typing import cast from geocachingapi.models import GeocachingStatus from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -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 AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import GeocachingDataUpdateCoordinator +from .coordinator import GeocachingConfigEntry, GeocachingDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -65,11 +64,11 @@ SENSORS: tuple[GeocachingSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GeocachingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Geocaching sensor entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( GeocachingSensor(coordinator, description) for description in SENSORS ) diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 46a3482ce1e..6ced8af8bc6 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -20,9 +20,12 @@ from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN +type GeofencyConfigEntry = ConfigEntry[set[str]] + PLATFORMS = [Platform.DEVICE_TRACKER] CONF_MOBILE_BEACONS = "mobile_beacons" @@ -75,16 +78,13 @@ WEBHOOK_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_DATA_GEOFENCY: HassKey[list[str]] = HassKey(DOMAIN) + async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: """Set up the Geofency component.""" - config = hass_config.get(DOMAIN, {}) - mobile_beacons = config.get(CONF_MOBILE_BEACONS, []) - hass.data[DOMAIN] = { - "beacons": [slugify(beacon) for beacon in mobile_beacons], - "devices": set(), - "unsub_device_tracker": {}, - } + mobile_beacons = hass_config.get(DOMAIN, {}).get(CONF_MOBILE_BEACONS, []) + hass.data[_DATA_GEOFENCY] = [slugify(beacon) for beacon in mobile_beacons] return True @@ -99,7 +99,7 @@ async def handle_webhook( text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY ) - if _is_mobile_beacon(data, hass.data[DOMAIN]["beacons"]): + if _is_mobile_beacon(data, hass.data[_DATA_GEOFENCY]): return _set_location(hass, data, None) if data["entry"] == LOCATION_ENTRY: location_name = data["name"] @@ -140,8 +140,9 @@ def _set_location(hass, data, location_name): return web.Response(text=f"Setting location for {device}") -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GeofencyConfigEntry) -> bool: """Configure based on config entry.""" + entry.runtime_data = set() webhook.async_register( hass, DOMAIN, "Geofency", entry.data[CONF_WEBHOOK_ID], handle_webhook ) @@ -150,10 +151,9 @@ 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: GeofencyConfigEntry) -> bool: """Unload a config entry.""" webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - hass.data[DOMAIN]["unsub_device_tracker"].pop(entry.entry_id)() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index c74dad1cebb..4a57eaab2f5 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -1,7 +1,6 @@ """Support for the Geofency device tracker platform.""" from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -10,12 +9,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE +from . import TRACKER_UPDATE, GeofencyConfigEntry +from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GeofencyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Geofency config entry.""" @@ -23,14 +23,16 @@ async def async_setup_entry( @callback def _receive_data(device, gps, location_name, attributes): """Fire HA event to set location.""" - if device in hass.data[GF_DOMAIN]["devices"]: + if device in config_entry.runtime_data: return - hass.data[GF_DOMAIN]["devices"].add(device) + config_entry.runtime_data.add(device) - async_add_entities([GeofencyEntity(device, gps, location_name, attributes)]) + async_add_entities( + [GeofencyEntity(config_entry, device, gps, location_name, attributes)] + ) - hass.data[GF_DOMAIN]["unsub_device_tracker"][config_entry.entry_id] = ( + config_entry.async_on_unload( async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) ) @@ -45,8 +47,8 @@ async def async_setup_entry( } if dev_ids: - hass.data[GF_DOMAIN]["devices"].update(dev_ids) - async_add_entities(GeofencyEntity(dev_id) for dev_id in dev_ids) + config_entry.runtime_data.update(dev_ids) + async_add_entities(GeofencyEntity(config_entry, dev_id) for dev_id in dev_ids) class GeofencyEntity(TrackerEntity, RestoreEntity): @@ -55,8 +57,9 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, device, gps=None, location_name=None, attributes=None): + def __init__(self, entry, device, gps=None, location_name=None, attributes=None): """Set up Geofency entity.""" + self._entry = entry self._attr_extra_state_attributes = attributes or {} self._name = device self._attr_location_name = location_name @@ -66,7 +69,7 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): self._unsub_dispatcher = None self._attr_unique_id = device self._attr_device_info = DeviceInfo( - identifiers={(GF_DOMAIN, device)}, + identifiers={(DOMAIN, device)}, name=device, ) @@ -93,7 +96,7 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): """Clean up after entity before removal.""" await super().async_will_remove_from_hass() self._unsub_dispatcher() - self.hass.data[GF_DOMAIN]["devices"].remove(self.unique_id) + self._entry.runtime_data.remove(self.unique_id) @callback def _async_receive_data(self, device, gps, location_name, attributes): diff --git a/homeassistant/components/geofency/strings.json b/homeassistant/components/geofency/strings.json index 1ce926c3d2f..aa1b51697bf 100644 --- a/homeassistant/components/geofency/strings.json +++ b/homeassistant/components/geofency/strings.json @@ -2,8 +2,8 @@ "config": { "step": { "user": { - "title": "Set up the Geofency Webhook", - "description": "Are you sure you want to set up the Geofency Webhook?" + "title": "Set up the Geofency webhook", + "description": "Are you sure you want to set up the Geofency webhook?" } }, "abort": { diff --git a/homeassistant/components/geonetnz_quakes/__init__.py b/homeassistant/components/geonetnz_quakes/__init__.py index b9443d4aed8..a1522862dca 100644 --- a/homeassistant/components/geonetnz_quakes/__init__.py +++ b/homeassistant/components/geonetnz_quakes/__init__.py @@ -31,7 +31,6 @@ from .const import ( DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN, - FEED, PLATFORMS, ) @@ -59,6 +58,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type GeonetnzQuakesConfigEntry = ConfigEntry[GeonetnzQuakesFeedEntityManager] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the GeoNet NZ Quakes component.""" @@ -89,11 +90,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: GeonetnzQuakesConfigEntry +) -> bool: """Set up the GeoNet NZ Quakes component as config entry.""" - hass.data.setdefault(DOMAIN, {}) - feeds = hass.data[DOMAIN].setdefault(FEED, {}) - radius = config_entry.data[CONF_RADIUS] if hass.config.units is US_CUSTOMARY_SYSTEM: radius = DistanceConverter.convert( @@ -101,16 +101,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) # Create feed entity manager for all platforms. manager = GeonetnzQuakesFeedEntityManager(hass, config_entry, radius) - feeds[config_entry.entry_id] = manager + config_entry.runtime_data = manager _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) await manager.async_init() return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: GeonetnzQuakesConfigEntry +) -> bool: """Unload an GeoNet NZ Quakes component config entry.""" - manager = hass.data[DOMAIN][FEED].pop(entry.entry_id) - await manager.async_stop() + await entry.runtime_data.async_stop() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/geonetnz_quakes/const.py b/homeassistant/components/geonetnz_quakes/const.py index db529a17fbe..9c0f1a08c6f 100644 --- a/homeassistant/components/geonetnz_quakes/const.py +++ b/homeassistant/components/geonetnz_quakes/const.py @@ -11,8 +11,6 @@ PLATFORMS = [Platform.GEO_LOCATION, Platform.SENSOR] CONF_MINIMUM_MAGNITUDE = "minimum_magnitude" CONF_MMI = "mmi" -FEED = "feed" - DEFAULT_FILTER_TIME_INTERVAL = timedelta(days=7) DEFAULT_MINIMUM_MAGNITUDE = 0.0 DEFAULT_MMI = 3 diff --git a/homeassistant/components/geonetnz_quakes/diagnostics.py b/homeassistant/components/geonetnz_quakes/diagnostics.py index fbe9bf511aa..ebb6a2e9046 100644 --- a/homeassistant/components/geonetnz_quakes/diagnostics.py +++ b/homeassistant/components/geonetnz_quakes/diagnostics.py @@ -5,28 +5,23 @@ 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 from homeassistant.core import HomeAssistant -from . import GeonetnzQuakesFeedEntityManager -from .const import DOMAIN, FEED +from . import GeonetnzQuakesConfigEntry TO_REDACT = {CONF_LATITUDE, CONF_LONGITUDE} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: GeonetnzQuakesConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" data: dict[str, Any] = { "info": async_redact_data(config_entry.data, TO_REDACT), } - manager: GeonetnzQuakesFeedEntityManager = hass.data[DOMAIN][FEED][ - config_entry.entry_id - ] - status_info = manager.status_info() + status_info = config_entry.runtime_data.status_info() if status_info: data["service"] = { "status": status_info.status, diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index 96a1c3c09b2..e67d22c850f 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -9,7 +9,6 @@ from typing import Any from aio_geojson_geonetnz_quakes.feed_entry import GeonetnzQuakesFeedEntry from homeassistant.components.geo_location import GeolocationEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -18,8 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import GeonetnzQuakesFeedEntityManager -from .const import DOMAIN, FEED +from . import GeonetnzQuakesConfigEntry, GeonetnzQuakesFeedEntityManager _LOGGER = logging.getLogger(__name__) @@ -39,11 +37,11 @@ SOURCE = "geonetnz_quakes" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GeonetnzQuakesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoNet NZ Quakes Feed platform.""" - manager: GeonetnzQuakesFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id] + manager = entry.runtime_data @callback def async_add_geolocation( diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index b8a1e2dd4db..cc4b4e16282 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -5,13 +5,12 @@ from __future__ import annotations import logging from homeassistant.components.sensor import SensorEntity -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 AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN, FEED +from . import GeonetnzQuakesConfigEntry _LOGGER = logging.getLogger(__name__) @@ -32,11 +31,11 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GeonetnzQuakesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoNet NZ Quakes Feed platform.""" - manager = hass.data[DOMAIN][FEED][entry.entry_id] + manager = entry.runtime_data sensor = GeonetnzQuakesSensor(entry.entry_id, entry.unique_id, entry.title, manager) async_add_entities([sensor]) _LOGGER.debug("Sensor setup done") diff --git a/homeassistant/components/geonetnz_volcano/__init__.py b/homeassistant/components/geonetnz_volcano/__init__.py index b08d6d62c55..c3ceeab33f8 100644 --- a/homeassistant/components/geonetnz_volcano/__init__.py +++ b/homeassistant/components/geonetnz_volcano/__init__.py @@ -29,7 +29,6 @@ from .const import ( DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN, - FEED, IMPERIAL_UNITS, PLATFORMS, ) @@ -52,6 +51,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type GeonetnzVolcanoConfigEntry = ConfigEntry[GeonetnzVolcanoFeedEntityManager] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the GeoNet NZ Volcano component.""" @@ -84,11 +85,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: GeonetnzVolcanoConfigEntry +) -> bool: """Set up the GeoNet NZ Volcano component as config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault(FEED, {}) - radius = config_entry.data[CONF_RADIUS] unit_system = config_entry.data[CONF_UNIT_SYSTEM] if unit_system == IMPERIAL_UNITS: @@ -97,16 +97,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) # Create feed entity manager for all platforms. manager = GeonetnzVolcanoFeedEntityManager(hass, config_entry, radius, unit_system) - hass.data[DOMAIN][FEED][config_entry.entry_id] = manager + config_entry.runtime_data = manager _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) await manager.async_init() return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: GeonetnzVolcanoConfigEntry +) -> bool: """Unload an GeoNet NZ Volcano component config entry.""" - manager = hass.data[DOMAIN][FEED].pop(entry.entry_id) - await manager.async_stop() + await entry.runtime_data.async_stop() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/geonetnz_volcano/const.py b/homeassistant/components/geonetnz_volcano/const.py index be04a25d27a..98ac69fec19 100644 --- a/homeassistant/components/geonetnz_volcano/const.py +++ b/homeassistant/components/geonetnz_volcano/const.py @@ -6,8 +6,6 @@ from homeassistant.const import Platform DOMAIN = "geonetnz_volcano" -FEED = "feed" - ATTR_ACTIVITY = "activity" ATTR_DISTANCE = "distance" ATTR_EXTERNAL_ID = "external_id" diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index bde04acb895..159806778ce 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations import logging from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -13,14 +12,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter +from . import GeonetnzVolcanoConfigEntry from .const import ( ATTR_ACTIVITY, ATTR_DISTANCE, ATTR_EXTERNAL_ID, ATTR_HAZARDS, DEFAULT_ICON, - DOMAIN, - FEED, IMPERIAL_UNITS, ) @@ -32,11 +30,11 @@ ATTR_LAST_UPDATE_SUCCESSFUL = "feed_last_update_successful" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GeonetnzVolcanoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoNet NZ Volcano Feed platform.""" - manager = hass.data[DOMAIN][FEED][entry.entry_id] + manager = entry.runtime_data @callback def async_add_sensor(feed_manager, external_id, unit_system): diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index c76efbcf361..31f704fcacc 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -4,9 +4,14 @@ from __future__ import annotations import logging +from aiohttp.client_exceptions import ClientConnectorError +from gios import Gios +from gios.exceptions import GiosError + from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.const import 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 @@ -36,8 +41,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool device_registry.async_update_device(device_entry.id, new_identifiers={new_ids}) websession = async_get_clientsession(hass) + try: + gios = await Gios.create(websession, station_id) + except (GiosError, ConnectionError, ClientConnectorError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "entry": entry.title, + "error": repr(err), + }, + ) from err - coordinator = GiosDataUpdateCoordinator(hass, entry, websession, station_id) + coordinator = GiosDataUpdateCoordinator(hass, entry, gios) await coordinator.async_config_entry_first_refresh() entry.runtime_data = GiosData(coordinator) diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index a089aeab820..9b242a8cc99 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp.client_exceptions import ClientConnectorError from gios import ApiError, Gios, InvalidSensorsDataError, NoStationError @@ -12,6 +12,12 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import API_TIMEOUT, CONF_STATION_ID, DOMAIN @@ -27,40 +33,59 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} + websession = async_get_clientsession(self.hass) + if user_input is not None: + station_id = user_input[CONF_STATION_ID] + try: - await self.async_set_unique_id( - str(user_input[CONF_STATION_ID]), raise_on_progress=False - ) + await self.async_set_unique_id(station_id, raise_on_progress=False) self._abort_if_unique_id_configured() - websession = async_get_clientsession(self.hass) - async with asyncio.timeout(API_TIMEOUT): - gios = Gios(user_input[CONF_STATION_ID], websession) + gios = await Gios.create(websession, int(station_id)) await gios.async_update() - assert gios.station_name is not None + # GIOS treats station ID as int + user_input[CONF_STATION_ID] = int(station_id) + + if TYPE_CHECKING: + assert gios.station_name is not None + return self.async_create_entry( title=gios.station_name, data=user_input, ) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" - except NoStationError: - errors[CONF_STATION_ID] = "wrong_station_id" except InvalidSensorsDataError: errors[CONF_STATION_ID] = "invalid_sensors_data" + try: + gios = await Gios.create(websession) + except (ApiError, ClientConnectorError, NoStationError): + return self.async_abort(reason="cannot_connect") + + options: list[SelectOptionDict] = [ + SelectOptionDict(value=str(station.id), label=station.name) + for station in gios.measurement_stations.values() + ] + + schema: vol.Schema = vol.Schema( + { + vol.Required(CONF_STATION_ID): SelectSelector( + SelectSelectorConfig( + options=options, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Optional(CONF_NAME, default=self.hass.config.location_name): str, + } + ) + return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_STATION_ID): int, - vol.Optional( - CONF_NAME, default=self.hass.config.location_name - ): str, - } - ), + data_schema=schema, errors=errors, ) diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index a8490511ab8..2294e89c961 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -13,7 +13,7 @@ SCAN_INTERVAL: Final = timedelta(minutes=30) DOMAIN: Final = "gios" MANUFACTURER: Final = "Główny Inspektorat Ochrony Środowiska" -URL = "http://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id}" +URL = "https://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id}" API_TIMEOUT: Final = 30 diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index be4b41ca6ee..eb0dd82eb67 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -6,7 +6,6 @@ import asyncio from dataclasses import dataclass import logging -from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from gios import Gios from gios.exceptions import GiosError @@ -39,11 +38,10 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): self, hass: HomeAssistant, config_entry: GiosConfigEntry, - session: ClientSession, - station_id: int, + gios: Gios, ) -> None: """Class to manage fetching GIOS data API.""" - self.gios = Gios(station_id, session) + self.gios = gios super().__init__( hass, @@ -59,4 +57,11 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): async with asyncio.timeout(API_TIMEOUT): return await self.gios.async_update() except (GiosError, ClientConnectorError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(error), + }, + ) from error diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 3d2e719fab6..8deb2eee414 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==5.0.0"] + "requirements": ["gios==6.0.0"] } diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index fc82f1c843d..eca23159a13 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -5,17 +5,17 @@ "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)", "data": { "name": "[%key:common::config_flow::data::name%]", - "station_id": "ID of the measuring station" + "station_id": "Measuring station" } } }, "error": { - "wrong_station_id": "ID of the measuring station is not correct.", "invalid_sensors_data": "Invalid sensors' data for this measuring station.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, "system_health": { @@ -170,5 +170,13 @@ } } } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while connecting to the GIOS API for {entry}: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the GIOS API for {entry}: {error}" + } } } diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index 234411936cb..491b2269043 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.8" +RECOMMENDED_VERSION = "1.9.9" diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index 07dbd3bd29b..09f7b3fd74c 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -8,6 +8,6 @@ "integration_type": "system", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["go2rtc-client==0.1.2"], + "requirements": ["go2rtc-client==0.1.3b0"], "single_config_entry": true } diff --git a/homeassistant/components/gogogate2/__init__.py b/homeassistant/components/gogogate2/__init__.py index ceb07c99849..1afb77a4f70 100644 --- a/homeassistant/components/gogogate2/__init__.py +++ b/homeassistant/components/gogogate2/__init__.py @@ -1,16 +1,16 @@ """The gogogate2 component.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant -from .common import get_data_update_coordinator +from .common import create_data_update_coordinator from .const import DEVICE_TYPE_GOGOGATE2 +from .coordinator import GogoGateConfigEntry PLATFORMS = [Platform.COVER, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GogoGateConfigEntry) -> bool: """Do setup of Gogogate2.""" # Update the config entry. @@ -24,14 +24,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if config_updates: hass.config_entries.async_update_entry(entry, data=config_updates) - data_update_coordinator = get_data_update_coordinator(hass, entry) + data_update_coordinator = create_data_update_coordinator(hass, entry) await data_update_coordinator.async_config_entry_first_refresh() + entry.runtime_data = data_update_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: GogoGateConfigEntry) -> bool: """Unload Gogogate2 config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index 8506414ca33..a98e1194e5b 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -16,7 +16,6 @@ from ismartgate import ( ) from ismartgate.common import AbstractDoor -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE, CONF_IP_ADDRESS, @@ -27,8 +26,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import UpdateFailed -from .const import DATA_UPDATE_COORDINATOR, DEVICE_TYPE_ISMARTGATE, DOMAIN -from .coordinator import DeviceDataUpdateCoordinator +from .const import DEVICE_TYPE_ISMARTGATE +from .coordinator import DeviceDataUpdateCoordinator, GogoGateConfigEntry _LOGGER = logging.getLogger(__name__) @@ -41,47 +40,40 @@ class StateData(NamedTuple): door: AbstractDoor | None -def get_data_update_coordinator( - hass: HomeAssistant, config_entry: ConfigEntry +def create_data_update_coordinator( + hass: HomeAssistant, config_entry: GogoGateConfigEntry ) -> DeviceDataUpdateCoordinator: """Get an update coordinator.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault(config_entry.entry_id, {}) - config_entry_data = hass.data[DOMAIN][config_entry.entry_id] + api = get_api(hass, config_entry.data) - if DATA_UPDATE_COORDINATOR not in config_entry_data: - api = get_api(hass, config_entry.data) + async def async_update_data() -> GogoGate2InfoResponse | ISmartGateInfoResponse: + try: + return await api.async_info() + except Exception as exception: + raise UpdateFailed( + f"Error communicating with API: {exception}" + ) from exception - async def async_update_data() -> GogoGate2InfoResponse | ISmartGateInfoResponse: - try: - return await api.async_info() - except Exception as exception: - raise UpdateFailed( - f"Error communicating with API: {exception}" - ) from exception - - config_entry_data[DATA_UPDATE_COORDINATOR] = DeviceDataUpdateCoordinator( - hass, - config_entry, - _LOGGER, - api, - # Name of the data. For logging purposes. - name="gogogate2", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=5), - ) - - return config_entry_data[DATA_UPDATE_COORDINATOR] + return DeviceDataUpdateCoordinator( + hass, + config_entry, + _LOGGER, + api, + # Name of the data. For logging purposes. + name="gogogate2", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=5), + ) -def cover_unique_id(config_entry: ConfigEntry, door: AbstractDoor) -> str: +def cover_unique_id(config_entry: GogoGateConfigEntry, door: AbstractDoor) -> str: """Generate a cover entity unique id.""" return f"{config_entry.unique_id}_{door.door_id}" def sensor_unique_id( - config_entry: ConfigEntry, door: AbstractDoor, sensor_type: str + config_entry: GogoGateConfigEntry, door: AbstractDoor, sensor_type: str ) -> str: """Generate a cover entity unique id.""" return f"{config_entry.unique_id}_{door.door_id}_{sensor_type}" diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index 0348d0b428c..cebff656d5d 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses +import logging import re from typing import Any, Self @@ -27,6 +28,8 @@ from homeassistant.helpers.service_info.zeroconf import ( from .common import get_api from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN +_LOGGER = logging.getLogger(__name__) + DEVICE_NAMES = { DEVICE_TYPE_GOGOGATE2: "Gogogate2", DEVICE_TYPE_ISMARTGATE: "ismartgate", @@ -115,7 +118,8 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): else: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" if self._ip_address and self._device_type: diff --git a/homeassistant/components/gogogate2/const.py b/homeassistant/components/gogogate2/const.py index 2f6ac76122f..a5122b7e215 100644 --- a/homeassistant/components/gogogate2/const.py +++ b/homeassistant/components/gogogate2/const.py @@ -1,7 +1,7 @@ """Constants for integration.""" DOMAIN = "gogogate2" -DATA_UPDATE_COORDINATOR = "data_update_coordinator" + DEVICE_TYPE_GOGOGATE2 = "gogogate2" DEVICE_TYPE_ISMARTGATE = "ismartgate" MANUFACTURER = "Remsol" diff --git a/homeassistant/components/gogogate2/coordinator.py b/homeassistant/components/gogogate2/coordinator.py index c2e7cc47b46..5f5a082084c 100644 --- a/homeassistant/components/gogogate2/coordinator.py +++ b/homeassistant/components/gogogate2/coordinator.py @@ -13,18 +13,20 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +type GogoGateConfigEntry = ConfigEntry[DeviceDataUpdateCoordinator] + class DeviceDataUpdateCoordinator( DataUpdateCoordinator[GogoGate2InfoResponse | ISmartGateInfoResponse] ): """Manages polling for state changes from the device.""" - config_entry: ConfigEntry + config_entry: GogoGateConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, logger: logging.Logger, api: AbstractGateApi, *, diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index 9492108d4b2..539e53598fb 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -16,22 +16,21 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import cover_unique_id, get_data_update_coordinator -from .coordinator import DeviceDataUpdateCoordinator +from .common import cover_unique_id +from .coordinator import DeviceDataUpdateCoordinator, GogoGateConfigEntry from .entity import GoGoGate2Entity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the config entry.""" - data_update_coordinator = get_data_update_coordinator(hass, config_entry) + data_update_coordinator = config_entry.runtime_data async_add_entities( [ @@ -48,7 +47,7 @@ class DeviceCover(GoGoGate2Entity, CoverEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, data_update_coordinator: DeviceDataUpdateCoordinator, door: AbstractDoor, ) -> None: diff --git a/homeassistant/components/gogogate2/entity.py b/homeassistant/components/gogogate2/entity.py index 8a699f6101b..a6879f038bc 100644 --- a/homeassistant/components/gogogate2/entity.py +++ b/homeassistant/components/gogogate2/entity.py @@ -4,13 +4,12 @@ from __future__ import annotations from ismartgate.common import AbstractDoor, get_door_by_id -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER -from .coordinator import DeviceDataUpdateCoordinator +from .coordinator import DeviceDataUpdateCoordinator, GogoGateConfigEntry class GoGoGate2Entity(CoordinatorEntity[DeviceDataUpdateCoordinator]): @@ -18,7 +17,7 @@ class GoGoGate2Entity(CoordinatorEntity[DeviceDataUpdateCoordinator]): def __init__( self, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, data_update_coordinator: DeviceDataUpdateCoordinator, door: AbstractDoor, unique_id: str, diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index ce86ca9ac43..c594671b34f 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -11,13 +11,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import get_data_update_coordinator, sensor_unique_id -from .coordinator import DeviceDataUpdateCoordinator +from .common import sensor_unique_id +from .coordinator import DeviceDataUpdateCoordinator, GogoGateConfigEntry from .entity import GoGoGate2Entity SENSOR_ID_WIRED = "WIRE" @@ -25,11 +24,11 @@ SENSOR_ID_WIRED = "WIRE" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the config entry.""" - data_update_coordinator = get_data_update_coordinator(hass, config_entry) + data_update_coordinator = config_entry.runtime_data sensors = chain( [ @@ -69,7 +68,7 @@ class DoorSensorBattery(DoorSensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, data_update_coordinator: DeviceDataUpdateCoordinator, door: AbstractDoor, ) -> None: @@ -97,7 +96,7 @@ class DoorSensorTemperature(DoorSensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: GogoGateConfigEntry, data_update_coordinator: DeviceDataUpdateCoordinator, door: AbstractDoor, ) -> None: diff --git a/homeassistant/components/goodwe/__init__.py b/homeassistant/components/goodwe/__init__.py index 02c1d5beac7..b6637bc8b50 100644 --- a/homeassistant/components/goodwe/__init__.py +++ b/homeassistant/components/goodwe/__init__.py @@ -2,26 +2,17 @@ from goodwe import InverterError, connect -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo -from .const import ( - CONF_MODEL_FAMILY, - DOMAIN, - KEY_COORDINATOR, - KEY_DEVICE_INFO, - KEY_INVERTER, - PLATFORMS, -) -from .coordinator import GoodweUpdateCoordinator +from .const import CONF_MODEL_FAMILY, DOMAIN, PLATFORMS +from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool: """Set up the Goodwe components from a config entry.""" - hass.data.setdefault(DOMAIN, {}) host = entry.data[CONF_HOST] model_family = entry.data[CONF_MODEL_FAMILY] @@ -50,11 +41,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { - KEY_INVERTER: inverter, - KEY_COORDINATOR: coordinator, - KEY_DEVICE_INFO: device_info, - } + entry.runtime_data = GoodweRuntimeData( + inverter=inverter, + coordinator=coordinator, + device_info=device_info, + ) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -63,18 +54,13 @@ 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: GoodweConfigEntry +) -> bool: """Unload a config entry.""" - 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) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/goodwe/button.py b/homeassistant/components/goodwe/button.py index e93b23570db..64d1e08276d 100644 --- a/homeassistant/components/goodwe/button.py +++ b/homeassistant/components/goodwe/button.py @@ -8,13 +8,12 @@ import logging from goodwe import Inverter, InverterError 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 AddConfigEntryEntitiesCallback -from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER +from .coordinator import GoodweConfigEntry _LOGGER = logging.getLogger(__name__) @@ -36,12 +35,12 @@ SYNCHRONIZE_CLOCK = GoodweButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter button entities from a config entry.""" - inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] - device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO] + inverter = config_entry.runtime_data.inverter + device_info = config_entry.runtime_data.device_info # read current time from the inverter try: diff --git a/homeassistant/components/goodwe/const.py b/homeassistant/components/goodwe/const.py index 730433c4a66..432d18e5867 100644 --- a/homeassistant/components/goodwe/const.py +++ b/homeassistant/components/goodwe/const.py @@ -12,7 +12,3 @@ DEFAULT_NAME = "GoodWe" SCAN_INTERVAL = timedelta(seconds=10) CONF_MODEL_FAMILY = "model_family" - -KEY_INVERTER = "inverter" -KEY_COORDINATOR = "coordinator" -KEY_DEVICE_INFO = "device_info" diff --git a/homeassistant/components/goodwe/coordinator.py b/homeassistant/components/goodwe/coordinator.py index 914ba3155b4..3236b95d9e0 100644 --- a/homeassistant/components/goodwe/coordinator.py +++ b/homeassistant/components/goodwe/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any @@ -9,22 +10,34 @@ from goodwe import Inverter, InverterError, RequestFailedException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type GoodweConfigEntry = ConfigEntry[GoodweRuntimeData] + + +@dataclass +class GoodweRuntimeData: + """Data class for runtime data.""" + + inverter: Inverter + coordinator: GoodweUpdateCoordinator + device_info: DeviceInfo + class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Gather data for the energy device.""" - config_entry: ConfigEntry + config_entry: GoodweConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: GoodweConfigEntry, inverter: Inverter, ) -> None: """Initialize update coordinator.""" diff --git a/homeassistant/components/goodwe/diagnostics.py b/homeassistant/components/goodwe/diagnostics.py index 66806d31589..ece5f3b6507 100644 --- a/homeassistant/components/goodwe/diagnostics.py +++ b/homeassistant/components/goodwe/diagnostics.py @@ -4,19 +4,16 @@ from __future__ import annotations from typing import Any -from goodwe import Inverter - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, KEY_INVERTER +from .coordinator import GoodweConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: GoodweConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - inverter: Inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] + inverter = config_entry.runtime_data.inverter return { "config_entry": config_entry.as_dict(), diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index 0a61ac19d64..0d200c2725c 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -13,13 +13,13 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER +from .const import DOMAIN +from .coordinator import GoodweConfigEntry _LOGGER = logging.getLogger(__name__) @@ -86,12 +86,12 @@ NUMBERS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter select entities from a config entry.""" - inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] - device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO] + inverter = config_entry.runtime_data.inverter + device_info = config_entry.runtime_data.device_info entities = [] diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py index 340e10bfa0f..c26e8135b3f 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -5,13 +5,13 @@ import logging from goodwe import Inverter, InverterError, OperationMode 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.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER +from .const import DOMAIN +from .coordinator import GoodweConfigEntry _LOGGER = logging.getLogger(__name__) @@ -39,12 +39,12 @@ OPERATION_MODE = SelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter select entities from a config entry.""" - inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] - device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO] + inverter = config_entry.runtime_data.inverter + device_info = config_entry.runtime_data.device_info supported_modes = await inverter.get_operation_modes(False) # read current operating mode from the inverter diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index d2dce2770e4..c51827712d4 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -17,7 +17,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -39,8 +38,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE_INFO, KEY_INVERTER -from .coordinator import GoodweUpdateCoordinator +from .const import DOMAIN +from .coordinator import GoodweConfigEntry, GoodweUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -165,14 +164,14 @@ TEXT_SENSOR = GoodweSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GoodWe inverter from a config entry.""" entities: list[InverterSensor] = [] - inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO] + inverter = config_entry.runtime_data.inverter + coordinator = config_entry.runtime_data.coordinator + device_info = config_entry.runtime_data.device_info # Individual inverter sensors entities entities.extend( diff --git a/homeassistant/components/goodwe/strings.json b/homeassistant/components/goodwe/strings.json index ec4ea80e22a..6348da45618 100644 --- a/homeassistant/components/goodwe/strings.json +++ b/homeassistant/components/goodwe/strings.json @@ -36,7 +36,7 @@ "name": "Inverter operation mode", "state": { "general": "General mode", - "off_grid": "Off grid mode", + "off_grid": "Off-grid mode", "backup": "Backup mode", "eco": "Eco mode", "peak_shaving": "Peak shaving mode", diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 2b7aeadc0ba..3c3d6577e6c 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -14,7 +14,6 @@ from gcal_sync.model import DateOrDatetime, Event import voluptuous as vol import yaml -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_ENTITIES, @@ -34,8 +33,6 @@ from homeassistant.helpers.entity import generate_entity_id from .api import ApiAuthImpl, get_feature_access from .const import ( - DATA_SERVICE, - DATA_STORE, DOMAIN, EVENT_DESCRIPTION, EVENT_END_DATE, @@ -50,7 +47,7 @@ from .const import ( EVENT_TYPES_CONF, FeatureAccess, ) -from .store import LocalCalendarStore +from .store import GoogleConfigEntry, GoogleRuntimeData, LocalCalendarStore _LOGGER = logging.getLogger(__name__) @@ -139,11 +136,8 @@ ADD_EVENT_SERVICE_SCHEMA = vol.All( ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bool: """Set up Google from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} - # Validate google_calendars.yaml (if present) as soon as possible to return # helpful error messages. try: @@ -181,9 +175,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: calendar_service = GoogleCalendarService( ApiAuthImpl(async_get_clientsession(hass), session) ) - hass.data[DOMAIN][entry.entry_id][DATA_SERVICE] = calendar_service - hass.data[DOMAIN][entry.entry_id][DATA_STORE] = LocalCalendarStore( - hass, entry.entry_id + entry.runtime_data = GoogleRuntimeData( + service=calendar_service, + store=LocalCalendarStore(hass, entry.entry_id), ) if entry.unique_id is None: @@ -207,27 +201,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def async_entry_has_scopes(entry: ConfigEntry) -> bool: +def async_entry_has_scopes(entry: GoogleConfigEntry) -> bool: """Verify that the config entry desired scope is present in the oauth token.""" access = get_feature_access(entry) token_scopes = entry.data.get("token", {}).get("scope", []) return access.scope in token_scopes -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> 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: GoogleConfigEntry) -> None: """Reload config entry if the access options change.""" if not async_entry_has_scopes(entry): await hass.config_entries.async_reload(entry.entry_id) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> None: """Handle removal of a local storage.""" store = LocalCalendarStore(hass, entry.entry_id) await store.async_remove() diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index 194c2a0b4a5..efbbec73017 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -17,7 +17,6 @@ from oauth2client.client import ( ) from homeassistant.components.application_credentials import AuthImplementation -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.event import ( @@ -27,6 +26,7 @@ from homeassistant.helpers.event import ( from homeassistant.util import dt as dt_util from .const import CONF_CALENDAR_ACCESS, DEFAULT_FEATURE_ACCESS, FeatureAccess +from .store import GoogleConfigEntry _LOGGER = logging.getLogger(__name__) @@ -155,7 +155,7 @@ class DeviceFlow: self._listener() -def get_feature_access(config_entry: ConfigEntry) -> FeatureAccess: +def get_feature_access(config_entry: GoogleConfigEntry) -> FeatureAccess: """Return the desired calendar feature access.""" if config_entry.options and CONF_CALENDAR_ACCESS in config_entry.options: return FeatureAccess[config_entry.options[CONF_CALENDAR_ACCESS]] diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 4f8ffba1d19..6fef46395e8 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -37,7 +37,6 @@ from homeassistant.components.calendar import ( extract_offset, is_offset_reached, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, PlatformNotReady @@ -52,7 +51,6 @@ from . import ( CONF_SEARCH, CONF_TRACK, DEFAULT_CONF_OFFSET, - DOMAIN, YAML_DEVICES, get_calendar_info, load_config, @@ -60,8 +58,6 @@ from . import ( ) from .api import get_feature_access from .const import ( - DATA_SERVICE, - DATA_STORE, EVENT_END_DATE, EVENT_END_DATETIME, EVENT_IN, @@ -72,6 +68,7 @@ from .const import ( FeatureAccess, ) from .coordinator import CalendarQueryUpdateCoordinator, CalendarSyncUpdateCoordinator +from .store import GoogleConfigEntry _LOGGER = logging.getLogger(__name__) @@ -89,6 +86,7 @@ OPAQUE = "opaque" RRULE_PREFIX = "RRULE:" SERVICE_CREATE_EVENT = "create_event" +FILTERED_EVENT_TYPES = [EventTypeEnum.BIRTHDAY, EventTypeEnum.WORKING_LOCATION] @dataclasses.dataclass(frozen=True, kw_only=True) @@ -103,12 +101,12 @@ class GoogleCalendarEntityDescription(CalendarEntityDescription): search: str | None local_sync: bool device_id: str - working_location: bool = False + event_type: EventTypeEnum | None = None def _get_entity_descriptions( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoogleConfigEntry, calendar_item: Calendar, calendar_info: Mapping[str, Any], ) -> list[GoogleCalendarEntityDescription]: @@ -173,14 +171,24 @@ def _get_entity_descriptions( local_sync, ) if calendar_item.primary and local_sync: - _LOGGER.debug("work location entity") + # Create a separate calendar for birthdays + entity_descriptions.append( + dataclasses.replace( + entity_description, + key=f"{key}-birthdays", + translation_key="birthdays", + event_type=EventTypeEnum.BIRTHDAY, + name=None, + entity_id=None, + ) + ) # Create an optional disabled by default entity for Work Location entity_descriptions.append( dataclasses.replace( entity_description, key=f"{key}-work-location", translation_key="working_location", - working_location=True, + event_type=EventTypeEnum.WORKING_LOCATION, name=None, entity_id=None, entity_registry_enabled_default=False, @@ -191,12 +199,12 @@ def _get_entity_descriptions( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoogleConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the google calendar platform.""" - calendar_service = hass.data[DOMAIN][config_entry.entry_id][DATA_SERVICE] - store = hass.data[DOMAIN][config_entry.entry_id][DATA_STORE] + calendar_service = config_entry.runtime_data.service + store = config_entry.runtime_data.store try: result = await calendar_service.async_list_calendars() except ApiException as err: @@ -383,9 +391,18 @@ class GoogleCalendarEntity( for attendee in event.attendees ): return False - - if event.event_type == EventTypeEnum.WORKING_LOCATION: - return self.entity_description.working_location + # Calendar enttiy may be limited to a specific event type + if ( + self.entity_description.event_type is not None + and self.entity_description.event_type != event.event_type + ): + return False + # Default calendar entity omits the special types but includes all the others + if ( + self.entity_description.event_type is None + and event.event_type in FILTERED_EVENT_TYPES + ): + return False if self._ignore_availability: return True return event.transparency == OPAQUE diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index 8ae09b58957..15b9ed1c0d8 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -11,12 +11,7 @@ from gcal_sync.api import GoogleCalendarService from gcal_sync.exceptions import ApiException, ApiForbiddenException import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -38,6 +33,7 @@ from .const import ( CredentialType, FeatureAccess, ) +from .store import GoogleConfigEntry _LOGGER = logging.getLogger(__name__) @@ -197,7 +193,12 @@ class OAuth2FlowHandler( "Error reading primary calendar, make sure Google Calendar API is enabled: %s", err, ) - return self.async_abort(reason="api_disabled") + return self.async_abort( + reason="calendar_api_disabled", + description_placeholders={ + "calendar_api_url": "https://console.cloud.google.com/apis/library/calendar-json.googleapis.com" + }, + ) except ApiException as err: _LOGGER.error("Error reading primary calendar: %s", err) return self.async_abort(reason="cannot_connect") @@ -235,7 +236,7 @@ class OAuth2FlowHandler( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: GoogleConfigEntry, ) -> OptionsFlow: """Create an options flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index 1e0b2fc910b..6613668cf91 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -9,9 +9,7 @@ DOMAIN = "google" CONF_CALENDAR_ACCESS = "calendar_access" CONF_CREDENTIAL_TYPE = "credential_type" DATA_CALENDARS = "calendars" -DATA_SERVICE = "service" DATA_CONFIG = "config" -DATA_STORE = "store" class FeatureAccess(Enum): diff --git a/homeassistant/components/google/coordinator.py b/homeassistant/components/google/coordinator.py index 4a8a3d9f167..9f51c60b069 100644 --- a/homeassistant/components/google/coordinator.py +++ b/homeassistant/components/google/coordinator.py @@ -14,12 +14,13 @@ from gcal_sync.sync import CalendarEventSyncManager from gcal_sync.timeline import Timeline from ical.iter import SortableItemValue -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util +from .store import GoogleConfigEntry + _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) @@ -47,12 +48,12 @@ def _truncate_timeline(timeline: Timeline, max_events: int) -> Timeline: class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): """Coordinator for calendar RPC calls that use an efficient sync.""" - config_entry: ConfigEntry + config_entry: GoogleConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoogleConfigEntry, sync: CalendarEventSyncManager, name: str, ) -> None: @@ -108,12 +109,12 @@ class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): for limitations in the calendar API for supporting search. """ - config_entry: ConfigEntry + config_entry: GoogleConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GoogleConfigEntry, calendar_service: GoogleCalendarService, name: str, calendar_id: str, diff --git a/homeassistant/components/google/diagnostics.py b/homeassistant/components/google/diagnostics.py index 1a6f498b4cd..6dc6e321a23 100644 --- a/homeassistant/components/google/diagnostics.py +++ b/homeassistant/components/google/diagnostics.py @@ -4,11 +4,10 @@ import datetime from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from .const import DATA_STORE, DOMAIN +from .store import GoogleConfigEntry TO_REDACT = { "id", @@ -40,7 +39,7 @@ def redact_store(data: dict[str, Any]) -> dict[str, Any]: async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: GoogleConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" payload: dict[str, Any] = { @@ -49,7 +48,7 @@ async def async_get_config_entry_diagnostics( "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), } - store = hass.data[DOMAIN][config_entry.entry_id][DATA_STORE] - data = await store.async_load() - payload["store"] = redact_store(data) + store = config_entry.runtime_data.store + if data := await store.async_load(): + payload["store"] = redact_store(data) return payload diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index bd04597e513..d6f2ee76615 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==7.0.0", "oauth2client==4.1.3", "ical==8.3.0"] + "requirements": ["gcal-sync==7.0.1", "oauth2client==4.1.3", "ical==9.2.4"] } diff --git a/homeassistant/components/google/quality_scale.yaml b/homeassistant/components/google/quality_scale.yaml new file mode 100644 index 00000000000..43c86c54e28 --- /dev/null +++ b/homeassistant/components/google/quality_scale.yaml @@ -0,0 +1,115 @@ +rules: + # Bronze + config-flow: + status: todo + comment: Some fields missing data_description in the option flow. + brands: done + dependency-transparency: + status: todo + comment: | + This depends on the legacy (deprecated) oauth libraries for device + auth (no longer recommended auth). Google publishes to pypi using + an internal build system. We need to either revisit approach or + revisit our stance on this. + common-modules: done + has-entity-name: done + action-setup: + status: todo + comment: | + Actions are current setup in `async_setup_entry` and need 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: + status: todo + comment: | + The integration does not test the connection in `async_setup_entry` but + instead does this in the calendar platform only, which can be improved. + docs-high-level-description: done + config-flow-test-coverage: + status: todo + comment: | + The config flow has 100% test coverage, however there are opportunities + to increase functionality such as checking for the specific contents + of a unique id assigned to a config entry. + docs-actions: done + runtime-data: done + + # Silver + log-when-unavailable: done + config-entry-unloading: done + reauthentication-flow: + status: todo + comment: | + The integration supports reauthentication, however the config flow test + coverage can be improved on reauth corner cases. + action-exceptions: done + docs-installation-parameters: todo + integration-owner: done + parallel-updates: todo + test-coverage: + status: todo + comment: One module needs an additional line of coverage to be above the bar + docs-configuration-parameters: todo + entity-unavailable: done + + # Gold + docs-examples: done + discovery-update-info: + status: exempt + comment: Google calendar does not support discovery + entity-device-class: todo + entity-translations: todo + docs-data-update: todo + entity-disabled-by-default: done + discovery: + status: exempt + comment: Google calendar does not support discovery + exception-translations: todo + devices: todo + docs-supported-devices: done + icon-translations: + status: exempt + comment: Google calendar does not have any icons + docs-known-limitations: todo + stale-devices: + status: exempt + comment: Google calendar does not have devices + docs-supported-functions: done + repair-issues: + status: todo + comment: There are some warnings/deprecations that should be repair issues + reconfiguration-flow: + status: exempt + comment: There is nothing to configure in the configuration flow + entity-category: + status: exempt + comment: The entities in google calendar do not support categories + dynamic-devices: + status: exempt + comment: Google calendar does not have devices + docs-troubleshooting: todo + diagnostics: todo + docs-use-cases: todo + + # Platinum + async-dependency: + status: done + comment: | + The main client `gcal_sync` library is async. The primary authentication + used in config flow is handled by built in async OAuth code. The + integration still supports legacy OAuth credentials setup in the + configuration flow, which is no longer recommended or described in the + documentation for new users. This legacy config flow uses oauth2client + which is not natively async. + strict-typing: + status: todo + comment: Dependency oauth2client does not confirm to PEP 561 + inject-websession: done diff --git a/homeassistant/components/google/store.py b/homeassistant/components/google/store.py index c4d9e4c3e9c..4936a86f384 100644 --- a/homeassistant/components/google/store.py +++ b/homeassistant/components/google/store.py @@ -2,11 +2,14 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any +from gcal_sync.api import GoogleCalendarService from gcal_sync.store import CalendarStore +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store @@ -19,6 +22,16 @@ STORAGE_VERSION = 1 # Buffer writes every few minutes (plus guaranteed to be written at shutdown) STORAGE_SAVE_DELAY_SECONDS = 120 +type GoogleConfigEntry = ConfigEntry[GoogleRuntimeData] + + +@dataclass +class GoogleRuntimeData: + """Google runtime data.""" + + service: GoogleCalendarService + store: LocalCalendarStore + class LocalCalendarStore(CalendarStore): """Storage for local persistence of calendar and event data.""" diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 5ee0cdd9c14..4f3e27af27e 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -28,7 +28,7 @@ "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" + "calendar_api_disabled": "You must [enable the Google Calendar API]({calendar_api_url}) in the Google Cloud Console" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -131,6 +131,9 @@ "calendar": { "working_location": { "name": "Working location" + }, + "birthdays": { + "name": "Birthdays" } } } diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 273e46040b7..cfcada03a5c 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -95,6 +95,8 @@ CONFIG_SCHEMA = vol.Schema( {vol.Optional(DOMAIN): GOOGLE_ASSISTANT_SCHEMA}, extra=vol.ALLOW_EXTRA ) +type GoogleConfigEntry = ConfigEntry[GoogleConfig] + async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: """Activate Google Actions component.""" @@ -115,7 +117,7 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bool: """Set up from a config entry.""" config: ConfigType = {**hass.data[DOMAIN][DATA_CONFIG]} @@ -141,7 +143,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: google_config = GoogleConfig(hass, config) await google_config.async_initialize() - hass.data[DOMAIN][entry.entry_id] = google_config + entry.runtime_data = google_config hass.http.register_view(GoogleAssistantView(google_config)) diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py index 58560d7b8d1..00d809a851c 100644 --- a/homeassistant/components/google_assistant/button.py +++ b/homeassistant/components/google_assistant/button.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant import config_entries from homeassistant.components.button import ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -11,18 +10,19 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType +from . import GoogleConfigEntry from .const import CONF_PROJECT_ID, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN from .http import GoogleConfig async def async_setup_entry( hass: HomeAssistant, - config_entry: config_entries.ConfigEntry, + config_entry: GoogleConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform.""" yaml_config: ConfigType = hass.data[DOMAIN][DATA_CONFIG] - google_config: GoogleConfig = hass.data[DOMAIN][config_entry.entry_id] + google_config = config_entry.runtime_data entities = [] diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 8132ecaae2c..71738c9d13e 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -14,6 +14,7 @@ from homeassistant.components import ( input_boolean, input_button, input_select, + lawn_mower, light, lock, media_player, @@ -58,6 +59,7 @@ DEFAULT_EXPOSED_DOMAINS = [ "humidifier", "input_boolean", "input_select", + "lawn_mower", "light", "lock", "media_player", @@ -88,6 +90,7 @@ TYPE_GATE = f"{PREFIX_TYPES}GATE" TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER" TYPE_LIGHT = f"{PREFIX_TYPES}LIGHT" TYPE_LOCK = f"{PREFIX_TYPES}LOCK" +TYPE_MOWER = f"{PREFIX_TYPES}MOWER" TYPE_OUTLET = f"{PREFIX_TYPES}OUTLET" TYPE_RECEIVER = f"{PREFIX_TYPES}AUDIO_VIDEO_RECEIVER" TYPE_SCENE = f"{PREFIX_TYPES}SCENE" @@ -149,6 +152,7 @@ DOMAIN_TO_GOOGLE_TYPES = { input_boolean.DOMAIN: TYPE_SWITCH, input_button.DOMAIN: TYPE_SCENE, input_select.DOMAIN: TYPE_SENSOR, + lawn_mower.DOMAIN: TYPE_MOWER, light.DOMAIN: TYPE_LIGHT, lock.DOMAIN: TYPE_LOCK, media_player.DOMAIN: TYPE_SETTOP, diff --git a/homeassistant/components/google_assistant/diagnostics.py b/homeassistant/components/google_assistant/diagnostics.py index 48902147b05..5121a68f35c 100644 --- a/homeassistant/components/google_assistant/diagnostics.py +++ b/homeassistant/components/google_assistant/diagnostics.py @@ -5,13 +5,12 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import REDACTED, async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from . import GoogleConfigEntry from .const import CONF_SECURE_DEVICES_PIN, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN -from .http import GoogleConfig from .smart_home import ( async_devices_query_response, async_devices_sync_response, @@ -29,12 +28,11 @@ TO_REDACT = [ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: GoogleConfigEntry ) -> dict[str, Any]: """Return diagnostic information.""" - data = hass.data[DOMAIN] - config: GoogleConfig = data[entry.entry_id] - yaml_config: ConfigType = data[DATA_CONFIG] + config = entry.runtime_data + yaml_config: ConfigType = hass.data[DOMAIN][DATA_CONFIG] devices = await async_devices_sync_response(hass, config, REDACTED) sync = create_sync_response(REDACTED, devices) query = await async_devices_query_response(hass, config, devices) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 44251a3be04..9edd340d7d9 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -21,6 +21,7 @@ from homeassistant.components import ( input_boolean, input_button, input_select, + lawn_mower, light, lock, media_player, @@ -42,6 +43,7 @@ from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.fan import FanEntityFeature from homeassistant.components.humidifier import HumidifierEntityFeature +from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import LockState from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType @@ -714,7 +716,7 @@ class DockTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - return domain == vacuum.DOMAIN + return domain in (vacuum.DOMAIN, lawn_mower.DOMAIN) def sync_attributes(self) -> dict[str, Any]: """Return dock attributes for a sync request.""" @@ -722,17 +724,32 @@ class DockTrait(_Trait): def query_attributes(self) -> dict[str, Any]: """Return dock query attributes.""" - return {"isDocked": self.state.state == vacuum.VacuumActivity.DOCKED} + domain = self.state.domain + state = self.state.state + if domain == vacuum.DOMAIN: + return {"isDocked": state == vacuum.VacuumActivity.DOCKED} + if domain == lawn_mower.DOMAIN: + return {"isDocked": state == lawn_mower.LawnMowerActivity.DOCKED} + raise NotImplementedError(f"Unsupported domain {domain}") async def execute(self, command, data, params, challenge): """Execute a dock command.""" - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_RETURN_TO_BASE, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) + domain = self.state.domain + service: str | None = None + + if domain == vacuum.DOMAIN: + service = vacuum.SERVICE_RETURN_TO_BASE + elif domain == lawn_mower.DOMAIN: + service = lawn_mower.SERVICE_DOCK + + if service: + await self.hass.services.async_call( + self.state.domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, + ) @register_trait @@ -843,7 +860,7 @@ class StartStopTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - if domain == vacuum.DOMAIN: + if domain in (vacuum.DOMAIN, lawn_mower.DOMAIN): return True if ( @@ -863,6 +880,12 @@ class StartStopTrait(_Trait): & VacuumEntityFeature.PAUSE != 0 } + if domain == lawn_mower.DOMAIN: + return { + "pausable": self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & LawnMowerEntityFeature.PAUSE + != 0 + } if domain in COVER_VALVE_DOMAINS: return {} @@ -878,6 +901,11 @@ class StartStopTrait(_Trait): "isRunning": state == vacuum.VacuumActivity.CLEANING, "isPaused": state == vacuum.VacuumActivity.PAUSED, } + if domain == lawn_mower.DOMAIN: + return { + "isRunning": state == lawn_mower.LawnMowerActivity.MOWING, + "isPaused": state == lawn_mower.LawnMowerActivity.PAUSED, + } if domain in COVER_VALVE_DOMAINS: return { @@ -896,46 +924,52 @@ class StartStopTrait(_Trait): if domain == vacuum.DOMAIN: await self._execute_vacuum(command, data, params, challenge) return + if domain == lawn_mower.DOMAIN: + await self._execute_lawn_mower(command, data, params, challenge) + return if domain in COVER_VALVE_DOMAINS: await self._execute_cover_or_valve(command, data, params, challenge) return async def _execute_vacuum(self, command, data, params, challenge): """Execute a StartStop command.""" + service: str | None = None if command == COMMAND_START_STOP: - if params["start"]: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_START, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) - else: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_STOP, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) + service = vacuum.SERVICE_START if params["start"] else vacuum.SERVICE_STOP elif command == COMMAND_PAUSE_UNPAUSE: - if params["pause"]: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_PAUSE, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) - else: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_START, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) + service = vacuum.SERVICE_PAUSE if params["pause"] else vacuum.SERVICE_START + if service: + await self.hass.services.async_call( + self.state.domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, + ) + + async def _execute_lawn_mower(self, command, data, params, challenge): + """Execute a StartStop command.""" + service: str | None = None + if command == COMMAND_START_STOP: + service = ( + lawn_mower.SERVICE_START_MOWING + if params["start"] + else lawn_mower.SERVICE_DOCK + ) + elif command == COMMAND_PAUSE_UNPAUSE: + service = ( + lawn_mower.SERVICE_PAUSE + if params["pause"] + else lawn_mower.SERVICE_START_MOWING + ) + if service: + await self.hass.services.async_call( + self.state.domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, + ) async def _execute_cover_or_valve(self, command, data, params, challenge): """Execute a StartStop command.""" diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index a08d7554516..94b0e0b8a25 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -10,7 +10,6 @@ from google.oauth2.credentials import Credentials import voluptuous as vol from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform from homeassistant.core import ( HomeAssistant, @@ -26,15 +25,11 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_LANGUAGE_CODE, - DATA_MEM_STORAGE, - DATA_SESSION, - DOMAIN, - SUPPORTED_LANGUAGE_CODES, -) +from .const import CONF_LANGUAGE_CODE, DOMAIN, SUPPORTED_LANGUAGE_CODES from .helpers import ( GoogleAssistantSDKAudioView, + GoogleAssistantSDKConfigEntry, + GoogleAssistantSDKRuntimeData, InMemoryStorage, async_send_text_commands, best_matching_language_code, @@ -66,10 +61,10 @@ 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: GoogleAssistantSDKConfigEntry +) -> bool: """Set up Google Assistant SDK from a config entry.""" - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {} - implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) try: @@ -82,23 +77,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err except aiohttp.ClientError as err: raise ConfigEntryNotReady from err - hass.data[DOMAIN][entry.entry_id][DATA_SESSION] = session mem_storage = InMemoryStorage(hass) - hass.data[DOMAIN][entry.entry_id][DATA_MEM_STORAGE] = mem_storage hass.http.register_view(GoogleAssistantSDKAudioView(mem_storage)) await async_setup_service(hass) + entry.runtime_data = GoogleAssistantSDKRuntimeData( + session=session, mem_storage=mem_storage + ) agent = GoogleAssistantConversationAgent(hass, entry) conversation.async_set_agent(hass, entry, agent) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry +) -> bool: """Unload a config entry.""" - hass.data[DOMAIN].pop(entry.entry_id) if not hass.config_entries.async_loaded_entries(DOMAIN): for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) @@ -141,7 +138,9 @@ async def async_setup_service(hass: HomeAssistant) -> None: class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): """Google Assistant SDK conversation agent.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry + ) -> None: """Initialize the agent.""" self.hass = hass self.entry = entry @@ -161,7 +160,7 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): if self.session: session = self.session else: - session = self.hass.data[DOMAIN][self.entry.entry_id][DATA_SESSION] + session = self.entry.runtime_data.session self.session = session if not session.valid_token: await session.async_ensure_token_valid() diff --git a/homeassistant/components/google_assistant_sdk/config_flow.py b/homeassistant/components/google_assistant_sdk/config_flow.py index 48c92832483..6c010d39c43 100644 --- a/homeassistant/components/google_assistant_sdk/config_flow.py +++ b/homeassistant/components/google_assistant_sdk/config_flow.py @@ -8,17 +8,12 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow from .const import CONF_LANGUAGE_CODE, DEFAULT_NAME, DOMAIN, SUPPORTED_LANGUAGE_CODES -from .helpers import default_language_code +from .helpers import GoogleAssistantSDKConfigEntry, default_language_code _LOGGER = logging.getLogger(__name__) @@ -77,7 +72,7 @@ class OAuth2FlowHandler( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: GoogleAssistantSDKConfigEntry, ) -> OptionsFlow: """Create the options flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/google_assistant_sdk/const.py b/homeassistant/components/google_assistant_sdk/const.py index 4059f006d4b..2ad5bbbfec8 100644 --- a/homeassistant/components/google_assistant_sdk/const.py +++ b/homeassistant/components/google_assistant_sdk/const.py @@ -8,9 +8,6 @@ DEFAULT_NAME: Final = "Google Assistant SDK" CONF_LANGUAGE_CODE: Final = "language_code" -DATA_MEM_STORAGE: Final = "mem_storage" -DATA_SESSION: Final = "session" - # https://developers.google.com/assistant/sdk/reference/rpc/languages SUPPORTED_LANGUAGE_CODES: Final = [ "de-DE", diff --git a/homeassistant/components/google_assistant_sdk/diagnostics.py b/homeassistant/components/google_assistant_sdk/diagnostics.py index eacded4e2e6..45600f5010e 100644 --- a/homeassistant/components/google_assistant_sdk/diagnostics.py +++ b/homeassistant/components/google_assistant_sdk/diagnostics.py @@ -5,14 +5,15 @@ 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 .helpers import GoogleAssistantSDKConfigEntry + TO_REDACT = {"access_token", "refresh_token"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return async_redact_data( diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index f9d332cd735..ca774bed77e 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -28,13 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.event import async_call_later -from .const import ( - CONF_LANGUAGE_CODE, - DATA_MEM_STORAGE, - DATA_SESSION, - DOMAIN, - SUPPORTED_LANGUAGE_CODES, -) +from .const import CONF_LANGUAGE_CODE, DOMAIN, SUPPORTED_LANGUAGE_CODES _LOGGER = logging.getLogger(__name__) @@ -49,6 +43,16 @@ DEFAULT_LANGUAGE_CODES = { "pt": "pt-BR", } +type GoogleAssistantSDKConfigEntry = ConfigEntry[GoogleAssistantSDKRuntimeData] + + +@dataclass +class GoogleAssistantSDKRuntimeData: + """Runtime data for Google Assistant SDK.""" + + session: OAuth2Session + mem_storage: InMemoryStorage + @dataclass class CommandResponse: @@ -62,9 +66,9 @@ async def async_send_text_commands( ) -> list[CommandResponse]: """Send text commands to Google Assistant Service.""" # There can only be 1 entry (config_flow has single_instance_allowed) - entry: ConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + entry: GoogleAssistantSDKConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - session: OAuth2Session = hass.data[DOMAIN][entry.entry_id][DATA_SESSION] + session = entry.runtime_data.session try: await session.async_ensure_token_valid() except aiohttp.ClientResponseError as err: @@ -84,11 +88,10 @@ async def async_send_text_commands( _LOGGER.debug("command: %s\nresponse: %s", command, text_response) audio_response = resp[2] if media_players and audio_response: - mem_storage: InMemoryStorage = hass.data[DOMAIN][entry.entry_id][ - DATA_MEM_STORAGE - ] audio_url = GoogleAssistantSDKAudioView.url.format( - filename=mem_storage.store_and_get_identifier(audio_response) + filename=entry.runtime_data.mem_storage.store_and_get_identifier( + audio_response + ) ) await hass.services.async_call( DOMAIN_MP, diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json index 85469a464b3..70e93f39f42 100644 --- a/homeassistant/components/google_assistant_sdk/manifest.json +++ b/homeassistant/components/google_assistant_sdk/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["gassist-text==0.0.11"], + "requirements": ["gassist-text==0.0.12"], "single_config_entry": true } diff --git a/homeassistant/components/google_assistant_sdk/notify.py b/homeassistant/components/google_assistant_sdk/notify.py index ffe34eefdfd..067f222ca50 100644 --- a/homeassistant/components/google_assistant_sdk/notify.py +++ b/homeassistant/components/google_assistant_sdk/notify.py @@ -5,12 +5,15 @@ from __future__ import annotations from typing import Any from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_LANGUAGE_CODE, DOMAIN -from .helpers import async_send_text_commands, default_language_code +from .helpers import ( + GoogleAssistantSDKConfigEntry, + async_send_text_commands, + default_language_code, +) # https://support.google.com/assistant/answer/9071582?hl=en LANG_TO_BROADCAST_COMMAND = { @@ -59,7 +62,9 @@ class BroadcastNotificationService(BaseNotificationService): return # There can only be 1 entry (config_flow has single_instance_allowed) - entry: ConfigEntry = self.hass.config_entries.async_entries(DOMAIN)[0] + entry: GoogleAssistantSDKConfigEntry = self.hass.config_entries.async_entries( + DOMAIN + )[0] language_code = entry.options.get( CONF_LANGUAGE_CODE, default_language_code(self.hass) ) diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json index 3e08b6254db..3e6371cbe23 100644 --- a/homeassistant/components/google_cloud/manifest.json +++ b/homeassistant/components/google_cloud/manifest.json @@ -8,7 +8,7 @@ "integration_type": "service", "iot_class": "cloud_push", "requirements": [ - "google-cloud-texttospeech==2.17.2", - "google-cloud-speech==2.27.0" + "google-cloud-texttospeech==2.25.1", + "google-cloud-speech==2.31.1" ] } diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index 41c5a6710b7..cd5055383ea 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -6,6 +6,7 @@ from collections.abc import AsyncGenerator, AsyncIterable import logging from google.api_core.exceptions import GoogleAPIError, Unauthenticated +from google.api_core.retry import AsyncRetry from google.cloud import speech_v1 from homeassistant.components.stt import ( @@ -127,6 +128,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): responses = await self._client.streaming_recognize( requests=request_generator(), timeout=10, + retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), ) transcript = "" diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 1f5f838b593..16519645dee 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any, cast from google.api_core.exceptions import GoogleAPIError, Unauthenticated +from google.api_core.retry import AsyncRetry from google.cloud import texttospeech import voluptuous as vol @@ -215,7 +216,11 @@ class BaseGoogleCloudProvider: ), ) - response = await self._client.synthesize_speech(request, timeout=10) + response = await self._client.synthesize_speech( + request, + timeout=10, + retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), + ) if encoding == texttospeech.AudioEncoding.MP3: extension = "mp3" diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index 73e5902f8f5..a4b7fc956ce 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -8,7 +8,12 @@ from typing import Any from google_drive_api.exceptions import GoogleDriveApiError -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator @@ -93,13 +98,13 @@ class GoogleDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" backups = await self.async_list_backups() for backup in backups: if backup.backup_id == backup_id: return backup - return None + raise BackupNotFound(f"Backup {backup_id} not found") async def async_download_backup( self, @@ -120,7 +125,7 @@ class GoogleDriveBackupAgent(BackupAgent): return ChunkAsyncStreamIterator(stream) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: raise BackupAgentError(f"Failed to download backup: {err}") from err - raise BackupAgentError("Backup not found") + raise BackupNotFound(f"Backup {backup_id} not found") async def async_delete_backup( self, @@ -138,5 +143,7 @@ class GoogleDriveBackupAgent(BackupAgent): _LOGGER.debug("Deleting file_id: %s", file_id) await self._client.async_delete(file_id) _LOGGER.debug("Deleted backup_id: %s", backup_id) + return except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: raise BackupAgentError(f"Failed to delete backup: {err}") from err + raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/google_gemini/__init__.py b/homeassistant/components/google_gemini/__init__.py new file mode 100644 index 00000000000..b0ecda85e6b --- /dev/null +++ b/homeassistant/components/google_gemini/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Google Gemini.""" diff --git a/homeassistant/components/google_gemini/manifest.json b/homeassistant/components/google_gemini/manifest.json new file mode 100644 index 00000000000..783a6210a38 --- /dev/null +++ b/homeassistant/components/google_gemini/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "google_gemini", + "name": "Google Gemini", + "integration_type": "virtual", + "supported_by": "google_generative_ai_conversation" +} diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 33e361d1433..79d092a60c3 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,10 +2,13 @@ from __future__ import annotations +import asyncio +import mimetypes from pathlib import Path -from google import genai # type: ignore[attr-defined] +from google.genai import Client from google.genai.errors import APIError, ClientError +from google.genai.types import File, FileState from requests.exceptions import Timeout import voluptuous as vol @@ -31,6 +34,8 @@ from .const import ( CONF_CHAT_MODEL, CONF_PROMPT, DOMAIN, + FILE_POLLING_INTERVAL_SECONDS, + LOGGER, RECOMMENDED_CHAT_MODEL, TIMEOUT_MILLIS, ) @@ -42,7 +47,7 @@ CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = (Platform.CONVERSATION,) -type GoogleGenerativeAIConfigEntry = ConfigEntry[genai.Client] +type GoogleGenerativeAIConfigEntry = ConfigEntry[Client] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -65,9 +70,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: prompt_parts = [call.data[CONF_PROMPT]] - config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries( - DOMAIN - )[0] + config_entry: GoogleGenerativeAIConfigEntry = ( + hass.config_entries.async_loaded_entries(DOMAIN)[0] + ) client = config_entry.runtime_data @@ -83,10 +88,47 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) if not Path(filename).exists(): raise HomeAssistantError(f"`{filename}` does not exist") - prompt_parts.append(client.files.upload(file=filename)) + mimetype = mimetypes.guess_type(filename)[0] + with open(filename, "rb") as file: + uploaded_file = client.files.upload( + file=file, config={"mime_type": mimetype} + ) + prompt_parts.append(uploaded_file) + + async def wait_for_file_processing(uploaded_file: File) -> None: + """Wait for file processing to complete.""" + while True: + uploaded_file = await client.aio.files.get( + name=uploaded_file.name, + config={"http_options": {"timeout": TIMEOUT_MILLIS}}, + ) + if uploaded_file.state not in ( + FileState.STATE_UNSPECIFIED, + FileState.PROCESSING, + ): + break + LOGGER.debug( + "Waiting for file `%s` to be processed, current state: %s", + uploaded_file.name, + uploaded_file.state, + ) + await asyncio.sleep(FILE_POLLING_INTERVAL_SECONDS) + + if uploaded_file.state == FileState.FAILED: + raise HomeAssistantError( + f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message}" + ) await hass.async_add_executor_job(append_files_to_prompt) + tasks = [ + asyncio.create_task(wait_for_file_processing(part)) + for part in prompt_parts + if isinstance(part, File) and part.state != FileState.ACTIVE + ] + async with asyncio.timeout(TIMEOUT_MILLIS / 1000): + await asyncio.gather(*tasks) + try: response = await client.aio.models.generate_content( model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts @@ -133,7 +175,11 @@ async def async_setup_entry( """Set up Google Generative AI Conversation from a config entry.""" try: - client = genai.Client(api_key=entry.data[CONF_API_KEY]) + + def _init_client() -> Client: + return Client(api_key=entry.data[CONF_API_KEY]) + + client = await hass.async_add_executor_job(_init_client) await client.aio.models.get( model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), config={"http_options": {"timeout": TIMEOUT_MILLIS}}, diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 00a016143f4..551f9b0c9de 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -7,7 +7,7 @@ import logging from types import MappingProxyType from typing import Any -from google import genai # type: ignore[attr-defined] +from google import genai from google.genai.errors import APIError, ClientError from requests.exceptions import Timeout import voluptuous as vol @@ -44,6 +44,7 @@ from .const import ( CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -51,6 +52,7 @@ from .const import ( RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, TIMEOUT_MILLIS, ) @@ -177,50 +179,50 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + errors: dict[str, str] = {} if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) - return self.async_create_entry(title="", data=user_input) + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + if not ( + user_input.get(CONF_LLM_HASS_API) + and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True + ): + # Don't allow to save options that enable the Google Seearch tool with an Assist API + return self.async_create_entry(title="", data=user_input) + errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option" # Re-render the options again, now with the recommended options shown/hidden self.last_rendered_recommended = user_input[CONF_RECOMMENDED] - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], - } + options = user_input schema = await google_generative_ai_config_option_schema( self.hass, options, self._genai_client ) return self.async_show_form( - step_id="init", - data_schema=vol.Schema(schema), + step_id="init", data_schema=vol.Schema(schema), errors=errors ) async def google_generative_ai_config_option_schema( hass: HomeAssistant, - options: dict[str, Any] | MappingProxyType[str, Any], + options: Mapping[str, Any], genai_client: genai.Client, ) -> dict: """Return a schema for Google Generative AI completion options.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) + ] + if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance( + suggested_llm_apis, str + ): + suggested_llm_apis = [suggested_llm_apis] schema = { vol.Optional( @@ -233,9 +235,8 @@ async def google_generative_ai_config_option_schema( ): TemplateSelector(), vol.Optional( CONF_LLM_HASS_API, - description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - default="none", - ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, @@ -299,7 +300,7 @@ async def google_generative_ai_config_option_schema( CONF_TEMPERATURE, description={"suggested_value": options.get(CONF_TEMPERATURE)}, default=RECOMMENDED_TEMPERATURE, - ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), vol.Optional( CONF_TOP_P, description={"suggested_value": options.get(CONF_TOP_P)}, @@ -341,6 +342,13 @@ async def google_generative_ai_config_option_schema( }, default=RECOMMENDED_HARM_BLOCK_THRESHOLD, ): harm_block_thresholds_selector, + vol.Optional( + CONF_USE_GOOGLE_SEARCH_TOOL, + description={ + "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL), + }, + default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + ): bool, } ) return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 35834f6e7f9..239b3ff763e 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -16,11 +16,14 @@ RECOMMENDED_TOP_P = 0.95 CONF_TOP_K = "top_k" RECOMMENDED_TOP_K = 64 CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 150 +RECOMMENDED_MAX_TOKENS = 1500 CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold" CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold" CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold" RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE" +CONF_USE_GOOGLE_SEARCH_TOOL = "enable_google_search_tool" +RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False TIMEOUT_MILLIS = 10000 +FILE_POLLING_INTERVAL_SECONDS = 0.05 diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index c99c4c07a7d..73a82b98664 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -4,6 +4,7 @@ from __future__ import annotations import codecs from collections.abc import Callable +from dataclasses import replace from typing import Any, Literal, cast from google.genai.errors import APIError @@ -12,6 +13,7 @@ from google.genai.types import ( Content, FunctionDeclaration, GenerateContentConfig, + GoogleSearch, HarmCategory, Part, SafetySetting, @@ -25,7 +27,7 @@ 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 -from homeassistant.helpers import chat_session, device_registry as dr, intent, llm +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -39,6 +41,7 @@ from .const import ( CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, @@ -52,6 +55,10 @@ from .const import ( # Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 +ERROR_GETTING_RESPONSE = ( + "Sorry, I had a problem getting a response from Google Generative AI." +) + async def async_setup_entry( hass: HomeAssistant, @@ -64,28 +71,18 @@ async def async_setup_entry( SUPPORTED_SCHEMA_KEYS = { - "min_items", - "example", - "property_ordering", - "pattern", - "minimum", - "default", - "any_of", - "max_length", - "title", - "min_properties", - "min_length", - "max_items", - "maximum", - "nullable", - "max_properties", + # Gemini API does not support all of the OpenAPI schema + # SoT: https://ai.google.dev/api/caching#Schema "type", - "description", - "enum", "format", - "items", + "description", + "nullable", + "enum", + "max_items", + "min_items", "properties", "required", + "items", } @@ -109,11 +106,20 @@ def _format_schema(schema: dict[str, Any]) -> Schema: key = _camel_to_snake(key) if key not in SUPPORTED_SCHEMA_KEYS: continue - if key == "any_of": - val = [_format_schema(subschema) for subschema in val] if key == "type": val = val.upper() - if key == "items": + elif key == "format": + # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema + # formats that are not supported are ignored + if schema.get("type") == "string" and val not in ("enum", "date-time"): + continue + if schema.get("type") == "number" and val not in ("float", "double"): + continue + if schema.get("type") == "integer" and val not in ("int32", "int64"): + continue + if schema.get("type") not in ("string", "number", "integer"): + continue + elif key == "items": val = _format_schema(val) elif key == "properties": val = {k: _format_schema(v) for k, v in val.items()} @@ -169,17 +175,25 @@ def _escape_decode(value: Any) -> Any: return value +def _create_google_tool_response_parts( + parts: list[conversation.ToolResultContent], +) -> list[Part]: + """Create Google tool response parts.""" + return [ + Part.from_function_response( + name=tool_result.tool_name, response=tool_result.tool_result + ) + for tool_result in parts + ] + + def _create_google_tool_response_content( content: list[conversation.ToolResultContent], ) -> Content: """Create a Google tool response content.""" return Content( - parts=[ - Part.from_function_response( - name=tool_result.tool_name, response=tool_result.tool_result - ) - for tool_result in content - ] + role="user", + parts=_create_google_tool_response_parts(content), ) @@ -189,7 +203,7 @@ def _convert_content( | conversation.SystemContent, ) -> Content: """Convert HA content to Google content.""" - if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] + if content.role != "assistant" or not content.tool_calls: role = "model" if content.role == "assistant" else content.role return Content( role=role, @@ -265,17 +279,12 @@ class GoogleGenerativeAIConversationEntity( conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) + def _fix_tool_name(self, tool_name: str) -> str: + """Fix tool name if needed.""" + # The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool + # name. This makes sure when it incorrectly changes the name, that we change it + # back for HA to call. + return tool_name if tool_name != "HasListAddItem" else "HassListAddItem" async def _async_handle_message( self, @@ -302,6 +311,13 @@ class GoogleGenerativeAIConversationEntity( for tool in chat_log.llm_api.tools ] + # Using search grounding allows the model to retrieve information from the web, + # however, it may interfere with how the model decides to use some tools, or entities + # for example weather entity may be disregarded if the model chooses to Google it. + if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True: + tools = tools or [] + tools.append(Tool(google_search=GoogleSearch())) + model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) # Gemini 1.0 doesn't support system_instruction while 1.5 does. # Assume future versions will support it (if not, the request fails with a @@ -327,23 +343,30 @@ class GoogleGenerativeAIConversationEntity( for chat_content in chat_log.content[1:-1]: if chat_content.role == "tool_result": - # mypy doesn't like picking a type based on checking shared property 'role' - tool_results.append(cast(conversation.ToolResultContent, chat_content)) + tool_results.append(chat_content) continue + if ( + not isinstance(chat_content, conversation.ToolResultContent) + and chat_content.content == "" + ): + # Skipping is not possible since the number of function calls need to match the number of function responses + # and skipping one would mean removing the other and hence this would prevent a proper chat log + chat_content = replace(chat_content, content=" ") + if tool_results: messages.append(_create_google_tool_response_content(tool_results)) tool_results.clear() - messages.append( - _convert_content( - cast( - conversation.UserContent - | conversation.SystemContent - | conversation.AssistantContent, - chat_content, - ) - ) + messages.append(_convert_content(chat_content)) + + # The SDK requires the first message to be a user message + # This is not the case if user used `start_conversation` + # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 + if messages and messages[0].role != "user": + messages.insert( + 0, + Content(role="user", parts=[Part.from_text(text=" ")]), ) if tool_results: @@ -400,7 +423,7 @@ class GoogleGenerativeAIConversationEntity( chat = self._genai_client.aio.chats.create( model=model_name, history=messages, config=generateContentConfig ) - chat_request: str | Content = user_input.text + chat_request: str | list[Part] = user_input.text # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: @@ -410,6 +433,12 @@ class GoogleGenerativeAIConversationEntity( raise HomeAssistantError( f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}" ) + if not chat_response.candidates: + LOGGER.error( + "No candidates found in the response: %s", + chat_response, + ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) except ( APIError, @@ -419,11 +448,21 @@ class GoogleGenerativeAIConversationEntity( error = f"Sorry, I had a problem talking to Google Generative AI: {err}" raise HomeAssistantError(error) from err + if (usage_metadata := chat_response.usage_metadata) is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": usage_metadata.prompt_token_count, + "cached_input_tokens": usage_metadata.cached_content_token_count + or 0, + "output_tokens": usage_metadata.candidates_token_count, + } + } + ) + response_parts = chat_response.candidates[0].content.parts if not response_parts: - raise HomeAssistantError( - "Sorry, I had a problem getting a response from Google Generative AI." - ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) content = " ".join( [part.text.strip() for part in response_parts if part.text] ) @@ -436,10 +475,13 @@ class GoogleGenerativeAIConversationEntity( tool_name = tool_call.name tool_args = _escape_decode(tool_call.args) tool_calls.append( - llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + llm.ToolInput( + tool_name=self._fix_tool_name(tool_name), + tool_args=tool_args, + ) ) - chat_request = _create_google_tool_response_content( + chat_request = _create_google_tool_response_parts( [ tool_response async for tool_response in chat_log.async_add_assistant_content( @@ -460,7 +502,9 @@ class GoogleGenerativeAIConversationEntity( " ".join([part.text.strip() for part in response_parts if part.text]) ) return conversation.ConversationResult( - response=response, conversation_id=chat_log.conversation_id + response=response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, ) async def _async_entry_update_listener( diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index cc381532c6f..25e44964a6d 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -2,11 +2,11 @@ "domain": "google_generative_ai_conversation", "name": "Google Generative AI", "after_dependencies": ["assist_pipeline", "intent"], - "codeowners": ["@tronikos"], + "codeowners": ["@tronikos", "@ivanlh"], "config_flow": true, "dependencies": ["conversation"], "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-genai==1.1.0"] + "requirements": ["google-genai==1.7.0"] } diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 772fadb089c..2697f30eda0 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -36,12 +36,17 @@ "harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes", "hate_block_threshold": "Content that is rude, disrespectful, or profane", "sexual_block_threshold": "Contains references to sexual acts or other lewd content", - "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts" + "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts", + "enable_google_search_tool": "Enable Google Search tool" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "enable_google_search_tool": "Only works with \"No control\" in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"." } } + }, + "error": { + "invalid_google_search_option": "Google Search cannot be enabled alongside any Assist capability, this can only be used when Assist is set to \"No control\"." } }, "services": { @@ -70,7 +75,7 @@ "issues": { "deprecated_image_filename_parameter": { "title": "Deprecated 'image_filename' parameter", - "description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' intead." + "description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' instead." } } } diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json index d3e57c26e39..b96f4e9ebc0 100644 --- a/homeassistant/components/google_pubsub/manifest.json +++ b/homeassistant/components/google_pubsub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_pubsub", "iot_class": "cloud_push", "quality_scale": "legacy", - "requirements": ["google-cloud-pubsub==2.28.0"] + "requirements": ["google-cloud-pubsub==2.29.0"] } diff --git a/homeassistant/components/google_travel_time/__init__.py b/homeassistant/components/google_travel_time/__init__.py index 4ee9d53cf3b..1f999bbc9d0 100644 --- a/homeassistant/components/google_travel_time/__init__.py +++ b/homeassistant/components/google_travel_time/__init__.py @@ -1,11 +1,18 @@ """The google_travel_time component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .const import CONF_TIME PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Maps Travel Time from a config entry.""" @@ -16,3 +23,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate an old config entry.""" + + if config_entry.version == 1: + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + options = dict(config_entry.options) + if options.get(CONF_TIME) == "now": + options[CONF_TIME] = None + elif options.get(CONF_TIME) is not None: + if dt_util.parse_time(options[CONF_TIME]) is None: + try: + from_timestamp = dt_util.utc_from_timestamp(int(options[CONF_TIME])) + options[CONF_TIME] = ( + f"{from_timestamp.time().hour:02}:{from_timestamp.time().minute:02}" + ) + except ValueError: + _LOGGER.error( + "Invalid time format found while migrating: %s. The old config never worked. Reset to default (empty)", + options[CONF_TIME], + ) + options[CONF_TIME] = None + hass.config_entries.async_update_entry(config_entry, options=options, version=2) + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + return True diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index a29d3d75b3e..24ea29aef03 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -19,6 +19,7 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TimeSelector, ) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -106,7 +107,7 @@ OPTIONS_SCHEMA = vol.Schema( translation_key=CONF_TIME_TYPE, ) ), - vol.Optional(CONF_TIME, default=""): cv.string, + vol.Optional(CONF_TIME): TimeSelector(), vol.Optional(CONF_TRAFFIC_MODEL): SelectSelector( SelectSelectorConfig( options=TRAFFIC_MODELS, @@ -181,8 +182,7 @@ async def validate_input( ) -> dict[str, str] | None: """Validate the user input allows us to connect.""" try: - await hass.async_add_executor_job( - validate_config_entry, + await validate_config_entry( hass, user_input[CONF_API_KEY], user_input[CONF_ORIGIN], @@ -201,7 +201,7 @@ async def validate_input( class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Maps Travel Time.""" - VERSION = 1 + VERSION = 2 @staticmethod @callback diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index 046e52095c0..5452e993497 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -1,5 +1,12 @@ """Constants for Google Travel Time.""" +from google.maps.routing_v2 import ( + RouteTravelMode, + TrafficModel, + TransitPreferences, + Units, +) + DOMAIN = "google_travel_time" ATTRIBUTION = "Powered by Google" @@ -7,7 +14,6 @@ ATTRIBUTION = "Powered by Google" CONF_DESTINATION = "destination" CONF_OPTIONS = "options" CONF_ORIGIN = "origin" -CONF_TRAVEL_MODE = "travel_mode" CONF_AVOID = "avoid" CONF_UNITS = "units" CONF_ARRIVAL_TIME = "arrival_time" @@ -79,11 +85,37 @@ ALL_LANGUAGES = [ AVOID_OPTIONS = ["tolls", "highways", "ferries", "indoor"] TRANSIT_PREFS = ["less_walking", "fewer_transfers"] +TRANSIT_PREFS_TO_GOOGLE_SDK_ENUM = { + "less_walking": TransitPreferences.TransitRoutingPreference.LESS_WALKING, + "fewer_transfers": TransitPreferences.TransitRoutingPreference.FEWER_TRANSFERS, +} TRANSPORT_TYPES = ["bus", "subway", "train", "tram", "rail"] +TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM = { + "bus": TransitPreferences.TransitTravelMode.BUS, + "subway": TransitPreferences.TransitTravelMode.SUBWAY, + "train": TransitPreferences.TransitTravelMode.TRAIN, + "tram": TransitPreferences.TransitTravelMode.LIGHT_RAIL, + "rail": TransitPreferences.TransitTravelMode.RAIL, +} TRAVEL_MODES = ["driving", "walking", "bicycling", "transit"] +TRAVEL_MODES_TO_GOOGLE_SDK_ENUM = { + "driving": RouteTravelMode.DRIVE, + "walking": RouteTravelMode.WALK, + "bicycling": RouteTravelMode.BICYCLE, + "transit": RouteTravelMode.TRANSIT, +} TRAFFIC_MODELS = ["best_guess", "pessimistic", "optimistic"] +TRAFFIC_MODELS_TO_GOOGLE_SDK_ENUM = { + "best_guess": TrafficModel.BEST_GUESS, + "pessimistic": TrafficModel.PESSIMISTIC, + "optimistic": TrafficModel.OPTIMISTIC, +} # googlemaps library uses "metric" or "imperial" terminology in distance_matrix UNITS_METRIC = "metric" UNITS_IMPERIAL = "imperial" UNITS = [UNITS_METRIC, UNITS_IMPERIAL] +UNITS_TO_GOOGLE_SDK_ENUM = { + UNITS_METRIC: Units.METRIC, + UNITS_IMPERIAL: Units.IMPERIAL, +} diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index baceffecc73..49294455a49 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -2,41 +2,80 @@ import logging -from googlemaps import Client -from googlemaps.distance_matrix import distance_matrix -from googlemaps.exceptions import ApiError, Timeout, TransportError +from google.api_core.client_options import ClientOptions +from google.api_core.exceptions import ( + Forbidden, + GatewayTimeout, + GoogleAPIError, + Unauthorized, +) +from google.maps.routing_v2 import ( + ComputeRoutesRequest, + Location, + RoutesAsyncClient, + RouteTravelMode, + Waypoint, +) +from google.type import latlng_pb2 +import voluptuous as vol from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.location import find_coordinates _LOGGER = logging.getLogger(__name__) -def validate_config_entry( +def convert_to_waypoint(hass: HomeAssistant, location: str) -> Waypoint | None: + """Convert a location to a Waypoint. + + Will either use coordinates or if none are found, use the location as an address. + """ + coordinates = find_coordinates(hass, location) + if coordinates is None: + return None + try: + formatted_coordinates = coordinates.split(",") + vol.Schema(cv.gps(formatted_coordinates)) + except (AttributeError, vol.ExactSequenceInvalid): + return Waypoint(address=location) + return Waypoint( + location=Location( + lat_lng=latlng_pb2.LatLng( + latitude=float(formatted_coordinates[0]), + longitude=float(formatted_coordinates[1]), + ) + ) + ) + + +async def validate_config_entry( hass: HomeAssistant, api_key: str, origin: str, destination: str ) -> None: """Return whether the config entry data is valid.""" - resolved_origin = find_coordinates(hass, origin) - resolved_destination = find_coordinates(hass, destination) + resolved_origin = convert_to_waypoint(hass, origin) + resolved_destination = convert_to_waypoint(hass, destination) + client_options = ClientOptions(api_key=api_key) + client = RoutesAsyncClient(client_options=client_options) + field_mask = "routes.duration" + request = ComputeRoutesRequest( + origin=resolved_origin, + destination=resolved_destination, + travel_mode=RouteTravelMode.DRIVE, + ) try: - client = Client(api_key, timeout=10) - except ValueError as value_error: - _LOGGER.error("Malformed API key") - raise InvalidApiKeyException from value_error - try: - distance_matrix(client, resolved_origin, resolved_destination, mode="driving") - except ApiError as api_error: - if api_error.status == "REQUEST_DENIED": - _LOGGER.error("Request denied: %s", api_error.message) - raise InvalidApiKeyException from api_error - _LOGGER.error("Unknown error: %s", api_error.message) - raise UnknownException from api_error - except TransportError as transport_error: - _LOGGER.error("Unknown error: %s", transport_error) - raise UnknownException from transport_error - except Timeout as timeout_error: + await client.compute_routes( + request, metadata=[("x-goog-fieldmask", field_mask)] + ) + except (Unauthorized, Forbidden) as unauthorized_error: + _LOGGER.error("Request denied: %s", unauthorized_error.message) + raise InvalidApiKeyException from unauthorized_error + except GatewayTimeout as timeout_error: _LOGGER.error("Timeout error") raise TimeoutError from timeout_error + except GoogleAPIError as unknown_error: + _LOGGER.error("Unknown error: %s", unknown_error) + raise UnknownException from unknown_error class InvalidApiKeyException(Exception): diff --git a/homeassistant/components/google_travel_time/manifest.json b/homeassistant/components/google_travel_time/manifest.json index d7c98478272..74c015c5345 100644 --- a/homeassistant/components/google_travel_time/manifest.json +++ b/homeassistant/components/google_travel_time/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/google_travel_time", "iot_class": "cloud_polling", - "loggers": ["googlemaps", "homeassistant.helpers.location"], - "requirements": ["googlemaps==2.5.1"] + "loggers": ["google", "homeassistant.helpers.location"], + "requirements": ["google-maps-routing==0.6.15"] } diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index cac792dca53..7448fc1cb09 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -2,12 +2,22 @@ from __future__ import annotations -from datetime import datetime, timedelta +import datetime import logging +from typing import TYPE_CHECKING, Any -from googlemaps import Client -from googlemaps.distance_matrix import distance_matrix -from googlemaps.exceptions import ApiError, Timeout, TransportError +from google.api_core.client_options import ClientOptions +from google.api_core.exceptions import GoogleAPIError +from google.maps.routing_v2 import ( + ComputeRoutesRequest, + Route, + RouteModifiers, + RoutesAsyncClient, + RouteTravelMode, + RoutingPreference, + TransitPreferences, +) +from google.protobuf import timestamp_pb2 from homeassistant.components.sensor import ( SensorDeviceClass, @@ -17,6 +27,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, + CONF_MODE, CONF_NAME, EVENT_HOMEASSISTANT_STARTED, UnitOfTime, @@ -30,26 +42,49 @@ from homeassistant.util import dt as dt_util from .const import ( ATTRIBUTION, CONF_ARRIVAL_TIME, + CONF_AVOID, CONF_DEPARTURE_TIME, CONF_DESTINATION, CONF_ORIGIN, + CONF_TRAFFIC_MODEL, + CONF_TRANSIT_MODE, + CONF_TRANSIT_ROUTING_PREFERENCE, + CONF_UNITS, DEFAULT_NAME, DOMAIN, + TRAFFIC_MODELS_TO_GOOGLE_SDK_ENUM, + TRANSIT_PREFS_TO_GOOGLE_SDK_ENUM, + TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM, + TRAVEL_MODES_TO_GOOGLE_SDK_ENUM, + UNITS_TO_GOOGLE_SDK_ENUM, ) +from .helpers import convert_to_waypoint _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=5) +SCAN_INTERVAL = datetime.timedelta(minutes=10) +FIELD_MASK = "routes.duration,routes.localized_values" -def convert_time_to_utc(timestr): - """Take a string like 08:00:00 and convert it to a unix timestamp.""" - combined = datetime.combine( - dt_util.start_of_local_day(), dt_util.parse_time(timestr) +def convert_time(time_str: str) -> timestamp_pb2.Timestamp | None: + """Convert a string like '08:00' to a google pb2 Timestamp. + + If the time is in the past, it will be shifted to the next day. + """ + parsed_time = dt_util.parse_time(time_str) + if TYPE_CHECKING: + assert parsed_time is not None + start_of_day = dt_util.start_of_local_day() + combined = datetime.datetime.combine( + start_of_day, + parsed_time, + start_of_day.tzinfo, ) - if combined < datetime.now(): - combined = combined + timedelta(days=1) - return dt_util.as_timestamp(combined) + if combined < dt_util.now(): + combined = combined + datetime.timedelta(days=1) + timestamp = timestamp_pb2.Timestamp() + timestamp.FromDatetime(dt=combined) + return timestamp async def async_setup_entry( @@ -63,7 +98,8 @@ async def async_setup_entry( destination = config_entry.data[CONF_DESTINATION] name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) - client = Client(api_key, timeout=10) + client_options = ClientOptions(api_key=api_key) + client = RoutesAsyncClient(client_options=client_options) sensor = GoogleTravelTimeSensor( config_entry, name, api_key, origin, destination, client @@ -80,7 +116,15 @@ class GoogleTravelTimeSensor(SensorEntity): _attr_device_class = SensorDeviceClass.DURATION _attr_state_class = SensorStateClass.MEASUREMENT - def __init__(self, config_entry, name, api_key, origin, destination, client): + def __init__( + self, + config_entry: ConfigEntry, + name: str, + api_key: str, + origin: str, + destination: str, + client: RoutesAsyncClient, + ) -> None: """Initialize the sensor.""" self._attr_name = name self._attr_unique_id = config_entry.entry_id @@ -91,13 +135,12 @@ class GoogleTravelTimeSensor(SensorEntity): ) self._config_entry = config_entry - self._matrix = None - self._api_key = api_key + self._route: Route | None = None self._client = client self._origin = origin self._destination = destination - self._resolved_origin = None - self._resolved_destination = None + self._resolved_origin: str | None = None + self._resolved_destination: str | None = None async def async_added_to_hass(self) -> None: """Handle when entity is added.""" @@ -109,77 +152,127 @@ class GoogleTravelTimeSensor(SensorEntity): await self.first_update() @property - def native_value(self): + def native_value(self) -> float | None: """Return the state of the sensor.""" - if self._matrix is None: + if self._route is None: return None - _data = self._matrix["rows"][0]["elements"][0] - if "duration_in_traffic" in _data: - return round(_data["duration_in_traffic"]["value"] / 60) - if "duration" in _data: - return round(_data["duration"]["value"] / 60) - return None + return round(self._route.duration.seconds / 60) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - if self._matrix is None: + if self._route is None: return None - res = self._matrix.copy() - options = self._config_entry.options.copy() - res.update(options) - del res["rows"] - _data = self._matrix["rows"][0]["elements"][0] - if "duration_in_traffic" in _data: - res["duration_in_traffic"] = _data["duration_in_traffic"]["text"] - if "duration" in _data: - res["duration"] = _data["duration"]["text"] - if "distance" in _data: - res["distance"] = _data["distance"]["text"] - res["origin"] = self._resolved_origin - res["destination"] = self._resolved_destination - return res + result = self._config_entry.options.copy() + result["duration_in_traffic"] = self._route.localized_values.duration.text + result["duration"] = self._route.localized_values.static_duration.text + result["distance"] = self._route.localized_values.distance.text - async def first_update(self, _=None): + result["origin"] = self._resolved_origin + result["destination"] = self._resolved_destination + return result + + async def first_update(self, _=None) -> None: """Run the first update and write the state.""" - await self.hass.async_add_executor_job(self.update) + await self.async_update() self.async_write_ha_state() - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data from Google.""" - options_copy = self._config_entry.options.copy() - dtime = options_copy.get(CONF_DEPARTURE_TIME) - atime = options_copy.get(CONF_ARRIVAL_TIME) - if dtime is not None and ":" in dtime: - options_copy[CONF_DEPARTURE_TIME] = convert_time_to_utc(dtime) - elif dtime is not None: - options_copy[CONF_DEPARTURE_TIME] = dtime - elif atime is None: - options_copy[CONF_DEPARTURE_TIME] = "now" + travel_mode = TRAVEL_MODES_TO_GOOGLE_SDK_ENUM[ + self._config_entry.options[CONF_MODE] + ] - if atime is not None and ":" in atime: - options_copy[CONF_ARRIVAL_TIME] = convert_time_to_utc(atime) - elif atime is not None: - options_copy[CONF_ARRIVAL_TIME] = atime + if ( + departure_time := self._config_entry.options.get(CONF_DEPARTURE_TIME) + ) is not None: + departure_time = convert_time(departure_time) + + if ( + arrival_time := self._config_entry.options.get(CONF_ARRIVAL_TIME) + ) is not None: + arrival_time = convert_time(arrival_time) + if travel_mode != RouteTravelMode.TRANSIT: + arrival_time = None + + traffic_model = None + routing_preference = None + route_modifiers = None + if travel_mode == RouteTravelMode.DRIVE: + if ( + options_traffic_model := self._config_entry.options.get( + CONF_TRAFFIC_MODEL + ) + ) is not None: + traffic_model = TRAFFIC_MODELS_TO_GOOGLE_SDK_ENUM[options_traffic_model] + routing_preference = RoutingPreference.TRAFFIC_AWARE_OPTIMAL + route_modifiers = RouteModifiers( + avoid_tolls=self._config_entry.options.get(CONF_AVOID) == "tolls", + avoid_ferries=self._config_entry.options.get(CONF_AVOID) == "ferries", + avoid_highways=self._config_entry.options.get(CONF_AVOID) == "highways", + avoid_indoor=self._config_entry.options.get(CONF_AVOID) == "indoor", + ) + + transit_preferences = None + if travel_mode == RouteTravelMode.TRANSIT: + transit_routing_preference = None + transit_travel_mode = ( + TransitPreferences.TransitTravelMode.TRANSIT_TRAVEL_MODE_UNSPECIFIED + ) + if ( + option_transit_preferences := self._config_entry.options.get( + CONF_TRANSIT_ROUTING_PREFERENCE + ) + ) is not None: + transit_routing_preference = TRANSIT_PREFS_TO_GOOGLE_SDK_ENUM[ + option_transit_preferences + ] + if ( + option_transit_mode := self._config_entry.options.get(CONF_TRANSIT_MODE) + ) is not None: + transit_travel_mode = TRANSPORT_TYPES_TO_GOOGLE_SDK_ENUM[ + option_transit_mode + ] + transit_preferences = TransitPreferences( + routing_preference=transit_routing_preference, + allowed_travel_modes=[transit_travel_mode], + ) + + language = None + if ( + options_language := self._config_entry.options.get(CONF_LANGUAGE) + ) is not None: + language = options_language self._resolved_origin = find_coordinates(self.hass, self._origin) self._resolved_destination = find_coordinates(self.hass, self._destination) - _LOGGER.debug( "Getting update for origin: %s destination: %s", self._resolved_origin, self._resolved_destination, ) if self._resolved_destination is not None and self._resolved_origin is not None: + request = ComputeRoutesRequest( + origin=convert_to_waypoint(self.hass, self._resolved_origin), + destination=convert_to_waypoint(self.hass, self._resolved_destination), + travel_mode=travel_mode, + routing_preference=routing_preference, + departure_time=departure_time, + arrival_time=arrival_time, + route_modifiers=route_modifiers, + language_code=language, + units=UNITS_TO_GOOGLE_SDK_ENUM[self._config_entry.options[CONF_UNITS]], + traffic_model=traffic_model, + transit_preferences=transit_preferences, + ) try: - self._matrix = distance_matrix( - self._client, - self._resolved_origin, - self._resolved_destination, - **options_copy, + response = await self._client.compute_routes( + request, metadata=[("x-goog-fieldmask", FIELD_MASK)] ) - except (ApiError, TransportError, Timeout) as ex: + if response is not None and len(response.routes) > 0: + self._route = response.routes[0] + except GoogleAPIError as ex: _LOGGER.error("Error getting travel time: %s", ex) - self._matrix = None + self._route = None diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 765cfc9c4b6..87bc09eb456 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates, or a Google place ID. When specifying the location using a Google place ID, the ID must be prefixed with `place_id:`.", + "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or zone friendly name (case sensitive)", "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", @@ -33,16 +33,16 @@ "options": { "step": { "init": { - "description": "You can optionally specify either a Departure Time or Arrival Time. If specifying a departure time, you can enter `now`, a Unix timestamp, or a 24 hour time string like `08:00:00`. If specifying an arrival time, you can use a Unix timestamp or a 24 hour time string like `08:00:00`", + "description": "You can optionally specify either a departure time or arrival time in the form of a 24 hour time string like `08:00:00`", "data": { - "mode": "Travel Mode", + "mode": "Travel mode", "language": "[%key:common::config_flow::data::language%]", - "time_type": "Time Type", + "time_type": "Time type", "time": "Time", "avoid": "Avoid", - "traffic_model": "Traffic Model", - "transit_mode": "Transit Mode", - "transit_routing_preference": "Transit Routing Preference", + "traffic_model": "Traffic model", + "transit_mode": "Transit mode", + "transit_routing_preference": "Transit routing preference", "units": "Units" } } @@ -68,19 +68,19 @@ }, "units": { "options": { - "metric": "Metric System", - "imperial": "Imperial System" + "metric": "Metric system", + "imperial": "Imperial system" } }, "time_type": { "options": { - "arrival_time": "Arrival Time", - "departure_time": "Departure Time" + "arrival_time": "Arrival time", + "departure_time": "Departure time" } }, "traffic_model": { "options": { - "best_guess": "Best Guess", + "best_guess": "Best guess", "pessimistic": "Pessimistic", "optimistic": "Optimistic" } @@ -96,8 +96,8 @@ }, "transit_routing_preference": { "options": { - "less_walking": "Less Walking", - "fewer_transfers": "Fewer Transfers" + "less_walking": "Less walking", + "fewer_transfers": "Fewer transfers" } } } diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 1c61ae31010..93f90e36876 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -50,6 +50,10 @@ "local_name": "GVH5130*", "connectable": false }, + { + "local_name": "GVH5110*", + "connectable": false + }, { "manufacturer_id": 1, "service_uuid": "0000ec88-0000-1000-8000-00805f9b34fb", @@ -135,5 +139,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.43.0"] + "requirements": ["govee-ble==0.44.0"] } diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index 984654477e9..c5c8ed42ad5 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -157,9 +157,6 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if not self.is_on or not kwargs: - await self.coordinator.turn_on(self._device) - if ATTR_BRIGHTNESS in kwargs: brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0) await self.coordinator.set_brightness(self._device, brightness) @@ -187,6 +184,9 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): self._save_last_color_state() await self.coordinator.set_scene(self._device, effect) + if not self.is_on or not kwargs: + await self.coordinator.turn_on(self._device) + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index cba341cd482..55a6b9e8578 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==2.0.1"] + "requirements": ["govee-local-api==2.1.0"] } diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 7c7612ed201..37493ed24fa 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -24,6 +24,8 @@ from .const import ( DOMAIN, ) +type GPSLoggerConfigEntry = ConfigEntry[set[str]] + PLATFORMS = [Platform.DEVICE_TRACKER] TRACKER_UPDATE = f"{DOMAIN}_tracker_update" @@ -88,9 +90,9 @@ async def handle_webhook( return web.Response(text=f"Setting location for {device}") -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GPSLoggerConfigEntry) -> bool: """Configure based on config entry.""" - hass.data.setdefault(DOMAIN, {"devices": set(), "unsub_device_tracker": {}}) + entry.runtime_data = set() webhook.async_register( hass, DOMAIN, "GPSLogger", entry.data[CONF_WEBHOOK_ID], handle_webhook ) @@ -103,7 +105,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - hass.data[DOMAIN]["unsub_device_tracker"].pop(entry.entry_id)() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index be38382098d..950aa2a2638 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -1,7 +1,6 @@ """Support for the GPSLogger device tracking.""" from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_GPS_ACCURACY, @@ -15,19 +14,20 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import DOMAIN as GPL_DOMAIN, TRACKER_UPDATE +from . import TRACKER_UPDATE, GPSLoggerConfigEntry from .const import ( ATTR_ACTIVITY, ATTR_ALTITUDE, ATTR_DIRECTION, ATTR_PROVIDER, ATTR_SPEED, + DOMAIN, ) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GPSLoggerConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure a dispatcher connection based on a config entry.""" @@ -35,16 +35,14 @@ async def async_setup_entry( @callback def _receive_data(device, gps, battery, accuracy, attrs): """Receive set location.""" - if device in hass.data[GPL_DOMAIN]["devices"]: + if device in entry.runtime_data: return - hass.data[GPL_DOMAIN]["devices"].add(device) + entry.runtime_data.add(device) async_add_entities([GPSLoggerEntity(device, gps, battery, accuracy, attrs)]) - hass.data[GPL_DOMAIN]["unsub_device_tracker"][entry.entry_id] = ( - async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) - ) + entry.async_on_unload(async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data)) # Restore previously loaded devices dev_reg = dr.async_get(hass) @@ -58,7 +56,7 @@ async def async_setup_entry( entities = [] for dev_id in dev_ids: - hass.data[GPL_DOMAIN]["devices"].add(dev_id) + entry.runtime_data.add(dev_id) entity = GPSLoggerEntity(dev_id, None, None, None, None) entities.append(entity) @@ -83,7 +81,7 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity): self._unsub_dispatcher = None self._attr_unique_id = device self._attr_device_info = DeviceInfo( - identifiers={(GPL_DOMAIN, device)}, + identifiers={(DOMAIN, device)}, name=device, ) diff --git a/homeassistant/components/gpslogger/strings.json b/homeassistant/components/gpslogger/strings.json index a946574f8b8..3238d6f460e 100644 --- a/homeassistant/components/gpslogger/strings.json +++ b/homeassistant/components/gpslogger/strings.json @@ -2,8 +2,8 @@ "config": { "step": { "user": { - "title": "Set up the GPSLogger Webhook", - "description": "Are you sure you want to set up the GPSLogger Webhook?" + "title": "Set up the GPSLogger webhook", + "description": "Are you sure you want to set up the GPSLogger webhook?" } }, "abort": { diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index 7cb4f0f0921..2b5a38082fc 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -1,33 +1,29 @@ """The Gree Climate integration.""" +from __future__ import annotations + from datetime import timedelta import logging from homeassistant.components.network import async_get_ipv4_broadcast_addresses -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_track_time_interval -from .const import ( - COORDINATORS, - DATA_DISCOVERY_SERVICE, - DISCOVERY_SCAN_INTERVAL, - DISPATCHERS, - DOMAIN, -) -from .coordinator import DiscoveryService +from .const import DISCOVERY_SCAN_INTERVAL +from .coordinator import DiscoveryService, GreeConfigEntry, GreeRuntimeData _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool: """Set up Gree Climate from a config entry.""" - hass.data.setdefault(DOMAIN, {}) gree_discovery = DiscoveryService(hass, entry) - hass.data[DATA_DISCOVERY_SERVICE] = gree_discovery + entry.runtime_data = GreeRuntimeData( + discovery_service=gree_discovery, coordinators=[] + ) async def _async_scan_update(_=None): bcast_addr = list(await async_get_ipv4_broadcast_addresses(hass)) @@ -47,15 +43,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: GreeConfigEntry) -> bool: """Unload a config entry.""" - if hass.data.get(DATA_DISCOVERY_SERVICE) is not None: - hass.data.pop(DATA_DISCOVERY_SERVICE) - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - hass.data[DOMAIN].pop(COORDINATORS, None) - hass.data[DOMAIN].pop(DISPATCHERS, None) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index f703ded1ea2..e3549973f43 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -36,21 +36,18 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - COORDINATORS, DISPATCH_DEVICE_DISCOVERED, - DOMAIN, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, TARGET_TEMPERATURE_STEP, ) -from .coordinator import DeviceDataUpdateCoordinator +from .coordinator import DeviceDataUpdateCoordinator, GreeConfigEntry from .entity import GreeEntity _LOGGER = logging.getLogger(__name__) @@ -87,17 +84,17 @@ SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GreeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @callback - def init_device(coordinator): + def init_device(coordinator: DeviceDataUpdateCoordinator) -> None: """Register the device.""" async_add_entities([GreeClimateEntity(coordinator)]) - for coordinator in hass.data[DOMAIN][COORDINATORS]: + for coordinator in entry.runtime_data.coordinators: init_device(coordinator) entry.async_on_unload( diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index f926eb1c53e..6c1f8f954c9 100644 --- a/homeassistant/components/gree/const.py +++ b/homeassistant/components/gree/const.py @@ -1,16 +1,10 @@ """Constants for the Gree Climate integration.""" -COORDINATORS = "coordinators" - -DATA_DISCOVERY_SERVICE = "gree_discovery" - DISCOVERY_SCAN_INTERVAL = 300 DISCOVERY_TIMEOUT = 8 DISPATCH_DEVICE_DISCOVERED = "gree_device_discovered" -DISPATCHERS = "dispatchers" DOMAIN = "gree" -COORDINATOR = "coordinator" FAN_MEDIUM_LOW = "medium low" FAN_MEDIUM_HIGH = "medium high" @@ -20,3 +14,4 @@ MAX_ERRORS = 2 TARGET_TEMPERATURE_STEP = 1 UPDATE_INTERVAL = 60 +MAX_EXPECTED_RESPONSE_TIME_INTERVAL = UPDATE_INTERVAL * 2 diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py index 0d1aa60deaa..0d697398fc0 100644 --- a/homeassistant/components/gree/coordinator.py +++ b/homeassistant/components/gree/coordinator.py @@ -2,6 +2,8 @@ from __future__ import annotations +import copy +from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import Any @@ -19,24 +21,34 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from homeassistant.util.dt import utcnow from .const import ( - COORDINATORS, DISCOVERY_TIMEOUT, DISPATCH_DEVICE_DISCOVERED, DOMAIN, MAX_ERRORS, + MAX_EXPECTED_RESPONSE_TIME_INTERVAL, UPDATE_INTERVAL, ) _LOGGER = logging.getLogger(__name__) +type GreeConfigEntry = ConfigEntry[GreeRuntimeData] + + +@dataclass +class GreeRuntimeData: + """RUntime data for Gree Climate integration.""" + + discovery_service: DiscoveryService + coordinators: list[DeviceDataUpdateCoordinator] + class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Manages polling for state changes from the device.""" - config_entry: ConfigEntry + config_entry: GreeConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, device: Device + self, hass: HomeAssistant, config_entry: GreeConfigEntry, device: Device ) -> None: """Initialize the data update coordinator.""" super().__init__( @@ -48,7 +60,6 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): always_update=False, ) self.device = device - self.device.add_handler(Response.DATA, self.device_state_updated) self.device.add_handler(Response.RESULT, self.device_state_updated) self._error_count: int = 0 @@ -88,7 +99,9 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # raise update failed if time for more than MAX_ERRORS has passed since last update now = utcnow() elapsed_success = now - self._last_response_time - if self.update_interval and elapsed_success >= self.update_interval: + if self.update_interval and elapsed_success >= timedelta( + seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL + ): if not self._last_error_time or ( (now - self.update_interval) >= self._last_error_time ): @@ -96,16 +109,19 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._error_count += 1 _LOGGER.warning( - "Device %s is unresponsive for %s seconds", + "Device %s took an unusually long time to respond, %s seconds", self.name, elapsed_success, ) + else: + self._error_count = 0 if self.last_update_success and self._error_count >= MAX_ERRORS: raise UpdateFailed( f"Device {self.name} is unresponsive for too long and now unavailable" ) - return self.device.raw_properties + self._last_response_time = utcnow() + return copy.deepcopy(self.device.raw_properties) async def push_state_update(self): """Send state updates to the physical device.""" @@ -122,7 +138,7 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): class DiscoveryService(Listener): """Discovery event handler for gree devices.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: GreeConfigEntry) -> None: """Initialize discovery service.""" super().__init__() self.hass = hass @@ -131,8 +147,6 @@ class DiscoveryService(Listener): self.discovery = Discovery(DISCOVERY_TIMEOUT) self.discovery.add_listener(self) - hass.data[DOMAIN].setdefault(COORDINATORS, []) - async def device_found(self, device_info: DeviceInfo) -> None: """Handle new device found on the network.""" @@ -151,14 +165,14 @@ class DiscoveryService(Listener): device.device_info.port, ) coordo = DeviceDataUpdateCoordinator(self.hass, self.entry, device) - self.hass.data[DOMAIN][COORDINATORS].append(coordo) + self.entry.runtime_data.coordinators.append(coordo) await coordo.async_refresh() 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]: + for coordinator in self.entry.runtime_data.coordinators: if coordinator.device.device_info.mac == device_info.mac: coordinator.device.device_info.ip = device_info.ip await coordinator.async_refresh() diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index 45911433b92..403cf7d45fc 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -16,13 +16,13 @@ "name": "Panel light" }, "quiet": { - "name": "Quiet" + "name": "Quiet mode" }, "fresh_air": { "name": "Fresh air" }, "xfan": { - "name": "XFan" + "name": "Xtra fan" }, "health_mode": { "name": "Health mode" diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 67dc10138d1..ab138ea3be6 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -13,13 +13,13 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -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 AddConfigEntryEntitiesCallback -from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN -from .entity import GreeEntity +from .const import DISPATCH_DEVICE_DISCOVERED +from .coordinator import GreeConfigEntry +from .entity import DeviceDataUpdateCoordinator, GreeEntity @dataclass(kw_only=True, frozen=True) @@ -92,13 +92,13 @@ GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GreeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @callback - def init_device(coordinator): + def init_device(coordinator: DeviceDataUpdateCoordinator) -> None: """Register the device.""" async_add_entities( @@ -106,7 +106,7 @@ async def async_setup_entry( for description in GREE_SWITCHES ) - for coordinator in hass.data[DOMAIN][COORDINATORS]: + for coordinator in entry.runtime_data.coordinators: init_device(coordinator) entry.async_on_unload( diff --git a/homeassistant/components/greeneye_monitor/const.py b/homeassistant/components/greeneye_monitor/const.py index 40236b3219f..02c6d9845b0 100644 --- a/homeassistant/components/greeneye_monitor/const.py +++ b/homeassistant/components/greeneye_monitor/const.py @@ -1,5 +1,14 @@ """Shared constants for the greeneye_monitor integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from greeneye import Monitors + CONF_CHANNELS = "channels" CONF_COUNTED_QUANTITY = "counted_quantity" CONF_COUNTED_QUANTITY_PER_PULSE = "counted_quantity_per_pulse" @@ -13,8 +22,8 @@ CONF_TEMPERATURE_SENSORS = "temperature_sensors" CONF_TIME_UNIT = "time_unit" CONF_VOLTAGE_SENSORS = "voltage" -DATA_GREENEYE_MONITOR = "greeneye_monitor" DOMAIN = "greeneye_monitor" +DATA_GREENEYE_MONITOR: HassKey[Monitors] = HassKey(DOMAIN) SENSOR_TYPE_CURRENT = "current_sensor" SENSOR_TYPE_PULSE_COUNTER = "pulse_counter" diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index 04464fe2567..7cfc0e40fc0 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -109,7 +109,7 @@ async def async_setup_platform( if len(monitor_configs) == 0: monitors.remove_listener(on_new_monitor) - monitors: greeneye.Monitors = hass.data[DATA_GREENEYE_MONITOR] + monitors = hass.data[DATA_GREENEYE_MONITOR] monitors.add_listener(on_new_monitor) for monitor in monitors.monitors.values(): on_new_monitor(monitor) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index fb90eb9b22c..b80b78027bf 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Create Group", + "title": "Create group", "description": "Groups allow you to create a new entity that represents multiple entities of the same type.", "menu_options": { "binary_sensor": "Binary sensor group", @@ -104,7 +104,7 @@ "round_digits": "Round value to number of decimals", "device_class": "Device class", "state_class": "State class", - "unit_of_measurement": "Unit of Measurement" + "unit_of_measurement": "Unit of measurement" } }, "switch": { diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index 98ceb35ee17..7b3e67228b1 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/growatt_server", "iot_class": "cloud_polling", "loggers": ["growattServer"], - "requirements": ["growattServer==1.5.0"] + "requirements": ["growattServer==1.6.0"] } diff --git a/homeassistant/components/growatt_server/sensor/total.py b/homeassistant/components/growatt_server/sensor/total.py index 8111728d1e9..578745c8610 100644 --- a/homeassistant/components/growatt_server/sensor/total.py +++ b/homeassistant/components/growatt_server/sensor/total.py @@ -26,6 +26,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="todayEnergy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="total_output_power", @@ -33,6 +34,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="invTodayPpv", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="total_energy_output", diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index 9a985d98034..256efea447d 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -38,28 +38,28 @@ "name": "Input 1 voltage" }, "inverter_amperage_input_1": { - "name": "Input 1 Amperage" + "name": "Input 1 amperage" }, "inverter_wattage_input_1": { - "name": "Input 1 Wattage" + "name": "Input 1 wattage" }, "inverter_voltage_input_2": { "name": "Input 2 voltage" }, "inverter_amperage_input_2": { - "name": "Input 2 Amperage" + "name": "Input 2 amperage" }, "inverter_wattage_input_2": { - "name": "Input 2 Wattage" + "name": "Input 2 wattage" }, "inverter_voltage_input_3": { "name": "Input 3 voltage" }, "inverter_amperage_input_3": { - "name": "Input 3 Amperage" + "name": "Input 3 amperage" }, "inverter_wattage_input_3": { - "name": "Input 3 Wattage" + "name": "Input 3 wattage" }, "inverter_internal_wattage": { "name": "Internal wattage" @@ -137,13 +137,13 @@ "name": "Load consumption" }, "mix_wattage_pv_1": { - "name": "PV1 Wattage" + "name": "PV1 wattage" }, "mix_wattage_pv_2": { - "name": "PV2 Wattage" + "name": "PV2 wattage" }, "mix_wattage_pv_all": { - "name": "All PV Wattage" + "name": "All PV wattage" }, "mix_export_to_grid": { "name": "Export to grid" @@ -164,7 +164,7 @@ "name": "Load consumption today (solar)" }, "mix_self_consumption_today": { - "name": "Self consumption today (solar + battery)" + "name": "Self-consumption today (solar + battery)" }, "mix_load_consumption_battery_today": { "name": "Load consumption today (battery)" @@ -173,7 +173,7 @@ "name": "Import from grid today (load)" }, "mix_last_update": { - "name": "Last Data Update" + "name": "Last data update" }, "mix_import_from_grid_today_combined": { "name": "Import from grid today (load + charging)" @@ -182,7 +182,7 @@ "name": "Storage production today" }, "storage_storage_production_lifetime": { - "name": "Lifetime Storage production" + "name": "Lifetime storage production" }, "storage_grid_discharge_today": { "name": "Grid discharged today" @@ -224,7 +224,7 @@ "name": "Storage charging/ discharging(-ve)" }, "storage_load_consumption_solar_storage": { - "name": "Load consumption (Solar + Storage)" + "name": "Load consumption (solar + storage)" }, "storage_charge_today": { "name": "Charge today" @@ -257,7 +257,7 @@ "name": "Output voltage" }, "storage_ac_output_frequency": { - "name": "Ac output frequency" + "name": "AC output frequency" }, "storage_current_pv": { "name": "Solar charge current" @@ -290,7 +290,7 @@ "name": "Lifetime total energy input 1" }, "tlx_energy_today_input_1": { - "name": "Energy Today Input 1" + "name": "Energy today input 1" }, "tlx_voltage_input_1": { "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_1::name%]" @@ -305,7 +305,7 @@ "name": "Lifetime total energy input 2" }, "tlx_energy_today_input_2": { - "name": "Energy Today Input 2" + "name": "Energy today input 2" }, "tlx_voltage_input_2": { "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_2::name%]" @@ -320,7 +320,7 @@ "name": "Lifetime total energy input 3" }, "tlx_energy_today_input_3": { - "name": "Energy Today Input 3" + "name": "Energy today input 3" }, "tlx_voltage_input_3": { "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_3::name%]" @@ -335,16 +335,16 @@ "name": "Lifetime total energy input 4" }, "tlx_energy_today_input_4": { - "name": "Energy Today Input 4" + "name": "Energy today input 4" }, "tlx_voltage_input_4": { "name": "Input 4 voltage" }, "tlx_amperage_input_4": { - "name": "Input 4 Amperage" + "name": "Input 4 amperage" }, "tlx_wattage_input_4": { - "name": "Input 4 Wattage" + "name": "Input 4 wattage" }, "tlx_solar_generation_total": { "name": "Lifetime total solar energy" @@ -434,10 +434,10 @@ "name": "Money lifetime" }, "total_energy_today": { - "name": "Energy Today" + "name": "Energy today" }, "total_output_power": { - "name": "Output Power" + "name": "Output power" }, "total_energy_output": { "name": "[%key:component::growatt_server::entity::sensor::inverter_energy_total::name%]" diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 075c388c4e4..65f5525d587 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -3,28 +3,16 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any from aioguardian import Client -from aioguardian.errors import GuardianError -import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_DEVICE_ID, - CONF_FILENAME, - CONF_IP_ADDRESS, - CONF_PORT, - CONF_URL, - Platform, -) -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( API_SENSOR_PAIR_DUMP, @@ -39,40 +27,10 @@ from .const import ( SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .coordinator import GuardianDataUpdateCoordinator +from .services import setup_services -DATA_PAIRED_SENSOR_MANAGER = "paired_sensor_manager" +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -SERVICE_NAME_PAIR_SENSOR = "pair_sensor" -SERVICE_NAME_UNPAIR_SENSOR = "unpair_sensor" -SERVICE_NAME_UPGRADE_FIRMWARE = "upgrade_firmware" - -SERVICES = ( - SERVICE_NAME_PAIR_SENSOR, - SERVICE_NAME_UNPAIR_SENSOR, - SERVICE_NAME_UPGRADE_FIRMWARE, -) - -SERVICE_BASE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - } -) - -SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Required(CONF_UID): cv.string, - } -) - -SERVICE_UPGRADE_FIRMWARE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Optional(CONF_URL): cv.url, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_FILENAME): cv.string, - }, -) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -82,36 +40,26 @@ PLATFORMS = [ Platform.VALVE, ] +type GuardianConfigEntry = ConfigEntry[GuardianData] + @dataclass class GuardianData: - """Define an object to be stored in `hass.data`.""" + """Define an object to be stored in `entry.runtime_data`.""" - entry: ConfigEntry + entry: GuardianConfigEntry client: Client valve_controller_coordinators: dict[str, GuardianDataUpdateCoordinator] paired_sensor_manager: PairedSensorManager -@callback -def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) -> str: - """Get the entry ID related to a service call (by device ID).""" - device_id = call.data[CONF_DEVICE_ID] - device_registry = dr.async_get(hass) - - if (device_entry := device_registry.async_get(device_id)) is None: - raise ValueError(f"Invalid Guardian device ID: {device_id}") - - for entry_id in device_entry.config_entries: - if (entry := hass.config_entries.async_get_entry(entry_id)) is None: - continue - if entry.domain == DOMAIN: - return entry_id - - raise ValueError(f"No config entry for device ID: {device_id}") +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Elexa Guardian component.""" + setup_services(hass) + return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GuardianConfigEntry) -> bool: """Set up Elexa Guardian from a config entry.""" client = Client(entry.data[CONF_IP_ADDRESS], port=entry.data[CONF_PORT]) @@ -162,8 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await paired_sensor_manager.async_initialize() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = GuardianData( + entry.runtime_data = GuardianData( entry=entry, client=client, valve_controller_coordinators=valve_controller_coordinators, @@ -173,87 +120,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Set up all of the Guardian entity platforms: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - @callback - def call_with_data( - func: Callable[[ServiceCall, GuardianData], Coroutine[Any, Any, None]], - ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: - """Hydrate a service call with the appropriate GuardianData object.""" - - async def wrapper(call: ServiceCall) -> None: - """Wrap the service function.""" - entry_id = async_get_entry_id_for_service_call(hass, call) - data = hass.data[DOMAIN][entry_id] - - try: - async with data.client: - await func(call, data) - except GuardianError as err: - raise HomeAssistantError( - f"Error while executing {func.__name__}: {err}" - ) from err - - return wrapper - - @call_with_data - async def async_pair_sensor(call: ServiceCall, data: GuardianData) -> None: - """Add a new paired sensor.""" - uid = call.data[CONF_UID] - await data.client.sensor.pair_sensor(uid) - await data.paired_sensor_manager.async_pair_sensor(uid) - - @call_with_data - async def async_unpair_sensor(call: ServiceCall, data: GuardianData) -> None: - """Remove a paired sensor.""" - uid = call.data[CONF_UID] - await data.client.sensor.unpair_sensor(uid) - await data.paired_sensor_manager.async_unpair_sensor(uid) - - @call_with_data - async def async_upgrade_firmware(call: ServiceCall, data: GuardianData) -> None: - """Upgrade the device firmware.""" - await data.client.system.upgrade_firmware( - url=call.data[CONF_URL], - port=call.data[CONF_PORT], - filename=call.data[CONF_FILENAME], - ) - - for service_name, schema, method in ( - ( - SERVICE_NAME_PAIR_SENSOR, - SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, - async_pair_sensor, - ), - ( - SERVICE_NAME_UNPAIR_SENSOR, - SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, - async_unpair_sensor, - ), - ( - SERVICE_NAME_UPGRADE_FIRMWARE, - SERVICE_UPGRADE_FIRMWARE_SCHEMA, - async_upgrade_firmware, - ), - ): - if hass.services.has_service(DOMAIN, service_name): - continue - hass.services.async_register(DOMAIN, service_name, method, schema=schema) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GuardianConfigEntry) -> 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.config_entries.async_loaded_entries(DOMAIN): - # If this is the last loaded instance of Guardian, 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) class PairedSensorManager: @@ -262,7 +134,7 @@ class PairedSensorManager: def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, client: Client, api_lock: asyncio.Lock, sensor_pair_dump_coordinator: GuardianDataUpdateCoordinator, diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 7d5f97bdb65..d6583abd843 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -12,17 +12,15 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry 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 AddConfigEntryEntitiesCallback -from . import GuardianData +from . import GuardianConfigEntry from .const import ( API_SYSTEM_ONBOARD_SENSOR_STATUS, CONF_UID, - DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .coordinator import GuardianDataUpdateCoordinator @@ -87,11 +85,11 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data uid = entry.data[CONF_UID] async_finish_entity_domain_replacements( @@ -151,7 +149,7 @@ class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, coordinator: GuardianDataUpdateCoordinator, description: BinarySensorEntityDescription, ) -> None: @@ -173,7 +171,7 @@ class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, coordinators: dict[str, GuardianDataUpdateCoordinator], description: ValveControllerBinarySensorDescription, ) -> None: diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py index 01bac63c6e3..2ecdbed38ea 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -12,14 +12,13 @@ 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.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import GuardianData -from .const import API_SYSTEM_DIAGNOSTICS, DOMAIN +from . import GuardianConfigEntry, GuardianData +from .const import API_SYSTEM_DIAGNOSTICS from .entity import ValveControllerEntity, ValveControllerEntityDescription from .util import convert_exceptions_to_homeassistant_error @@ -69,11 +68,11 @@ BUTTON_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian buttons based on a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( GuardianButton(entry, data, description) for description in BUTTON_DESCRIPTIONS @@ -90,7 +89,7 @@ class GuardianButton(ValveControllerEntity, ButtonEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, data: GuardianData, description: ValveControllerButtonDescription, ) -> None: diff --git a/homeassistant/components/guardian/coordinator.py b/homeassistant/components/guardian/coordinator.py index 500b7c10784..a49bf6803d9 100644 --- a/homeassistant/components/guardian/coordinator.py +++ b/homeassistant/components/guardian/coordinator.py @@ -5,18 +5,20 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine from datetime import timedelta -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from aioguardian import Client from aioguardian.errors import GuardianError -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +if TYPE_CHECKING: + from . import GuardianConfigEntry + DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}" @@ -25,13 +27,13 @@ SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}" class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Define an extended DataUpdateCoordinator with some Guardian goodies.""" - config_entry: ConfigEntry + config_entry: GuardianConfigEntry def __init__( self, hass: HomeAssistant, *, - entry: ConfigEntry, + entry: GuardianConfigEntry, client: Client, api_name: str, api_coro: Callable[[], Coroutine[Any, Any, dict[str, Any]]], diff --git a/homeassistant/components/guardian/diagnostics.py b/homeassistant/components/guardian/diagnostics.py index 2f4287bea29..22a1bde7817 100644 --- a/homeassistant/components/guardian/diagnostics.py +++ b/homeassistant/components/guardian/diagnostics.py @@ -5,12 +5,11 @@ 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_UNIQUE_ID from homeassistant.core import HomeAssistant -from . import GuardianData -from .const import CONF_UID, DOMAIN +from . import GuardianConfigEntry +from .const import CONF_UID CONF_BSSID = "bssid" CONF_PAIRED_UIDS = "paired_uids" @@ -29,10 +28,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: GuardianConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/guardian/entity.py b/homeassistant/components/guardian/entity.py index fca0afeda0e..c48c87afa01 100644 --- a/homeassistant/components/guardian/entity.py +++ b/homeassistant/components/guardian/entity.py @@ -4,11 +4,11 @@ from __future__ import annotations from dataclasses import dataclass -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 GuardianConfigEntry from .const import API_SYSTEM_DIAGNOSTICS, CONF_UID, DOMAIN from .coordinator import GuardianDataUpdateCoordinator @@ -32,7 +32,7 @@ class PairedSensorEntity(GuardianEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, coordinator: GuardianDataUpdateCoordinator, description: EntityDescription, ) -> None: @@ -62,7 +62,7 @@ class ValveControllerEntity(GuardianEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, coordinators: dict[str, GuardianDataUpdateCoordinator], description: ValveControllerEntityDescription, ) -> None: diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 13dd8e01296..da4a78d7b7e 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfElectricCurrent, @@ -25,13 +24,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import GuardianData +from . import GuardianConfigEntry from .const import ( API_SYSTEM_DIAGNOSTICS, API_SYSTEM_ONBOARD_SENSOR_STATUS, API_VALVE_STATUS, CONF_UID, - DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .entity import ( @@ -138,11 +136,11 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data @callback def add_new_paired_sensor(uid: str) -> None: diff --git a/homeassistant/components/guardian/services.py b/homeassistant/components/guardian/services.py new file mode 100644 index 00000000000..288c6becbee --- /dev/null +++ b/homeassistant/components/guardian/services.py @@ -0,0 +1,144 @@ +"""Support for Guardian services.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any + +from aioguardian.errors import GuardianError +import voluptuous as vol + +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_DEVICE_ID, + CONF_FILENAME, + CONF_PORT, + CONF_URL, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .const import CONF_UID, DOMAIN + +if TYPE_CHECKING: + from . import GuardianConfigEntry, GuardianData + +SERVICE_NAME_PAIR_SENSOR = "pair_sensor" +SERVICE_NAME_UNPAIR_SENSOR = "unpair_sensor" +SERVICE_NAME_UPGRADE_FIRMWARE = "upgrade_firmware" + +SERVICES = ( + SERVICE_NAME_PAIR_SENSOR, + SERVICE_NAME_UNPAIR_SENSOR, + SERVICE_NAME_UPGRADE_FIRMWARE, +) + +SERVICE_BASE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + } +) + +SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(CONF_UID): cv.string, + } +) + +SERVICE_UPGRADE_FIRMWARE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Optional(CONF_URL): cv.url, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_FILENAME): cv.string, + }, +) + + +@callback +def async_get_entry_id_for_service_call(call: ServiceCall) -> GuardianConfigEntry: + """Get the entry ID related to a service call (by device ID).""" + device_id = call.data[CONF_DEVICE_ID] + device_registry = dr.async_get(call.hass) + + if (device_entry := device_registry.async_get(device_id)) is None: + raise ValueError(f"Invalid Guardian device ID: {device_id}") + + for entry_id in device_entry.config_entries: + if (entry := call.hass.config_entries.async_get_entry(entry_id)) is None: + continue + if entry.domain == DOMAIN: + return entry + + raise ValueError(f"No config entry for device ID: {device_id}") + + +@callback +def call_with_data( + func: Callable[[ServiceCall, GuardianData], Coroutine[Any, Any, None]], +) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: + """Hydrate a service call with the appropriate GuardianData object.""" + + async def wrapper(call: ServiceCall) -> None: + """Wrap the service function.""" + data = async_get_entry_id_for_service_call(call).runtime_data + + try: + async with data.client: + await func(call, data) + except GuardianError as err: + raise HomeAssistantError( + f"Error while executing {func.__name__}: {err}" + ) from err + + return wrapper + + +@call_with_data +async def async_pair_sensor(call: ServiceCall, data: GuardianData) -> None: + """Add a new paired sensor.""" + uid = call.data[CONF_UID] + await data.client.sensor.pair_sensor(uid) + await data.paired_sensor_manager.async_pair_sensor(uid) + + +@call_with_data +async def async_unpair_sensor(call: ServiceCall, data: GuardianData) -> None: + """Remove a paired sensor.""" + uid = call.data[CONF_UID] + await data.client.sensor.unpair_sensor(uid) + await data.paired_sensor_manager.async_unpair_sensor(uid) + + +@call_with_data +async def async_upgrade_firmware(call: ServiceCall, data: GuardianData) -> None: + """Upgrade the device firmware.""" + await data.client.system.upgrade_firmware( + url=call.data[CONF_URL], + port=call.data[CONF_PORT], + filename=call.data[CONF_FILENAME], + ) + + +def setup_services(hass: HomeAssistant) -> None: + """Register the Renault services.""" + for service_name, schema, method in ( + ( + SERVICE_NAME_PAIR_SENSOR, + SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, + async_pair_sensor, + ), + ( + SERVICE_NAME_UNPAIR_SENSOR, + SERVICE_PAIR_UNPAIR_SENSOR_SCHEMA, + async_unpair_sensor, + ), + ( + SERVICE_NAME_UPGRADE_FIRMWARE, + SERVICE_UPGRADE_FIRMWARE_SCHEMA, + async_upgrade_firmware, + ), + ): + hass.services.async_register(DOMAIN, service_name, method, schema=schema) diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index a2c9ca282be..7640425d8c1 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -9,13 +9,12 @@ from typing import Any from aioguardian import Client from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import GuardianData -from .const import API_VALVE_STATUS, API_WIFI_STATUS, DOMAIN +from . import GuardianConfigEntry, GuardianData +from .const import API_VALVE_STATUS, API_WIFI_STATUS from .entity import ValveControllerEntity, ValveControllerEntityDescription from .util import convert_exceptions_to_homeassistant_error from .valve import GuardianValveState @@ -111,11 +110,11 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( ValveControllerSwitch(entry, data, description) @@ -130,7 +129,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, data: GuardianData, description: ValveControllerSwitchDescription, ) -> None: diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 69e79f6627e..d05b6ef98d9 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any, Concatenate from aioguardian.errors import GuardianError -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -18,6 +17,7 @@ from homeassistant.helpers import entity_registry as er from .const import LOGGER if TYPE_CHECKING: + from . import GuardianConfigEntry from .entity import GuardianEntity DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) @@ -36,7 +36,7 @@ class EntityDomainReplacementStrategy: @callback def async_finish_entity_domain_replacements( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, entity_replacement_strategies: Iterable[EntityDomainReplacementStrategy], ) -> None: """Remove old entities and create a repairs issue with info on their replacement.""" diff --git a/homeassistant/components/guardian/valve.py b/homeassistant/components/guardian/valve.py index 6847b3211c5..ad8cd9cae00 100644 --- a/homeassistant/components/guardian/valve.py +++ b/homeassistant/components/guardian/valve.py @@ -15,12 +15,11 @@ from homeassistant.components.valve import ( ValveEntityDescription, ValveEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import GuardianData -from .const import API_VALVE_STATUS, DOMAIN +from . import GuardianConfigEntry, GuardianData +from .const import API_VALVE_STATUS from .entity import ValveControllerEntity, ValveControllerEntityDescription from .util import convert_exceptions_to_homeassistant_error @@ -110,11 +109,11 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuardianConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" - data: GuardianData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( ValveControllerValve(entry, data, description) @@ -132,7 +131,7 @@ class ValveControllerValve(ValveControllerEntity, ValveEntity): def __init__( self, - entry: ConfigEntry, + entry: GuardianConfigEntry, data: GuardianData, description: ValveControllerValveDescription, ) -> None: diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 353bcbbd39d..f9874c711f0 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 APPLICATION_NAME, CONF_PATH, __version__ +from homeassistant.const import APPLICATION_NAME, __version__ CONF_API_USER = "api_user" @@ -13,15 +13,6 @@ HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png" DOMAIN = "habitica" -# service constants -SERVICE_API_CALL = "api_call" -ATTR_PATH = CONF_PATH -ATTR_ARGS = "args" - -# event constants -EVENT_API_CALL_SUCCESS = f"{DOMAIN}_{SERVICE_API_CALL}_success" -ATTR_DATA = "data" - MANUFACTURER = "HabitRPG, Inc." NAME = "Habitica" @@ -40,6 +31,23 @@ ATTR_ALIAS = "alias" ATTR_PRIORITY = "priority" ATTR_COST = "cost" ATTR_NOTES = "notes" +ATTR_UP_DOWN = "up_down" +ATTR_FREQUENCY = "frequency" +ATTR_COUNTER_UP = "counter_up" +ATTR_COUNTER_DOWN = "counter_down" +ATTR_ADD_CHECKLIST_ITEM = "add_checklist_item" +ATTR_REMOVE_CHECKLIST_ITEM = "remove_checklist_item" +ATTR_SCORE_CHECKLIST_ITEM = "score_checklist_item" +ATTR_UNSCORE_CHECKLIST_ITEM = "unscore_checklist_item" +ATTR_REMINDER = "reminder" +ATTR_REMOVE_REMINDER = "remove_reminder" +ATTR_CLEAR_REMINDER = "clear_reminder" +ATTR_CLEAR_DATE = "clear_date" +ATTR_REPEAT = "repeat" +ATTR_INTERVAL = "every_x" +ATTR_START_DATE = "start_date" +ATTR_REPEAT_MONTHLY = "repeat_monthly" +ATTR_STREAK = "streak" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -56,6 +64,13 @@ SERVICE_SCORE_REWARD = "score_reward" SERVICE_TRANSFORMATION = "transformation" SERVICE_UPDATE_REWARD = "update_reward" +SERVICE_CREATE_REWARD = "create_reward" +SERVICE_UPDATE_HABIT = "update_habit" +SERVICE_CREATE_HABIT = "create_habit" +SERVICE_UPDATE_TODO = "update_todo" +SERVICE_CREATE_TODO = "create_todo" +SERVICE_UPDATE_DAILY = "update_daily" +SERVICE_CREATE_DAILY = "create_daily" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" @@ -63,3 +78,5 @@ 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" + +WEEK_DAYS = ["m", "t", "w", "th", "f", "s", "su"] diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 3c3a16f591a..d0eb60312b4 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -52,10 +52,10 @@ type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): """Habitica Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: HabiticaConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, habitica: Habitica + self, hass: HomeAssistant, config_entry: HabiticaConfigEntry, habitica: Habitica ) -> None: """Initialize the Habitica data coordinator.""" super().__init__( diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index e119b063aa5..aac90814af5 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -224,6 +224,60 @@ "tag_options": "mdi:tag", "developer_options": "mdi:test-tube" } + }, + "create_reward": { + "service": "mdi:treasure-chest-outline", + "sections": { + "developer_options": "mdi:test-tube" + } + }, + "update_habit": { + "service": "mdi:contrast-box", + "sections": { + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube" + } + }, + "create_habit": { + "service": "mdi:contrast-box", + "sections": { + "developer_options": "mdi:test-tube" + } + }, + "update_todo": { + "service": "mdi:pencil-box-outline", + "sections": { + "checklist_options": "mdi:format-list-checks", + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube", + "duedate_options": "mdi:calendar-blank", + "reminder_options": "mdi:reminder" + } + }, + "create_todo": { + "service": "mdi:pencil-box-outline", + "sections": { + "developer_options": "mdi:test-tube" + } + }, + "update_daily": { + "service": "mdi:calendar-month", + "sections": { + "checklist_options": "mdi:format-list-checks", + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube", + "reminder_options": "mdi:reminder", + "repeat_weekly_options": "mdi:calendar-refresh", + "repeat_monthly_options": "mdi:calendar-refresh" + } + }, + "create_daily": { + "service": "mdi:calendar-month", + "sections": { + "developer_options": "mdi:test-tube", + "repeat_weekly_options": "mdi:calendar-refresh", + "repeat_monthly_options": "mdi:calendar-refresh" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 57005cf2b72..8ef12a38f1c 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -3,16 +3,21 @@ from __future__ import annotations from dataclasses import asdict +from datetime import UTC, date, datetime, time import logging from typing import TYPE_CHECKING, Any, cast -from uuid import UUID +from uuid import UUID, uuid4 from aiohttp import ClientError from habiticalib import ( + Checklist, Direction, + Frequency, HabiticaException, NotAuthorizedError, NotFoundError, + Reminders, + Repeat, Skill, Task, TaskData, @@ -24,7 +29,7 @@ import voluptuous as vol from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_NAME, CONF_NAME +from homeassistant.const import ATTR_DATE, ATTR_NAME from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -33,34 +38,50 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.selector import ConfigEntrySelector +from homeassistant.util import dt as dt_util from .const import ( + ATTR_ADD_CHECKLIST_ITEM, ATTR_ALIAS, - ATTR_ARGS, + ATTR_CLEAR_DATE, + ATTR_CLEAR_REMINDER, ATTR_CONFIG_ENTRY, ATTR_COST, - ATTR_DATA, + ATTR_COUNTER_DOWN, + ATTR_COUNTER_UP, ATTR_DIRECTION, + ATTR_FREQUENCY, + ATTR_INTERVAL, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, - ATTR_PATH, ATTR_PRIORITY, + ATTR_REMINDER, + ATTR_REMOVE_CHECKLIST_ITEM, + ATTR_REMOVE_REMINDER, ATTR_REMOVE_TAG, + ATTR_REPEAT, + ATTR_REPEAT_MONTHLY, + ATTR_SCORE_CHECKLIST_ITEM, ATTR_SKILL, + ATTR_START_DATE, + ATTR_STREAK, ATTR_TAG, ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UNSCORE_CHECKLIST_ITEM, + ATTR_UP_DOWN, DOMAIN, - EVENT_API_CALL_SUCCESS, SERVICE_ABORT_QUEST, SERVICE_ACCEPT_QUEST, - SERVICE_API_CALL, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_DAILY, + SERVICE_CREATE_HABIT, + SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -68,21 +89,17 @@ from .const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_DAILY, + SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, + WEEK_DAYS, ) from .coordinator import HabiticaConfigEntry _LOGGER = logging.getLogger(__name__) -SERVICE_API_CALL_SCHEMA = vol.Schema( - { - vol.Required(ATTR_NAME): str, - vol.Required(ATTR_PATH): vol.All(cv.ensure_list, [str]), - vol.Optional(ATTR_ARGS): dict, - } -) - SERVICE_CAST_SKILL_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), @@ -112,21 +129,65 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( } ) -SERVICE_UPDATE_TASK_SCHEMA = vol.Schema( +BASE_TASK_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), - vol.Required(ATTR_TASK): cv.string, vol.Optional(ATTR_RENAME): cv.string, vol.Optional(ATTR_NOTES): cv.string, vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), - vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_ALIAS): vol.All( cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") ), - vol.Optional(ATTR_COST): vol.Coerce(float), + vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)), + vol.Optional(ATTR_PRIORITY): vol.All( + vol.Upper, vol.In(TaskPriority._member_names_) + ), + vol.Optional(ATTR_UP_DOWN): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_COUNTER_UP): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_COUNTER_DOWN): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_FREQUENCY): vol.Coerce(Frequency), + vol.Optional(ATTR_DATE): cv.date, + vol.Optional(ATTR_CLEAR_DATE): cv.boolean, + vol.Optional(ATTR_REMINDER): vol.All( + cv.ensure_list, [vol.Any(cv.datetime, cv.time)] + ), + vol.Optional(ATTR_REMOVE_REMINDER): vol.All( + cv.ensure_list, [vol.Any(cv.datetime, cv.time)] + ), + vol.Optional(ATTR_CLEAR_REMINDER): cv.boolean, + vol.Optional(ATTR_ADD_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_REMOVE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_SCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_UNSCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_START_DATE): cv.date, + vol.Optional(ATTR_INTERVAL): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_REPEAT): vol.All(cv.ensure_list, [vol.In(WEEK_DAYS)]), + vol.Optional(ATTR_REPEAT_MONTHLY): vol.All( + cv.string, vol.In({"day_of_month", "day_of_week"}) + ), + vol.Optional(ATTR_STREAK): vol.All(int, vol.Range(0)), } ) +SERVICE_UPDATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( + { + vol.Required(ATTR_TASK): cv.string, + vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), + } +) + +SERVICE_CREATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( + { + vol.Required(ATTR_NAME): cv.string, + } +) + +SERVICE_DAILY_SCHEMA = { + vol.Optional(ATTR_REMINDER): vol.All(cv.ensure_list, [cv.time]), + vol.Optional(ATTR_REMOVE_REMINDER): vol.All(cv.ensure_list, [cv.time]), +} + + SERVICE_GET_TASKS_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), @@ -161,6 +222,17 @@ ITEMID_MAP = { "shiny_seed": Skill.SHINY_SEED, } +SERVICE_TASK_TYPE_MAP = { + SERVICE_UPDATE_REWARD: TaskType.REWARD, + SERVICE_CREATE_REWARD: TaskType.REWARD, + SERVICE_UPDATE_HABIT: TaskType.HABIT, + SERVICE_CREATE_HABIT: TaskType.HABIT, + SERVICE_UPDATE_TODO: TaskType.TODO, + SERVICE_CREATE_TODO: TaskType.TODO, + SERVICE_UPDATE_DAILY: TaskType.DAILY, + SERVICE_CREATE_DAILY: TaskType.DAILY, +} + def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: """Return config entry or raise if not found or not loaded.""" @@ -180,46 +252,6 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Set up services for Habitica integration.""" - async def handle_api_call(call: ServiceCall) -> None: - async_create_issue( - hass, - DOMAIN, - "deprecated_api_call", - breaks_in_ha_version="2025.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_api_call", - ) - _LOGGER.warning( - "Deprecated action called: 'habitica.api_call' is deprecated and will be removed in Home Assistant version 2025.6.0" - ) - - name = call.data[ATTR_NAME] - path = call.data[ATTR_PATH] - entries: list[HabiticaConfigEntry] = hass.config_entries.async_entries(DOMAIN) - - api = None - for entry in entries: - if entry.data[CONF_NAME] == name: - api = await entry.runtime_data.habitica.habitipy() - break - if api is None: - _LOGGER.error("API_CALL: User '%s' not configured", name) - return - try: - for element in path: - api = api[element] - except KeyError: - _LOGGER.error( - "API_CALL: Path %s is invalid for API on '{%s}' element", path, element - ) - return - kwargs = call.data.get(ATTR_ARGS, {}) - data = await api(**kwargs) - hass.bus.async_fire( - EVENT_API_CALL_SUCCESS, {ATTR_NAME: name, ATTR_PATH: path, ATTR_DATA: data} - ) - async def cast_skill(call: ServiceCall) -> ServiceResponse: """Skill action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) @@ -539,33 +571,42 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result - async def update_task(call: ServiceCall) -> ServiceResponse: - """Update task action.""" + async def create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901 + """Create or update task action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data await coordinator.async_refresh() + is_update = call.service in ( + SERVICE_UPDATE_HABIT, + SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, + SERVICE_UPDATE_DAILY, + ) + task_type = SERVICE_TASK_TYPE_MAP[call.service] + current_task = None - try: - current_task = next( - task - for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is TaskType.REWARD - ) - except StopIteration as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="task_not_found", - translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, - ) from e + if is_update: + try: + current_task = next( + task + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + and task.Type is task_type + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e - task_id = current_task.id - if TYPE_CHECKING: - assert task_id data = Task() - if rename := call.data.get(ATTR_RENAME): - data["text"] = rename + if not is_update: + data["type"] = task_type + + if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): + data["text"] = text if (notes := call.data.get(ATTR_NOTES)) is not None: data["notes"] = notes @@ -574,7 +615,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) if tags or remove_tags: - update_tags = set(current_task.tags) + update_tags = set(current_task.tags) if current_task else set() user_tags = { tag.name.lower(): tag.id for tag in coordinator.data.user.tags @@ -633,8 +674,159 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if (cost := call.data.get(ATTR_COST)) is not None: data["value"] = cost + if priority := call.data.get(ATTR_PRIORITY): + data["priority"] = TaskPriority[priority] + + if frequency := call.data.get(ATTR_FREQUENCY): + data["frequency"] = frequency + else: + frequency = current_task.frequency if current_task else Frequency.WEEKLY + + if up_down := call.data.get(ATTR_UP_DOWN): + data["up"] = "up" in up_down + data["down"] = "down" in up_down + + if counter_up := call.data.get(ATTR_COUNTER_UP): + data["counterUp"] = counter_up + + if counter_down := call.data.get(ATTR_COUNTER_DOWN): + data["counterDown"] = counter_down + + if due_date := call.data.get(ATTR_DATE): + data["date"] = datetime.combine(due_date, time()) + + if call.data.get(ATTR_CLEAR_DATE): + data["date"] = None + + checklist = current_task.checklist if current_task else [] + + if add_checklist_item := call.data.get(ATTR_ADD_CHECKLIST_ITEM): + checklist.extend( + Checklist(completed=False, id=uuid4(), text=item) + for item in add_checklist_item + if not any(i.text == item for i in checklist) + ) + if remove_checklist_item := call.data.get(ATTR_REMOVE_CHECKLIST_ITEM): + checklist = [ + item for item in checklist if item.text not in remove_checklist_item + ] + + if score_checklist_item := call.data.get(ATTR_SCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in score_checklist_item: + item.completed = True + + if unscore_checklist_item := call.data.get(ATTR_UNSCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in unscore_checklist_item: + item.completed = False + if ( + add_checklist_item + or remove_checklist_item + or score_checklist_item + or unscore_checklist_item + ): + data["checklist"] = checklist + + reminders = current_task.reminders if current_task else [] + + if add_reminders := call.data.get(ATTR_REMINDER): + if task_type is TaskType.TODO: + existing_reminder_datetimes = { + r.time.replace(tzinfo=None) for r in reminders + } + + reminders.extend( + Reminders(id=uuid4(), time=r) + for r in add_reminders + if r not in existing_reminder_datetimes + ) + if task_type is TaskType.DAILY: + existing_reminder_times = { + r.time.time().replace(microsecond=0, second=0) for r in reminders + } + + reminders.extend( + Reminders( + id=uuid4(), + time=datetime.combine(date.today(), r, tzinfo=UTC), + ) + for r in add_reminders + if r not in existing_reminder_times + ) + + if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER): + if task_type is TaskType.TODO: + reminders = list( + filter( + lambda r: r.time.replace(tzinfo=None) not in remove_reminder, + reminders, + ) + ) + if task_type is TaskType.DAILY: + reminders = list( + filter( + lambda r: r.time.time().replace(second=0, microsecond=0) + not in remove_reminder, + reminders, + ) + ) + + if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER): + reminders = [] + + if add_reminders or remove_reminder or clear_reminders: + data["reminders"] = reminders + + if start_date := call.data.get(ATTR_START_DATE): + data["startDate"] = datetime.combine(start_date, time()) + else: + start_date = ( + current_task.startDate + if current_task and current_task.startDate + else dt_util.start_of_local_day() + ) + if repeat := call.data.get(ATTR_REPEAT): + if frequency is Frequency.WEEKLY: + data["repeat"] = Repeat(**{d: d in repeat for d in WEEK_DAYS}) + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="frequency_not_weekly", + ) + if repeat_monthly := call.data.get(ATTR_REPEAT_MONTHLY): + if frequency is not Frequency.MONTHLY: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="frequency_not_monthly", + ) + + if repeat_monthly == "day_of_week": + weekday = start_date.weekday() + data["weeksOfMonth"] = [(start_date.day - 1) // 7] + data["repeat"] = Repeat( + **{day: i == weekday for i, day in enumerate(WEEK_DAYS)} + ) + data["daysOfMonth"] = [] + + else: + data["daysOfMonth"] = [start_date.day] + data["weeksOfMonth"] = [] + + if interval := call.data.get(ATTR_INTERVAL): + data["everyX"] = interval + + if streak := call.data.get(ATTR_STREAK): + data["streak"] = streak + try: - response = await coordinator.habitica.update_task(task_id, data) + if is_update: + if TYPE_CHECKING: + assert current_task + assert current_task.id + response = await coordinator.habitica.update_task(current_task.id, data) + else: + response = await coordinator.habitica.create_task(data) except TooManyRequestsError as e: raise HomeAssistantError( translation_domain=DOMAIN, @@ -656,19 +848,32 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 else: return response.data.to_dict(omit_none=True) - hass.services.async_register( - DOMAIN, + for service in ( + SERVICE_UPDATE_DAILY, + SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, - update_task, - schema=SERVICE_UPDATE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - hass.services.async_register( - DOMAIN, - SERVICE_API_CALL, - handle_api_call, - schema=SERVICE_API_CALL_SCHEMA, - ) + SERVICE_UPDATE_TODO, + ): + hass.services.async_register( + DOMAIN, + service, + create_or_update_task, + schema=SERVICE_UPDATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + for service in ( + SERVICE_CREATE_DAILY, + SERVICE_CREATE_HABIT, + SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, + ): + hass.services.async_register( + DOMAIN, + service, + create_or_update_task, + schema=SERVICE_CREATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 7b486690ef5..e7f4b4207b0 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -1,20 +1,4 @@ # Describes the format for Habitica service -api_call: - fields: - name: - required: true - example: "xxxNotAValidNickxxx" - selector: - text: - path: - required: true - example: '["tasks", "user", "post"]' - selector: - object: - args: - example: '{"text": "Use API from Home Assistant", "type": "todo"}' - selector: - object: cast_skill: fields: config_entry: &config_entry @@ -144,26 +128,26 @@ update_reward: fields: config_entry: *config_entry task: *task - rename: + rename: &rename selector: text: - notes: + notes: ¬es required: false selector: text: multiline: true cost: required: false - selector: + selector: &cost_selector number: min: 0 step: 0.01 unit_of_measurement: "🪙" mode: box - tag_options: + tag_options: &tag_options collapsed: true fields: - tag: + tag: &tag required: false selector: text: @@ -173,10 +157,274 @@ update_reward: selector: text: multiple: true - developer_options: + developer_options: &developer_options collapsed: true fields: - alias: + alias: &alias required: false selector: text: +create_reward: + fields: + config_entry: *config_entry + name: &name + required: true + selector: + text: + notes: *notes + cost: + required: true + selector: *cost_selector + tag: *tag + developer_options: *developer_options +update_habit: + fields: + config_entry: *config_entry + task: *task + rename: *rename + notes: *notes + up_down: &up_down + required: false + selector: + select: + options: + - value: up + label: "➕" + - value: down + label: "➖" + multiple: true + mode: list + priority: &priority + required: false + selector: + select: + options: + - "trivial" + - "easy" + - "medium" + - "hard" + mode: dropdown + translation_key: "priority" + frequency: &frequency + required: false + selector: + select: + options: + - "daily" + - "weekly" + - "monthly" + translation_key: "frequency" + mode: dropdown + tag_options: *tag_options + developer_options: + collapsed: true + fields: + counter_up: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "➕" + mode: box + counter_down: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "➖" + mode: box + alias: *alias +create_habit: + fields: + config_entry: *config_entry + name: *name + notes: *notes + up_down: *up_down + priority: *priority + frequency: *frequency + tag: *tag + developer_options: *developer_options +update_todo: + fields: + config_entry: *config_entry + task: *task + rename: *rename + notes: *notes + checklist_options: &checklist_options + collapsed: true + fields: + add_checklist_item: &add_checklist_item + required: false + selector: + text: + multiple: true + remove_checklist_item: + required: false + selector: + text: + multiple: true + score_checklist_item: + required: false + selector: + text: + multiple: true + unscore_checklist_item: + required: false + selector: + text: + multiple: true + priority: *priority + duedate_options: + collapsed: true + fields: + date: &due_date + required: false + selector: + date: + clear_date: + required: false + selector: + constant: + value: true + label: "🗑️" + reminder_options: + collapsed: true + fields: + reminder: &reminder + required: false + selector: + text: + type: datetime-local + multiple: true + remove_reminder: + required: false + selector: + text: + type: datetime-local + multiple: true + clear_reminder: &clear_reminder + required: false + selector: + constant: + value: true + label: "🗑️" + tag_options: *tag_options + developer_options: *developer_options +create_todo: + fields: + config_entry: *config_entry + name: *name + notes: *notes + add_checklist_item: *add_checklist_item + priority: *priority + date: *due_date + reminder: *reminder + tag: *tag + developer_options: *developer_options +update_daily: + fields: + config_entry: *config_entry + task: *task + rename: *rename + notes: *notes + checklist_options: *checklist_options + priority: *priority + start_date: &start_date + required: false + selector: + date: + frequency: &frequency_daily + required: false + selector: + select: + options: + - "daily" + - "weekly" + - "monthly" + - "yearly" + translation_key: "frequency" + mode: dropdown + every_x: &every_x + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "🔃" + mode: box + repeat_weekly_options: &repeat_weekly_options + collapsed: true + fields: + repeat: + required: false + selector: + select: + options: + - "m" + - "t" + - "w" + - "th" + - "f" + - "s" + - "su" + mode: list + translation_key: repeat + multiple: true + repeat_monthly_options: &repeat_monthly_options + collapsed: true + fields: + repeat_monthly: + required: false + selector: + select: + options: + - "day_of_month" + - "day_of_week" + translation_key: repeat_monthly + mode: list + reminder_options: + collapsed: true + fields: + reminder: &reminder_daily + required: false + selector: + text: + type: time + multiple: true + remove_reminder: + required: false + selector: + text: + type: time + multiple: true + clear_reminder: *clear_reminder + tag_options: *tag_options + developer_options: + collapsed: true + fields: + streak: &streak + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "▶▶" + mode: box + alias: *alias +create_daily: + fields: + config_entry: *config_entry + name: *name + notes: *notes + add_checklist_item: *add_checklist_item + priority: *priority + start_date: *start_date + frequency: *frequency_daily + every_x: *every_x + repeat_weekly_options: *repeat_weekly_options + repeat_monthly_options: *repeat_monthly_options + reminder: *reminder_daily + tag: *tag + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 1bb2fcbd9d7..5b03d8662cb 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -11,9 +11,9 @@ "config_entry_description": "Select the Habitica account to update a task.", "task_description": "The name (or task ID) of the task you want to update.", "rename_name": "Rename", - "rename_description": "The new title for the Habitica task.", - "notes_name": "Update notes", - "notes_description": "The new notes for the Habitica task.", + "rename_description": "The title for the Habitica task.", + "notes_name": "Notes", + "notes_description": "The notes for the Habitica task.", "tag_name": "Add tags", "tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.", "remove_tag_name": "Remove tags", @@ -23,7 +23,48 @@ "developer_options_name": "Advanced settings", "developer_options_description": "Additional features available in developer mode.", "tag_options_name": "Tags", - "tag_options_description": "Add or remove tags from a task." + "tag_options_description": "Add or remove tags from a task.", + "name_description": "The title for the Habitica task.", + "cost_name": "Cost", + "priority_name": "Difficulty", + "priority_description": "The difficulty of the task.", + "frequency_name": "Counter reset", + "frequency_description": "The frequency at which the habit's counter resets: daily at the start of a new day, weekly after Sunday night, or monthly at the beginning of a new month.", + "up_down_name": "Rewards or losses", + "up_down_description": "Whether the habit is good and rewarding (positive), bad and penalizing (negative), or both.", + "add_checklist_item_name": "Add checklist items", + "add_checklist_item_description": "The items to add to a task's checklist.", + "remove_checklist_item_name": "Delete items", + "remove_checklist_item_description": "Remove items from a task's checklist.", + "score_checklist_item_name": "Complete items", + "score_checklist_item_description": "Mark items from a task's checklist as completed.", + "unscore_checklist_item_name": "Uncomplete items", + "unscore_checklist_item_description": "Undo completion of items of a task's checklist.", + "checklist_options_name": "Checklist", + "checklist_options_description": "Add, remove, or update status of an item on a task's checklist.", + "reminder_name": "Add reminders", + "reminder_description": "Add reminders to a Habitica task.", + "remove_reminder_name": "Remove reminders", + "remove_reminder_description": "Remove specific reminders from a Habitica task.", + "clear_reminder_name": "Clear all reminders", + "clear_reminder_description": "Remove all reminders from a Habitica task.", + "reminder_options_name": "Reminders", + "reminder_options_description": "Add, remove or clear reminders of a Habitica task.", + "date_name": "Due date", + "date_description": "The to-do's due date.", + "repeat_name": "Repeat on", + "start_date_name": "Start date", + "start_date_description": "Defines when the daily task becomes active and specifies the exact weekday or day of the month it repeats on.", + "frequency_daily_name": "Repeat interval", + "frequency_daily_description": "The repetition interval of a daily.", + "every_x_name": "Repeat every X", + "every_x_description": "The number of intervals (days, weeks, months, or years) after which the daily repeats, based on the chosen repetition interval. A value of 0 makes the daily inactive ('Grey Daily').", + "repeat_weekly_description": "The days of the week the daily repeats.", + "repeat_monthly_description": "Whether a monthly recurring task repeats on the same calendar day each month or on the same weekday and week of the month, based on the start date.", + "repeat_weekly_options_name": "Weekly repeat days", + "repeat_weekly_options_description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly.", + "repeat_monthly_options_name": "Monthly repeat day", + "repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly." }, "config": { "abort": { @@ -485,31 +526,9 @@ "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", - "description": "The Habitica action `habitica.api_call` is deprecated and will be removed in Home Assistant 2025.5.0.\n\nPlease update your automations and scripts to use other Habitica actions and entities." } }, "services": { - "api_call": { - "name": "API name", - "description": "Calls Habitica API.", - "fields": { - "name": { - "name": "[%key:common::config_flow::data::name%]", - "description": "Habitica's username to call for." - }, - "path": { - "name": "[%key:common::config_flow::data::path%]", - "description": "Items from API URL in form of an array with method attached at the end. Consult https://habitica.com/apidoc/. Example uses https://habitica.com/apidoc/#api-Task-CreateUserTasks." - }, - "args": { - "name": "Args", - "description": "Any additional JSON or URL parameter arguments. See apidoc mentioned for path. Example uses same API endpoint." - } - } - }, "cast_skill": { "name": "Cast a skill", "description": "Uses a skill or spell from your Habitica character on a specific task to affect its progress or status.", @@ -651,7 +670,7 @@ "description": "Filter tasks by type." }, "priority": { - "name": "Difficulty", + "name": "[%key:component::habitica::common::priority_name%]", "description": "Filter tasks by difficulty." }, "task": { @@ -707,7 +726,7 @@ "description": "[%key:component::habitica::common::alias_description%]" }, "cost": { - "name": "Cost", + "name": "[%key:component::habitica::common::cost_name%]", "description": "Update the cost of a reward." } }, @@ -721,6 +740,482 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "create_reward": { + "name": "Create reward", + "description": "Adds a new custom reward.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to create a reward." + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "cost": { + "name": "[%key:component::habitica::common::cost_name%]", + "description": "The cost of the reward." + } + }, + "sections": { + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } + }, + "update_habit": { + "name": "Update a habit", + "description": "Updates a specific habit for the selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to update a habit." + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::task_description%]" + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "remove_tag": { + "name": "[%key:component::habitica::common::remove_tag_name%]", + "description": "[%key:component::habitica::common::remove_tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "frequency": { + "name": "[%key:component::habitica::common::frequency_name%]", + "description": "[%key:component::habitica::common::frequency_description%]" + }, + "up_down": { + "name": "[%key:component::habitica::common::up_down_name%]", + "description": "[%key:component::habitica::common::up_down_description%]" + }, + "counter_up": { + "name": "Adjust positive counter", + "description": "Update the up counter of a positive habit." + }, + "counter_down": { + "name": "Adjust negative counter", + "description": "Update the down counter of a negative habit." + } + }, + "sections": { + "tag_options": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } + }, + "create_habit": { + "name": "Create habit", + "description": "Adds a new habit.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to create a habit." + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "frequency": { + "name": "[%key:component::habitica::common::frequency_name%]", + "description": "[%key:component::habitica::common::frequency_description%]" + }, + "up_down": { + "name": "[%key:component::habitica::common::up_down_name%]", + "description": "[%key:component::habitica::common::up_down_description%]" + } + }, + "sections": { + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } + }, + "update_todo": { + "name": "Update a to-do", + "description": "Updates a specific to-do for a selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::common::config_entry_description%]" + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "The name (or task ID) of the to-do you want to update." + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "remove_tag": { + "name": "[%key:component::habitica::common::remove_tag_name%]", + "description": "[%key:component::habitica::common::remove_tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "date": { + "name": "[%key:component::habitica::common::date_name%]", + "description": "[%key:component::habitica::common::date_description%]" + }, + "clear_date": { + "name": "Clear due date", + "description": "Remove the due date from the to-do." + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + }, + "remove_reminder": { + "name": "[%key:component::habitica::common::remove_reminder_name%]", + "description": "[%key:component::habitica::common::remove_reminder_description%]" + }, + "clear_reminder": { + "name": "[%key:component::habitica::common::clear_reminder_name%]", + "description": "[%key:component::habitica::common::clear_reminder_description%]" + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::add_checklist_item_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + }, + "remove_checklist_item": { + "name": "[%key:component::habitica::common::remove_checklist_item_name%]", + "description": "[%key:component::habitica::common::remove_checklist_item_description%]" + }, + "score_checklist_item": { + "name": "[%key:component::habitica::common::score_checklist_item_name%]", + "description": "[%key:component::habitica::common::score_checklist_item_description%]" + }, + "unscore_checklist_item": { + "name": "[%key:component::habitica::common::unscore_checklist_item_name%]", + "description": "[%key:component::habitica::common::unscore_checklist_item_description%]" + } + }, + "sections": { + "checklist_options": { + "name": "[%key:component::habitica::common::checklist_options_name%]", + "description": "[%key:component::habitica::common::checklist_options_description%]" + }, + "duedate_options": { + "name": "[%key:component::habitica::common::date_name%]", + "description": "Set, update or remove due dates of a to-do." + }, + "reminder_options": { + "name": "[%key:component::habitica::common::reminder_options_name%]", + "description": "[%key:component::habitica::common::reminder_options_description%]" + }, + "tag_options": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } + }, + "create_todo": { + "name": "Create to-do", + "description": "Adds a new to-do.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to create a to-do." + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "date": { + "name": "[%key:component::habitica::common::date_name%]", + "description": "[%key:component::habitica::common::date_description%]" + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_options_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::checklist_options_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + } + }, + "sections": { + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } + }, + "update_daily": { + "name": "Update a daily", + "description": "Updates a specific daily for a selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::common::config_entry_description%]" + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "The name (or task ID) of the daily you want to update." + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "remove_tag": { + "name": "[%key:component::habitica::common::remove_tag_name%]", + "description": "[%key:component::habitica::common::remove_tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "start_date": { + "name": "[%key:component::habitica::common::start_date_name%]", + "description": "[%key:component::habitica::common::start_date_description%]" + }, + "frequency": { + "name": "[%key:component::habitica::common::frequency_daily_name%]", + "description": "[%key:component::habitica::common::frequency_daily_description%]" + }, + "every_x": { + "name": "[%key:component::habitica::common::every_x_name%]", + "description": "[%key:component::habitica::common::every_x_description%]" + }, + "repeat": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "[%key:component::habitica::common::repeat_weekly_description%]" + }, + "repeat_monthly": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "[%key:component::habitica::common::repeat_monthly_description%]" + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::add_checklist_item_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + }, + "remove_checklist_item": { + "name": "[%key:component::habitica::common::remove_checklist_item_name%]", + "description": "[%key:component::habitica::common::remove_checklist_item_description%]" + }, + "score_checklist_item": { + "name": "[%key:component::habitica::common::score_checklist_item_name%]", + "description": "[%key:component::habitica::common::score_checklist_item_description%]" + }, + "unscore_checklist_item": { + "name": "[%key:component::habitica::common::unscore_checklist_item_name%]", + "description": "[%key:component::habitica::common::unscore_checklist_item_description%]" + }, + "streak": { + "name": "Adjust streak", + "description": "Adjust or reset the streak counter of the daily." + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + }, + "remove_reminder": { + "name": "[%key:component::habitica::common::remove_reminder_name%]", + "description": "[%key:component::habitica::common::remove_reminder_description%]" + }, + "clear_reminder": { + "name": "[%key:component::habitica::common::clear_reminder_name%]", + "description": "[%key:component::habitica::common::clear_reminder_description%]" + } + }, + "sections": { + "checklist_options": { + "name": "[%key:component::habitica::common::checklist_options_name%]", + "description": "[%key:component::habitica::common::checklist_options_description%]" + }, + "repeat_weekly_options": { + "name": "[%key:component::habitica::common::repeat_weekly_options_name%]", + "description": "[%key:component::habitica::common::repeat_weekly_options_description%]" + }, + "repeat_monthly_options": { + "name": "[%key:component::habitica::common::repeat_monthly_options_name%]", + "description": "[%key:component::habitica::common::repeat_monthly_options_description%]" + }, + "tag_options": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + }, + "reminder_options": { + "name": "[%key:component::habitica::common::reminder_options_name%]", + "description": "[%key:component::habitica::common::reminder_options_description%]" + } + } + }, + "create_daily": { + "name": "Create a daily", + "description": "Adds a new daily.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::common::config_entry_description%]" + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "start_date": { + "name": "[%key:component::habitica::common::start_date_name%]", + "description": "[%key:component::habitica::common::start_date_description%]" + }, + "frequency": { + "name": "[%key:component::habitica::common::frequency_daily_name%]", + "description": "[%key:component::habitica::common::frequency_daily_description%]" + }, + "every_x": { + "name": "[%key:component::habitica::common::every_x_name%]", + "description": "[%key:component::habitica::common::every_x_description%]" + }, + "repeat": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "[%key:component::habitica::common::repeat_weekly_description%]" + }, + "repeat_monthly": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "[%key:component::habitica::common::repeat_monthly_description%]" + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::checklist_options_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_options_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + } + }, + "sections": { + "repeat_weekly_options": { + "name": "[%key:component::habitica::common::repeat_weekly_options_name%]", + "description": "[%key:component::habitica::common::repeat_weekly_options_description%]" + }, + "repeat_monthly_options": { + "name": "[%key:component::habitica::common::repeat_monthly_options_name%]", + "description": "[%key:component::habitica::common::repeat_monthly_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { @@ -755,6 +1250,31 @@ "medium": "Medium", "hard": "Hard" } + }, + "frequency": { + "options": { + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "yearly": "Yearly" + } + }, + "repeat": { + "options": { + "m": "[%key:common::time::monday%]", + "t": "[%key:common::time::tuesday%]", + "w": "[%key:common::time::wednesday%]", + "th": "[%key:common::time::thursday%]", + "f": "[%key:common::time::friday%]", + "s": "[%key:common::time::saturday%]", + "su": "[%key:common::time::sunday%]" + } + }, + "repeat_monthly": { + "options": { + "day_of_month": "Day of the month", + "day_of_week": "Day of the week" + } } } } diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 757c675b045..1ca908eb3ff 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -74,7 +74,7 @@ def build_rrule(task: TaskData) -> rrule: bysetpos = None if rrule_frequency == MONTHLY and task.weeksOfMonth: - bysetpos = task.weeksOfMonth + bysetpos = [i + 1 for i in task.weeksOfMonth] weekdays = weekdays if weekdays else [MO] return rrule( diff --git a/homeassistant/components/hardware/__init__.py b/homeassistant/components/hardware/__init__.py index 9de281b1e50..5db9671a4ed 100644 --- a/homeassistant/components/hardware/__init__.py +++ b/homeassistant/components/hardware/__init__.py @@ -2,19 +2,31 @@ from __future__ import annotations +import psutil_home_assistant as ha_psutil + from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import websocket_api -from .const import DOMAIN +from .const import DATA_HARDWARE, DOMAIN +from .hardware import async_process_hardware_platforms +from .models import HardwareData, SystemStatus CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Hardware.""" - hass.data[DOMAIN] = {} + hass.data[DATA_HARDWARE] = HardwareData( + hardware_platform={}, + system_status=SystemStatus( + ha_psutil=await hass.async_add_executor_job(ha_psutil.PsutilWrapper), + remove_periodic_timer=None, + subscribers=set(), + ), + ) + await async_process_hardware_platforms(hass) await websocket_api.async_setup(hass) diff --git a/homeassistant/components/hardware/const.py b/homeassistant/components/hardware/const.py index 7fd64d5d968..2bde218c19d 100644 --- a/homeassistant/components/hardware/const.py +++ b/homeassistant/components/hardware/const.py @@ -1,3 +1,14 @@ """Constants for the Hardware integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .models import HardwareData + DOMAIN = "hardware" + +DATA_HARDWARE: HassKey[HardwareData] = HassKey(DOMAIN) diff --git a/homeassistant/components/hardware/hardware.py b/homeassistant/components/hardware/hardware.py index f2de9182b57..9fd257a14a7 100644 --- a/homeassistant/components/hardware/hardware.py +++ b/homeassistant/components/hardware/hardware.py @@ -8,14 +8,14 @@ from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) -from .const import DOMAIN +from .const import DATA_HARDWARE, DOMAIN from .models import HardwareProtocol -async def async_process_hardware_platforms(hass: HomeAssistant) -> None: +async def async_process_hardware_platforms( + hass: HomeAssistant, +) -> None: """Start processing hardware platforms.""" - hass.data[DOMAIN]["hardware_platform"] = {} - await async_process_integration_platforms( hass, DOMAIN, _register_hardware_platform, wait_for_platforms=True ) @@ -30,4 +30,4 @@ def _register_hardware_platform( return if not hasattr(platform, "async_info"): raise HomeAssistantError(f"Invalid hardware platform {platform}") - hass.data[DOMAIN]["hardware_platform"][integration_domain] = platform + hass.data[DATA_HARDWARE].hardware_platform[integration_domain] = platform diff --git a/homeassistant/components/hardware/models.py b/homeassistant/components/hardware/models.py index 6f25d6669cf..a972b567db2 100644 --- a/homeassistant/components/hardware/models.py +++ b/homeassistant/components/hardware/models.py @@ -5,7 +5,27 @@ from __future__ import annotations from dataclasses import dataclass from typing import Protocol -from homeassistant.core import HomeAssistant, callback +import psutil_home_assistant as ha_psutil + +from homeassistant.components import websocket_api +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback + + +@dataclass +class HardwareData: + """Hardware data.""" + + hardware_platform: dict[str, HardwareProtocol] + system_status: SystemStatus + + +@dataclass(slots=True) +class SystemStatus: + """System status.""" + + ha_psutil: ha_psutil + remove_periodic_timer: CALLBACK_TYPE | None + subscribers: set[tuple[websocket_api.ActiveConnection, int]] @dataclass(slots=True) diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py index 7224c0f8f7e..599eab34135 100644 --- a/homeassistant/components/hardware/websocket_api.py +++ b/homeassistant/components/hardware/websocket_api.py @@ -3,42 +3,25 @@ from __future__ import annotations import contextlib -from dataclasses import asdict, dataclass +from dataclasses import asdict from datetime import datetime, timedelta from typing import Any -import psutil_home_assistant as ha_psutil import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import dt as dt_util -from .const import DOMAIN -from .hardware import async_process_hardware_platforms -from .models import HardwareProtocol - - -@dataclass(slots=True) -class SystemStatus: - """System status.""" - - ha_psutil: ha_psutil - remove_periodic_timer: CALLBACK_TYPE | None - subscribers: set[tuple[websocket_api.ActiveConnection, int]] +from .const import DATA_HARDWARE async def async_setup(hass: HomeAssistant) -> None: """Set up the hardware websocket API.""" websocket_api.async_register_command(hass, ws_info) websocket_api.async_register_command(hass, ws_subscribe_system_status) - hass.data[DOMAIN]["system_status"] = SystemStatus( - ha_psutil=await hass.async_add_executor_job(ha_psutil.PsutilWrapper), - remove_periodic_timer=None, - subscribers=set(), - ) @websocket_api.websocket_command( @@ -53,12 +36,7 @@ async def ws_info( """Return hardware info.""" hardware_info = [] - if "hardware_platform" not in hass.data[DOMAIN]: - await async_process_hardware_platforms(hass) - - hardware_platform: dict[str, HardwareProtocol] = hass.data[DOMAIN][ - "hardware_platform" - ] + hardware_platform = hass.data[DATA_HARDWARE].hardware_platform for platform in hardware_platform.values(): if hasattr(platform, "async_info"): with contextlib.suppress(HomeAssistantError): @@ -78,7 +56,7 @@ def ws_subscribe_system_status( ) -> None: """Subscribe to system status updates.""" - system_status: SystemStatus = hass.data[DOMAIN]["system_status"] + system_status = hass.data[DATA_HARDWARE].system_status @callback def async_update_status(now: datetime) -> None: diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index aab4f51b09a..f67eb4db5aa 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.4.1"], + "requirements": ["aioharmony==0.5.2"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index d71b2b85f7b..eeeedff00bb 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -51,11 +51,9 @@ from homeassistant.helpers.hassio import ( get_supervisor_ip as _get_supervisor_ip, is_hassio as _is_hassio, ) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service_info.hassio import ( HassioServiceInfo as _HassioServiceInfo, ) -from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.async_ import create_eager_task @@ -78,6 +76,7 @@ from . import ( # noqa: F401 from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401 from .addon_panel import async_setup_addon_panel from .auth import async_setup_auth_view +from .config import HassioConfig from .const import ( ADDONS_COORDINATOR, ATTR_ADDON, @@ -91,6 +90,7 @@ from .const import ( ATTR_PASSWORD, ATTR_SLUG, DATA_COMPONENT, + DATA_CONFIG_STORE, DATA_CORE_INFO, DATA_HOST_INFO, DATA_INFO, @@ -104,7 +104,6 @@ from .const import ( ) from .coordinator import ( HassioDataUpdateCoordinator, - get_addons_changelogs, # noqa: F401 get_addons_info, get_addons_stats, # noqa: F401 get_core_info, # noqa: F401 @@ -144,8 +143,6 @@ _DEPRECATED_HassioServiceInfo = DeprecatedConstant( "2025.11", ) -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 # If new platforms are added, be sure to import them above # so we do not make other components that depend on hassio # wait for the import of the platforms @@ -161,7 +158,6 @@ CONFIG_SCHEMA = vol.Schema( SERVICE_ADDON_START = "addon_start" SERVICE_ADDON_STOP = "addon_stop" SERVICE_ADDON_RESTART = "addon_restart" -SERVICE_ADDON_UPDATE = "addon_update" SERVICE_ADDON_STDIN = "addon_stdin" SERVICE_HOST_SHUTDOWN = "host_shutdown" SERVICE_HOST_REBOOT = "host_reboot" @@ -242,7 +238,6 @@ MAP_SERVICE_API = { SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON), SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON), SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON), - SERVICE_ADDON_UPDATE: APIEndpointSettings("/addons/{addon}/update", SCHEMA_ADDON), SERVICE_ADDON_STDIN: APIEndpointSettings( "/addons/{addon}/stdin", SCHEMA_ADDON_STDIN ), @@ -335,13 +330,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: except SupervisorError: _LOGGER.warning("Not connected with the supervisor / system too busy!") - store = Store[dict[str, str]](hass, STORAGE_VERSION, STORAGE_KEY) - if (data := await store.async_load()) is None: - data = {} + # Load the store + config_store = HassioConfig(hass) + await config_store.load() + hass.data[DATA_CONFIG_STORE] = config_store refresh_token = None - if "hassio_user" in data: - user = await hass.auth.async_get_user(data["hassio_user"]) + if (hassio_user := config_store.data.hassio_user) is not None: + user = await hass.auth.async_get_user(hassio_user) if user and user.refresh_tokens: refresh_token = list(user.refresh_tokens.values())[0] @@ -358,8 +354,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] ) refresh_token = await hass.auth.async_create_refresh_token(user) - data["hassio_user"] = user.id - await store.async_save(data) + config_store.update(hassio_user=user.id) # This overrides the normal API call that would be forwarded development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO) @@ -390,18 +385,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) last_timezone = None + last_country = None async def push_config(_: Event | None) -> None: """Push core config to Hass.io.""" nonlocal last_timezone + nonlocal last_country new_timezone = str(hass.config.time_zone) + new_country = str(hass.config.country) - if new_timezone == last_timezone: - return - - last_timezone = new_timezone - await hassio.update_hass_timezone(new_timezone) + if new_timezone != last_timezone or new_country != last_country: + last_timezone = new_timezone + last_country = new_country + await hassio.update_hass_config(new_timezone, new_country) hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config) @@ -412,16 +409,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_service_handler(service: ServiceCall) -> None: """Handle service calls for Hass.io.""" - if service.service == SERVICE_ADDON_UPDATE: - async_create_issue( - hass, - DOMAIN, - "update_service_deprecated", - breaks_in_ha_version="2025.5", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="update_service_deprecated", - ) api_endpoint = MAP_SERVICE_API[service.service] data = service.data.copy() diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index fe69b9e08e5..38bf3c82561 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping +from contextlib import suppress import logging import os from pathlib import Path, PurePath @@ -56,7 +57,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from .const import DOMAIN, EVENT_SUPERVISOR_EVENT +from .const import DATA_CONFIG_STORE, DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") @@ -173,7 +174,7 @@ class SupervisorBackupAgent(BackupAgent): ), ) except SupervisorNotFoundError as err: - raise BackupNotFound from err + raise BackupNotFound(f"Backup {backup_id} not found") from err async def async_upload_backup( self, @@ -186,13 +187,14 @@ class SupervisorBackupAgent(BackupAgent): The upload will be skipped if the backup already exists in the agent's location. """ - if await self.async_get_backup(backup.backup_id): - _LOGGER.debug( - "Backup %s already exists in location %s", - backup.backup_id, - self.location, - ) - return + with suppress(BackupNotFound): + if await self.async_get_backup(backup.backup_id): + _LOGGER.debug( + "Backup %s already exists in location %s", + backup.backup_id, + self.location, + ) + return stream = await open_stream() upload_options = supervisor_backups.UploadBackupOptions( location={self.location}, @@ -218,14 +220,14 @@ class SupervisorBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" try: details = await self._client.backups.backup_info(backup_id) - except SupervisorNotFoundError: - return None + except SupervisorNotFoundError as err: + raise BackupNotFound(f"Backup {backup_id} not found") from err if self.location not in details.location_attributes: - return None + raise BackupNotFound(f"Backup {backup_id} not found") return _backup_details_to_agent_backup(details, self.location) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: @@ -237,8 +239,8 @@ class SupervisorBackupAgent(BackupAgent): location={self.location} ), ) - except SupervisorNotFoundError: - _LOGGER.debug("Backup %s does not exist", backup_id) + except SupervisorNotFoundError as err: + raise BackupNotFound(f"Backup {backup_id} not found") from err class SupervisorBackupReaderWriter(BackupReaderWriter): @@ -492,10 +494,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) -> 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. + # The backup manager has already checked that the backup exists so we don't + # need to catch BackupNotFound here. backup = await manager.backup_agents[agent_id].async_get_backup(backup_id) if ( + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 backup and restore_homeassistant and restore_database != backup.database_included @@ -725,6 +729,18 @@ async def backup_addon_before_update( if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon } + def _delete_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return oldest backups more numerous than copies to delete.""" + update_config = hass.data[DATA_CONFIG_STORE].data.update_config + return dict( + sorted( + backups.items(), + key=lambda backup_item: backup_item[1].date, + )[: max(len(backups) - update_config.add_on_backup_retain_copies, 0)] + ) + try: await backup_manager.async_create_backup( agent_ids=[await _default_agent(client)], @@ -743,7 +759,7 @@ async def backup_addon_before_update( try: await backup_manager.async_delete_filtered_backups( include_filter=addon_update_backup_filter, - delete_filter=lambda backups: backups, + delete_filter=_delete_filter, ) except BackupManagerError as err: raise HomeAssistantError(f"Error deleting old backups: {err}") from err diff --git a/homeassistant/components/hassio/config.py b/homeassistant/components/hassio/config.py new file mode 100644 index 00000000000..f277249ee94 --- /dev/null +++ b/homeassistant/components/hassio/config.py @@ -0,0 +1,148 @@ +"""Provide persistent configuration for the hassio integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Required, Self, TypedDict + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import UNDEFINED, UndefinedType + +from .const import DOMAIN + +STORE_DELAY_SAVE = 30 +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 1 + + +class HassioConfig: + """Handle update config.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize update config.""" + self.data = HassioConfigData( + hassio_user=None, + update_config=HassioUpdateConfig(), + ) + self._hass = hass + self._store = HassioConfigStore(hass, self) + + async def load(self) -> None: + """Load config.""" + if not (store_data := await self._store.load()): + return + self.data = HassioConfigData.from_dict(store_data) + + @callback + def update( + self, + *, + hassio_user: str | UndefinedType = UNDEFINED, + update_config: HassioUpdateParametersDict | UndefinedType = UNDEFINED, + ) -> None: + """Update config.""" + if hassio_user is not UNDEFINED: + self.data.hassio_user = hassio_user + if update_config is not UNDEFINED: + self.data.update_config = replace(self.data.update_config, **update_config) + + self._store.save() + + +@dataclass(kw_only=True) +class HassioConfigData: + """Represent loaded update config data.""" + + hassio_user: str | None + update_config: HassioUpdateConfig + + @classmethod + def from_dict(cls, data: StoredHassioConfig) -> Self: + """Initialize update config data from a dict.""" + if update_data := data.get("update_config"): + update_config = HassioUpdateConfig( + add_on_backup_before_update=update_data["add_on_backup_before_update"], + add_on_backup_retain_copies=update_data["add_on_backup_retain_copies"], + core_backup_before_update=update_data["core_backup_before_update"], + ) + else: + update_config = HassioUpdateConfig() + return cls( + hassio_user=data["hassio_user"], + update_config=update_config, + ) + + def to_dict(self) -> StoredHassioConfig: + """Convert update config data to a dict.""" + return StoredHassioConfig( + hassio_user=self.hassio_user, + update_config=self.update_config.to_dict(), + ) + + +@dataclass(kw_only=True) +class HassioUpdateConfig: + """Represent the backup retention configuration.""" + + add_on_backup_before_update: bool = False + add_on_backup_retain_copies: int = 1 + core_backup_before_update: bool = False + + def to_dict(self) -> StoredHassioUpdateConfig: + """Convert backup retention configuration to a dict.""" + return StoredHassioUpdateConfig( + add_on_backup_before_update=self.add_on_backup_before_update, + add_on_backup_retain_copies=self.add_on_backup_retain_copies, + core_backup_before_update=self.core_backup_before_update, + ) + + +class HassioUpdateParametersDict(TypedDict, total=False): + """Represent the parameters for update.""" + + add_on_backup_before_update: bool + add_on_backup_retain_copies: int + core_backup_before_update: bool + + +class HassioConfigStore: + """Store hassio config.""" + + def __init__(self, hass: HomeAssistant, config: HassioConfig) -> None: + """Initialize the hassio config store.""" + self._hass = hass + self._config = config + self._store: Store[StoredHassioConfig] = Store( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) + + async def load(self) -> StoredHassioConfig | 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) -> StoredHassioConfig: + """Return data to save.""" + return self._config.data.to_dict() + + +class StoredHassioConfig(TypedDict, total=False): + """Represent the stored hassio config.""" + + hassio_user: Required[str | None] + update_config: StoredHassioUpdateConfig + + +class StoredHassioUpdateConfig(TypedDict): + """Represent the stored update config.""" + + add_on_backup_before_update: bool + add_on_backup_retain_copies: int + core_backup_before_update: bool diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index d1cda51ec7b..563b271c578 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: + from .config import HassioConfig from .handler import HassIO @@ -74,6 +75,7 @@ ADDONS_COORDINATOR = "hassio_addons_coordinator" DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN) +DATA_CONFIG_STORE: HassKey[HassioConfig] = HassKey("hassio_config_store") DATA_CORE_INFO = "hassio_core_info" DATA_CORE_STATS = "hassio_core_stats" DATA_HOST_INFO = "hassio_host_info" @@ -83,7 +85,6 @@ DATA_OS_INFO = "hassio_os_info" DATA_NETWORK_INFO = "hassio_network_info" DATA_SUPERVISOR_INFO = "hassio_supervisor_info" DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" -DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) @@ -92,7 +93,6 @@ ATTR_AUTO_UPDATE = "auto_update" ATTR_VERSION = "version" ATTR_VERSION_LATEST = "version_latest" ATTR_CPU_PERCENT = "cpu_percent" -ATTR_CHANGELOG = "changelog" ATTR_LOCATION = "location" ATTR_MEMORY_PERCENT = "memory_percent" ATTR_SLUG = "slug" @@ -122,14 +122,13 @@ CORE_CONTAINER = "homeassistant" SUPERVISOR_CONTAINER = "hassio_supervisor" CONTAINER_STATS = "stats" -CONTAINER_CHANGELOG = "changelog" CONTAINER_INFO = "info" # This is a mapping of which endpoint the key in the addon data # is obtained from so we know which endpoint to update when the # coordinator polls for updates. KEY_TO_UPDATE_TYPES: dict[str, set[str]] = { - ATTR_VERSION_LATEST: {CONTAINER_INFO, CONTAINER_CHANGELOG}, + ATTR_VERSION_LATEST: {CONTAINER_INFO}, ATTR_MEMORY_PERCENT: {CONTAINER_STATS}, ATTR_CPU_PERCENT: {CONTAINER_STATS}, ATTR_VERSION: {CONTAINER_INFO}, diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 833068a713c..1e529593f09 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -7,7 +7,7 @@ from collections import defaultdict import logging from typing import TYPE_CHECKING, Any -from aiohasupervisor import SupervisorError +from aiohasupervisor import SupervisorError, SupervisorNotFoundError from aiohasupervisor.models import StoreInfo from homeassistant.config_entries import ConfigEntry @@ -21,18 +21,15 @@ from homeassistant.loader import bind_hass from .const import ( ATTR_AUTO_UPDATE, - ATTR_CHANGELOG, ATTR_REPOSITORY, ATTR_SLUG, ATTR_STARTED, ATTR_STATE, ATTR_URL, ATTR_VERSION, - CONTAINER_CHANGELOG, CONTAINER_INFO, CONTAINER_STATS, CORE_CONTAINER, - DATA_ADDONS_CHANGELOGS, DATA_ADDONS_INFO, DATA_ADDONS_STATS, DATA_COMPONENT, @@ -155,16 +152,6 @@ def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]: return hass.data.get(DATA_SUPERVISOR_STATS) or {} -@callback -@bind_hass -def get_addons_changelogs(hass: HomeAssistant): - """Return Addons changelogs. - - Async friendly. - """ - return hass.data.get(DATA_ADDONS_CHANGELOGS) - - @callback @bind_hass def get_os_info(hass: HomeAssistant) -> dict[str, Any] | None: @@ -337,7 +324,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): supervisor_info = get_supervisor_info(self.hass) or {} addons_info = get_addons_info(self.hass) or {} addons_stats = get_addons_stats(self.hass) - addons_changelogs = get_addons_changelogs(self.hass) store_data = get_store(self.hass) if store_data: @@ -355,7 +341,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): ATTR_AUTO_UPDATE: (addons_info.get(addon[ATTR_SLUG]) or {}).get( ATTR_AUTO_UPDATE, False ), - ATTR_CHANGELOG: (addons_changelogs or {}).get(addon[ATTR_SLUG]), ATTR_REPOSITORY: repositories.get( addon.get(ATTR_REPOSITORY), addon.get(ATTR_REPOSITORY, "") ), @@ -422,10 +407,12 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): return new_data - async def force_info_update_supervisor(self) -> None: - """Force update of the supervisor info.""" - self.hass.data[DATA_SUPERVISOR_INFO] = await self.hassio.get_supervisor_info() - await self.async_refresh() + async def get_changelog(self, addon_slug: str) -> str | None: + """Get the changelog for an add-on.""" + try: + return await self.supervisor_client.store.addon_changelog(addon_slug) + except SupervisorNotFoundError: + return None async def force_data_refresh(self, first_update: bool) -> None: """Force update of the addon info.""" @@ -475,13 +462,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): started_addons, False, ), - ( - DATA_ADDONS_CHANGELOGS, - self._update_addon_changelog, - CONTAINER_CHANGELOG, - all_addons, - True, - ), ( DATA_ADDONS_INFO, self._update_addon_info, @@ -513,15 +493,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): return (slug, None) return (slug, stats.to_dict()) - async def _update_addon_changelog(self, slug: str) -> tuple[str, str | None]: - """Return the changelog for an add-on.""" - try: - changelog = await self.supervisor_client.store.addon_changelog(slug) - except SupervisorError as err: - _LOGGER.warning("Could not fetch changelog for %s: %s", slug, err) - return (slug, None) - return (slug, changelog) - async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]: """Return the info for an add-on.""" try: diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 752f535ca04..7aec0aa7a61 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -248,12 +248,14 @@ class HassIO: return await self.send_command("/homeassistant/options", payload=options) @_api_bool - def update_hass_timezone(self, timezone: str) -> Coroutine: + def update_hass_config(self, timezone: str, country: str | None) -> Coroutine: """Update Home-Assistant timezone data on Hass.io. This method returns a coroutine. """ - return self.send_command("/supervisor/options", payload={"timezone": timezone}) + return self.send_command( + "/supervisor/options", payload={"timezone": timezone, "country": country} + ) @_api_bool def update_diagnostics(self, diagnostics: bool) -> Coroutine: diff --git a/homeassistant/components/hassio/icons.json b/homeassistant/components/hassio/icons.json index 64f032d9f80..33eb154edc4 100644 --- a/homeassistant/components/hassio/icons.json +++ b/homeassistant/components/hassio/icons.json @@ -22,9 +22,6 @@ "addon_stop": { "service": "mdi:stop" }, - "addon_update": { - "service": "mdi:update" - }, "host_reboot": { "service": "mdi:restart" }, diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 3a3eb0e945c..e673c3a70e9 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -46,6 +46,8 @@ RESPONSE_HEADERS_FILTER = { MIN_COMPRESSED_SIZE = 128 MAX_SIMPLE_RESPONSE_SIZE = 4194000 +DISABLED_TIMEOUT = ClientTimeout(total=None) + @callback def async_setup_ingress_view(hass: HomeAssistant, host: str) -> None: @@ -107,6 +109,7 @@ class HassIOIngress(HomeAssistantView): delete = _handle patch = _handle options = _handle + head = _handle async def _handle_websocket( self, request: web.Request, token: str, path: str @@ -167,7 +170,7 @@ class HassIOIngress(HomeAssistantView): params=request.query, allow_redirects=False, data=request.content if request.method != "GET" else None, - timeout=ClientTimeout(total=None), + timeout=DISABLED_TIMEOUT, skip_auto_headers={hdrs.CONTENT_TYPE}, ) as result: headers = _response_header(result) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index ad98beb5baa..a2af6fb217c 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.3.0"], + "requirements": ["aiohasupervisor==0.3.1"], "single_config_entry": true } diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 30086e4dd2b..43143fe6889 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -30,14 +30,6 @@ addon_stop: selector: addon: -addon_update: - fields: - addon: - required: true - example: core_ssh - selector: - addon: - host_reboot: host_shutdown: backup_full: diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 799067b8215..e34aa020c5a 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -24,8 +24,8 @@ "fix_menu": { "description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.", "menu_options": { - "addon_execute_start": "Start", - "addon_disable_boot": "Disable" + "addon_execute_start": "[%key:common::action::start%]", + "addon_disable_boot": "[%key:common::action::disable%]" } } }, @@ -152,7 +152,7 @@ }, "unsupported_connectivity_check": { "title": "Unsupported system - Connectivity check disabled", - "description": "System is unsupported because Home Assistant cannot determine when an internet connection is available. Use the link to learn more and how to fix this." + "description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. Use the link to learn more and how to fix this." }, "unsupported_content_trust": { "title": "Unsupported system - Content-trust check disabled", @@ -216,7 +216,7 @@ }, "unsupported_systemd_journal": { "title": "Unsupported system - Systemd Journal issues", - "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured . Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. Use the link to learn more and how to fix this." }, "unsupported_systemd_resolved": { "title": "Unsupported system - Systemd-Resolved issues", @@ -225,10 +225,6 @@ "unsupported_virtualization_image": { "title": "Unsupported system - Incorrect OS image for virtualization", "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this." - }, - "update_service_deprecated": { - "title": "Deprecated update add-on action", - "description": "The update add-on action has been deprecated and will be removed in 2025.5. Please use the update entity and the respective action to update the add-on instead." } }, "entity": { @@ -265,6 +261,11 @@ "version_latest": { "name": "Newest version" } + }, + "update": { + "update": { + "name": "[%key:component::update::title%]" + } } }, "services": { @@ -308,16 +309,6 @@ } } }, - "addon_update": { - "name": "Update add-on", - "description": "Updates an add-on. This action should be used with caution since add-on updates can contain breaking changes. It is highly recommended that you review release notes/change logs before updating an add-on.", - "fields": { - "addon": { - "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", - "description": "The add-on to update." - } - } - }, "host_reboot": { "name": "Reboot the host system", "description": "Reboots the host system." @@ -348,7 +339,7 @@ }, "homeassistant_exclude_database": { "name": "Home Assistant exclude database", - "description": "Exclude the Home Assistant database file from backup" + "description": "Exclude the Home Assistant database file from the backup." } } }, @@ -385,8 +376,8 @@ "description": "[%key:component::hassio::services::backup_full::fields::location::description%]" }, "homeassistant_exclude_database": { - "name": "Home Assistant exclude database", - "description": "Exclude the Home Assistant database file from backup" + "name": "[%key:component::hassio::services::backup_full::fields::homeassistant_exclude_database::name%]", + "description": "[%key:component::hassio::services::backup_full::fields::homeassistant_exclude_database::description%]" } } }, diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 4ea703e87c3..2515ee04ab3 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -2,10 +2,10 @@ from __future__ import annotations +import re from typing import Any from aiohasupervisor import SupervisorError -from aiohasupervisor.models import OSUpdate from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.update import ( @@ -22,7 +22,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ADDONS_COORDINATOR, ATTR_AUTO_UPDATE, - ATTR_CHANGELOG, ATTR_VERSION, ATTR_VERSION_LATEST, DATA_KEY_ADDONS, @@ -36,10 +35,10 @@ from .entity import ( HassioOSEntity, HassioSupervisorEntity, ) -from .update_helper import update_addon, update_core +from .update_helper import update_addon, update_core, update_os ENTITY_DESCRIPTION = UpdateEntityDescription( - name="Update", + translation_key="update", key=ATTR_VERSION_LATEST, ) @@ -117,11 +116,6 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): """Version installed and in use.""" return self._addon_data[ATTR_VERSION] - @property - def release_summary(self) -> str | None: - """Release summary for the add-on.""" - return self._strip_release_notes() - @property def entity_picture(self) -> str | None: """Return the icon of the add-on if any.""" @@ -131,27 +125,22 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): return f"/api/hassio/addons/{self._addon_slug}/icon" return None - def _strip_release_notes(self) -> str | None: - """Strip the release notes to contain the needed sections.""" - if (notes := self._addon_data[ATTR_CHANGELOG]) is None: - return None - - if ( - f"# {self.latest_version}" in notes - and f"# {self.installed_version}" in notes - ): - # Split the release notes to only what is between the versions if we can - new_notes = notes.split(f"# {self.installed_version}")[0] - if f"# {self.latest_version}" in new_notes: - # Make sure the latest version is still there. - # This can be False if the order of the release notes are not correct - # In that case we just return the whole release notes - return new_notes - return notes - async def async_release_notes(self) -> str | None: """Return the release notes for the update.""" - return self._strip_release_notes() + if ( + changelog := await self.coordinator.get_changelog(self._addon_slug) + ) is None: + return None + + if self.latest_version is None or self.installed_version is None: + return changelog + + regex_pattern = re.compile( + rf"^#* {re.escape(self.latest_version)}\n(?:^(?!#* {re.escape(self.installed_version)}).*\n)*", + re.MULTILINE, + ) + match = regex_pattern.search(changelog) + return match.group(0) if match else changelog async def async_install( self, @@ -163,14 +152,16 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): await update_addon( self.hass, self._addon_slug, backup, self.title, self.installed_version ) - await self.coordinator.force_info_update_supervisor() + await self.coordinator.async_refresh() class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): """Update entity to handle updates for the Home Assistant Operating System.""" _attr_supported_features = ( - UpdateEntityFeature.INSTALL | UpdateEntityFeature.SPECIFIC_VERSION + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.SPECIFIC_VERSION + | UpdateEntityFeature.BACKUP ) _attr_title = "Home Assistant Operating System" @@ -203,14 +194,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - try: - await self.coordinator.supervisor_client.os.update( - OSUpdate(version=version) - ) - except SupervisorError as err: - raise HomeAssistantError( - f"Error updating Home Assistant Operating System: {err}" - ) from err + await update_os(self.hass, version, backup) class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): diff --git a/homeassistant/components/hassio/update_helper.py b/homeassistant/components/hassio/update_helper.py index d801f6b5771..65a3ba38485 100644 --- a/homeassistant/components/hassio/update_helper.py +++ b/homeassistant/components/hassio/update_helper.py @@ -3,7 +3,11 @@ from __future__ import annotations from aiohasupervisor import SupervisorError -from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate +from aiohasupervisor.models import ( + HomeAssistantUpdateOptions, + OSUpdate, + StoreAddonUpdate, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -57,3 +61,24 @@ async def update_core(hass: HomeAssistant, version: str | None, backup: bool) -> ) except SupervisorError as err: raise HomeAssistantError(f"Error updating Home Assistant Core: {err}") from err + + +async def update_os(hass: HomeAssistant, version: str | None, backup: bool) -> None: + """Update OS. + + Optionally make a core backup before updating. + """ + client = get_supervisor_client(hass) + + if backup: + # pylint: disable-next=import-outside-toplevel + from .backup import backup_core_before_update + + await backup_core_before_update(hass) + + try: + await client.os.update(OSUpdate(version=version)) + except SupervisorError as err: + raise HomeAssistantError( + f"Error updating Home Assistant Operating System: {err}" + ) from err diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index c046e20feab..81f7ab9d0da 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -3,7 +3,7 @@ import logging from numbers import Number import re -from typing import Any +from typing import Any, cast import voluptuous as vol @@ -19,6 +19,7 @@ from homeassistant.helpers.dispatcher import ( ) from . import HassioAPIError +from .config import HassioUpdateParametersDict from .const import ( ATTR_DATA, ATTR_ENDPOINT, @@ -29,6 +30,7 @@ from .const import ( ATTR_VERSION, ATTR_WS_EVENT, DATA_COMPONENT, + DATA_CONFIG_STORE, EVENT_SUPERVISOR_EVENT, WS_ID, WS_TYPE, @@ -65,6 +67,8 @@ def async_load_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_subscribe) websocket_api.async_register_command(hass, websocket_update_addon) websocket_api.async_register_command(hass, websocket_update_core) + websocket_api.async_register_command(hass, websocket_update_config_info) + websocket_api.async_register_command(hass, websocket_update_config_update) @callback @@ -182,6 +186,45 @@ async def websocket_update_addon( async def websocket_update_core( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: - """Websocket handler to update an addon.""" + """Websocket handler to update Home Assistant Core.""" await update_core(hass, None, msg["backup"]) connection.send_result(msg[WS_ID]) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "hassio/update/config/info"}) +def websocket_update_config_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Send the stored backup config.""" + connection.send_result( + msg["id"], hass.data[DATA_CONFIG_STORE].data.update_config.to_dict() + ) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "hassio/update/config/update", + vol.Optional("add_on_backup_before_update"): bool, + vol.Optional("add_on_backup_retain_copies"): vol.All(int, vol.Range(min=1)), + vol.Optional("core_backup_before_update"): bool, + } +) +def websocket_update_config_update( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Update the stored backup config.""" + changes = dict(msg) + changes.pop("id") + changes.pop("type") + hass.data[DATA_CONFIG_STORE].update( + update_config=cast(HassioUpdateParametersDict, changes) + ) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index a2f9671c94b..e2d3e2522dc 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -14,7 +14,12 @@ from pyheos import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector @@ -141,8 +146,10 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): hostname = urlparse(discovery_info.ssdp_location).hostname assert hostname is not None - # Abort early when discovered host is part of the current system - if entry and hostname in _get_current_hosts(entry): + # Abort early when discovery is ignored or host is part of the current system + if entry and ( + entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) + ): return self.async_abort(reason="single_instance_allowed") # Connect to discovered host and get system information @@ -198,7 +205,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Obtain host and validate connection.""" - await self.async_set_unique_id(DOMAIN) + await self.async_set_unique_id(DOMAIN, raise_on_progress=False) self._abort_if_unique_id_configured(error="single_instance_allowed") # Try connecting to host if provided errors: dict[str, str] = {} diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 6d603f7ad30..d49fc17aa53 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -2,10 +2,15 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" +ATTR_DESTINATION_POSITION = "destination_position" +ATTR_QUEUE_IDS = "queue_ids" DOMAIN = "heos" ENTRY_TITLE = "HEOS System" +SERVICE_GET_QUEUE = "get_queue" SERVICE_GROUP_VOLUME_SET = "group_volume_set" SERVICE_GROUP_VOLUME_DOWN = "group_volume_down" SERVICE_GROUP_VOLUME_UP = "group_volume_up" +SERVICE_MOVE_QUEUE_ITEM = "move_queue_item" +SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 0303d150794..5e72eb1427e 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -6,7 +6,6 @@ entities to update. Entities subscribe to entity-specific updates within the ent """ from collections.abc import Callable, Sequence -from datetime import datetime, timedelta import logging from typing import Any @@ -25,10 +24,10 @@ from pyheos import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -43,7 +42,6 @@ class HeosCoordinator(DataUpdateCoordinator[None]): def __init__(self, hass: HomeAssistant, config_entry: HeosConfigEntry) -> 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( @@ -53,19 +51,31 @@ class HeosCoordinator(DataUpdateCoordinator[None]): # media position update upon start of playback or when media changes self.heos = Heos( HeosOptions( - self.host, + config_entry.data[CONF_HOST], all_progress_events=False, auto_reconnect=True, + auto_failover=True, credentials=credentials, ) ) self._platform_callbacks: list[Callable[[Sequence[HeosPlayer]], None]] = [] - self._update_sources_pending: bool = False + self._update_sources_debouncer = Debouncer( + hass, + _LOGGER, + immediate=True, + cooldown=2.0, + function=self._async_update_sources, + ) self._source_list: list[str] = [] self._favorites: dict[int, MediaItem] = {} self._inputs: Sequence[MediaItem] = [] super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) + @property + def host(self) -> str: + """Get the host address of the device.""" + return self.heos.current_host + @property def inputs(self) -> Sequence[MediaItem]: """Get input sources across all devices.""" @@ -159,43 +169,27 @@ class HeosCoordinator(DataUpdateCoordinator[None]): async def _async_on_reconnected(self) -> None: """Handle when reconnected so resources are updated and entities marked available.""" - await self._async_update_players() + assert self.config_entry is not None + if self.host != self.config_entry.data[CONF_HOST]: + self.hass.config_entries.async_update_entry( + self.config_entry, data={CONF_HOST: self.host} + ) + _LOGGER.warning("Successfully failed over to HEOS host %s", self.host) + else: + _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host) 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 + self, event: str, data: PlayerUpdateResult | None = None ) -> None: """Handle a controller event, such as players or groups changed.""" if event == const.EVENT_PLAYERS_CHANGED: assert data is not None self._async_handle_player_update_result(data) - 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, - ), - ) - ) + elif event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED): + # Debounce because we may have received multiple qualifying events in rapid succession. + await self._update_sources_debouncer.async_call() self.async_update_listeners() def _async_update_player_ids(self, updated_player_ids: dict[int, int]) -> None: @@ -257,15 +251,6 @@ class HeosCoordinator(DataUpdateCoordinator[None]): 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 - self._async_handle_player_update_result(player_updates) - @callback def async_get_source_list(self) -> list[str]: """Return the list of sources for players.""" diff --git a/homeassistant/components/heos/icons.json b/homeassistant/components/heos/icons.json index d7a998b6aec..b03f15a4b0f 100644 --- a/homeassistant/components/heos/icons.json +++ b/homeassistant/components/heos/icons.json @@ -1,5 +1,14 @@ { "services": { + "get_queue": { + "service": "mdi:playlist-music" + }, + "remove_from_queue": { + "service": "mdi:playlist-remove" + }, + "move_queue_item": { + "service": "mdi:playlist-edit" + }, "group_volume_set": { "service": "mdi:volume-medium" }, diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 573deda2132..8a88913456d 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "platinum", - "requirements": ["pyheos==1.0.2"], + "requirements": ["pyheos==1.0.5"], "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 9edc674d1cf..dd0cef0ec10 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -3,27 +3,34 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine, Sequence +from contextlib import suppress +import dataclasses from datetime import datetime from functools import reduce, wraps +import logging from operator import ior -from typing import Any +from typing import Any, Final from pyheos import ( AddCriteriaType, ControlType, HeosError, HeosPlayer, + MediaItem, + MediaMusicSource, + MediaType as HeosMediaType, PlayState, RepeatType, const as heos_const, ) -import voluptuous as vol +from pyheos.util import mediauri as heos_source from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, - ATTR_MEDIA_VOLUME_LEVEL, + BrowseError, BrowseMedia, + MediaClass, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -32,29 +39,24 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) +from homeassistant.components.media_source import BrowseMediaSource from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - entity_registry as er, -) +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from .const import ( - DOMAIN as HEOS_DOMAIN, - SERVICE_GROUP_VOLUME_DOWN, - SERVICE_GROUP_VOLUME_SET, - SERVICE_GROUP_VOLUME_UP, -) +from . import services +from .const import DOMAIN from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 +BROWSE_ROOT: Final = "heos://media" + BASE_SUPPORTED_FEATURES = ( MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET @@ -69,6 +71,7 @@ BASE_SUPPORTED_FEATURES = ( PLAY_STATE_TO_STATE = { None: MediaPlayerState.IDLE, + PlayState.UNKNOWN: MediaPlayerState.IDLE, PlayState.PLAY: MediaPlayerState.PLAYING, PlayState.STOP: MediaPlayerState.IDLE, PlayState.PAUSE: MediaPlayerState.PAUSED, @@ -97,6 +100,21 @@ HEOS_HA_REPEAT_TYPE_MAP = { } HA_HEOS_REPEAT_TYPE_MAP = {v: k for k, v in HEOS_HA_REPEAT_TYPE_MAP.items()} +HEOS_MEDIA_TYPE_TO_MEDIA_CLASS = { + HeosMediaType.ALBUM: MediaClass.ALBUM, + HeosMediaType.ARTIST: MediaClass.ARTIST, + HeosMediaType.CONTAINER: MediaClass.DIRECTORY, + HeosMediaType.GENRE: MediaClass.GENRE, + HeosMediaType.HEOS_SERVER: MediaClass.DIRECTORY, + HeosMediaType.HEOS_SERVICE: MediaClass.DIRECTORY, + HeosMediaType.MUSIC_SERVICE: MediaClass.DIRECTORY, + HeosMediaType.PLAYLIST: MediaClass.PLAYLIST, + HeosMediaType.SONG: MediaClass.TRACK, + HeosMediaType.STATION: MediaClass.TRACK, +} + +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -104,19 +122,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add media players for a config entry.""" - # Register custom entity services - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_SET, - {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float}, - "async_set_group_volume_level", - ) - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_DOWN, None, "async_group_volume_down" - ) - platform.async_register_entity_service( - SERVICE_GROUP_VOLUME_UP, None, "async_group_volume_up" - ) + services.register_media_player_services() def add_entities_callback(players: Sequence[HeosPlayer]) -> None: """Add entities for each player.""" @@ -129,23 +135,23 @@ async def async_setup_entry( add_entities_callback(list(coordinator.heos.players.values())) -type _FuncType[**_P] = Callable[_P, Awaitable[Any]] -type _ReturnFuncType[**_P] = Callable[_P, Coroutine[Any, Any, None]] +type _FuncType[**_P, _R] = Callable[_P, Awaitable[_R]] +type _ReturnFuncType[**_P, _R] = Callable[_P, Coroutine[Any, Any, _R]] -def catch_action_error[**_P]( +def catch_action_error[**_P, _R]( action: str, -) -> Callable[[_FuncType[_P]], _ReturnFuncType[_P]]: +) -> Callable[[_FuncType[_P, _R]], _ReturnFuncType[_P, _R]]: """Return decorator that catches errors and raises HomeAssistantError.""" - def decorator(func: _FuncType[_P]) -> _ReturnFuncType[_P]: + def decorator(func: _FuncType[_P, _R]) -> _ReturnFuncType[_P, _R]: @wraps(func) - async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None: + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: try: - await func(*args, **kwargs) + return await func(*args, **kwargs) except (HeosError, ValueError) as ex: raise HomeAssistantError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="action_error", translation_placeholders={"action": action, "error": str(ex)}, ) from ex @@ -173,7 +179,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): 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, str(player.player_id))}, + identifiers={(DOMAIN, str(player.player_id))}, manufacturer=manufacturer, model=model, name=player.name, @@ -209,7 +215,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): for member_id in player_ids if ( entity_id := entity_registry.async_get_entity_id( - Platform.MEDIA_PLAYER, HEOS_DOMAIN, str(member_id) + Platform.MEDIA_PLAYER, DOMAIN, str(member_id) ) ) ] @@ -242,6 +248,12 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): self.async_on_remove(self._player.add_on_player_event(self._player_update)) await super().async_added_to_hass() + @catch_action_error("get queue") + async def async_get_queue(self) -> ServiceResponse: + """Get the queue for the current player.""" + queue = await self._player.get_queue() + return {"queue": [dataclasses.asdict(item) for item in queue]} + @catch_action_error("clear playlist") async def async_clear_playlist(self) -> None: """Clear players playlist.""" @@ -282,6 +294,16 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" + if heos_source.is_media_uri(media_id): + media, data = heos_source.from_media_uri(media_id) + if not isinstance(media, MediaItem): + raise ValueError(f"Invalid media id '{media_id}'") + await self._player.play_media( + media, + HA_HEOS_ENQUEUE_MAP[kwargs.get(ATTR_MEDIA_ENQUEUE)], + ) + return + if media_source.is_media_source_id(media_id): media_type = MediaType.URL play_item = await media_source.async_resolve_media( @@ -332,6 +354,15 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): await self._player.play_preset_station(index) return + if media_type == "queue": + # media_id must be an int + try: + queue_id = int(media_id) + except ValueError: + raise ValueError(f"Invalid queue id '{media_id}'") from None + await self._player.play_queue(queue_id) + return + raise ValueError(f"Unsupported media type '{media_type}'") @catch_action_error("select source") @@ -348,7 +379,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): return raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="unknown_source", translation_placeholders={"source": source}, ) @@ -375,7 +406,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Set group volume level.""" if self._player.group_id is None: raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="entity_not_grouped", translation_placeholders={"entity_id": self.entity_id}, ) @@ -388,7 +419,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Turn group volume down for media player.""" if self._player.group_id is None: raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="entity_not_grouped", translation_placeholders={"entity_id": self.entity_id}, ) @@ -399,7 +430,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Turn group volume up for media player.""" if self._player.group_id is None: raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="entity_not_grouped", translation_placeholders={"entity_id": self.entity_id}, ) @@ -415,13 +446,13 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): entity_entry = entity_registry.async_get(entity_id) if entity_entry is None: raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="entity_not_found", translation_placeholders={"entity_id": entity_id}, ) - if entity_entry.platform != HEOS_DOMAIN: + if entity_entry.platform != DOMAIN: raise ServiceValidationError( - translation_domain=HEOS_DOMAIN, + translation_domain=DOMAIN, translation_key="not_heos_media_player", translation_placeholders={"entity_id": entity_id}, ) @@ -445,6 +476,17 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): await self.coordinator.heos.set_group(new_members) return + async def async_remove_from_queue(self, queue_ids: list[int]) -> None: + """Remove items from the queue.""" + await self._player.remove_from_queue(queue_ids) + + @catch_action_error("move queue item") + async def async_move_queue_item( + self, queue_ids: list[int], destination_position: int + ) -> None: + """Move items in the queue.""" + await self._player.move_queue_item(queue_ids, destination_position) + @property def available(self) -> bool: """Return True if the device is available.""" @@ -534,14 +576,103 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Volume level of the media player (0..1).""" return self._player.volume / 100 + async def _async_browse_media_root(self) -> BrowseMedia: + """Return media browsing root.""" + if not self.coordinator.heos.music_sources: + try: + await self.coordinator.heos.get_music_sources() + except HeosError as error: + _LOGGER.debug("Unable to load music sources: %s", error) + children: list[BrowseMedia] = [ + _media_to_browse_media(source) + for source in self.coordinator.heos.music_sources.values() + if source.available or source.source_id == heos_const.MUSIC_SOURCE_TUNEIN + ] + root = BrowseMedia( + title="Music Sources", + media_class=MediaClass.DIRECTORY, + children_media_class=MediaClass.DIRECTORY, + media_content_type="", + media_content_id=BROWSE_ROOT, + can_expand=True, + can_play=False, + children=children, + ) + # Append media source items + with suppress(BrowseError): + browse = await self._async_browse_media_source() + # If domain is None, it's an overview of available sources + if browse.domain is None and browse.children: + children.extend(browse.children) + else: + children.append(browse) + return root + + async def _async_browse_heos_media(self, media_content_id: str) -> BrowseMedia: + """Browse a HEOS media item.""" + media, data = heos_source.from_media_uri(media_content_id) + browse_media = _media_to_browse_media(media) + try: + browse_result = await self.coordinator.heos.browse_media(media) + except HeosError as error: + _LOGGER.debug("Unable to browse media %s: %s", media, error) + else: + browse_media.children = [ + _media_to_browse_media(item) + for item in browse_result.items + if item.browsable or item.playable + ] + return browse_media + + async def _async_browse_media_source( + self, media_content_id: str | None = None + ) -> BrowseMediaSource: + """Browse a media source item.""" + return await media_source.async_browse_media( + self.hass, + media_content_id, + content_filter=lambda item: item.media_content_type.startswith("audio/"), + ) + 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( - self.hass, - media_content_id, - content_filter=lambda item: item.media_content_type.startswith("audio/"), + if media_content_id in (None, BROWSE_ROOT): + return await self._async_browse_media_root() + assert media_content_id is not None + if heos_source.is_media_uri(media_content_id): + return await self._async_browse_heos_media(media_content_id) + if media_source.is_media_source_id(media_content_id): + return await self._async_browse_media_source(media_content_id) + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_media_content_id", + translation_placeholders={"media_content_id": media_content_id}, ) + + +def _media_to_browse_media(media: MediaItem | MediaMusicSource) -> BrowseMedia: + """Convert a HEOS media item to a browse media item.""" + can_expand = False + can_play = False + + if isinstance(media, MediaMusicSource): + can_expand = ( + media.source_id == heos_const.MUSIC_SOURCE_TUNEIN or media.available + ) + else: + can_expand = media.browsable + can_play = media.playable + + return BrowseMedia( + can_expand=can_expand, + can_play=can_play, + media_content_id=heos_source.to_media_uri(media), + media_content_type="", + media_class=HEOS_MEDIA_TYPE_TO_MEDIA_CLASS[media.type], + title=media.name, + thumbnail=media.image_url, + ) diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index dc11bb7a76d..86c6f6d0533 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -1,19 +1,35 @@ """Services for the HEOS integration.""" +from dataclasses import dataclass import logging +from typing import Final from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol +from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + issue_registry as ir, +) +from homeassistant.helpers.typing import VolDictType, VolSchemaType from .const import ( + ATTR_DESTINATION_POSITION, ATTR_PASSWORD, + ATTR_QUEUE_IDS, ATTR_USERNAME, DOMAIN, + SERVICE_GET_QUEUE, + SERVICE_GROUP_VOLUME_DOWN, + SERVICE_GROUP_VOLUME_SET, + SERVICE_GROUP_VOLUME_UP, + SERVICE_MOVE_QUEUE_ITEM, + SERVICE_REMOVE_FROM_QUEUE, SERVICE_SIGN_IN, SERVICE_SIGN_OUT, ) @@ -44,6 +60,75 @@ def register(hass: HomeAssistant) -> None: ) +@dataclass(frozen=True) +class EntityServiceDescription: + """Describe an entity service.""" + + name: str + method_name: str + schema: VolDictType | VolSchemaType | None = None + supports_response: SupportsResponse = SupportsResponse.NONE + + def async_register(self, platform: entity_platform.EntityPlatform) -> None: + """Register the service with the platform.""" + platform.async_register_entity_service( + self.name, + self.schema, + self.method_name, + supports_response=self.supports_response, + ) + + +REMOVE_FROM_QUEUE_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_QUEUE_IDS): vol.All( + cv.ensure_list, + [vol.All(cv.positive_int, vol.Range(min=1))], + vol.Unique(), + ) +} +GROUP_VOLUME_SET_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float +} +MOVE_QEUEUE_ITEM_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_QUEUE_IDS): vol.All( + cv.ensure_list, + [vol.All(vol.Coerce(int), vol.Range(min=1, max=1000))], + vol.Unique(), + ), + vol.Required(ATTR_DESTINATION_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=1, max=1000) + ), +} + +MEDIA_PLAYER_ENTITY_SERVICES: Final = ( + # Player queue services + EntityServiceDescription( + SERVICE_GET_QUEUE, "async_get_queue", supports_response=SupportsResponse.ONLY + ), + EntityServiceDescription( + SERVICE_REMOVE_FROM_QUEUE, "async_remove_from_queue", REMOVE_FROM_QUEUE_SCHEMA + ), + EntityServiceDescription( + SERVICE_MOVE_QUEUE_ITEM, "async_move_queue_item", MOVE_QEUEUE_ITEM_SCHEMA + ), + # Group volume services + EntityServiceDescription( + SERVICE_GROUP_VOLUME_SET, + "async_set_group_volume_level", + GROUP_VOLUME_SET_SCHEMA, + ), + EntityServiceDescription(SERVICE_GROUP_VOLUME_DOWN, "async_group_volume_down"), + EntityServiceDescription(SERVICE_GROUP_VOLUME_UP, "async_group_volume_up"), +) + + +def register_media_player_services() -> None: + """Register media_player entity services.""" + platform = entity_platform.async_get_current_platform() + for service in MEDIA_PLAYER_ENTITY_SERVICES: + service.async_register(platform) + + def _get_controller(hass: HomeAssistant) -> Heos: """Get the HEOS controller instance.""" _LOGGER.warning( diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index 8f3a43421f6..333a15940bc 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -1,3 +1,42 @@ +get_queue: + target: + entity: + integration: heos + domain: media_player + +remove_from_queue: + target: + entity: + integration: heos + domain: media_player + fields: + queue_ids: + required: true + selector: + text: + multiple: true + type: number + +move_queue_item: + target: + entity: + integration: heos + domain: media_player + fields: + queue_ids: + required: true + selector: + text: + multiple: true + type: number + destination_position: + required: true + selector: + number: + min: 1 + max: 1000 + step: 1 + group_volume_set: target: entity: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 340eecb9f8b..76b71f70e28 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -56,8 +56,8 @@ "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.", + "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%]" @@ -86,6 +86,34 @@ } } }, + "get_queue": { + "name": "Get queue", + "description": "Retrieves the queue of the media player." + }, + "remove_from_queue": { + "name": "Remove from queue", + "description": "Removes items from the play queue.", + "fields": { + "queue_ids": { + "name": "Queue IDs", + "description": "The IDs (indexes) of the items in the queue to remove." + } + } + }, + "move_queue_item": { + "name": "Move queue item", + "description": "Moves one or more items within the play queue.", + "fields": { + "queue_ids": { + "name": "Queue IDs", + "description": "The IDs (indexes) of the items in the queue to move." + }, + "destination_position": { + "name": "Destination position", + "description": "The position index in the queue to move the items to." + } + } + }, "group_volume_down": { "name": "Turn down group volume", "description": "Turns down the group volume." @@ -146,6 +174,9 @@ }, "unknown_source": { "message": "Unknown source: {source}" + }, + "unsupported_media_content_id": { + "message": "Unsupported media_content_id: {media_content_id}" } }, "issues": { diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 132b12de4ce..525da15bd74 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started @@ -18,10 +17,10 @@ from .const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, - DOMAIN, TRAVEL_MODE_PUBLIC, ) from .coordinator import ( + HereConfigEntry, HERERoutingDataUpdateCoordinator, HERETransitDataUpdateCoordinator, ) @@ -30,7 +29,7 @@ from .model import HERETravelTimeConfig PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) -> bool: """Set up HERE Travel Time from a config entry.""" api_key = config_entry.data[CONF_API_KEY] @@ -57,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b cls = HERERoutingDataUpdateCoordinator data_coordinator = cls(hass, config_entry, api_key, here_travel_time_config) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = data_coordinator + config_entry.runtime_data = data_coordinator async def _async_update_at_start(_: HomeAssistant) -> None: await data_coordinator.async_refresh() @@ -68,12 +67,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: HereConfigEntry +) -> bool: """Unload a config entry.""" - 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) diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index a3345e78e4e..aa36404c584 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -41,16 +41,20 @@ BACKOFF_MULTIPLIER = 1.1 _LOGGER = logging.getLogger(__name__) +type HereConfigEntry = ConfigEntry[ + HERETransitDataUpdateCoordinator | HERERoutingDataUpdateCoordinator +] + class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]): """here_routing DataUpdateCoordinator.""" - config_entry: ConfigEntry + config_entry: HereConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HereConfigEntry, api_key: str, config: HERETravelTimeConfig, ) -> None: @@ -173,12 +177,12 @@ class HERETransitDataUpdateCoordinator( ): """HERETravelTime DataUpdateCoordinator.""" - config_entry: ConfigEntry + config_entry: HereConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HereConfigEntry, api_key: str, config: HERETravelTimeConfig, ) -> None: diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 0f0cbb7d3cb..bbaabb56d46 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LATITUDE, @@ -40,6 +39,7 @@ from .const import ( ICONS, ) from .coordinator import ( + HereConfigEntry, HERERoutingDataUpdateCoordinator, HERETransitDataUpdateCoordinator, ) @@ -77,14 +77,14 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HereConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add HERE travel time entities from a config_entry.""" entry_id = config_entry.entry_id name = config_entry.data[CONF_NAME] - coordinator = hass.data[DOMAIN][entry_id] + coordinator = config_entry.runtime_data sensors: list[HERETravelTimeSensor] = [ HERETravelTimeSensor( @@ -164,7 +164,8 @@ class OriginSensor(HERETravelTimeSensor): self, unique_id_prefix: str, name: str, - coordinator: HERERoutingDataUpdateCoordinator, + coordinator: HERERoutingDataUpdateCoordinator + | HERETransitDataUpdateCoordinator, ) -> None: """Initialize the sensor.""" sensor_description = SensorEntityDescription( @@ -192,7 +193,8 @@ class DestinationSensor(HERETravelTimeSensor): self, unique_id_prefix: str, name: str, - coordinator: HERERoutingDataUpdateCoordinator, + coordinator: HERERoutingDataUpdateCoordinator + | HERETransitDataUpdateCoordinator, ) -> None: """Initialize the sensor.""" sensor_description = SensorEntityDescription( diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index c57e766eaed..3761c935992 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -52,7 +52,7 @@ class HistoryLiveStream: subscriptions: list[CALLBACK_TYPE] end_time_unsub: CALLBACK_TYPE | None = None task: asyncio.Task | None = None - wait_sync_task: asyncio.Task | None = None + wait_sync_future: asyncio.Future[None] | None = None @callback @@ -491,8 +491,8 @@ async def ws_stream( subscriptions.clear() if live_stream.task: live_stream.task.cancel() - if live_stream.wait_sync_task: - live_stream.wait_sync_task.cancel() + if live_stream.wait_sync_future: + live_stream.wait_sync_future.cancel() if live_stream.end_time_unsub: live_stream.end_time_unsub() live_stream.end_time_unsub = None @@ -554,10 +554,12 @@ async def ws_stream( ) ) - live_stream.wait_sync_task = create_eager_task( - get_instance(hass).async_block_till_done() - ) - await live_stream.wait_sync_task + if sync_future := get_instance(hass).async_get_commit_future(): + # Set the future so we can cancel it if the client + # unsubscribes before the commit is done so we don't + # query the database needlessly + live_stream.wait_sync_future = sync_future + await live_stream.wait_sync_future # # Fetch any states from the database that have diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index a69abe26f6c..756a6b3ce9d 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -54,7 +54,7 @@ class HistoryStats: self._period = (MIN_TIME_UTC, MIN_TIME_UTC) self._state: HistoryStatsState = HistoryStatsState(None, None, self._period) self._history_current_period: list[HistoryState] = [] - self._previous_run_before_start = False + self._has_recorder_data = False self._entity_states = set(entity_states) self._duration = duration self._start = start @@ -88,20 +88,20 @@ class HistoryStats: if current_period_start_timestamp > now_timestamp: # History cannot tell the future self._history_current_period = [] - self._previous_run_before_start = True + self._has_recorder_data = False self._state = HistoryStatsState(None, None, self._period) return self._state # # We avoid querying the database if the below did NOT happen: # - # - The previous run happened before the start time - # - The start time changed - # - The period shrank in size + # - No previous run occurred (uninitialized) + # - The start time moved back in time + # - The end time moved back in time # - The previous period ended before now # if ( - not self._previous_run_before_start - and current_period_start_timestamp == previous_period_start_timestamp + self._has_recorder_data + and current_period_start_timestamp >= previous_period_start_timestamp and ( current_period_end_timestamp == previous_period_end_timestamp or ( @@ -110,6 +110,12 @@ class HistoryStats: ) ) ): + start_changed = ( + current_period_start_timestamp != previous_period_start_timestamp + ) + if start_changed: + self._prune_history_cache(current_period_start_timestamp) + new_data = False if event and (new_state := event.data["new_state"]) is not None: if ( @@ -121,7 +127,11 @@ class HistoryStats: HistoryState(new_state.state, new_state.last_changed_timestamp) ) new_data = True - if not new_data and current_period_end_timestamp < now_timestamp: + if ( + not new_data + and current_period_end_timestamp < now_timestamp + and not start_changed + ): # If period has not changed and current time after the period end... # Don't compute anything as the value cannot have changed return self._state @@ -139,7 +149,7 @@ class HistoryStats: HistoryState(new_state.state, new_state.last_changed_timestamp) ) - self._previous_run_before_start = False + self._has_recorder_data = True seconds_matched, match_count = self._async_compute_seconds_and_changes( now_timestamp, @@ -223,3 +233,18 @@ class HistoryStats: # Save value in seconds seconds_matched = elapsed return seconds_matched, match_count + + def _prune_history_cache(self, start_timestamp: float) -> None: + """Remove unnecessary old data from the history state cache from previous runs. + + Update the timestamp of the last record from before the start to the current start time. + """ + trim_count = 0 + for i, history_state in enumerate(self._history_current_period): + if history_state.last_changed >= start_timestamp: + break + history_state.last_changed = start_timestamp + if i > 0: + trim_count += 1 + if trim_count: # Don't slice if no data was removed + self._history_current_period = self._history_current_period[trim_count:] diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index ac008b857af..c45ecd24ea3 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -24,11 +24,11 @@ from .entity import HiveEntity _LOGGER = logging.getLogger(__name__) +type HiveConfigEntry = ConfigEntry[Hive] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> 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) hive = Hive(web_session) @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hive_config["options"].update( {CONF_SCAN_INTERVAL: dict(entry.options).get(CONF_SCAN_INTERVAL, 120)} ) - hass.data[DOMAIN][entry.entry_id] = hive + entry.runtime_data = hive try: devices = await hive.session.startSession(hive_config) @@ -59,16 +59,12 @@ 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: HiveConfigEntry) -> 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 async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> None: """Remove a config entry.""" hive = Auth(entry.data["username"], entry.data["password"]) await hive.forget_device( @@ -78,7 +74,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: HiveConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove a config entry from a device.""" return True diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py index c2fe47642a0..338cc6bcf0a 100644 --- a/homeassistant/components/hive/alarm_control_panel.py +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -9,11 +9,10 @@ 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 AddConfigEntryEntitiesCallback -from .const import DOMAIN +from . import HiveConfigEntry from .entity import HiveEntity PARALLEL_UPDATES = 0 @@ -28,12 +27,12 @@ HIVETOHA = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data if devices := hive.session.deviceList.get("alarm_control_panel"): async_add_entities( [HiveAlarmControlPanelEntity(hive, dev) for dev in devices], True diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index 2076d592a7c..cdf6c253916 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -10,11 +10,10 @@ 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 AddConfigEntryEntitiesCallback -from .const import DOMAIN +from . import HiveConfigEntry from .entity import HiveEntity PARALLEL_UPDATES = 0 @@ -69,12 +68,12 @@ SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data sensors: list[BinarySensorEntity] = [] diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index bd7553faa1a..28062adb0e3 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -15,19 +15,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.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import refresh_system -from .const import ( - ATTR_TIME_PERIOD, - DOMAIN, - SERVICE_BOOST_HEATING_OFF, - SERVICE_BOOST_HEATING_ON, -) +from . import HiveConfigEntry, refresh_system +from .const import ATTR_TIME_PERIOD, SERVICE_BOOST_HEATING_OFF, SERVICE_BOOST_HEATING_ON from .entity import HiveEntity HIVE_TO_HASS_STATE = { @@ -59,12 +53,12 @@ _LOGGER = logging.getLogger() async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data devices = hive.session.deviceList.get("climate") if devices: async_add_entities((HiveClimateEntity(hive, dev) for dev in devices), True) diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index e3180dc9734..41dba27c3a5 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -16,7 +16,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -24,6 +23,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import callback +from . import HiveConfigEntry from .const import CONF_CODE, CONF_DEVICE_NAME, CONFIG_ENTRY_VERSION, DOMAIN @@ -37,7 +37,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): """Initialize the config flow.""" self.data: dict[str, Any] = {} self.tokens: dict[str, str] = {} - self.entry: ConfigEntry | None = None self.device_registration: bool = False self.device_name = "Home Assistant" @@ -54,7 +53,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): ) # Get user from existing entry and abort if already setup - self.entry = await self.async_set_unique_id(self.data[CONF_USERNAME]) + await self.async_set_unique_id(self.data[CONF_USERNAME]) if self.context["source"] != SOURCE_REAUTH: self._abort_if_unique_id_configured() @@ -145,12 +144,12 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): # Setup the config entry self.data["tokens"] = self.tokens if self.source == SOURCE_REAUTH: - assert self.entry - self.hass.config_entries.async_update_entry( - self.entry, title=self.data["username"], data=self.data + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + title=self.data["username"], + data=self.data, + reason="reauth_successful", ) - await self.hass.config_entries.async_reload(self.entry.entry_id) - return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=self.data["username"], data=self.data) async def async_step_reauth( @@ -166,7 +165,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: HiveConfigEntry, ) -> HiveOptionsFlowHandler: """Hive options callback.""" return HiveOptionsFlowHandler(config_entry) @@ -175,7 +174,9 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN): class HiveOptionsFlowHandler(OptionsFlow): """Config flow options for Hive.""" - def __init__(self, config_entry: ConfigEntry) -> None: + config_entry: HiveConfigEntry + + def __init__(self, config_entry: HiveConfigEntry) -> None: """Initialize Hive options flow.""" self.hive = None self.interval = config_entry.options.get(CONF_SCAN_INTERVAL, 120) @@ -190,7 +191,7 @@ class HiveOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - self.hive = self.hass.data["hive"][self.config_entry.entry_id] + self.hive = self.config_entry.runtime_data errors: dict[str, str] = {} if user_input is not None: new_interval = user_input.get(CONF_SCAN_INTERVAL) diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 80a81583429..f89d23b8513 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -3,7 +3,9 @@ from __future__ import annotations from datetime import timedelta -from typing import TYPE_CHECKING, Any +from typing import Any + +from apyhiveapi import Hive from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -12,30 +14,26 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from . import refresh_system -from .const import ATTR_MODE, DOMAIN +from . import HiveConfigEntry, refresh_system +from .const import ATTR_MODE from .entity import HiveEntity -if TYPE_CHECKING: - from apyhiveapi import Hive - PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive: Hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data devices = hive.session.deviceList.get("light") if not devices: return diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index 0609e43c4a9..70a21038d67 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -24,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import HiveConfigEntry from .entity import HiveEntity PARALLEL_UPDATES = 0 @@ -90,11 +89,11 @@ SENSOR_TYPES: tuple[HiveSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data devices = hive.session.deviceList.get("sensor") if not devices: return diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 219776ad7e6..2aa17f0e005 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -2,27 +2,27 @@ "config": { "step": { "user": { - "title": "Hive Login", + "title": "Hive login", "description": "Enter your Hive login information.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "scan_interval": "Scan Interval (seconds)" + "scan_interval": "Scan interval (seconds)" } }, "2fa": { - "title": "Hive Two-factor Authentication.", - "description": "Enter your Hive authentication code. \n \n Please enter code 0000 to request another code.", + "title": "Hive two-factor authentication", + "description": "Enter your Hive authentication code.\n\nPlease enter code 0000 to request another code.", "data": { "2fa": "Two-factor code" } }, "configuration": { + "title": "Hive configuration", + "description": "Enter your Hive configuration.", "data": { - "device_name": "Device Name" - }, - "description": "Enter your Hive configuration", - "title": "Hive Configuration." + "device_name": "Device name" + } }, "reauth": { "title": "[%key:component::hive::config::step::user::title%]", @@ -34,10 +34,10 @@ } }, "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_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.", + "invalid_username": "Failed to sign in to Hive. Your email address is not recognized.", + "invalid_password": "Failed to sign in to Hive. Incorrect password, please try again.", + "invalid_code": "Failed to sign in to 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%]" }, "abort": { @@ -105,7 +105,7 @@ "sensor": { "heating": { "state": { - "manual": "Manual", + "manual": "[%key:common::state::manual%]", "off": "[%key:common::state::off%]", "schedule": "Schedule" } diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index d4fefea5a56..0640436d105 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -8,13 +8,12 @@ from typing import Any from apyhiveapi import Hive 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 AddConfigEntryEntitiesCallback -from . import refresh_system -from .const import ATTR_MODE, DOMAIN +from . import HiveConfigEntry, refresh_system +from .const import ATTR_MODE from .entity import HiveEntity PARALLEL_UPDATES = 0 @@ -34,12 +33,12 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data devices = hive.session.deviceList.get("switch") if not devices: return diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py index 5f0a3d0f3fa..104c4f62f9c 100644 --- a/homeassistant/components/hive/water_heater.py +++ b/homeassistant/components/hive/water_heater.py @@ -10,17 +10,15 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import refresh_system +from . import HiveConfigEntry, refresh_system from .const import ( ATTR_ONOFF, ATTR_TIME_PERIOD, - DOMAIN, SERVICE_BOOST_HOT_WATER, WATER_HEATER_MODES, ) @@ -46,12 +44,12 @@ SUPPORT_WATER_HEATER = [STATE_ECO, STATE_ON, STATE_OFF] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HiveConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] + hive = entry.runtime_data devices = hive.session.deviceList.get("water_heater") if devices: async_add_entities((HiveWaterHeater(hive, dev) for dev in devices), True) diff --git a/homeassistant/components/hko/__init__.py b/homeassistant/components/hko/__init__.py index b7e21f731d8..b99fc07bc2f 100644 --- a/homeassistant/components/hko/__init__.py +++ b/homeassistant/components/hko/__init__.py @@ -4,18 +4,17 @@ from __future__ import annotations from hko import LOCATIONS -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LOCATION, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEFAULT_DISTRICT, DOMAIN, KEY_DISTRICT, KEY_LOCATION -from .coordinator import HKOUpdateCoordinator +from .const import DEFAULT_DISTRICT, KEY_DISTRICT, KEY_LOCATION +from .coordinator import HKOConfigEntry, HKOUpdateCoordinator PLATFORMS: list[Platform] = [Platform.WEATHER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HKOConfigEntry) -> bool: """Set up Hong Kong Observatory from a config entry.""" location = entry.data[CONF_LOCATION] @@ -27,16 +26,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = HKOUpdateCoordinator(hass, entry, websession, district, location) 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: HKOConfigEntry) -> 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/hko/config_flow.py b/homeassistant/components/hko/config_flow.py index 8548bb4767d..1e2a6230455 100644 --- a/homeassistant/components/hko/config_flow.py +++ b/homeassistant/components/hko/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from asyncio import timeout +import logging from typing import Any from hko import HKO, LOCATIONS, HKOError @@ -15,6 +16,8 @@ from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .const import API_RHRREAD, DEFAULT_LOCATION, DOMAIN, KEY_LOCATION +_LOGGER = logging.getLogger(__name__) + def get_loc_name(item): """Return an array of supported locations.""" @@ -54,7 +57,8 @@ class HKOConfigFlow(ConfigFlow, domain=DOMAIN): except HKOError: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: await self.async_set_unique_id( diff --git a/homeassistant/components/hko/coordinator.py b/homeassistant/components/hko/coordinator.py index 5845e8831fe..29746c20728 100644 --- a/homeassistant/components/hko/coordinator.py +++ b/homeassistant/components/hko/coordinator.py @@ -11,7 +11,6 @@ from hko import HKO, HKOError from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, ATTR_CONDITION_FOG, - ATTR_CONDITION_HAIL, ATTR_CONDITION_LIGHTNING_RAINY, ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_POURING, @@ -66,16 +65,18 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type HKOConfigEntry = ConfigEntry[HKOUpdateCoordinator] + class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """HKO Update Coordinator.""" - config_entry: ConfigEntry + config_entry: HKOConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HKOConfigEntry, session: ClientSession, district: str, location: str, @@ -145,7 +146,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Return the condition corresponding to the weather info.""" info = info.lower() if WEATHER_INFO_RAIN in info: - return ATTR_CONDITION_HAIL + return ATTR_CONDITION_RAINY if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info: return ATTR_CONDITION_SNOWY_RAINY if WEATHER_INFO_SNOW in info: diff --git a/homeassistant/components/hko/weather.py b/homeassistant/components/hko/weather.py index e746d4304d3..075090ecc3f 100644 --- a/homeassistant/components/hko/weather.py +++ b/homeassistant/components/hko/weather.py @@ -5,7 +5,6 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -22,19 +21,18 @@ from .const import ( DOMAIN, MANUFACTURER, ) -from .coordinator import HKOUpdateCoordinator +from .coordinator import HKOConfigEntry, HKOUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HKOConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a HKO weather entity from a config_entry.""" assert config_entry.unique_id is not None unique_id = config_entry.unique_id - coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([HKOEntity(unique_id, coordinator)], False) + async_add_entities([HKOEntity(unique_id, config_entry.runtime_data)], False) class HKOEntity(CoordinatorEntity[HKOUpdateCoordinator], WeatherEntity): diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index ebd92908b93..f55535d9be0 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -3,6 +3,7 @@ import logging from hlk_sw16 import create_hlk_sw16_connection +from hlk_sw16.protocol import SW16Client import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -24,9 +25,6 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SWITCH] -DATA_DEVICE_REGISTER = "hlk_sw16_device_register" -DATA_DEVICE_LISTENER = "hlk_sw16_device_listener" - SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string}) RELAY_ID = vol.All( @@ -52,6 +50,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type HlkConfigEntry = ConfigEntry[SW16Client] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Component setup, do nothing.""" @@ -70,15 +70,12 @@ 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: HlkConfigEntry) -> bool: """Set up the HLK-SW16 switch.""" - hass.data.setdefault(DOMAIN, {}) host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] address = f"{host}:{port}" - hass.data[DOMAIN][entry.entry_id] = {} - @callback def disconnected(): """Schedule reconnect after connection has been lost.""" @@ -106,7 +103,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, ) - hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER] = client + entry.runtime_data = client # Load entities await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -116,14 +113,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: HlkConfigEntry) -> bool: """Unload a config entry.""" - client = hass.data[DOMAIN][entry.entry_id].pop(DATA_DEVICE_REGISTER) - client.stop() - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - if hass.data[DOMAIN][entry.entry_id]: - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - return unload_ok + entry.runtime_data.stop() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hlk_sw16/entity.py b/homeassistant/components/hlk_sw16/entity.py index 91510760968..d3784fef5ee 100644 --- a/homeassistant/components/hlk_sw16/entity.py +++ b/homeassistant/components/hlk_sw16/entity.py @@ -2,6 +2,8 @@ import logging +from hlk_sw16.protocol import SW16Client + from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -17,12 +19,12 @@ class SW16Entity(Entity): _attr_should_poll = False - def __init__(self, device_port, entry_id, client): + def __init__(self, device_port: str, entry_id: str, client: SW16Client) -> None: """Initialize the device.""" # HLK-SW16 specific attributes for every component type self._entry_id = entry_id self._device_port = device_port - self._is_on = None + self._is_on: bool | None = None self._client = client self._attr_name = device_port self._attr_unique_id = f"{self._entry_id}_{self._device_port}" diff --git a/homeassistant/components/hlk_sw16/switch.py b/homeassistant/components/hlk_sw16/switch.py index c6e6f7f5201..795f3dc68ea 100644 --- a/homeassistant/components/hlk_sw16/switch.py +++ b/homeassistant/components/hlk_sw16/switch.py @@ -1,22 +1,22 @@ """Support for HLK-SW16 switches.""" +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 AddConfigEntryEntitiesCallback -from . import DATA_DEVICE_REGISTER -from .const import DOMAIN +from . import HlkConfigEntry from .entity import SW16Entity PARALLEL_UPDATES = 0 -def devices_from_entities(hass, entry): +def devices_from_entities(entry: HlkConfigEntry) -> list[SW16Switch]: """Parse configuration and add HLK-SW16 switch devices.""" - device_client = hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER] + device_client = entry.runtime_data devices = [] for i in range(16): device_port = f"{i:01x}" @@ -27,18 +27,18 @@ def devices_from_entities(hass, entry): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HlkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HLK-SW16 platform.""" - async_add_entities(devices_from_entities(hass, entry)) + async_add_entities(devices_from_entities(entry)) class SW16Switch(SW16Entity, SwitchEntity): """Representation of a HLK-SW16 switch.""" @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" return self._is_on diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index cd5ac1ec1a9..9809862cd52 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.67", "babel==2.15.0"] + "requirements": ["holidays==0.72", "babel==2.15.0"] } diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json index d464f9e8bfd..6e317b8fa7b 100644 --- a/homeassistant/components/holiday/strings.json +++ b/homeassistant/components/holiday/strings.json @@ -8,7 +8,7 @@ "step": { "user": { "data": { - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" } }, "options": { diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 3e1bd1da156..01f2acd1851 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,189 +2,33 @@ from __future__ import annotations -from collections.abc import Awaitable import logging -from typing import Any, cast +from typing import Any from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import ( - ArrayOfOptions, - CommandKey, - Option, - OptionKey, - ProgramKey, - SettingKey, -) -from aiohomeconnect.model.error import HomeConnectError -import voluptuous as vol +import aiohttp +import jwt -from homeassistant.const import ATTR_DEVICE_ID, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, - device_registry as dr, + issue_registry as ir, ) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth -from .const import ( - AFFECTS_TO_ACTIVE_PROGRAM, - AFFECTS_TO_SELECTED_PROGRAM, - ATTR_AFFECTS_TO, - ATTR_KEY, - ATTR_PROGRAM, - ATTR_UNIT, - ATTR_VALUE, - DOMAIN, - OLD_NEW_UNIQUE_ID_SUFFIX_MAP, - PROGRAM_ENUM_OPTIONS, - SERVICE_OPTION_ACTIVE, - SERVICE_OPTION_SELECTED, - SERVICE_PAUSE_PROGRAM, - SERVICE_RESUME_PROGRAM, - SERVICE_SELECT_PROGRAM, - SERVICE_SET_PROGRAM_AND_OPTIONS, - SERVICE_SETTING, - SERVICE_START_PROGRAM, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, - SVE_TRANSLATION_PLACEHOLDER_VALUE, - TRANSLATION_KEYS_PROGRAMS_MAP, -) +from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator -from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +from .services import register_actions _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - -PROGRAM_OPTIONS = { - bsh_key_to_translation_key(key): ( - key, - value, - ) - for key, value in { - OptionKey.BSH_COMMON_DURATION: int, - OptionKey.BSH_COMMON_START_IN_RELATIVE: int, - OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, - OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, - OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, - OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool, - OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool, - OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, - OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, - OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, - OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, - }.items() -} - - -SERVICE_SETTING_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(SettingKey), - vol.NotIn([SettingKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(str, int, bool), - } -) - -# DEPRECATED: Remove in 2025.9.0 -SERVICE_OPTION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(str, int, bool), - vol.Optional(ATTR_UNIT): str, - } -) - -# DEPRECATED: Remove in 2025.9.0 -SERVICE_PROGRAM_SCHEMA = vol.Any( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(int, str), - vol.Optional(ATTR_UNIT): str, - }, - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - }, -) - - -def _require_program_or_at_least_one_option(data: dict) -> dict: - if ATTR_PROGRAM not in data and not any( - option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS) - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="required_program_or_one_option_at_least", - ) - return data - - -SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_AFFECTS_TO): vol.In( - [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM] - ), - vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()), - } - ) - .extend( - { - vol.Optional(translation_key): vol.In(allowed_values.keys()) - for translation_key, ( - key, - allowed_values, - ) in PROGRAM_ENUM_OPTIONS.items() - } - ) - .extend( - { - vol.Optional(translation_key): schema - for translation_key, (key, schema) in PROGRAM_OPTIONS.items() - } - ), - _require_program_or_at_least_one_option, -) - -SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -197,406 +41,9 @@ PLATFORMS = [ ] -async def _get_client_and_ha_id( - hass: HomeAssistant, device_id: str -) -> tuple[HomeConnectClient, str]: - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) - if device_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="device_entry_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - entry: HomeConnectConfigEntry | None = None - for entry_id in device_entry.config_entries: - _entry = hass.config_entries.async_get_entry(entry_id) - assert _entry - if _entry.domain == DOMAIN: - entry = cast(HomeConnectConfigEntry, _entry) - break - if entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="config_entry_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - - ha_id = next( - ( - identifier[1] - for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - ), - None, - ) - if ha_id is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="appliance_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - return entry.runtime_data.client, ha_id - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - - async def _async_service_program(call: ServiceCall, start: bool) -> None: - """Execute calls to services taking a program.""" - program = call.data[ATTR_PROGRAM] - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - option_key = call.data.get(ATTR_KEY) - options = ( - [ - Option( - option_key, - call.data[ATTR_VALUE], - unit=call.data.get(ATTR_UNIT), - ) - ] - if option_key is not None - else None - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_PROGRAM}: {program}", - *([f" {ATTR_KEY}: {options[0].key}"] if options else []), - *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), - *( - [f" {ATTR_UNIT}: {options[0].unit}"] - if options and options[0].unit - else [] - ), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", - f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", - *( - [ - f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" - ] - if options - else [] - ), - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - - try: - if start: - await client.start_program(ha_id, program_key=program, options=options) - else: - await client.set_selected_program( - ha_id, program_key=program, options=options - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="start_program" if start else "select_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, - }, - ) from err - - async def _async_service_set_program_options( - call: ServiceCall, active: bool - ) -> None: - """Execute calls to services taking a program.""" - option_key = call.data[ATTR_KEY] - value = call.data[ATTR_VALUE] - unit = call.data.get(ATTR_UNIT) - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - async_create_issue( - hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_KEY}: {option_key}", - f" {ATTR_VALUE}: {value}", - *([f" {ATTR_UNIT}: {unit}"] if unit else []), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", - f" {bsh_key_to_translation_key(option_key)}: {value}", - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - try: - if active: - await client.set_active_program_option( - ha_id, - option_key=option_key, - value=value, - unit=unit, - ) - else: - await client.set_selected_program_option( - ha_id, - option_key=option_key, - value=value, - unit=unit, - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_options_active_program" - if active - else "set_options_selected_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_KEY: option_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), - }, - ) from err - - async def _async_service_command( - call: ServiceCall, command_key: CommandKey - ) -> None: - """Execute calls to services executing a command.""" - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - async_create_issue( - hass, - DOMAIN, - "deprecated_command_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_command_actions", - ) - - try: - await client.put_command(ha_id, command_key=command_key, value=True) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="execute_command", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "command": command_key.value, - }, - ) from err - - async def async_service_option_active(call: ServiceCall) -> None: - """Service for setting an option for an active program.""" - await _async_service_set_program_options(call, True) - - async def async_service_option_selected(call: ServiceCall) -> None: - """Service for setting an option for a selected program.""" - await _async_service_set_program_options(call, False) - - async def async_service_setting(call: ServiceCall) -> None: - """Service for changing a setting.""" - key = call.data[ATTR_KEY] - value = call.data[ATTR_VALUE] - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - try: - await client.set_setting(ha_id, setting_key=key, value=value) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_setting", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_KEY: key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), - }, - ) from err - - async def async_service_pause_program(call: ServiceCall) -> None: - """Service for pausing a program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - - async def async_service_resume_program(call: ServiceCall) -> None: - """Service for resuming a paused program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - - async def async_service_select_program(call: ServiceCall) -> None: - """Service for selecting a program.""" - await _async_service_program(call, False) - - async def async_service_set_program_and_options(call: ServiceCall) -> None: - """Service for setting a program and options.""" - data = dict(call.data) - program = data.pop(ATTR_PROGRAM, None) - affects_to = data.pop(ATTR_AFFECTS_TO) - client, ha_id = await _get_client_and_ha_id(hass, data.pop(ATTR_DEVICE_ID)) - - options: list[Option] = [] - - for option, value in data.items(): - if option in PROGRAM_ENUM_OPTIONS: - options.append( - Option( - PROGRAM_ENUM_OPTIONS[option][0], - PROGRAM_ENUM_OPTIONS[option][1][value], - ) - ) - elif option in PROGRAM_OPTIONS: - option_key = PROGRAM_OPTIONS[option][0] - options.append(Option(option_key, value)) - - method_call: Awaitable[Any] - exception_translation_key: str - if program: - program = ( - program - if isinstance(program, ProgramKey) - else TRANSLATION_KEYS_PROGRAMS_MAP[program] - ) - - if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: - method_call = client.start_program( - ha_id, program_key=program, options=options - ) - exception_translation_key = "start_program" - elif affects_to == AFFECTS_TO_SELECTED_PROGRAM: - method_call = client.set_selected_program( - ha_id, program_key=program, options=options - ) - exception_translation_key = "select_program" - else: - array_of_options = ArrayOfOptions(options) - if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: - method_call = client.set_active_program_options( - ha_id, array_of_options=array_of_options - ) - exception_translation_key = "set_options_active_program" - else: - # affects_to is AFFECTS_TO_SELECTED_PROGRAM - method_call = client.set_selected_program_options( - ha_id, array_of_options=array_of_options - ) - exception_translation_key = "set_options_selected_program" - - try: - await method_call - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=exception_translation_key, - translation_placeholders={ - **get_dict_from_home_connect_error(err), - **( - {SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program} - if program - else {} - ), - }, - ) from err - - async def async_service_start_program(call: ServiceCall) -> None: - """Service for starting a program.""" - await _async_service_program(call, True) - - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_ACTIVE, - async_service_option_active, - schema=SERVICE_OPTION_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_SELECTED, - async_service_option_selected, - schema=SERVICE_OPTION_SCHEMA, - ) - hass.services.async_register( - DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA - ) - hass.services.async_register( - DOMAIN, - SERVICE_PAUSE_PROGRAM, - async_service_pause_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_PROGRAM, - async_service_resume_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SELECT_PROGRAM, - async_service_select_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_START_PROGRAM, - async_service_start_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SET_PROGRAM_AND_OPTIONS, - async_service_set_program_and_options, - schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, - ) - + register_actions(hass) return True @@ -611,18 +58,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) config_entry_auth = AsyncConfigEntryAuth(hass, session) + try: + await config_entry_auth.async_get_access_token() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err home_connect_client = HomeConnectClient(config_entry_auth) coordinator = HomeConnectCoordinator(hass, entry, home_connect_client) - await coordinator.async_config_entry_first_refresh() - + await coordinator.async_setup() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.runtime_data.start_event_listener() + entry.async_create_background_task( + hass, + coordinator.async_refresh(), + f"home_connect-initial-full-refresh-{entry.entry_id}", + ) + return True @@ -630,8 +90,18 @@ async def async_unload_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: """Unload a config entry.""" - async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") - async_delete_issue(hass, DOMAIN, "deprecated_command_actions") + issue_registry = ir.async_get(hass) + issues_to_delete = [ + "deprecated_set_program_and_option_actions", + "deprecated_command_actions", + ] + [ + issue_id + for (issue_domain, issue_id) in issue_registry.issues + if issue_domain == DOMAIN + and issue_id.startswith("home_connect_too_many_connected_paired_events") + ] + for issue_id in issues_to_delete: + issue_registry.async_delete(DOMAIN, issue_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -641,25 +111,39 @@ async def async_migrate_entry( """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version == 1 and entry.minor_version == 1: + if entry.version == 1: + match entry.minor_version: + case 1: - @callback - def update_unique_id( - entity_entry: RegistryEntry, - ) -> dict[str, Any] | None: - """Update unique ID of entity entry.""" - for old_id_suffix, new_id_suffix in OLD_NEW_UNIQUE_ID_SUFFIX_MAP.items(): - if entity_entry.unique_id.endswith(f"-{old_id_suffix}"): - return { - "new_unique_id": entity_entry.unique_id.replace( - old_id_suffix, new_id_suffix - ) - } - return None + @callback + def update_unique_id( + entity_entry: RegistryEntry, + ) -> dict[str, Any] | None: + """Update unique ID of entity entry.""" + for ( + old_id_suffix, + new_id_suffix, + ) in OLD_NEW_UNIQUE_ID_SUFFIX_MAP.items(): + if entity_entry.unique_id.endswith(f"-{old_id_suffix}"): + return { + "new_unique_id": entity_entry.unique_id.replace( + old_id_suffix, new_id_suffix + ) + } + return None - await async_migrate_entries(hass, entry.entry_id, update_unique_id) + await async_migrate_entries(hass, entry.entry_id, update_unique_id) - hass.config_entries.async_update_entry(entry, minor_version=2) + hass.config_entries.async_update_entry(entry, minor_version=2) + case 2: + hass.config_entries.async_update_entry( + entry, + minor_version=3, + unique_id=jwt.decode( + entry.data["token"]["access_token"], + options={"verify_signature": False}, + )["sub"], + ) _LOGGER.debug("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 1f82aa71766..7e4523201f9 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -5,37 +5,18 @@ from typing import cast from aiohomeconnect.model import EventKey, StatusKey -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from .common import setup_home_connect_entry -from .const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, - DOMAIN, - REFRIGERATION_STATUS_DOOR_CLOSED, - REFRIGERATION_STATUS_DOOR_OPEN, -) -from .coordinator import ( - HomeConnectApplianceData, - HomeConnectConfigEntry, - HomeConnectCoordinator, -) +from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity PARALLEL_UPDATES = 0 @@ -106,8 +87,26 @@ BINARY_SENSORS = ( key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, + translation_key="common_chiller_door", + ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, translation_key="chiller_door", ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_LEFT, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, + translation_key="left_chiller_door", + ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_RIGHT, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, + translation_key="right_chiller_door", + ), HomeConnectBinarySensorEntityDescription( key=StatusKey.REFRIGERATION_COMMON_DOOR_FLEX_COMPARTMENT, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, @@ -155,8 +154,6 @@ def _get_entities_for_appliance( for description in BINARY_SENSORS if description.key in appliance.status ) - if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: - entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) return entities @@ -202,81 +199,3 @@ class HomeConnectConnectivityBinarySensor(HomeConnectEntity, BinarySensorEntity) def available(self) -> bool: """Return the availability.""" return self.coordinator.last_update_success - - -class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): - """Binary sensor for Home Connect Generic Door.""" - - _attr_has_entity_name = False - - def __init__( - self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, - ) -> None: - """Initialize the entity.""" - super().__init__( - coordinator, - appliance, - HomeConnectBinarySensorEntityDescription( - key=StatusKey.BSH_COMMON_DOOR_STATE, - device_class=BinarySensorDeviceClass.DOOR, - boolean_map={ - BSH_DOOR_STATE_CLOSED: False, - BSH_DOOR_STATE_LOCKED: False, - BSH_DOOR_STATE_OPEN: True, - }, - ), - ) - self._attr_unique_id = f"{appliance.info.ha_id}-Door" - self._attr_name = f"{appliance.info.name} Door" - - 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_binary_common_door_sensor_{self.entity_id}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_binary_common_door_sensor", - translation_placeholders={ - "entity": 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.""" - await super().async_will_remove_from_hass() - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" - ) diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py index 0a5538ec588..0bd31c6b7c9 100644 --- a/homeassistant/components/home_connect/button.py +++ b/homeassistant/components/home_connect/button.py @@ -1,6 +1,6 @@ """Provides button entities for Home Connect.""" -from aiohomeconnect.model import CommandKey, EventKey +from aiohomeconnect.model import CommandKey from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -94,15 +94,9 @@ class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity): super().__init__( coordinator, appliance, - # The entity is subscribed to the appliance connected event, - # but it will receive also the disconnected event - ButtonEntityDescription( - key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, - ), + desc, + (appliance.info.ha_id,), ) - self.entity_description = desc - self.appliance = appliance - self.unique_id = f"{appliance.info.ha_id}-{desc.key}" def update_native_value(self) -> None: """Set the value of the entity.""" diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index f52b59bc213..cd3fefad80c 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -137,41 +137,6 @@ def setup_home_connect_entry( defaultdict(list) ) - entities: list[HomeConnectEntity] = [] - for appliance in entry.runtime_data.data.values(): - entities_to_add = get_entities_for_appliance(entry, appliance) - if get_option_entities_for_appliance: - entities_to_add.extend(get_option_entities_for_appliance(entry, appliance)) - for event_key in ( - EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, - ): - changed_options_listener_remove_callback = ( - entry.runtime_data.async_add_listener( - partial( - _create_option_entities, - entry, - appliance, - known_entity_unique_ids, - get_option_entities_for_appliance, - async_add_entities, - ), - (appliance.info.ha_id, event_key), - ) - ) - entry.async_on_unload(changed_options_listener_remove_callback) - changed_options_listener_remove_callbacks[appliance.info.ha_id].append( - changed_options_listener_remove_callback - ) - known_entity_unique_ids.update( - { - cast(str, entity.unique_id): appliance.info.ha_id - for entity in entities_to_add - } - ) - entities.extend(entities_to_add) - async_add_entities(entities) - entry.async_on_unload( entry.runtime_data.async_add_special_listener( partial( diff --git a/homeassistant/components/home_connect/config_flow.py b/homeassistant/components/home_connect/config_flow.py index 02a3ca29335..9c7da4d98df 100644 --- a/homeassistant/components/home_connect/config_flow.py +++ b/homeassistant/components/home_connect/config_flow.py @@ -4,10 +4,12 @@ from collections.abc import Mapping import logging from typing import Any +import jwt import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import config_entry_oauth2_flow, device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -19,7 +21,7 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN - MINOR_VERSION = 2 + MINOR_VERSION = 3 @property def logger(self) -> logging.Logger: @@ -45,9 +47,34 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an oauth config entry or update existing entry for reauth.""" + await self.async_set_unique_id( + jwt.decode( + data["token"]["access_token"], options={"verify_signature": False} + )["sub"] + ) if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data_updates=data, + self._get_reauth_entry(), data_updates=data ) + self._abort_if_unique_id_configured() return await super().async_oauth_create_entry(data) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a DHCP discovery.""" + device_registry = dr.async_get(self.hass) + if device_entry := device_registry.async_get_device( + identifiers={ + (DOMAIN, discovery_info.hostname), + (DOMAIN, discovery_info.hostname.split("-")[-1]), + } + ): + device_registry.async_update_device( + device_entry.id, + new_connections={ + (dr.CONNECTION_NETWORK_MAC, discovery_info.macaddress) + }, + ) + return await super().async_step_dhcp(discovery_info) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 66c635f5d95..64bf4af29a4 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -10,6 +10,7 @@ from .utils import bsh_key_to_translation_key DOMAIN = "home_connect" +API_DEFAULT_RETRY_AFTER = 60 APPLIANCES_WITH_PROGRAMS = ( "CleaningRobot", @@ -78,13 +79,6 @@ ATTR_VALUE = "value" AFFECTS_TO_ACTIVE_PROGRAM = "active_program" AFFECTS_TO_SELECTED_PROGRAM = "selected_program" -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_PROGRAM = "program" -SVE_TRANSLATION_PLACEHOLDER_KEY = "key" -SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" - TRANSLATION_KEYS_PROGRAMS_MAP = { bsh_key_to_translation_key(program.value): cast(ProgramKey, program) @@ -284,7 +278,9 @@ SPIN_SPEED_OPTIONS = { "LaundryCare.Washer.EnumType.SpinSpeed.Off", "LaundryCare.Washer.EnumType.SpinSpeed.RPM400", "LaundryCare.Washer.EnumType.SpinSpeed.RPM600", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM700", "LaundryCare.Washer.EnumType.SpinSpeed.RPM800", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM900", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1200", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1400", diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 80ae8173d86..9e40de86e24 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -2,9 +2,10 @@ from __future__ import annotations -import asyncio +from asyncio import sleep as asyncio_sleep from collections import defaultdict from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass import logging from typing import Any, cast @@ -29,6 +30,7 @@ from aiohomeconnect.model.error import ( HomeConnectApiError, HomeConnectError, HomeConnectRequestError, + TooManyRequestsError, UnauthorizedError, ) from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption @@ -36,18 +38,19 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN +from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) -type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] +MAX_EXECUTIONS_TIME_WINDOW = 60 * 60 # 1 hour +MAX_EXECUTIONS = 8 -EVENT_STREAM_RECONNECT_DELAY = 30 +type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] @dataclass(frozen=True, kw_only=True) @@ -74,6 +77,19 @@ class HomeConnectApplianceData: self.settings.update(other.settings) self.status.update(other.status) + @classmethod + def empty(cls, appliance: HomeAppliance) -> HomeConnectApplianceData: + """Return empty data.""" + return cls( + commands=set(), + events={}, + info=appliance, + options={}, + programs=[], + settings={}, + status={}, + ) + class HomeConnectCoordinator( DataUpdateCoordinator[dict[str, HomeConnectApplianceData]] @@ -100,6 +116,8 @@ class HomeConnectCoordinator( CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]] ] = {} self.device_registry = dr.async_get(self.hass) + self.data = {} + self._execution_tracker: dict[str, list[float]] = defaultdict(list) @cached_property def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: @@ -119,8 +137,11 @@ class HomeConnectCoordinator( self.__dict__.pop("context_listeners", None) def remove_listener_and_invalidate_context_listeners() -> None: - remove_listener() - self.__dict__.pop("context_listeners", None) + # There are cases where the remove_listener will be called + # although it has been already removed somewhere else + with suppress(KeyError): + remove_listener() + self.__dict__.pop("context_listeners", None) return remove_listener_and_invalidate_context_listeners @@ -155,12 +176,22 @@ class HomeConnectCoordinator( f"home_connect-events_listener_task-{self.config_entry.entry_id}", ) - async def _event_listener(self) -> None: + async def _event_listener(self) -> None: # noqa: C901 """Match event with listener for event type.""" + retry_time = 10 while True: try: async for event_message in self.client.stream_all_events(): + retry_time = 10 event_message_ha_id = event_message.ha_id + if ( + event_message_ha_id in self.data + and not self.data[event_message_ha_id].info.connected + ): + self.data[event_message_ha_id].info.connected = True + self._call_all_event_listeners_for_appliance( + event_message_ha_id + ) match event_message.type: case EventType.STATUS: statuses = self.data[event_message_ha_id].status @@ -181,7 +212,7 @@ class HomeConnectCoordinator( events = self.data[event_message_ha_id].events for event in event_message.data.items: event_key = event.key - if event_key in SettingKey: + if event_key in SettingKey.__members__.values(): # type: ignore[comparison-overlap] setting_key = SettingKey(event_key) if setting_key in settings: settings[setting_key].value = event.value @@ -211,6 +242,9 @@ class HomeConnectCoordinator( self._call_event_listener(event_message) case EventType.CONNECTED | EventType.PAIRED: + if self.refreshed_too_often_recently(event_message_ha_id): + continue + appliance_info = await self.client.get_specific_appliance( event_message_ha_id ) @@ -218,19 +252,17 @@ class HomeConnectCoordinator( appliance_data = await self._get_appliance_data( appliance_info, self.data.get(appliance_info.ha_id) ) - if event_message_ha_id in self.data: - self.data[event_message_ha_id].update(appliance_data) - else: + if event_message_ha_id not in self.data: self.data[event_message_ha_id] = appliance_data - for listener, context in list( - self._special_listeners.values() - ) + list(self._listeners.values()): - assert isinstance(context, tuple) + for listener, context in self._special_listeners.values(): if ( EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context ): listener() + self._call_all_event_listeners_for_appliance( + event_message_ha_id + ) case EventType.DISCONNECTED: self.data[event_message_ha_id].info.connected = False @@ -256,20 +288,18 @@ class HomeConnectCoordinator( except (EventStreamInterruptedError, HomeConnectRequestError) as error: _LOGGER.debug( "Non-breaking error (%s) while listening for events," - " continuing in 30 seconds", - type(error).__name__, + " continuing in %s seconds", + error, + retry_time, ) - await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY) + await asyncio_sleep(retry_time) + retry_time = min(retry_time * 2, 3600) except HomeConnectApiError as error: _LOGGER.error("Error while listening for events: %s", error) self.hass.config_entries.async_schedule_reload( self.config_entry.entry_id ) break - # if there was a non-breaking error, we continue listening - # but we need to refresh the data to get the possible changes - # that happened while the event stream was interrupted - await self.async_refresh() @callback def _call_event_listener(self, event_message: EventMessage) -> None: @@ -288,6 +318,42 @@ class HomeConnectCoordinator( async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: """Fetch data from Home Connect.""" + await self._async_setup() + + for appliance_data in self.data.values(): + appliance = appliance_data.info + ha_id = appliance.ha_id + while True: + try: + self.data[ha_id] = await self._get_appliance_data( + appliance, self.data.get(ha_id) + ) + except TooManyRequestsError as err: + _LOGGER.debug( + "Rate limit exceeded on initial fetch: %s", + err, + ) + await asyncio_sleep(err.retry_after or API_DEFAULT_RETRY_AFTER) + else: + break + + for listener, context in self._special_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context: + listener() + + return self.data + + async def async_setup(self) -> None: + """Set up the devices.""" + try: + await self._async_setup() + except UpdateFailed as err: + raise ConfigEntryNotReady from err + + async def _async_setup(self) -> None: + """Set up the devices.""" + old_appliances = set(self.data.keys()) try: appliances = await self.client.get_home_appliances() except UnauthorizedError as error: @@ -297,18 +363,45 @@ class HomeConnectCoordinator( translation_placeholders=get_dict_from_home_connect_error(error), ) from error except HomeConnectError as error: + for appliance_data in self.data.values(): + appliance_data.info.connected = False raise UpdateFailed( translation_domain=DOMAIN, translation_key="fetch_api_error", translation_placeholders=get_dict_from_home_connect_error(error), ) from error - return { - appliance.ha_id: await self._get_appliance_data( - appliance, self.data.get(appliance.ha_id) if self.data else None + for appliance in appliances.homeappliances: + self.device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + manufacturer=appliance.brand, + name=appliance.name, + model=appliance.vib, ) - for appliance in appliances.homeappliances - } + if appliance.ha_id not in self.data: + self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance) + else: + self.data[appliance.ha_id].info.connected = appliance.connected + old_appliances.remove(appliance.ha_id) + + for ha_id in old_appliances: + self.data.pop(ha_id, None) + device = self.device_registry.async_get_device( + identifiers={(DOMAIN, ha_id)} + ) + if device: + self.device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + # Trigger to delete the possible depaired device entities + # from known_entities variable at common.py + for listener, context in self._special_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: + listener() async def _get_appliance_data( self, @@ -323,6 +416,15 @@ class HomeConnectCoordinator( name=appliance.name, model=appliance.vib, ) + if not appliance.connected: + _LOGGER.debug( + "Appliance %s is not connected, skipping data fetch", + appliance.ha_id, + ) + if appliance_data_to_update: + appliance_data_to_update.info.connected = False + return appliance_data_to_update + return HomeConnectApplianceData.empty(appliance) try: settings = { setting.key: setting @@ -330,13 +432,13 @@ class HomeConnectCoordinator( await self.client.get_settings(appliance.ha_id) ).settings } + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching settings for %s: %s", appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, + error, ) settings = {} try: @@ -344,13 +446,13 @@ class HomeConnectCoordinator( status.key: status for status in (await self.client.get_status(appliance.ha_id)).status } + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching status for %s: %s", appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, + error, ) status = {} @@ -360,13 +462,13 @@ class HomeConnectCoordinator( if appliance.type in APPLIANCES_WITH_PROGRAMS: try: all_programs = await self.client.get_all_programs(appliance.ha_id) + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching programs for %s: %s", appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, + error, ) else: programs.extend(all_programs.programs) @@ -418,6 +520,8 @@ class HomeConnectCoordinator( await self.client.get_available_commands(appliance.ha_id) ).commands } + except TooManyRequestsError: + raise except HomeConnectError: commands = set() @@ -440,13 +544,27 @@ class HomeConnectCoordinator( self, ha_id: str, program_key: ProgramKey ) -> dict[OptionKey, ProgramDefinitionOption]: """Get options with constraints for appliance.""" - return { - option.key: option - for option in ( - await self.client.get_available_program(ha_id, program_key=program_key) - ).options - or [] - } + if program_key is ProgramKey.UNKNOWN: + return {} + try: + return { + option.key: option + for option in ( + await self.client.get_available_program( + ha_id, program_key=program_key + ) + ).options + or [] + } + except TooManyRequestsError: + raise + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching options for %s: %s", + ha_id, + error, + ) + return {} async def update_options( self, ha_id: str, event_key: EventKey, program_key: ProgramKey @@ -456,8 +574,7 @@ class HomeConnectCoordinator( events = self.data[ha_id].events options_to_notify = options.copy() options.clear() - if program_key is not ProgramKey.UNKNOWN: - options.update(await self.get_options_definitions(ha_id, program_key)) + options.update(await self.get_options_definitions(ha_id, program_key)) for option in options.values(): option_value = option.constraints.default if option.constraints else None @@ -480,3 +597,60 @@ class HomeConnectCoordinator( [], ): listener() + + def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool: + """Check if the appliance data hasn't been refreshed too often recently.""" + + now = self.hass.loop.time() + if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS: + return True + + execution_tracker = self._execution_tracker[appliance_ha_id] = [ + timestamp + for timestamp in self._execution_tracker[appliance_ha_id] + if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW + ] + + execution_tracker.append(now) + + if len(execution_tracker) >= MAX_EXECUTIONS: + ir.async_create_issue( + self.hass, + DOMAIN, + f"home_connect_too_many_connected_paired_events_{appliance_ha_id}", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="home_connect_too_many_connected_paired_events", + data={ + "entry_id": self.config_entry.entry_id, + "appliance_ha_id": appliance_ha_id, + }, + translation_placeholders={ + "appliance_name": self.data[appliance_ha_id].info.name, + "times": str(MAX_EXECUTIONS), + "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), + "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", + "home_assistant_core_new_issue_url": ( + "https://github.com/home-assistant/core/issues/new?template=bug_report.yml" + f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/" + ), + }, + ) + return True + + return False + + async def reset_execution_tracker(self, appliance_ha_id: str) -> None: + """Reset the execution tracker for a specific appliance.""" + self._execution_tracker.pop(appliance_ha_id, None) + appliance_info = await self.client.get_specific_appliance(appliance_ha_id) + + appliance_data = await self._get_appliance_data( + appliance_info, self.data.get(appliance_info.ha_id) + ) + self.data[appliance_ha_id].update(appliance_data) + for listener, context in self._special_listeners.values(): + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context: + listener() + self._call_all_event_listeners_for_appliance(appliance_ha_id) diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index fd74277a815..59856999ec7 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -4,8 +4,6 @@ from __future__ import annotations from typing import Any -from aiohomeconnect.client import Client as HomeConnectClient - from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -14,7 +12,7 @@ from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry async def _generate_appliance_diagnostics( - client: HomeConnectClient, appliance: HomeConnectApplianceData + appliance: HomeConnectApplianceData, ) -> dict[str, Any]: return { **appliance.info.to_dict(), @@ -31,9 +29,7 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return { - appliance.info.ha_id: await _generate_appliance_diagnostics( - entry.runtime_data.client, appliance - ) + appliance.info.ha_id: await _generate_appliance_diagnostics(appliance) for appliance in entry.runtime_data.data.values() } @@ -45,6 +41,4 @@ async def async_get_device_diagnostics( ha_id = next( (identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN), ) - return await _generate_appliance_diagnostics( - entry.runtime_data.client, entry.runtime_data.data[ha_id] - ) + return await _generate_appliance_diagnostics(entry.runtime_data.data[ha_id]) diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 52eaaecace7..a3368ce550c 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,20 +1,28 @@ """Home Connect entity base class.""" from abc import abstractmethod +from collections.abc import Callable, Coroutine import contextlib +from datetime import datetime import logging -from typing import cast +from typing import Any, Concatenate, cast from aiohomeconnect.model import EventKey, OptionKey -from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + TooManyRequestsError, +) +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import API_DEFAULT_RETRY_AFTER, DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator from .utils import get_dict_from_home_connect_error @@ -24,7 +32,6 @@ _LOGGER = logging.getLogger(__name__) class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): """Generic Home Connect entity (base class).""" - _attr_should_poll = False _attr_has_entity_name = True def __init__( @@ -32,9 +39,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): coordinator: HomeConnectCoordinator, appliance: HomeConnectApplianceData, desc: EntityDescription, + context_override: Any | None = None, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key))) + context = (appliance.info.ha_id, EventKey(desc.key)) + if context_override is not None: + context = context_override + super().__init__(coordinator, context) self.appliance = appliance self.entity_description = desc self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" @@ -51,8 +62,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self.update_native_value() + available = self._attr_available = self.appliance.info.connected self.async_write_ha_state() - _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state) + state = STATE_UNAVAILABLE if not available else self.state + _LOGGER.debug("Updated %s, new state: %s", self.entity_id, state) @property def bsh_key(self) -> str: @@ -61,10 +74,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): @property def available(self) -> bool: - """Return True if entity is available.""" - return ( - self.appliance.info.connected and self._attr_available and super().available - ) + """Return True if entity is available. + + Do not use self.last_update_success for available state + as event updates should take precedence over the coordinator + refresh. + """ + return self._attr_available class HomeConnectOptionEntity(HomeConnectEntity): @@ -121,3 +137,34 @@ class HomeConnectOptionEntity(HomeConnectEntity): def bsh_key(self) -> OptionKey: """Return the BSH key.""" return cast(OptionKey, self.entity_description.key) + + +def constraint_fetcher[_EntityT: HomeConnectEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate the function to catch Home Connect too many requests error and retry later. + + If it needs to be called later, it will call async_write_ha_state function + """ + + async def handler_to_return( + self: _EntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + async def handler(_datetime: datetime | None = None) -> None: + try: + await func(self, *args, **kwargs) + except TooManyRequestsError as err: + if (retry_after := err.retry_after) is None: + retry_after = API_DEFAULT_RETRY_AFTER + async_call_later(self.hass, retry_after, handler) + except HomeConnectError as err: + _LOGGER.error( + "Error fetching constraints for %s: %s", self.entity_id, err + ) + else: + if _datetime is not None: + self.async_write_ha_state() + + await handler() + + return handler_to_return diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index f781db3ab24..9b4c9276998 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -113,7 +113,7 @@ "milk_counter": { "default": "mdi:cup" }, - "coffee_and_milk": { + "coffee_and_milk_counter": { "default": "mdi:coffee" }, "ristretto_espresso_counter": { diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 72c6b9aaa2b..b4ea57c63f6 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -21,11 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .common import setup_home_connect_entry -from .const import ( - BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, -) +from .const import BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, @@ -43,11 +39,11 @@ PARALLEL_UPDATES = 1 class HomeConnectLightEntityDescription(LightEntityDescription): """Light entity description.""" - brightness_key: SettingKey | None = None + brightness_key: SettingKey + brightness_scale: tuple[float, float] color_key: SettingKey | None = None enable_custom_color_value_key: str | None = None custom_color_key: SettingKey | None = None - brightness_scale: tuple[float, float] = (0.0, 100.0) LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = ( @@ -164,7 +160,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="turn_on_light", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err if self._color_key and self._custom_color_key: @@ -183,7 +179,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="select_light_custom_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err @@ -201,7 +197,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err return @@ -211,11 +207,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): brightness = round( color_util.brightness_to_value( self._brightness_scale, - kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness), + cast(int, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness)), ) ) - hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) + hs_color = cast( + tuple[float, float], kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) + ) rgb = color_util.color_hsv_to_RGB(hs_color[0], hs_color[1], brightness) hex_val = color_util.color_rgb_to_hex(*rgb) @@ -231,7 +229,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err return @@ -254,7 +252,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_brightness", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err @@ -272,7 +270,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="turn_off_light", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 28714b31679..e550d22e0ca 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -4,9 +4,23 @@ "codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"], "config_flow": true, "dependencies": ["application_credentials", "repairs"], + "dhcp": [ + { + "hostname": "balay-*", + "macaddress": "C8D778*" + }, + { + "hostname": "(bosch|siemens)-*", + "macaddress": "68A40E*" + }, + { + "hostname": "siemens-*", + "macaddress": "38B4D3*" + } + ], "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.15.0"], - "single_config_entry": true + "requirements": ["aiohomeconnect==0.17.0"], + "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index cef35005b32..790036d26f8 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -1,4 +1,4 @@ -"""Provides number enties for Home Connect.""" +"""Provides number entities for Home Connect.""" import logging from typing import cast @@ -11,21 +11,15 @@ 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 AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry -from .const import ( - DOMAIN, - SVE_TRANSLATION_KEY_SET_SETTING, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, - UNIT_MAP, -) +from .const import DOMAIN, UNIT_MAP from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity, HomeConnectOptionEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -33,6 +27,11 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 NUMBERS = ( + NumberEntityDescription( + key=SettingKey.BSH_COMMON_ALARM_CLOCK, + device_class=NumberDeviceClass.DURATION, + translation_key="alarm_clock", + ), NumberEntityDescription( key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, device_class=NumberDeviceClass.TEMPERATURE, @@ -81,7 +80,7 @@ NUMBERS = ( NumberEntityDescription( key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE_PERCENT, translation_key="color_temperature_percent", - native_unit_of_measurement="%", + native_unit_of_measurement=PERCENTAGE, ), NumberEntityDescription( key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL, @@ -180,28 +179,34 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": str(value), }, ) from err + @constraint_fetcher async def async_fetch_constraints(self) -> None: """Fetch the max and min values and step for the number entity.""" - try: + setting_key = cast(SettingKey, self.bsh_key) + data = self.appliance.settings.get(setting_key) + if not data or not data.unit or not data.constraints: data = await self.coordinator.client.get_setting( - self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key) + self.appliance.info.ha_id, setting_key=setting_key ) - except HomeConnectError as err: - _LOGGER.error("An error occurred: %s", err) - else: + if data.unit: + self._attr_native_unit_of_measurement = data.unit self.set_constraints(data) def set_constraints(self, setting: GetSetting) -> None: """Set constraints for the number entity.""" + if setting.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get( + setting.unit, setting.unit + ) if not (constraints := setting.constraints): return if constraints.max: @@ -222,10 +227,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): """When entity is added to hass.""" await super().async_added_to_hass() data = self.appliance.settings[cast(SettingKey, self.bsh_key)] - self._attr_native_unit_of_measurement = data.unit self.set_constraints(data) if ( - not hasattr(self, "_attr_native_min_value") + not hasattr(self, "_attr_native_unit_of_measurement") + or not hasattr(self, "_attr_native_min_value") or not hasattr(self, "_attr_native_max_value") or not hasattr(self, "_attr_native_step") ): @@ -253,7 +258,6 @@ class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity): or candidate_unit != self._attr_native_unit_of_measurement ): self._attr_native_unit_of_measurement = candidate_unit - self.__dict__.pop("unit_of_measurement", None) option_constraints = option_definition.constraints if option_constraints: if ( diff --git a/homeassistant/components/home_connect/repairs.py b/homeassistant/components/home_connect/repairs.py new file mode 100644 index 00000000000..21c6775e549 --- /dev/null +++ b/homeassistant/components/home_connect/repairs.py @@ -0,0 +1,60 @@ +"""Repairs flows for Home Connect.""" + +from typing import cast + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from .coordinator import HomeConnectConfigEntry + + +class EnableApplianceUpdatesFlow(RepairsFlow): + """Handler for enabling appliance's updates after being refreshed too many times.""" + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.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 + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + assert self.data + entry = self.hass.config_entries.async_get_entry( + cast(str, self.data["entry_id"]) + ) + assert entry + entry = cast(HomeConnectConfigEntry, entry) + await entry.runtime_data.reset_execution_tracker( + cast(str, self.data["appliance_ha_id"]) + ) + return self.async_create_entry(data={}) + + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=description_placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id.startswith("home_connect_too_many_connected_paired_events"): + return EnableApplianceUpdatesFlow() + return ConfirmRepairFlow() diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index ef3e2ccbf82..025480828d8 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,8 +1,8 @@ """Provides a select platform for Home Connect.""" from collections.abc import Callable, Coroutine -import contextlib from dataclasses import dataclass +import logging from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient @@ -11,13 +11,12 @@ from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import Execution from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry from .const import ( - APPLIANCES_WITH_PROGRAMS, AVAILABLE_MAPS_ENUM, BEAN_AMOUNT_OPTIONS, BEAN_CONTAINER_OPTIONS, @@ -31,11 +30,6 @@ from .const import ( INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, SPIN_SPEED_OPTIONS, - SVE_TRANSLATION_KEY_SET_SETTING, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, - SVE_TRANSLATION_PLACEHOLDER_VALUE, TEMPERATURE_OPTIONS, TRANSLATION_KEYS_PROGRAMS_MAP, VARIO_PERFECT_OPTIONS, @@ -47,9 +41,11 @@ from .coordinator import ( HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity, HomeConnectOptionEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 1 FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = { @@ -316,7 +312,7 @@ def _get_entities_for_appliance( HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS ] - if appliance.info.type in APPLIANCES_WITH_PROGRAMS + if appliance.programs else [] ), *[ @@ -370,16 +366,37 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): appliance, desc, ) + self.set_options() + + def set_options(self) -> None: + """Set the options for the entity.""" self._attr_options = [ PROGRAMS_TRANSLATION_KEYS_MAP[program.key] - for program in appliance.programs + for program in self.appliance.programs if program.key != ProgramKey.UNKNOWN and ( program.constraints is None - or program.constraints.execution in desc.allowed_executions + or program.constraints.execution + in self.entity_description.allowed_executions ) ] + @callback + def refresh_options(self) -> None: + """Refresh the options for the entity.""" + self.set_options() + self.async_write_ha_state() + + 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.coordinator.async_add_listener( + self.refresh_options, + (self.appliance.info.ha_id, EventKey.BSH_COMMON_APPLIANCE_CONNECTED), + ) + ) + def update_native_value(self) -> None: """Set the program value.""" event = self.appliance.events.get(cast(EventKey, self.bsh_key)) @@ -404,7 +421,7 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): translation_key=self.entity_description.error_translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, + "program": program_key.value, }, ) from err @@ -413,6 +430,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): """Select setting class for Home Connect.""" entity_description: HomeConnectSelectEntityDescription + _original_option_keys: set[str | None] def __init__( self, @@ -421,6 +439,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" + self._original_option_keys = set(desc.values_translation_key) super().__init__( coordinator, appliance, @@ -439,12 +458,12 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: value, + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": value, }, ) from err @@ -458,23 +477,29 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() + await self.async_fetch_options() + + @constraint_fetcher + async def async_fetch_options(self) -> None: + """Fetch options from the API.""" setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key)) if ( not setting or not setting.constraints or not setting.constraints.allowed_values ): - with contextlib.suppress(HomeConnectError): - setting = await self.coordinator.client.get_setting( - self.appliance.info.ha_id, - setting_key=cast(SettingKey, self.bsh_key), - ) + setting = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, + setting_key=cast(SettingKey, self.bsh_key), + ) if setting and setting.constraints and setting.constraints.allowed_values: + self._original_option_keys = set(setting.constraints.allowed_values) self._attr_options = [ self.entity_description.values_translation_key[option] - for option in setting.constraints.allowed_values - if option in self.entity_description.values_translation_key + for option in self._original_option_keys + if option is not None + and option in self.entity_description.values_translation_key ] @@ -491,7 +516,7 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" - self._original_option_keys = set(desc.values_translation_key.keys()) + self._original_option_keys = set(desc.values_translation_key) super().__init__( coordinator, appliance, @@ -524,5 +549,5 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): self.entity_description.values_translation_key[option] for option in self._original_option_keys if option is not None + and option in self.entity_description.values_translation_key ] - self.__dict__.pop("options", None) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 924744ded56..2872c4a95d3 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,12 +1,14 @@ """Provides a sensor for Home Connect.""" -import contextlib +from collections import defaultdict +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta +from functools import partial +import logging from typing import cast from aiohomeconnect.model import EventKey, StatusKey -from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,7 +17,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify @@ -28,7 +30,9 @@ from .const import ( UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, constraint_fetcher + +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -41,7 +45,6 @@ class HomeConnectSensorEntityDescription( ): """Entity Description class for sensors.""" - default_value: str | None = None appliance_types: tuple[str, ...] | None = None fetch_unit: bool = False @@ -52,7 +55,7 @@ BSH_PROGRAM_SENSORS = ( device_class=SensorDeviceClass.TIMESTAMP, translation_key="program_finish_time", appliance_types=( - "CoffeMaker", + "CoffeeMaker", "CookProcessor", "Dishwasher", "Dryer", @@ -156,7 +159,6 @@ SENSORS = ( HomeConnectSensorEntityDescription( key=StatusKey.BSH_COMMON_BATTERY_LEVEL, device_class=SensorDeviceClass.BATTERY, - translation_key="battery_level", ), HomeConnectSensorEntityDescription( key=StatusKey.BSH_COMMON_VIDEO_CAMERA_STATE, @@ -194,58 +196,59 @@ SENSORS = ( EVENT_SENSORS = ( HomeConnectSensorEntityDescription( - key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + key=EventKey.BSH_COMMON_EVENT_PROGRAM_ABORTED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="freezer_door_alarm", - appliance_types=("FridgeFreezer", "Freezer"), + translation_key="program_aborted", + appliance_types=("Dishwasher", "CleaningRobot", "CookProcessor"), ), HomeConnectSensorEntityDescription( - key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, + key=EventKey.BSH_COMMON_EVENT_PROGRAM_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="refrigerator_door_alarm", - appliance_types=("FridgeFreezer", "Refrigerator"), + translation_key="program_finished", + appliance_types=( + "Oven", + "Dishwasher", + "Washer", + "Dryer", + "WasherDryer", + "CleaningRobot", + "CookProcessor", + ), ), HomeConnectSensorEntityDescription( - key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, + key=EventKey.BSH_COMMON_EVENT_ALARM_CLOCK_ELAPSED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="freezer_temperature_alarm", - appliance_types=("FridgeFreezer", "Freezer"), + translation_key="alarm_clock_elapsed", + appliance_types=("Oven", "Cooktop"), ), HomeConnectSensorEntityDescription( - key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + key=EventKey.COOKING_OVEN_EVENT_PREHEAT_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="bean_container_empty", - appliance_types=("CoffeeMaker",), + translation_key="preheat_finished", + appliance_types=("Oven", "Cooktop"), ), HomeConnectSensorEntityDescription( - key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, + key=EventKey.COOKING_OVEN_EVENT_REGULAR_PREHEAT_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="water_tank_empty", - appliance_types=("CoffeeMaker",), + translation_key="regular_preheat_finished", + appliance_types=("Oven",), ), HomeConnectSensorEntityDescription( - key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, + key=EventKey.LAUNDRY_CARE_DRYER_EVENT_DRYING_PROCESS_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", - translation_key="drip_tray_full", - appliance_types=("CoffeeMaker",), + translation_key="drying_process_finished", + appliance_types=("Dryer",), ), HomeConnectSensorEntityDescription( key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="salt_nearly_empty", appliance_types=("Dishwasher",), ), @@ -253,10 +256,219 @@ EVENT_SENSORS = ( key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="rinse_aid_nearly_empty", appliance_types=("Dishwasher",), ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="bean_container_empty", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="water_tank_empty", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="drip_tray_full", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_KEEP_MILK_TANK_COOL, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="keep_milk_tank_cool", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_20_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="descaling_in_20_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_15_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="descaling_in_15_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_10_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="descaling_in_10_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_5_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="descaling_in_5_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_DESCALED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_should_be_descaled", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_OVERDUE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_descaling_overdue", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_BLOCKAGE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_descaling_blockage", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CLEANED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_should_be_cleaned", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CLEANING_OVERDUE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_cleaning_overdue", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN20CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="calc_n_clean_in20cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN15CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="calc_n_clean_in15cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN10CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="calc_n_clean_in10cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN5CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="calc_n_clean_in5cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CALC_N_CLEANED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_should_be_calc_n_cleaned", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_OVERDUE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_calc_n_clean_overdue", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_BLOCKAGE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="device_calc_n_clean_blockage", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="freezer_door_alarm", + appliance_types=("FridgeFreezer", "Freezer"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="refrigerator_door_alarm", + appliance_types=("FridgeFreezer", "Refrigerator"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="freezer_temperature_alarm", + appliance_types=("FridgeFreezer", "Freezer"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_EMPTY_DUST_BOX_AND_CLEAN_FILTER, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="empty_dust_box_and_clean_filter", + appliance_types=("CleaningRobot",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_ROBOT_IS_STUCK, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="robot_is_stuck", + appliance_types=("CleaningRobot",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_DOCKING_STATION_NOT_FOUND, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="docking_station_not_found", + appliance_types=("CleaningRobot",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="poor_i_dos_1_fill_level", + appliance_types=("Washer", "WasherDryer"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_2_FILL_LEVEL_POOR, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="poor_i_dos_2_fill_level", + appliance_types=("Washer", "WasherDryer"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_NEARLY_REACHED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="grease_filter_max_saturation_nearly_reached", + appliance_types=("Hood",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_REACHED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + translation_key="grease_filter_max_saturation_reached", + appliance_types=("Hood",), + ), ) @@ -266,12 +478,6 @@ def _get_entities_for_appliance( ) -> list[HomeConnectEntity]: """Get a list of entities.""" return [ - *[ - HomeConnectEventSensor(entry.runtime_data, appliance, description) - for description in EVENT_SENSORS - if description.appliance_types - and appliance.info.type in description.appliance_types - ], *[ HomeConnectProgramSensor(entry.runtime_data, appliance, desc) for desc in BSH_PROGRAM_SENSORS @@ -285,6 +491,72 @@ def _get_entities_for_appliance( ] +def _add_event_sensor_entity( + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + appliance: HomeConnectApplianceData, + description: HomeConnectSensorEntityDescription, + remove_event_sensor_listener_list: list[Callable[[], None]], +) -> None: + """Add an event sensor entity.""" + if ( + (appliance_data := entry.runtime_data.data.get(appliance.info.ha_id)) is None + ) or description.key not in appliance_data.events: + return + + for remove_listener in remove_event_sensor_listener_list: + remove_listener() + async_add_entities( + [ + HomeConnectEventSensor(entry.runtime_data, appliance, description), + ] + ) + + +def _add_event_sensor_listeners( + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]], +) -> None: + for appliance in entry.runtime_data.data.values(): + if appliance.info.ha_id in remove_event_sensor_listener_dict: + continue + for event_sensor_description in EVENT_SENSORS: + if appliance.info.type not in cast( + tuple[str, ...], event_sensor_description.appliance_types + ): + continue + # We use a list as a kind of lazy initializer, as we can use the + # remove_listener while we are initializing it. + remove_event_sensor_listener_list = remove_event_sensor_listener_dict[ + appliance.info.ha_id + ] + remove_listener = entry.runtime_data.async_add_listener( + partial( + _add_event_sensor_entity, + entry, + async_add_entities, + appliance, + event_sensor_description, + remove_event_sensor_listener_list, + ), + (appliance.info.ha_id, event_sensor_description.key), + ) + remove_event_sensor_listener_list.append(remove_listener) + entry.async_on_unload(remove_listener) + + +def _remove_event_sensor_listeners_on_depaired( + entry: HomeConnectConfigEntry, + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]], +) -> None: + registered_listeners_ha_id = set(remove_event_sensor_listener_dict) + actual_appliances = set(entry.runtime_data.data) + for appliance_ha_id in registered_listeners_ha_id - actual_appliances: + for listener in remove_event_sensor_listener_dict.pop(appliance_ha_id): + listener() + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -297,6 +569,32 @@ async def async_setup_entry( async_add_entities, ) + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]] = defaultdict( + list + ) + + entry.async_on_unload( + entry.runtime_data.async_add_special_listener( + partial( + _add_event_sensor_listeners, + entry, + async_add_entities, + remove_event_sensor_listener_dict, + ), + (EventKey.BSH_COMMON_APPLIANCE_PAIRED,), + ) + ) + entry.async_on_unload( + entry.runtime_data.async_add_special_listener( + partial( + _remove_event_sensor_listeners_on_depaired, + entry, + remove_event_sensor_listener_dict, + ), + (EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,), + ) + ) + class HomeConnectSensor(HomeConnectEntity, SensorEntity): """Sensor class for Home Connect.""" @@ -335,16 +633,14 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): else: await self.fetch_unit() + @constraint_fetcher async def fetch_unit(self) -> None: """Fetch the unit of measurement.""" - with contextlib.suppress(HomeConnectError): - data = await self.coordinator.client.get_status_value( - self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key) - ) - if data.unit: - self._attr_native_unit_of_measurement = UNIT_MAP.get( - data.unit, data.unit - ) + data = await self.coordinator.client.get_status_value( + self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key) + ) + if data.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get(data.unit, data.unit) class HomeConnectProgramSensor(HomeConnectSensor): @@ -386,6 +682,13 @@ class HomeConnectProgramSensor(HomeConnectSensor): def update_native_value(self) -> None: """Update the program sensor's status.""" + self.program_running = ( + status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) + ) is not None and status.value in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] event = self.appliance.events.get(cast(EventKey, self.bsh_key)) if event: self._update_native_value(event.value) @@ -396,8 +699,5 @@ class HomeConnectEventSensor(HomeConnectSensor): def update_native_value(self) -> None: """Update the sensor's status.""" - event = self.appliance.events.get(cast(EventKey, self.bsh_key)) - if event: - self._update_native_value(event.value) - elif not self._attr_native_value: - self._attr_native_value = self.entity_description.default_value + event = self.appliance.events[cast(EventKey, self.bsh_key)] + self._update_native_value(event.value) diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py new file mode 100644 index 00000000000..fac1c5fe1a9 --- /dev/null +++ b/homeassistant/components/home_connect/services.py @@ -0,0 +1,572 @@ +"""Custom actions (previously known as services) for the Home Connect integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable +from typing import Any, cast + +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + ArrayOfOptions, + CommandKey, + Option, + OptionKey, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import ( + AFFECTS_TO_ACTIVE_PROGRAM, + AFFECTS_TO_SELECTED_PROGRAM, + ATTR_AFFECTS_TO, + ATTR_KEY, + ATTR_PROGRAM, + ATTR_UNIT, + ATTR_VALUE, + DOMAIN, + PROGRAM_ENUM_OPTIONS, + SERVICE_OPTION_ACTIVE, + SERVICE_OPTION_SELECTED, + SERVICE_PAUSE_PROGRAM, + SERVICE_RESUME_PROGRAM, + SERVICE_SELECT_PROGRAM, + SERVICE_SET_PROGRAM_AND_OPTIONS, + SERVICE_SETTING, + SERVICE_START_PROGRAM, + TRANSLATION_KEYS_PROGRAMS_MAP, +) +from .coordinator import HomeConnectConfigEntry +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +PROGRAM_OPTIONS = { + bsh_key_to_translation_key(key): ( + key, + value, + ) + for key, value in { + OptionKey.BSH_COMMON_DURATION: int, + OptionKey.BSH_COMMON_START_IN_RELATIVE: int, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, + OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, + OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool, + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool, + OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, + OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, + }.items() +} + + +SERVICE_SETTING_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(SettingKey), + vol.NotIn([SettingKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + } +) + +# DEPRECATED: Remove in 2025.9.0 +SERVICE_OPTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(OptionKey), + vol.NotIn([OptionKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + vol.Optional(ATTR_UNIT): str, + } +) + +# DEPRECATED: Remove in 2025.9.0 +SERVICE_PROGRAM_SCHEMA = vol.Any( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): vol.All( + vol.Coerce(ProgramKey), + vol.NotIn([ProgramKey.UNKNOWN]), + ), + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(OptionKey), + vol.NotIn([OptionKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(int, str), + vol.Optional(ATTR_UNIT): str, + }, + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): vol.All( + vol.Coerce(ProgramKey), + vol.NotIn([ProgramKey.UNKNOWN]), + ), + }, +) + + +def _require_program_or_at_least_one_option(data: dict) -> dict: + if ATTR_PROGRAM not in data and not any( + option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS) + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="required_program_or_one_option_at_least", + ) + return data + + +SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_AFFECTS_TO): vol.In( + [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM] + ), + vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()), + } + ) + .extend( + { + vol.Optional(translation_key): vol.In(allowed_values.keys()) + for translation_key, ( + key, + allowed_values, + ) in PROGRAM_ENUM_OPTIONS.items() + } + ) + .extend( + { + vol.Optional(translation_key): schema + for translation_key, (key, schema) in PROGRAM_OPTIONS.items() + } + ), + _require_program_or_at_least_one_option, +) + +SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) + + +async def _get_client_and_ha_id( + hass: HomeAssistant, device_id: str +) -> tuple[HomeConnectClient, str]: + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + entry: HomeConnectConfigEntry | None = None + for entry_id in device_entry.config_entries: + _entry = hass.config_entries.async_get_entry(entry_id) + assert _entry + if _entry.domain == DOMAIN: + entry = cast(HomeConnectConfigEntry, _entry) + break + if entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + + ha_id = next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if ha_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="appliance_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + return entry.runtime_data.client, ha_id + + +async def _async_service_program(call: ServiceCall, start: bool) -> None: + """Execute calls to services taking a program.""" + program = call.data[ATTR_PROGRAM] + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + option_key = call.data.get(ATTR_KEY) + options = ( + [ + Option( + option_key, + call.data[ATTR_VALUE], + unit=call.data.get(ATTR_UNIT), + ) + ] + if option_key is not None + else None + ) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_PROGRAM}: {program}", + *([f" {ATTR_KEY}: {options[0].key}"] if options else []), + *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), + *( + [f" {ATTR_UNIT}: {options[0].unit}"] + if options and options[0].unit + else [] + ), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", + f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", + *( + [ + f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" + ] + if options + else [] + ), + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + + try: + if start: + await client.start_program(ha_id, program_key=program, options=options) + else: + await client.set_selected_program( + ha_id, program_key=program, options=options + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_program" if start else "select_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "program": program, + }, + ) from err + + +async def _async_service_set_program_options(call: ServiceCall, active: bool) -> None: + """Execute calls to services taking a program.""" + option_key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + unit = call.data.get(ATTR_UNIT) + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_KEY}: {option_key}", + f" {ATTR_VALUE}: {value}", + *([f" {ATTR_UNIT}: {unit}"] if unit else []), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", + f" {bsh_key_to_translation_key(option_key)}: {value}", + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + try: + if active: + await client.set_active_program_option( + ha_id, + option_key=option_key, + value=value, + unit=unit, + ) + else: + await client.set_selected_program_option( + ha_id, + option_key=option_key, + value=value, + unit=unit, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_options_active_program" + if active + else "set_options_selected_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "key": option_key, + "value": str(value), + }, + ) from err + + +async def _async_service_command(call: ServiceCall, command_key: CommandKey) -> None: + """Execute calls to services executing a command.""" + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_command_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_command_actions", + ) + + try: + await client.put_command(ha_id, command_key=command_key, value=True) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "command": command_key.value, + }, + ) from err + + +async def async_service_option_active(call: ServiceCall) -> None: + """Service for setting an option for an active program.""" + await _async_service_set_program_options(call, True) + + +async def async_service_option_selected(call: ServiceCall) -> None: + """Service for setting an option for a selected program.""" + await _async_service_set_program_options(call, False) + + +async def async_service_setting(call: ServiceCall) -> None: + """Service for changing a setting.""" + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + try: + await client.set_setting(ha_id, setting_key=key, value=value) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_setting", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "key": key, + "value": str(value), + }, + ) from err + + +async def async_service_pause_program(call: ServiceCall) -> None: + """Service for pausing a program.""" + await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) + + +async def async_service_resume_program(call: ServiceCall) -> None: + """Service for resuming a paused program.""" + await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) + + +async def async_service_select_program(call: ServiceCall) -> None: + """Service for selecting a program.""" + await _async_service_program(call, False) + + +async def async_service_set_program_and_options(call: ServiceCall) -> None: + """Service for setting a program and options.""" + data = dict(call.data) + program = data.pop(ATTR_PROGRAM, None) + affects_to = data.pop(ATTR_AFFECTS_TO) + client, ha_id = await _get_client_and_ha_id(call.hass, data.pop(ATTR_DEVICE_ID)) + + options: list[Option] = [] + + for option, value in data.items(): + if option in PROGRAM_ENUM_OPTIONS: + options.append( + Option( + PROGRAM_ENUM_OPTIONS[option][0], + PROGRAM_ENUM_OPTIONS[option][1][value], + ) + ) + elif option in PROGRAM_OPTIONS: + option_key = PROGRAM_OPTIONS[option][0] + options.append(Option(option_key, value)) + + method_call: Awaitable[Any] + exception_translation_key: str + if program: + program = ( + program + if isinstance(program, ProgramKey) + else TRANSLATION_KEYS_PROGRAMS_MAP[program] + ) + + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.start_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "start_program" + elif affects_to == AFFECTS_TO_SELECTED_PROGRAM: + method_call = client.set_selected_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "select_program" + else: + array_of_options = ArrayOfOptions(options) + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.set_active_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_active_program" + else: + # affects_to is AFFECTS_TO_SELECTED_PROGRAM + method_call = client.set_selected_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_selected_program" + + try: + await method_call + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=exception_translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + **({"program": program} if program else {}), + }, + ) from err + + +async def async_service_start_program(call: ServiceCall) -> None: + """Service for starting a program.""" + await _async_service_program(call, True) + + +def register_actions(hass: HomeAssistant) -> None: + """Register custom actions.""" + + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_ACTIVE, + async_service_option_active, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_SELECTED, + async_service_option_selected, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_PAUSE_PROGRAM, + async_service_pause_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_RESUME_PROGRAM, + async_service_resume_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SELECT_PROGRAM, + async_service_select_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_START_PROGRAM, + async_service_start_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROGRAM_AND_OPTIONS, + async_service_set_program_and_options, + schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, + ) diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 91b0089d653..e07e8e91457 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -64,7 +64,6 @@ set_program_and_options: - selected_program program: example: dishcare_dishwasher_program_auto2 - required: true selector: select: mode: dropdown @@ -468,11 +467,11 @@ set_program_and_options: translation_key: venting_level options: - cooking_hood_enum_type_stage_fan_off - - cooking_hood_enum_type_stage_fan_stage01 - - cooking_hood_enum_type_stage_fan_stage02 - - cooking_hood_enum_type_stage_fan_stage03 - - cooking_hood_enum_type_stage_fan_stage04 - - cooking_hood_enum_type_stage_fan_stage05 + - cooking_hood_enum_type_stage_fan_stage_01 + - cooking_hood_enum_type_stage_fan_stage_02 + - cooking_hood_enum_type_stage_fan_stage_03 + - cooking_hood_enum_type_stage_fan_stage_04 + - cooking_hood_enum_type_stage_fan_stage_05 cooking_hood_option_intensive_level: example: cooking_hood_enum_type_intensive_stage_intensive_stage1 required: false @@ -528,7 +527,7 @@ set_program_and_options: collapsed: true fields: laundry_care_washer_option_temperature: - example: laundry_care_washer_enum_type_temperature_g_c40 + example: laundry_care_washer_enum_type_temperature_g_c_40 required: false selector: select: @@ -536,14 +535,14 @@ set_program_and_options: translation_key: washer_temperature options: - laundry_care_washer_enum_type_temperature_cold - - laundry_care_washer_enum_type_temperature_g_c20 - - laundry_care_washer_enum_type_temperature_g_c30 - - laundry_care_washer_enum_type_temperature_g_c40 - - laundry_care_washer_enum_type_temperature_g_c50 - - laundry_care_washer_enum_type_temperature_g_c60 - - laundry_care_washer_enum_type_temperature_g_c70 - - laundry_care_washer_enum_type_temperature_g_c80 - - laundry_care_washer_enum_type_temperature_g_c90 + - laundry_care_washer_enum_type_temperature_g_c_20 + - laundry_care_washer_enum_type_temperature_g_c_30 + - laundry_care_washer_enum_type_temperature_g_c_40 + - laundry_care_washer_enum_type_temperature_g_c_50 + - laundry_care_washer_enum_type_temperature_g_c_60 + - laundry_care_washer_enum_type_temperature_g_c_70 + - laundry_care_washer_enum_type_temperature_g_c_80 + - laundry_care_washer_enum_type_temperature_g_c_90 - laundry_care_washer_enum_type_temperature_ul_cold - laundry_care_washer_enum_type_temperature_ul_warm - laundry_care_washer_enum_type_temperature_ul_hot @@ -557,13 +556,15 @@ set_program_and_options: translation_key: spin_speed options: - laundry_care_washer_enum_type_spin_speed_off - - laundry_care_washer_enum_type_spin_speed_r_p_m400 - - laundry_care_washer_enum_type_spin_speed_r_p_m600 - - laundry_care_washer_enum_type_spin_speed_r_p_m800 - - laundry_care_washer_enum_type_spin_speed_r_p_m1000 - - laundry_care_washer_enum_type_spin_speed_r_p_m1200 - - laundry_care_washer_enum_type_spin_speed_r_p_m1400 - - laundry_care_washer_enum_type_spin_speed_r_p_m1600 + - laundry_care_washer_enum_type_spin_speed_r_p_m_400 + - laundry_care_washer_enum_type_spin_speed_r_p_m_600 + - laundry_care_washer_enum_type_spin_speed_r_p_m_700 + - laundry_care_washer_enum_type_spin_speed_r_p_m_800 + - laundry_care_washer_enum_type_spin_speed_r_p_m_900 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1000 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1200 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1400 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1600 - laundry_care_washer_enum_type_spin_speed_ul_off - laundry_care_washer_enum_type_spin_speed_ul_low - laundry_care_washer_enum_type_spin_speed_ul_medium diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7b06128dbe6..3fc509e79f3 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -14,13 +14,15 @@ } }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "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%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "wrong_account": "Please ensure you reconfigure against the same account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -110,17 +112,71 @@ } }, "issues": { - "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." + "home_connect_too_many_connected_paired_events": { + "title": "{appliance_name} sent too many connected or paired events", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", + "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})." + } + } + } + }, + "deprecated_time_alarm_clock_in_automations_scripts": { + "title": "Deprecated alarm clock entity detected in some automations or scripts", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_time_alarm_clock_in_automations_scripts::title%]", + "description": "The alarm clock entity `{entity_id}`, which is deprecated because it's being moved to the `number` platform, is used in the following automations or scripts:\n{items}\n\nPlease, fix this issue by updating your automations or scripts to use the new `number` entity." + } + } + } + }, + "deprecated_time_alarm_clock": { + "title": "Deprecated alarm clock entity", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_time_alarm_clock::title%]", + "description": "The alarm clock entity `{entity_id}` is deprecated because it's being moved to the `number` platform.\n\nPlease use the new `number` entity." + } + } + } }, "deprecated_command_actions": { "title": "The command related actions are deprecated in favor of the new buttons", - "description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue." + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_command_actions::title%]", + "description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue." + } + } + } + }, + "deprecated_program_switch_in_automations_scripts": { + "title": "Deprecated program switch detected in some automations or scripts", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_program_switch_in_automations_scripts::title%]", + "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program 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 switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue." + "title": "Deprecated program switch entities", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_program_switch::title%]", + "description": "The switch entity `{entity_id}` and all the other program switches are deprecated.\n\nPlease use the active program select entity instead." + } + } + } }, "deprecated_set_program_and_option_actions": { "title": "The executed action is deprecated", @@ -178,7 +234,7 @@ "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_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", @@ -196,7 +252,7 @@ "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_kurz_60": "Speed 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", @@ -417,11 +473,11 @@ "venting_level": { "options": { "cooking_hood_enum_type_stage_fan_off": "Fan off", - "cooking_hood_enum_type_stage_fan_stage01": "Fan stage 1", - "cooking_hood_enum_type_stage_fan_stage02": "Fan stage 2", - "cooking_hood_enum_type_stage_fan_stage03": "Fan stage 3", - "cooking_hood_enum_type_stage_fan_stage04": "Fan stage 4", - "cooking_hood_enum_type_stage_fan_stage05": "Fan stage 5" + "cooking_hood_enum_type_stage_fan_stage_01": "Fan stage 1", + "cooking_hood_enum_type_stage_fan_stage_02": "Fan stage 2", + "cooking_hood_enum_type_stage_fan_stage_03": "Fan stage 3", + "cooking_hood_enum_type_stage_fan_stage_04": "Fan stage 4", + "cooking_hood_enum_type_stage_fan_stage_05": "Fan stage 5" } }, "intensive_level": { @@ -433,22 +489,22 @@ }, "warming_level": { "options": { - "cooking_oven_enum_type_warming_level_low": "Low", - "cooking_oven_enum_type_warming_level_medium": "Medium", - "cooking_oven_enum_type_warming_level_high": "High" + "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]" } }, "washer_temperature": { "options": { "laundry_care_washer_enum_type_temperature_cold": "Cold", - "laundry_care_washer_enum_type_temperature_g_c20": "20ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c30": "30ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c40": "40ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c50": "50ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c60": "60ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c70": "70ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c80": "80ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c90": "90ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_20": "20ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_30": "30ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_40": "40ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_50": "50ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_60": "60ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_70": "70ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_80": "80ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_90": "90ºC clothes", "laundry_care_washer_enum_type_temperature_ul_cold": "Cold", "laundry_care_washer_enum_type_temperature_ul_warm": "Warm", "laundry_care_washer_enum_type_temperature_ul_hot": "Hot", @@ -457,23 +513,25 @@ }, "spin_speed": { "options": { - "laundry_care_washer_enum_type_spin_speed_off": "Off", - "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "1600 rpm", - "laundry_care_washer_enum_type_spin_speed_ul_off": "Off", - "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", - "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", - "laundry_care_washer_enum_type_spin_speed_ul_high": "High" + "laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_800": "800 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_900": "900 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "1000 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm", + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]" } }, "vario_perfect": { "options": { - "laundry_care_common_enum_type_vario_perfect_off": "Off", + "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]", "laundry_care_common_enum_type_vario_perfect_eco_perfect": "Eco perfect", "laundry_care_common_enum_type_vario_perfect_speed_perfect": "Speed perfect" } @@ -809,9 +867,18 @@ "bottle_cooler_door": { "name": "Bottle cooler door" }, + "common_chiller_door": { + "name": "Common chiller door" + }, "chiller_door": { "name": "Chiller door" }, + "left_chiller_door": { + "name": "Left chiller door" + }, + "right_chiller_door": { + "name": "Right chiller door" + }, "flex_compartment_door": { "name": "Flex compartment door" }, @@ -857,6 +924,9 @@ } }, "number": { + "alarm_clock": { + "name": "Alarm clock" + }, "refrigerator_setpoint_temperature": { "name": "Refrigerator temperature" }, @@ -1382,11 +1452,11 @@ "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]", "state": { "cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]", - "cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]", - "cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]", - "cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]", - "cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]", - "cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]" + "cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]", + "cooking_hood_enum_type_stage_fan_stage_02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_02%]", + "cooking_hood_enum_type_stage_fan_stage_03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_03%]", + "cooking_hood_enum_type_stage_fan_stage_04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_04%]", + "cooking_hood_enum_type_stage_fan_stage_05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_05%]" } }, "intensive_level": { @@ -1400,23 +1470,23 @@ "warming_level": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]", "state": { - "cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]", - "cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]", - "cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]" + "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]" } }, "washer_temperature": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]", "state": { "laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]", - "laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]", - "laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]", - "laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]", - "laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]", - "laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]", - "laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]", - "laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]", - "laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]", + "laundry_care_washer_enum_type_temperature_g_c_20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_20%]", + "laundry_care_washer_enum_type_temperature_g_c_30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_30%]", + "laundry_care_washer_enum_type_temperature_g_c_40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_40%]", + "laundry_care_washer_enum_type_temperature_g_c_50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_50%]", + "laundry_care_washer_enum_type_temperature_g_c_60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_60%]", + "laundry_care_washer_enum_type_temperature_g_c_70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_70%]", + "laundry_care_washer_enum_type_temperature_g_c_80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_80%]", + "laundry_care_washer_enum_type_temperature_g_c_90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_90%]", "laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]", "laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]", "laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]", @@ -1426,24 +1496,26 @@ "spin_speed": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]", "state": { - "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]", - "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]", - "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", - "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", - "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]" + "laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_800%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_900%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1000%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]", + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]" } }, "vario_perfect": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]", "state": { - "laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]", + "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]", "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]", "laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]" } @@ -1466,7 +1538,7 @@ "pause": "[%key:common::state::paused%]", "actionrequired": "Action required", "finished": "Finished", - "error": "Error", + "error": "[%key:common::state::error%]", "aborting": "Aborting" } }, @@ -1479,34 +1551,39 @@ } }, "coffee_counter": { - "name": "Coffees" + "name": "Coffees", + "unit_of_measurement": "coffees" }, "powder_coffee_counter": { - "name": "Powder coffees" + "name": "Powder coffees", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::coffee_counter::unit_of_measurement%]" }, "hot_water_counter": { "name": "Hot water" }, "hot_water_cups_counter": { - "name": "Hot water cups" + "name": "Hot water cups", + "unit_of_measurement": "cups" }, "hot_milk_counter": { - "name": "Hot milk cups" + "name": "Hot milk cups", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::hot_water_cups_counter::unit_of_measurement%]" }, "frothy_milk_counter": { - "name": "Frothy milk cups" + "name": "Frothy milk cups", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::hot_water_cups_counter::unit_of_measurement%]" }, "milk_counter": { - "name": "Milk cups" + "name": "Milk cups", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::hot_water_cups_counter::unit_of_measurement%]" }, "coffee_and_milk_counter": { - "name": "Coffee and milk cups" + "name": "Coffee and milk cups", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::hot_water_cups_counter::unit_of_measurement%]" }, "ristretto_espresso_counter": { - "name": "Ristretto espresso cups" - }, - "battery_level": { - "name": "Battery level" + "name": "Ristretto espresso cups", + "unit_of_measurement": "[%key:component::home_connect::entity::sensor::hot_water_cups_counter::unit_of_measurement%]" }, "camera_state": { "name": "Camera state", @@ -1517,7 +1594,7 @@ "streaminglocal": "Streaming local", "streamingcloud": "Streaming cloud", "streaminglocal_and_cloud": "Streaming local and cloud", - "error": "Error" + "error": "[%key:common::state::error%]" } }, "last_selected_map": { @@ -1532,23 +1609,64 @@ "oven_current_cavity_temperature": { "name": "Current oven cavity temperature" }, - "freezer_door_alarm": { - "name": "Freezer door alarm", - "state": { - "confirmed": "[%key:component::home_connect::common::confirmed%]", - "present": "[%key:component::home_connect::common::present%]" - } - }, - "refrigerator_door_alarm": { - "name": "Refrigerator door alarm", + "program_aborted": { + "name": "Program aborted", "state": { "off": "[%key:common::state::off%]", "confirmed": "[%key:component::home_connect::common::confirmed%]", "present": "[%key:component::home_connect::common::present%]" } }, - "freezer_temperature_alarm": { - "name": "Freezer temperature alarm", + "program_finished": { + "name": "Program finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "alarm_clock_elapsed": { + "name": "Alarm clock elapsed", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "preheat_finished": { + "name": "Pre-heat finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "regular_preheat_finished": { + "name": "Regular pre-heat finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "drying_process_finished": { + "name": "Drying process finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "salt_nearly_empty": { + "name": "Salt nearly empty", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "rinse_aid_nearly_empty": { + "name": "Rinse aid nearly empty", "state": { "off": "[%key:common::state::off%]", "confirmed": "[%key:component::home_connect::common::confirmed%]", @@ -1579,16 +1697,216 @@ "present": "[%key:component::home_connect::common::present%]" } }, - "salt_nearly_empty": { - "name": "Salt nearly empty", + "keep_milk_tank_cool": { + "name": "Keep milk tank cool", "state": { "off": "[%key:common::state::off%]", "confirmed": "[%key:component::home_connect::common::confirmed%]", "present": "[%key:component::home_connect::common::present%]" } }, - "rinse_aid_nearly_empty": { - "name": "Rinse aid nearly empty", + "descaling_in_20_cups": { + "name": "Descaling in 20 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "descaling_in_15_cups": { + "name": "Descaling in 15 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "descaling_in_10_cups": { + "name": "Descaling in 10 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "descaling_in_5_cups": { + "name": "Descaling in 5 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_should_be_descaled": { + "name": "Device should be descaled", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_descaling_overdue": { + "name": "Device descaling overdue", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_descaling_blockage": { + "name": "Device descaling blockage", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_should_be_cleaned": { + "name": "Device should be cleaned", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_cleaning_overdue": { + "name": "Device cleaning overdue", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in20cups": { + "name": "Calc'N'Clean in 20 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in15cups": { + "name": "Calc'N'Clean in 15 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in10cups": { + "name": "Calc'N'Clean in 10 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in5cups": { + "name": "Calc'N'Clean in 5 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_should_be_calc_n_cleaned": { + "name": "Device should be Calc'N'Cleaned", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_calc_n_clean_overdue": { + "name": "Device Calc'N'Clean overdue", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_calc_n_clean_blockage": { + "name": "Device Calc'N'Clean blockage", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "freezer_door_alarm": { + "name": "Freezer door alarm", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "refrigerator_door_alarm": { + "name": "Refrigerator door alarm", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "freezer_temperature_alarm": { + "name": "Freezer temperature alarm", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "empty_dust_box_and_clean_filter": { + "name": "Empty dust box and clean filter", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "robot_is_stuck": { + "name": "Robot is stuck", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "docking_station_not_found": { + "name": "Docking station not found", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "poor_i_dos_1_fill_level": { + "name": "Poor i-Dos 1 fill level", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "poor_i_dos_2_fill_level": { + "name": "Poor i-Dos 2 fill level", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "grease_filter_max_saturation_nearly_reached": { + "name": "Grease filter max saturation nearly reached", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "grease_filter_max_saturation_reached": { + "name": "Grease filter max saturation reached", "state": { "off": "[%key:common::state::off%]", "confirmed": "[%key:component::home_connect::common::confirmed%]", diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 6f9aa0e679f..cb032a5815d 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -3,40 +3,18 @@ import logging from typing import Any, cast -from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey +from aiohomeconnect.model import OptionKey, SettingKey from aiohomeconnect.model.error import HomeConnectError -from aiohomeconnect.model.program import EnumerateProgram -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 HomeAssistantError -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .common import setup_home_connect_entry -from .const import ( - BSH_POWER_OFF, - BSH_POWER_ON, - BSH_POWER_STANDBY, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, -) -from .coordinator import ( - HomeConnectApplianceData, - HomeConnectConfigEntry, - HomeConnectCoordinator, -) +from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error @@ -163,11 +141,6 @@ def _get_entities_for_appliance( ) -> list[HomeConnectEntity]: """Get a list of entities.""" entities: list[HomeConnectEntity] = [] - entities.extend( - HomeConnectProgramSwitch(entry.runtime_data, appliance, program) - for program in appliance.programs - if program.key != ProgramKey.UNKNOWN - ) if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings: entities.append( HomeConnectPowerSwitch( @@ -226,8 +199,8 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_key="turn_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, + "entity_id": self.entity_id, + "key": self.bsh_key, }, ) from err @@ -246,8 +219,8 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_key="turn_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, + "entity_id": self.entity_id, + "key": self.bsh_key, }, ) from err @@ -256,115 +229,6 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value -class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): - """Switch class for Home Connect.""" - - def __init__( - self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, - program: EnumerateProgram, - ) -> None: - """Initialize the entity.""" - desc = " ".join(["Program", program.key.split(".")[-1]]) - if appliance.info.type == "WasherDryer": - desc = " ".join( - ["Program", program.key.split(".")[-3], program.key.split(".")[-1]] - ) - self.program = program - super().__init__( - coordinator, - appliance, - SwitchEntityDescription(key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM), - ) - self._attr_name = f"{appliance.info.name} {desc}" - self._attr_unique_id = f"{appliance.info.ha_id}-{desc}" - self._attr_has_entity_name = False - - 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.""" - try: - await self.coordinator.client.start_program( - self.appliance.info.ha_id, program_key=self.program.key - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="start_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "program": self.program.key, - }, - ) from err - - async def async_turn_off(self, **kwargs: Any) -> None: - """Stop the program.""" - try: - await self.coordinator.client.stop_program(self.appliance.info.ha_id) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="stop_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - }, - ) from err - - def update_native_value(self) -> None: - """Update the switch's status based on if the program related to this entity is currently active.""" - event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM) - self._attr_is_on = bool(event and event.value == self.program.key) - - class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): """Power switch class for Home Connect.""" @@ -385,7 +249,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, + "appliance_name": self.appliance.info.name, }, ) from err @@ -398,7 +262,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_domain=DOMAIN, translation_key="unable_to_retrieve_turn_off", translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name + "appliance_name": self.appliance.info.name }, ) @@ -406,9 +270,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off_not_supported", - translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name - }, + translation_placeholders={"appliance_name": self.appliance.info.name}, ) try: await self.coordinator.client.set_setting( @@ -423,8 +285,8 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, - SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state, + "appliance_name": self.appliance.info.name, + "value": self.power_off_state, }, ) from err diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index a1761219d30..6a6e57c4dd3 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -1,4 +1,4 @@ -"""Provides time enties for Home Connect.""" +"""Provides time entities for Home Connect.""" from datetime import time from typing import cast @@ -6,19 +6,21 @@ from typing import cast from aiohomeconnect.model import SettingKey from aiohomeconnect.model.error import HomeConnectError +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from .common import setup_home_connect_entry -from .const import ( - DOMAIN, - SVE_TRANSLATION_KEY_SET_SETTING, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, -) +from .const import DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error @@ -29,6 +31,7 @@ TIME_ENTITIES = ( TimeEntityDescription( key=SettingKey.BSH_COMMON_ALARM_CLOCK, translation_key="alarm_clock", + entity_registry_enabled_default=False, ), ) @@ -73,8 +76,78 @@ def time_to_seconds(t: time) -> int: class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): """Time setting class for Home Connect.""" + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + if self.bsh_key is SettingKey.BSH_COMMON_ALARM_CLOCK: + 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_time_alarm_clock_in_automations_scripts_{self.entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_time_alarm_clock", + 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.""" + if self.bsh_key is SettingKey.BSH_COMMON_ALARM_CLOCK: + async_delete_issue( + self.hass, + DOMAIN, + f"deprecated_time_alarm_clock_in_automations_scripts_{self.entity_id}", + ) + async_delete_issue( + self.hass, DOMAIN, f"deprecated_time_alarm_clock_{self.entity_id}" + ) + async def async_set_value(self, value: time) -> None: """Set the native value of the entity.""" + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_time_alarm_clock_{self.entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_time_alarm_clock", + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) try: await self.coordinator.client.set_setting( self.appliance.info.ha_id, @@ -84,12 +157,12 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": str(value), }, ) from err diff --git a/homeassistant/components/home_connect/utils.py b/homeassistant/components/home_connect/utils.py index 108465072e1..ee5febb3cf7 100644 --- a/homeassistant/components/home_connect/utils.py +++ b/homeassistant/components/home_connect/utils.py @@ -2,7 +2,7 @@ import re -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError +from aiohomeconnect.model.error import HomeConnectError RE_CAMEL_CASE = re.compile(r"(? dict[str, str]: """Return a translation string from a Home Connect error.""" - return { - "error": str(err) - if isinstance(err, HomeConnectApiError) - else type(err).__name__ - } + return {"error": str(err)} def bsh_key_to_translation_key(bsh_key: str) -> str: diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 897b7d50e31..372f4fa9955 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -61,7 +61,7 @@ reload_config_entry: required: false example: 8955375327824e14ba89e4b29cc3ec9a selector: - text: + config_entry: save_persistent_states: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 590afd697b5..b8b5f77cf52 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -12,7 +12,7 @@ }, "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." + "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", @@ -111,8 +111,8 @@ "description": "Checks the Home Assistant YAML-configuration files for errors. Errors will be shown in the Home Assistant logs." }, "reload_core_config": { - "name": "Reload core configuration", - "description": "Reloads the core configuration from the YAML-configuration." + "name": "Reload Core configuration", + "description": "Reloads the Core configuration from the YAML-configuration." }, "restart": { "name": "[%key:common::action::restart%]", @@ -160,7 +160,7 @@ }, "update_entity": { "name": "Update entity", - "description": "Forces one or more entities to update its data.", + "description": "Forces one or more entities to update their data.", "fields": { "entity_id": { "name": "Entities to update", @@ -188,7 +188,7 @@ }, "reload_all": { "name": "Reload all", - "description": "Reload all YAML configuration that can be reloaded without restarting Home Assistant." + "description": "Reloads all YAML configuration that can be reloaded without restarting Home Assistant." } }, "exceptions": { diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py index df49a79bcb6..14096d87277 100644 --- a/homeassistant/components/homeassistant/triggers/time_pattern.py +++ b/homeassistant/components/homeassistant/triggers/time_pattern.py @@ -37,6 +37,8 @@ class TimePattern: if isinstance(value, str) and value.startswith("/"): number = int(value[1:]) + if number == 0: + raise vol.Invalid(f"must be a value between 1 and {self.maximum}") else: value = number = int(value) diff --git a/homeassistant/components/homeassistant_hardware/coordinator.py b/homeassistant/components/homeassistant_hardware/coordinator.py new file mode 100644 index 00000000000..c9a5c891328 --- /dev/null +++ b/homeassistant/components/homeassistant_hardware/coordinator.py @@ -0,0 +1,46 @@ +"""Home Assistant hardware firmware update coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiohttp import ClientSession +from ha_silabs_firmware_client import ( + FirmwareManifest, + FirmwareUpdateClient, + ManifestMissing, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +FIRMWARE_REFRESH_INTERVAL = timedelta(hours=8) + + +class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]): + """Coordinator to manage firmware updates.""" + + def __init__(self, hass: HomeAssistant, session: ClientSession, url: str) -> None: + """Initialize the firmware update coordinator.""" + super().__init__( + hass, + _LOGGER, + name="firmware update coordinator", + update_interval=FIRMWARE_REFRESH_INTERVAL, + ) + self.hass = hass + self.session = session + + self.client = FirmwareUpdateClient(url, session) + + async def _async_update_data(self) -> FirmwareManifest: + try: + return await self.client.async_update_data() + except ManifestMissing as err: + raise UpdateFailed( + "GitHub release assets haven't been uploaded yet" + ) from err diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 83031587712..1b4840e5a98 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -33,6 +33,7 @@ from .util import ( OwningIntegration, get_otbr_addon_manager, get_zigbee_flasher_addon_manager, + guess_firmware_info, guess_hardware_owners, probe_silabs_firmware_info, ) @@ -511,6 +512,16 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm a discovery.""" + assert self._device is not None + fw_info = await guess_firmware_info(self.hass, self._device) + + # If our guess for the firmware type is actually running, we can save the user + # an unnecessary confirmation and silently confirm the flow + for owner in fw_info.owners: + if await owner.is_running(self.hass): + self._probed_firmware_info = fw_info + return self._async_flow_finished() + return await self.async_step_pick_firmware() diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index 8f59ab61600..f3a02185b83 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -5,5 +5,8 @@ "codeowners": ["@home-assistant/core"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", - "requirements": ["universal-silabs-flasher==0.0.29"] + "requirements": [ + "universal-silabs-flasher==0.0.30", + "ha-silabs-firmware-client==0.2.0" + ] } diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index de328a54bb7..6dda01561f1 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -39,8 +39,8 @@ "description": "The OpenThread Border Router (OTBR) add-on is now starting." }, "otbr_failed": { - "title": "Failed to setup OpenThread Border Router", - "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists." + "title": "Failed to set up OpenThread Border Router", + "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists." }, "confirm_otbr": { "title": "OpenThread Border Router setup complete", @@ -48,16 +48,16 @@ } }, "abort": { - "not_hassio_thread": "The OpenThread Border Router addon can only be installed with Home Assistant OS. If you would like to use the {model} as an Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.", + "not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.", "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.", "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." }, "progress": { - "install_zigbee_flasher_addon": "The Silicon Labs Flasher addon is installed, this may take a few minutes.", + "install_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is installed, this may take a few minutes.", "run_zigbee_flasher_addon": "Please wait while Zigbee firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes.", - "uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher addon is being removed." + "uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is being removed." } } }, diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py new file mode 100644 index 00000000000..1b0f15ca021 --- /dev/null +++ b/homeassistant/components/homeassistant_hardware/update.py @@ -0,0 +1,326 @@ +"""Home Assistant Hardware base firmware update entity.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable +from contextlib import AsyncExitStack, asynccontextmanager +from dataclasses import dataclass +import logging +from typing import Any, cast + +from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata +from universal_silabs_flasher.firmware import parse_firmware_image +from universal_silabs_flasher.flasher import Flasher +from yarl import URL + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.restore_state import ExtraStoredData +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import FirmwareUpdateCoordinator +from .helpers import async_register_firmware_info_callback +from .util import ( + ApplicationType, + FirmwareInfo, + guess_firmware_info, + probe_silabs_firmware_info, +) + +_LOGGER = logging.getLogger(__name__) + +type FirmwareChangeCallbackType = Callable[ + [ApplicationType | None, ApplicationType | None], None +] + + +@dataclass(kw_only=True, frozen=True) +class FirmwareUpdateEntityDescription(UpdateEntityDescription): + """Describes Home Assistant Hardware firmware update entity.""" + + version_parser: Callable[[str], str] + fw_type: str | None + version_key: str | None + expected_firmware_type: ApplicationType | None + firmware_name: str | None + + +@dataclass +class FirmwareUpdateExtraStoredData(ExtraStoredData): + """Extra stored data for Home Assistant Hardware firmware update entity.""" + + firmware_manifest: FirmwareManifest | None = None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return { + "firmware_manifest": ( + self.firmware_manifest.as_dict() + if self.firmware_manifest is not None + else None + ) + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> FirmwareUpdateExtraStoredData: + """Initialize the extra data from a dict.""" + if data["firmware_manifest"] is None: + return cls(firmware_manifest=None) + + return cls( + FirmwareManifest.from_json( + data["firmware_manifest"], + # This data is not technically part of the manifest and is loaded externally + url=URL(data["firmware_manifest"]["url"]), + html_url=URL(data["firmware_manifest"]["html_url"]), + ) + ) + + +class BaseFirmwareUpdateEntity( + CoordinatorEntity[FirmwareUpdateCoordinator], UpdateEntity +): + """Base Home Assistant Hardware firmware update entity.""" + + # Subclasses provide the mapping between firmware types and entity descriptions + entity_description: FirmwareUpdateEntityDescription + bootloader_reset_type: str | None = None + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + _attr_has_entity_name = True + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the Hardware firmware update entity.""" + super().__init__(update_coordinator) + + self.entity_description = entity_description + self._current_device = device + self._config_entry = config_entry + self._current_firmware_info: FirmwareInfo | None = None + self._firmware_type_change_callbacks: set[FirmwareChangeCallbackType] = set() + + self._latest_manifest: FirmwareManifest | None = None + self._latest_firmware: FirmwareMetadata | None = None + + def add_firmware_type_changed_callback( + self, + change_callback: FirmwareChangeCallbackType, + ) -> CALLBACK_TYPE: + """Add a callback for when the firmware type changes.""" + self._firmware_type_change_callbacks.add(change_callback) + + @callback + def remove_callback() -> None: + self._firmware_type_change_callbacks.discard(change_callback) + + return remove_callback + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + self.async_on_remove( + async_register_firmware_info_callback( + self.hass, + self._current_device, + self._firmware_info_callback, + ) + ) + + self.async_on_remove( + self._config_entry.async_on_state_change(self._on_config_entry_change) + ) + + if (extra_data := await self.async_get_last_extra_data()) and ( + hardware_extra_data := FirmwareUpdateExtraStoredData.from_dict( + extra_data.as_dict() + ) + ): + self._latest_manifest = hardware_extra_data.firmware_manifest + + self._update_attributes() + + @property + def extra_restore_state_data(self) -> FirmwareUpdateExtraStoredData: + """Return state data to be restored.""" + return FirmwareUpdateExtraStoredData(firmware_manifest=self._latest_manifest) + + @callback + def _on_config_entry_change(self) -> None: + """Handle config entry changes.""" + self._update_attributes() + self.async_write_ha_state() + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + self._current_firmware_info = firmware_info + + # If the firmware type does not change, we can just update the attributes + if ( + self._current_firmware_info.firmware_type + == self.entity_description.expected_firmware_type + ): + self._update_attributes() + self.async_write_ha_state() + return + + # Otherwise, fire the firmware type change callbacks. They are expected to + # replace the entity so there is no purpose in firing other callbacks. + for change_callback in self._firmware_type_change_callbacks.copy(): + try: + change_callback( + self.entity_description.expected_firmware_type, + self._current_firmware_info.firmware_type, + ) + except Exception: # noqa: BLE001 + _LOGGER.warning( + "Failed to call firmware type changed callback", exc_info=True + ) + + def _update_attributes(self) -> None: + """Recompute the attributes of the entity.""" + self._attr_title = self.entity_description.firmware_name or "Unknown" + + if ( + self._current_firmware_info is None + or self._current_firmware_info.firmware_version is None + ): + self._attr_installed_version = None + else: + self._attr_installed_version = self.entity_description.version_parser( + self._current_firmware_info.firmware_version + ) + + self._latest_firmware = None + self._attr_latest_version = None + self._attr_release_summary = None + self._attr_release_url = None + + if ( + self._latest_manifest is None + or self.entity_description.fw_type is None + or self.entity_description.version_key is None + ): + return + + try: + self._latest_firmware = next( + f + for f in self._latest_manifest.firmwares + if f.filename.startswith(self.entity_description.fw_type) + ) + except StopIteration: + pass + else: + version = cast( + str, self._latest_firmware.metadata[self.entity_description.version_key] + ) + self._attr_latest_version = self.entity_description.version_parser(version) + self._attr_release_summary = self._latest_firmware.release_notes + self._attr_release_url = str(self._latest_manifest.html_url) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._latest_manifest = self.coordinator.data + self._update_attributes() + self.async_write_ha_state() + + def _update_progress(self, offset: int, total_size: int) -> None: + """Handle update progress.""" + + # Firmware updates in ~30s so we still get responsive update progress even + # without decimal places + self._attr_update_percentage = round((offset * 100) / total_size) + self.async_write_ha_state() + + @asynccontextmanager + async def _temporarily_stop_hardware_owners( + self, device: str + ) -> AsyncIterator[None]: + """Temporarily stop addons and integrations communicating with the device.""" + firmware_info = await guess_firmware_info(self.hass, device) + _LOGGER.debug("Identified firmware info: %s", firmware_info) + + async with AsyncExitStack() as stack: + for owner in firmware_info.owners: + await stack.enter_async_context(owner.temporarily_stop(self.hass)) + + yield + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + assert self._latest_firmware is not None + assert self.entity_description.expected_firmware_type is not None + + # Start off by setting the progress bar to an indeterminate state + self._attr_in_progress = True + self._attr_update_percentage = None + self.async_write_ha_state() + + fw_data = await self.coordinator.client.async_fetch_firmware( + self._latest_firmware + ) + fw_image = await self.hass.async_add_executor_job(parse_firmware_image, fw_data) + + device = self._current_device + + flasher = Flasher( + device=device, + probe_methods=( + ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(), + ApplicationType.EZSP.as_flasher_application_type(), + ApplicationType.SPINEL.as_flasher_application_type(), + ApplicationType.CPC.as_flasher_application_type(), + ), + bootloader_reset=self.bootloader_reset_type, + ) + + async with self._temporarily_stop_hardware_owners(device): + try: + try: + # Enter the bootloader with indeterminate progress + await flasher.enter_bootloader() + + # Flash the firmware, with progress + await flasher.flash_firmware( + fw_image, progress_callback=self._update_progress + ) + except Exception as err: + raise HomeAssistantError("Failed to flash firmware") from err + + # Probe the running application type with indeterminate progress + self._attr_update_percentage = None + self.async_write_ha_state() + + firmware_info = await probe_silabs_firmware_info( + device, + probe_methods=(self.entity_description.expected_firmware_type,), + ) + + if firmware_info is None: + raise HomeAssistantError( + "Failed to probe the firmware after flashing" + ) + + self._firmware_info_callback(firmware_info) + finally: + self._attr_in_progress = False + self.async_write_ha_state() diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 1afb786369e..64f363e4f23 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -4,7 +4,8 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Iterable +from collections.abc import AsyncIterator, Iterable +from contextlib import asynccontextmanager from dataclasses import dataclass from enum import StrEnum import logging @@ -105,6 +106,28 @@ class OwningAddon: else: return addon_info.state == AddonState.RUNNING + @asynccontextmanager + async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]: + """Temporarily stop the add-on, restarting it after completion.""" + addon_manager = self._get_addon_manager(hass) + + try: + addon_info = await addon_manager.async_get_addon_info() + except AddonError: + yield + return + + if addon_info.state != AddonState.RUNNING: + yield + return + + try: + await addon_manager.async_stop_addon() + await addon_manager.async_wait_until_addon_state(AddonState.NOT_RUNNING) + yield + finally: + await addon_manager.async_start_addon_waiting() + @dataclass(kw_only=True) class OwningIntegration: @@ -123,6 +146,23 @@ class OwningIntegration: ConfigEntryState.SETUP_IN_PROGRESS, ) + @asynccontextmanager + async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]: + """Temporarily stop the integration, restarting it after completion.""" + if (entry := hass.config_entries.async_get_entry(self.config_entry_id)) is None: + yield + return + + if entry.state != ConfigEntryState.LOADED: + yield + return + + try: + await hass.config_entries.async_unload(entry.entry_id) + yield + finally: + await hass.config_entries.async_setup(entry.entry_id) + @dataclass(kw_only=True) class FirmwareInfo: diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 758f0c1e1ef..dfc129ddc75 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -3,21 +3,87 @@ from __future__ import annotations import logging +import os.path from homeassistant.components.homeassistant_hardware.util import guess_firmware_info +from homeassistant.components.usb import ( + USBDevice, + async_register_port_event_callback, + scan_serial_ports, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import ( + DESCRIPTION, + DEVICE, + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + MANUFACTURER, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, +) _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the ZBT-1 integration.""" + + @callback + def async_port_event_callback( + added: set[USBDevice], removed: set[USBDevice] + ) -> None: + """Handle USB port events.""" + current_entries_by_path = { + entry.data[DEVICE]: entry + for entry in hass.config_entries.async_entries(DOMAIN) + } + + for device in added | removed: + path = device.device + entry = current_entries_by_path.get(path) + + if entry is not None: + _LOGGER.debug( + "Device %r has changed state, reloading config entry %s", + path, + entry, + ) + hass.config_entries.async_schedule_reload(entry.entry_id) + + async_register_port_event_callback(hass, async_port_event_callback) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant SkyConnect config entry.""" + + # Postpone loading the config entry if the device is missing + device_path = entry.data[DEVICE] + if not await hass.async_add_executor_job(os.path.exists, device_path): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_disconnected", + ) + + await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + await hass.config_entries.async_unload_platforms(entry, ["update"]) return True @@ -25,7 +91,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> """Migrate old entry.""" _LOGGER.debug( - "Migrating from version %s:%s", config_entry.version, config_entry.minor_version + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) if config_entry.version == 1: @@ -33,15 +99,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Add-on startup with type service get started before Core, always (e.g. the # Multi-Protocol add-on). Probing the firmware would interfere with the add-on, # so we can't safely probe here. Instead, we must make an educated guess! - firmware_guess = await guess_firmware_info( - hass, config_entry.data["device"] - ) + firmware_guess = await guess_firmware_info(hass, config_entry.data[DEVICE]) new_data = {**config_entry.data} - new_data["firmware"] = firmware_guess.firmware_type.value + new_data[FIRMWARE] = firmware_guess.firmware_type.value # Copy `description` to `product` - new_data["product"] = new_data["description"] + new_data[PRODUCT] = new_data[DESCRIPTION] hass.config_entries.async_update_entry( config_entry, @@ -50,6 +114,55 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> minor_version=2, ) + if config_entry.minor_version == 2: + # Add a `firmware_version` key + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + FIRMWARE_VERSION: None, + }, + version=1, + minor_version=3, + ) + + if config_entry.minor_version == 3: + # Old SkyConnect config entries were missing keys + if any( + key not in config_entry.data + for key in (VID, PID, MANUFACTURER, PRODUCT, SERIAL_NUMBER) + ): + serial_ports = await hass.async_add_executor_job(scan_serial_ports) + serial_ports_info = {port.device: port for port in serial_ports} + device = config_entry.data[DEVICE] + + if not (usb_info := serial_ports_info.get(device)): + raise HomeAssistantError( + f"USB device {device} is missing, cannot migrate" + ) + + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + VID: usb_info.vid, + PID: usb_info.pid, + MANUFACTURER: usb_info.manufacturer, + PRODUCT: usb_info.description, + DESCRIPTION: usb_info.description, + SERIAL_NUMBER: usb_info.serial_number, + }, + version=1, + minor_version=4, + ) + else: + # Existing entries are migrated by just incrementing the version + hass.config_entries.async_update_entry( + config_entry, + version=1, + minor_version=4, + ) + _LOGGER.debug( "Migration to version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index d8446c2d3f9..eb5ea214b3e 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -24,7 +24,20 @@ from homeassistant.config_entries import ( from homeassistant.core import callback from homeassistant.helpers.service_info.usb import UsbServiceInfo -from .const import DOCS_WEB_FLASHER_URL, DOMAIN, HardwareVariant +from .const import ( + DESCRIPTION, + DEVICE, + DOCS_WEB_FLASHER_URL, + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + MANUFACTURER, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, + HardwareVariant, +) from .util import get_hardware_variant, get_usb_service_info _LOGGER = logging.getLogger(__name__) @@ -37,6 +50,7 @@ if TYPE_CHECKING: def _get_translation_placeholders(self) -> dict[str, str]: return {} + else: # Multiple inheritance with `Protocol` seems to break TranslationPlaceholderProtocol = object @@ -67,7 +81,7 @@ class HomeAssistantSkyConnectConfigFlow( """Handle a config flow for Home Assistant SkyConnect.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 4 def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the config flow.""" @@ -82,7 +96,7 @@ class HomeAssistantSkyConnectConfigFlow( config_entry: ConfigEntry, ) -> OptionsFlow: """Return the options flow.""" - firmware_type = ApplicationType(config_entry.data["firmware"]) + firmware_type = ApplicationType(config_entry.data[FIRMWARE]) if firmware_type is ApplicationType.CPC: return HomeAssistantSkyConnectMultiPanOptionsFlowHandler(config_entry) @@ -100,7 +114,7 @@ class HomeAssistantSkyConnectConfigFlow( unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" if await self.async_set_unique_id(unique_id): - self._abort_if_unique_id_configured(updates={"device": device}) + self._abort_if_unique_id_configured(updates={DEVICE: device}) discovery_info.device = await self.hass.async_add_executor_job( usb.get_serial_by_id, discovery_info.device @@ -126,14 +140,15 @@ class HomeAssistantSkyConnectConfigFlow( return self.async_create_entry( title=self._hw_variant.full_name, data={ - "vid": self._usb_info.vid, - "pid": self._usb_info.pid, - "serial_number": self._usb_info.serial_number, - "manufacturer": self._usb_info.manufacturer, - "description": self._usb_info.description, # For backwards compatibility - "product": self._usb_info.description, - "device": self._usb_info.device, - "firmware": self._probed_firmware_info.firmware_type.value, + VID: self._usb_info.vid, + PID: self._usb_info.pid, + SERIAL_NUMBER: self._usb_info.serial_number, + MANUFACTURER: self._usb_info.manufacturer, + DESCRIPTION: self._usb_info.description, # For backwards compatibility + PRODUCT: self._usb_info.description, + DEVICE: self._usb_info.device, + FIRMWARE: self._probed_firmware_info.firmware_type.value, + FIRMWARE_VERSION: self._probed_firmware_info.firmware_version, }, ) @@ -148,7 +163,7 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( ) -> silabs_multiprotocol_addon.SerialPortSettings: """Return the radio serial port settings.""" return silabs_multiprotocol_addon.SerialPortSettings( - device=self.config_entry.data["device"], + device=self.config_entry.data[DEVICE], baudrate="115200", flow_control=True, ) @@ -182,7 +197,8 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( entry=self.config_entry, data={ **self.config_entry.data, - "firmware": ApplicationType.EZSP.value, + FIRMWARE: ApplicationType.EZSP.value, + FIRMWARE_VERSION: None, }, options=self.config_entry.options, ) @@ -201,15 +217,15 @@ class HomeAssistantSkyConnectOptionsFlowHandler( self._usb_info = get_usb_service_info(self.config_entry) self._hw_variant = HardwareVariant.from_usb_product_name( - self.config_entry.data["product"] + self.config_entry.data[PRODUCT] ) self._hardware_name = self._hw_variant.full_name self._device = self._usb_info.device self._probed_firmware_info = FirmwareInfo( device=self._device, - firmware_type=ApplicationType(self.config_entry.data["firmware"]), - firmware_version=None, + firmware_type=ApplicationType(self.config_entry.data[FIRMWARE]), + firmware_version=self.config_entry.data[FIRMWARE_VERSION], source="guess", owners=[], ) @@ -225,7 +241,8 @@ class HomeAssistantSkyConnectOptionsFlowHandler( entry=self.config_entry, data={ **self.config_entry.data, - "firmware": self._probed_firmware_info.firmware_type.value, + FIRMWARE: self._probed_firmware_info.firmware_type.value, + FIRMWARE_VERSION: self._probed_firmware_info.firmware_version, }, options=self.config_entry.options, ) diff --git a/homeassistant/components/homeassistant_sky_connect/const.py b/homeassistant/components/homeassistant_sky_connect/const.py index cae0b98a25b..70ff047366d 100644 --- a/homeassistant/components/homeassistant_sky_connect/const.py +++ b/homeassistant/components/homeassistant_sky_connect/const.py @@ -7,6 +7,20 @@ from typing import Self DOMAIN = "homeassistant_sky_connect" DOCS_WEB_FLASHER_URL = "https://skyconnect.home-assistant.io/firmware-update/" +NABU_CASA_FIRMWARE_RELEASES_URL = ( + "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest" +) + +FIRMWARE = "firmware" +FIRMWARE_VERSION = "firmware_version" +SERIAL_NUMBER = "serial_number" +MANUFACTURER = "manufacturer" +PRODUCT = "product" +DESCRIPTION = "description" +PID = "pid" +VID = "vid" +DEVICE = "device" + @dataclasses.dataclass(frozen=True) class VariantInfo: diff --git a/homeassistant/components/homeassistant_sky_connect/hardware.py b/homeassistant/components/homeassistant_sky_connect/hardware.py index 2872077111a..9bfa5d16655 100644 --- a/homeassistant/components/homeassistant_sky_connect/hardware.py +++ b/homeassistant/components/homeassistant_sky_connect/hardware.py @@ -5,17 +5,21 @@ from __future__ import annotations from homeassistant.components.hardware.models import HardwareInfo, USBInfo from homeassistant.core import HomeAssistant, callback +from .config_flow import HomeAssistantSkyConnectConfigFlow from .const import DOMAIN from .util import get_hardware_variant DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/" +EXPECTED_ENTRY_VERSION = ( + HomeAssistantSkyConnectConfigFlow.VERSION, + HomeAssistantSkyConnectConfigFlow.MINOR_VERSION, +) @callback def async_info(hass: HomeAssistant) -> list[HardwareInfo]: """Return board info.""" entries = hass.config_entries.async_entries(DOMAIN) - return [ HardwareInfo( board=None, @@ -31,4 +35,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]: url=DOCUMENTATION_URL, ) for entry in entries + # Ignore unmigrated config entries in the hardware page + if (entry.version, entry.minor_version) == EXPECTED_ENTRY_VERSION ] diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index a596b9846ce..a990f025e8d 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -195,5 +195,10 @@ "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" } + }, + "exceptions": { + "device_disconnected": { + "message": "The device is not plugged in" + } } } diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py new file mode 100644 index 00000000000..74c28b37eaf --- /dev/null +++ b/homeassistant/components/homeassistant_sky_connect/update.py @@ -0,0 +1,228 @@ +"""Home Assistant SkyConnect firmware update entity.""" + +from __future__ import annotations + +import logging + +import aiohttp + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.update import ( + BaseFirmwareUpdateEntity, + FirmwareUpdateEntityDescription, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.components.update import UpdateDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +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 DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + NABU_CASA_FIRMWARE_RELEASES_URL, + PRODUCT, + SERIAL_NUMBER, + HardwareVariant, +) + +_LOGGER = logging.getLogger(__name__) + + +FIRMWARE_ENTITY_DESCRIPTIONS: dict[ + ApplicationType | None, FirmwareUpdateEntityDescription +] = { + ApplicationType.EZSP: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split(" ", 1)[0], + fw_type="skyconnect_zigbee_ncp", + version_key="ezsp_version", + expected_firmware_type=ApplicationType.EZSP, + firmware_name="EmberZNet Zigbee", + ), + ApplicationType.SPINEL: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0], + fw_type="skyconnect_openthread_rcp", + version_key="ot_rcp_version", + expected_firmware_type=ApplicationType.SPINEL, + firmware_name="OpenThread RCP", + ), + ApplicationType.CPC: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type="skyconnect_multipan", + version_key="cpc_version", + expected_firmware_type=ApplicationType.CPC, + firmware_name="Multiprotocol", + ), + ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, # We don't want to update the bootloader + version_key="gecko_bootloader_version", + expected_firmware_type=ApplicationType.GECKO_BOOTLOADER, + firmware_name="Gecko Bootloader", + ), + None: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, + version_key=None, + expected_firmware_type=None, + firmware_name=None, + ), +} + + +def _async_create_update_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + session: aiohttp.ClientSession, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> FirmwareUpdateEntity: + """Create an update entity that handles firmware type changes.""" + firmware_type = config_entry.data[FIRMWARE] + + try: + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) + ] + except (KeyError, ValueError): + _LOGGER.debug( + "Unknown firmware type %r, using default entity description", firmware_type + ) + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None] + + entity = FirmwareUpdateEntity( + device=config_entry.data["device"], + config_entry=config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + NABU_CASA_FIRMWARE_RELEASES_URL, + ), + entity_description=entity_description, + ) + + def firmware_type_changed( + old_type: ApplicationType | None, new_type: ApplicationType | None + ) -> None: + """Replace the current entity when the firmware type changes.""" + er.async_get(hass).async_remove(entity.entity_id) + async_add_entities( + [ + _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + ] + ) + + entity.async_on_remove( + entity.add_firmware_type_changed_callback(firmware_type_changed) + ) + + return entity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the firmware update config entry.""" + session = async_get_clientsession(hass) + entity = _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + + async_add_entities([entity]) + + +class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): + """SkyConnect firmware update entity.""" + + bootloader_reset_type = None + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the SkyConnect firmware update entity.""" + super().__init__(device, config_entry, update_coordinator, entity_description) + + variant = HardwareVariant.from_usb_product_name( + self._config_entry.data[PRODUCT] + ) + serial_number = self._config_entry.data[SERIAL_NUMBER] + + self._attr_unique_id = f"{serial_number}_{self.entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + name=f"{variant.full_name} ({serial_number[:8]})", + model=variant.full_name, + manufacturer="Nabu Casa", + serial_number=serial_number, + ) + + # Use the cached firmware info if it exists + if self._config_entry.data[FIRMWARE] is not None: + self._current_firmware_info = FirmwareInfo( + device=device, + firmware_type=ApplicationType(self._config_entry.data[FIRMWARE]), + firmware_version=self._config_entry.data[FIRMWARE_VERSION], + owners=[], + source="homeassistant_sky_connect", + ) + + def _update_attributes(self) -> None: + """Recompute the attributes of the entity.""" + super()._update_attributes() + + assert self.device_entry is not None + device_registry = dr.async_get(self.hass) + device_registry.async_update_device( + device_id=self.device_entry.id, + sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}", + ) + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + FIRMWARE: firmware_info.firmware_type, + FIRMWARE_VERSION: firmware_info.firmware_version, + }, + ) + super()._firmware_info_callback(firmware_info) diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index b0837eeedbe..27c40e35946 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import discovery_flow from homeassistant.helpers.hassio import is_hassio -from .const import FIRMWARE, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA +from .const import FIRMWARE, FIRMWARE_VERSION, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA _LOGGER = logging.getLogger(__name__) @@ -55,11 +55,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data=ZHA_HW_DISCOVERY_DATA, ) + await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + await hass.config_entries.async_unload_platforms(entry, ["update"]) return True @@ -87,6 +90,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> minor_version=2, ) + if config_entry.minor_version <= 3: + # Add a `firmware_version` key if it doesn't exist to handle entries created + # with minor version 1.3 where the firmware version was not set. + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + FIRMWARE_VERSION: config_entry.data.get(FIRMWARE_VERSION), + }, + version=1, + minor_version=4, + ) + _LOGGER.debug( "Migration to version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index b916c6e46ca..1fac6bcac96 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -37,7 +37,14 @@ from homeassistant.config_entries import ( 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 +from .const import ( + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + RADIO_DEVICE, + ZHA_DOMAIN, + ZHA_HW_DISCOVERY_DATA, +) from .hardware import BOARD_NAME _LOGGER = logging.getLogger(__name__) @@ -55,7 +62,7 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN): """Handle a config flow for Home Assistant Yellow.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 4 def __init__(self, *args: Any, **kwargs: Any) -> None: """Instantiate config flow.""" @@ -109,6 +116,11 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN): if self._probed_firmware_info is not None else ApplicationType.EZSP ).value, + FIRMWARE_VERSION: ( + self._probed_firmware_info.firmware_version + if self._probed_firmware_info is not None + else None + ), }, ) @@ -310,6 +322,7 @@ class HomeAssistantYellowOptionsFlowHandler( data={ **self.config_entry.data, FIRMWARE: self._probed_firmware_info.firmware_type.value, + FIRMWARE_VERSION: self._probed_firmware_info.firmware_version, }, ) diff --git a/homeassistant/components/homeassistant_yellow/const.py b/homeassistant/components/homeassistant_yellow/const.py index 79753ae9b9e..b8bf17391f9 100644 --- a/homeassistant/components/homeassistant_yellow/const.py +++ b/homeassistant/components/homeassistant_yellow/const.py @@ -2,7 +2,11 @@ DOMAIN = "homeassistant_yellow" +MODEL = "Home Assistant Yellow" +MANUFACTURER = "Nabu Casa" + RADIO_DEVICE = "/dev/ttyAMA1" + ZHA_HW_DISCOVERY_DATA = { "name": "Yellow", "port": { @@ -14,4 +18,9 @@ ZHA_HW_DISCOVERY_DATA = { } FIRMWARE = "firmware" +FIRMWARE_VERSION = "firmware_version" ZHA_DOMAIN = "zha" + +NABU_CASA_FIRMWARE_RELEASES_URL = ( + "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest" +) diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index b089e483899..41c1438b234 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -149,5 +149,12 @@ "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" } + }, + "entity": { + "update": { + "radio_firmware": { + "name": "Radio firmware" + } + } } } diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py new file mode 100644 index 00000000000..9531bd456cb --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/update.py @@ -0,0 +1,226 @@ +"""Home Assistant Yellow firmware update entity.""" + +from __future__ import annotations + +import logging + +import aiohttp + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.update import ( + BaseFirmwareUpdateEntity, + FirmwareUpdateEntityDescription, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.components.update import UpdateDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +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 DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + MANUFACTURER, + MODEL, + NABU_CASA_FIRMWARE_RELEASES_URL, + RADIO_DEVICE, +) + +_LOGGER = logging.getLogger(__name__) + + +FIRMWARE_ENTITY_DESCRIPTIONS: dict[ + ApplicationType | None, FirmwareUpdateEntityDescription +] = { + ApplicationType.EZSP: FirmwareUpdateEntityDescription( + key="radio_firmware", + translation_key="radio_firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split(" ", 1)[0], + fw_type="yellow_zigbee_ncp", + version_key="ezsp_version", + expected_firmware_type=ApplicationType.EZSP, + firmware_name="EmberZNet Zigbee", + ), + ApplicationType.SPINEL: FirmwareUpdateEntityDescription( + key="radio_firmware", + translation_key="radio_firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0], + fw_type="yellow_openthread_rcp", + version_key="ot_rcp_version", + expected_firmware_type=ApplicationType.SPINEL, + firmware_name="OpenThread RCP", + ), + ApplicationType.CPC: FirmwareUpdateEntityDescription( + key="radio_firmware", + translation_key="radio_firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type="yellow_multipan", + version_key="cpc_version", + expected_firmware_type=ApplicationType.CPC, + firmware_name="Multiprotocol", + ), + ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription( + key="radio_firmware", + translation_key="radio_firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, # We don't want to update the bootloader + version_key="gecko_bootloader_version", + expected_firmware_type=ApplicationType.GECKO_BOOTLOADER, + firmware_name="Gecko Bootloader", + ), + None: FirmwareUpdateEntityDescription( + key="radio_firmware", + translation_key="radio_firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, + version_key=None, + expected_firmware_type=None, + firmware_name=None, + ), +} + + +def _async_create_update_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + session: aiohttp.ClientSession, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> FirmwareUpdateEntity: + """Create an update entity that handles firmware type changes.""" + firmware_type = config_entry.data[FIRMWARE] + + try: + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) + ] + except (KeyError, ValueError): + _LOGGER.debug( + "Unknown firmware type %r, using default entity description", firmware_type + ) + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None] + + entity = FirmwareUpdateEntity( + device=RADIO_DEVICE, + config_entry=config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + NABU_CASA_FIRMWARE_RELEASES_URL, + ), + entity_description=entity_description, + ) + + def firmware_type_changed( + old_type: ApplicationType | None, new_type: ApplicationType | None + ) -> None: + """Replace the current entity when the firmware type changes.""" + er.async_get(hass).async_remove(entity.entity_id) + async_add_entities( + [ + _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + ] + ) + + entity.async_on_remove( + entity.add_firmware_type_changed_callback(firmware_type_changed) + ) + + return entity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the firmware update config entry.""" + session = async_get_clientsession(hass) + entity = _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + + async_add_entities([entity]) + + +class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): + """Yellow firmware update entity.""" + + bootloader_reset_type = "yellow" # Triggers a GPIO reset + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the Yellow firmware update entity.""" + super().__init__(device, config_entry, update_coordinator, entity_description) + self._attr_unique_id = self.entity_description.key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, "yellow")}, + name=MODEL, + model=MODEL, + manufacturer=MANUFACTURER, + ) + + # Use the cached firmware info if it exists + if self._config_entry.data[FIRMWARE] is not None: + self._current_firmware_info = FirmwareInfo( + device=device, + firmware_type=ApplicationType(self._config_entry.data[FIRMWARE]), + firmware_version=self._config_entry.data[FIRMWARE_VERSION], + owners=[], + source="homeassistant_yellow", + ) + + def _update_attributes(self) -> None: + """Recompute the attributes of the entity.""" + super()._update_attributes() + + assert self.device_entry is not None + device_registry = dr.async_get(self.hass) + device_registry.async_update_device( + device_id=self.device_entry.id, + sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}", + ) + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + FIRMWARE: firmware_info.firmware_type, + FIRMWARE_VERSION: firmware_info.firmware_version, + }, + ) + super()._firmware_info_callback(firmware_info) diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index d7785ad9104..654bdde6211 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -15,10 +15,16 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, + Platform.FAN, Platform.LIGHT, + Platform.LOCK, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.VALVE, diff --git a/homeassistant/components/homee/alarm_control_panel.py b/homeassistant/components/homee/alarm_control_panel.py new file mode 100644 index 00000000000..fd7371b31e4 --- /dev/null +++ b/homeassistant/components/homee/alarm_control_panel.py @@ -0,0 +1,138 @@ +"""The Homee alarm control panel platform.""" + +from dataclasses import dataclass + +from pyHomee.const import AttributeChangedBy, AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityDescription, + AlarmControlPanelEntityFeature, + AlarmControlPanelState, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import DOMAIN, HomeeConfigEntry +from .entity import HomeeEntity +from .helpers import get_name_for_enum + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class HomeeAlarmControlPanelEntityDescription(AlarmControlPanelEntityDescription): + """A class that describes Homee alarm control panel entities.""" + + code_arm_required: bool = False + state_list: list[AlarmControlPanelState] + + +ALARM_DESCRIPTIONS = { + AttributeType.HOMEE_MODE: HomeeAlarmControlPanelEntityDescription( + key="homee_mode", + code_arm_required=False, + state_list=[ + AlarmControlPanelState.ARMED_HOME, + AlarmControlPanelState.ARMED_NIGHT, + AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelState.ARMED_VACATION, + ], + ) +} + + +def get_supported_features( + state_list: list[AlarmControlPanelState], +) -> AlarmControlPanelEntityFeature: + """Return supported features based on the state list.""" + supported_features = AlarmControlPanelEntityFeature(0) + if AlarmControlPanelState.ARMED_HOME in state_list: + supported_features |= AlarmControlPanelEntityFeature.ARM_HOME + if AlarmControlPanelState.ARMED_AWAY in state_list: + supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY + if AlarmControlPanelState.ARMED_NIGHT in state_list: + supported_features |= AlarmControlPanelEntityFeature.ARM_NIGHT + if AlarmControlPanelState.ARMED_VACATION in state_list: + supported_features |= AlarmControlPanelEntityFeature.ARM_VACATION + return supported_features + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the alarm control panel component.""" + + async_add_entities( + HomeeAlarmPanel(attribute, config_entry, ALARM_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in ALARM_DESCRIPTIONS and attribute.editable + ) + + +class HomeeAlarmPanel(HomeeEntity, AlarmControlPanelEntity): + """Representation of a Homee alarm control panel.""" + + entity_description: HomeeAlarmControlPanelEntityDescription + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: HomeeAlarmControlPanelEntityDescription, + ) -> None: + """Initialize a Homee alarm control panel entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_code_arm_required = description.code_arm_required + self._attr_supported_features = get_supported_features(description.state_list) + self._attr_translation_key = description.key + + @property + def alarm_state(self) -> AlarmControlPanelState: + """Return current state.""" + return self.entity_description.state_list[int(self._attribute.current_value)] + + @property + def changed_by(self) -> str: + """Return by whom or what the entity was last changed.""" + changed_by_name = get_name_for_enum( + AttributeChangedBy, self._attribute.changed_by + ) + return f"{changed_by_name} - {self._attribute.changed_by_id}" + + async def _async_set_alarm_state(self, state: AlarmControlPanelState) -> None: + """Set the alarm state.""" + if state in self.entity_description.state_list: + await self.async_set_homee_value( + self.entity_description.state_list.index(state) + ) + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + # Since disarm is always present in the UI, we raise an error. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="disarm_not_supported", + ) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self._async_set_alarm_state(AlarmControlPanelState.ARMED_HOME) + + async def async_alarm_arm_night(self, code: str | None = None) -> None: + """Send arm night command.""" + await self._async_set_alarm_state(AlarmControlPanelState.ARMED_NIGHT) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._async_set_alarm_state(AlarmControlPanelState.ARMED_AWAY) + + async def async_alarm_arm_vacation(self, code: str | None = None) -> None: + """Send arm vacation command.""" + await self._async_set_alarm_state(AlarmControlPanelState.ARMED_VACATION) diff --git a/homeassistant/components/homee/binary_sensor.py b/homeassistant/components/homee/binary_sensor.py new file mode 100644 index 00000000000..3f5f5c46a29 --- /dev/null +++ b/homeassistant/components/homee/binary_sensor.py @@ -0,0 +1,190 @@ +"""The Homee binary sensor platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +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 AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity + +PARALLEL_UPDATES = 0 + +BINARY_SENSOR_DESCRIPTIONS: dict[AttributeType, BinarySensorEntityDescription] = { + AttributeType.BATTERY_LOW_ALARM: BinarySensorEntityDescription( + key="battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.BLACKOUT_ALARM: BinarySensorEntityDescription( + key="blackout_alarm", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.COALARM: BinarySensorEntityDescription( + key="carbon_monoxide", device_class=BinarySensorDeviceClass.CO + ), + AttributeType.CO2ALARM: BinarySensorEntityDescription( + key="carbon_dioxide", device_class=BinarySensorDeviceClass.PROBLEM + ), + AttributeType.FLOOD_ALARM: BinarySensorEntityDescription( + key="flood", + device_class=BinarySensorDeviceClass.MOISTURE, + ), + AttributeType.HIGH_TEMPERATURE_ALARM: BinarySensorEntityDescription( + key="high_temperature", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.LEAK_ALARM: BinarySensorEntityDescription( + key="leak_alarm", + device_class=BinarySensorDeviceClass.PROBLEM, + ), + AttributeType.LOAD_ALARM: BinarySensorEntityDescription( + key="load_alarm", + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.LOCK_STATE: BinarySensorEntityDescription( + key="lock", + device_class=BinarySensorDeviceClass.LOCK, + ), + AttributeType.LOW_TEMPERATURE_ALARM: BinarySensorEntityDescription( + key="low_temperature", + device_class=BinarySensorDeviceClass.COLD, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MALFUNCTION_ALARM: BinarySensorEntityDescription( + key="malfunction", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MAXIMUM_ALARM: BinarySensorEntityDescription( + key="maximum", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MINIMUM_ALARM: BinarySensorEntityDescription( + key="minimum", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MOTION_ALARM: BinarySensorEntityDescription( + key="motion", + device_class=BinarySensorDeviceClass.MOTION, + ), + AttributeType.MOTOR_BLOCKED_ALARM: BinarySensorEntityDescription( + key="motor_blocked", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.ON_OFF: BinarySensorEntityDescription( + key="plug", + device_class=BinarySensorDeviceClass.PLUG, + ), + AttributeType.OPEN_CLOSE: BinarySensorEntityDescription( + key="opening", + device_class=BinarySensorDeviceClass.OPENING, + ), + AttributeType.OVER_CURRENT_ALARM: BinarySensorEntityDescription( + key="overcurrent", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.OVERLOAD_ALARM: BinarySensorEntityDescription( + key="overload", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.PRESENCE_ALARM: BinarySensorEntityDescription( + key="presence", + device_class=BinarySensorDeviceClass.PRESENCE, + ), + AttributeType.POWER_SUPPLY_ALARM: BinarySensorEntityDescription( + key="power", + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.RAIN_FALL: BinarySensorEntityDescription( + key="rain", + device_class=BinarySensorDeviceClass.MOISTURE, + ), + AttributeType.REPLACE_FILTER_ALARM: BinarySensorEntityDescription( + key="replace_filter", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.SMOKE_ALARM: BinarySensorEntityDescription( + key="smoke", + device_class=BinarySensorDeviceClass.SMOKE, + ), + AttributeType.STORAGE_ALARM: BinarySensorEntityDescription( + key="storage", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.SURGE_ALARM: BinarySensorEntityDescription( + key="surge", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.TAMPER_ALARM: BinarySensorEntityDescription( + key="tamper", + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.VOLTAGE_DROP_ALARM: BinarySensorEntityDescription( + key="voltage_drop", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.WATER_ALARM: BinarySensorEntityDescription( + key="water", + device_class=BinarySensorDeviceClass.MOISTURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the binary sensor component.""" + + async_add_devices( + HomeeBinarySensor( + attribute, config_entry, BINARY_SENSOR_DESCRIPTIONS[attribute.type] + ) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in BINARY_SENSOR_DESCRIPTIONS and not attribute.editable + ) + + +class HomeeBinarySensor(HomeeEntity, BinarySensorEntity): + """Representation of a Homee binary sensor.""" + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize a Homee binary sensor entity.""" + super().__init__(attribute, entry) + + self.entity_description = description + self._attr_translation_key = description.key + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return bool(self._attribute.current_value) diff --git a/homeassistant/components/homee/button.py b/homeassistant/components/homee/button.py index af6d769c1dc..33a8b5f23c8 100644 --- a/homeassistant/components/homee/button.py +++ b/homeassistant/components/homee/button.py @@ -15,6 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeeConfigEntry from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + BUTTON_DESCRIPTIONS: dict[AttributeType, ButtonEntityDescription] = { AttributeType.AUTOMATIC_MODE_IMPULSE: ButtonEntityDescription(key="automatic_mode"), AttributeType.BRIEFLY_OPEN_IMPULSE: ButtonEntityDescription(key="briefly_open"), diff --git a/homeassistant/components/homee/climate.py b/homeassistant/components/homee/climate.py new file mode 100644 index 00000000000..3411d31461c --- /dev/null +++ b/homeassistant/components/homee/climate.py @@ -0,0 +1,200 @@ +"""The Homee climate platform.""" + +from typing import Any + +from pyHomee.const import AttributeType, NodeProfile +from pyHomee.model import HomeeNode + +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .const import CLIMATE_PROFILES, DOMAIN, HOMEE_UNIT_TO_HA_UNIT, PRESET_MANUAL +from .entity import HomeeNodeEntity + +PARALLEL_UPDATES = 0 + +ROOM_THERMOSTATS = { + NodeProfile.ROOM_THERMOSTAT, + NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR, + NodeProfile.WIFI_ROOM_THERMOSTAT, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the climate component.""" + + async_add_devices( + HomeeClimate(node, config_entry) + for node in config_entry.runtime_data.nodes + if node.profile in CLIMATE_PROFILES + ) + + +class HomeeClimate(HomeeNodeEntity, ClimateEntity): + """Representation of a Homee climate entity.""" + + _attr_name = None + _attr_translation_key = DOMAIN + + def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + """Initialize a Homee climate entity.""" + super().__init__(node, entry) + + ( + self._attr_supported_features, + self._attr_hvac_modes, + self._attr_preset_modes, + ) = get_climate_features(self._node) + + self._target_temp = self._node.get_attribute_by_type( + AttributeType.TARGET_TEMPERATURE + ) + assert self._target_temp is not None + self._attr_temperature_unit = str(HOMEE_UNIT_TO_HA_UNIT[self._target_temp.unit]) + self._attr_target_temperature_step = self._target_temp.step_value + self._attr_unique_id = f"{self._attr_unique_id}-{self._target_temp.id}" + + self._heating_mode = self._node.get_attribute_by_type( + AttributeType.HEATING_MODE + ) + self._temperature = self._node.get_attribute_by_type(AttributeType.TEMPERATURE) + self._valve_position = self._node.get_attribute_by_type( + AttributeType.CURRENT_VALVE_POSITION + ) + + @property + def hvac_mode(self) -> HVACMode: + """Return the hvac operation mode.""" + if ClimateEntityFeature.TURN_OFF in self.supported_features and ( + self._heating_mode is not None + ): + if self._heating_mode.current_value == 0: + return HVACMode.OFF + + return HVACMode.HEAT + + @property + def hvac_action(self) -> HVACAction: + """Return the hvac action.""" + if self._heating_mode is not None and self._heating_mode.current_value == 0: + return HVACAction.OFF + + if ( + self._valve_position is not None and self._valve_position.current_value == 0 + ) or ( + self._temperature is not None + and self._temperature.current_value >= self.target_temperature + ): + return HVACAction.IDLE + + return HVACAction.HEATING + + @property + def preset_mode(self) -> str: + """Return the present preset mode.""" + if ( + ClimateEntityFeature.PRESET_MODE in self.supported_features + and self._heating_mode is not None + and self._heating_mode.current_value > 0 + ): + assert self._attr_preset_modes is not None + return self._attr_preset_modes[int(self._heating_mode.current_value) - 1] + + return PRESET_NONE + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if self._temperature is not None: + return self._temperature.current_value + return None + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + assert self._target_temp is not None + return self._target_temp.current_value + + @property + def min_temp(self) -> float: + """Return the lowest settable target temperature.""" + assert self._target_temp is not None + return self._target_temp.minimum + + @property + def max_temp(self) -> float: + """Return the lowest settable target temperature.""" + assert self._target_temp is not None + return self._target_temp.maximum + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + # Currently only HEAT and OFF are supported. + assert self._heating_mode is not None + await self.async_set_homee_value( + self._heating_mode, float(hvac_mode == HVACMode.HEAT) + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + assert self._heating_mode is not None and self._attr_preset_modes is not None + await self.async_set_homee_value( + self._heating_mode, self._attr_preset_modes.index(preset_mode) + 1 + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + assert self._target_temp is not None + if ATTR_TEMPERATURE in kwargs: + await self.async_set_homee_value( + self._target_temp, kwargs[ATTR_TEMPERATURE] + ) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + assert self._heating_mode is not None + await self.async_set_homee_value(self._heating_mode, 1) + + async def async_turn_off(self) -> None: + """Turn the entity on.""" + assert self._heating_mode is not None + await self.async_set_homee_value(self._heating_mode, 0) + + +def get_climate_features( + node: HomeeNode, +) -> tuple[ClimateEntityFeature, list[HVACMode], list[str] | None]: + """Determine supported climate features of a node based on the available attributes.""" + features = ClimateEntityFeature.TARGET_TEMPERATURE + hvac_modes = [HVACMode.HEAT] + preset_modes: list[str] = [] + + if ( + attribute := node.get_attribute_by_type(AttributeType.HEATING_MODE) + ) is not None: + features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + hvac_modes.append(HVACMode.OFF) + + if attribute.maximum > 1: + # Node supports more modes than off and heating. + features |= ClimateEntityFeature.PRESET_MODE + preset_modes.extend([PRESET_ECO, PRESET_BOOST, PRESET_MANUAL]) + + if len(preset_modes) > 0: + preset_modes.insert(0, PRESET_NONE) + return (features, hvac_modes, preset_modes if len(preset_modes) > 0 else None) diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py index 61d2a3f25a5..1a3c5011f82 100644 --- a/homeassistant/components/homee/config_flow.py +++ b/homeassistant/components/homee/config_flow.py @@ -52,7 +52,7 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except HomeeAuthenticationFailedException: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 2c614d3f5eb..7bc3de189d6 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -95,3 +95,8 @@ LIGHT_PROFILES = [ NodeProfile.WIFI_DIMMABLE_LIGHT, NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH, ] + +# Preset modes +PRESET_AUTO = "auto" +PRESET_MANUAL = "manual" +PRESET_SUMMER = "summer" diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index 6e7e4fd5c55..79a9b00ffba 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -21,6 +21,8 @@ from .entity import HomeeNodeEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + OPEN_CLOSE_ATTRIBUTES = [ AttributeType.OPEN_CLOSE, AttributeType.SLAT_ROTATION_IMPULSE, diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 165a655d82b..4c85f52bb28 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -27,14 +27,20 @@ class HomeeEntity(Entity): ) self._entry = entry node = entry.runtime_data.get_node_by_id(attribute.node_id) - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}") - }, - name=node.name, - model=get_name_for_enum(NodeProfile, node.profile), - via_device=(DOMAIN, entry.runtime_data.settings.uid), - ) + # 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.runtime_data.settings.uid}-{attribute.node_id}") + }, + name=node.name, + model=get_name_for_enum(NodeProfile, node.profile), + via_device=(DOMAIN, entry.runtime_data.settings.uid), + ) self._host_connected = entry.runtime_data.connected diff --git a/homeassistant/components/homee/fan.py b/homeassistant/components/homee/fan.py new file mode 100644 index 00000000000..d4694ee8d66 --- /dev/null +++ b/homeassistant/components/homee/fan.py @@ -0,0 +1,134 @@ +"""The Homee fan platform.""" + +import math +from typing import Any, cast + +from pyHomee.const import AttributeType, NodeProfile +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) +from homeassistant.util.scaling import int_states_in_range + +from . import HomeeConfigEntry +from .const import DOMAIN, PRESET_AUTO, PRESET_MANUAL, PRESET_SUMMER +from .entity import HomeeNodeEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Homee fan platform.""" + + async_add_devices( + HomeeFan(node, config_entry) + for node in config_entry.runtime_data.nodes + if node.profile == NodeProfile.VENTILATION_CONTROL + ) + + +class HomeeFan(HomeeNodeEntity, FanEntity): + """Representation of a Homee fan entity.""" + + _attr_translation_key = DOMAIN + _attr_name = None + _attr_preset_modes = [PRESET_MANUAL, PRESET_AUTO, PRESET_SUMMER] + speed_range = (1, 8) + _attr_speed_count = int_states_in_range(speed_range) + + def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + """Initialize a Homee fan entity.""" + super().__init__(node, entry) + self._speed_attribute: HomeeAttribute = cast( + HomeeAttribute, node.get_attribute_by_type(AttributeType.VENTILATION_LEVEL) + ) + self._mode_attribute: HomeeAttribute = cast( + HomeeAttribute, node.get_attribute_by_type(AttributeType.VENTILATION_MODE) + ) + + @property + def supported_features(self) -> FanEntityFeature: + """Return the supported features based on preset_mode.""" + features = FanEntityFeature.PRESET_MODE + + if self.preset_mode == PRESET_MANUAL: + features |= ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + ) + + return features + + @property + def is_on(self) -> bool: + """Return true if the entity is on.""" + return self.percentage > 0 + + @property + def percentage(self) -> int: + """Return the current speed percentage.""" + return ranged_value_to_percentage( + self.speed_range, self._speed_attribute.current_value + ) + + @property + def preset_mode(self) -> str: + """Return the mode from the float state.""" + return self._attr_preset_modes[int(self._mode_attribute.current_value)] + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + await self.async_set_homee_value( + self._speed_attribute, + math.ceil(percentage_to_ranged_value(self.speed_range, percentage)), + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + await self.async_set_homee_value( + self._mode_attribute, self._attr_preset_modes.index(preset_mode) + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self.async_set_homee_value(self._speed_attribute, 0) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the fan on.""" + if preset_mode is not None: + if preset_mode != "manual": + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_preset_mode", + translation_placeholders={"preset_mode": preset_mode}, + ) + + await self.async_set_preset_mode(preset_mode) + + # If no percentage is given, use the last known value. + if percentage is None: + percentage = ranged_value_to_percentage( + self.speed_range, + self._speed_attribute.last_value, + ) + # If the last known value is 0, set 100%. + if percentage == 0: + percentage = 100 + + await self.async_set_percentage(percentage) diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index 07ae598095b..062b530ac7e 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -1,6 +1,36 @@ { "entity": { + "climate": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "mdi:hand-back-left" + } + } + } + } + }, + "fan": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "mdi:hand-back-left", + "auto": "mdi:auto-mode", + "summer": "mdi:sun-thermometer-outline" + } + } + } + } + }, "sensor": { + "brightness": { + "default": "mdi:brightness-5" + }, + "brightness_instance": { + "default": "mdi:brightness-5" + }, "link_quality": { "default": "mdi:signal" }, @@ -9,7 +39,7 @@ } }, "switch": { - "watchdog_on_off": { + "watchdog": { "default": "mdi:dog" }, "manual_operation": { diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py index b9c4460075a..9c66764760e 100644 --- a/homeassistant/components/homee/light.py +++ b/homeassistant/components/homee/light.py @@ -32,6 +32,8 @@ LIGHT_ATTRIBUTES = [ AttributeType.DIMMING_LEVEL, ] +PARALLEL_UPDATES = 0 + def is_light_node(node: HomeeNode) -> bool: """Determine if a node is controllable as a homee light based on its profile and attributes.""" diff --git a/homeassistant/components/homee/lock.py b/homeassistant/components/homee/lock.py new file mode 100644 index 00000000000..4cfc34e11fe --- /dev/null +++ b/homeassistant/components/homee/lock.py @@ -0,0 +1,73 @@ +"""The Homee lock platform.""" + +from typing import Any + +from pyHomee.const import AttributeChangedBy, AttributeType + +from homeassistant.components.lock import LockEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity +from .helpers import get_name_for_enum + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the lock component.""" + + async_add_devices( + HomeeLock(attribute, config_entry) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if (attribute.type == AttributeType.LOCK_STATE and attribute.editable) + ) + + +class HomeeLock(HomeeEntity, LockEntity): + """Representation of a Homee lock.""" + + _attr_name = None + + @property + def is_locked(self) -> bool: + """Return if lock is locked.""" + return self._attribute.current_value == 1.0 + + @property + def is_locking(self) -> bool: + """Return if lock is locking.""" + return self._attribute.target_value > self._attribute.current_value + + @property + def is_unlocking(self) -> bool: + """Return if lock is unlocking.""" + return self._attribute.target_value < self._attribute.current_value + + @property + def changed_by(self) -> str: + """Return by whom or what the lock was last changed.""" + changed_id = str(self._attribute.changed_by_id) + changed_by_name = get_name_for_enum( + AttributeChangedBy, self._attribute.changed_by + ) + if self._attribute.changed_by == AttributeChangedBy.USER: + changed_id = self._entry.runtime_data.get_user_by_id( + self._attribute.changed_by_id + ).username + + return f"{changed_by_name}-{changed_id}" + + async def async_lock(self, **kwargs: Any) -> None: + """Lock specified lock. A code to lock the lock with may be specified.""" + await self.async_set_homee_value(1) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock specified lock. A code to unlock the lock with may be specified.""" + await self.async_set_homee_value(0) diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index e4622222be1..3c2a99c30dc 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "bronze", - "requirements": ["pyHomee==1.2.7"] + "requirements": ["pyHomee==1.2.8"] } diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py index 3f1f08a6618..231c2ecac94 100644 --- a/homeassistant/components/homee/number.py +++ b/homeassistant/components/homee/number.py @@ -1,5 +1,8 @@ """The Homee number platform.""" +from collections.abc import Callable +from dataclasses import dataclass + from pyHomee.const import AttributeType from pyHomee.model import HomeeAttribute @@ -8,7 +11,7 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfSpeed from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -16,69 +19,91 @@ from . import HomeeConfigEntry from .const import HOMEE_UNIT_TO_HA_UNIT from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class HomeeNumberEntityDescription(NumberEntityDescription): + """A class that describes Homee number entities.""" + + native_value_fn: Callable[[float], float] = lambda value: value + set_native_value_fn: Callable[[float], float] = lambda value: value + + NUMBER_DESCRIPTIONS = { - AttributeType.DOWN_POSITION: NumberEntityDescription( + AttributeType.DOWN_POSITION: HomeeNumberEntityDescription( key="down_position", entity_category=EntityCategory.CONFIG, ), - AttributeType.DOWN_SLAT_POSITION: NumberEntityDescription( + AttributeType.DOWN_SLAT_POSITION: HomeeNumberEntityDescription( key="down_slat_position", entity_category=EntityCategory.CONFIG, ), - AttributeType.DOWN_TIME: NumberEntityDescription( + AttributeType.DOWN_TIME: HomeeNumberEntityDescription( key="down_time", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.ENDPOSITION_CONFIGURATION: NumberEntityDescription( + AttributeType.ENDPOSITION_CONFIGURATION: HomeeNumberEntityDescription( key="endposition_configuration", entity_category=EntityCategory.CONFIG, ), - AttributeType.MOTION_ALARM_CANCELATION_DELAY: NumberEntityDescription( + AttributeType.MOTION_ALARM_CANCELATION_DELAY: HomeeNumberEntityDescription( key="motion_alarm_cancelation_delay", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: NumberEntityDescription( + AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: HomeeNumberEntityDescription( key="open_window_detection_sensibility", entity_category=EntityCategory.CONFIG, ), - AttributeType.POLLING_INTERVAL: NumberEntityDescription( + AttributeType.POLLING_INTERVAL: HomeeNumberEntityDescription( key="polling_interval", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.SHUTTER_SLAT_TIME: NumberEntityDescription( + AttributeType.SHUTTER_SLAT_TIME: HomeeNumberEntityDescription( key="shutter_slat_time", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.SLAT_MAX_ANGLE: NumberEntityDescription( + AttributeType.SLAT_MAX_ANGLE: HomeeNumberEntityDescription( key="slat_max_angle", entity_category=EntityCategory.CONFIG, ), - AttributeType.SLAT_MIN_ANGLE: NumberEntityDescription( + AttributeType.SLAT_MIN_ANGLE: HomeeNumberEntityDescription( key="slat_min_angle", entity_category=EntityCategory.CONFIG, ), - AttributeType.SLAT_STEPS: NumberEntityDescription( + AttributeType.SLAT_STEPS: HomeeNumberEntityDescription( key="slat_steps", entity_category=EntityCategory.CONFIG, ), - AttributeType.TEMPERATURE_OFFSET: NumberEntityDescription( + AttributeType.TEMPERATURE_OFFSET: HomeeNumberEntityDescription( key="temperature_offset", entity_category=EntityCategory.CONFIG, ), - AttributeType.UP_TIME: NumberEntityDescription( + AttributeType.UP_TIME: HomeeNumberEntityDescription( key="up_time", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), - AttributeType.WAKE_UP_INTERVAL: NumberEntityDescription( + AttributeType.WAKE_UP_INTERVAL: HomeeNumberEntityDescription( key="wake_up_interval", device_class=NumberDeviceClass.DURATION, entity_category=EntityCategory.CONFIG, ), + AttributeType.WIND_MONITORING_STATE: HomeeNumberEntityDescription( + key="wind_monitoring_state", + device_class=NumberDeviceClass.WIND_SPEED, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=22.5, + native_step=2.5, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + native_value_fn=lambda value: value * 2.5, + set_native_value_fn=lambda value: value / 2.5, + ), } @@ -100,20 +125,25 @@ async def async_setup_entry( class HomeeNumber(HomeeEntity, NumberEntity): """Representation of a Homee number.""" + entity_description: HomeeNumberEntityDescription + def __init__( self, attribute: HomeeAttribute, entry: HomeeConfigEntry, - description: NumberEntityDescription, + description: HomeeNumberEntityDescription, ) -> None: """Initialize a Homee number entity.""" super().__init__(attribute, entry) self.entity_description = description self._attr_translation_key = description.key - self._attr_native_unit_of_measurement = HOMEE_UNIT_TO_HA_UNIT[attribute.unit] - self._attr_native_min_value = attribute.minimum - self._attr_native_max_value = attribute.maximum - self._attr_native_step = attribute.step_value + self._attr_native_unit_of_measurement = ( + description.native_unit_of_measurement + or HOMEE_UNIT_TO_HA_UNIT[attribute.unit] + ) + self._attr_native_min_value = description.native_min_value or attribute.minimum + self._attr_native_max_value = description.native_max_value or attribute.maximum + self._attr_native_step = description.native_step or attribute.step_value @property def available(self) -> bool: @@ -121,10 +151,12 @@ class HomeeNumber(HomeeEntity, NumberEntity): return super().available and self._attribute.editable @property - def native_value(self) -> int: + def native_value(self) -> float | None: """Return the native value of the number.""" - return int(self._attribute.current_value) + return self.entity_description.native_value_fn(self._attribute.current_value) async def async_set_native_value(self, value: float) -> None: """Set the selected value.""" - await self.async_set_homee_value(value) + await self.async_set_homee_value( + self.entity_description.set_native_value_fn(value) + ) diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml index ff99d177018..906218cf823 100644 --- a/homeassistant/components/homee/quality_scale.yaml +++ b/homeassistant/components/homee/quality_scale.yaml @@ -35,7 +35,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: todo test-coverage: todo diff --git a/homeassistant/components/homee/select.py b/homeassistant/components/homee/select.py new file mode 100644 index 00000000000..70c7972bbda --- /dev/null +++ b/homeassistant/components/homee/select.py @@ -0,0 +1,63 @@ +"""The Homee select platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity + +PARALLEL_UPDATES = 0 + +SELECT_DESCRIPTIONS: dict[AttributeType, SelectEntityDescription] = { + AttributeType.REPEATER_MODE: SelectEntityDescription( + key="repeater_mode", + options=["off", "level1", "level2"], + entity_category=EntityCategory.CONFIG, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the select component.""" + + async_add_entities( + HomeeSelect(attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in SELECT_DESCRIPTIONS and attribute.editable + ) + + +class HomeeSelect(HomeeEntity, SelectEntity): + """Representation of a Homee select entity.""" + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: SelectEntityDescription, + ) -> None: + """Initialize a Homee select entity.""" + super().__init__(attribute, entry) + self.entity_description = description + assert description.options is not None + self._attr_options = description.options + self._attr_translation_key = description.key + + @property + def current_option(self) -> str: + """Return the current selected option.""" + return self.options[int(self._attribute.current_value)] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.async_set_homee_value(self.options.index(option)) diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index 86733aae778..ab1d5bd4f49 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -6,7 +6,10 @@ from dataclasses import dataclass from pyHomee.const import AttributeType, NodeState from pyHomee.model import HomeeAttribute, HomeeNode +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, SensorEntity, SensorEntityDescription, @@ -14,10 +17,17 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import HomeeConfigEntry from .const import ( + DOMAIN, HOMEE_UNIT_TO_HA_UNIT, OPEN_CLOSE_MAP, OPEN_CLOSE_MAP_REVERSED, @@ -27,6 +37,8 @@ from .const import ( from .entity import HomeeEntity, HomeeNodeEntity from .helpers import get_name_for_enum +PARALLEL_UPDATES = 0 + def get_open_close_value(attribute: HomeeAttribute) -> str | None: """Return the open/close value.""" @@ -40,10 +52,22 @@ def get_window_value(attribute: HomeeAttribute) -> str | None: return vals.get(attribute.current_value) +def get_brightness_device_class( + attribute: HomeeAttribute, device_class: SensorDeviceClass | None +) -> SensorDeviceClass | None: + """Return the device class for a brightness sensor.""" + if attribute.unit == "%": + return None + return device_class + + @dataclass(frozen=True, kw_only=True) class HomeeSensorEntityDescription(SensorEntityDescription): """A class that describes Homee sensor entities.""" + device_class_fn: Callable[ + [HomeeAttribute, SensorDeviceClass | None], SensorDeviceClass | None + ] = lambda attribute, device_class: device_class value_fn: Callable[[HomeeAttribute], str | float | None] = ( lambda value: value.current_value ) @@ -67,6 +91,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = { AttributeType.BRIGHTNESS: HomeeSensorEntityDescription( key="brightness", device_class=SensorDeviceClass.ILLUMINANCE, + device_class_fn=get_brightness_device_class, state_class=SensorStateClass.MEASUREMENT, value_fn=( lambda attribute: attribute.current_value * 1000 @@ -259,14 +284,55 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = ( ) +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: HomeeConfigEntry, async_add_devices: AddConfigEntryEntitiesCallback, ) -> None: """Add the homee platform for the sensor components.""" - + ent_reg = er.async_get(hass) devices: list[HomeeSensor | HomeeNodeSensor] = [] + + def add_deprecated_entity( + attribute: HomeeAttribute, description: HomeeSensorEntityDescription + ) -> None: + """Add deprecated entities.""" + entity_uid = f"{config_entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}" + if entity_id := ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, entity_uid): + 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_{entity_uid}", + ) + elif entity_entry: + devices.append(HomeeSensor(attribute, config_entry, description)) + if entity_used_in(hass, entity_id): + async_create_issue( + hass, + DOMAIN, + f"deprecated_entity_{entity_uid}", + breaks_in_ha_version="2025.12.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 node in config_entry.runtime_data.nodes: # Node properties that are sensors. devices.extend( @@ -275,11 +341,15 @@ async def async_setup_entry( ) # 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 - ) + for attribute in node.attributes: + if attribute.type == AttributeType.CURRENT_VALVE_POSITION: + add_deprecated_entity(attribute, SENSOR_DESCRIPTIONS[attribute.type]) + elif attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable: + devices.append( + HomeeSensor( + attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type] + ) + ) if devices: async_add_devices(devices) @@ -303,6 +373,9 @@ class HomeeSensor(HomeeEntity, SensorEntity): if attribute.instance > 0: self._attr_translation_key = f"{self._attr_translation_key}_instance" self._attr_translation_placeholders = {"instance": str(attribute.instance)} + self._attr_device_class = description.device_class_fn( + attribute, description.device_class + ) @property def native_value(self) -> float | str | None: diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index cf5b90dbe2a..092fca0c0ac 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "Homee {name} ({host})", + "flow_title": "homee {name} ({host})", "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, @@ -18,14 +18,89 @@ "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." + "host": "The IP address of your homee.", + "username": "The username for your homee.", + "password": "The password for your homee." } } } }, "entity": { + "alarm_control_panel": { + "homee_mode": { + "name": "Status" + } + }, + "binary_sensor": { + "blackout_alarm": { + "name": "Blackout" + }, + "carbon_dioxide": { + "name": "Carbon dioxide" + }, + "flood": { + "name": "Flood" + }, + "high_temperature": { + "name": "High temperature" + }, + "leak_alarm": { + "name": "Leak" + }, + "load_alarm": { + "name": "Load", + "state": { + "off": "[%key:common::state::normal%]", + "on": "Overload" + } + }, + "low_temperature": { + "name": "Low temperature" + }, + "malfunction": { + "name": "Malfunction" + }, + "maximum": { + "name": "Maximum level" + }, + "minimum": { + "name": "Minimum level" + }, + "motor_blocked": { + "name": "Motor blocked" + }, + "overcurrent": { + "name": "Overcurrent" + }, + "overload": { + "name": "Overload" + }, + "rain": { + "name": "Rain" + }, + "replace_filter": { + "name": "Replace filter", + "state": { + "on": "Replace" + } + }, + "storage": { + "name": "Storage", + "state": { + "off": "Space available", + "on": "Storage full" + } + }, + "surge": { + "name": "Surge" + }, + "voltage_drop": { + "name": "Voltage drop" + }, + "water": { + "name": "Water" + } + }, "button": { "automatic_mode": { "name": "Automatic mode" @@ -61,6 +136,30 @@ "name": "Ventilate" } }, + "climate": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "[%key:common::state::manual%]" + } + } + } + } + }, + "fan": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "[%key:common::state::manual%]", + "auto": "[%key:common::state::auto%]", + "summer": "Summer" + } + } + } + } + }, "light": { "light_instance": { "name": "Light {instance}" @@ -108,9 +207,25 @@ }, "wake_up_interval": { "name": "Wake-up interval" + }, + "wind_monitoring_state": { + "name": "Threshold for wind trigger" + } + }, + "select": { + "repeater_mode": { + "name": "Repeater mode", + "state": { + "off": "[%key:common::state::off%]", + "level1": "Level 1", + "level2": "Level 2" + } } }, "sensor": { + "brightness": { + "name": "Illuminance" + }, "brightness_instance": { "name": "Illuminance {instance}" }, @@ -214,8 +329,8 @@ "open": "[%key:common::state::open%]", "closed": "[%key:common::state::closed%]", "partial": "Partially open", - "opening": "Opening", - "closing": "Closing" + "opening": "[%key:common::state::opening%]", + "closing": "[%key:common::state::closing%]" } }, "uv": { @@ -258,7 +373,19 @@ }, "exceptions": { "connection_closed": { - "message": "Could not connect to Homee while setting attribute." + "message": "Could not connect to homee while setting attribute." + }, + "disarm_not_supported": { + "message": "Disarm is not supported by homee." + }, + "invalid_preset_mode": { + "message": "Invalid preset mode: {preset_mode}. Turning on is only supported with preset mode 'Manual'." + } + }, + "issues": { + "deprecated_entity": { + "title": "The Homee {name} entity is deprecated", + "description": "The Homee entity `{entity}` is deprecated and will be removed in release 2025.12.\nThe valve is available directly in the respective climate entity.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py index 86c7acdbf11..041b96963f1 100644 --- a/homeassistant/components/homee/switch.py +++ b/homeassistant/components/homee/switch.py @@ -20,6 +20,8 @@ from . import HomeeConfigEntry from .const import CLIMATE_PROFILES, LIGHT_PROFILES from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + def get_device_class( attribute: HomeeAttribute, config_entry: HomeeConfigEntry diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py index 9a4ff446a10..995716d7ef8 100644 --- a/homeassistant/components/homee/valve.py +++ b/homeassistant/components/homee/valve.py @@ -15,6 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeeConfigEntry from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + VALVE_DESCRIPTIONS = { AttributeType.CURRENT_VALVE_POSITION: ValveEntityDescription( key="valve_position", diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 97fb17d7db5..8b526b62302 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -31,6 +31,7 @@ from homeassistant.components.device_automation.trigger import ( async_validate_trigger_config, ) from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN 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 @@ -49,6 +50,7 @@ from homeassistant.const import ( CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, + CONF_TYPE, EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, ) @@ -83,6 +85,7 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.util.async_ import create_eager_task from . import ( # noqa: F401 + type_air_purifiers, type_cameras, type_covers, type_fans, @@ -113,6 +116,8 @@ from .const import ( CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, CONFIG_OPTIONS, DEFAULT_EXCLUDE_ACCESSORY_MODE, DEFAULT_HOMEKIT_MODE, @@ -126,6 +131,7 @@ from .const import ( SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, SIGNAL_RELOAD_ENTITIES, + TYPE_AIR_PURIFIER, ) from .iidmanager import AccessoryIIDStorage from .models import HomeKitConfigEntry, HomeKitEntryData @@ -169,6 +175,8 @@ MOTION_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.MOTION) MOTION_SENSOR = (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION) DOORBELL_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.DOORBELL) HUMIDITY_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.HUMIDITY) +TEMPERATURE_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.TEMPERATURE) +PM25_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.PM25) def _has_all_unique_names_and_ports( @@ -221,6 +229,34 @@ UNPAIR_SERVICE_SCHEMA = vol.All( ) +@callback +def _async_update_entries_from_yaml( + hass: HomeAssistant, config: ConfigType, start_import_flow: bool +) -> None: + current_entries = hass.config_entries.async_entries(DOMAIN) + entries_by_name, entries_by_port = _async_get_imported_entries_indices( + current_entries + ) + hk_config: list[dict[str, Any]] = config[DOMAIN] + + for index, conf in enumerate(hk_config): + if _async_update_config_entry_from_yaml( + hass, entries_by_name, entries_by_port, conf + ): + continue + + if start_import_flow: + conf[CONF_ENTRY_INDEX] = index + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=conf, + ), + eager_start=True, + ) + + def _async_all_homekit_instances(hass: HomeAssistant) -> list[HomeKit]: """All active HomeKit instances.""" hk_data: HomeKitEntryData | None @@ -258,31 +294,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await hass.async_add_executor_job(get_loader) _async_register_events_and_services(hass) - if DOMAIN not in config: return True - current_entries = hass.config_entries.async_entries(DOMAIN) - entries_by_name, entries_by_port = _async_get_imported_entries_indices( - current_entries - ) - - for index, conf in enumerate(config[DOMAIN]): - if _async_update_config_entry_from_yaml( - hass, entries_by_name, entries_by_port, conf - ): - continue - - conf[CONF_ENTRY_INDEX] = index - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=conf, - ), - eager_start=True, - ) - + _async_update_entries_from_yaml(hass, config, start_import_flow=True) return True @@ -326,13 +341,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeKitConfigEntry) -> b conf = entry.data options = entry.options - name = conf[CONF_NAME] - port = conf[CONF_PORT] - _LOGGER.debug("Begin setup HomeKit for %s", name) - + name: str = conf[CONF_NAME] + port: int = conf[CONF_PORT] # ip_address and advertise_ip are yaml only - ip_address = conf.get(CONF_IP_ADDRESS, _DEFAULT_BIND) - advertise_ips: list[str] = conf.get( + ip_address: str | list[str] | None = conf.get(CONF_IP_ADDRESS, _DEFAULT_BIND) + advertise_ips: list[str] + advertise_ips = conf.get( CONF_ADVERTISE_IP ) or await network.async_get_announce_addresses(hass) @@ -344,13 +358,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeKitConfigEntry) -> b # with users who have not migrated yet we do not do exclude # these entities by default as we cannot migrate automatically # since it requires a re-pairing. - exclude_accessory_mode = conf.get( + exclude_accessory_mode: bool = conf.get( CONF_EXCLUDE_ACCESSORY_MODE, DEFAULT_EXCLUDE_ACCESSORY_MODE ) - homekit_mode = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) - entity_config = options.get(CONF_ENTITY_CONFIG, {}).copy() - entity_filter = FILTER_SCHEMA(options.get(CONF_FILTER, {})) - devices = options.get(CONF_DEVICES, []) + homekit_mode: str = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) + entity_config: dict[str, Any] = options.get(CONF_ENTITY_CONFIG, {}).copy() + entity_filter: EntityFilter = FILTER_SCHEMA(options.get(CONF_FILTER, {})) + devices: list[str] = options.get(CONF_DEVICES, []) homekit = HomeKit( hass, @@ -500,26 +514,15 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None: async def _handle_homekit_reload(service: ServiceCall) -> None: """Handle start HomeKit service call.""" config = await async_integration_yaml_config(hass, DOMAIN) - if not config or DOMAIN not in config: return - - current_entries = hass.config_entries.async_entries(DOMAIN) - entries_by_name, entries_by_port = _async_get_imported_entries_indices( - current_entries - ) - - for conf in config[DOMAIN]: - _async_update_config_entry_from_yaml( - hass, entries_by_name, entries_by_port, conf + _async_update_entries_from_yaml(hass, config, start_import_flow=False) + await asyncio.gather( + *( + create_eager_task(hass.config_entries.async_reload(entry.entry_id)) + for entry in hass.config_entries.async_entries(DOMAIN) ) - - reload_tasks = [ - create_eager_task(hass.config_entries.async_reload(entry.entry_id)) - for entry in current_entries - ] - - await asyncio.gather(*reload_tasks) + ) async_register_admin_service( hass, @@ -537,7 +540,7 @@ class HomeKit: hass: HomeAssistant, name: str, port: int, - ip_address: str | None, + ip_address: list[str] | str | None, entity_filter: EntityFilter, exclude_accessory_mode: bool, entity_config: dict[str, Any], @@ -1141,6 +1144,21 @@ class HomeKit: CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id ) + if domain == FAN_DOMAIN: + if current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR): + config[entity_id].setdefault( + CONF_LINKED_HUMIDITY_SENSOR, current_humidity_sensor_entity_id + ) + if current_pm25_sensor_entity_id := lookup.get(PM25_SENSOR): + config[entity_id].setdefault(CONF_TYPE, TYPE_AIR_PURIFIER) + config[entity_id].setdefault( + CONF_LINKED_PM25_SENSOR, current_pm25_sensor_entity_id + ) + if current_temperature_sensor_entity_id := lookup.get(TEMPERATURE_SENSOR): + config[entity_id].setdefault( + CONF_LINKED_TEMPERATURE_SENSOR, current_temperature_sensor_entity_id + ) + if domain == HUMIDIFIER_DOMAIN and ( current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR) ): diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 8d10387e239..95842d56094 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -15,6 +15,7 @@ from pyhap.service import Service from pyhap.util import callback as pyhap_callback from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature +from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.remote import RemoteEntityFeature from homeassistant.components.sensor import SensorDeviceClass @@ -84,6 +85,8 @@ from .const import ( SERV_ACCESSORY_INFO, SERV_BATTERY_SERVICE, SIGNAL_RELOAD_ENTITIES, + TYPE_AIR_PURIFIER, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -111,6 +114,10 @@ SWITCH_TYPES = { TYPE_SWITCH: "Switch", TYPE_VALVE: "ValveSwitch", } +FAN_TYPES = { + TYPE_AIR_PURIFIER: "AirPurifier", + TYPE_FAN: "Fan", +} TYPES: Registry[str, type[HomeAccessory]] = Registry() RELOAD_ON_CHANGE_ATTRS = ( @@ -177,7 +184,10 @@ def get_accessory( # noqa: C901 a_type = "WindowCovering" elif state.domain == "fan": - a_type = "Fan" + if fan_type := config.get(CONF_TYPE): + a_type = FAN_TYPES[fan_type] + else: + a_type = "Fan" elif state.domain == "humidifier": a_type = "HumidifierDehumidifier" @@ -235,6 +245,13 @@ def get_accessory( # noqa: C901 a_type = "CarbonDioxideSensor" elif device_class == SensorDeviceClass.ILLUMINANCE or unit == LIGHT_LUX: a_type = "LightSensor" + else: + _LOGGER.debug( + "%s: Unsupported sensor type (device_class=%s) (unit=%s)", + state.entity_id, + device_class, + unit, + ) elif state.domain == "switch": if switch_type := config.get(CONF_TYPE): @@ -250,6 +267,13 @@ def get_accessory( # noqa: C901 elif state.domain == "vacuum": a_type = "Vacuum" + elif ( + state.domain == "lawn_mower" + and features & LawnMowerEntityFeature.DOCK + and features & LawnMowerEntityFeature.START_MOWING + ): + a_type = "LawnMower" + elif state.domain == "remote" and features & RemoteEntityFeature.ACTIVITY: a_type = "ActivityRemote" diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 53db7774821..0ef2e8563bc 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -106,6 +106,7 @@ SUPPORTED_DOMAINS = [ "sensor", "switch", "vacuum", + "lawn_mower", "water_heater", VALVE_DOMAIN, ] @@ -123,6 +124,7 @@ DEFAULT_DOMAINS = [ REMOTE_DOMAIN, "switch", "vacuum", + "lawn_mower", "water_heater", ] diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 00b3de49169..ae682a0ea2d 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -49,9 +49,13 @@ CONF_EXCLUDE_ACCESSORY_MODE = "exclude_accessory_mode" CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor" CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor" CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor" +CONF_LINKED_FILTER_CHANGE_INDICATION = "linked_filter_change_indication_binary_sensor" +CONF_LINKED_FILTER_LIFE_LEVEL = "linked_filter_life_level_sensor" CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor" CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor" CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor" +CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor" +CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" CONF_MAX_FPS = "max_fps" CONF_MAX_HEIGHT = "max_height" @@ -120,12 +124,15 @@ TYPE_SHOWER = "shower" TYPE_SPRINKLER = "sprinkler" TYPE_SWITCH = "switch" TYPE_VALVE = "valve" +TYPE_FAN = "fan" +TYPE_AIR_PURIFIER = "air_purifier" # #### Categories #### CATEGORY_RECEIVER = 34 # #### Services #### SERV_ACCESSORY_INFO = "AccessoryInformation" +SERV_AIR_PURIFIER = "AirPurifier" SERV_AIR_QUALITY_SENSOR = "AirQualitySensor" SERV_BATTERY_SERVICE = "BatteryService" SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" @@ -135,6 +142,7 @@ SERV_CONTACT_SENSOR = "ContactSensor" SERV_DOOR = "Door" SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" +SERV_FILTER_MAINTENANCE = "FilterMaintenance" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier" SERV_HUMIDITY_SENSOR = "HumiditySensor" @@ -181,6 +189,7 @@ CHAR_CONFIGURED_NAME = "ConfiguredName" CHAR_CONTACT_SENSOR_STATE = "ContactSensorState" CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature" CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel" +CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState" CHAR_CURRENT_DOOR_STATE = "CurrentDoorState" CHAR_CURRENT_FAN_STATE = "CurrentFanState" CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState" @@ -192,6 +201,8 @@ CHAR_CURRENT_TEMPERATURE = "CurrentTemperature" CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle" CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState" CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityDehumidifierThreshold" +CHAR_FILTER_CHANGE_INDICATION = "FilterChangeIndication" +CHAR_FILTER_LIFE_LEVEL = "FilterLifeLevel" CHAR_FIRMWARE_REVISION = "FirmwareRevision" CHAR_HARDWARE_REVISION = "HardwareRevision" CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature" @@ -229,6 +240,7 @@ CHAR_SMOKE_DETECTED = "SmokeDetected" CHAR_STATUS_LOW_BATTERY = "StatusLowBattery" CHAR_STREAMING_STRATUS = "StreamingStatus" CHAR_SWING_MODE = "SwingMode" +CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState" CHAR_TARGET_DOOR_STATE = "TargetDoorState" CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" CHAR_TARGET_POSITION = "TargetPosition" @@ -256,6 +268,7 @@ PROP_VALID_VALUES = "ValidValues" # #### Thresholds #### THRESHOLD_CO = 25 THRESHOLD_CO2 = 1000 +THRESHOLD_FILTER_CHANGE_NEEDED = 10 # #### Default values #### DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index f9a31489ca4..431de804023 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.2.6", + "fnv-hash-fast==1.5.0", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index dcdf6892dc2..e6507c4a912 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -39,7 +39,7 @@ "camera_copy": "Cameras that support native H.264 streams", "camera_audio": "Cameras that support audio" }, - "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", + "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single-board computers.", "title": "Camera configuration" }, "advanced": { diff --git a/homeassistant/components/homekit/type_air_purifiers.py b/homeassistant/components/homekit/type_air_purifiers.py new file mode 100644 index 00000000000..feb75f4a856 --- /dev/null +++ b/homeassistant/components/homekit/type_air_purifiers.py @@ -0,0 +1,485 @@ +"""Class to hold all air purifier accessories.""" + +import logging +from typing import Any + +from pyhap.characteristic import Characteristic +from pyhap.const import CATEGORY_AIR_PURIFIER +from pyhap.service import Service +from pyhap.util import callback as pyhap_callback + +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfTemperature, +) +from homeassistant.core import ( + Event, + EventStateChangedData, + HassJobType, + State, + callback, +) +from homeassistant.helpers.event import async_track_state_change_event + +from .accessories import TYPES +from .const import ( + CHAR_ACTIVE, + CHAR_AIR_QUALITY, + CHAR_CURRENT_AIR_PURIFIER_STATE, + CHAR_CURRENT_HUMIDITY, + CHAR_CURRENT_TEMPERATURE, + CHAR_FILTER_CHANGE_INDICATION, + CHAR_FILTER_LIFE_LEVEL, + CHAR_NAME, + CHAR_PM25_DENSITY, + CHAR_TARGET_AIR_PURIFIER_STATE, + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, + CONF_LINKED_HUMIDITY_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, + SERV_AIR_PURIFIER, + SERV_AIR_QUALITY_SENSOR, + SERV_FILTER_MAINTENANCE, + SERV_HUMIDITY_SENSOR, + SERV_TEMPERATURE_SENSOR, + THRESHOLD_FILTER_CHANGE_NEEDED, +) +from .type_fans import ATTR_PRESET_MODE, CHAR_ROTATION_SPEED, Fan +from .util import ( + cleanup_name_for_homekit, + convert_to_float, + density_to_air_quality, + temperature_to_homekit, +) + +_LOGGER = logging.getLogger(__name__) + +CURRENT_STATE_INACTIVE = 0 +CURRENT_STATE_IDLE = 1 +CURRENT_STATE_PURIFYING_AIR = 2 +TARGET_STATE_MANUAL = 0 +TARGET_STATE_AUTO = 1 +FILTER_CHANGE_FILTER = 1 +FILTER_OK = 0 + +IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN} + + +@TYPES.register("AirPurifier") +class AirPurifier(Fan): + """Generate an AirPurifier accessory for an air purifier entity. + + Currently supports, in addition to Fan properties: + temperature; humidity; PM2.5; auto mode. + """ + + def __init__(self, *args: Any) -> None: + """Initialize a new AirPurifier accessory object.""" + super().__init__(*args, category=CATEGORY_AIR_PURIFIER) + + self.auto_preset: str | None = None + if self.preset_modes is not None: + for preset in self.preset_modes: + if str(preset).lower() == "auto": + self.auto_preset = preset + break + + def create_services(self) -> Service: + """Create and configure the primary service for this accessory.""" + self.chars.append(CHAR_ACTIVE) + self.chars.append(CHAR_CURRENT_AIR_PURIFIER_STATE) + self.chars.append(CHAR_TARGET_AIR_PURIFIER_STATE) + serv_air_purifier = self.add_preload_service(SERV_AIR_PURIFIER, self.chars) + self.set_primary_service(serv_air_purifier) + + self.char_active: Characteristic = serv_air_purifier.configure_char( + CHAR_ACTIVE, value=0 + ) + + self.preset_mode_chars: dict[str, Characteristic] + self.char_current_humidity: Characteristic | None = None + self.char_pm25_density: Characteristic | None = None + self.char_current_temperature: Characteristic | None = None + self.char_filter_change_indication: Characteristic | None = None + self.char_filter_life_level: Characteristic | None = None + + self.char_target_air_purifier_state: Characteristic = ( + serv_air_purifier.configure_char( + CHAR_TARGET_AIR_PURIFIER_STATE, + value=0, + ) + ) + + self.char_current_air_purifier_state: Characteristic = ( + serv_air_purifier.configure_char( + CHAR_CURRENT_AIR_PURIFIER_STATE, + value=0, + ) + ) + + self.linked_humidity_sensor = self.config.get(CONF_LINKED_HUMIDITY_SENSOR) + if self.linked_humidity_sensor: + humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME) + serv_air_purifier.add_linked_service(humidity_serv) + self.char_current_humidity = humidity_serv.configure_char( + CHAR_CURRENT_HUMIDITY, value=0 + ) + + humidity_state = self.hass.states.get(self.linked_humidity_sensor) + if humidity_state: + self._async_update_current_humidity(humidity_state) + + self.linked_pm25_sensor = self.config.get(CONF_LINKED_PM25_SENSOR) + if self.linked_pm25_sensor: + pm25_serv = self.add_preload_service( + SERV_AIR_QUALITY_SENSOR, + [CHAR_AIR_QUALITY, CHAR_NAME, CHAR_PM25_DENSITY], + ) + serv_air_purifier.add_linked_service(pm25_serv) + self.char_pm25_density = pm25_serv.configure_char( + CHAR_PM25_DENSITY, value=0 + ) + + self.char_air_quality = pm25_serv.configure_char(CHAR_AIR_QUALITY) + + pm25_state = self.hass.states.get(self.linked_pm25_sensor) + if pm25_state: + self._async_update_current_pm25(pm25_state) + + self.linked_temperature_sensor = self.config.get(CONF_LINKED_TEMPERATURE_SENSOR) + if self.linked_temperature_sensor: + temperature_serv = self.add_preload_service( + SERV_TEMPERATURE_SENSOR, [CHAR_NAME, CHAR_CURRENT_TEMPERATURE] + ) + serv_air_purifier.add_linked_service(temperature_serv) + self.char_current_temperature = temperature_serv.configure_char( + CHAR_CURRENT_TEMPERATURE, value=0 + ) + + temperature_state = self.hass.states.get(self.linked_temperature_sensor) + if temperature_state: + self._async_update_current_temperature(temperature_state) + + self.linked_filter_change_indicator_binary_sensor = self.config.get( + CONF_LINKED_FILTER_CHANGE_INDICATION + ) + self.linked_filter_life_level_sensor = self.config.get( + CONF_LINKED_FILTER_LIFE_LEVEL + ) + if ( + self.linked_filter_change_indicator_binary_sensor + or self.linked_filter_life_level_sensor + ): + chars = [CHAR_NAME, CHAR_FILTER_CHANGE_INDICATION] + if self.linked_filter_life_level_sensor: + chars.append(CHAR_FILTER_LIFE_LEVEL) + serv_filter_maintenance = self.add_preload_service( + SERV_FILTER_MAINTENANCE, chars + ) + serv_air_purifier.add_linked_service(serv_filter_maintenance) + serv_filter_maintenance.configure_char( + CHAR_NAME, + value=cleanup_name_for_homekit(f"{self.display_name} Filter"), + ) + + self.char_filter_change_indication = serv_filter_maintenance.configure_char( + CHAR_FILTER_CHANGE_INDICATION, + value=0, + ) + + if self.linked_filter_change_indicator_binary_sensor: + filter_change_indicator_state = self.hass.states.get( + self.linked_filter_change_indicator_binary_sensor + ) + if filter_change_indicator_state: + self._async_update_filter_change_indicator( + filter_change_indicator_state + ) + + if self.linked_filter_life_level_sensor: + self.char_filter_life_level = serv_filter_maintenance.configure_char( + CHAR_FILTER_LIFE_LEVEL, + value=0, + ) + + filter_life_level_state = self.hass.states.get( + self.linked_filter_life_level_sensor + ) + if filter_life_level_state: + self._async_update_filter_life_level(filter_life_level_state) + + return serv_air_purifier + + def should_add_preset_mode_switch(self, preset_mode: str) -> bool: + """Check if a preset mode switch should be added.""" + return preset_mode.lower() != "auto" + + @callback + @pyhap_callback # type: ignore[misc] + def run(self) -> None: + """Handle accessory driver started event. + + Run inside the Home Assistant event loop. + """ + if self.linked_humidity_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_humidity_sensor], + self._async_update_current_humidity_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_pm25_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_pm25_sensor], + self._async_update_current_pm25_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_temperature_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_temperature_sensor], + self._async_update_current_temperature_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_filter_change_indicator_binary_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_filter_change_indicator_binary_sensor], + self._async_update_filter_change_indicator_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_filter_life_level_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_filter_life_level_sensor], + self._async_update_filter_life_level_event, + job_type=HassJobType.Callback, + ) + ) + + super().run() + + @callback + def _async_update_current_humidity_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_humidity(event.data["new_state"]) + + @callback + def _async_update_current_humidity(self, new_state: State | None) -> None: + """Handle linked humidity sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_humidity := convert_to_float(new_state.state)) is None + or not self.char_current_humidity + or self.char_current_humidity.value == current_humidity + ): + return + + _LOGGER.debug( + "%s: Linked humidity sensor %s changed to %d", + self.entity_id, + self.linked_humidity_sensor, + current_humidity, + ) + self.char_current_humidity.set_value(current_humidity) + + @callback + def _async_update_current_pm25_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_pm25(event.data["new_state"]) + + @callback + def _async_update_current_pm25(self, new_state: State | None) -> None: + """Handle linked pm25 sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_pm25 := convert_to_float(new_state.state)) is None + or not self.char_pm25_density + or self.char_pm25_density.value == current_pm25 + ): + return + + _LOGGER.debug( + "%s: Linked pm25 sensor %s changed to %d", + self.entity_id, + self.linked_pm25_sensor, + current_pm25, + ) + self.char_pm25_density.set_value(current_pm25) + air_quality = density_to_air_quality(current_pm25) + self.char_air_quality.set_value(air_quality) + _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) + + @callback + def _async_update_current_temperature_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_temperature(event.data["new_state"]) + + @callback + def _async_update_current_temperature(self, new_state: State | None) -> None: + """Handle linked temperature sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_temperature := convert_to_float(new_state.state)) is None + or not self.char_current_temperature + or self.char_current_temperature.value == current_temperature + ): + return + + unit = new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature.CELSIUS + ) + current_temperature = temperature_to_homekit(current_temperature, unit) + + _LOGGER.debug( + "%s: Linked temperature sensor %s changed to %d °C", + self.entity_id, + self.linked_temperature_sensor, + current_temperature, + ) + self.char_current_temperature.set_value(current_temperature) + + @callback + def _async_update_filter_change_indicator_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_filter_change_indicator(event.data.get("new_state")) + + @callback + def _async_update_filter_change_indicator(self, new_state: State | None) -> None: + """Handle linked filter change indicator binary sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + current_change_indicator = ( + FILTER_CHANGE_FILTER if new_state.state == "on" else FILTER_OK + ) + if ( + not self.char_filter_change_indication + or self.char_filter_change_indication.value == current_change_indicator + ): + return + + _LOGGER.debug( + "%s: Linked filter change indicator binary sensor %s changed to %d", + self.entity_id, + self.linked_filter_change_indicator_binary_sensor, + current_change_indicator, + ) + self.char_filter_change_indication.set_value(current_change_indicator) + + @callback + def _async_update_filter_life_level_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_filter_life_level(event.data.get("new_state")) + + @callback + def _async_update_filter_life_level(self, new_state: State | None) -> None: + """Handle linked filter life level sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_life_level := convert_to_float(new_state.state)) is not None + and self.char_filter_life_level + and self.char_filter_life_level.value != current_life_level + ): + _LOGGER.debug( + "%s: Linked filter life level sensor %s changed to %d", + self.entity_id, + self.linked_filter_life_level_sensor, + current_life_level, + ) + self.char_filter_life_level.set_value(current_life_level) + + if self.linked_filter_change_indicator_binary_sensor or not current_life_level: + # Handled by its own event listener + return + + current_change_indicator = ( + FILTER_CHANGE_FILTER + if (current_life_level < THRESHOLD_FILTER_CHANGE_NEEDED) + else FILTER_OK + ) + if ( + not self.char_filter_change_indication + or self.char_filter_change_indication.value == current_change_indicator + ): + return + + _LOGGER.debug( + "%s: Linked filter life level sensor %s changed to %d", + self.entity_id, + self.linked_filter_life_level_sensor, + current_change_indicator, + ) + self.char_filter_change_indication.set_value(current_change_indicator) + + @callback + def async_update_state(self, new_state: State) -> None: + """Update fan after state change.""" + super().async_update_state(new_state) + # Handle State + state = new_state.state + + if self.char_current_air_purifier_state is not None: + self.char_current_air_purifier_state.set_value( + CURRENT_STATE_PURIFYING_AIR + if state == STATE_ON + else CURRENT_STATE_INACTIVE + ) + + # Automatic mode is represented in HASS by a preset called Auto or auto + attributes = new_state.attributes + if ATTR_PRESET_MODE in attributes: + current_preset_mode = attributes.get(ATTR_PRESET_MODE) + self.char_target_air_purifier_state.set_value( + TARGET_STATE_AUTO + if current_preset_mode and current_preset_mode.lower() == "auto" + else TARGET_STATE_MANUAL + ) + + def set_chars(self, char_values: dict[str, Any]) -> None: + """Handle automatic mode after state change.""" + super().set_chars(char_values) + if ( + CHAR_TARGET_AIR_PURIFIER_STATE in char_values + and self.auto_preset is not None + ): + if char_values[CHAR_TARGET_AIR_PURIFIER_STATE] == TARGET_STATE_AUTO: + super().set_preset_mode(True, self.auto_preset) + elif self.char_speed is not None: + super().set_chars({CHAR_ROTATION_SPEED: self.char_speed.get_value()}) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 542d4500cbc..5c91dd0c3bb 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -4,6 +4,7 @@ import logging from typing import Any from pyhap.const import CATEGORY_FAN +from pyhap.service import Service from homeassistant.components.fan import ( ATTR_DIRECTION, @@ -34,6 +35,7 @@ from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import ( CHAR_ACTIVE, + CHAR_CONFIGURED_NAME, CHAR_NAME, CHAR_ON, CHAR_ROTATION_DIRECTION, @@ -56,9 +58,9 @@ class Fan(HomeAccessory): Currently supports: state, speed, oscillate, direction. """ - def __init__(self, *args: Any) -> None: + def __init__(self, *args: Any, category: int = CATEGORY_FAN) -> None: """Initialize a new Fan accessory object.""" - super().__init__(*args, category=CATEGORY_FAN) + super().__init__(*args, category=category) self.chars: list[str] = [] state = self.hass.states.get(self.entity_id) assert state @@ -79,12 +81,8 @@ class Fan(HomeAccessory): self.chars.append(CHAR_SWING_MODE) if features & FanEntityFeature.SET_SPEED: self.chars.append(CHAR_ROTATION_SPEED) - if self.preset_modes and len(self.preset_modes) == 1: - self.chars.append(CHAR_TARGET_FAN_STATE) - serv_fan = self.add_preload_service(SERV_FANV2, self.chars) - self.set_primary_service(serv_fan) - self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0) + serv_fan = self.create_services() self.char_direction = None self.char_speed = None @@ -107,15 +105,25 @@ class Fan(HomeAccessory): properties={PROP_MIN_STEP: percentage_step}, ) - if self.preset_modes and len(self.preset_modes) == 1: + if ( + self.preset_modes + and len(self.preset_modes) == 1 + # NOTE: This would be missing for air purifiers + and CHAR_TARGET_FAN_STATE in self.chars + ): self.char_target_fan_state = serv_fan.configure_char( CHAR_TARGET_FAN_STATE, value=0, ) elif self.preset_modes: for preset_mode in self.preset_modes: + if not self.should_add_preset_mode_switch(preset_mode): + continue + preset_serv = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=preset_mode + SERV_SWITCH, + [CHAR_NAME, CHAR_CONFIGURED_NAME], + unique_id=preset_mode, ) serv_fan.add_linked_service(preset_serv) preset_serv.configure_char( @@ -124,9 +132,12 @@ class Fan(HomeAccessory): f"{self.display_name} {preset_mode}" ), ) + preset_serv.configure_char( + CHAR_CONFIGURED_NAME, value=cleanup_name_for_homekit(preset_mode) + ) def setter_callback(value: int, preset_mode: str = preset_mode) -> None: - return self.set_preset_mode(value, preset_mode) + self.set_preset_mode(value, preset_mode) self.preset_mode_chars[preset_mode] = preset_serv.configure_char( CHAR_ON, @@ -137,10 +148,27 @@ class Fan(HomeAccessory): if CHAR_SWING_MODE in self.chars: self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0) self.async_update_state(state) - serv_fan.setter_callback = self._set_chars + serv_fan.setter_callback = self.set_chars - def _set_chars(self, char_values: dict[str, Any]) -> None: - _LOGGER.debug("Fan _set_chars: %s", char_values) + def create_services(self) -> Service: + """Create and configure the primary service for this accessory.""" + if self.preset_modes and len(self.preset_modes) == 1: + self.chars.append(CHAR_TARGET_FAN_STATE) + serv_fan = self.add_preload_service(SERV_FANV2, self.chars) + self.set_primary_service(serv_fan) + self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0) + return serv_fan + + def should_add_preset_mode_switch(self, preset_mode: str) -> bool: + """Check if a preset mode switch should be added. + + Always true for fans, but can be overridden by subclasses. + """ + return True + + def set_chars(self, char_values: dict[str, Any]) -> None: + """Set characteristic values.""" + _LOGGER.debug("Fan set_chars: %s", char_values) if CHAR_ACTIVE in char_values: if char_values[CHAR_ACTIVE]: # If the device supports set speed we diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index adb16da5a2d..88d227d0ca5 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -41,6 +41,7 @@ from .const import ( ATTR_KEY_NAME, CATEGORY_RECEIVER, CHAR_ACTIVE, + CHAR_CONFIGURED_NAME, CHAR_MUTE, CHAR_NAME, CHAR_ON, @@ -100,41 +101,67 @@ class MediaPlayer(HomeAccessory): ) if FEATURE_ON_OFF in feature_list: - name = self.generate_service_name(FEATURE_ON_OFF) serv_on_off = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_ON_OFF + SERV_SWITCH, [CHAR_CONFIGURED_NAME, CHAR_NAME], unique_id=FEATURE_ON_OFF + ) + serv_on_off.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_ON_OFF) + ) + serv_on_off.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_ON_OFF), ) - serv_on_off.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char( CHAR_ON, value=False, setter_callback=self.set_on_off ) if FEATURE_PLAY_PAUSE in feature_list: - name = self.generate_service_name(FEATURE_PLAY_PAUSE) serv_play_pause = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_PAUSE + SERV_SWITCH, + [CHAR_CONFIGURED_NAME, CHAR_NAME], + unique_id=FEATURE_PLAY_PAUSE, + ) + serv_play_pause.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_PLAY_PAUSE) + ) + serv_play_pause.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_PLAY_PAUSE), ) - serv_play_pause.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char( CHAR_ON, value=False, setter_callback=self.set_play_pause ) if FEATURE_PLAY_STOP in feature_list: - name = self.generate_service_name(FEATURE_PLAY_STOP) serv_play_stop = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_STOP + SERV_SWITCH, + [CHAR_CONFIGURED_NAME, CHAR_NAME], + unique_id=FEATURE_PLAY_STOP, + ) + serv_play_stop.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_PLAY_STOP) + ) + serv_play_stop.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_PLAY_STOP), ) - serv_play_stop.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char( CHAR_ON, value=False, setter_callback=self.set_play_stop ) if FEATURE_TOGGLE_MUTE in feature_list: - name = self.generate_service_name(FEATURE_TOGGLE_MUTE) serv_toggle_mute = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_TOGGLE_MUTE + SERV_SWITCH, + [CHAR_CONFIGURED_NAME, CHAR_NAME], + unique_id=FEATURE_TOGGLE_MUTE, + ) + serv_toggle_mute.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_TOGGLE_MUTE) + ) + serv_toggle_mute.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_TOGGLE_MUTE), ) - serv_toggle_mute.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char( CHAR_ON, value=False, setter_callback=self.set_toggle_mute ) @@ -146,6 +173,10 @@ class MediaPlayer(HomeAccessory): f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}" ) + def generated_configured_name(self, mode: str) -> str: + """Generate name for individual service.""" + return cleanup_name_for_homekit(MODE_FRIENDLY_NAME[mode]) + def set_on_off(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 0482a5956ac..18150c820c3 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -16,6 +16,12 @@ from pyhap.const import ( from homeassistant.components import button, input_button from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + SERVICE_DOCK, + SERVICE_START_MOWING, + LawnMowerActivity, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, @@ -43,6 +49,7 @@ from homeassistant.helpers.event import async_call_later from .accessories import TYPES, HomeAccessory, HomeDriver from .const import ( CHAR_ACTIVE, + CHAR_CONFIGURED_NAME, CHAR_IN_USE, CHAR_NAME, CHAR_ON, @@ -218,6 +225,29 @@ class Vacuum(Switch): self.char_on.set_value(current_state) +@TYPES.register("LawnMower") +class LawnMower(Switch): + """Generate a Switch accessory.""" + + def set_state(self, value: bool) -> None: + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) + state = self.hass.states.get(self.entity_id) + assert state + + service = SERVICE_START_MOWING if value else SERVICE_DOCK + self.async_call_service( + LAWN_MOWER_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id} + ) + + @callback + def async_update_state(self, new_state: State) -> None: + """Update switch state after state changed.""" + current_state = new_state.state in (LawnMowerActivity.MOWING, STATE_ON) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) + + class ValveBase(HomeAccessory): """Valve base class.""" @@ -331,11 +361,13 @@ class SelectSwitch(HomeAccessory): options = state.attributes[ATTR_OPTIONS] for option in options: serv_option = self.add_preload_service( - SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE], unique_id=option - ) - serv_option.configure_char( - CHAR_NAME, value=cleanup_name_for_homekit(option) + SERV_OUTLET, + [CHAR_NAME, CHAR_CONFIGURED_NAME, CHAR_IN_USE], + unique_id=option, ) + name = cleanup_name_for_homekit(option) + serv_option.configure_char(CHAR_NAME, value=name) + serv_option.configure_char(CHAR_CONFIGURED_NAME, value=name) serv_option.configure_char(CHAR_IN_USE, value=False) self.select_chars[option] = serv_option.configure_char( CHAR_ON, diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index f32c4f55a0f..44db65d7b0b 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -15,6 +15,7 @@ from homeassistant.helpers.trigger import async_initialize_triggers from .accessories import TYPES, HomeAccessory from .aidmanager import get_system_unique_id from .const import ( + CHAR_CONFIGURED_NAME, CHAR_NAME, CHAR_PROGRAMMABLE_SWITCH_EVENT, CHAR_SERVICE_LABEL_INDEX, @@ -66,7 +67,7 @@ class DeviceTriggerAccessory(HomeAccessory): trigger_name = cleanup_name_for_homekit(" ".join(trigger_name_parts)) serv_stateless_switch = self.add_preload_service( SERV_STATELESS_PROGRAMMABLE_SWITCH, - [CHAR_NAME, CHAR_SERVICE_LABEL_INDEX], + [CHAR_NAME, CHAR_CONFIGURED_NAME, CHAR_SERVICE_LABEL_INDEX], unique_id=unique_id, ) self.triggers.append( @@ -77,6 +78,9 @@ class DeviceTriggerAccessory(HomeAccessory): ) ) serv_stateless_switch.configure_char(CHAR_NAME, value=trigger_name) + serv_stateless_switch.configure_char( + CHAR_CONFIGURED_NAME, value=trigger_name + ) serv_stateless_switch.configure_char( CHAR_SERVICE_LABEL_INDEX, value=idx + 1 ) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 1181ceaa953..bc98f00c15a 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -62,9 +62,13 @@ from .const import ( CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, CONF_LINKED_DOORBELL_SENSOR, + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_LINKED_OBSTRUCTION_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -98,6 +102,8 @@ from .const import ( FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, MAX_NAME_LENGTH, + TYPE_AIR_PURIFIER, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -187,6 +193,27 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend( {vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)} ) +FAN_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_TYPE, default=TYPE_FAN): vol.All( + cv.string, + vol.In( + ( + TYPE_FAN, + TYPE_AIR_PURIFIER, + ) + ), + ), + vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_PM25_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_TEMPERATURE_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_FILTER_CHANGE_INDICATION): cv.entity_domain( + binary_sensor.DOMAIN + ), + vol.Optional(CONF_LINKED_FILTER_LIFE_LEVEL): cv.entity_domain(sensor.DOMAIN), + } +) + COVER_SCHEMA = BASIC_INFO_SCHEMA.extend( { vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain( @@ -325,6 +352,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "cover": config = COVER_SCHEMA(config) + elif domain == "fan": + config = FAN_SCHEMA(config) + elif domain == "sensor": config = SENSOR_SCHEMA(config) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 7341bbd3a4a..4c8bf8517be 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -659,13 +659,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): # e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit. # Can be 0 - 2 (Off, Heat, Cool) - # If the HVAC is switched off, it must be idle - # This works around a bug in some devices (like Eve radiator valves) that - # return they are heating when they are not. target = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) - if target == HeatingCoolingTargetValues.OFF: - return HVACAction.IDLE - value = self.service.value(CharacteristicsTypes.HEATING_COOLING_CURRENT) current_hass_value = CURRENT_MODE_HOMEKIT_TO_HASS.get(value) @@ -679,6 +673,12 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): ): return HVACAction.FAN + # If the HVAC is switched off, it must be idle + # This works around a bug in some devices (like Eve radiator valves) that + # return they are heating when they are not. + if target == HeatingCoolingTargetValues.OFF: + return HVACAction.IDLE + return current_hass_value @property diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 211aec2c2d5..931bd40d64c 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -9,10 +9,11 @@ from functools import partial import logging from operator import attrgetter from types import MappingProxyType -from typing import Any +from typing import Any, cast from aiohomekit import Controller from aiohomekit.controller import TransportType +from aiohomekit.controller.ble.discovery import BleDiscovery from aiohomekit.exceptions import ( AccessoryDisconnectedError, AccessoryNotFoundError, @@ -154,7 +155,6 @@ class HKDevice: self._pending_subscribes: set[tuple[int, int]] = set() self._subscribe_timer: CALLBACK_TYPE | None = None self._load_platforms_lock = asyncio.Lock() - self._full_update_requested: bool = False @property def entity_map(self) -> Accessories: @@ -373,6 +373,16 @@ class HKDevice: if not self.unreliable_serial_numbers: identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number)) + connections: set[tuple[str, str]] = set() + if self.pairing.transport == Transport.BLE and ( + discovery := self.pairing.controller.discoveries.get( + normalize_hkid(self.unique_id) + ) + ): + connections = { + (dr.CONNECTION_BLUETOOTH, cast(BleDiscovery, discovery).device.address), + } + device_info = DeviceInfo( identifiers={ ( @@ -380,6 +390,7 @@ class HKDevice: f"{self.unique_id}:aid:{accessory.aid}", ) }, + connections=connections, name=accessory.name, manufacturer=accessory.manufacturer, model=accessory.model, @@ -841,48 +852,11 @@ class HKDevice: async def async_request_update(self, now: datetime | None = None) -> None: """Request an debounced update from the accessory.""" - self._full_update_requested = True await self._debounced_update.async_call() async def async_update(self, now: datetime | None = None) -> None: """Poll state of all entities attached to this bridge/accessory.""" to_poll = self.pollable_characteristics - accessories = self.entity_map.accessories - - if ( - not self._full_update_requested - and len(accessories) == 1 - and self.available - and not (to_poll - self.watchable_characteristics) - and self.pairing.is_available - and await self.pairing.controller.async_reachable( - self.unique_id, timeout=5.0 - ) - ): - # If its a single accessory and all chars are watchable, - # only poll the firmware version to keep the connection alive - # https://github.com/home-assistant/core/issues/123412 - # - # Firmware revision is used here since iOS does this to keep camera - # connections alive, and the goal is to not regress - # https://github.com/home-assistant/core/issues/116143 - # by polling characteristics that are not normally polled frequently - # and may not be tested by the device vendor. - # - _LOGGER.debug( - "Accessory is reachable, limiting poll to firmware version: %s", - self.unique_id, - ) - first_accessory = accessories[0] - accessory_info = first_accessory.services.first( - service_type=ServicesTypes.ACCESSORY_INFORMATION - ) - assert accessory_info is not None - firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid - to_poll = {(first_accessory.aid, firmware_iid)} - - self._full_update_requested = False - if not to_poll: self.async_update_available_state() _LOGGER.debug( diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 9ba476a0ef3..4138277d81c 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -5,6 +5,10 @@ from __future__ import annotations from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics.const import ( + TargetAirPurifierStateValues, + TargetFanStateValues, +) from aiohomekit.model.services import Service, ServicesTypes from propcache.api import cached_property @@ -35,6 +39,8 @@ DIRECTION_TO_HK = { } HK_DIRECTION_TO_HA = {v: k for (k, v) in DIRECTION_TO_HK.items()} +PRESET_AUTO = "auto" + class BaseHomeKitFan(HomeKitEntity, FanEntity): """Representation of a Homekit fan.""" @@ -42,6 +48,9 @@ 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 + preset_char = CharacteristicsTypes.FAN_STATE_TARGET + preset_manual_value: int = TargetFanStateValues.MANUAL + preset_automatic_value: int = TargetFanStateValues.AUTOMATIC @callback def _async_reconfigure(self) -> None: @@ -51,6 +60,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): "_speed_range", "_min_speed", "_max_speed", + "preset_modes", "speed_count", "supported_features", ) @@ -59,12 +69,15 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" - return [ + types = [ CharacteristicsTypes.SWING_MODE, CharacteristicsTypes.ROTATION_DIRECTION, CharacteristicsTypes.ROTATION_SPEED, self.on_characteristic, ] + if self.service.has(self.preset_char): + types.append(self.preset_char) + return types @property def is_on(self) -> bool: @@ -124,6 +137,9 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): if self.service.has(CharacteristicsTypes.SWING_MODE): features |= FanEntityFeature.OSCILLATE + if self.service.has(self.preset_char): + features |= FanEntityFeature.PRESET_MODE + return features @cached_property @@ -134,6 +150,32 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): / max(1, self.service[CharacteristicsTypes.ROTATION_SPEED].minStep or 0) ) + @cached_property + def preset_modes(self) -> list[str]: + """Return the preset modes.""" + return [PRESET_AUTO] if self.service.has(self.preset_char) else [] + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + if ( + self.service.has(self.preset_char) + and self.service.value(self.preset_char) == self.preset_automatic_value + ): + return PRESET_AUTO + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + if self.service.has(self.preset_char): + await self.async_put_characteristics( + { + self.preset_char: self.preset_automatic_value + if preset_mode == PRESET_AUTO + else self.preset_manual_value + } + ) + async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" await self.async_put_characteristics( @@ -146,13 +188,16 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): await self.async_turn_off() return - await self.async_put_characteristics( - { - CharacteristicsTypes.ROTATION_SPEED: round( - percentage_to_ranged_value(self._speed_range, percentage) - ) - } - ) + characteristics = { + CharacteristicsTypes.ROTATION_SPEED: round( + percentage_to_ranged_value(self._speed_range, percentage) + ) + } + + if FanEntityFeature.PRESET_MODE in self.supported_features: + characteristics[self.preset_char] = self.preset_manual_value + + await self.async_put_characteristics(characteristics) async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" @@ -172,13 +217,17 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): if not self.is_on: characteristics[self.on_characteristic] = True - if ( + if preset_mode == PRESET_AUTO: + characteristics[self.preset_char] = self.preset_automatic_value + elif ( percentage is not None and FanEntityFeature.SET_SPEED in self.supported_features ): characteristics[CharacteristicsTypes.ROTATION_SPEED] = round( percentage_to_ranged_value(self._speed_range, percentage) ) + if FanEntityFeature.PRESET_MODE in self.supported_features: + characteristics[self.preset_char] = self.preset_manual_value if characteristics: await self.async_put_characteristics(characteristics) @@ -200,10 +249,18 @@ class HomeKitFanV2(BaseHomeKitFan): on_characteristic = CharacteristicsTypes.ACTIVE +class HomeKitAirPurifer(HomeKitFanV2): + """Implement air purifier support for public.hap.service.airpurifier.""" + + preset_char = CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET + preset_manual_value = TargetAirPurifierStateValues.MANUAL + preset_automatic_value = TargetAirPurifierStateValues.AUTOMATIC + + ENTITY_TYPES = { ServicesTypes.FAN: HomeKitFanV1, ServicesTypes.FAN_V2: HomeKitFanV2, - ServicesTypes.AIR_PURIFIER: HomeKitFanV2, + ServicesTypes.AIR_PURIFIER: HomeKitAirPurifer, } diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index b7c82b9fd51..dbcd2788c8a 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.7"], + "requirements": ["aiohomekit==3.2.14"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 5315c7c89f3..e3b4a760680 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -83,7 +83,7 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity): @property def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" - features = MediaPlayerEntityFeature(0) + features = MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_ON if self.service.has(CharacteristicsTypes.ACTIVE_IDENTIFIER): features |= MediaPlayerEntityFeature.SELECT_SOURCE @@ -177,6 +177,14 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity): return MediaPlayerState.ON + async def async_turn_on(self) -> None: + """Turn the tv on.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: 1}) + + async def async_turn_off(self) -> None: + """Turn the tv off.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: 0}) + async def async_media_play(self) -> None: """Send play command.""" if self.state == MediaPlayerState.PLAYING: diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index d1205645fd3..15785a3947a 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -12,9 +12,9 @@ }, "pair": { "title": "Pair with a device via HomeKit Accessory Protocol", - "description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your eight digit HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging, often close to a HomeKit bar code, next to the image of a small house.", + "description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your eight-digit HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging, often close to a HomeKit bar code, next to the image of a small house.", "data": { - "pairing_code": "Pairing Code", + "pairing_code": "Pairing code", "allow_insecure_setup_codes": "Allow pairing with insecure setup codes." } }, @@ -112,7 +112,7 @@ "air_purifier_state_target": { "state": { "automatic": "Automatic", - "manual": "Manual" + "manual": "[%key:common::state::manual%]" } } }, @@ -141,7 +141,7 @@ "air_purifier_state_current": { "state": { "inactive": "Inactive", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "purifying": "Purifying" } } diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 24172e196c1..bdd446d7091 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -178,6 +178,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { key="WIND_DIRECTION", native_unit_of_measurement=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), "WIND_DIRECTION_RANGE": SensorEntityDescription( key="WIND_DIRECTION_RANGE", diff --git a/homeassistant/components/homematic/strings.json b/homeassistant/components/homematic/strings.json index d962a218a4f..78159189db8 100644 --- a/homeassistant/components/homematic/strings.json +++ b/homeassistant/components/homematic/strings.json @@ -2,7 +2,7 @@ "services": { "virtualkey": { "name": "Virtual key", - "description": "Presses a virtual key from CCU/Homegear or simulate keypress.", + "description": "Simulates a keypress (or other valid action) on CCU/Homegear with virtual or device keys.", "fields": { "address": { "name": "Address", @@ -24,7 +24,7 @@ }, "set_variable_value": { "name": "Set variable value", - "description": "Sets the name of a node.", + "description": "Sets the value of a system variable.", "fields": { "entity_id": { "name": "Entity", diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index c59a9d788b3..e460c162398 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -3,7 +3,6 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( @@ -21,7 +20,7 @@ from .const import ( HMIPC_HAPID, HMIPC_NAME, ) -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP from .services import async_setup_services, async_unload_services CONFIG_SCHEMA = vol.Schema( @@ -45,8 +44,6 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HomematicIP Cloud component.""" - hass.data[DOMAIN] = {} - accesspoints = config.get(DOMAIN, []) for conf in accesspoints: @@ -69,7 +66,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: HomematicIPConfigEntry) -> bool: """Set up an access point from a config entry.""" # 0.104 introduced config entry unique id, this makes upgrading possible @@ -81,8 +78,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hap = HomematicipHAP(hass, entry) - hass.data[DOMAIN][entry.unique_id] = hap + entry.runtime_data = hap if not await hap.async_setup(): return False @@ -110,9 +107,12 @@ 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: HomematicIPConfigEntry +) -> bool: """Unload a config entry.""" - hap = hass.data[DOMAIN].pop(entry.unique_id) + hap = entry.runtime_data + assert hap.reset_connection_listener is not None hap.reset_connection_listener() await async_unload_services(hass) @@ -122,7 +122,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def _async_remove_obsolete_entities( - hass: HomeAssistant, entry: ConfigEntry, hap: HomematicipHAP + hass: HomeAssistant, entry: HomematicIPConfigEntry, hap: HomematicipHAP ): """Remove obsolete entities from entity registry.""" diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index d5b084644e3..ddfe10fba54 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -11,13 +11,12 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -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 AddConfigEntryEntitiesCallback from .const import DOMAIN -from .hap import AsyncHome, HomematicipHAP +from .hap import AsyncHome, HomematicIPConfigEntry, HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -26,11 +25,11 @@ CONST_ALARM_CONTROL_PANEL_NAME = "HmIP Alarm Control Panel" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP alrm control panel from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data async_add_entities([HomematicipAlarmControlPanelEntity(hap)]) @@ -82,15 +81,15 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - await self._home.set_security_zones_activation(False, False) + await self._home.set_security_zones_activation_async(False, False) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._home.set_security_zones_activation(False, True) + await self._home.set_security_zones_activation_async(False, True) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._home.set_security_zones_activation(True, True) + await self._home.set_security_zones_activation_async(True, True) async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index f0cd3732718..9c0e5620022 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -4,44 +4,43 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncAccelerationSensor, - AsyncContactInterface, - AsyncDevice, - AsyncFullFlushContactInterface, - AsyncFullFlushContactInterface6, - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, - AsyncPluggableMainsFailureSurveillance, - AsyncPresenceDetectorIndoor, - AsyncRainSensor, - AsyncRotaryHandleSensor, - AsyncShutterContact, - AsyncShutterContactMagnetic, - AsyncSmokeDetector, - AsyncTiltVibrationSensor, - AsyncWaterSensor, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, - AsyncWiredInput32, -) -from homematicip.aio.group import AsyncSecurityGroup, AsyncSecurityZoneGroup from homematicip.base.enums import SmokeDetectorAlarmType, WindowState +from homematicip.device import ( + AccelerationSensor, + ContactInterface, + Device, + FullFlushContactInterface, + FullFlushContactInterface6, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, + PluggableMainsFailureSurveillance, + PresenceDetectorIndoor, + RainSensor, + RotaryHandleSensor, + ShutterContact, + ShutterContactMagnetic, + SmokeDetector, + TiltVibrationSensor, + WaterSensor, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, + WiredInput32, +) +from homematicip.group import SecurityGroup, SecurityZoneGroup 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 DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP ATTR_ACCELERATION_SENSOR_MODE = "acceleration_sensor_mode" ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position" @@ -75,73 +74,67 @@ SAM_DEVICE_ATTRIBUTES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud binary sensor from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [HomematicipCloudConnectionSensor(hap)] for device in hap.home.devices: - if isinstance(device, AsyncAccelerationSensor): + if isinstance(device, AccelerationSensor): entities.append(HomematicipAccelerationSensor(hap, device)) - if isinstance(device, AsyncTiltVibrationSensor): + if isinstance(device, TiltVibrationSensor): entities.append(HomematicipTiltVibrationSensor(hap, device)) - if isinstance(device, AsyncWiredInput32): + if isinstance(device, WiredInput32): entities.extend( HomematicipMultiContactInterface(hap, device, channel=channel) for channel in range(1, 33) ) - elif isinstance(device, AsyncFullFlushContactInterface6): + elif isinstance(device, FullFlushContactInterface6): entities.extend( HomematicipMultiContactInterface(hap, device, channel=channel) for channel in range(1, 7) ) - elif isinstance( - device, (AsyncContactInterface, AsyncFullFlushContactInterface) - ): + elif isinstance(device, (ContactInterface, FullFlushContactInterface)): entities.append(HomematicipContactInterface(hap, device)) if isinstance( device, - (AsyncShutterContact, AsyncShutterContactMagnetic), + (ShutterContact, ShutterContactMagnetic), ): entities.append(HomematicipShutterContact(hap, device)) - if isinstance(device, AsyncRotaryHandleSensor): + if isinstance(device, RotaryHandleSensor): entities.append(HomematicipShutterContact(hap, device, True)) if isinstance( device, ( - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, ), ): entities.append(HomematicipMotionDetector(hap, device)) - if isinstance(device, AsyncPluggableMainsFailureSurveillance): + if isinstance(device, PluggableMainsFailureSurveillance): entities.append( HomematicipPluggableMainsFailureSurveillanceSensor(hap, device) ) - if isinstance(device, AsyncPresenceDetectorIndoor): + if isinstance(device, PresenceDetectorIndoor): entities.append(HomematicipPresenceDetector(hap, device)) - if isinstance(device, AsyncSmokeDetector): + if isinstance(device, SmokeDetector): entities.append(HomematicipSmokeDetector(hap, device)) - if isinstance(device, AsyncWaterSensor): + if isinstance(device, WaterSensor): entities.append(HomematicipWaterDetector(hap, device)) - if isinstance( - device, (AsyncRainSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) - ): + if isinstance(device, (RainSensor, WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipRainSensor(hap, device)) - if isinstance( - device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) - ): + if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipStormSensor(hap, device)) entities.append(HomematicipSunshineSensor(hap, device)) - if isinstance(device, AsyncDevice) and device.lowBat is not None: + if isinstance(device, Device) and device.lowBat is not None: entities.append(HomematicipBatterySensor(hap, device)) for group in hap.home.groups: - if isinstance(group, AsyncSecurityGroup): + if isinstance(group, SecurityGroup): entities.append(HomematicipSecuritySensorGroup(hap, device=group)) - elif isinstance(group, AsyncSecurityZoneGroup): + elif isinstance(group, SecurityZoneGroup): entities.append(HomematicipSecurityZoneSensorGroup(hap, device=group)) async_add_entities(entities) diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index fedc271714c..31fa2c889ac 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -2,30 +2,28 @@ from __future__ import annotations -from homematicip.aio.device import AsyncWallMountedGarageDoorController +from homematicip.device import WallMountedGarageDoorController from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP button from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data async_add_entities( HomematicipGarageDoorControllerButton(hap, device) for device in hap.home.devices - if isinstance(device, AsyncWallMountedGarageDoorController) + if isinstance(device, WallMountedGarageDoorController) ) @@ -39,4 +37,4 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti async def async_press(self) -> None: """Handle the button press.""" - await self._device.send_start_impulse() + await self._device.send_start_impulse_async() diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 35bd18ff438..7f393cf52bd 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -4,16 +4,15 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, -) -from homematicip.aio.group import AsyncHeatingGroup from homematicip.base.enums import AbsenceType -from homematicip.device import Switch +from homematicip.device import ( + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, + Switch, +) from homematicip.functionalHomes import IndoorClimateHome -from homematicip.group import HeatingCoolingProfile +from homematicip.group import HeatingCoolingProfile, HeatingGroup from homeassistant.components.climate import ( PRESET_AWAY, @@ -25,7 +24,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.helpers.device_registry import DeviceInfo @@ -33,7 +31,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP HEATING_PROFILES = {"PROFILE_1": 0, "PROFILE_2": 1, "PROFILE_3": 2} COOLING_PROFILES = {"PROFILE_4": 3, "PROFILE_5": 4, "PROFILE_6": 5} @@ -56,16 +54,16 @@ HMIP_ECO_CM = "ECO" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP climate from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data async_add_entities( HomematicipHeatingGroup(hap, device) for device in hap.home.groups - if isinstance(device, AsyncHeatingGroup) + if isinstance(device, HeatingGroup) ) @@ -82,7 +80,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: + def __init__(self, hap: HomematicipHAP, device: HeatingGroup) -> None: """Initialize heating group.""" device.modelType = "HmIP-Heating-Group" super().__init__(hap, device) @@ -214,7 +212,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): return if self.min_temp <= temperature <= self.max_temp: - await self._device.set_point_temperature(temperature) + await self._device.set_point_temperature_async(temperature) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -222,23 +220,23 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): return if hvac_mode == HVACMode.AUTO: - await self._device.set_control_mode(HMIP_AUTOMATIC_CM) + await self._device.set_control_mode_async(HMIP_AUTOMATIC_CM) else: - await self._device.set_control_mode(HMIP_MANUAL_CM) + await self._device.set_control_mode_async(HMIP_MANUAL_CM) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" if self._device.boostMode and preset_mode != PRESET_BOOST: - await self._device.set_boost(False) + await self._device.set_boost_async(False) if preset_mode == PRESET_BOOST: - await self._device.set_boost() + await self._device.set_boost_async() if preset_mode == PRESET_ECO: - await self._device.set_control_mode(HMIP_ECO_CM) + await self._device.set_control_mode_async(HMIP_ECO_CM) if preset_mode in self._device_profile_names: profile_idx = self._get_profile_idx_by_name(preset_mode) if self._device.controlMode != HMIP_AUTOMATIC_CM: await self.async_set_hvac_mode(HVACMode.AUTO) - await self._device.set_active_profile(profile_idx) + await self._device.set_active_profile_async(profile_idx) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -332,20 +330,15 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): @property def _first_radiator_thermostat( self, - ) -> ( - AsyncHeatingThermostat - | AsyncHeatingThermostatCompact - | AsyncHeatingThermostatEvo - | None - ): + ) -> HeatingThermostat | HeatingThermostatCompact | HeatingThermostatEvo | None: """Return the first radiator thermostat from the hmip heating group.""" for device in self._device.devices: if isinstance( device, ( - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, ), ): return device diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 27a84abb572..f9986e0c526 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -4,16 +4,16 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncBlindModule, - AsyncDinRailBlind4, - AsyncFullFlushBlind, - AsyncFullFlushShutter, - AsyncGarageDoorModuleTormatic, - AsyncHoermannDrivesModule, -) -from homematicip.aio.group import AsyncExtendedLinkedShutterGroup from homematicip.base.enums import DoorCommand, DoorState +from homematicip.device import ( + BlindModule, + DinRailBlind4, + FullFlushBlind, + FullFlushShutter, + GarageDoorModuleTormatic, + HoermannDrivesModule, +) +from homematicip.group import ExtendedLinkedShutterGroup from homeassistant.components.cover import ( ATTR_POSITION, @@ -21,13 +21,11 @@ from homeassistant.components.cover import ( CoverDeviceClass, CoverEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP HMIP_COVER_OPEN = 0 HMIP_COVER_CLOSED = 1 @@ -37,31 +35,29 @@ HMIP_SLATS_CLOSED = 1 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP cover from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [ HomematicipCoverShutterGroup(hap, group) for group in hap.home.groups - if isinstance(group, AsyncExtendedLinkedShutterGroup) + if isinstance(group, ExtendedLinkedShutterGroup) ] for device in hap.home.devices: - if isinstance(device, AsyncBlindModule): + if isinstance(device, BlindModule): entities.append(HomematicipBlindModule(hap, device)) - elif isinstance(device, AsyncDinRailBlind4): + elif isinstance(device, DinRailBlind4): entities.extend( HomematicipMultiCoverSlats(hap, device, channel=channel) for channel in range(1, 5) ) - elif isinstance(device, AsyncFullFlushBlind): + elif isinstance(device, FullFlushBlind): entities.append(HomematicipCoverSlats(hap, device)) - elif isinstance(device, AsyncFullFlushShutter): + elif isinstance(device, FullFlushShutter): entities.append(HomematicipCoverShutter(hap, device)) - elif isinstance( - device, (AsyncHoermannDrivesModule, AsyncGarageDoorModuleTormatic) - ): + elif isinstance(device, (HoermannDrivesModule, GarageDoorModuleTormatic)): entities.append(HomematicipGarageDoorModule(hap, device)) async_add_entities(entities) @@ -91,14 +87,14 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_primary_shading_level(primaryShadingLevel=level) + await self._device.set_primary_shading_level_async(primaryShadingLevel=level) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_secondary_shading_level( + await self._device.set_secondary_shading_level_async( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=level, ) @@ -112,37 +108,37 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.set_primary_shading_level( + await self._device.set_primary_shading_level_async( primaryShadingLevel=HMIP_COVER_OPEN ) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.set_primary_shading_level( + await self._device.set_primary_shading_level_async( primaryShadingLevel=HMIP_COVER_CLOSED ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.stop() + await self._device.stop_async() async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" - await self._device.set_secondary_shading_level( + await self._device.set_secondary_shading_level_async( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=HMIP_SLATS_OPEN, ) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" - await self._device.set_secondary_shading_level( + await self._device.set_secondary_shading_level_async( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=HMIP_SLATS_CLOSED, ) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.stop() + await self._device.stop_async() class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): @@ -176,7 +172,7 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_shutter_level(level, self._channel) + await self._device.set_shutter_level_async(level, self._channel) @property def is_closed(self) -> bool | None: @@ -190,15 +186,15 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.set_shutter_level(HMIP_COVER_OPEN, self._channel) + await self._device.set_shutter_level_async(HMIP_COVER_OPEN, self._channel) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.set_shutter_level(HMIP_COVER_CLOSED, self._channel) + await self._device.set_shutter_level_async(HMIP_COVER_CLOSED, self._channel) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.set_shutter_stop(self._channel) + await self._device.set_shutter_stop_async(self._channel) class HomematicipCoverShutter(HomematicipMultiCoverShutter, CoverEntity): @@ -238,23 +234,25 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity): position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_slats_level(slatsLevel=level, channelIndex=self._channel) + await self._device.set_slats_level_async( + slatsLevel=level, channelIndex=self._channel + ) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" - await self._device.set_slats_level( + await self._device.set_slats_level_async( slatsLevel=HMIP_SLATS_OPEN, channelIndex=self._channel ) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" - await self._device.set_slats_level( + await self._device.set_slats_level_async( slatsLevel=HMIP_SLATS_CLOSED, channelIndex=self._channel ) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.set_shutter_stop(self._channel) + await self._device.set_shutter_stop_async(self._channel) class HomematicipCoverSlats(HomematicipMultiCoverSlats, CoverEntity): @@ -288,15 +286,15 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.send_door_command(DoorCommand.OPEN) + await self._device.send_door_command_async(DoorCommand.OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.send_door_command(DoorCommand.CLOSE) + await self._device.send_door_command_async(DoorCommand.CLOSE) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._device.send_door_command(DoorCommand.STOP) + await self._device.send_door_command_async(DoorCommand.STOP) class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): @@ -335,35 +333,35 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_shutter_level(level) + await self._device.set_shutter_level_async(level) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_slats_level(level) + await self._device.set_slats_level_async(level) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.set_shutter_level(HMIP_COVER_OPEN) + await self._device.set_shutter_level_async(HMIP_COVER_OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.set_shutter_level(HMIP_COVER_CLOSED) + await self._device.set_shutter_level_async(HMIP_COVER_CLOSED) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the group if in motion.""" - await self._device.set_shutter_stop() + await self._device.set_shutter_stop_async() async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" - await self._device.set_slats_level(HMIP_SLATS_OPEN) + await self._device.set_slats_level_async(HMIP_SLATS_OPEN) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" - await self._device.set_slats_level(HMIP_SLATS_CLOSED) + await self._device.set_slats_level_async(HMIP_SLATS_CLOSED) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the group if in motion.""" - await self._device.set_shutter_stop() + await self._device.set_shutter_stop_async() diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 82d682b9910..41ccbb4b060 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -5,9 +5,9 @@ from __future__ import annotations import logging from typing import Any -from homematicip.aio.device import AsyncDevice -from homematicip.aio.group import AsyncGroup from homematicip.base.functionalChannels import FunctionalChannel +from homematicip.device import Device +from homematicip.group import Group from homeassistant.const import ATTR_ID from homeassistant.core import callback @@ -100,7 +100,7 @@ class HomematicipGenericEntity(Entity): def device_info(self) -> DeviceInfo | None: """Return device specific attributes.""" # Only physical devices should be HA devices. - if isinstance(self._device, AsyncDevice): + if isinstance(self._device, Device): return DeviceInfo( identifiers={ # Serial numbers of Homematic IP device @@ -237,14 +237,14 @@ class HomematicipGenericEntity(Entity): """Return the state attributes of the generic entity.""" state_attr = {} - if isinstance(self._device, AsyncDevice): + if isinstance(self._device, Device): for attr, attr_key in DEVICE_ATTRIBUTES.items(): if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value state_attr[ATTR_IS_GROUP] = False - if isinstance(self._device, AsyncGroup): + if isinstance(self._device, Group): for attr, attr_key in GROUP_ATTRIBUTES.items(): if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py index 654f56bb47f..101c3e3015a 100644 --- a/homeassistant/components/homematicip_cloud/event.py +++ b/homeassistant/components/homematicip_cloud/event.py @@ -1,28 +1,32 @@ """Support for HomematicIP Cloud events.""" +from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING -from homematicip.aio.device import Device +from homematicip.base.channel_event import ChannelEvent +from homematicip.base.functionalChannels import FunctionalChannel +from homematicip.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 AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP @dataclass(frozen=True, kw_only=True) class HmipEventEntityDescription(EventEntityDescription): """Description of a HomematicIP Cloud event.""" + channel_event_types: list[str] | None = None + channel_selector_fn: Callable[[FunctionalChannel], bool] | None = None + EVENT_DESCRIPTIONS = { "doorbell": HmipEventEntityDescription( @@ -30,35 +34,42 @@ EVENT_DESCRIPTIONS = { translation_key="doorbell", device_class=EventDeviceClass.DOORBELL, event_types=["ring"], + channel_event_types=["DOOR_BELL_SENSOR_EVENT"], + channel_selector_fn=lambda channel: channel.channelRole == "DOOR_BELL_INPUT", ), } async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP cover from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data + entities: list[HomematicipGenericEntity] = [] - async_add_entities( + entities.extend( HomematicipDoorBellEvent( hap, device, channel.index, - EVENT_DESCRIPTIONS["doorbell"], + description, ) + for description in EVENT_DESCRIPTIONS.values() for device in hap.home.devices for channel in device.functionalChannels - if channel.channelRole == "DOOR_BELL_INPUT" + if description.channel_selector_fn and description.channel_selector_fn(channel) ) + async_add_entities(entities) + class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity): """Event class for HomematicIP doorbell events.""" _attr_device_class = EventDeviceClass.DOORBELL + entity_description: HmipEventEntityDescription def __init__( self, @@ -86,9 +97,27 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity): @callback def _async_handle_event(self, *args, **kwargs) -> None: """Handle the event fired by the functional channel.""" + raised_channel_event = self._get_channel_event_from_args(*args) + + if not self._should_raise(raised_channel_event): + return + 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() + + def _should_raise(self, event_type: str) -> bool: + """Check if the event should be raised.""" + if self.entity_description.channel_event_types is None: + return False + return event_type in self.entity_description.channel_event_types + + def _get_channel_event_from_args(self, *args) -> str: + """Get the channel event.""" + if isinstance(args[0], ChannelEvent): + return args[0].channelEventType + + return "" diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index db7fcb348c8..86630c2896c 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -7,26 +7,46 @@ from collections.abc import Callable import logging from typing import Any -from homematicip.aio.auth import AsyncAuth -from homematicip.aio.home import AsyncHome -from homematicip.base.base_connection import HmipConnectionError +from homematicip.async_home import AsyncHome +from homematicip.auth import Auth from homematicip.base.enums import EventType +from homematicip.connection.connection_context import ConnectionContextBuilder +from homematicip.connection.rest_connection import RestConnection +from homematicip.exceptions.connection_exceptions import HmipConnectionError +import homeassistant from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.httpx_client import get_async_client from .const import HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN, PLATFORMS from .errors import HmipcConnectionError _LOGGER = logging.getLogger(__name__) +type HomematicIPConfigEntry = ConfigEntry[HomematicipHAP] + + +async def build_context_async( + hass: HomeAssistant, hapid: str | None, authtoken: str | None +): + """Create a HomematicIP context object.""" + ssl_ctx = homeassistant.util.ssl.get_default_context() + client_session = get_async_client(hass) + + return await ConnectionContextBuilder.build_context_async( + accesspoint_id=hapid, + auth_token=authtoken, + ssl_ctx=ssl_ctx, + httpx_client_session=client_session, + ) + class HomematicipAuth: """Manages HomematicIP client registration.""" - auth: AsyncAuth + auth: Auth def __init__(self, hass: HomeAssistant, config: dict[str, str]) -> None: """Initialize HomematicIP Cloud client registration.""" @@ -46,27 +66,34 @@ class HomematicipAuth: async def async_checkbutton(self) -> bool: """Check blue butten has been pressed.""" try: - return await self.auth.isRequestAcknowledged() + return await self.auth.is_request_acknowledged() except HmipConnectionError: return False async def async_register(self): """Register client at HomematicIP.""" try: - authtoken = await self.auth.requestAuthToken() - await self.auth.confirmAuthToken(authtoken) + authtoken = await self.auth.request_auth_token() + await self.auth.confirm_auth_token(authtoken) except HmipConnectionError: return False return authtoken async def get_auth(self, hass: HomeAssistant, hapid, pin): """Create a HomematicIP access point object.""" - auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) + context = await build_context_async(hass, hapid, None) + connection = RestConnection( + context, + log_status_exceptions=False, + httpx_client_session=get_async_client(hass), + ) + # hass.loop + auth = Auth(connection, context.client_auth_token, hapid) + try: - await auth.init(hapid) - if pin: - auth.pin = pin - await auth.connectionRequest("HomeAssistant") + auth.set_pin(pin) + result = await auth.connection_request(hapid) + _LOGGER.debug("Connection request result: %s", result) except HmipConnectionError: return None return auth @@ -77,7 +104,9 @@ class HomematicipHAP: home: AsyncHome - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: HomematicIPConfigEntry + ) -> None: """Initialize HomematicIP Cloud connection.""" self.hass = hass self.config_entry = config_entry @@ -156,7 +185,7 @@ class HomematicipHAP: async def get_state(self) -> None: """Update HMIP state and tell Home Assistant.""" - await self.home.get_current_state() + await self.home.get_current_state_async() self.update_all() def get_state_finished(self, future) -> None: @@ -187,8 +216,8 @@ class HomematicipHAP: retry_delay = 2 ** min(tries, 8) try: - await self.home.get_current_state() - hmip_events = await self.home.enable_events() + await self.home.get_current_state_async() + hmip_events = self.home.enable_events() tries = 0 await hmip_events except HmipConnectionError: @@ -219,7 +248,7 @@ class HomematicipHAP: self._ws_close_requested = True if self._retry_task is not None: self._retry_task.cancel() - await self.home.disable_events() + await self.home.disable_events_async() _LOGGER.debug("Closed connection to HomematicIP cloud server") await self.hass.config_entries.async_unload_platforms( self.config_entry, PLATFORMS @@ -246,17 +275,17 @@ class HomematicipHAP: name: str | None, ) -> AsyncHome: """Create a HomematicIP access point object.""" - home = AsyncHome(hass.loop, async_get_clientsession(hass)) + home = AsyncHome() home.name = name # Use the title of the config entry as title for the home. home.label = self.config_entry.title home.modelType = "HomematicIP Cloud Home" - home.set_auth_token(authtoken) try: - await home.init(hapid) - await home.get_current_state() + context = await build_context_async(hass, hapid, authtoken) + home.init_with_context(context, True, get_async_client(hass)) + await home.get_current_state_async() except HmipConnectionError as err: raise HmipcConnectionError from err home.on_update(self.async_update) diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index ad946809fd4..855f5851d73 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -4,18 +4,18 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncBrandDimmer, - AsyncBrandSwitchMeasuring, - AsyncBrandSwitchNotificationLight, - AsyncDimmer, - AsyncDinRailDimmer3, - AsyncFullFlushDimmer, - AsyncPluggableDimmer, - AsyncWiredDimmer3, -) from homematicip.base.enums import OpticalSignalBehaviour, RGBColorState from homematicip.base.functionalChannels import NotificationLightChannel +from homematicip.device import ( + BrandDimmer, + BrandSwitchMeasuring, + BrandSwitchNotificationLight, + Dimmer, + DinRailDimmer3, + FullFlushDimmer, + PluggableDimmer, + WiredDimmer3, +) from packaging.version import Version from homeassistant.components.light import ( @@ -28,27 +28,25 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud lights from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: - if isinstance(device, AsyncBrandSwitchMeasuring): + if isinstance(device, BrandSwitchMeasuring): entities.append(HomematicipLightMeasuring(hap, device)) - elif isinstance(device, AsyncBrandSwitchNotificationLight): + elif isinstance(device, BrandSwitchNotificationLight): device_version = Version(device.firmwareVersion) entities.append(HomematicipLight(hap, device)) @@ -65,14 +63,14 @@ async def async_setup_entry( entity_class(hap, device, device.bottomLightChannelIndex, "Bottom") ) - elif isinstance(device, (AsyncWiredDimmer3, AsyncDinRailDimmer3)): + elif isinstance(device, (WiredDimmer3, DinRailDimmer3)): entities.extend( HomematicipMultiDimmer(hap, device, channel=channel) for channel in range(1, 4) ) elif isinstance( device, - (AsyncDimmer, AsyncPluggableDimmer, AsyncBrandDimmer, AsyncFullFlushDimmer), + (Dimmer, PluggableDimmer, BrandDimmer, FullFlushDimmer), ): entities.append(HomematicipDimmer(hap, device)) @@ -96,11 +94,11 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - await self._device.turn_on() + await self._device.turn_on_async() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self._device.turn_off() + await self._device.turn_off_async() class HomematicipLightMeasuring(HomematicipLight): @@ -141,15 +139,15 @@ class HomematicipMultiDimmer(HomematicipGenericEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the dimmer on.""" if ATTR_BRIGHTNESS in kwargs: - await self._device.set_dim_level( + await self._device.set_dim_level_async( kwargs[ATTR_BRIGHTNESS] / 255.0, self._channel ) else: - await self._device.set_dim_level(1, self._channel) + await self._device.set_dim_level_async(1, self._channel) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the dimmer off.""" - await self._device.set_dim_level(0, self._channel) + await self._device.set_dim_level_async(0, self._channel) class HomematicipDimmer(HomematicipMultiDimmer, LightEntity): @@ -239,7 +237,7 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): dim_level = brightness / 255.0 transition = kwargs.get(ATTR_TRANSITION, 0.5) - await self._device.set_rgb_dim_level_with_time( + await self._device.set_rgb_dim_level_with_time_async( channelIndex=self._channel, rgb=simple_rgb_color, dimLevel=dim_level, @@ -252,7 +250,7 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): simple_rgb_color = self._func_channel.simpleRGBColorState transition = kwargs.get(ATTR_TRANSITION, 0.5) - await self._device.set_rgb_dim_level_with_time( + await self._device.set_rgb_dim_level_with_time_async( channelIndex=self._channel, rgb=simple_rgb_color, dimLevel=0.0, diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py index a054e95a80d..bae075e1a17 100644 --- a/homeassistant/components/homematicip_cloud/lock.py +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -5,16 +5,15 @@ from __future__ import annotations import logging from typing import Any -from homematicip.aio.device import AsyncDoorLockDrive from homematicip.base.enums import LockState, MotorState +from homematicip.device import DoorLockDrive from homeassistant.components.lock import LockEntity, LockEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity +from .hap import HomematicIPConfigEntry from .helpers import handle_errors _LOGGER = logging.getLogger(__name__) @@ -36,16 +35,16 @@ DEVICE_DLD_ATTRIBUTES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP locks from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data async_add_entities( HomematicipDoorLockDrive(hap, device) for device in hap.home.devices - if isinstance(device, AsyncDoorLockDrive) + if isinstance(device, DoorLockDrive) ) @@ -75,17 +74,17 @@ class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity): @handle_errors async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" - return await self._device.set_lock_state(LockState.LOCKED) + return await self._device.set_lock_state_async(LockState.LOCKED) @handle_errors async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - return await self._device.set_lock_state(LockState.UNLOCKED) + return await self._device.set_lock_state_async(LockState.UNLOCKED) @handle_errors async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - return await self._device.set_lock_state(LockState.OPEN) + return await self._device.set_lock_state_async(LockState.OPEN) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 414ba37709e..15bc24c110f 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.7"] + "requirements": ["homematicip==2.0.1.1"] } diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 0280f5bc7d5..4f43e6d6ca7 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -5,47 +5,47 @@ from __future__ import annotations from collections.abc import Callable from typing import Any -from homematicip.aio.device import ( - AsyncBrandSwitchMeasuring, - AsyncEnergySensorsInterface, - AsyncFloorTerminalBlock6, - AsyncFloorTerminalBlock10, - AsyncFloorTerminalBlock12, - AsyncFullFlushSwitchMeasuring, - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, - AsyncHomeControlAccessPoint, - AsyncLightSensor, - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, - AsyncPassageDetector, - AsyncPlugableSwitchMeasuring, - AsyncPresenceDetectorIndoor, - AsyncRoomControlDeviceAnalog, - AsyncTemperatureDifferenceSensor2, - AsyncTemperatureHumiditySensorDisplay, - AsyncTemperatureHumiditySensorOutdoor, - AsyncTemperatureHumiditySensorWithoutDisplay, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, - AsyncWiredFloorTerminalBlock12, -) from homematicip.base.enums import FunctionalChannelType, ValveState from homematicip.base.functionalChannels import ( FloorTerminalBlockMechanicChannel, FunctionalChannel, ) +from homematicip.device import ( + BrandSwitchMeasuring, + EnergySensorsInterface, + FloorTerminalBlock6, + FloorTerminalBlock10, + FloorTerminalBlock12, + FullFlushSwitchMeasuring, + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, + HomeControlAccessPoint, + LightSensor, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, + PassageDetector, + PlugableSwitchMeasuring, + PresenceDetectorIndoor, + RoomControlDeviceAnalog, + TemperatureDifferenceSensor2, + TemperatureHumiditySensorDisplay, + TemperatureHumiditySensorOutdoor, + TemperatureHumiditySensorWithoutDisplay, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, + WiredFloorTerminalBlock12, +) from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, LIGHT_LUX, PERCENTAGE, UnitOfEnergy, @@ -60,9 +60,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP from .helpers import get_channels_from_device ATTR_CURRENT_ILLUMINATION = "current_illumination" @@ -95,21 +94,21 @@ ILLUMINATION_DEVICE_ATTRIBUTES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud sensors from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: - if isinstance(device, AsyncHomeControlAccessPoint): + if isinstance(device, HomeControlAccessPoint): entities.append(HomematicipAccesspointDutyCycle(hap, device)) if isinstance( device, ( - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, ), ): entities.append(HomematicipHeatingThermostat(hap, device)) @@ -117,55 +116,54 @@ async def async_setup_entry( if isinstance( device, ( - AsyncTemperatureHumiditySensorDisplay, - AsyncTemperatureHumiditySensorWithoutDisplay, - AsyncTemperatureHumiditySensorOutdoor, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, + TemperatureHumiditySensorDisplay, + TemperatureHumiditySensorWithoutDisplay, + TemperatureHumiditySensorOutdoor, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, ), ): entities.append(HomematicipTemperatureSensor(hap, device)) entities.append(HomematicipHumiditySensor(hap, device)) - elif isinstance(device, (AsyncRoomControlDeviceAnalog,)): + entities.append(HomematicipAbsoluteHumiditySensor(hap, device)) + elif isinstance(device, (RoomControlDeviceAnalog,)): entities.append(HomematicipTemperatureSensor(hap, device)) if isinstance( device, ( - AsyncLightSensor, - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, - AsyncPresenceDetectorIndoor, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, + LightSensor, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, + PresenceDetectorIndoor, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, ), ): entities.append(HomematicipIlluminanceSensor(hap, device)) if isinstance( device, ( - AsyncPlugableSwitchMeasuring, - AsyncBrandSwitchMeasuring, - AsyncFullFlushSwitchMeasuring, + PlugableSwitchMeasuring, + BrandSwitchMeasuring, + FullFlushSwitchMeasuring, ), ): entities.append(HomematicipPowerSensor(hap, device)) entities.append(HomematicipEnergySensor(hap, device)) - if isinstance( - device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) - ): + if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipWindspeedSensor(hap, device)) - if isinstance(device, (AsyncWeatherSensorPlus, AsyncWeatherSensorPro)): + if isinstance(device, (WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipTodayRainSensor(hap, device)) - if isinstance(device, AsyncPassageDetector): + if isinstance(device, PassageDetector): entities.append(HomematicipPassageDetectorDeltaCounter(hap, device)) - if isinstance(device, AsyncTemperatureDifferenceSensor2): + if isinstance(device, TemperatureDifferenceSensor2): entities.append(HomematicpTemperatureExternalSensorCh1(hap, device)) entities.append(HomematicpTemperatureExternalSensorCh2(hap, device)) entities.append(HomematicpTemperatureExternalSensorDelta(hap, device)) - if isinstance(device, AsyncEnergySensorsInterface): + if isinstance(device, EnergySensorsInterface): for ch in get_channels_from_device( device, FunctionalChannelType.ENERGY_SENSORS_INTERFACE_CHANNEL ): @@ -194,10 +192,10 @@ async def async_setup_entry( if isinstance( device, ( - AsyncFloorTerminalBlock6, - AsyncFloorTerminalBlock10, - AsyncFloorTerminalBlock12, - AsyncWiredFloorTerminalBlock12, + FloorTerminalBlock6, + FloorTerminalBlock10, + FloorTerminalBlock12, + WiredFloorTerminalBlock12, ), ): entities.extend( @@ -350,6 +348,35 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): return state_attr +class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP absolute humidity sensor.""" + + _attr_native_unit_of_measurement = CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the thermometer device.""" + super().__init__(hap, device, post="Absolute Humidity") + + @property + def native_value(self) -> int | None: + """Return the state.""" + if self.functional_channel is None: + return None + + value = self.functional_channel.vaporAmount + + # Handle case where value might be None + if ( + self.functional_channel.vaporAmount is None + or self.functional_channel.vaporAmount == "" + ): + return None + + # Convert from g/m³ to mg/m³ + return int(float(value) * 1000) + + class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP Illuminance sensor.""" diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 7a4dfd4916f..2e76a0b7aac 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -5,10 +5,10 @@ from __future__ import annotations import logging from pathlib import Path -from homematicip.aio.device import AsyncSwitchMeasuring -from homematicip.aio.group import AsyncHeatingGroup -from homematicip.aio.home import AsyncHome +from homematicip.async_home import AsyncHome from homematicip.base.helpers import handle_config +from homematicip.device import SwitchMeasuring +from homematicip.group import HeatingGroup import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE @@ -22,6 +22,7 @@ from homeassistant.helpers.service import ( ) from .const import DOMAIN +from .hap import HomematicIPConfigEntry _LOGGER = logging.getLogger(__name__) @@ -218,7 +219,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def async_unload_services(hass: HomeAssistant): """Unload HomematicIP Cloud services.""" - if hass.data[DOMAIN]: + if hass.config_entries.async_loaded_entries(DOMAIN): return for hmipc_service in HMIPC_SERVICES: @@ -233,10 +234,11 @@ async def _async_activate_eco_mode_with_duration( if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.activate_absence_with_duration(duration) + await home.activate_absence_with_duration_async(duration) else: - for hap in hass.data[DOMAIN].values(): - await hap.home.activate_absence_with_duration(duration) + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.activate_absence_with_duration_async(duration) async def _async_activate_eco_mode_with_period( @@ -247,10 +249,11 @@ async def _async_activate_eco_mode_with_period( if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.activate_absence_with_period(endtime) + await home.activate_absence_with_period_async(endtime) else: - for hap in hass.data[DOMAIN].values(): - await hap.home.activate_absence_with_period(endtime) + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.activate_absence_with_period_async(endtime) async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> None: @@ -260,30 +263,33 @@ async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.activate_vacation(endtime, temperature) + await home.activate_vacation_async(endtime, temperature) else: - for hap in hass.data[DOMAIN].values(): - await hap.home.activate_vacation(endtime, temperature) + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.activate_vacation_async(endtime, temperature) async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) -> None: """Service to deactivate eco mode.""" if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.deactivate_absence() + await home.deactivate_absence_async() else: - for hap in hass.data[DOMAIN].values(): - await hap.home.deactivate_absence() + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.deactivate_absence_async() async def _async_deactivate_vacation(hass: HomeAssistant, service: ServiceCall) -> None: """Service to deactivate vacation.""" if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.deactivate_vacation() + await home.deactivate_vacation_async() else: - for hap in hass.data[DOMAIN].values(): - await hap.home.deactivate_vacation() + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.deactivate_vacation_async() async def _set_active_climate_profile( @@ -293,16 +299,17 @@ async def _set_active_climate_profile( entity_id_list = service.data[ATTR_ENTITY_ID] climate_profile_index = service.data[ATTR_CLIMATE_PROFILE_INDEX] - 1 - for hap in hass.data[DOMAIN].values(): + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): if entity_id_list != "all": for entity_id in entity_id_list: - group = hap.hmip_device_by_entity_id.get(entity_id) - if group and isinstance(group, AsyncHeatingGroup): - await group.set_active_profile(climate_profile_index) + group = entry.runtime_data.hmip_device_by_entity_id.get(entity_id) + if group and isinstance(group, HeatingGroup): + await group.set_active_profile_async(climate_profile_index) else: - for group in hap.home.groups: - if isinstance(group, AsyncHeatingGroup): - await group.set_active_profile(climate_profile_index) + for group in entry.runtime_data.home.groups: + if isinstance(group, HeatingGroup): + await group.set_active_profile_async(climate_profile_index) async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> None: @@ -313,8 +320,10 @@ async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> N config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] anonymize = service.data[ATTR_ANONYMIZE] - for hap in hass.data[DOMAIN].values(): - hap_sgtin = hap.config_entry.unique_id + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + hap_sgtin = entry.unique_id + assert hap_sgtin is not None if anonymize: hap_sgtin = hap_sgtin[-4:] @@ -323,7 +332,7 @@ async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> N path = Path(config_path) config_file = path / file_name - json_state = await hap.home.download_configuration() + json_state = await entry.runtime_data.home.download_configuration_async() json_state = handle_config(json_state, anonymize) config_file.write_text(json_state, encoding="utf8") @@ -333,16 +342,17 @@ async def _async_reset_energy_counter(hass: HomeAssistant, service: ServiceCall) """Service to reset the energy counter.""" entity_id_list = service.data[ATTR_ENTITY_ID] - for hap in hass.data[DOMAIN].values(): + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): if entity_id_list != "all": for entity_id in entity_id_list: - device = hap.hmip_device_by_entity_id.get(entity_id) - if device and isinstance(device, AsyncSwitchMeasuring): - await device.reset_energy_counter() + device = entry.runtime_data.hmip_device_by_entity_id.get(entity_id) + if device and isinstance(device, SwitchMeasuring): + await device.reset_energy_counter_async() else: - for device in hap.home.devices: - if isinstance(device, AsyncSwitchMeasuring): - await device.reset_energy_counter() + for device in entry.runtime_data.home.devices: + if isinstance(device, SwitchMeasuring): + await device.reset_energy_counter_async() async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall): @@ -351,16 +361,19 @@ async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.set_cooling(cooling) + await home.set_cooling_async(cooling) else: - for hap in hass.data[DOMAIN].values(): - await hap.home.set_cooling(cooling) + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + await entry.runtime_data.home.set_cooling_async(cooling) def _get_home(hass: HomeAssistant, hapid: str) -> AsyncHome | None: """Return a HmIP home.""" - if hap := hass.data[DOMAIN].get(hapid): - return hap.home + entry: HomematicIPConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if entry.unique_id == hapid: + return entry.runtime_data.home raise ServiceValidationError( translation_domain=DOMAIN, diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 37deace7ebf..7b1b08ac4e2 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -3,6 +3,7 @@ "step": { "init": { "title": "Pick Homematic IP access point", + "description": "If you are about to register a **Homematic IP HCU1**, please press the button on top of the device before you continue.\n\nThe registration process must be completed within 5 minutes.", "data": { "hapid": "Access point ID (SGTIN)", "pin": "[%key:common::config_flow::data::pin%]", @@ -34,7 +35,7 @@ "services": { "activate_eco_mode_with_duration": { "name": "Activate eco mode with duration", - "description": "Activates eco mode with period.", + "description": "Activates the eco mode for a specified duration.", "fields": { "duration": { "name": "Duration", @@ -48,7 +49,7 @@ }, "activate_eco_mode_with_period": { "name": "Activate eco more with period", - "description": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::description%]", + "description": "Activates the eco mode until a given time.", "fields": { "endtime": { "name": "Endtime", @@ -62,7 +63,7 @@ }, "activate_vacation": { "name": "Activate vacation", - "description": "Activates the vacation mode until the given time.", + "description": "Activates the vacation mode until a given time.", "fields": { "endtime": { "name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_period::fields::endtime::name%]", diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index a9aa1c664d7..4927d9a32df 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -4,64 +4,60 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncBrandSwitch2, - AsyncBrandSwitchMeasuring, - AsyncDinRailSwitch, - AsyncDinRailSwitch4, - AsyncFullFlushInputSwitch, - AsyncFullFlushSwitchMeasuring, - AsyncHeatingSwitch2, - AsyncMultiIOBox, - AsyncOpenCollector8Module, - AsyncPlugableSwitch, - AsyncPlugableSwitchMeasuring, - AsyncPrintedCircuitBoardSwitch2, - AsyncPrintedCircuitBoardSwitchBattery, - AsyncWiredSwitch8, +from homematicip.device import ( + BrandSwitch2, + BrandSwitchMeasuring, + DinRailSwitch, + DinRailSwitch4, + FullFlushInputSwitch, + FullFlushSwitchMeasuring, + HeatingSwitch2, + MultiIOBox, + OpenCollector8Module, + PlugableSwitch, + PlugableSwitchMeasuring, + PrintedCircuitBoardSwitch2, + PrintedCircuitBoardSwitchBattery, + WiredSwitch8, ) -from homematicip.aio.group import AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup +from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import ATTR_GROUP_MEMBER_UNREACHABLE, HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP switch from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [ HomematicipGroupSwitch(hap, group) for group in hap.home.groups - if isinstance(group, (AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup)) + if isinstance(group, (ExtendedLinkedSwitchingGroup, SwitchingGroup)) ] for device in hap.home.devices: - if isinstance(device, AsyncBrandSwitchMeasuring): + if isinstance(device, BrandSwitchMeasuring): # BrandSwitchMeasuring inherits PlugableSwitchMeasuring # This entity is implemented in the light platform and will # not be added in the switch platform pass - elif isinstance( - device, (AsyncPlugableSwitchMeasuring, AsyncFullFlushSwitchMeasuring) - ): + elif isinstance(device, (PlugableSwitchMeasuring, FullFlushSwitchMeasuring)): entities.append(HomematicipSwitchMeasuring(hap, device)) - elif isinstance(device, AsyncWiredSwitch8): + elif isinstance(device, WiredSwitch8): entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) for channel in range(1, 9) ) - elif isinstance(device, AsyncDinRailSwitch): + elif isinstance(device, DinRailSwitch): entities.append(HomematicipMultiSwitch(hap, device, channel=1)) - elif isinstance(device, AsyncDinRailSwitch4): + elif isinstance(device, DinRailSwitch4): entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) for channel in range(1, 5) @@ -69,13 +65,13 @@ async def async_setup_entry( elif isinstance( device, ( - AsyncPlugableSwitch, - AsyncPrintedCircuitBoardSwitchBattery, - AsyncFullFlushInputSwitch, + PlugableSwitch, + PrintedCircuitBoardSwitchBattery, + FullFlushInputSwitch, ), ): entities.append(HomematicipSwitch(hap, device)) - elif isinstance(device, AsyncOpenCollector8Module): + elif isinstance(device, OpenCollector8Module): entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) for channel in range(1, 9) @@ -83,10 +79,10 @@ async def async_setup_entry( elif isinstance( device, ( - AsyncBrandSwitch2, - AsyncPrintedCircuitBoardSwitch2, - AsyncHeatingSwitch2, - AsyncMultiIOBox, + BrandSwitch2, + PrintedCircuitBoardSwitch2, + HeatingSwitch2, + MultiIOBox, ), ): entities.extend( @@ -119,11 +115,11 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self._device.turn_on(self._channel) + await self._device.turn_on_async(self._channel) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self._device.turn_off(self._channel) + await self._device.turn_off_async(self._channel) class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity): @@ -168,11 +164,11 @@ class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the group on.""" - await self._device.turn_on() + await self._device.turn_on_async() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the group off.""" - await self._device.turn_off() + await self._device.turn_off_async() class HomematicipSwitchMeasuring(HomematicipSwitch): diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index 1125c73f8d4..061f6642bb2 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -2,12 +2,8 @@ from __future__ import annotations -from homematicip.aio.device import ( - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, -) from homematicip.base.enums import WeatherCondition +from homematicip.device import WeatherSensor, WeatherSensorPlus, WeatherSensorPro from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, @@ -22,14 +18,12 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, WeatherEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import HomematicipGenericEntity -from .hap import HomematicipHAP +from .hap import HomematicIPConfigEntry, HomematicipHAP HOME_WEATHER_CONDITION = { WeatherCondition.CLEAR: ATTR_CONDITION_SUNNY, @@ -52,16 +46,16 @@ HOME_WEATHER_CONDITION = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomematicIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP weather sensor from a config entry.""" - hap = hass.data[DOMAIN][config_entry.unique_id] + hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: - if isinstance(device, AsyncWeatherSensorPro): + if isinstance(device, WeatherSensorPro): entities.append(HomematicipWeatherSensorPro(hap, device)) - elif isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus)): + elif isinstance(device, (WeatherSensor, WeatherSensorPlus)): entities.append(HomematicipWeatherSensor(hap, device)) entities.append(HomematicipHomeWeather(hap)) diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index 75fdeb4f8cc..4beea27374a 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping from dataclasses import dataclass import logging from typing import Any @@ -58,6 +57,8 @@ SERVICE_SEND_COMMAND_SCHEMA = vol.Schema( } ) +type HomeworksConfigEntry = ConfigEntry[HomeworksData] + @dataclass class HomeworksData: @@ -72,45 +73,44 @@ class HomeworksData: def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Lutron Homeworks Series 4 and 8 integration.""" - async def async_call_service(service_call: ServiceCall) -> None: - """Call the service.""" - await async_send_command(hass, service_call.data) - hass.services.async_register( DOMAIN, "send_command", - async_call_service, + async_send_command, schema=SERVICE_SEND_COMMAND_SCHEMA, ) -async def async_send_command(hass: HomeAssistant, data: Mapping[str, Any]) -> None: +async def async_send_command(service_call: ServiceCall) -> None: """Send command to a controller.""" def get_controller_ids() -> list[str]: """Get homeworks data for the specified controller ID.""" - return [data.controller_id for data in hass.data[DOMAIN].values()] + return [ + entry.runtime_data.controller_id + for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN) + ] def get_homeworks_data(controller_id: str) -> HomeworksData | None: """Get homeworks data for the specified controller ID.""" - data: HomeworksData - for data in hass.data[DOMAIN].values(): - if data.controller_id == controller_id: - return data + entry: HomeworksConfigEntry + for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN): + if entry.runtime_data.controller_id == controller_id: + return entry.runtime_data return None - homeworks_data = get_homeworks_data(data[CONF_CONTROLLER_ID]) + homeworks_data = get_homeworks_data(service_call.data[CONF_CONTROLLER_ID]) if not homeworks_data: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_controller_id", translation_placeholders={ - "controller_id": data[CONF_CONTROLLER_ID], + "controller_id": service_call.data[CONF_CONTROLLER_ID], "controller_ids": ",".join(get_controller_ids()), }, ) - commands = data[CONF_COMMAND] + commands = service_call.data[CONF_COMMAND] _LOGGER.debug("Send commands: %s", commands) for command in commands: if command.lower().startswith("delay"): @@ -119,7 +119,7 @@ async def async_send_command(hass: HomeAssistant, data: Mapping[str, Any]) -> No await asyncio.sleep(delay / 1000) else: _LOGGER.debug("Sending command '%s'", command) - await hass.async_add_executor_job( + await service_call.hass.async_add_executor_job( homeworks_data.controller._send, # noqa: SLF001 command, ) @@ -132,10 +132,9 @@ 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: HomeworksConfigEntry) -> bool: """Set up Homeworks from a config entry.""" - hass.data.setdefault(DOMAIN, {}) controller_id = entry.options[CONF_CONTROLLER_ID] def hw_callback(msg_type: Any, values: Any) -> None: @@ -174,9 +173,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name = key_config[CONF_NAME] keypads[addr] = HomeworksKeypad(hass, controller, controller_id, addr, name) - hass.data[DOMAIN][entry.entry_id] = HomeworksData( - controller, controller_id, keypads - ) + entry.runtime_data = HomeworksData(controller, controller_id, keypads) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -184,19 +181,18 @@ 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: HomeworksConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: HomeworksData = hass.data[DOMAIN].pop(entry.entry_id) - for keypad in data.keypads.values(): + for keypad in entry.runtime_data.keypads.values(): keypad.unsubscribe() - await hass.async_add_executor_job(data.controller.stop) + await hass.async_add_executor_job(entry.runtime_data.controller.stop) return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: HomeworksConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/homeworks/binary_sensor.py b/homeassistant/components/homeworks/binary_sensor.py index 9bdea75479d..9c2b2e12bc2 100644 --- a/homeassistant/components/homeworks/binary_sensor.py +++ b/homeassistant/components/homeworks/binary_sensor.py @@ -8,14 +8,13 @@ from typing import Any from pyhomeworks.pyhomeworks import HW_KEYPAD_LED_CHANGED, Homeworks from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import HomeworksData, HomeworksKeypad +from . import HomeworksConfigEntry, HomeworksKeypad from .const import ( CONF_ADDR, CONF_BUTTONS, @@ -32,11 +31,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HomeworksConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homeworks binary sensors.""" - data: HomeworksData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data controller = data.controller controller_id = entry.options[CONF_CONTROLLER_ID] entities = [] diff --git a/homeassistant/components/homeworks/button.py b/homeassistant/components/homeworks/button.py index d76c18985e9..47c92a323ee 100644 --- a/homeassistant/components/homeworks/button.py +++ b/homeassistant/components/homeworks/button.py @@ -7,13 +7,12 @@ import asyncio from pyhomeworks.pyhomeworks import Homeworks from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import HomeworksData +from . import HomeworksConfigEntry from .const import ( CONF_ADDR, CONF_BUTTONS, @@ -28,12 +27,11 @@ from .entity import HomeworksEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HomeworksConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homeworks buttons.""" - data: HomeworksData = hass.data[DOMAIN][entry.entry_id] - controller = data.controller + controller = entry.runtime_data.controller controller_id = entry.options[CONF_CONTROLLER_ID] entities = [] for keypad in entry.options.get(CONF_KEYPADS, []): diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py index f07758bbace..a9ed35f859e 100644 --- a/homeassistant/components/homeworks/light.py +++ b/homeassistant/components/homeworks/light.py @@ -8,14 +8,13 @@ from typing import Any from pyhomeworks.pyhomeworks import HW_LIGHT_CHANGED, Homeworks from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import HomeworksData +from . import HomeworksConfigEntry from .const import CONF_ADDR, CONF_CONTROLLER_ID, CONF_DIMMERS, CONF_RATE, DOMAIN from .entity import HomeworksEntity @@ -24,12 +23,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HomeworksConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homeworks lights.""" - data: HomeworksData = hass.data[DOMAIN][entry.entry_id] - controller = data.controller + controller = entry.runtime_data.controller controller_id = entry.options[CONF_CONTROLLER_ID] entities = [] for dimmer in entry.options.get(CONF_DIMMERS, []): diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index 1a144615e89..3ec4945957b 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -57,7 +57,7 @@ }, "exceptions": { "invalid_controller_id": { - "message": "Invalid controller_id \"{controller_id}\", expected one of \"{controller_ids}\"" + "message": "Invalid controller ID \"{controller_id}\", expected one of \"{controller_ids}\"" } }, "options": { diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index 2538e7101a1..67295ec5802 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Please enter the credentials used to log into mytotalconnectcomfort.com.", + "description": "Please enter the credentials used to log in to mytotalconnectcomfort.com.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -55,7 +55,7 @@ "preset_mode": { "state": { "hold": "Hold", - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" } } diff --git a/homeassistant/components/http/decorators.py b/homeassistant/components/http/decorators.py index 1adc21be09f..19a0a5d1c55 100644 --- a/homeassistant/components/http/decorators.py +++ b/homeassistant/components/http/decorators.py @@ -27,7 +27,8 @@ def require_admin[ ]( _func: None = None, *, - error: Unauthorized | None = None, + perm_category: str | None = None, + permission: str | None = None, ) -> Callable[ [_FuncType[_HomeAssistantViewT, _P, _ResponseT]], _FuncType[_HomeAssistantViewT, _P, _ResponseT], @@ -51,7 +52,8 @@ def require_admin[ ]( _func: _FuncType[_HomeAssistantViewT, _P, _ResponseT] | None = None, *, - error: Unauthorized | None = None, + perm_category: str | None = None, + permission: str | None = None, ) -> ( Callable[ [_FuncType[_HomeAssistantViewT, _P, _ResponseT]], @@ -76,7 +78,7 @@ def require_admin[ """Check admin and call function.""" user: User = request["hass_user"] if not user.is_admin: - raise error or Unauthorized() + raise Unauthorized(perm_category=perm_category, permission=permission) return await func(self, request, *args, **kwargs) diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py index ebc0594e15a..fdb325c7b74 100644 --- a/homeassistant/components/http/headers.py +++ b/homeassistant/components/http/headers.py @@ -3,25 +3,34 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from typing import Final +from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware from aiohttp.web_exceptions import HTTPException +from multidict import CIMultiDict, istr from homeassistant.core import callback +REFERRER_POLICY: Final[istr] = istr("Referrer-Policy") +X_CONTENT_TYPE_OPTIONS: Final[istr] = istr("X-Content-Type-Options") +X_FRAME_OPTIONS: Final[istr] = istr("X-Frame-Options") + @callback def setup_headers(app: Application, use_x_frame_options: bool) -> None: """Create headers middleware for the app.""" - added_headers = { - "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - "Server": "", # Empty server header, to prevent aiohttp of setting one. - } + added_headers = CIMultiDict( + { + REFERRER_POLICY: "no-referrer", + X_CONTENT_TYPE_OPTIONS: "nosniff", + hdrs.SERVER: "", # Empty server header, to prevent aiohttp of setting one. + } + ) if use_x_frame_options: - added_headers["X-Frame-Options"] = "SAMEORIGIN" + added_headers[X_FRAME_OPTIONS] = "SAMEORIGIN" @middleware async def headers_middleware( diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index a5a60d8406d..6126968eab6 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -23,7 +23,6 @@ from huawei_lte_api.exceptions import ( from requests.exceptions import Timeout import voluptuous as vol -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_HW_VERSION, @@ -88,38 +87,9 @@ from .utils import get_device_macs, non_verifying_requests_session _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=30) -NOTIFY_SCHEMA = vol.Any( - None, - vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_RECIPIENT): vol.Any( - None, vol.All(cv.ensure_list, [cv.string]) - ), - } - ), -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_URL): cv.url, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(NOTIFY_DOMAIN): NOTIFY_SCHEMA, - } - ) - ], - ) - }, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_URL): cv.url}) diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index c3434dd0b64..41f4638b713 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -9,6 +9,7 @@ from huawei_lte_api.enums.cradle import ConnectionStatusEnum from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -104,6 +105,7 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): _attr_translation_key = "mobile_connection" _attr_entity_registry_enabled_default = True + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY key = KEY_MONITORING_STATUS item = "ConnectionStatus" @@ -140,6 +142,8 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): class HuaweiLteBaseWifiStatusBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE WiFi status binary sensor base class.""" + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + @property def is_on(self) -> bool: """Return whether the binary sensor is on.""" diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 96e160ece7b..88167fab4b9 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -40,6 +40,7 @@ from homeassistant.core import callback from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, ATTR_UPNP_PRESENTATION_URL, ATTR_UPNP_SERIAL, ATTR_UPNP_UDN, @@ -178,8 +179,8 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): except Timeout: _LOGGER.warning("Connection timeout", exc_info=True) errors[CONF_URL] = "connection_timeout" - except Exception: # noqa: BLE001 - _LOGGER.warning("Unknown error connecting to device", exc_info=True) + except Exception: + _LOGGER.exception("Unknown error connecting to device") errors[CONF_URL] = "unknown" return conn @@ -188,8 +189,8 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): try: conn.close() conn.requests_session.close() - except Exception: # noqa: BLE001 - _LOGGER.debug("Disconnect error", exc_info=True) + except Exception: + _LOGGER.exception("Disconnect error") async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -276,11 +277,12 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): if TYPE_CHECKING: assert discovery_info.ssdp_location url = url_normalize( - discovery_info.upnp.get( - ATTR_UPNP_PRESENTATION_URL, - f"http://{urlparse(discovery_info.ssdp_location).hostname}/", - ) + discovery_info.upnp.get(ATTR_UPNP_PRESENTATION_URL) + or f"http://{urlparse(discovery_info.ssdp_location).hostname}/" ) + if TYPE_CHECKING: + # url_normalize only returns None if passed None, and we don't do that + assert url is not None unique_id = discovery_info.upnp.get( ATTR_UPNP_SERIAL, discovery_info.upnp[ATTR_UPNP_UDN] @@ -308,8 +310,11 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - CONF_NAME: discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) - or "Huawei LTE" + CONF_NAME: ( + discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) + or discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) + or "Huawei LTE" + ) } } ) diff --git a/homeassistant/components/huawei_lte/entity.py b/homeassistant/components/huawei_lte/entity.py index 99d7ca112c4..b69d2e79fb6 100644 --- a/homeassistant/components/huawei_lte/entity.py +++ b/homeassistant/components/huawei_lte/entity.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from datetime import timedelta from homeassistant.helpers.device_registry import DeviceInfo @@ -25,7 +24,6 @@ class HuaweiLteBaseEntity(Entity): def __init__(self, router: Router) -> None: """Initialize.""" self.router = router - self._unsub_handlers: list[Callable] = [] @property def _device_unique_id(self) -> str: @@ -48,7 +46,7 @@ class HuaweiLteBaseEntity(Entity): async def async_added_to_hass(self) -> None: """Connect to update signals.""" - self._unsub_handlers.append( + self.async_on_remove( async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self._async_maybe_update) ) @@ -57,12 +55,6 @@ class HuaweiLteBaseEntity(Entity): if config_entry_unique_id == self.router.config_entry.unique_id: self.async_schedule_update_ha_state(True) - async def async_will_remove_from_hass(self) -> None: - """Invoke unsubscription handlers.""" - for unsub in self._unsub_handlers: - unsub() - self._unsub_handlers.clear() - class HuaweiLteBaseEntityWithDevice(HuaweiLteBaseEntity): """Base entity with device info.""" diff --git a/homeassistant/components/huawei_lte/icons.json b/homeassistant/components/huawei_lte/icons.json index a338cc65ed4..862daa47cde 100644 --- a/homeassistant/components/huawei_lte/icons.json +++ b/homeassistant/components/huawei_lte/icons.json @@ -34,7 +34,138 @@ }, "select": { "preferred_network_mode": { - "default": "mdi:transmission-tower" + "default": "mdi:antenna" + } + }, + "sensor": { + "uptime": { + "default": "mdi:timer-outline" + }, + "wan_ip_address": { + "default": "mdi:ip" + }, + "wan_ipv6_address": { + "default": "mdi:ip" + }, + "cell_id": { + "default": "mdi:antenna" + }, + "cqi0": { + "default": "mdi:speedometer" + }, + "cqi1": { + "default": "mdi:speedometer" + }, + "enodeb_id": { + "default": "mdi:antenna" + }, + "lac": { + "default": "mdi:map-marker" + }, + "nei_cellid": { + "default": "mdi:antenna" + }, + "nrcqi0": { + "default": "mdi:speedometer" + }, + "nrcqi1": { + "default": "mdi:speedometer" + }, + "pci": { + "default": "mdi:antenna" + }, + "rac": { + "default": "mdi:map-marker" + }, + "tac": { + "default": "mdi:map-marker" + }, + "sms_unread": { + "default": "mdi:email-arrow-left" + }, + "current_day_transfer": { + "default": "mdi:arrow-up-down-bold" + }, + "current_month_download": { + "default": "mdi:download" + }, + "current_month_upload": { + "default": "mdi:upload" + }, + "wifi_clients_connected": { + "default": "mdi:wifi" + }, + "primary_dns_server": { + "default": "mdi:ip" + }, + "primary_ipv6_dns_server": { + "default": "mdi:ip" + }, + "secondary_dns_server": { + "default": "mdi:ip" + }, + "secondary_ipv6_dns_server": { + "default": "mdi:ip" + }, + "current_connection_duration": { + "default": "mdi:timer-outline" + }, + "current_connection_download": { + "default": "mdi:download" + }, + "current_download_rate": { + "default": "mdi:download" + }, + "current_connection_upload": { + "default": "mdi:upload" + }, + "current_upload_rate": { + "default": "mdi:upload" + }, + "total_connected_duration": { + "default": "mdi:timer-outline" + }, + "total_download": { + "default": "mdi:download" + }, + "total_upload": { + "default": "mdi:upload" + }, + "sms_deleted_device": { + "default": "mdi:email-minus" + }, + "sms_drafts_device": { + "default": "mdi:email-arrow-right-outline" + }, + "sms_inbox_device": { + "default": "mdi:email" + }, + "sms_capacity_device": { + "default": "mdi:email" + }, + "sms_outbox_device": { + "default": "mdi:email-arrow-right" + }, + "sms_unread_device": { + "default": "mdi:email-arrow-left" + }, + "sms_drafts_sim": { + "default": "mdi:email-arrow-right-outline" + }, + "sms_inbox_sim": { + "default": "mdi:email" + }, + "sms_capacity_sim": { + "default": "mdi:email" + }, + "sms_outbox_sim": { + "default": "mdi:email-arrow-right" + }, + "sms_unread_sim": { + "default": "mdi:email-arrow-left" + }, + "sms_messages_sim": { + "default": "mdi:email-arrow-left" } }, "switch": { diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 6720d6718ef..c2e945e9c49 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -7,9 +7,9 @@ "iot_class": "local_polling", "loggers": ["huawei_lte_api.Session"], "requirements": [ - "huawei-lte-api==1.10.0", + "huawei-lte-api==1.11.0", "stringcase==1.2.0", - "url-normalize==1.4.3" + "url-normalize==2.2.1" ], "ssdp": [ { diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 3543433ca45..003ba1f9823 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -138,7 +138,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "uptime": HuaweiSensorEntityDescription( key="uptime", translation_key="uptime", - icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, @@ -146,14 +145,12 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "WanIPAddress": HuaweiSensorEntityDescription( key="WanIPAddress", translation_key="wan_ip_address", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=True, ), "WanIPv6Address": HuaweiSensorEntityDescription( key="WanIPv6Address", translation_key="wan_ipv6_address", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), }, @@ -181,19 +178,16 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "cell_id": HuaweiSensorEntityDescription( key="cell_id", translation_key="cell_id", - icon="mdi:transmission-tower", entity_category=EntityCategory.DIAGNOSTIC, ), "cqi0": HuaweiSensorEntityDescription( key="cqi0", translation_key="cqi0", - icon="mdi:speedometer", entity_category=EntityCategory.DIAGNOSTIC, ), "cqi1": HuaweiSensorEntityDescription( key="cqi1", translation_key="cqi1", - icon="mdi:speedometer", entity_category=EntityCategory.DIAGNOSTIC, ), "dl_mcs": HuaweiSensorEntityDescription( @@ -232,10 +226,14 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="enodeb_id", entity_category=EntityCategory.DIAGNOSTIC, ), + "ims": HuaweiSensorEntityDescription( + key="ims", + translation_key="ims", + entity_category=EntityCategory.DIAGNOSTIC, + ), "lac": HuaweiSensorEntityDescription( key="lac", translation_key="lac", - icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "ltedlfreq": HuaweiSensorEntityDescription( @@ -270,6 +268,11 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), entity_category=EntityCategory.DIAGNOSTIC, ), + "nei_cellid": HuaweiSensorEntityDescription( + key="nei_cellid", + translation_key="nei_cellid", + entity_category=EntityCategory.DIAGNOSTIC, + ), "nrbler": HuaweiSensorEntityDescription( key="nrbler", translation_key="nrbler", @@ -278,13 +281,11 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "nrcqi0": HuaweiSensorEntityDescription( key="nrcqi0", translation_key="nrcqi0", - icon="mdi:speedometer", entity_category=EntityCategory.DIAGNOSTIC, ), "nrcqi1": HuaweiSensorEntityDescription( key="nrcqi1", translation_key="nrcqi1", - icon="mdi:speedometer", entity_category=EntityCategory.DIAGNOSTIC, ), "nrdlbandwidth": HuaweiSensorEntityDescription( @@ -364,7 +365,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "pci": HuaweiSensorEntityDescription( key="pci", translation_key="pci", - icon="mdi:transmission-tower", entity_category=EntityCategory.DIAGNOSTIC, ), "plmn": HuaweiSensorEntityDescription( @@ -375,7 +375,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "rac": HuaweiSensorEntityDescription( key="rac", translation_key="rac", - icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "rrc_status": HuaweiSensorEntityDescription( @@ -422,6 +421,17 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=True, ), + "rxlev": HuaweiSensorEntityDescription( + key="rxlev", + translation_key="rxlev", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "sc": HuaweiSensorEntityDescription( + key="sc", + translation_key="sc", + entity_category=EntityCategory.DIAGNOSTIC, + ), "sinr": HuaweiSensorEntityDescription( key="sinr", translation_key="sinr", @@ -435,7 +445,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "tac": HuaweiSensorEntityDescription( key="tac", translation_key="tac", - icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "tdd": HuaweiSensorEntityDescription( @@ -479,6 +488,12 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { device_class=SensorDeviceClass.FREQUENCY, entity_category=EntityCategory.DIAGNOSTIC, ), + "wdlfreq": HuaweiSensorEntityDescription( + key="wdlfreq", + translation_key="wdlfreq", + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + ), } ), # @@ -493,7 +508,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "UnreadMessage": HuaweiSensorEntityDescription( key="UnreadMessage", translation_key="sms_unread", - icon="mdi:email-arrow-left", ), }, ), @@ -507,7 +521,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_day_transfer", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:arrow-up-down-bold", state_class=SensorStateClass.TOTAL, last_reset_item="CurrentDayDuration", last_reset_format_fn=format_last_reset_elapsed_seconds, @@ -517,7 +530,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_month_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:download", state_class=SensorStateClass.TOTAL, last_reset_item="MonthDuration", last_reset_format_fn=format_last_reset_elapsed_seconds, @@ -527,7 +539,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_month_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:upload", state_class=SensorStateClass.TOTAL, last_reset_item="MonthDuration", last_reset_format_fn=format_last_reset_elapsed_seconds, @@ -542,6 +553,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "BatteryPercent": HuaweiSensorEntityDescription( key="BatteryPercent", + translation_key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -550,32 +562,27 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "CurrentWifiUser": HuaweiSensorEntityDescription( key="CurrentWifiUser", translation_key="wifi_clients_connected", - icon="mdi:wifi", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), "PrimaryDns": HuaweiSensorEntityDescription( key="PrimaryDns", translation_key="primary_dns_server", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "PrimaryIPv6Dns": HuaweiSensorEntityDescription( key="PrimaryIPv6Dns", translation_key="primary_ipv6_dns_server", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "SecondaryDns": HuaweiSensorEntityDescription( key="SecondaryDns", translation_key="secondary_dns_server", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "SecondaryIPv6Dns": HuaweiSensorEntityDescription( key="SecondaryIPv6Dns", translation_key="secondary_ipv6_dns_server", - icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), }, @@ -588,14 +595,12 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_connection_duration", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, - icon="mdi:timer-outline", ), "CurrentDownload": HuaweiSensorEntityDescription( key="CurrentDownload", translation_key="current_connection_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:download", state_class=SensorStateClass.TOTAL_INCREASING, ), "CurrentDownloadRate": HuaweiSensorEntityDescription( @@ -603,7 +608,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_download_rate", native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:download", state_class=SensorStateClass.MEASUREMENT, ), "CurrentUpload": HuaweiSensorEntityDescription( @@ -611,7 +615,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_connection_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:upload", state_class=SensorStateClass.TOTAL_INCREASING, ), "CurrentUploadRate": HuaweiSensorEntityDescription( @@ -619,7 +622,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="current_upload_rate", native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:upload", state_class=SensorStateClass.MEASUREMENT, ), "TotalConnectTime": HuaweiSensorEntityDescription( @@ -627,7 +629,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="total_connected_duration", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, - icon="mdi:timer-outline", state_class=SensorStateClass.TOTAL_INCREASING, ), "TotalDownload": HuaweiSensorEntityDescription( @@ -635,7 +636,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="total_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:download", state_class=SensorStateClass.TOTAL_INCREASING, ), "TotalUpload": HuaweiSensorEntityDescription( @@ -643,7 +643,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { translation_key="total_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:upload", state_class=SensorStateClass.TOTAL_INCREASING, ), }, @@ -689,62 +688,50 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "LocalDeleted": HuaweiSensorEntityDescription( key="LocalDeleted", translation_key="sms_deleted_device", - icon="mdi:email-minus", ), "LocalDraft": HuaweiSensorEntityDescription( key="LocalDraft", translation_key="sms_drafts_device", - icon="mdi:email-arrow-right-outline", ), "LocalInbox": HuaweiSensorEntityDescription( key="LocalInbox", translation_key="sms_inbox_device", - icon="mdi:email", ), "LocalMax": HuaweiSensorEntityDescription( key="LocalMax", translation_key="sms_capacity_device", - icon="mdi:email", ), "LocalOutbox": HuaweiSensorEntityDescription( key="LocalOutbox", translation_key="sms_outbox_device", - icon="mdi:email-arrow-right", ), "LocalUnread": HuaweiSensorEntityDescription( key="LocalUnread", translation_key="sms_unread_device", - icon="mdi:email-arrow-left", ), "SimDraft": HuaweiSensorEntityDescription( key="SimDraft", translation_key="sms_drafts_sim", - icon="mdi:email-arrow-right-outline", ), "SimInbox": HuaweiSensorEntityDescription( key="SimInbox", translation_key="sms_inbox_sim", - icon="mdi:email", ), "SimMax": HuaweiSensorEntityDescription( key="SimMax", translation_key="sms_capacity_sim", - icon="mdi:email", ), "SimOutbox": HuaweiSensorEntityDescription( key="SimOutbox", translation_key="sms_outbox_sim", - icon="mdi:email-arrow-right", ), "SimUnread": HuaweiSensorEntityDescription( key="SimUnread", translation_key="sms_unread_sim", - icon="mdi:email-arrow-left", ), "SimUsed": HuaweiSensorEntityDescription( key="SimUsed", translation_key="sms_messages_sim", - icon="mdi:email-arrow-left", ), }, ), @@ -840,7 +827,7 @@ class HuaweiLteSensor(HuaweiLteBaseEntityWithDevice, SensorEntity): """Return icon for sensor.""" if self.entity_description.icon_fn: return self.entity_description.icon_fn(self.state) - return self.entity_description.icon + return super().icon @property def device_class(self) -> SensorDeviceClass | None: diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 879c7215562..2845338b9cf 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -26,6 +26,10 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::huawei_lte::config::step::user::data_description::password%]", + "username": "[%key:component::huawei_lte::config::step::user::data_description::username%]" } }, "user": { @@ -35,6 +39,12 @@ "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "password": "Password for accessing the router's API. Typically, the same as the one used for the router's web interface.", + "url": "Base URL to the API of the router. Typically, something like `http://192.168.X.1`. This is the beginning of the location shown in a browser when accessing the router's web interface.", + "username": "Username for accessing the router's API. Typically, the same as the one used for the router's web interface. Usually, either `admin`, or left empty (recommended if that works).", + "verify_ssl": "Whether to verify the SSL certificate of the router when accessing it. Applicable only if the router is accessed via HTTPS." + }, "description": "Enter device access details.", "title": "Configure Huawei LTE" } @@ -48,6 +58,12 @@ "recipient": "SMS notification recipients", "track_wired_clients": "Track wired network clients", "unauthenticated_mode": "Unauthenticated mode (change requires reload)" + }, + "data_description": { + "name": "Used to distinguish between notification services in case there are multiple Huawei LTE devices configured. Changes to this option value take effect after Home Assistant restart.", + "recipient": "Comma-separated list of default recipient SMS phone numbers for the notification service, used in case the notification sender does not specify any.", + "track_wired_clients": "Whether the device tracker entities track also clients attached to the router's wired Ethernet network, in addition to wireless clients.", + "unauthenticated_mode": "Whether to run in unauthenticated mode. Unauthenticated mode provides a limited set of features, but may help in case there are problems accessing the router's web interface from a browser while the integration is active. Changes to this option value take effect after integration reload." } } } @@ -116,6 +132,9 @@ "enodeb_id": { "name": "eNodeB ID" }, + "ims": { + "name": "IMS" + }, "lac": { "name": "LAC" }, @@ -125,6 +144,12 @@ "lte_uplink_frequency": { "name": "LTE uplink frequency" }, + "mode": { + "name": "Mode" + }, + "nei_cellid": { + "name": "Neighbor cell ID" + }, "nrbler": { "name": "5G block error rate" }, @@ -188,6 +213,12 @@ "rssi": { "name": "RSSI" }, + "rxlev": { + "name": "Received signal level" + }, + "sc": { + "name": "Scrambling code" + }, "sinr": { "name": "SINR" }, @@ -212,6 +243,9 @@ "uplink_frequency": { "name": "Uplink frequency" }, + "wdlfreq": { + "name": "WCDMA downlink frequency" + }, "sms_unread": { "name": "SMS unread" }, @@ -224,6 +258,9 @@ "current_month_upload": { "name": "Current month upload" }, + "battery": { + "name": "Battery" + }, "wifi_clients_connected": { "name": "Wi-Fi clients connected" }, @@ -272,8 +309,8 @@ "operator_search_mode": { "name": "Operator search mode", "state": { - "0": "Auto", - "1": "Manual" + "0": "[%key:common::state::auto%]", + "1": "[%key:common::state::manual%]" } }, "preferred_network_mode": { diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index d4c2959771b..991d7b51500 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -3,17 +3,17 @@ from aiohue.util import normalize_bridge_id from homeassistant.components import persistent_notification -from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .bridge import HueBridge +from .bridge import HueBridge, HueConfigEntry from .const import DOMAIN, SERVICE_HUE_ACTIVATE_SCENE from .migration import check_migration from .services import async_register_services -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool: """Set up a bridge from a config entry.""" # check (and run) migrations if needed await check_migration(hass, entry) @@ -104,10 +104,9 @@ 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: HueConfigEntry) -> bool: """Unload a config entry.""" - unload_success = await hass.data[DOMAIN][entry.entry_id].async_reset() - if len(hass.data[DOMAIN]) == 0: - hass.data.pop(DOMAIN) + unload_success = await entry.runtime_data.async_reset() + if not hass.config_entries.async_loaded_entries(DOMAIN): hass.services.async_remove(DOMAIN, SERVICE_HUE_ACTIVATE_SCENE) return unload_success diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index ecaa6576775..1d5f10a8c91 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -2,23 +2,21 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import HueBridge -from .const import DOMAIN +from .bridge import HueConfigEntry from .v1.binary_sensor import async_setup_entry as setup_entry_v1 from .v2.binary_sensor import async_setup_entry as setup_entry_v2 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data if bridge.api_version == 1: await setup_entry_v1(hass, config_entry, async_add_entities) else: diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 5397eeebd96..5dbb894c213 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -36,11 +36,13 @@ PLATFORMS_v2 = [ Platform.SWITCH, ] +type HueConfigEntry = ConfigEntry[HueBridge] + class HueBridge: """Manages a single Hue bridge.""" - def __init__(self, hass: core.HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: core.HomeAssistant, config_entry: HueConfigEntry) -> None: """Initialize the system.""" self.config_entry = config_entry self.hass = hass @@ -58,7 +60,7 @@ class HueBridge: else: self.api = HueBridgeV2(self.host, app_key) # store (this) bridge object in hass data - hass.data.setdefault(DOMAIN, {})[self.config_entry.entry_id] = self + self.config_entry.runtime_data = self @property def host(self) -> str: @@ -163,7 +165,7 @@ class HueBridge: ) if unload_success: - self.hass.data[DOMAIN].pop(self.config_entry.entry_id) + delattr(self.config_entry, "runtime_data") return unload_success @@ -179,7 +181,7 @@ class HueBridge: create_config_flow(self.hass, self.host) -async def _update_listener(hass: core.HomeAssistant, entry: ConfigEntry) -> None: +async def _update_listener(hass: core.HomeAssistant, entry: HueConfigEntry) -> None: """Handle ConfigEntry options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index db025922ef8..bec44352613 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -13,12 +13,7 @@ from aiohue.util import normalize_bridge_id import slugify as unicode_slug 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_VERSION, CONF_HOST from homeassistant.core import callback from homeassistant.helpers import ( @@ -28,6 +23,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from .bridge import HueConfigEntry from .const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, @@ -53,7 +49,7 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: HueConfigEntry, ) -> HueV1OptionsFlowHandler | HueV2OptionsFlowHandler: """Get the options flow for this handler.""" if config_entry.data.get(CONF_API_VERSION, 1) == 1: diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index dba5aba81da..9592be69e7e 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -26,14 +26,15 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo - from .bridge import HueBridge + from .bridge import HueConfigEntry async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - if DOMAIN not in hass.data: + entries: list[HueConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + if not entries: # happens at startup return config device_id = config[CONF_DEVICE_ID] @@ -42,10 +43,10 @@ async def async_validate_trigger_config( if (device_entry := dev_reg.async_get(device_id)) is None: raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid") - for conf_entry_id in device_entry.config_entries: - if conf_entry_id not in hass.data[DOMAIN]: + for entry in entries: + if entry.entry_id not in device_entry.config_entries: continue - bridge: HueBridge = hass.data[DOMAIN][conf_entry_id] + bridge = entry.runtime_data if bridge.api_version == 1: return await async_validate_trigger_config_v1(bridge, device_entry, config) return await async_validate_trigger_config_v2(bridge, device_entry, config) @@ -65,10 +66,11 @@ async def async_attach_trigger( if (device_entry := dev_reg.async_get(device_id)) is None: raise InvalidDeviceAutomationConfig(f"Device ID {device_id} is not valid") - for conf_entry_id in device_entry.config_entries: - if conf_entry_id not in hass.data[DOMAIN]: + entry: HueConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if entry.entry_id not in device_entry.config_entries: continue - bridge: HueBridge = hass.data[DOMAIN][conf_entry_id] + bridge = entry.runtime_data if bridge.api_version == 1: return await async_attach_trigger_v1( bridge, device_entry, config, action, trigger_info @@ -85,7 +87,8 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """Get device triggers for given (hass) device id.""" - if DOMAIN not in hass.data: + entries: list[HueConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + if not entries: return [] # lookup device in HASS DeviceRegistry dev_reg: dr.DeviceRegistry = dr.async_get(hass) @@ -94,10 +97,10 @@ async def async_get_triggers( # Iterate all config entries for this device # and work out the bridge version - for conf_entry_id in device_entry.config_entries: - if conf_entry_id not in hass.data[DOMAIN]: + for entry in entries: + if entry.entry_id not in device_entry.config_entries: continue - bridge: HueBridge = hass.data[DOMAIN][conf_entry_id] + bridge = entry.runtime_data if bridge.api_version == 1: return async_get_triggers_v1(bridge, device_entry) diff --git a/homeassistant/components/hue/diagnostics.py b/homeassistant/components/hue/diagnostics.py index 6bb23d832cd..a45813151e4 100644 --- a/homeassistant/components/hue/diagnostics.py +++ b/homeassistant/components/hue/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 .bridge import HueBridge -from .const import DOMAIN +from .bridge import HueConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: HueConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - bridge: HueBridge = hass.data[DOMAIN][entry.entry_id] + bridge = entry.runtime_data if bridge.api_version == 1: # diagnostics is only implemented for V2 bridges. return {} diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index 249f81687c0..4cffbb73a38 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -14,22 +14,21 @@ from homeassistant.components.event import ( EventEntity, EventEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import HueBridge -from .const import DEFAULT_BUTTON_EVENT_TYPES, DEVICE_SPECIFIC_EVENT_TYPES, DOMAIN +from .bridge import HueConfigEntry +from .const import DEFAULT_BUTTON_EVENT_TYPES, DEVICE_SPECIFIC_EVENT_TYPES from .v2.entity import HueBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up event platform from Hue button resources.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api if bridge.api_version == 1: diff --git a/homeassistant/components/hue/icons.json b/homeassistant/components/hue/icons.json index 31464308b0a..646c420f1fe 100644 --- a/homeassistant/components/hue/icons.json +++ b/homeassistant/components/hue/icons.json @@ -1,4 +1,28 @@ { + "entity": { + "light": { + "hue_light": { + "state_attributes": { + "effect": { + "state": { + "candle": "mdi:candle", + "sparkle": "mdi:shimmer", + "glisten": "mdi:creation", + "sunrise": "mdi:weather-sunset-up", + "sunset": "mdi:weather-sunset", + "fire": "mdi:fire", + "prism": "mdi:triangle-outline", + "opal": "mdi:diamond-stone", + "underwater": "mdi:waves", + "cosmos": "mdi:star-shooting", + "sunbeam": "mdi:spotlight-beam", + "enchant": "mdi:magic-staff" + } + } + } + } + } + }, "services": { "hue_activate_scene": { "service": "mdi:palette" diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 9906c9bffa4..332dc6978ad 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -2,12 +2,10 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import HueBridge -from .const import DOMAIN +from .bridge import HueConfigEntry from .v1.light import async_setup_entry as setup_entry_v1 from .v2.group import async_setup_entry as setup_groups_entry_v2 from .v2.light import async_setup_entry as setup_entry_v2 @@ -15,11 +13,11 @@ from .v2.light import async_setup_entry as setup_entry_v2 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up light entities.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data if bridge.api_version == 1: await setup_entry_v1(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/hue/migration.py b/homeassistant/components/hue/migration.py index 1214f39d146..55edf7d5565 100644 --- a/homeassistant/components/hue/migration.py +++ b/homeassistant/components/hue/migration.py @@ -10,7 +10,6 @@ from aiohue.v2.models.resource import ResourceTypes from homeassistant import core from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST, CONF_USERNAME from homeassistant.helpers import ( aiohttp_client, @@ -18,12 +17,13 @@ from homeassistant.helpers import ( entity_registry as er, ) +from .bridge import HueConfigEntry from .const import DOMAIN LOGGER = logging.getLogger(__name__) -async def check_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> None: +async def check_migration(hass: core.HomeAssistant, entry: HueConfigEntry) -> None: """Check if config entry needs any migration actions.""" host = entry.data[CONF_HOST] @@ -66,7 +66,7 @@ async def check_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> None: hass.config_entries.async_update_entry(entry, data=data) -async def handle_v2_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> None: +async def handle_v2_migration(hass: core.HomeAssistant, entry: HueConfigEntry) -> None: """Perform migration of devices and entities to V2 Id's.""" host = entry.data[CONF_HOST] api_key = entry.data[CONF_API_KEY] diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 0b9eb4efbd6..5327a54fcc8 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -12,7 +12,6 @@ from aiohue.v2.models.smart_scene import SmartScene as HueSmartScene, SmartScene import voluptuous as vol from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity -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 ( @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import ( async_get_current_platform, ) -from .bridge import HueBridge +from .bridge import HueBridge, HueConfigEntry from .const import DOMAIN from .v2.entity import HueBaseEntity from .v2.helpers import normalize_hue_brightness, normalize_hue_transition @@ -33,11 +32,11 @@ ATTR_BRIGHTNESS = "brightness" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up scene platform from Hue group scenes.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api if bridge.api_version == 1: diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 227742fdbab..60845c0be7a 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -2,23 +2,21 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import HueBridge -from .const import DOMAIN +from .bridge import HueConfigEntry from .v1.sensor import async_setup_entry as setup_entry_v1 from .v2.sensor import async_setup_entry as setup_entry_v2 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data if bridge.api_version == 1: await setup_entry_v1(hass, config_entry, async_add_entities) return diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py index de6da161fba..18dd19e3391 100644 --- a/homeassistant/components/hue/services.py +++ b/homeassistant/components/hue/services.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import verify_domain_control -from .bridge import HueBridge +from .bridge import HueBridge, HueConfigEntry from .const import ( ATTR_DYNAMIC, ATTR_GROUP_NAME, @@ -37,14 +37,16 @@ def async_register_services(hass: HomeAssistant) -> None: dynamic = call.data.get(ATTR_DYNAMIC, False) # Call the set scene function on each bridge + entries: list[HueConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) tasks = [ - hue_activate_scene_v1(bridge, group_name, scene_name, transition) - if bridge.api_version == 1 - else hue_activate_scene_v2( - bridge, group_name, scene_name, transition, dynamic + hue_activate_scene_v1( + entry.runtime_data, group_name, scene_name, transition ) - for bridge in hass.data[DOMAIN].values() - if isinstance(bridge, HueBridge) + if entry.runtime_data.api_version == 1 + else hue_activate_scene_v2( + entry.runtime_data, group_name, scene_name, transition, dynamic + ) + for entry in entries ] results = await asyncio.gather(*tasks) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 2f7f2e55561..44a6eb72acc 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -11,7 +11,7 @@ } }, "manual": { - "title": "Manual configure a Hue bridge", + "title": "Manually configure a Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" }, @@ -46,8 +46,8 @@ "button_2": "Second button", "button_3": "Third button", "button_4": "Fourth button", - "double_buttons_1_3": "First and Third buttons", - "double_buttons_2_4": "Second and Fourth buttons", + "double_buttons_1_3": "First and third button", + "double_buttons_2_4": "Second and fourth button", "dim_down": "Dim down", "dim_up": "Dim up", "turn_off": "[%key:common::action::turn_off%]", @@ -57,7 +57,7 @@ "3": "[%key:component::hue::device_automation::trigger_subtype::button_3%]", "4": "[%key:component::hue::device_automation::trigger_subtype::button_4%]", "clock_wise": "Rotation clockwise", - "counter_clock_wise": "Rotation counter-clockwise" + "counter_clock_wise": "Rotation counterclockwise" }, "trigger_type": { "remote_button_long_release": "\"{subtype}\" released after long press", @@ -96,7 +96,29 @@ "event_type": { "state": { "clock_wise": "Clockwise", - "counter_clock_wise": "Counter clockwise" + "counter_clock_wise": "Counterclockwise" + } + } + } + } + }, + "light": { + "hue_light": { + "state_attributes": { + "effect": { + "state": { + "candle": "Candle", + "sparkle": "Sparkle", + "glisten": "Glisten", + "sunrise": "Sunrise", + "sunset": "Sunset", + "fire": "Fire", + "prism": "Prism", + "opal": "Opal", + "underwater": "Underwater", + "cosmos": "Cosmos", + "sunbeam": "Sunbeam", + "enchant": "Enchant" } } } @@ -175,5 +197,11 @@ } } } + }, + "issues": { + "deprecated_effect_none": { + "title": "Light turned on with deprecated effect", + "description": "A light was turned on with the deprecated effect `None`. This has been replaced with `off`. Please update any automations, scenes, or scripts that use this effect." + } } } diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index b6b21686d25..33dfe02dd49 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -19,23 +19,21 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .bridge import HueBridge -from .const import DOMAIN +from .bridge import HueConfigEntry from .v2.entity import HueBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue switch platform from Hue resources.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api if bridge.api_version == 1: diff --git a/homeassistant/components/hue/v1/binary_sensor.py b/homeassistant/components/hue/v1/binary_sensor.py index 325c4d022fa..e06d61210b8 100644 --- a/homeassistant/components/hue/v1/binary_sensor.py +++ b/homeassistant/components/hue/v1/binary_sensor.py @@ -6,16 +6,22 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from ..const import DOMAIN as HUE_DOMAIN +from ..bridge import HueConfigEntry from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor PRESENCE_NAME_FORMAT = "{} motion" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: """Defer binary sensor setup to the shared sensor module.""" - bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data if not bridge.sensor_manager: return diff --git a/homeassistant/components/hue/v1/device_trigger.py b/homeassistant/components/hue/v1/device_trigger.py index 493c668f549..c55573899d2 100644 --- a/homeassistant/components/hue/v1/device_trigger.py +++ b/homeassistant/components/hue/v1/device_trigger.py @@ -27,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN if TYPE_CHECKING: - from ..bridge import HueBridge + from ..bridge import HueBridge, HueConfigEntry TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} @@ -111,8 +111,9 @@ REMOTES: dict[str, dict[tuple[str, str], dict[str, int]]] = { def _get_hue_event_from_device_id(hass, device_id): """Resolve hue event from device id.""" - for bridge in hass.data.get(DOMAIN, {}).values(): - for hue_event in bridge.sensor_manager.current_events.values(): + entries: list[HueConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + for entry in entries: + for hue_event in entry.runtime_data.sensor_manager.current_events.values(): if device_id == hue_event.device_registry_id: return hue_event diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index 33b99a7895b..b7251382296 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -28,10 +28,11 @@ from homeassistant.components.light import ( LightEntityFeature, filter_supported_color_modes, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -39,13 +40,13 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import color as color_util -from ..bridge import HueBridge +from ..bridge import HueConfigEntry from ..const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_UNREACHABLE, - DOMAIN as HUE_DOMAIN, + DOMAIN, GROUP_TYPE_ENTERTAINMENT, GROUP_TYPE_LIGHT_GROUP, GROUP_TYPE_LIGHT_SOURCE, @@ -139,11 +140,15 @@ def create_light(item_class, coordinator, bridge, is_group, rooms, api, item_id) ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: """Set up the Hue lights from a config entry.""" - bridge: HueBridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api_version = tuple(int(v) for v in bridge.api.config.apiversion.split(".")) - rooms = {} + rooms: dict[str, str] = {} allow_groups = config_entry.options.get( CONF_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS @@ -518,7 +523,7 @@ class HueLight(CoordinatorEntity, LightEntity): suggested_area = self._rooms[self.light.id] return DeviceInfo( - identifiers={(HUE_DOMAIN, self.device_id)}, + identifiers={(DOMAIN, self.device_id)}, manufacturer=self.light.manufacturername, # productname added in Hue Bridge API 1.24 # (published 03/05/2018) @@ -526,7 +531,7 @@ class HueLight(CoordinatorEntity, LightEntity): name=self.name, sw_version=self.light.swversion, suggested_area=suggested_area, - via_device=(HUE_DOMAIN, self.bridge.api.config.bridgeid), + via_device=(DOMAIN, self.bridge.api.config.bridgeid), ) async def async_turn_on(self, **kwargs): diff --git a/homeassistant/components/hue/v1/sensor.py b/homeassistant/components/hue/v1/sensor.py index 88d494ed44b..765808bdf18 100644 --- a/homeassistant/components/hue/v1/sensor.py +++ b/homeassistant/components/hue/v1/sensor.py @@ -13,8 +13,10 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from ..const import DOMAIN as HUE_DOMAIN +from ..bridge import HueConfigEntry from .sensor_base import SENSOR_CONFIG_MAP, GenericHueSensor, GenericZLLSensor LIGHT_LEVEL_NAME_FORMAT = "{} light level" @@ -22,9 +24,13 @@ REMOTE_NAME_FORMAT = "{} battery level" TEMPERATURE_NAME_FORMAT = "{} temperature" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: """Defer sensor setup to the shared sensor module.""" - bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data if not bridge.sensor_manager: return diff --git a/homeassistant/components/hue/v1/sensor_device.py b/homeassistant/components/hue/v1/sensor_device.py index cb0a2721334..a18f2176f67 100644 --- a/homeassistant/components/hue/v1/sensor_device.py +++ b/homeassistant/components/hue/v1/sensor_device.py @@ -3,11 +3,7 @@ from homeassistant.helpers import entity from homeassistant.helpers.device_registry import DeviceInfo -from ..const import ( - CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_UNREACHABLE, - DOMAIN as HUE_DOMAIN, -) +from ..const import CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE, DOMAIN class GenericHueDevice(entity.Entity): # pylint: disable=hass-enforce-class-module @@ -55,10 +51,10 @@ class GenericHueDevice(entity.Entity): # pylint: disable=hass-enforce-class-mod Links individual entities together in the hass device registry. """ return DeviceInfo( - identifiers={(HUE_DOMAIN, self.device_id)}, + identifiers={(DOMAIN, self.device_id)}, manufacturer=self.primary_sensor.manufacturername, model=(self.primary_sensor.productname or self.primary_sensor.modelid), name=self.primary_sensor.name, sw_version=self.primary_sensor.swversion, - via_device=(HUE_DOMAIN, self.bridge.api.config.bridgeid), + via_device=(DOMAIN, self.bridge.api.config.bridgeid), ) diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 6e4c7f98973..17584a0f5cb 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -27,13 +27,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, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from ..bridge import HueBridge -from ..const import DOMAIN +from ..bridge import HueConfigEntry from .entity import HueBaseEntity type SensorType = CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper @@ -48,11 +46,11 @@ type ControllerType = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue Sensors from Config Entry.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api @callback diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 25a027f9ebe..7bb3d28e962 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -94,7 +94,12 @@ async def async_setup_devices(bridge: HueBridge): add_device(hue_resource) # create/update all current devices found in controllers - known_devices = [add_device(hue_device) for hue_device in dev_controller] + # sort the devices to ensure bridges are added first + hue_devices = list(dev_controller) + hue_devices.sort( + key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2 + ) + known_devices = [add_device(hue_device) for hue_device in hue_devices] known_devices += [add_device(hue_room) for hue_room in api.groups.room] known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone] diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 2f9f195df97..4db9bc16ca8 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -22,14 +22,13 @@ from homeassistant.components.light import ( LightEntityDescription, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from ..bridge import HueBridge +from ..bridge import HueBridge, HueConfigEntry from ..const import DOMAIN from .entity import HueBaseEntity from .helpers import ( @@ -41,11 +40,11 @@ from .helpers import ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue groups on light platform.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api async def async_add_light(event_type: EventType, resource: GroupedLight) -> None: diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 4b00299bc9d..d83cdaa8009 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -18,6 +18,7 @@ from homeassistant.components.light import ( ATTR_FLASH, ATTR_TRANSITION, ATTR_XY_COLOR, + EFFECT_OFF, FLASH_SHORT, ColorMode, LightEntity, @@ -25,12 +26,12 @@ from homeassistant.components.light import ( LightEntityFeature, filter_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util import color as color_util -from ..bridge import HueBridge +from ..bridge import HueBridge, HueConfigEntry from ..const import DOMAIN from .entity import HueBaseEntity from .helpers import ( @@ -39,19 +40,21 @@ from .helpers import ( normalize_hue_transition, ) -EFFECT_NONE = "None" FALLBACK_MIN_KELVIN = 6500 FALLBACK_MAX_KELVIN = 2000 FALLBACK_KELVIN = 5800 # halfway +# HA 2025.4 replaced the deprecated effect "None" with HA default "off" +DEPRECATED_EFFECT_NONE = "None" + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue Light from Config Entry.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api controller: LightsController = api.lights make_light_entity = partial(HueLight, bridge, controller) @@ -75,7 +78,7 @@ class HueLight(HueBaseEntity, LightEntity): _fixed_color_mode: ColorMode | None = None entity_description = LightEntityDescription( - key="hue_light", has_entity_name=True, name=None + key="hue_light", translation_key="hue_light", has_entity_name=True, name=None ) def __init__( @@ -118,7 +121,7 @@ class HueLight(HueBaseEntity, LightEntity): if x != TimedEffectStatus.NO_EFFECT ] if len(self._attr_effect_list) > 0: - self._attr_effect_list.insert(0, EFFECT_NONE) + self._attr_effect_list.insert(0, EFFECT_OFF) self._attr_supported_features |= LightEntityFeature.EFFECT @property @@ -211,7 +214,7 @@ class HueLight(HueBaseEntity, LightEntity): if timed_effects := self.resource.timed_effects: if timed_effects.status != TimedEffectStatus.NO_EFFECT: return timed_effects.status.value - return EFFECT_NONE + return EFFECT_OFF async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" @@ -233,12 +236,29 @@ class HueLight(HueBaseEntity, LightEntity): self._color_temp_active = color_temp is not None flash = kwargs.get(ATTR_FLASH) effect = effect_str = kwargs.get(ATTR_EFFECT) - if effect_str in (EFFECT_NONE, EFFECT_NONE.lower()): - # ignore effect if set to "None" and we have no effect active - # the special effect "None" is only used to stop an active effect + if effect_str == DEPRECATED_EFFECT_NONE: + # deprecated effect "None" is now "off" + effect_str = EFFECT_OFF + async_create_issue( + self.hass, + DOMAIN, + "deprecated_effect_none", + breaks_in_ha_version="2025.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_effect_none", + ) + self.logger.warning( + "Detected deprecated effect 'None' in %s, use 'off' instead. " + "This will stop working in HA 2025.10", + self.entity_id, + ) + if effect_str == EFFECT_OFF: + # ignore effect if set to "off" and we have no effect active + # the special effect "off" is only used to stop an active effect # but sending it while no effect is active can actually result in issues # https://github.com/home-assistant/core/issues/122165 - effect = None if self.effect == EFFECT_NONE else EffectStatus.NO_EFFECT + effect = None if self.effect == EFFECT_OFF else EffectStatus.NO_EFFECT elif effect_str is not None: # work out if we got a regular effect or timed effect effect = EffectStatus(effect_str) diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index ae6e456a8b4..1eec4eaa6b9 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -25,13 +25,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from ..bridge import HueBridge -from ..const import DOMAIN +from ..bridge import HueBridge, HueConfigEntry from .entity import HueBaseEntity type SensorType = DevicePower | LightLevel | Temperature | ZigbeeConnectivity @@ -45,11 +43,11 @@ type ControllerType = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HueConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue Sensors from Config Entry.""" - bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data api: HueBridgeV2 = bridge.api ctrl_base: SensorsController = api.sensors diff --git a/homeassistant/components/huisbaasje/__init__.py b/homeassistant/components/huisbaasje/__init__.py index f9703f67df5..7eca8141dc3 100644 --- a/homeassistant/components/huisbaasje/__init__.py +++ b/homeassistant/components/huisbaasje/__init__.py @@ -1,36 +1,21 @@ """The EnergyFlip integration.""" -import asyncio -from datetime import timedelta import logging -from typing import Any from energyflip import EnergyFlip, EnergyFlipException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - DATA_COORDINATOR, - DOMAIN, - FETCH_TIMEOUT, - POLLING_INTERVAL, - SENSOR_TYPE_RATE, - SENSOR_TYPE_THIS_DAY, - SENSOR_TYPE_THIS_MONTH, - SENSOR_TYPE_THIS_WEEK, - SENSOR_TYPE_THIS_YEAR, - SOURCE_TYPES, -) +from .const import FETCH_TIMEOUT, SOURCE_TYPES +from .coordinator import EnergyFlipConfigEntry, EnergyFlipUpdateCoordinator PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EnergyFlipConfigEntry) -> bool: """Set up EnergyFlip from a config entry.""" # Create the EnergyFlip client energyflip = EnergyFlip( @@ -47,23 +32,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Authentication failed: %s", str(exception)) return False - async def async_update_data() -> dict[str, dict[str, Any]]: - return await async_update_energyflip(energyflip) - # Create a coordinator for polling updates - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="sensor", - update_method=async_update_data, - update_interval=timedelta(seconds=POLLING_INTERVAL), - ) + coordinator = EnergyFlipUpdateCoordinator(hass, entry, energyflip) await coordinator.async_config_entry_first_refresh() # Load the client in the data of home assistant - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_COORDINATOR: coordinator} + entry.runtime_data = coordinator # Offload the loading of entities to the platform await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -71,87 +46,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: EnergyFlipConfigEntry) -> bool: """Unload a config entry.""" # Forward the unloading of the entry to the platform - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - # If successful, unload the EnergyFlip client - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok - - -async def async_update_energyflip(energyflip: EnergyFlip) -> dict[str, dict[str, Any]]: - """Update the data by performing a request to EnergyFlip.""" - try: - # Note: TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with asyncio.timeout(FETCH_TIMEOUT): - if not energyflip.is_authenticated(): - _LOGGER.warning("EnergyFlip is unauthenticated. Reauthenticating") - await energyflip.authenticate() - - current_measurements = await energyflip.current_measurements() - - return { - source_type: { - SENSOR_TYPE_RATE: _get_measurement_rate( - current_measurements, source_type - ), - SENSOR_TYPE_THIS_DAY: _get_cumulative_value( - current_measurements, source_type, SENSOR_TYPE_THIS_DAY - ), - SENSOR_TYPE_THIS_WEEK: _get_cumulative_value( - current_measurements, source_type, SENSOR_TYPE_THIS_WEEK - ), - SENSOR_TYPE_THIS_MONTH: _get_cumulative_value( - current_measurements, source_type, SENSOR_TYPE_THIS_MONTH - ), - SENSOR_TYPE_THIS_YEAR: _get_cumulative_value( - current_measurements, source_type, SENSOR_TYPE_THIS_YEAR - ), - } - for source_type in SOURCE_TYPES - } - except EnergyFlipException as exception: - raise UpdateFailed(f"Error communicating with API: {exception}") from exception - - -def _get_cumulative_value( - current_measurements: dict, - source_type: str, - period_type: str, -): - """Get the cumulative energy consumption for a certain period. - - :param current_measurements: The result from the EnergyFlip client - :param source_type: The source of energy (electricity or gas) - :param period_type: The period for which cumulative value should be given. - """ - if source_type in current_measurements: - if ( - period_type in current_measurements[source_type] - and current_measurements[source_type][period_type] is not None - ): - return current_measurements[source_type][period_type]["value"] - else: - _LOGGER.error( - "Source type %s not present in %s", source_type, current_measurements - ) - return None - - -def _get_measurement_rate(current_measurements: dict, source_type: str): - if source_type in current_measurements: - if ( - "measurement" in current_measurements[source_type] - and current_measurements[source_type]["measurement"] is not None - ): - return current_measurements[source_type]["measurement"]["rate"] - else: - _LOGGER.error( - "Source type %s not present in %s", source_type, current_measurements - ) - return None + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/huisbaasje/const.py b/homeassistant/components/huisbaasje/const.py index 2738289343f..a2dc39cb565 100644 --- a/homeassistant/components/huisbaasje/const.py +++ b/homeassistant/components/huisbaasje/const.py @@ -9,8 +9,6 @@ from energyflip.const import ( SOURCE_TYPE_GAS, ) -DATA_COORDINATOR = "coordinator" - DOMAIN = "huisbaasje" """Interval in seconds between polls to EnergyFlip.""" diff --git a/homeassistant/components/huisbaasje/coordinator.py b/homeassistant/components/huisbaasje/coordinator.py new file mode 100644 index 00000000000..529f7916bc6 --- /dev/null +++ b/homeassistant/components/huisbaasje/coordinator.py @@ -0,0 +1,128 @@ +"""The EnergyFlip integration.""" + +import asyncio +from datetime import timedelta +import logging +from typing import Any + +from energyflip import EnergyFlip, EnergyFlipException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + FETCH_TIMEOUT, + POLLING_INTERVAL, + SENSOR_TYPE_RATE, + SENSOR_TYPE_THIS_DAY, + SENSOR_TYPE_THIS_MONTH, + SENSOR_TYPE_THIS_WEEK, + SENSOR_TYPE_THIS_YEAR, + SOURCE_TYPES, +) + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + +type EnergyFlipConfigEntry = ConfigEntry[EnergyFlipUpdateCoordinator] + + +class EnergyFlipUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """EnergyFlip data update coordinator.""" + + config_entry: EnergyFlipConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: EnergyFlipConfigEntry, + energyflip: EnergyFlip, + ) -> None: + """Initialize the Huisbaasje data coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="sensor", + update_interval=timedelta(seconds=POLLING_INTERVAL), + ) + + self._energyflip = energyflip + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Update the data by performing a request to EnergyFlip.""" + try: + # Note: TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with asyncio.timeout(FETCH_TIMEOUT): + if not self._energyflip.is_authenticated(): + _LOGGER.warning("EnergyFlip is unauthenticated. Reauthenticating") + await self._energyflip.authenticate() + + current_measurements = await self._energyflip.current_measurements() + + return { + source_type: { + SENSOR_TYPE_RATE: _get_measurement_rate( + current_measurements, source_type + ), + SENSOR_TYPE_THIS_DAY: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_DAY + ), + SENSOR_TYPE_THIS_WEEK: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_WEEK + ), + SENSOR_TYPE_THIS_MONTH: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_MONTH + ), + SENSOR_TYPE_THIS_YEAR: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_YEAR + ), + } + for source_type in SOURCE_TYPES + } + except EnergyFlipException as exception: + raise UpdateFailed( + f"Error communicating with API: {exception}" + ) from exception + + +def _get_cumulative_value( + current_measurements: dict, + source_type: str, + period_type: str, +): + """Get the cumulative energy consumption for a certain period. + + :param current_measurements: The result from the EnergyFlip client + :param source_type: The source of energy (electricity or gas) + :param period_type: The period for which cumulative value should be given. + """ + if source_type in current_measurements: + if ( + period_type in current_measurements[source_type] + and current_measurements[source_type][period_type] is not None + ): + return current_measurements[source_type][period_type]["value"] + else: + _LOGGER.error( + "Source type %s not present in %s", source_type, current_measurements + ) + return None + + +def _get_measurement_rate(current_measurements: dict, source_type: str): + if source_type in current_measurements: + if ( + "measurement" in current_measurements[source_type] + and current_measurements[source_type]["measurement"] is not None + ): + return current_measurements[source_type]["measurement"]["rate"] + else: + _LOGGER.error( + "Source type %s not present in %s", source_type, current_measurements + ) + return None diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 91c953b2182..d6049e58550 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import dataclass import logging -from typing import Any from energyflip.const import ( SOURCE_TYPE_ELECTRICITY, @@ -21,7 +20,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ID, UnitOfEnergy, @@ -31,13 +29,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - DATA_COORDINATOR, DOMAIN, SENSOR_TYPE_RATE, SENSOR_TYPE_THIS_DAY, @@ -45,6 +39,7 @@ from .const import ( SENSOR_TYPE_THIS_WEEK, SENSOR_TYPE_THIS_YEAR, ) +from .coordinator import EnergyFlipConfigEntry, EnergyFlipUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -218,13 +213,11 @@ SENSORS_INFO = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EnergyFlipConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]] = hass.data[DOMAIN][ - config_entry.entry_id - ][DATA_COORDINATOR] + coordinator = config_entry.runtime_data user_id = config_entry.data[CONF_ID] async_add_entities( @@ -233,9 +226,7 @@ async def async_setup_entry( ) -class EnergyFlipSensor( - CoordinatorEntity[DataUpdateCoordinator[dict[str, dict[str, Any]]]], SensorEntity -): +class EnergyFlipSensor(CoordinatorEntity[EnergyFlipUpdateCoordinator], SensorEntity): """Defines a EnergyFlip sensor.""" entity_description: EnergyFlipSensorEntityDescription @@ -243,7 +234,7 @@ class EnergyFlipSensor( def __init__( self, - coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], + coordinator: EnergyFlipUpdateCoordinator, user_id: str, description: EnergyFlipSensorEntityDescription, ) -> None: diff --git a/homeassistant/components/huisbaasje/strings.json b/homeassistant/components/huisbaasje/strings.json index de112f7519f..3958e6a8903 100644 --- a/homeassistant/components/huisbaasje/strings.json +++ b/homeassistant/components/huisbaasje/strings.json @@ -26,25 +26,25 @@ "name": "Current power in peak" }, "current_power_off_peak": { - "name": "Current power in off peak" + "name": "Current power in off-peak" }, "current_power_out_peak": { "name": "Current power out peak" }, "current_power_out_off_peak": { - "name": "Current power out off peak" + "name": "Current power out off-peak" }, "energy_consumption_peak_today": { "name": "Energy consumption peak today" }, "energy_consumption_off_peak_today": { - "name": "Energy consumption off peak today" + "name": "Energy consumption off-peak today" }, "energy_production_peak_today": { "name": "Energy production peak today" }, "energy_production_off_peak_today": { - "name": "Energy production off peak today" + "name": "Energy production off-peak today" }, "energy_today": { "name": "Energy today" diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 753368dc572..6c0c691c705 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -62,15 +62,15 @@ "mode": { "name": "Mode", "state": { - "normal": "Normal", - "eco": "Eco", - "away": "Away", + "normal": "[%key:common::state::normal%]", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", + "auto": "[%key:common::state::auto%]", + "baby": "Baby", "boost": "Boost", "comfort": "Comfort", - "home": "[%key:common::state::home%]", - "sleep": "Sleep", - "auto": "Auto", - "baby": "Baby" + "eco": "Eco", + "sleep": "Sleep" } } } @@ -89,7 +89,7 @@ "fields": { "mode": { "name": "Mode", - "description": "Operation mode. For example, _normal_, _eco_, or _away_. For a list of possible values, refer to the integration documentation." + "description": "Operation mode. For example, \"normal\", \"eco\", or \"away\". For a list of possible values, refer to the integration documentation." } } }, diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py index 1e5b9fac990..82c78123bde 100644 --- a/homeassistant/components/husqvarna_automower/binary_sensor.py +++ b/homeassistant/components/husqvarna_automower/binary_sensor.py @@ -3,29 +3,18 @@ from collections.abc import Callable from dataclasses import dataclass import logging -from typing import TYPE_CHECKING from aioautomower.model import MowerActivities, MowerAttributes -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from . import AutomowerConfigEntry -from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity @@ -34,13 +23,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -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 - - @dataclass(frozen=True, kw_only=True) class AutomowerBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Automower binary sensor entity.""" @@ -59,12 +41,6 @@ MOWER_BINARY_SENSOR_TYPES: tuple[AutomowerBinarySensorEntityDescription, ...] = translation_key="leaving_dock", value_fn=lambda data: data.mower.activity == MowerActivities.LEAVING, ), - AutomowerBinarySensorEntityDescription( - key="returning_to_dock", - translation_key="returning_to_dock", - value_fn=lambda data: data.mower.activity == MowerActivities.GOING_HOME, - entity_registry_enabled_default=False, - ), ) @@ -107,39 +83,3 @@ class AutomowerBinarySensorEntity(AutomowerBaseEntity, BinarySensorEntity): def is_on(self) -> bool: """Return the state of the binary sensor.""" return self.entity_description.value_fn(self.mower_attributes) - - 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 not ( - entity_id := er.async_get(self.hass).async_get_entity_id( - BINARY_SENSOR_DOMAIN, DOMAIN, self.unique_id - ) - ): - return - if ( - self.enabled - and self.entity_description.key == "returning_to_dock" - and entity_used_in(self.hass, entity_id) - ): - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_entity_{self.entity_description.key}", - breaks_in_ha_version="2025.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders={ - "entity_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/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py index 7efed529453..31ca5eef0cd 100644 --- a/homeassistant/components/husqvarna_automower/config_flow.py +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -54,7 +54,8 @@ class HusqvarnaConfigFlowHandler( automower_api = AutomowerSession(AsyncConfigFlowAuth(websession, token), tz) try: status_data = await automower_api.get_status() - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") if status_data == {}: return self.async_abort(reason="no_mower_connected") diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 819ee41a43d..dc653d8ce80 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -13,7 +13,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import MowerAttributes +from aioautomower.model import MowerDictionary from aioautomower.session import AutomowerSession from homeassistant.config_entries import ConfigEntry @@ -32,7 +32,7 @@ DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator] -class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): +class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): """Class to manage fetching Husqvarna data.""" config_entry: AutomowerConfigEntry @@ -61,7 +61,16 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib 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]: + def _async_add_remove_devices_and_entities(self, data: MowerDictionary) -> None: + """Add/remove devices and dynamic entities, when amount of devices changed.""" + 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) + if data[mower_id].capabilities.work_areas: + self._async_add_remove_work_areas(data) + + async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" if not self.ws_connected: await self.api.connect() @@ -73,20 +82,14 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib raise UpdateFailed(err) from 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) + self._async_add_remove_devices_and_entities(data) return data @callback - def callback(self, ws_data: dict[str, MowerAttributes]) -> None: + def callback(self, ws_data: MowerDictionary) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" self.async_set_updated_data(ws_data) + self._async_add_remove_devices_and_entities(ws_data) async def client_listen( self, @@ -119,7 +122,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib "reconnect_task", ) - def _async_add_remove_devices(self, data: dict[str, MowerAttributes]) -> None: + def _async_add_remove_devices(self, data: MowerDictionary) -> None: """Add new device, remove non-existing device.""" current_devices = set(data) @@ -136,6 +139,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib # Process new device new_devices = current_devices - self._devices_last_update if new_devices: + self.data = data _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) self._add_new_devices(new_devices) @@ -159,9 +163,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib 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: + def _async_add_remove_stay_out_zones(self, data: MowerDictionary) -> None: """Add new stay-out zones, remove non-existing stay-out zones.""" current_zones = { mower_id: set(mower_data.stay_out_zones.zones) @@ -207,7 +209,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib return current_zones - def _async_add_remove_work_areas(self, data: dict[str, MowerAttributes]) -> None: + def _async_add_remove_work_areas(self, data: MowerDictionary) -> None: """Add new work areas, remove non-existing work areas.""" current_areas = { mower_id: set(mower_data.work_areas) diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index ee6007f089b..5a728265651 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -110,14 +110,14 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity): mower_attributes = self.mower_attributes if mower_attributes.mower.state in PAUSED_STATES: return LawnMowerActivity.PAUSED - if mower_attributes.mower.activity in MOWING_ACTIVITIES: - return LawnMowerActivity.MOWING - if mower_attributes.mower.activity == MowerActivities.GOING_HOME: - return LawnMowerActivity.RETURNING if (mower_attributes.mower.state == "RESTRICTED") or ( mower_attributes.mower.activity in DOCKED_ACTIVITIES ): return LawnMowerActivity.DOCKED + if mower_attributes.mower.state in MowerStates.IN_OPERATION: + if mower_attributes.mower.activity == MowerActivities.GOING_HOME: + return LawnMowerActivity.RETURNING + return LawnMowerActivity.MOWING return LawnMowerActivity.ERROR @property diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 0eabf5ec0d6..705975bb966 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2025.1.1"] + "requirements": ["aioautomower==2025.5.1"] } diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index cdcf4b45a2d..4a57c48e66f 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -44,8 +44,8 @@ async def async_set_work_area_cutting_height( ) -> None: """Set cutting height for work area.""" await coordinator.api.commands.workarea_settings( - mower_id, int(cheight), work_area_id - ) + mower_id, work_area_id + ).cutting_height(cutting_height=int(cheight)) async def async_set_cutting_height( diff --git a/homeassistant/components/husqvarna_automower/quality_scale.yaml b/homeassistant/components/husqvarna_automower/quality_scale.yaml index 2fa41c02a4c..d0435c51eee 100644 --- a/homeassistant/components/husqvarna_automower/quality_scale.yaml +++ b/homeassistant/components/husqvarna_automower/quality_scale.yaml @@ -67,7 +67,9 @@ rules: reconfiguration-flow: status: exempt comment: no configuration possible - repair-issues: done + repair-issues: + status: exempt + comment: no issues available stale-devices: done # Platinum diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py index 9124a0705e1..1dde9e16295 100644 --- a/homeassistant/components/husqvarna_automower/select.py +++ b/homeassistant/components/husqvarna_automower/select.py @@ -19,10 +19,10 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 HEADLIGHT_MODES: list = [ - HeadlightModes.ALWAYS_OFF.lower(), - HeadlightModes.ALWAYS_ON.lower(), - HeadlightModes.EVENING_AND_NIGHT.lower(), - HeadlightModes.EVENING_ONLY.lower(), + HeadlightModes.ALWAYS_OFF, + HeadlightModes.ALWAYS_ON, + HeadlightModes.EVENING_AND_NIGHT, + HeadlightModes.EVENING_ONLY, ] @@ -65,13 +65,11 @@ class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity): @property def current_option(self) -> str: """Return the current option for the entity.""" - return cast( - HeadlightModes, self.mower_attributes.settings.headlight.mode - ).lower() + return cast(HeadlightModes, self.mower_attributes.settings.headlight.mode) @handle_sending_exception() async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.coordinator.api.commands.set_headlight_mode( - self.mower_id, cast(HeadlightModes, option.upper()) + self.mower_id, HeadlightModes(option) ) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 2e1d4041e5a..5ad8ad91b48 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -40,8 +40,7 @@ PARALLEL_UPDATES = 0 ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment" -ERROR_KEY_LIST = [ - "no_error", +ERROR_KEYS = [ "alarm_mower_in_motion", "alarm_mower_lifted", "alarm_mower_stopped", @@ -50,13 +49,11 @@ ERROR_KEY_LIST = [ "alarm_outside_geofence", "angular_sensor_problem", "battery_problem", - "battery_problem", "battery_restriction_due_to_ambient_temperature", "can_error", "charging_current_too_high", "charging_station_blocked", "charging_system_problem", - "charging_system_problem", "collision_sensor_defect", "collision_sensor_error", "collision_sensor_problem_front", @@ -67,24 +64,18 @@ ERROR_KEY_LIST = [ "connection_changed", "connection_not_changed", "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", - "connectivity_problem", "connectivity_settings_restored", "cutting_drive_motor_1_defect", "cutting_drive_motor_2_defect", "cutting_drive_motor_3_defect", "cutting_height_blocked", - "cutting_height_problem", "cutting_height_problem_curr", "cutting_height_problem_dir", "cutting_height_problem_drive", + "cutting_height_problem", "cutting_motor_problem", "cutting_stopped_slope_too_steep", "cutting_system_blocked", - "cutting_system_blocked", "cutting_system_imbalance_warning", "cutting_system_major_imbalance", "destination_not_reachable", @@ -92,13 +83,9 @@ ERROR_KEY_LIST = [ "docking_sensor_defect", "electronic_problem", "empty_battery", - MowerStates.ERROR.lower(), - MowerStates.ERROR_AT_POWER_UP.lower(), - MowerStates.FATAL_ERROR.lower(), "folding_cutting_deck_sensor_defect", "folding_sensor_activated", "geofence_problem", - "geofence_problem", "gps_navigation_problem", "guide_1_not_found", "guide_2_not_found", @@ -116,7 +103,6 @@ ERROR_KEY_LIST = [ "lift_sensor_defect", "lifted", "limited_cutting_height_range", - "limited_cutting_height_range", "loop_sensor_defect", "loop_sensor_problem_front", "loop_sensor_problem_left", @@ -129,6 +115,7 @@ ERROR_KEY_LIST = [ "no_accurate_position_from_satellites", "no_confirmed_position", "no_drive", + "no_error", "no_loop_signal", "no_power_in_charging_station", "no_response_from_charger", @@ -139,9 +126,6 @@ ERROR_KEY_LIST = [ "safety_function_faulty", "settings_restored", "sim_card_locked", - "sim_card_locked", - "sim_card_locked", - "sim_card_locked", "sim_card_not_found", "sim_card_requires_pin", "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern", @@ -151,13 +135,6 @@ ERROR_KEY_LIST = [ "stuck_in_charging_station", "switch_cord_problem", "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", - "temporary_battery_problem", "tilt_sensor_problem", "too_high_discharge_current", "too_high_internal_current", @@ -189,11 +166,19 @@ ERROR_KEY_LIST = [ "zone_generator_problem", ] -ERROR_STATES = { - MowerStates.ERROR, +ERROR_STATES = [ MowerStates.ERROR_AT_POWER_UP, + MowerStates.ERROR, MowerStates.FATAL_ERROR, -} + MowerStates.OFF, + MowerStates.STOPPED, + MowerStates.WAIT_POWER_UP, + MowerStates.WAIT_UPDATING, +] + +ERROR_KEY_LIST = list( + dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) +) RESTRICTED_REASONS: list = [ RestrictedReasons.ALL_WORK_AREAS_COMPLETED, @@ -227,12 +212,16 @@ def _get_work_area_names(data: MowerAttributes) -> list[str]: @callback def _get_current_work_area_name(data: MowerAttributes) -> str: """Return the name of the current work area.""" - if data.mower.work_area_id is None: - return STATE_NO_WORK_AREA_ACTIVE if TYPE_CHECKING: # Sensor does not get created if values are None assert data.work_areas is not None - return data.work_areas[data.mower.work_area_id].name + if ( + data.mower.work_area_id is not None + and data.mower.work_area_id in data.work_areas + ): + return data.work_areas[data.mower.work_area_id].name + + return STATE_NO_WORK_AREA_ACTIVE @callback @@ -288,6 +277,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( AutomowerSensorEntityDescription( key="cutting_blade_usage_time", translation_key="cutting_blade_usage_time", + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -295,6 +285,19 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None, value_fn=attrgetter("statistics.cutting_blade_usage_time"), ), + AutomowerSensorEntityDescription( + key="downtime", + translation_key="downtime", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfTime.HOURS, + exists_fn=lambda data: data.statistics.downtime is not None, + value_fn=attrgetter("statistics.downtime"), + ), AutomowerSensorEntityDescription( key="total_charging_time", translation_key="total_charging_time", @@ -367,6 +370,19 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( exists_fn=lambda data: data.statistics.total_drive_distance is not None, value_fn=attrgetter("statistics.total_drive_distance"), ), + AutomowerSensorEntityDescription( + key="uptime", + translation_key="uptime", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfTime.HOURS, + exists_fn=lambda data: data.statistics.uptime is not None, + value_fn=attrgetter("statistics.uptime"), + ), AutomowerSensorEntityDescription( key="next_start_timestamp", translation_key="next_start_timestamp", diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 9bd0bb06b3e..5b815e79263 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -39,9 +39,6 @@ "binary_sensor": { "leaving_dock": { "name": "Leaving dock" - }, - "returning_to_dock": { - "name": "Returning to dock" } }, "button": { @@ -106,10 +103,10 @@ "cutting_drive_motor_2_defect": "Cutting drive motor 2 defect", "cutting_drive_motor_3_defect": "Cutting drive motor 3 defect", "cutting_height_blocked": "Cutting height blocked", - "cutting_height_problem": "Cutting height problem", "cutting_height_problem_curr": "Cutting height problem, curr", "cutting_height_problem_dir": "Cutting height problem, dir", "cutting_height_problem_drive": "Cutting height problem, drive", + "cutting_height_problem": "Cutting height problem", "cutting_motor_problem": "Cutting motor problem", "cutting_stopped_slope_too_steep": "Cutting stopped - slope too steep", "cutting_system_blocked": "Cutting system blocked", @@ -120,8 +117,8 @@ "docking_sensor_defect": "Docking sensor defect", "electronic_problem": "Electronic problem", "empty_battery": "Empty battery", - "error": "Error", "error_at_power_up": "Error at power up", + "error": "[%key:common::state::error%]", "fatal_error": "Fatal error", "folding_cutting_deck_sensor_defect": "Folding cutting deck sensor defect", "folding_sensor_activated": "Folding sensor activated", @@ -159,6 +156,7 @@ "no_loop_signal": "No loop signal", "no_power_in_charging_station": "No power in charging station", "no_response_from_charger": "No response from charger", + "off": "[%key:common::state::off%]", "outside_working_area": "Outside working area", "poor_signal_quality": "Poor signal quality", "reference_station_communication_problem": "Reference station communication problem", @@ -172,6 +170,7 @@ "slope_too_steep": "Slope too steep", "sms_could_not_be_sent": "SMS could not be sent", "stop_button_problem": "STOP button problem", + "stopped": "[%key:common::state::stopped%]", "stuck_in_charging_station": "Stuck in charging station", "switch_cord_problem": "Switch cord problem", "temporary_battery_problem": "Temporary battery problem", @@ -187,6 +186,8 @@ "unexpected_cutting_height_adj": "Unexpected cutting height adjustment", "unexpected_error": "Unexpected error", "upside_down": "Upside down", + "wait_power_up": "Wait power up", + "wait_updating": "Wait updating", "weak_gps_signal": "Weak GPS signal", "wheel_drive_problem_left": "Left wheel drive problem", "wheel_drive_problem_rear_left": "Rear left wheel drive problem", @@ -221,6 +222,9 @@ "cutting_blade_usage_time": { "name": "Cutting blade usage time" }, + "downtime": { + "name": "Downtime" + }, "restricted_reason": { "name": "Restricted reason", "state": { @@ -263,6 +267,9 @@ "demo": "Demo" } }, + "uptime": { + "name": "Uptime" + }, "work_area": { "name": "Work area", "state": { @@ -313,12 +320,6 @@ } } }, - "issues": { - "deprecated_entity": { - "title": "The Husqvarna Automower {entity_name} sensor is deprecated", - "description": "The Husqvarna Automower entity `{entity}` is deprecated and will be removed in a future release.\nYou can use the new returning state of the lawn mower entity instead.\nPlease update your automations and scripts to replace the sensor entity with the newly added lawn mower entity.\nWhen you are done migrating you can disable `{entity}`." - } - }, "services": { "override_schedule": { "name": "Override schedule", diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py index 69a3e670eda..1cfc79d5a71 100644 --- a/homeassistant/components/husqvarna_automower/switch.py +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -206,12 +206,12 @@ class WorkAreaSwitchEntity(WorkAreaControlEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.coordinator.api.commands.workarea_settings( - self.mower_id, self.work_area_id, enabled=False - ) + self.mower_id, self.work_area_id + ).enabled(enabled=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.workarea_settings( - self.mower_id, self.work_area_id, enabled=True - ) + self.mower_id, self.work_area_id + ).enabled(enabled=True) diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index 7566b5c9d32..6eb618cbb04 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble", "iot_class": "local_polling", - "requirements": ["automower-ble==0.2.0"] + "requirements": ["automower-ble==0.2.1"] } diff --git a/homeassistant/components/hvv_departures/__init__.py b/homeassistant/components/hvv_departures/__init__.py index 1104359111c..cfe76591688 100644 --- a/homeassistant/components/hvv_departures/__init__.py +++ b/homeassistant/components/hvv_departures/__init__.py @@ -1,17 +1,15 @@ """The HVV integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import DOMAIN -from .hub import GTIHub +from .hub import GTIHub, HVVConfigEntry PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HVVConfigEntry) -> bool: """Set up HVV from a config entry.""" hub = GTIHub( @@ -21,14 +19,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: aiohttp_client.async_get_clientsession(hass), ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = hub + entry.runtime_data = hub 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: HVVConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 622a8436e04..18598dd4c94 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -14,7 +14,6 @@ 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 AddConfigEntryEntitiesCallback @@ -25,17 +24,18 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ATTRIBUTION, CONF_STATION, DOMAIN, MANUFACTURER +from .hub import HVVConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HVVConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary_sensor platform.""" - hub = hass.data[DOMAIN][entry.entry_id] + hub = entry.runtime_data station_name = entry.data[CONF_STATION]["name"] station = entry.data[CONF_STATION] diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index d76ccef7cab..63d457bf302 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -9,18 +9,13 @@ from pygti.auth import GTI_DEFAULT_HOST from pygti.exceptions import CannotConnect, InvalidAuth 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_OFFSET, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import CONF_FILTER, CONF_REAL_TIME, CONF_STATION, DOMAIN -from .hub import GTIHub +from .hub import GTIHub, HVVConfigEntry _LOGGER = logging.getLogger(__name__) @@ -137,7 +132,7 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: HVVConfigEntry, ) -> OptionsFlowHandler: """Get options flow.""" return OptionsFlowHandler() @@ -146,6 +141,8 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlow): """Options flow handler.""" + config_entry: HVVConfigEntry + def __init__(self) -> None: """Initialize HVV Departures options flow.""" self.departure_filters: dict[str, Any] = {} @@ -157,7 +154,7 @@ class OptionsFlowHandler(OptionsFlow): errors = {} if not self.departure_filters: departure_list = {} - hub: GTIHub = self.hass.data[DOMAIN][self.config_entry.entry_id] + hub = self.config_entry.runtime_data try: departure_list = await hub.gti.departureList( diff --git a/homeassistant/components/hvv_departures/hub.py b/homeassistant/components/hvv_departures/hub.py index 7cffbed345c..31523b72ba1 100644 --- a/homeassistant/components/hvv_departures/hub.py +++ b/homeassistant/components/hvv_departures/hub.py @@ -2,6 +2,10 @@ from pygti.gti import GTI, Auth +from homeassistant.config_entries import ConfigEntry + +type HVVConfigEntry = ConfigEntry[GTIHub] + class GTIHub: """GTI Hub.""" diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index 667893db8f2..1b10451f22d 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -8,7 +8,6 @@ from aiohttp import ClientConnectorError from pygti.exceptions import InvalidAuth from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID, CONF_OFFSET from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -18,6 +17,7 @@ from homeassistant.util import Throttle from homeassistant.util.dt import get_time_zone, utcnow from .const import ATTRIBUTION, CONF_REAL_TIME, CONF_STATION, DOMAIN, MANUFACTURER +from .hub import HVVConfigEntry MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) MAX_LIST = 20 @@ -41,11 +41,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HVVConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - hub = hass.data[DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data session = aiohttp_client.async_get_clientsession(hass) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index ce4d7a8f8c2..d15df52bb71 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -2,13 +2,13 @@ from pydrawise import auth, hybrid -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import APP_ID, DOMAIN +from .const import APP_ID from .coordinator import ( + HydrawiseConfigEntry, HydrawiseMainDataUpdateCoordinator, HydrawiseUpdateCoordinators, HydrawiseWaterUseDataUpdateCoordinator, @@ -24,7 +24,9 @@ PLATFORMS: list[Platform] = [ _REQUIRED_AUTH_KEYS = (CONF_USERNAME, CONF_PASSWORD, CONF_API_KEY) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: HydrawiseConfigEntry +) -> bool: """Set up Hydrawise from a config entry.""" if any(k not in config_entry.data for k in _REQUIRED_AUTH_KEYS): # If we are missing any required authentication keys, trigger a reauth flow. @@ -45,18 +47,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass, config_entry, hydrawise, main_coordinator ) await water_use_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = ( - HydrawiseUpdateCoordinators( - main=main_coordinator, - water_use=water_use_coordinator, - ) + config_entry.runtime_data = HydrawiseUpdateCoordinators( + main=main_coordinator, + water_use=water_use_coordinator, ) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HydrawiseConfigEntry) -> 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/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index b2862930933..45537a2cc73 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -14,14 +14,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType -from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND -from .coordinator import HydrawiseUpdateCoordinators +from .const import SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND +from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity @@ -77,11 +76,11 @@ SCHEMA_SUSPEND: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HydrawiseConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise binary_sensor platform.""" - coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] + coordinators = config_entry.runtime_data entities: list[HydrawiseBinarySensor] = [] for controller in coordinators.main.data.controllers.values(): entities.extend( diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 35d816b341b..15d286801f9 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -14,6 +14,8 @@ from homeassistant.util.dt import now from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL +type HydrawiseConfigEntry = ConfigEntry[HydrawiseUpdateCoordinators] + @dataclass class HydrawiseData: @@ -40,7 +42,7 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): """Base class for Hydrawise Data Update Coordinators.""" api: HydrawiseBase - config_entry: ConfigEntry + config_entry: HydrawiseConfigEntry class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): @@ -52,7 +54,10 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): """ def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: HydrawiseBase + self, + hass: HomeAssistant, + config_entry: HydrawiseConfigEntry, + api: HydrawiseBase, ) -> None: """Initialize HydrawiseDataUpdateCoordinator.""" super().__init__( @@ -92,7 +97,7 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HydrawiseConfigEntry, api: HydrawiseBase, main_coordinator: HydrawiseMainDataUpdateCoordinator, ) -> None: diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 73423882e4a..0c355c34a71 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==2025.2.0"] + "requirements": ["pydrawise==2025.3.0"] } diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 60bc1d7dc63..ce0bc5a0997 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -14,14 +14,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN -from .coordinator import HydrawiseUpdateCoordinators +from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity @@ -130,11 +128,11 @@ FLOW_MEASUREMENT_KEYS = [x.key for x in FLOW_CONTROLLER_SENSORS] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HydrawiseConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise sensor platform.""" - coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] + coordinators = config_entry.runtime_data entities: list[HydrawiseSensor] = [] for controller in coordinators.main.data.controllers.values(): entities.extend( diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index bc6b31e6d2e..7a77f27265b 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -14,13 +14,12 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DEFAULT_WATERING_TIME, DOMAIN -from .coordinator import HydrawiseUpdateCoordinators +from .const import DEFAULT_WATERING_TIME +from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity @@ -62,11 +61,11 @@ SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HydrawiseConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise switch platform.""" - coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] + coordinators = config_entry.runtime_data async_add_entities( HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id) for controller in coordinators.main.data.controllers.values() diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py index 13aff22ccbf..85a91c807b2 100644 --- a/homeassistant/components/hydrawise/valve.py +++ b/homeassistant/components/hydrawise/valve.py @@ -12,12 +12,10 @@ from homeassistant.components.valve import ( ValveEntityDescription, ValveEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import HydrawiseUpdateCoordinators +from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity VALVE_TYPES: tuple[ValveEntityDescription, ...] = ( @@ -30,11 +28,11 @@ VALVE_TYPES: tuple[ValveEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HydrawiseConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise valve platform.""" - coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] + coordinators = config_entry.runtime_data async_add_entities( HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id) for controller in coordinators.main.data.controllers.values() diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 94137b5dd3f..0f49bacd1ef 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from contextlib import suppress +from dataclasses import dataclass import logging from typing import Any, cast @@ -22,9 +23,6 @@ from homeassistant.helpers.dispatcher import ( ) from .const import ( - CONF_INSTANCE_CLIENTS, - CONF_ON_UNLOAD, - CONF_ROOT_CLIENT, DEFAULT_NAME, DOMAIN, HYPERION_RELEASES_URL, @@ -52,15 +50,15 @@ _LOGGER = logging.getLogger(__name__) # The get_hyperion_unique_id method will create a per-entity unique id when given the # server id, an instance number and a name. -# hass.data format -# ================ -# -# hass.data[DOMAIN] = { -# : { -# "ROOT_CLIENT": , -# "ON_UNLOAD": [, ...], -# } -# } +type HyperionConfigEntry = ConfigEntry[HyperionData] + + +@dataclass +class HyperionData: + """Hyperion runtime data.""" + + root_client: client.HyperionClient + instance_clients: dict[int, client.HyperionClient] def get_hyperion_unique_id(server_id: str, instance: int, name: str) -> str: @@ -107,29 +105,29 @@ async def async_create_connect_hyperion_client( @callback def listen_for_instance_updates( hass: HomeAssistant, - config_entry: ConfigEntry, - add_func: Callable, - remove_func: Callable, + entry: HyperionConfigEntry, + add_func: Callable[[int, str], None], + remove_func: Callable[[int], None], ) -> None: """Listen for instance additions/removals.""" - hass.data[DOMAIN][config_entry.entry_id][CONF_ON_UNLOAD].extend( - [ - async_dispatcher_connect( - hass, - SIGNAL_INSTANCE_ADD.format(config_entry.entry_id), - add_func, - ), - async_dispatcher_connect( - hass, - SIGNAL_INSTANCE_REMOVE.format(config_entry.entry_id), - remove_func, - ), - ] + entry.async_on_unload( + async_dispatcher_connect( + hass, + SIGNAL_INSTANCE_ADD.format(entry.entry_id), + add_func, + ) + ) + entry.async_on_unload( + async_dispatcher_connect( + hass, + SIGNAL_INSTANCE_REMOVE.format(entry.entry_id), + remove_func, + ) ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool: """Set up Hyperion from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] @@ -185,12 +183,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # We need 1 root client (to manage instances being removed/added) and then 1 client # per Hyperion server instance which is shared for all entities associated with # that instance. - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - CONF_ROOT_CLIENT: hyperion_client, - CONF_INSTANCE_CLIENTS: {}, - CONF_ON_UNLOAD: [], - } + entry.runtime_data = HyperionData( + root_client=hyperion_client, + instance_clients={}, + ) async def async_instances_to_clients(response: dict[str, Any]) -> None: """Convert instances to Hyperion clients.""" @@ -203,7 +199,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_registry = dr.async_get(hass) running_instances: set[int] = set() stopped_instances: set[int] = set() - existing_instances = hass.data[DOMAIN][entry.entry_id][CONF_INSTANCE_CLIENTS] + existing_instances = entry.runtime_data.instance_clients server_id = cast(str, entry.unique_id) # In practice, an instance can be in 3 states as seen by this function: @@ -270,39 +266,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert hyperion_client if hyperion_client.instances is not None: await async_instances_to_clients_raw(hyperion_client.instances) - hass.data[DOMAIN][entry.entry_id][CONF_ON_UNLOAD].append( - entry.add_update_listener(_async_entry_updated) - ) + entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True -async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def _async_entry_updated(hass: HomeAssistant, entry: HyperionConfigEntry) -> None: """Handle entry updates.""" - await hass.config_entries.async_reload(config_entry.entry_id) + await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok and config_entry.entry_id in hass.data[DOMAIN]: - config_data = hass.data[DOMAIN].pop(config_entry.entry_id) - for func in config_data[CONF_ON_UNLOAD]: - func() - + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: # Disconnect the shared instance clients. await asyncio.gather( *( - config_data[CONF_INSTANCE_CLIENTS][ - instance_num - ].async_client_disconnect() - for instance_num in config_data[CONF_INSTANCE_CLIENTS] + inst.async_client_disconnect() + for inst in entry.runtime_data.instance_clients.values() ) ) # Disconnect the root client. - root_client = config_data[CONF_ROOT_CLIENT] + root_client = entry.runtime_data.root_client await root_client.async_client_disconnect() return unload_ok diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 1260be20eb2..ae9c9ba9025 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -25,7 +25,6 @@ from homeassistant.components.camera import ( Camera, async_get_still_stream, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -35,12 +34,12 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( - CONF_INSTANCE_CLIENTS, DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, @@ -53,12 +52,11 @@ IMAGE_STREAM_JPG_SENTINEL = "data:image/jpg;base64," async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id def camera_unique_id(instance_num: int) -> str: """Return the camera unique_id.""" @@ -75,7 +73,7 @@ async def async_setup_entry( server_id, instance_num, instance_name, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], + entry.runtime_data.instance_clients[instance_num], ) ] ) @@ -91,7 +89,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) # A note on Hyperion streaming semantics: diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index 3d44dd35e08..ac04d6dad3c 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -3,10 +3,7 @@ CONF_AUTH_ID = "auth_id" CONF_CREATE_TOKEN = "create_token" CONF_INSTANCE = "instance" -CONF_INSTANCE_CLIENTS = "INSTANCE_CLIENTS" -CONF_ON_UNLOAD = "ON_UNLOAD" CONF_PRIORITY = "priority" -CONF_ROOT_CLIENT = "ROOT_CLIENT" CONF_EFFECT_HIDE_LIST = "effect_hide_list" CONF_EFFECT_SHOW_LIST = "effect_show_list" diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index f8932a682ab..4cf0ed0f5e2 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence import functools import logging -from types import MappingProxyType from typing import Any from hyperion import client, const @@ -18,7 +17,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -29,13 +27,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( CONF_EFFECT_HIDE_LIST, - CONF_INSTANCE_CLIENTS, CONF_PRIORITY, DEFAULT_ORIGIN, DEFAULT_PRIORITY, @@ -75,28 +73,26 @@ ICON_EFFECT = "mdi:lava-lamp" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id @callback def instance_add(instance_num: int, instance_name: str) -> None: """Add entities for a new Hyperion instance.""" assert server_id - args = ( - server_id, - instance_num, - instance_name, - config_entry.options, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], - ) async_add_entities( [ - HyperionLight(*args), + HyperionLight( + server_id, + instance_num, + instance_name, + entry.options, + entry.runtime_data.instance_clients[instance_num], + ), ] ) @@ -111,7 +107,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) class HyperionLight(LightEntity): @@ -129,7 +125,7 @@ class HyperionLight(LightEntity): server_id: str, instance_num: int, instance_name: str, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], hyperion_client: client.HyperionClient, ) -> None: """Initialize the light.""" diff --git a/homeassistant/components/hyperion/sensor.py b/homeassistant/components/hyperion/sensor.py index 42b41acea96..bec17cfbd2f 100644 --- a/homeassistant/components/hyperion/sensor.py +++ b/homeassistant/components/hyperion/sensor.py @@ -19,7 +19,6 @@ from hyperion.const import ( ) from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -29,12 +28,12 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( - CONF_INSTANCE_CLIENTS, DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, @@ -62,12 +61,11 @@ def _sensor_unique_id(server_id: str, instance_num: int, suffix: str) -> str: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id @callback def instance_add(instance_num: int, instance_name: str) -> None: @@ -78,7 +76,7 @@ async def async_setup_entry( server_id, instance_num, instance_name, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], + entry.runtime_data.instance_clients[instance_num], PRIORITY_SENSOR_DESCRIPTION, ) ] @@ -98,7 +96,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) class HyperionSensor(SensorEntity): diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index 8b66783e889..c082c685304 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -26,7 +26,6 @@ from hyperion.const import ( ) from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -38,12 +37,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from . import ( + HyperionConfigEntry, get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( - CONF_INSTANCE_CLIENTS, DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, @@ -89,12 +88,11 @@ def _component_to_translation_key(component: str) -> str: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: HyperionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - server_id = config_entry.unique_id + server_id = entry.unique_id @callback def instance_add(instance_num: int, instance_name: str) -> None: @@ -106,7 +104,7 @@ async def async_setup_entry( instance_num, instance_name, component, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], + entry.runtime_data.instance_clients[instance_num], ) for component in COMPONENT_SWITCHES ) @@ -123,7 +121,7 @@ async def async_setup_entry( ), ) - listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + listen_for_instance_updates(hass, entry, instance_add, instance_remove) class HyperionComponentSwitch(SwitchEntity): diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index 2484a46f906..1604b37b967 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -6,18 +6,16 @@ import asyncio from pyialarm import IAlarm -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 DATA_COORDINATOR, DOMAIN -from .coordinator import IAlarmDataUpdateCoordinator +from .coordinator import IAlarmConfigEntry, IAlarmDataUpdateCoordinator PLATFORMS = [Platform.ALARM_CONTROL_PANEL] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IAlarmConfigEntry) -> bool: """Set up iAlarm config.""" host: str = entry.data[CONF_HOST] port: int = entry.data[CONF_PORT] @@ -32,20 +30,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = IAlarmDataUpdateCoordinator(hass, entry, ialarm, mac) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - - hass.data[DOMAIN][entry.entry_id] = { - 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: IAlarmConfigEntry) -> bool: """Unload iAlarm config.""" - 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/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py index e203f892c35..b2de9b3fefc 100644 --- a/homeassistant/components/ialarm/alarm_control_panel.py +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -7,26 +7,22 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import IAlarmDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import IAlarmConfigEntry, IAlarmDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IAlarmConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a iAlarm alarm control panel based on a config entry.""" - coordinator: IAlarmDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] - async_add_entities([IAlarmPanel(coordinator)], False) + async_add_entities([IAlarmPanel(entry.runtime_data)], False) class IAlarmPanel( diff --git a/homeassistant/components/ialarm/const.py b/homeassistant/components/ialarm/const.py index 1b8074c34f0..01ce47e002a 100644 --- a/homeassistant/components/ialarm/const.py +++ b/homeassistant/components/ialarm/const.py @@ -4,8 +4,6 @@ from pyialarm import IAlarm from homeassistant.components.alarm_control_panel import AlarmControlPanelState -DATA_COORDINATOR = "ialarm" - DEFAULT_PORT = 18034 DOMAIN = "ialarm" diff --git a/homeassistant/components/ialarm/coordinator.py b/homeassistant/components/ialarm/coordinator.py index 61e87c36796..546e0b6b714 100644 --- a/homeassistant/components/ialarm/coordinator.py +++ b/homeassistant/components/ialarm/coordinator.py @@ -19,14 +19,20 @@ from .const import DOMAIN, IALARM_TO_HASS _LOGGER = logging.getLogger(__name__) +type IAlarmConfigEntry = ConfigEntry[IAlarmDataUpdateCoordinator] + class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching iAlarm data.""" - config_entry: ConfigEntry + config_entry: IAlarmConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, ialarm: IAlarm, mac: str + self, + hass: HomeAssistant, + config_entry: IAlarmConfigEntry, + ialarm: IAlarm, + mac: str, ) -> None: """Initialize global iAlarm data updater.""" self.ialarm = ialarm diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 26bffc4e982..68a8a093c09 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass from datetime import datetime from functools import wraps import logging @@ -19,11 +20,6 @@ from iaqualink.device import ( ) from iaqualink.exception import AqualinkServiceException -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant @@ -48,21 +44,27 @@ PLATFORMS = [ Platform.SWITCH, ] +type AqualinkConfigEntry = ConfigEntry[AqualinkRuntimeData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +@dataclass +class AqualinkRuntimeData: + """Runtime data for Aqualink.""" + + client: AqualinkClient + # These will contain the initialized devices + binary_sensors: list[AqualinkBinarySensor] + lights: list[AqualinkLight] + sensors: list[AqualinkSensor] + switches: list[AqualinkSwitch] + thermostats: list[AqualinkThermostat] + + +async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> bool: """Set up Aqualink from a config entry.""" username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] - hass.data.setdefault(DOMAIN, {}) - - # These will contain the initialized devices - binary_sensors = hass.data[DOMAIN][BINARY_SENSOR_DOMAIN] = [] - climates = hass.data[DOMAIN][CLIMATE_DOMAIN] = [] - lights = hass.data[DOMAIN][LIGHT_DOMAIN] = [] - sensors = hass.data[DOMAIN][SENSOR_DOMAIN] = [] - switches = hass.data[DOMAIN][SWITCH_DOMAIN] = [] - aqualink = AqualinkClient(username, password, httpx_client=get_async_client(hass)) try: await aqualink.login() @@ -90,6 +92,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await aqualink.close() return False + runtime_data = AqualinkRuntimeData( + aqualink, binary_sensors=[], lights=[], sensors=[], switches=[], thermostats=[] + ) for system in systems: try: devices = await system.get_devices() @@ -101,36 +106,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for dev in devices.values(): if isinstance(dev, AqualinkThermostat): - climates += [dev] + runtime_data.thermostats += [dev] elif isinstance(dev, AqualinkLight): - lights += [dev] + runtime_data.lights += [dev] elif isinstance(dev, AqualinkSwitch): - switches += [dev] + runtime_data.switches += [dev] elif isinstance(dev, AqualinkBinarySensor): - binary_sensors += [dev] + runtime_data.binary_sensors += [dev] elif isinstance(dev, AqualinkSensor): - sensors += [dev] + runtime_data.sensors += [dev] - platforms = [] - if binary_sensors: - _LOGGER.debug("Got %s binary sensors: %s", len(binary_sensors), binary_sensors) - platforms.append(Platform.BINARY_SENSOR) - if climates: - _LOGGER.debug("Got %s climates: %s", len(climates), climates) - platforms.append(Platform.CLIMATE) - if lights: - _LOGGER.debug("Got %s lights: %s", len(lights), lights) - platforms.append(Platform.LIGHT) - if sensors: - _LOGGER.debug("Got %s sensors: %s", len(sensors), sensors) - platforms.append(Platform.SENSOR) - if switches: - _LOGGER.debug("Got %s switches: %s", len(switches), switches) - platforms.append(Platform.SWITCH) + _LOGGER.debug( + "Got %s binary sensors: %s", + len(runtime_data.binary_sensors), + runtime_data.binary_sensors, + ) + _LOGGER.debug("Got %s lights: %s", len(runtime_data.lights), runtime_data.lights) + _LOGGER.debug("Got %s sensors: %s", len(runtime_data.sensors), runtime_data.sensors) + _LOGGER.debug( + "Got %s switches: %s", len(runtime_data.switches), runtime_data.switches + ) + _LOGGER.debug( + "Got %s thermostats: %s", + len(runtime_data.thermostats), + runtime_data.thermostats, + ) - hass.data[DOMAIN]["client"] = aqualink + entry.runtime_data = runtime_data - await hass.config_entries.async_forward_entry_setups(entry, platforms) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def _async_systems_update(_: datetime) -> None: """Refresh internal state for all systems.""" @@ -161,18 +165,10 @@ 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: AqualinkConfigEntry) -> bool: """Unload a config entry.""" - aqualink = hass.data[DOMAIN]["client"] - await aqualink.close() - - platforms_to_unload = [ - platform for platform in PLATFORMS if platform in hass.data[DOMAIN] - ] - - del hass.data[DOMAIN] - - return await hass.config_entries.async_unload_platforms(entry, platforms_to_unload) + await entry.runtime_data.client.close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def refresh_system[_AqualinkEntityT: AqualinkEntity, **_P]( diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py index 8fe9d77fbe8..3c260c7ef03 100644 --- a/homeassistant/components/iaqualink/binary_sensor.py +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -5,15 +5,13 @@ from __future__ import annotations from iaqualink.device import AqualinkBinarySensor from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as AQUALINK_DOMAIN +from . import AqualinkConfigEntry from .entity import AqualinkEntity PARALLEL_UPDATES = 0 @@ -21,20 +19,22 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AqualinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered binary sensors.""" async_add_entities( ( HassAqualinkBinarySensor(dev) - for dev in hass.data[AQUALINK_DOMAIN][BINARY_SENSOR_DOMAIN] + for dev in config_entry.runtime_data.binary_sensors ), True, ) -class HassAqualinkBinarySensor(AqualinkEntity, BinarySensorEntity): +class HassAqualinkBinarySensor( + AqualinkEntity[AqualinkBinarySensor], BinarySensorEntity +): """Representation of a binary sensor.""" def __init__(self, dev: AqualinkBinarySensor) -> None: diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index d30700898c8..36aec12976a 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -9,19 +9,16 @@ from iaqualink.device import AqualinkThermostat from iaqualink.systems.iaqua.device import AqualinkState from homeassistant.components.climate import ( - DOMAIN as CLIMATE_DOMAIN, ClimateEntity, ClimateEntityFeature, 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 AddConfigEntryEntitiesCallback -from . import refresh_system -from .const import DOMAIN as AQUALINK_DOMAIN +from . import AqualinkConfigEntry, refresh_system from .entity import AqualinkEntity from .utils import await_or_reraise @@ -32,20 +29,17 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AqualinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered switches.""" async_add_entities( - ( - HassAqualinkThermostat(dev) - for dev in hass.data[AQUALINK_DOMAIN][CLIMATE_DOMAIN] - ), + (HassAqualinkThermostat(dev) for dev in config_entry.runtime_data.thermostats), True, ) -class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): +class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity): """Representation of a thermostat.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] diff --git a/homeassistant/components/iaqualink/entity.py b/homeassistant/components/iaqualink/entity.py index 437611e5a5f..0b3751e5fbc 100644 --- a/homeassistant/components/iaqualink/entity.py +++ b/homeassistant/components/iaqualink/entity.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN -class AqualinkEntity(Entity): +class AqualinkEntity[AqualinkDeviceT: AqualinkDevice](Entity): """Abstract class for all Aqualink platforms. Entity state is updated via the interval timer within the integration. @@ -23,7 +23,7 @@ class AqualinkEntity(Entity): _attr_should_poll = False - def __init__(self, dev: AqualinkDevice) -> None: + def __init__(self, dev: AqualinkDeviceT) -> None: """Initialize the entity.""" self.dev = dev self._attr_unique_id = f"{dev.system.serial}_{dev.name}" @@ -32,7 +32,6 @@ class AqualinkEntity(Entity): manufacturer=dev.manufacturer, model=dev.model, name=dev.label, - via_device=(DOMAIN, dev.system.serial), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index e515c482158..55b14065cef 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -9,17 +9,14 @@ from iaqualink.device import AqualinkLight from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, - DOMAIN as LIGHT_DOMAIN, ColorMode, LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import refresh_system -from .const import DOMAIN as AQUALINK_DOMAIN +from . import AqualinkConfigEntry, refresh_system from .entity import AqualinkEntity from .utils import await_or_reraise @@ -28,17 +25,17 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AqualinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered lights.""" async_add_entities( - (HassAqualinkLight(dev) for dev in hass.data[AQUALINK_DOMAIN][LIGHT_DOMAIN]), + (HassAqualinkLight(dev) for dev in config_entry.runtime_data.lights), True, ) -class HassAqualinkLight(AqualinkEntity, LightEntity): +class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity): """Representation of a light.""" def __init__(self, dev: AqualinkLight) -> None: diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index 2531632075c..a0742865438 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/iaqualink", "iot_class": "cloud_polling", "loggers": ["iaqualink"], - "requirements": ["iaqualink==0.5.0", "h2==4.1.0"], + "requirements": ["iaqualink==0.5.3", "h2==4.2.0"], "single_config_entry": true } diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index 1b453f28d8f..baeca799bc3 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -4,17 +4,12 @@ from __future__ import annotations from iaqualink.device import AqualinkSensor -from homeassistant.components.sensor import ( - DOMAIN as SENSOR_DOMAIN, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as AQUALINK_DOMAIN +from . import AqualinkConfigEntry from .entity import AqualinkEntity PARALLEL_UPDATES = 0 @@ -22,17 +17,17 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AqualinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered sensors.""" async_add_entities( - (HassAqualinkSensor(dev) for dev in hass.data[AQUALINK_DOMAIN][SENSOR_DOMAIN]), + (HassAqualinkSensor(dev) for dev in config_entry.runtime_data.sensors), True, ) -class HassAqualinkSensor(AqualinkEntity, SensorEntity): +class HassAqualinkSensor(AqualinkEntity[AqualinkSensor], SensorEntity): """Representation of a sensor.""" def __init__(self, dev: AqualinkSensor) -> None: diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index e746cbb4f4b..851554a1972 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -6,13 +6,11 @@ from typing import Any from iaqualink.device import AqualinkSwitch -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import refresh_system -from .const import DOMAIN as AQUALINK_DOMAIN +from . import AqualinkConfigEntry, refresh_system from .entity import AqualinkEntity from .utils import await_or_reraise @@ -21,17 +19,17 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AqualinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered switches.""" async_add_entities( - (HassAqualinkSwitch(dev) for dev in hass.data[AQUALINK_DOMAIN][SWITCH_DOMAIN]), + (HassAqualinkSwitch(dev) for dev in config_entry.runtime_data.switches), True, ) -class HassAqualinkSwitch(AqualinkEntity, SwitchEntity): +class HassAqualinkSwitch(AqualinkEntity[AqualinkSwitch], SwitchEntity): """Representation of a switch.""" def __init__(self, dev: AqualinkSwitch) -> None: diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 4ed66be6a4b..13551ebece5 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -4,77 +4,25 @@ from __future__ import annotations from typing import Any -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store -from homeassistant.util import slugify -from .account import IcloudAccount +from .account import IcloudAccount, IcloudConfigEntry from .const import ( CONF_GPS_ACCURACY_THRESHOLD, CONF_MAX_INTERVAL, CONF_WITH_FAMILY, - DOMAIN, PLATFORMS, STORAGE_KEY, STORAGE_VERSION, ) - -ATTRIBUTION = "Data provided by Apple iCloud" - -# entity attributes -ATTR_ACCOUNT_FETCH_INTERVAL = "account_fetch_interval" -ATTR_BATTERY = "battery" -ATTR_BATTERY_STATUS = "battery_status" -ATTR_DEVICE_NAME = "device_name" -ATTR_DEVICE_STATUS = "device_status" -ATTR_LOW_POWER_MODE = "low_power_mode" -ATTR_OWNER_NAME = "owner_fullname" - -# services -SERVICE_ICLOUD_PLAY_SOUND = "play_sound" -SERVICE_ICLOUD_DISPLAY_MESSAGE = "display_message" -SERVICE_ICLOUD_LOST_DEVICE = "lost_device" -SERVICE_ICLOUD_UPDATE = "update" -ATTR_ACCOUNT = "account" -ATTR_LOST_DEVICE_MESSAGE = "message" -ATTR_LOST_DEVICE_NUMBER = "number" -ATTR_LOST_DEVICE_SOUND = "sound" - -SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ACCOUNT): cv.string}) - -SERVICE_SCHEMA_PLAY_SOUND = vol.Schema( - {vol.Required(ATTR_ACCOUNT): cv.string, vol.Required(ATTR_DEVICE_NAME): cv.string} -) - -SERVICE_SCHEMA_DISPLAY_MESSAGE = vol.Schema( - { - vol.Required(ATTR_ACCOUNT): cv.string, - vol.Required(ATTR_DEVICE_NAME): cv.string, - vol.Required(ATTR_LOST_DEVICE_MESSAGE): cv.string, - vol.Optional(ATTR_LOST_DEVICE_SOUND): cv.boolean, - } -) - -SERVICE_SCHEMA_LOST_DEVICE = vol.Schema( - { - vol.Required(ATTR_ACCOUNT): cv.string, - vol.Required(ATTR_DEVICE_NAME): cv.string, - vol.Required(ATTR_LOST_DEVICE_NUMBER): cv.string, - vol.Required(ATTR_LOST_DEVICE_MESSAGE): cv.string, - } -) +from .services import register_services -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bool: """Set up an iCloud account from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] with_family = entry.data[CONF_WITH_FAMILY] @@ -99,93 +47,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.async_add_executor_job(account.setup) - hass.data[DOMAIN][entry.unique_id] = account + entry.runtime_data = account await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - def play_sound(service: ServiceCall) -> None: - """Play sound on the device.""" - account = service.data[ATTR_ACCOUNT] - device_name: str = service.data[ATTR_DEVICE_NAME] - device_name = slugify(device_name.replace(" ", "", 99)) - - for device in _get_account(account).get_devices_with_name(device_name): - device.play_sound() - - def display_message(service: ServiceCall) -> None: - """Display a message on the device.""" - account = service.data[ATTR_ACCOUNT] - device_name: str = service.data[ATTR_DEVICE_NAME] - device_name = slugify(device_name.replace(" ", "", 99)) - message = service.data.get(ATTR_LOST_DEVICE_MESSAGE) - sound = service.data.get(ATTR_LOST_DEVICE_SOUND, False) - - for device in _get_account(account).get_devices_with_name(device_name): - device.display_message(message, sound) - - def lost_device(service: ServiceCall) -> None: - """Make the device in lost state.""" - account = service.data[ATTR_ACCOUNT] - device_name: str = service.data[ATTR_DEVICE_NAME] - device_name = slugify(device_name.replace(" ", "", 99)) - number = service.data.get(ATTR_LOST_DEVICE_NUMBER) - message = service.data.get(ATTR_LOST_DEVICE_MESSAGE) - - for device in _get_account(account).get_devices_with_name(device_name): - device.lost_device(number, message) - - def update_account(service: ServiceCall) -> None: - """Call the update function of an iCloud account.""" - if (account := service.data.get(ATTR_ACCOUNT)) is None: - for account in hass.data[DOMAIN].values(): - account.keep_alive() - else: - _get_account(account).keep_alive() - - def _get_account(account_identifier: str) -> IcloudAccount: - if account_identifier is None: - return None - - icloud_account: IcloudAccount | None = hass.data[DOMAIN].get(account_identifier) - if icloud_account is None: - for account in hass.data[DOMAIN].values(): - if account.username == account_identifier: - icloud_account = account - - if icloud_account is None: - raise ValueError( - f"No iCloud account with username or name {account_identifier}" - ) - return icloud_account - - hass.services.async_register( - DOMAIN, SERVICE_ICLOUD_PLAY_SOUND, play_sound, schema=SERVICE_SCHEMA_PLAY_SOUND - ) - - hass.services.async_register( - DOMAIN, - SERVICE_ICLOUD_DISPLAY_MESSAGE, - display_message, - schema=SERVICE_SCHEMA_DISPLAY_MESSAGE, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_ICLOUD_LOST_DEVICE, - lost_device, - schema=SERVICE_SCHEMA_LOST_DEVICE, - ) - - hass.services.async_register( - DOMAIN, SERVICE_ICLOUD_UPDATE, update_account, schema=SERVICE_SCHEMA - ) + register_services(hass) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.data[CONF_USERNAME]) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 9536cd9ee5c..3006193a1ff 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -29,6 +29,13 @@ from homeassistant.util.dt import utcnow from homeassistant.util.location import distance from .const import ( + ATTR_ACCOUNT_FETCH_INTERVAL, + ATTR_BATTERY, + ATTR_BATTERY_STATUS, + ATTR_DEVICE_NAME, + ATTR_DEVICE_STATUS, + ATTR_LOW_POWER_MODE, + ATTR_OWNER_NAME, DEVICE_BATTERY_LEVEL, DEVICE_BATTERY_STATUS, DEVICE_CLASS, @@ -49,27 +56,10 @@ from .const import ( DOMAIN, ) -# entity attributes -ATTR_ACCOUNT_FETCH_INTERVAL = "account_fetch_interval" -ATTR_BATTERY = "battery" -ATTR_BATTERY_STATUS = "battery_status" -ATTR_DEVICE_NAME = "device_name" -ATTR_DEVICE_STATUS = "device_status" -ATTR_LOW_POWER_MODE = "low_power_mode" -ATTR_OWNER_NAME = "owner_fullname" - -# services -SERVICE_ICLOUD_PLAY_SOUND = "play_sound" -SERVICE_ICLOUD_DISPLAY_MESSAGE = "display_message" -SERVICE_ICLOUD_LOST_DEVICE = "lost_device" -SERVICE_ICLOUD_UPDATE = "update" -ATTR_ACCOUNT = "account" -ATTR_LOST_DEVICE_MESSAGE = "message" -ATTR_LOST_DEVICE_NUMBER = "number" -ATTR_LOST_DEVICE_SOUND = "sound" - _LOGGER = logging.getLogger(__name__) +type IcloudConfigEntry = ConfigEntry[IcloudAccount] + class IcloudAccount: """Representation of an iCloud account.""" @@ -83,7 +73,7 @@ class IcloudAccount: with_family: bool, max_interval: int, gps_accuracy_threshold: int, - config_entry: ConfigEntry, + config_entry: IcloudConfigEntry, ) -> None: """Initialize an iCloud account.""" self.hass = hass diff --git a/homeassistant/components/icloud/const.py b/homeassistant/components/icloud/const.py index b7ea2691ca4..72b1d496121 100644 --- a/homeassistant/components/icloud/const.py +++ b/homeassistant/components/icloud/const.py @@ -4,6 +4,8 @@ from homeassistant.const import Platform DOMAIN = "icloud" +ATTRIBUTION = "Data provided by Apple iCloud" + CONF_WITH_FAMILY = "with_family" CONF_MAX_INTERVAL = "max_interval" CONF_GPS_ACCURACY_THRESHOLD = "gps_accuracy_threshold" @@ -84,3 +86,17 @@ DEVICE_STATUS_CODES = { "203": "pending", "204": "unregistered", } + + +# entity / service attributes +ATTR_ACCOUNT = "account" +ATTR_ACCOUNT_FETCH_INTERVAL = "account_fetch_interval" +ATTR_BATTERY = "battery" +ATTR_BATTERY_STATUS = "battery_status" +ATTR_DEVICE_NAME = "device_name" +ATTR_DEVICE_STATUS = "device_status" +ATTR_LOW_POWER_MODE = "low_power_mode" +ATTR_LOST_DEVICE_MESSAGE = "message" +ATTR_LOST_DEVICE_NUMBER = "number" +ATTR_LOST_DEVICE_SOUND = "sound" +ATTR_OWNER_NAME = "owner_fullname" diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index ca194143852..e546d3034ae 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -5,13 +5,12 @@ from __future__ import annotations from typing import Any from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .account import IcloudAccount, IcloudDevice +from .account import IcloudAccount, IcloudConfigEntry, IcloudDevice from .const import ( DEVICE_LOCATION_HORIZONTAL_ACCURACY, DEVICE_LOCATION_LATITUDE, @@ -22,11 +21,11 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IcloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for iCloud component.""" - account: IcloudAccount = hass.data[DOMAIN][entry.unique_id] + account = entry.runtime_data tracked = set[str]() @callback diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index 533605b8c7b..11690a0da59 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -13,17 +12,17 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from .account import IcloudAccount, IcloudDevice +from .account import IcloudAccount, IcloudConfigEntry, IcloudDevice from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IcloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for iCloud component.""" - account: IcloudAccount = hass.data[DOMAIN][entry.unique_id] + account = entry.runtime_data tracked = set[str]() @callback diff --git a/homeassistant/components/icloud/services.py b/homeassistant/components/icloud/services.py new file mode 100644 index 00000000000..5897fcb06f7 --- /dev/null +++ b/homeassistant/components/icloud/services.py @@ -0,0 +1,141 @@ +"""The iCloud component.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.util import slugify + +from .account import IcloudAccount +from .const import ( + ATTR_ACCOUNT, + ATTR_DEVICE_NAME, + ATTR_LOST_DEVICE_MESSAGE, + ATTR_LOST_DEVICE_NUMBER, + ATTR_LOST_DEVICE_SOUND, + DOMAIN, +) + +# services +SERVICE_ICLOUD_PLAY_SOUND = "play_sound" +SERVICE_ICLOUD_DISPLAY_MESSAGE = "display_message" +SERVICE_ICLOUD_LOST_DEVICE = "lost_device" +SERVICE_ICLOUD_UPDATE = "update" + +SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ACCOUNT): cv.string}) + +SERVICE_SCHEMA_PLAY_SOUND = vol.Schema( + {vol.Required(ATTR_ACCOUNT): cv.string, vol.Required(ATTR_DEVICE_NAME): cv.string} +) + +SERVICE_SCHEMA_DISPLAY_MESSAGE = vol.Schema( + { + vol.Required(ATTR_ACCOUNT): cv.string, + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_LOST_DEVICE_MESSAGE): cv.string, + vol.Optional(ATTR_LOST_DEVICE_SOUND): cv.boolean, + } +) + +SERVICE_SCHEMA_LOST_DEVICE = vol.Schema( + { + vol.Required(ATTR_ACCOUNT): cv.string, + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_LOST_DEVICE_NUMBER): cv.string, + vol.Required(ATTR_LOST_DEVICE_MESSAGE): cv.string, + } +) + + +def play_sound(service: ServiceCall) -> None: + """Play sound on the device.""" + account = service.data[ATTR_ACCOUNT] + device_name: str = service.data[ATTR_DEVICE_NAME] + device_name = slugify(device_name.replace(" ", "", 99)) + + for device in _get_account(service.hass, account).get_devices_with_name( + device_name + ): + device.play_sound() + + +def display_message(service: ServiceCall) -> None: + """Display a message on the device.""" + account = service.data[ATTR_ACCOUNT] + device_name: str = service.data[ATTR_DEVICE_NAME] + device_name = slugify(device_name.replace(" ", "", 99)) + message = service.data.get(ATTR_LOST_DEVICE_MESSAGE) + sound = service.data.get(ATTR_LOST_DEVICE_SOUND, False) + + for device in _get_account(service.hass, account).get_devices_with_name( + device_name + ): + device.display_message(message, sound) + + +def lost_device(service: ServiceCall) -> None: + """Make the device in lost state.""" + account = service.data[ATTR_ACCOUNT] + device_name: str = service.data[ATTR_DEVICE_NAME] + device_name = slugify(device_name.replace(" ", "", 99)) + number = service.data.get(ATTR_LOST_DEVICE_NUMBER) + message = service.data.get(ATTR_LOST_DEVICE_MESSAGE) + + for device in _get_account(service.hass, account).get_devices_with_name( + device_name + ): + device.lost_device(number, message) + + +def update_account(service: ServiceCall) -> None: + """Call the update function of an iCloud account.""" + if (account := service.data.get(ATTR_ACCOUNT)) is None: + for account in service.hass.data[DOMAIN].values(): + account.keep_alive() + else: + _get_account(service.hass, account).keep_alive() + + +def _get_account(hass: HomeAssistant, account_identifier: str) -> IcloudAccount: + if account_identifier is None: + return None + + icloud_account: IcloudAccount | None = hass.data[DOMAIN].get(account_identifier) + if icloud_account is None: + for account in hass.data[DOMAIN].values(): + if account.username == account_identifier: + icloud_account = account + + if icloud_account is None: + raise ValueError( + f"No iCloud account with username or name {account_identifier}" + ) + return icloud_account + + +def register_services(hass: HomeAssistant) -> None: + """Set up an iCloud account from a config entry.""" + + hass.services.async_register( + DOMAIN, SERVICE_ICLOUD_PLAY_SOUND, play_sound, schema=SERVICE_SCHEMA_PLAY_SOUND + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ICLOUD_DISPLAY_MESSAGE, + display_message, + schema=SERVICE_SCHEMA_DISPLAY_MESSAGE, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ICLOUD_LOST_DEVICE, + lost_device, + schema=SERVICE_SCHEMA_LOST_DEVICE, + ) + + hass.services.async_register( + DOMAIN, SERVICE_ICLOUD_UPDATE, update_account, schema=SERVICE_SCHEMA + ) diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index adc96043d66..fc78e8c2ba6 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -46,7 +46,7 @@ "services": { "update": { "name": "Update", - "description": "Updates iCloud devices.", + "description": "Asks for a state update of all devices linked to an iCloud account.", "fields": { "account": { "name": "Account", diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json index 7486973638b..ff0cb5b8ae6 100644 --- a/homeassistant/components/idasen_desk/strings.json +++ b/homeassistant/components/idasen_desk/strings.json @@ -7,7 +7,7 @@ "address": "Device" }, "data_description": { - "address": "The bluetooth device for the desk." + "address": "The Bluetooth device for the desk." } } }, @@ -26,10 +26,10 @@ "entity": { "button": { "connect": { - "name": "Connect" + "name": "[%key:common::action::connect%]" }, "disconnect": { - "name": "Disconnect" + "name": "[%key:common::action::disconnect%]" } }, "sensor": { diff --git a/homeassistant/components/ifttt/strings.json b/homeassistant/components/ifttt/strings.json index 5ba0812697f..df5a2bc9d93 100644 --- a/homeassistant/components/ifttt/strings.json +++ b/homeassistant/components/ifttt/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up the IFTTT Webhook Applet", + "title": "Set up the IFTTT webhook applet", "description": "Are you sure you want to set up IFTTT?" } }, @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } }, "services": { @@ -32,7 +32,7 @@ }, "trigger": { "name": "Trigger", - "description": "Triggers the configured IFTTT Webhook.", + "description": "Triggers the configured IFTTT webhook.", "fields": { "event": { "name": "Event", diff --git a/homeassistant/components/igloohome/__init__.py b/homeassistant/components/igloohome/__init__.py index 5e5e21452cf..a3907fcbcf3 100644 --- a/homeassistant/components/igloohome/__init__.py +++ b/homeassistant/components/igloohome/__init__.py @@ -19,7 +19,7 @@ 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] +PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR] @dataclass @@ -35,7 +35,6 @@ 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], diff --git a/homeassistant/components/igloohome/lock.py b/homeassistant/components/igloohome/lock.py new file mode 100644 index 00000000000..b434c055145 --- /dev/null +++ b/homeassistant/components/igloohome/lock.py @@ -0,0 +1,91 @@ +"""Implementation of the lock platform.""" + +from datetime import timedelta + +from aiohttp import ClientError +from igloohome_api import ( + BRIDGE_JOB_LOCK, + BRIDGE_JOB_UNLOCK, + DEVICE_TYPE_LOCK, + Api as IgloohomeApi, + ApiException, + GetDeviceInfoResponse, +) + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import IgloohomeConfigEntry +from .entity import IgloohomeBaseEntity +from .utils import get_linked_bridge + +# Scan interval set to allow Lock entity update the bridge linked to it. +SCAN_INTERVAL = timedelta(hours=1) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IgloohomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up lock entities.""" + async_add_entities( + IgloohomeLockEntity( + api_device_info=device, + api=entry.runtime_data.api, + bridge_id=str(bridge), + ) + for device in entry.runtime_data.devices + if device.type == DEVICE_TYPE_LOCK + and (bridge := get_linked_bridge(device.deviceId, entry.runtime_data.devices)) + is not None + ) + + +class IgloohomeLockEntity(IgloohomeBaseEntity, LockEntity): + """Implementation of a device that has locking capabilities.""" + + # Operating on assumed state because there is no API to query the state. + _attr_assumed_state = True + _attr_supported_features = LockEntityFeature.OPEN + _attr_name = None + + def __init__( + self, api_device_info: GetDeviceInfoResponse, api: IgloohomeApi, bridge_id: str + ) -> None: + """Initialize the class.""" + super().__init__( + api_device_info=api_device_info, + api=api, + unique_key="lock", + ) + self.bridge_id = bridge_id + + async def async_lock(self, **kwargs): + """Lock this lock.""" + try: + await self.api.create_bridge_proxied_job( + self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_LOCK + ) + except (ApiException, ClientError) as err: + raise HomeAssistantError from err + + async def async_unlock(self, **kwargs): + """Unlock this lock.""" + try: + await self.api.create_bridge_proxied_job( + self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_UNLOCK + ) + except (ApiException, ClientError) as err: + raise HomeAssistantError from err + + async def async_open(self, **kwargs): + """Open (unlatch) this lock.""" + try: + await self.api.create_bridge_proxied_job( + self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_UNLOCK + ) + except (ApiException, ClientError) as err: + raise HomeAssistantError from err diff --git a/homeassistant/components/igloohome/utils.py b/homeassistant/components/igloohome/utils.py new file mode 100644 index 00000000000..be17912b8b8 --- /dev/null +++ b/homeassistant/components/igloohome/utils.py @@ -0,0 +1,16 @@ +"""House utility functions.""" + +from igloohome_api import DEVICE_TYPE_BRIDGE, GetDeviceInfoResponse + + +def get_linked_bridge( + device_id: str, devices: list[GetDeviceInfoResponse] +) -> str | None: + """Return the ID of the bridge that is linked to the device. None if no bridge is linked.""" + bridges = (bridge for bridge in devices if bridge.type == DEVICE_TYPE_BRIDGE) + for bridge in bridges: + if device_id in ( + linked_device.deviceId for linked_device in bridge.linkedDevices + ): + return bridge.deviceId + return None diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index e43377a3230..bc01476d509 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.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 74f7a86c0d6..34d3f43eb69 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -280,7 +280,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): if self.custom_event_template is not None: try: data["custom"] = self.custom_event_template.async_render( - data, parse_result=True + data | {"text": message.text}, parse_result=True ) _LOGGER.debug( "IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s", diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 8ff5d838199..0f6f99dff65 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -136,7 +136,7 @@ "services": { "fetch": { "name": "Fetch message", - "description": "Fetch an email message from the server.", + "description": "Fetches an email message from the server.", "fields": { "entry": { "name": "Entry", @@ -150,7 +150,7 @@ }, "seen": { "name": "Mark message as seen", - "description": "Mark an email as seen.", + "description": "Marks an email as seen.", "fields": { "entry": { "name": "Entry", @@ -164,7 +164,7 @@ }, "move": { "name": "Move message", - "description": "Move an email to a target folder.", + "description": "Moves an email to a target folder.", "fields": { "entry": { "name": "[%key:component::imap::services::seen::fields::entry::name%]", @@ -186,7 +186,7 @@ }, "delete": { "name": "Delete message", - "description": "Delete an email.", + "description": "Deletes an email.", "fields": { "entry": { "name": "[%key:component::imap::services::seen::fields::entry::name%]", diff --git a/homeassistant/components/imeon_inverter/__init__.py b/homeassistant/components/imeon_inverter/__init__.py new file mode 100644 index 00000000000..0676731f375 --- /dev/null +++ b/homeassistant/components/imeon_inverter/__init__.py @@ -0,0 +1,31 @@ +"""Initialize the Imeon component.""" + +from __future__ import annotations + +import logging + +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS +from .coordinator import InverterConfigEntry, InverterCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: InverterConfigEntry) -> bool: + """Handle the creation of a new config entry for the integration (asynchronous).""" + + # Create the corresponding HUB + coordinator = InverterCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + # Call for HUB creation then each entity as a List + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: InverterConfigEntry) -> bool: + """Handle entry unloading.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/imeon_inverter/config_flow.py b/homeassistant/components/imeon_inverter/config_flow.py new file mode 100644 index 00000000000..fadb2c65446 --- /dev/null +++ b/homeassistant/components/imeon_inverter/config_flow.py @@ -0,0 +1,114 @@ +"""Config flow for Imeon integration.""" + +import logging +from typing import Any +from urllib.parse import urlparse + +from imeon_inverter_api.inverter import Inverter +import voluptuous as vol + +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_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.typing import VolDictType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ImeonInverterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the initial setup flow for Imeon Inverters.""" + + _host: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step for creating a new configuration entry.""" + + errors: dict[str, str] = {} + + if user_input is not None: + # User have to provide the hostname if device is not discovered + host = self._host or user_input[CONF_HOST] + + async with Inverter(host) as client: + try: + # Check connection + if await client.login( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ): + serial = await client.get_serial() + + else: + errors["base"] = "invalid_auth" + + except TimeoutError: + errors["base"] = "cannot_connect" + + except ValueError as e: + if "Host invalid" in str(e): + errors["base"] = "invalid_host" + + elif "Route invalid" in str(e): + errors["base"] = "invalid_route" + + else: + errors["base"] = "unknown" + _LOGGER.exception( + "Unexpected error occurred while connecting to the Imeon" + ) + + if not errors: + # Check if entry already exists + await self.async_set_unique_id(serial, raise_on_progress=False) + self._abort_if_unique_id_configured() + + # Create a new configuration entry if login succeeds + return self.async_create_entry( + title=f"Imeon {serial}", data={CONF_HOST: host, **user_input} + ) + + host_schema: VolDictType = ( + {vol.Required(CONF_HOST): str} if not self._host else {} + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + **host_schema, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle a SSDP discovery.""" + + host = str(urlparse(discovery_info.ssdp_location).hostname) + serial = discovery_info.upnp.get(ATTR_UPNP_SERIAL, "") + + if not serial: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + self._host = host + + self.context["title_placeholders"] = { + "model": discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, ""), + "serial": serial, + } + + return await self.async_step_user() diff --git a/homeassistant/components/imeon_inverter/const.py b/homeassistant/components/imeon_inverter/const.py new file mode 100644 index 00000000000..c71a8c72d11 --- /dev/null +++ b/homeassistant/components/imeon_inverter/const.py @@ -0,0 +1,9 @@ +"""Constant for Imeon component.""" + +from homeassistant.const import Platform + +DOMAIN = "imeon_inverter" +TIMEOUT = 20 +PLATFORMS = [ + Platform.SENSOR, +] diff --git a/homeassistant/components/imeon_inverter/coordinator.py b/homeassistant/components/imeon_inverter/coordinator.py new file mode 100644 index 00000000000..8342240b9ff --- /dev/null +++ b/homeassistant/components/imeon_inverter/coordinator.py @@ -0,0 +1,97 @@ +"""Coordinator for Imeon integration.""" + +from __future__ import annotations + +from asyncio import timeout +from datetime import timedelta +import logging + +from aiohttp import ClientError +from imeon_inverter_api.inverter import Inverter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import TIMEOUT + +HUBNAME = "imeon_inverter_hub" +INTERVAL = timedelta(seconds=60) +_LOGGER = logging.getLogger(__name__) + +type InverterConfigEntry = ConfigEntry[InverterCoordinator] + + +# HUB CREATION # +class InverterCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]): + """Each inverter is it's own HUB, thus it's own data set. + + This allows this integration to handle as many + inverters as possible in parallel. + """ + + config_entry: InverterConfigEntry + + # Implement methods to fetch and update data + def __init__( + self, + hass: HomeAssistant, + entry: InverterConfigEntry, + ) -> None: + """Initialize data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=HUBNAME, + update_interval=INTERVAL, + config_entry=entry, + ) + + self._api = Inverter(entry.data[CONF_HOST]) + + @property + def api(self) -> Inverter: + """Return the inverter object.""" + return self._api + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + async with timeout(TIMEOUT): + await self._api.login( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + + await self._api.init() + + async def _async_update_data(self) -> dict[str, str | float | int]: + """Fetch and store newest data from API. + + This is the place to where entities can get their data. + It also includes the login process. + """ + + data: dict[str, str | float | int] = {} + + async with timeout(TIMEOUT): + await self._api.login( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + + # Fetch data using distant API + try: + await self._api.update() + except (ValueError, ClientError) as e: + raise UpdateFailed(e) from e + + # Store data + for key, val in self._api.storage.items(): + if key == "timeline": + data[key] = val + else: + for sub_key, sub_val in val.items(): + data[f"{key}_{sub_key}"] = sub_val + + return data diff --git a/homeassistant/components/imeon_inverter/icons.json b/homeassistant/components/imeon_inverter/icons.json new file mode 100644 index 00000000000..1c74cf4c745 --- /dev/null +++ b/homeassistant/components/imeon_inverter/icons.json @@ -0,0 +1,159 @@ +{ + "entity": { + "sensor": { + "battery_autonomy": { + "default": "mdi:battery-clock" + }, + "battery_charge_time": { + "default": "mdi:battery-charging" + }, + "battery_power": { + "default": "mdi:battery" + }, + "battery_soc": { + "default": "mdi:battery-charging-100" + }, + "battery_stored": { + "default": "mdi:battery" + }, + "grid_current_l1": { + "default": "mdi:current-ac" + }, + "grid_current_l2": { + "default": "mdi:current-ac" + }, + "grid_current_l3": { + "default": "mdi:current-ac" + }, + "grid_frequency": { + "default": "mdi:sine-wave" + }, + "grid_voltage_l1": { + "default": "mdi:flash" + }, + "grid_voltage_l2": { + "default": "mdi:flash" + }, + "grid_voltage_l3": { + "default": "mdi:flash" + }, + "input_power_l1": { + "default": "mdi:power-socket" + }, + "input_power_l2": { + "default": "mdi:power-socket" + }, + "input_power_l3": { + "default": "mdi:power-socket" + }, + "input_power_total": { + "default": "mdi:power-plug" + }, + "inverter_charging_current_limit": { + "default": "mdi:current-dc" + }, + "inverter_injection_power_limit": { + "default": "mdi:power-socket" + }, + "meter_power": { + "default": "mdi:power-plug" + }, + "meter_power_protocol": { + "default": "mdi:protocol" + }, + "output_current_l1": { + "default": "mdi:current-ac" + }, + "output_current_l2": { + "default": "mdi:current-ac" + }, + "output_current_l3": { + "default": "mdi:current-ac" + }, + "output_frequency": { + "default": "mdi:sine-wave" + }, + "output_power_l1": { + "default": "mdi:power-socket" + }, + "output_power_l2": { + "default": "mdi:power-socket" + }, + "output_power_l3": { + "default": "mdi:power-socket" + }, + "output_power_total": { + "default": "mdi:power-plug" + }, + "output_voltage_l1": { + "default": "mdi:flash" + }, + "output_voltage_l2": { + "default": "mdi:flash" + }, + "output_voltage_l3": { + "default": "mdi:flash" + }, + "pv_consumed": { + "default": "mdi:solar-power" + }, + "pv_injected": { + "default": "mdi:solar-power" + }, + "pv_power_1": { + "default": "mdi:solar-power" + }, + "pv_power_2": { + "default": "mdi:solar-power" + }, + "pv_power_total": { + "default": "mdi:solar-power" + }, + "temp_air_temperature": { + "default": "mdi:thermometer" + }, + "temp_component_temperature": { + "default": "mdi:thermometer" + }, + "monitoring_building_consumption": { + "default": "mdi:home-lightning-bolt" + }, + "monitoring_economy_factor": { + "default": "mdi:chart-bar" + }, + "monitoring_grid_consumption": { + "default": "mdi:transmission-tower" + }, + "monitoring_grid_injection": { + "default": "mdi:transmission-tower-export" + }, + "monitoring_grid_power_flow": { + "default": "mdi:power-plug" + }, + "monitoring_self_consumption": { + "default": "mdi:percent" + }, + "monitoring_self_sufficiency": { + "default": "mdi:percent" + }, + "monitoring_solar_production": { + "default": "mdi:solar-power" + }, + "monitoring_minute_building_consumption": { + "default": "mdi:home-lightning-bolt" + }, + "monitoring_minute_grid_consumption": { + "default": "mdi:transmission-tower" + }, + "monitoring_minute_grid_injection": { + "default": "mdi:transmission-tower-export" + }, + "monitoring_minute_grid_power_flow": { + "default": "mdi:power-plug" + }, + "monitoring_minute_solar_production": { + "default": "mdi:solar-power" + } + } + } +} diff --git a/homeassistant/components/imeon_inverter/manifest.json b/homeassistant/components/imeon_inverter/manifest.json new file mode 100644 index 00000000000..1398521dc45 --- /dev/null +++ b/homeassistant/components/imeon_inverter/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "imeon_inverter", + "name": "Imeon Inverter", + "codeowners": ["@Imeon-Energy"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/imeon_inverter", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["imeon_inverter_api==0.3.12"], + "ssdp": [ + { + "manufacturer": "IMEON", + "deviceType": "urn:schemas-upnp-org:device:Basic:1", + "st": "upnp:rootdevice" + } + ] +} diff --git a/homeassistant/components/imeon_inverter/quality_scale.yaml b/homeassistant/components/imeon_inverter/quality_scale.yaml new file mode 100644 index 00000000000..6e364977697 --- /dev/null +++ b/homeassistant/components/imeon_inverter/quality_scale.yaml @@ -0,0 +1,71 @@ +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: This integration doesn't have sensors that subscribe to events. + dependency-transparency: done + action-setup: + status: exempt + comment: This integration does not have any service for now. + 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 have any service for now. + brands: done + # Silver + action-exceptions: + status: exempt + comment: This integration does not have any service for now. + 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: todo + 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: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Currently no issues. + stale-devices: + status: exempt + comment: Device type integration. + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/imeon_inverter/sensor.py b/homeassistant/components/imeon_inverter/sensor.py new file mode 100644 index 00000000000..b7a01c3cf17 --- /dev/null +++ b/homeassistant/components/imeon_inverter/sensor.py @@ -0,0 +1,464 @@ +"""Imeon inverter sensor support.""" + +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import InverterCoordinator + +type InverterConfigEntry = ConfigEntry[InverterCoordinator] + +_LOGGER = logging.getLogger(__name__) + + +ENTITY_DESCRIPTIONS = ( + # Battery + SensorEntityDescription( + key="battery_autonomy", + translation_key="battery_autonomy", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="battery_charge_time", + translation_key="battery_charge_time", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="battery_power", + translation_key="battery_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="battery_soc", + translation_key="battery_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="battery_stored", + translation_key="battery_stored", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.TOTAL, + ), + # Grid + SensorEntityDescription( + key="grid_current_l1", + translation_key="grid_current_l1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_current_l2", + translation_key="grid_current_l2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_current_l3", + translation_key="grid_current_l3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_frequency", + translation_key="grid_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_voltage_l1", + translation_key="grid_voltage_l1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_voltage_l2", + translation_key="grid_voltage_l2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_voltage_l3", + translation_key="grid_voltage_l3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + # AC Input + SensorEntityDescription( + key="input_power_l1", + translation_key="input_power_l1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="input_power_l2", + translation_key="input_power_l2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="input_power_l3", + translation_key="input_power_l3", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="input_power_total", + translation_key="input_power_total", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # Inverter settings + SensorEntityDescription( + key="inverter_charging_current_limit", + translation_key="inverter_charging_current_limit", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="inverter_injection_power_limit", + translation_key="inverter_injection_power_limit", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # Meter + SensorEntityDescription( + key="meter_power", + translation_key="meter_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="meter_power_protocol", + translation_key="meter_power_protocol", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # AC Output + SensorEntityDescription( + key="output_current_l1", + translation_key="output_current_l1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_current_l2", + translation_key="output_current_l2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_current_l3", + translation_key="output_current_l3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_frequency", + translation_key="output_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_l1", + translation_key="output_power_l1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_l2", + translation_key="output_power_l2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_l3", + translation_key="output_power_l3", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_power_total", + translation_key="output_power_total", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_voltage_l1", + translation_key="output_voltage_l1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_voltage_l2", + translation_key="output_voltage_l2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="output_voltage_l3", + translation_key="output_voltage_l3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + # Solar Panel + SensorEntityDescription( + key="pv_consumed", + translation_key="pv_consumed", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="pv_injected", + translation_key="pv_injected", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="pv_power_1", + translation_key="pv_power_1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pv_power_2", + translation_key="pv_power_2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pv_power_total", + translation_key="pv_power_total", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + # Temperature + SensorEntityDescription( + key="temp_air_temperature", + translation_key="temp_air_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="temp_component_temperature", + translation_key="temp_component_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + # Monitoring (data over the last 24 hours) + SensorEntityDescription( + key="monitoring_building_consumption", + translation_key="monitoring_building_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_economy_factor", + translation_key="monitoring_economy_factor", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_grid_consumption", + translation_key="monitoring_grid_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_grid_injection", + translation_key="monitoring_grid_injection", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_grid_power_flow", + translation_key="monitoring_grid_power_flow", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_self_consumption", + translation_key="monitoring_self_consumption", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_self_sufficiency", + translation_key="monitoring_self_sufficiency", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_solar_production", + translation_key="monitoring_solar_production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + # Monitoring (instant minute data) + SensorEntityDescription( + key="monitoring_minute_building_consumption", + translation_key="monitoring_minute_building_consumption", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_grid_consumption", + translation_key="monitoring_minute_grid_consumption", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_grid_injection", + translation_key="monitoring_minute_grid_injection", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_grid_power_flow", + translation_key="monitoring_minute_grid_power_flow", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="monitoring_minute_solar_production", + translation_key="monitoring_minute_solar_production", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: InverterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create each sensor for a given config entry.""" + + coordinator = entry.runtime_data + + # Init sensor entities + async_add_entities( + InverterSensor(coordinator, entry, description) + for description in ENTITY_DESCRIPTIONS + ) + + +class InverterSensor(CoordinatorEntity[InverterCoordinator], SensorEntity): + """A sensor that returns numerical values with units.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: InverterCoordinator, + entry: InverterConfigEntry, + description: SensorEntityDescription, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self.entity_description = description + self._inverter = coordinator.api.inverter + self.data_key = description.key + assert entry.unique_id + self._attr_unique_id = f"{entry.unique_id}_{self.data_key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.unique_id)}, + name="Imeon inverter", + manufacturer="Imeon Energy", + model=self._inverter.get("inverter"), + sw_version=self._inverter.get("software"), + ) + + @property + def native_value(self) -> StateType | None: + """Value of the sensor.""" + return self.coordinator.data.get(self.data_key) diff --git a/homeassistant/components/imeon_inverter/strings.json b/homeassistant/components/imeon_inverter/strings.json new file mode 100644 index 00000000000..218e1c4e4aa --- /dev/null +++ b/homeassistant/components/imeon_inverter/strings.json @@ -0,0 +1,187 @@ +{ + "config": { + "flow_title": "Imeon {model} ({serial})", + "step": { + "user": { + "title": "Add Imeon inverter", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP of your inverter", + "username": "The username of your OS One account", + "password": "The password of your OS One account" + } + } + }, + "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%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "invalid_route": "Unable to request the API, make sure 'API Module' is enabled on your device", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "sensor": { + "battery_autonomy": { + "name": "Battery autonomy" + }, + "battery_charge_time": { + "name": "Battery charge time" + }, + "battery_power": { + "name": "Battery power" + }, + "battery_soc": { + "name": "Battery state of charge" + }, + "battery_stored": { + "name": "Battery stored" + }, + "grid_current_l1": { + "name": "Grid current L1" + }, + "grid_current_l2": { + "name": "Grid current L2" + }, + "grid_current_l3": { + "name": "Grid current L3" + }, + "grid_frequency": { + "name": "Grid frequency" + }, + "grid_voltage_l1": { + "name": "Grid voltage L1" + }, + "grid_voltage_l2": { + "name": "Grid voltage L2" + }, + "grid_voltage_l3": { + "name": "Grid voltage L3" + }, + "input_power_l1": { + "name": "Input power L1" + }, + "input_power_l2": { + "name": "Input power L2" + }, + "input_power_l3": { + "name": "Input power L3" + }, + "input_power_total": { + "name": "Input power total" + }, + "inverter_charging_current_limit": { + "name": "Charging current limit" + }, + "inverter_injection_power_limit": { + "name": "Injection power limit" + }, + "meter_power": { + "name": "Meter power" + }, + "meter_power_protocol": { + "name": "Meter power protocol" + }, + "output_current_l1": { + "name": "Output current L1" + }, + "output_current_l2": { + "name": "Output current L2" + }, + "output_current_l3": { + "name": "Output current L3" + }, + "output_frequency": { + "name": "Output frequency" + }, + "output_power_l1": { + "name": "Output power L1" + }, + "output_power_l2": { + "name": "Output power L2" + }, + "output_power_l3": { + "name": "Output power L3" + }, + "output_power_total": { + "name": "Output power total" + }, + "output_voltage_l1": { + "name": "Output voltage L1" + }, + "output_voltage_l2": { + "name": "Output voltage L2" + }, + "output_voltage_l3": { + "name": "Output voltage L3" + }, + "pv_consumed": { + "name": "PV consumed" + }, + "pv_injected": { + "name": "PV injected" + }, + "pv_power_1": { + "name": "PV power 1" + }, + "pv_power_2": { + "name": "PV power 2" + }, + "pv_power_total": { + "name": "PV power total" + }, + "temp_air_temperature": { + "name": "Air temperature" + }, + "temp_component_temperature": { + "name": "Component temperature" + }, + "monitoring_building_consumption": { + "name": "Monitoring building consumption" + }, + "monitoring_economy_factor": { + "name": "Monitoring economy factor" + }, + "monitoring_grid_consumption": { + "name": "Monitoring grid consumption" + }, + "monitoring_grid_injection": { + "name": "Monitoring grid injection" + }, + "monitoring_grid_power_flow": { + "name": "Monitoring grid power flow" + }, + "monitoring_self_consumption": { + "name": "Monitoring self-consumption" + }, + "monitoring_self_sufficiency": { + "name": "Monitoring self-sufficiency" + }, + "monitoring_solar_production": { + "name": "Monitoring solar production" + }, + "monitoring_minute_building_consumption": { + "name": "Monitoring building consumption (minute)" + }, + "monitoring_minute_grid_consumption": { + "name": "Monitoring grid consumption (minute)" + }, + "monitoring_minute_grid_injection": { + "name": "Monitoring grid injection (minute)" + }, + "monitoring_minute_grid_power_flow": { + "name": "Monitoring grid power flow (minute)" + }, + "monitoring_minute_solar_production": { + "name": "Monitoring solar production (minute)" + } + } + } +} diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py index f9524316570..4bceee51f8e 100644 --- a/homeassistant/components/imgw_pib/__init__.py +++ b/homeassistant/components/imgw_pib/__init__.py @@ -38,7 +38,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b hydrological_details=False, ) except (ClientError, TimeoutError, ApiError) as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "entry": entry.title, + "error": repr(err), + }, + ) from err coordinator = ImgwPibDataUpdateCoordinator(hass, entry, imgwpib, station_id) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/imgw_pib/config_flow.py b/homeassistant/components/imgw_pib/config_flow.py index 558528fcbef..805bfa2ccb3 100644 --- a/homeassistant/components/imgw_pib/config_flow.py +++ b/homeassistant/components/imgw_pib/config_flow.py @@ -50,7 +50,7 @@ class ImgwPibFlowHandler(ConfigFlow, domain=DOMAIN): hydrological_data = await imgwpib.get_hydrological_data() except (ClientError, TimeoutError, ApiError): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/imgw_pib/coordinator.py b/homeassistant/components/imgw_pib/coordinator.py index fbe470ca953..f74878d672c 100644 --- a/homeassistant/components/imgw_pib/coordinator.py +++ b/homeassistant/components/imgw_pib/coordinator.py @@ -63,4 +63,11 @@ class ImgwPibDataUpdateCoordinator(DataUpdateCoordinator[HydrologicalData]): try: return await self.imgwpib.get_hydrological_data() except ApiError as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(err), + }, + ) from err diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 0ecc1b4b7d0..e2d6e2bf584 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", - "requirements": ["imgw_pib==1.0.9"] + "quality_scale": "silver", + "requirements": ["imgw_pib==1.0.10"] } diff --git a/homeassistant/components/imgw_pib/quality_scale.yaml b/homeassistant/components/imgw_pib/quality_scale.yaml new file mode 100644 index 00000000000..6634c915255 --- /dev/null +++ b/homeassistant/components/imgw_pib/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register services. + 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 does not register services. + 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: The integration does not register services. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + 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: The integration is a cloud service and thus does not support discovery. + discovery: + status: exempt + comment: The 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: + status: exempt + comment: This is a service, which doesn't integrate with any devices. + docs-supported-functions: todo + docs-troubleshooting: + status: exempt + comment: No known issues that could be resolved by the user. + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration has a fixed single service. + 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: done + reconfiguration-flow: + status: exempt + comment: Only parameter that could be changed station_id would force a new config entry. + 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/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 33b82bbb43b..7871006b2ae 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -24,7 +24,8 @@ from .const import DOMAIN from .coordinator import ImgwPibConfigEntry, ImgwPibDataUpdateCoordinator from .entity import ImgwPibEntity -PARALLEL_UPDATES = 1 +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 9a17dcf7087..9b7f132da6f 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "station_id": "Hydrological station" + }, + "data_description": { + "station_id": "Select a hydrological station from the list." } } }, @@ -13,7 +16,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "cannot_connect": "Failed to connect" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, "entity": { @@ -25,5 +28,13 @@ "name": "Water temperature" } } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while connecting to the IMGW-PIB API for {entry}: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the IMGW-PIB API for {entry}: {error}" + } } } diff --git a/homeassistant/components/immich/__init__.py b/homeassistant/components/immich/__init__.py new file mode 100644 index 00000000000..18782ec6fd3 --- /dev/null +++ b/homeassistant/components/immich/__init__.py @@ -0,0 +1,56 @@ +"""The Immich integration.""" + +from __future__ import annotations + +from aioimmich import Immich +from aioimmich.const import CONNECT_ERRORS +from aioimmich.exceptions import ImmichUnauthorizedError + +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import ImmichConfigEntry, ImmichDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool: + """Set up Immich from a config entry.""" + + session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) + immich = Immich( + session, + entry.data[CONF_API_KEY], + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_SSL], + ) + + try: + user_info = await immich.users.async_get_my_user() + except ImmichUnauthorizedError as err: + raise ConfigEntryAuthFailed from err + except CONNECT_ERRORS as err: + raise ConfigEntryNotReady from err + + coordinator = ImmichDataUpdateCoordinator(hass, entry, immich, user_info.is_admin) + 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: ImmichConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/immich/config_flow.py b/homeassistant/components/immich/config_flow.py new file mode 100644 index 00000000000..69fae3ff1eb --- /dev/null +++ b/homeassistant/components/immich/config_flow.py @@ -0,0 +1,174 @@ +"""Config flow for the Immich integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from aioimmich import Immich +from aioimmich.const import CONNECT_ERRORS +from aioimmich.exceptions import ImmichUnauthorizedError +from aioimmich.users.models import ImmichUser +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DEFAULT_VERIFY_SSL, DOMAIN + + +class InvalidUrl(HomeAssistantError): + """Error to indicate invalid URL.""" + + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Required(CONF_API_KEY): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } +) + + +def _parse_url(url: str) -> tuple[str, int, bool]: + """Parse the URL and return host, port, and ssl.""" + parsed_url = URL(url) + if ( + (host := parsed_url.host) is None + or (port := parsed_url.port) is None + or (scheme := parsed_url.scheme) is None + ): + raise InvalidUrl + return host, port, scheme == "https" + + +async def check_user_info( + hass: HomeAssistant, host: str, port: int, ssl: bool, verify_ssl: bool, api_key: str +) -> ImmichUser: + """Test connection and fetch own user info.""" + session = async_get_clientsession(hass, verify_ssl) + immich = Immich(session, api_key, host, port, ssl) + return await immich.users.async_get_my_user() + + +class ImmichConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Immich.""" + + VERSION = 1 + + _name: str + _current_data: Mapping[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: + try: + (host, port, ssl) = _parse_url(user_input[CONF_URL]) + except InvalidUrl: + errors[CONF_URL] = "invalid_url" + else: + try: + my_user_info = await check_user_info( + self.hass, + host, + port, + ssl, + user_input[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + except ImmichUnauthorizedError: + errors["base"] = "invalid_auth" + except CONNECT_ERRORS: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(my_user_info.user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=my_user_info.name, + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: ssl, + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + + 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: + """Trigger a reauthentication flow.""" + self._current_data = entry_data + self._name = entry_data[CONF_HOST] + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthorization flow.""" + errors = {} + + if user_input is not None: + try: + my_user_info = await check_user_info( + self.hass, + self._current_data[CONF_HOST], + self._current_data[CONF_PORT], + self._current_data[CONF_SSL], + self._current_data[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + except ImmichUnauthorizedError: + errors["base"] = "invalid_auth" + except CONNECT_ERRORS: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(my_user_info.user_id) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + description_placeholders={"name": self._name}, + errors=errors, + ) diff --git a/homeassistant/components/immich/const.py b/homeassistant/components/immich/const.py new file mode 100644 index 00000000000..47180967a67 --- /dev/null +++ b/homeassistant/components/immich/const.py @@ -0,0 +1,7 @@ +"""Constants for the Immich integration.""" + +DOMAIN = "immich" + +DEFAULT_PORT = 2283 +DEFAULT_USE_SSL = False +DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/immich/coordinator.py b/homeassistant/components/immich/coordinator.py new file mode 100644 index 00000000000..e1904a62e24 --- /dev/null +++ b/homeassistant/components/immich/coordinator.py @@ -0,0 +1,74 @@ +"""Coordinator for the Immich integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from aioimmich import Immich +from aioimmich.const import CONNECT_ERRORS +from aioimmich.exceptions import ImmichUnauthorizedError +from aioimmich.server.models import ( + ImmichServerAbout, + ImmichServerStatistics, + ImmichServerStorage, +) + +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 = logging.getLogger(__name__) + + +@dataclass +class ImmichData: + """Data class for storing data from the API.""" + + server_about: ImmichServerAbout + server_storage: ImmichServerStorage + server_usage: ImmichServerStatistics | None + + +type ImmichConfigEntry = ConfigEntry[ImmichDataUpdateCoordinator] + + +class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]): + """Class to manage fetching IMGW-PIB data API.""" + + config_entry: ImmichConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, api: Immich, is_admin: bool + ) -> None: + """Initialize the data update coordinator.""" + self.api = api + self.is_admin = is_admin + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=60), + ) + + async def _async_update_data(self) -> ImmichData: + """Update data via internal method.""" + try: + server_about = await self.api.server.async_get_about_info() + server_storage = await self.api.server.async_get_storage_info() + server_usage = ( + await self.api.server.async_get_server_statistics() + if self.is_admin + else None + ) + except ImmichUnauthorizedError as err: + raise ConfigEntryAuthFailed from err + except CONNECT_ERRORS as err: + raise UpdateFailed from err + + return ImmichData(server_about, server_storage, server_usage) diff --git a/homeassistant/components/immich/diagnostics.py b/homeassistant/components/immich/diagnostics.py new file mode 100644 index 00000000000..c44e24d8202 --- /dev/null +++ b/homeassistant/components/immich/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for immich.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.core import HomeAssistant + +from .coordinator import ImmichConfigEntry + +TO_REDACT = {CONF_API_KEY, CONF_HOST} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ImmichConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "data": asdict(coordinator.data), + } diff --git a/homeassistant/components/immich/entity.py b/homeassistant/components/immich/entity.py new file mode 100644 index 00000000000..f99f8872ce5 --- /dev/null +++ b/homeassistant/components/immich/entity.py @@ -0,0 +1,27 @@ +"""Base entity for the Immich integration.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ImmichDataUpdateCoordinator + + +class ImmichEntity(CoordinatorEntity[ImmichDataUpdateCoordinator]): + """Define immich base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ImmichDataUpdateCoordinator, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer="Immich", + sw_version=coordinator.data.server_about.version, + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/immich/icons.json b/homeassistant/components/immich/icons.json new file mode 100644 index 00000000000..15bac6370a6 --- /dev/null +++ b/homeassistant/components/immich/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "disk_usage": { + "default": "mdi:database" + }, + "photos_count": { + "default": "mdi:file-image" + }, + "videos_count": { + "default": "mdi:file-video" + } + } + } +} diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json new file mode 100644 index 00000000000..bb8cbe720fd --- /dev/null +++ b/homeassistant/components/immich/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "immich", + "name": "Immich", + "codeowners": ["@mib1185"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/immich", + "iot_class": "local_polling", + "loggers": ["aioimmich"], + "quality_scale": "silver", + "requirements": ["aioimmich==0.5.0"] +} diff --git a/homeassistant/components/immich/quality_scale.yaml b/homeassistant/components/immich/quality_scale.yaml new file mode 100644 index 00000000000..053d51eb8c7 --- /dev/null +++ b/homeassistant/components/immich/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: done + comment: No integration specific 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: No integration specific 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: + status: done + comment: No integration specific actions + 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: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + 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: Only one device entry per config entry + entity-category: todo + 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 issues needed + stale-devices: + status: exempt + comment: Only one device entry per config entry + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/immich/sensor.py b/homeassistant/components/immich/sensor.py new file mode 100644 index 00000000000..f8eeed2935a --- /dev/null +++ b/homeassistant/components/immich/sensor.py @@ -0,0 +1,147 @@ +"""Sensor platform for the Immich integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import ImmichConfigEntry, ImmichData, ImmichDataUpdateCoordinator +from .entity import ImmichEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class ImmichSensorEntityDescription(SensorEntityDescription): + """Immich sensor entity description.""" + + value: Callable[[ImmichData], StateType] + is_suitable: Callable[[ImmichData], bool] = lambda _: True + + +SENSOR_TYPES: tuple[ImmichSensorEntityDescription, ...] = ( + ImmichSensorEntityDescription( + key="disk_size", + translation_key="disk_size", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_storage.disk_size_raw, + ), + ImmichSensorEntityDescription( + key="disk_available", + translation_key="disk_available", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_storage.disk_available_raw, + ), + ImmichSensorEntityDescription( + key="disk_use", + translation_key="disk_use", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_storage.disk_use_raw, + entity_registry_enabled_default=False, + ), + ImmichSensorEntityDescription( + key="disk_usage", + translation_key="disk_usage", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_storage.disk_usage_percentage, + entity_registry_enabled_default=False, + ), + ImmichSensorEntityDescription( + key="photos_count", + translation_key="photos_count", + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_usage.photos if data.server_usage else None, + is_suitable=lambda data: data.server_usage is not None, + ), + ImmichSensorEntityDescription( + key="videos_count", + translation_key="videos_count", + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.server_usage.videos if data.server_usage else None, + is_suitable=lambda data: data.server_usage is not None, + ), + ImmichSensorEntityDescription( + key="usage_by_photos", + translation_key="usage_by_photos", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda d: d.server_usage.usage_photos if d.server_usage else None, + is_suitable=lambda data: data.server_usage is not None, + entity_registry_enabled_default=False, + ), + ImmichSensorEntityDescription( + key="usage_by_videos", + translation_key="usage_by_videos", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=1, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda d: d.server_usage.usage_videos if d.server_usage else None, + is_suitable=lambda data: data.server_usage is not None, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ImmichConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add immich server state sensors.""" + coordinator = entry.runtime_data + async_add_entities( + ImmichSensorEntity(coordinator, description) + for description in SENSOR_TYPES + if description.is_suitable(coordinator.data) + ) + + +class ImmichSensorEntity(ImmichEntity, SensorEntity): + """Define Immich sensor entity.""" + + entity_description: ImmichSensorEntityDescription + + def __init__( + self, + coordinator: ImmichDataUpdateCoordinator, + description: ImmichSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/immich/strings.json b/homeassistant/components/immich/strings.json new file mode 100644 index 00000000000..875eb79f50b --- /dev/null +++ b/homeassistant/components/immich/strings.json @@ -0,0 +1,73 @@ +{ + "common": { + "data_desc_url": "The full URL of your immich instance.", + "data_desc_api_key": "API key to connect to your immich instance.", + "data_desc_ssl_verify": "Whether to verify the SSL certificate when SSL encryption is used to connect to your immich instance." + }, + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "[%key:component::immich::common::data_desc_url%]", + "api_key": "[%key:component::immich::common::data_desc_api_key%]", + "verify_ssl": "[%key:component::immich::common::data_desc_ssl_verify%]" + } + }, + "reauth_confirm": { + "description": "Update the API key for {name}.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::immich::common::data_desc_api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_url": "The provided URL is invalid.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The provided API key does not match the configured user.", + "already_configured": "This user is already configured for this immich instance." + } + }, + "entity": { + "sensor": { + "disk_size": { + "name": "Disk size" + }, + "disk_available": { + "name": "Disk available" + }, + "disk_use": { + "name": "Disk used" + }, + "disk_usage": { + "name": "Disk usage" + }, + "photos_count": { + "name": "Photos count", + "unit_of_measurement": "photos" + }, + "videos_count": { + "name": "Videos count", + "unit_of_measurement": "videos" + }, + "usage_by_photos": { + "name": "Disk used by photos" + }, + "usage_by_videos": { + "name": "Disk used by videos" + } + } + } +} diff --git a/homeassistant/components/improv_ble/__init__.py b/homeassistant/components/improv_ble/__init__.py index 985684cb5b8..ff40b65a8d0 100644 --- a/homeassistant/components/improv_ble/__init__.py +++ b/homeassistant/components/improv_ble/__init__.py @@ -1 +1,11 @@ """The Improv BLE integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up improv_ble from a config entry.""" + raise NotImplementedError diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 22f2bf3623c..0dcefba6428 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -83,12 +83,9 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_info = self._discovered_devices[address] return await self.async_step_start_improv() - current_addresses = self._async_current_ids() for discovery in bluetooth.async_discovered_service_info(self.hass): - if ( - discovery.address in current_addresses - or discovery.address in self._discovered_devices - or not device_filter(discovery.advertisement) + if discovery.address in self._discovered_devices or not device_filter( + discovery.advertisement ): continue self._discovered_devices[discovery.address] = discovery @@ -364,6 +361,18 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): assert self._provision_result is not None result = self._provision_result + if result["type"] == "abort" and result["reason"] in ( + "provision_successful", + "provision_successful_url", + ): + # Delete ignored config entry, if it exists + address = self.context["unique_id"] + current_entries = self._async_current_entries(include_ignore=True) + for entry in current_entries: + if entry.unique_id == address: + _LOGGER.debug("Removing ignored entry: %s", entry) + await self.hass.config_entries.async_remove(entry.entry_id) + break self._provision_result = None return result diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index d44ba15507e..c10cbe5be5b 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -12,7 +12,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, EntityCategory, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -43,7 +43,6 @@ async def async_setup_entry( 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 diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index 875bc25bd2f..027c3ad4691 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from incomfortclient import InvalidGateway, InvalidHeaterList @@ -31,6 +32,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import InComfortConfigEntry, async_connect_gateway +_LOGGER = logging.getLogger(__name__) TITLE = "Intergas InComfort/Intouch Lan2RF gateway" CONFIG_SCHEMA = vol.Schema( @@ -88,7 +90,8 @@ async def async_try_connect_gateway( return {"base": "no_heaters"} except TimeoutError: return {"base": "timeout_error"} - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return {"base": "unknown"} return None diff --git a/homeassistant/components/incomfort/icons.json b/homeassistant/components/incomfort/icons.json index 6e33ac75eee..56ba6f545de 100644 --- a/homeassistant/components/incomfort/icons.json +++ b/homeassistant/components/incomfort/icons.json @@ -32,6 +32,7 @@ "sensor_test": "mdi:thermometer-check", "central_heating": "mdi:radiator", "standby": "mdi:water-boiler-off", + "off": "mdi:water-boiler-off", "postrun_boyler": "mdi:water-boiler-auto", "service": "mdi:progress-wrench", "tapwater": "mdi:faucet", diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index d02b1d27554..6214eb03f40 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -10,5 +10,6 @@ "documentation": "https://www.home-assistant.io/integrations/incomfort", "iot_class": "local_polling", "loggers": ["incomfortclient"], - "requirements": ["incomfort-client==0.6.7"] + "quality_scale": "platinum", + "requirements": ["incomfort-client==0.6.8"] } diff --git a/homeassistant/components/incomfort/quality_scale.yaml b/homeassistant/components/incomfort/quality_scale.yaml new file mode 100644 index 00000000000..f5af3c9d061 --- /dev/null +++ b/homeassistant/components/incomfort/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No 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 actions implemented. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + comment: | + Entities are set up dand updated through the datacoordimator. + 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: done + 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: exempt + comment: > + There is a maximum of 3 heaters that can be discovered by the gateway. + The user must remove manually any heeater devices that have been replaced. + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: done + discovery-update-info: done + repair-issues: + status: exempt + comment: | + No current issues to repair. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: + status: done + comment: There are no known limmitations, + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 73ba88078a8..40673a67609 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -9,9 +9,9 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "Hostname or IP-address of the Intergas gateway.", - "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 gateway or is `intergas` for some older devices." + "host": "Hostname or IP address of the Intergas gateway.", + "username": "The username to log in to the gateway. This is `admin` in most cases.", + "password": "The password to log in to the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." } }, "dhcp_auth": { @@ -22,8 +22,8 @@ "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 Gateway or is `intergas` for some older devices." + "username": "[%key:component::incomfort::config::step::user::data_description::username%]", + "password": "[%key:component::incomfort::config::step::user::data_description::password%]" } }, "dhcp_confirm": { @@ -49,7 +49,7 @@ "auth_error": "Invalid credentials.", "no_heaters": "No heaters found.", "not_found": "No gateway found.", - "timeout_error": "Time out when connecting to the gateway.", + "timeout_error": "Timeout when connecting to the gateway.", "unknown": "Unknown error when connecting to the gateway." } }, @@ -118,14 +118,15 @@ "tapwater_int": "Tap water internal", "sensor_test": "Sensor test", "central_heating": "Central heating", - "standby": "Stand-by", + "standby": "[%key:common::state::standby%]", + "off": "[%key:common::state::off%]", "postrun_boyler": "Post run boiler", "service": "Service", "tapwater": "Tap water", "postrun_ch": "Post run central heating", "boiler_int": "Boiler internal", "buffer": "Buffer", - "sensor_fault_after_self_check_e0": "Sensor fault after self check", + "sensor_fault_after_self_check_e0": "Sensor fault after self-check", "cv_temperature_too_high_e1": "Temperature too high", "s1_and_s2_interchanged_e2": "S1 and S2 interchanged", "no_flame_signal_e4": "No flame signal", @@ -142,7 +143,7 @@ "sensor_fault_s2_e22": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s2_e20%]", "sensor_fault_s2_e23": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s2_e20%]", "sensor_fault_s2_e24": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s2_e20%]", - "shortcut_outside_sensor_temperature_e27": "Shortcut outside sensor temperature", + "shortcut_outside_sensor_temperature_e27": "Shortcut outside temperature sensor", "gas_valve_relay_faulty_e29": "Gas valve relay faulty", "gas_valve_relay_faulty_e30": "[%key:component::incomfort::entity::water_heater::boiler::state::gas_valve_relay_faulty_e29%]" } diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index 95a94cf8fa0..d0cf7c3f8c9 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -338,7 +338,7 @@ def get_influx_connection( # noqa: C901 conf, test_write=False, test_read=False ) -> InfluxClient: """Create the correct influx connection for the API version.""" - kwargs = { + kwargs: dict[str, Any] = { CONF_TIMEOUT: TIMEOUT, } precision = conf.get(CONF_PRECISION) diff --git a/homeassistant/components/influxdb/manifest.json b/homeassistant/components/influxdb/manifest.json index 55af2b37fb7..fbc6560899a 100644 --- a/homeassistant/components/influxdb/manifest.json +++ b/homeassistant/components/influxdb/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["influxdb", "influxdb_client"], "quality_scale": "legacy", - "requirements": ["influxdb==5.3.1", "influxdb-client==1.24.0"] + "requirements": ["influxdb==5.3.1", "influxdb-client==1.48.0"] } diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 9dd058e841a..8daa94f2f6d 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -2,65 +2,36 @@ from __future__ import annotations -import logging +from typing import Any -from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate - -from homeassistant.components.bluetooth import ( - BluetoothScanningMode, - BluetoothServiceInfo, -) -from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothProcessorCoordinator, -) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant -from .const import CONF_DEVICE_TYPE, DOMAIN +from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE +from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator + +INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator] 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: INKBIRDConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" - address = entry.unique_id - assert address is not None + assert entry.unique_id is not None device_type: str | None = entry.data.get(CONF_DEVICE_TYPE) - data = INKBIRDBluetoothDeviceData(device_type) - - @callback - def _async_on_update(service_info: BluetoothServiceInfo) -> SensorUpdate: - """Handle update callback from the passive BLE processor.""" - nonlocal device_type - update = data.update(service_info) - if device_type is None and data.device_type is not None: - device_type_str = str(data.device_type) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_DEVICE_TYPE: device_type_str} - ) - device_type = device_type_str - return update - - coordinator = PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=_async_on_update, + device_data: dict[str, Any] | None = entry.data.get(CONF_DEVICE_DATA) + coordinator = INKBIRDActiveBluetoothProcessorCoordinator( + hass, entry, device_type, device_data ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await coordinator.async_init() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # only start after all platforms have had a chance to subscribe entry.async_on_unload(coordinator.async_start()) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> 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/inkbird/config_flow.py b/homeassistant/components/inkbird/config_flow.py index 09dd31a9cf6..9ce20baaeda 100644 --- a/homeassistant/components/inkbird/config_flow.py +++ b/homeassistant/components/inkbird/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.components.bluetooth import ( from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS -from .const import DOMAIN +from .const import CONF_DEVICE_TYPE, DOMAIN class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): @@ -26,7 +26,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize the config flow.""" self._discovery_info: BluetoothServiceInfoBleak | None = None self._discovered_device: DeviceData | None = None - self._discovered_devices: dict[str, str] = {} + self._discovered_devices: dict[str, tuple[str, str]] = {} async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -51,7 +51,10 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): discovery_info = self._discovery_info title = device.title or device.get_device_name() or discovery_info.name if user_input is not None: - return self.async_create_entry(title=title, data={}) + return self.async_create_entry( + title=title, + data={CONF_DEVICE_TYPE: str(self._discovered_device.device_type)}, + ) self._set_confirm_only() placeholders = {"name": title} @@ -68,8 +71,9 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): address = user_input[CONF_ADDRESS] await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() + title, device_type = self._discovered_devices[address] return self.async_create_entry( - title=self._discovered_devices[address], data={} + title=title, data={CONF_DEVICE_TYPE: device_type} ) current_addresses = self._async_current_ids(include_ignore=False) @@ -80,7 +84,8 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): device = DeviceData() if device.supported(discovery_info): self._discovered_devices[address] = ( - device.title or device.get_device_name() or discovery_info.name + device.title or device.get_device_name() or discovery_info.name, + str(device.device_type), ) if not self._discovered_devices: diff --git a/homeassistant/components/inkbird/const.py b/homeassistant/components/inkbird/const.py index 93fdcc7519c..b20e1af8de1 100644 --- a/homeassistant/components/inkbird/const.py +++ b/homeassistant/components/inkbird/const.py @@ -3,3 +3,4 @@ DOMAIN = "inkbird" CONF_DEVICE_TYPE = "device_type" +CONF_DEVICE_DATA = "device_data" diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py new file mode 100644 index 00000000000..fbacedf7e0f --- /dev/null +++ b/homeassistant/components/inkbird/coordinator.py @@ -0,0 +1,136 @@ +"""The INKBIRD Bluetooth integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +from typing import Any + +from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfo, + BluetoothServiceInfoBleak, + async_ble_device_from_address, + async_last_service_info, +) +from homeassistant.components.bluetooth.active_update_processor import ( + ActiveBluetoothProcessorCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.event import async_track_time_interval + +from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +FALLBACK_POLL_INTERVAL = timedelta(seconds=180) + + +class INKBIRDActiveBluetoothProcessorCoordinator( + ActiveBluetoothProcessorCoordinator[SensorUpdate] +): + """Coordinator for INKBIRD Bluetooth devices.""" + + _data: INKBIRDBluetoothDeviceData + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + device_type: str | None, + device_data: dict[str, Any] | None, + ) -> None: + """Initialize the INKBIRD Bluetooth processor coordinator.""" + self._entry = entry + self._device_type = device_type + self._device_data = device_data + address = entry.unique_id + assert address is not None + super().__init__( + hass=hass, + logger=_LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=self._async_on_update, + needs_poll_method=self._async_needs_poll, + poll_method=self._async_poll_data, + connectable=False, # Polling only happens if active scanning is disabled + ) + + async def async_init(self) -> None: + """Initialize the coordinator.""" + self._data = INKBIRDBluetoothDeviceData( + self._device_type, + self._device_data, + self.async_set_updated_data, + self._async_device_data_changed, + ) + if not self._data.uses_notify: + self._entry.async_on_unload( + async_track_time_interval( + self.hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL + ) + ) + return + if not (service_info := async_last_service_info(self.hass, self.address)): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="no_advertisement", + translation_placeholders={"address": self.address}, + ) + await self._data.async_start(service_info, service_info.device) + self._entry.async_on_unload(self._data.async_stop) + + async def _async_poll_data( + self, last_service_info: BluetoothServiceInfoBleak + ) -> SensorUpdate: + """Poll the device.""" + return await self._data.async_poll(last_service_info.device) + + @callback + def _async_needs_poll( + self, service_info: BluetoothServiceInfoBleak, last_poll: float | None + ) -> bool: + return ( + not self.hass.is_stopping + and self._data.poll_needed(service_info, last_poll) + and bool( + async_ble_device_from_address( + self.hass, service_info.device.address, connectable=True + ) + ) + ) + + @callback + def _async_device_data_changed(self, new_device_data: dict[str, Any]) -> None: + """Handle device data changed.""" + self.hass.config_entries.async_update_entry( + self._entry, data={**self._entry.data, CONF_DEVICE_DATA: new_device_data} + ) + + @callback + def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate: + """Handle update callback from the passive BLE processor.""" + update = self._data.update(service_info) + if ( + self._entry.data.get(CONF_DEVICE_TYPE) is None + and self._data.device_type is not None + ): + device_type_str = str(self._data.device_type) + self.hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str}, + ) + return update + + @callback + def _async_schedule_poll(self, _: datetime) -> None: + """Schedule a poll of the device.""" + if self._last_service_info and self._async_needs_poll( + self._last_service_info, self._last_poll + ): + self._debounced_poll.async_schedule_call() diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 1a251f52582..38d406da62e 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -21,6 +21,31 @@ { "local_name": "tps", "connectable": false + }, + { + "local_name": "ITH-11-B", + "connectable": false + }, + { + "local_name": "ITH-13-B", + "connectable": false + }, + { + "local_name": "ITH-21-B", + "connectable": false + }, + { + "local_name": "IBS-P02B", + "connectable": false + }, + { + "local_name": "Ink@IAM-T1", + "connectable": true + }, + { + "manufacturer_id": 12628, + "manufacturer_data_start": [65, 67, 45], + "connectable": true } ], "codeowners": ["@bdraco"], @@ -28,5 +53,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.7.0"] + "requirements": ["inkbird-ble==0.16.1"] } diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index efda28b110d..c7d80e9bc9f 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -4,12 +4,10 @@ from __future__ import annotations from inkbird_ble import DeviceClass, DeviceKey, SensorUpdate, Units -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -19,15 +17,17 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfPressure, UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import INKBIRDConfigEntry SENSOR_DESCRIPTIONS = { (DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( @@ -58,6 +58,18 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + (DeviceClass.CO2, Units.CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( + key=f"{DeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + (DeviceClass.PRESSURE, Units.PRESSURE_HPA): SensorEntityDescription( + key=f"{DeviceClass.PRESSURE}_{Units.PRESSURE_HPA}", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + ), } @@ -97,20 +109,17 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: INKBIRDConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the INKBIRD BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( INKBIRDBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload(entry.runtime_data.async_register_processor(processor)) class INKBIRDBluetoothSensorEntity( diff --git a/homeassistant/components/inkbird/strings.json b/homeassistant/components/inkbird/strings.json index 4e12a84b653..b8490dfb92a 100644 --- a/homeassistant/components/inkbird/strings.json +++ b/homeassistant/components/inkbird/strings.json @@ -17,5 +17,10 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "exceptions": { + "no_advertisement": { + "message": "The device with address {address} is not advertising; Make sure it is in range and powered on." + } } } diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 538107dd816..3a15d667ca7 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -111,7 +111,7 @@ }, "services": { "add_all_link": { - "name": "Add all link", + "name": "Add All-Link", "description": "Tells the Insteon Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.", "fields": { "group": { @@ -120,13 +120,13 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "Linking mode controller - IM is controller responder - IM is responder." + "description": "Linking mode of the Insteon Modem." } } }, "delete_all_link": { - "name": "Delete all link", - "description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link Database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.", + "name": "Delete All-Link", + "description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.", "fields": { "group": { "name": "Group", @@ -135,8 +135,8 @@ } }, "load_all_link_database": { - "name": "Load all link database", - "description": "Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.", + "name": "Load All-Link database", + "description": "Loads the All-Link database for a device. WARNING - Loading a device All-Link database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.", "fields": { "entity_id": { "name": "Entity", @@ -149,8 +149,8 @@ } }, "print_all_link_database": { - "name": "Print all link database", - "description": "Prints the All-Link Database for a device. Requires that the All-Link Database is loaded into memory.", + "name": "Print All-Link database", + "description": "Prints the All-Link database for a device. Requires that the All-Link database is loaded into memory.", "fields": { "entity_id": { "name": "Entity", @@ -159,8 +159,8 @@ } }, "print_im_all_link_database": { - "name": "Print IM all link database", - "description": "Prints the All-Link Database for the INSTEON Modem (IM)." + "name": "Print IM All-Link database", + "description": "Prints the All-Link database for the INSTEON Modem (IM)." }, "x10_all_units_off": { "name": "X10 all units off", diff --git a/homeassistant/components/integration/strings.json b/homeassistant/components/integration/strings.json index ed4f5de3ea7..ddd0d42ca39 100644 --- a/homeassistant/components/integration/strings.json +++ b/homeassistant/components/integration/strings.json @@ -18,7 +18,7 @@ "round": "Controls the number of decimal digits in the output.", "unit_prefix": "The output will be scaled according to the selected metric prefix.", "unit_time": "The output will be scaled according to the selected time unit.", - "max_sub_interval": "Applies time based integration if the source did not change for this duration. Use 0 for no time based updates." + "max_sub_interval": "Applies time-based integration if the source did not change for this duration. Use 0 for no time-based updates." } } } diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index cda30820a2f..cc5da82ab92 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -8,7 +8,6 @@ from intellifire4py import UnifiedFireplace from intellifire4py.cloud_interface import IntelliFireCloudInterface from intellifire4py.model import IntelliFireCommonFireplaceData -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -27,12 +26,11 @@ from .const import ( CONF_SERIAL, CONF_USER_ID, CONF_WEB_CLIENT_ID, - DOMAIN, INIT_WAIT_TIME_SECONDS, LOGGER, STARTUP_TIMEOUT, ) -from .coordinator import IntellifireDataUpdateCoordinator +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -45,7 +43,9 @@ PLATFORMS = [ ] -def _construct_common_data(entry: ConfigEntry) -> IntelliFireCommonFireplaceData: +def _construct_common_data( + entry: IntellifireConfigEntry, +) -> IntelliFireCommonFireplaceData: """Convert config entry data into IntelliFireCommonFireplaceData.""" return IntelliFireCommonFireplaceData( @@ -60,7 +60,9 @@ def _construct_common_data(entry: ConfigEntry) -> IntelliFireCommonFireplaceData ) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: IntellifireConfigEntry +) -> bool: """Migrate entries.""" LOGGER.debug( "Migrating configuration from version %s.%s", @@ -105,7 +107,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry) -> bool: """Set up IntelliFire from a config entry.""" if CONF_USERNAME not in entry.data: @@ -133,7 +135,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Fireplace to Initialized - Awaiting first refresh") await data_update_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_update_coordinator + entry.runtime_data = data_update_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -151,9 +153,8 @@ async def _async_wait_for_initialization( await asyncio.sleep(INIT_WAIT_TIME_SECONDS) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: IntellifireConfigEntry +) -> 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/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index 3da1d2e3dc0..7cc22290e3c 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -10,13 +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 AddConfigEntryEntitiesCallback -from . import IntellifireDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator from .entity import IntellifireEntity @@ -151,11 +149,11 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a IntelliFire On/Off Sensor.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( IntellifireBinarySensor(coordinator=coordinator, description=description) diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index f067f2a849d..0af438a7374 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -10,13 +10,12 @@ 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 AddConfigEntryEntitiesCallback -from . import IntellifireDataUpdateCoordinator -from .const import DEFAULT_THERMOSTAT_TEMP, DOMAIN, LOGGER +from .const import DEFAULT_THERMOSTAT_TEMP, LOGGER +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator from .entity import IntellifireEntity INTELLIFIRE_CLIMATES: tuple[ClimateEntityDescription, ...] = ( @@ -26,11 +25,11 @@ INTELLIFIRE_CLIMATES: tuple[ClimateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure the fan entry..""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if coordinator.data.has_thermostat: async_add_entities( diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index 6a23e7438db..dc9aa45d58b 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -16,16 +16,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER +type IntellifireConfigEntry = ConfigEntry[IntellifireDataUpdateCoordinator] + class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntelliFirePollData]): """Class to manage the polling of the fireplace API.""" - config_entry: ConfigEntry + config_entry: IntellifireConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IntellifireConfigEntry, fireplace: UnifiedFireplace, ) -> None: """Initialize the Coordinator.""" diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index 174d964d357..3075a5fb2a8 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -15,7 +15,6 @@ from homeassistant.components.fan import ( FanEntityDescription, FanEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -23,8 +22,8 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import DOMAIN, LOGGER -from .coordinator import IntellifireDataUpdateCoordinator +from .const import LOGGER +from .coordinator import IntellifireConfigEntry from .entity import IntellifireEntity @@ -57,11 +56,11 @@ INTELLIFIRE_FANS: tuple[IntellifireFanEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fans.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if coordinator.data.has_fan: async_add_entities( diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index 0cf5c7774ed..c73614bfade 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -15,12 +15,11 @@ from homeassistant.components.light import ( LightEntity, LightEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LOGGER -from .coordinator import IntellifireDataUpdateCoordinator +from .const import LOGGER +from .coordinator import IntellifireConfigEntry from .entity import IntellifireEntity @@ -84,11 +83,11 @@ class IntellifireLight(IntellifireEntity, LightEntity): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fans.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if coordinator.data.has_light: async_add_entities( diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py index 0776835833e..68097d30b44 100644 --- a/homeassistant/components/intellifire/number.py +++ b/homeassistant/components/intellifire/number.py @@ -9,22 +9,21 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LOGGER -from .coordinator import IntellifireDataUpdateCoordinator +from .const import LOGGER +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator from .entity import IntellifireEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fans.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data description = NumberEntityDescription( key="flame_control", diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index 7763fb1b9b2..287f9a60ca0 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -12,14 +12,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow -from .const import DOMAIN -from .coordinator import IntellifireDataUpdateCoordinator +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator from .entity import IntellifireEntity @@ -142,12 +140,12 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Define setup entry call.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( IntelliFireSensor(coordinator=coordinator, description=description) for description in INTELLIFIRE_SENSORS diff --git a/homeassistant/components/intellifire/strings.json b/homeassistant/components/intellifire/strings.json index 423d2c0788d..7f53cb725b5 100644 --- a/homeassistant/components/intellifire/strings.json +++ b/homeassistant/components/intellifire/strings.json @@ -7,7 +7,7 @@ "description": "Select fireplace by serial number:" }, "cloud_api": { - "description": "Authenticate against IntelliFire Cloud", + "description": "Authenticate against IntelliFire cloud", "data_description": { "username": "Your IntelliFire app username", "password": "Your IntelliFire app password" @@ -45,7 +45,7 @@ "name": "Pilot flame error" }, "flame_error": { - "name": "Flame Error" + "name": "Flame error" }, "fan_delay_error": { "name": "Fan delay error" @@ -104,7 +104,7 @@ "name": "Target temperature" }, "fan_speed": { - "name": "Fan Speed" + "name": "Fan speed" }, "timer_end_timestamp": { "name": "Timer end" diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index 2185ad47cae..a6ab89d6bd7 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -7,12 +7,10 @@ from dataclasses import dataclass from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import IntellifireDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator from .entity import IntellifireEntity @@ -52,11 +50,11 @@ INTELLIFIRE_SWITCHES: tuple[IntellifireSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IntellifireConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure switch entities.""" - coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( IntellifireSwitch(coordinator=coordinator, description=description) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index a1451f8fcca..72853276ab3 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -2,13 +2,20 @@ from __future__ import annotations +from collections.abc import Collection import logging from typing import Any, Protocol from aiohttp import web import voluptuous as vol -from homeassistant.components import http +from homeassistant.components import http, sensor +from homeassistant.components.button import ( + DOMAIN as BUTTON_DOMAIN, + SERVICE_PRESS as SERVICE_PRESS_BUTTON, + ButtonDeviceClass, +) +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN as COVER_DOMAIN, @@ -18,6 +25,7 @@ from homeassistant.components.cover import ( CoverDeviceClass, ) from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, @@ -39,7 +47,12 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State -from homeassistant.helpers import config_validation as cv, integration_platform, intent +from homeassistant.helpers import ( + area_registry as ar, + config_validation as cv, + integration_platform, + intent, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -73,6 +86,7 @@ __all__ = [ ] ONOFF_DEVICE_CLASSES = { + ButtonDeviceClass, CoverDeviceClass, ValveDeviceClass, SwitchDeviceClass, @@ -96,7 +110,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.INTENT_TURN_ON, HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, - description="Turns on/opens a device or entity", + description="Turns on/opens/presses a device or entity. For locks, this performs a 'lock' action. Use for requests like 'turn on', 'activate', 'enable', or 'lock'.", device_classes=ONOFF_DEVICE_CLASSES, ), ) @@ -106,7 +120,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.INTENT_TURN_OFF, HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, - description="Turns off/closes a device or entity", + description="Turns off/closes a device or entity. For locks, this performs an 'unlock' action. Use for requests like 'turn off', 'deactivate', 'disable', or 'unlock'.", device_classes=ONOFF_DEVICE_CLASSES, ), ) @@ -140,6 +154,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, GetCurrentDateIntentHandler()) intent.async_register(hass, GetCurrentTimeIntentHandler()) intent.async_register(hass, RespondIntentHandler()) + intent.async_register(hass, GetTemperatureIntent()) return True @@ -160,6 +175,25 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): """Call service on entity with handling for special cases.""" hass = intent_obj.hass + if state.domain in (BUTTON_DOMAIN, INPUT_BUTTON_DOMAIN): + if service != SERVICE_TURN_ON: + raise intent.IntentHandleError( + f"Entity {state.entity_id} cannot be turned off" + ) + + await self._run_then_background( + hass.async_create_task( + hass.services.async_call( + state.domain, + SERVICE_PRESS_BUTTON, + {ATTR_ENTITY_ID: state.entity_id}, + context=intent_obj.context, + blocking=True, + ) + ) + ) + return + if state.domain == COVER_DOMAIN: # on = open # off = close @@ -444,6 +478,109 @@ class RespondIntentHandler(intent.IntentHandler): return response +class GetTemperatureIntent(intent.IntentHandler): + """Handle GetTemperature intents.""" + + intent_type = intent.INTENT_GET_TEMPERATURE + description = "Gets the current temperature of a climate device or entity" + slot_schema = { + vol.Optional("area"): intent.non_empty_string, + vol.Optional("name"): intent.non_empty_string, + vol.Optional("floor"): intent.non_empty_string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + platforms = {CLIMATE_DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + + area: str | None = None + if "area" in slots: + area = slots["area"]["value"] + + floor_name: str | None = None + if "floor" in slots: + floor_name = slots["floor"]["value"] + + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ) + + if (not name) and (area or match_preferences.area_id): + # Look for temperature sensors assigned to an area + area_registry = ar.async_get(hass) + area_temperature_ids: dict[str, str] = {} + + # Keep candidates that are registered as area temperature sensors + def area_candidate_filter( + candidate: intent.MatchTargetsCandidate, + possible_area_ids: Collection[str], + ) -> bool: + for area_id in possible_area_ids: + temperature_id = area_temperature_ids.get(area_id) + if (temperature_id is None) and ( + area_entry := area_registry.async_get_area(area_id) + ): + temperature_id = area_entry.temperature_entity_id or "" + area_temperature_ids[area_id] = temperature_id + + if candidate.state.entity_id == temperature_id: + return True + + return False + + match_constraints = intent.MatchTargetsConstraints( + area_name=area, + floor_name=floor_name, + domains=[sensor.DOMAIN], + device_classes=[sensor.SensorDeviceClass.TEMPERATURE], + assistant=intent_obj.assistant, + single_target=True, + ) + match_result = intent.async_match_targets( + hass, + match_constraints, + match_preferences, + area_candidate_filter=area_candidate_filter, + ) + if match_result.is_match: + # Found temperature sensor + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_states(matched_states=match_result.states) + return response + + # Look for climate devices + match_constraints = intent.MatchTargetsConstraints( + name=name, + area_name=area, + floor_name=floor_name, + domains=[CLIMATE_DOMAIN], + assistant=intent_obj.assistant, + single_target=True, + ) + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences + ) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_states(matched_states=match_result.states) + return response + + async def _async_process_intent( hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol ) -> None: diff --git a/homeassistant/components/iometer/__init__.py b/homeassistant/components/iometer/__init__.py index bbf046e70e9..feb7ce9b8cf 100644 --- a/homeassistant/components/iometer/__init__.py +++ b/homeassistant/components/iometer/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import IOmeterConfigEntry, IOMeterCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: IOmeterConfigEntry) -> bool: diff --git a/homeassistant/components/iometer/binary_sensor.py b/homeassistant/components/iometer/binary_sensor.py new file mode 100644 index 00000000000..f443c4ae94a --- /dev/null +++ b/homeassistant/components/iometer/binary_sensor.py @@ -0,0 +1,87 @@ +"""IOmeter binary sensor.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import IOMeterCoordinator, IOmeterData +from .entity import IOmeterEntity + + +@dataclass(frozen=True, kw_only=True) +class IOmeterBinarySensorDescription(BinarySensorEntityDescription): + """Describes Iometer binary sensor entity.""" + + value_fn: Callable[[IOmeterData], bool | None] + + +SENSOR_TYPES: list[IOmeterBinarySensorDescription] = [ + IOmeterBinarySensorDescription( + key="connection_status", + translation_key="connection_status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_registry_enabled_default=False, + value_fn=lambda data: ( + data.status.device.core.connection_status == "connected" + if data.status.device.core.connection_status is not None + else None + ), + ), + IOmeterBinarySensorDescription( + key="attachment_status", + translation_key="attachment_status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_registry_enabled_default=False, + value_fn=lambda data: ( + data.status.device.core.attachment_status == "attached" + if data.status.device.core.attachment_status is not None + else None + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sensors.""" + coordinator: IOMeterCoordinator = config_entry.runtime_data + + async_add_entities( + IOmeterBinarySensor( + coordinator=coordinator, + description=description, + ) + for description in SENSOR_TYPES + ) + + +class IOmeterBinarySensor(IOmeterEntity, BinarySensorEntity): + """Defines a IOmeter binary sensor.""" + + entity_description: IOmeterBinarySensorDescription + + def __init__( + self, + coordinator: IOMeterCoordinator, + description: IOmeterBinarySensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.identifier}_{description.key}" + + @property + def is_on(self) -> bool | None: + """Return the binary sensor state.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/iometer/coordinator.py b/homeassistant/components/iometer/coordinator.py index 708983fb28e..4050341151b 100644 --- a/homeassistant/components/iometer/coordinator.py +++ b/homeassistant/components/iometer/coordinator.py @@ -8,6 +8,7 @@ from iometer import IOmeterClient, IOmeterConnectionError, Reading, Status from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -31,6 +32,7 @@ class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): config_entry: IOmeterConfigEntry client: IOmeterClient + current_fw_version: str = "" def __init__( self, @@ -58,4 +60,17 @@ class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): except IOmeterConnectionError as error: raise UpdateFailed(f"Error communicating with IOmeter: {error}") from error + fw_version = f"{status.device.core.version}/{status.device.bridge.version}" + if self.current_fw_version and fw_version != self.current_fw_version: + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, status.device.id)} + ) + assert device_entry + device_registry.async_update_device( + device_entry.id, + sw_version=fw_version, + ) + self.current_fw_version = fw_version + return IOmeterData(reading=reading, status=status) diff --git a/homeassistant/components/iometer/entity.py b/homeassistant/components/iometer/entity.py index 86494857e18..a52ef1c66ed 100644 --- a/homeassistant/components/iometer/entity.py +++ b/homeassistant/components/iometer/entity.py @@ -20,5 +20,5 @@ class IOmeterEntity(CoordinatorEntity[IOMeterCoordinator]): identifiers={(DOMAIN, status.device.id)}, manufacturer="IOmeter GmbH", model="IOmeter", - sw_version=f"{status.device.core.version}/{status.device.bridge.version}", + sw_version=coordinator.current_fw_version, ) diff --git a/homeassistant/components/iometer/strings.json b/homeassistant/components/iometer/strings.json index 31deb16aa9c..65a962cb42b 100644 --- a/homeassistant/components/iometer/strings.json +++ b/homeassistant/components/iometer/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Setup your IOmeter device for local data", + "description": "Set up your IOmeter device for local data", "data": { "host": "[%key:common::config_flow::data::host%]" }, @@ -21,7 +21,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "Unexpected error" + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "entity": { @@ -60,6 +60,14 @@ "wifi_rssi": { "name": "Signal strength Wi-Fi" } + }, + "binary_sensor": { + "connection_status": { + "name": "Core/Bridge connection status" + }, + "attachment_status": { + "name": "Core attachment status" + } } } } diff --git a/homeassistant/components/iotawatt/__init__.py b/homeassistant/components/iotawatt/__init__.py index 8f35d4e0796..1dc38ba01c6 100644 --- a/homeassistant/components/iotawatt/__init__.py +++ b/homeassistant/components/iotawatt/__init__.py @@ -1,26 +1,22 @@ """The iotawatt integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import IotawattUpdater +from .coordinator import IotawattConfigEntry, IotawattUpdater PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IotawattConfigEntry) -> bool: """Set up iotawatt from a config entry.""" coordinator = IotawattUpdater(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: IotawattConfigEntry) -> 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/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py index 13802ebdd76..48d55dad818 100644 --- a/homeassistant/components/iotawatt/coordinator.py +++ b/homeassistant/components/iotawatt/coordinator.py @@ -21,14 +21,16 @@ _LOGGER = logging.getLogger(__name__) # Matches iotwatt data log interval REQUEST_REFRESH_DEFAULT_COOLDOWN = 5 +type IotawattConfigEntry = ConfigEntry[IotawattUpdater] + class IotawattUpdater(DataUpdateCoordinator): """Class to manage fetching update data from the IoTaWatt Energy Device.""" api: Iotawatt | None = None - config_entry: ConfigEntry + config_entry: IotawattConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: IotawattConfigEntry) -> None: """Initialize IotaWattUpdater object.""" super().__init__( hass=hass, diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index f5210f7fbba..591397ad6e7 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfApparentPower, @@ -31,8 +30,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS -from .coordinator import IotawattUpdater +from .const import VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS +from .coordinator import IotawattConfigEntry, IotawattUpdater _LOGGER = logging.getLogger(__name__) @@ -113,11 +112,11 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IotawattConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensors for passed config_entry in HA.""" - coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data created = set() @callback diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index 27b3eac26b5..9ba3b55ed4f 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ATTR_VERSION, DATA_UPDATED, DOMAIN as IPERF3_DOMAIN, SENSOR_TYPES +from . import ATTR_VERSION, DATA_UPDATED, DOMAIN, SENSOR_TYPES ATTR_PROTOCOL = "Protocol" ATTR_REMOTE_HOST = "Remote Server" @@ -29,7 +29,7 @@ async def async_setup_platform( entities = [ Iperf3Sensor(iperf3_host, description) - for iperf3_host in hass.data[IPERF3_DOMAIN].values() + for iperf3_host in hass.data[DOMAIN].values() for description in SENSOR_TYPES if description.key in discovery_info[CONF_MONITORED_CONDITIONS] ] diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py index 68289d13289..6c48ae4c925 100644 --- a/homeassistant/components/ipma/__init__.py +++ b/homeassistant/components/ipma/__init__.py @@ -1,6 +1,7 @@ """Component for the Portuguese weather service - IPMA.""" import asyncio +from dataclasses import dataclass import logging from pyipma import IPMAException @@ -14,7 +15,6 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .config_flow import IpmaFlowHandler # noqa: F401 -from .const import DATA_API, DATA_LOCATION, DOMAIN DEFAULT_NAME = "ipma" @@ -22,8 +22,18 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER] _LOGGER = logging.getLogger(__name__) +type IpmaConfigEntry = ConfigEntry[IpmaRuntimeData] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +@dataclass +class IpmaRuntimeData: + """IPMA runtime data.""" + + api: IPMA_API + location: Location + + +async def async_setup_entry(hass: HomeAssistant, config_entry: IpmaConfigEntry) -> bool: """Set up IPMA station as config entry.""" latitude = config_entry.data[CONF_LATITUDE] @@ -48,20 +58,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b location.global_id_local, ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = {DATA_API: api, DATA_LOCATION: location} + config_entry.runtime_data = IpmaRuntimeData(api=api, location=location) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IpmaConfigEntry) -> bool: """Unload a config entry.""" - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - 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) diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py index dd6f1fba64a..1cb1af17d95 100644 --- a/homeassistant/components/ipma/const.py +++ b/homeassistant/components/ipma/const.py @@ -27,9 +27,6 @@ DOMAIN = "ipma" HOME_LOCATION_NAME = "Home" -DATA_API = "api" -DATA_LOCATION = "location" - ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.ipma_{HOME_LOCATION_NAME}" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) diff --git a/homeassistant/components/ipma/diagnostics.py b/homeassistant/components/ipma/diagnostics.py index 948b69ee3e5..bf868324593 100644 --- a/homeassistant/components/ipma/diagnostics.py +++ b/homeassistant/components/ipma/diagnostics.py @@ -4,20 +4,19 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from .const import DATA_API, DATA_LOCATION, DOMAIN +from . import IpmaConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: IpmaConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - location = hass.data[DOMAIN][entry.entry_id][DATA_LOCATION] - api = hass.data[DOMAIN][entry.entry_id][DATA_API] + location = entry.runtime_data.location + api = entry.runtime_data.api return { "location_information": { diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index 78fd018cf9a..7e71457513b 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -14,12 +14,12 @@ from pyipma.rcm import RCM from pyipma.uv import UV from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle -from .const import DATA_API, DATA_LOCATION, DOMAIN, MIN_TIME_BETWEEN_UPDATES +from . import IpmaConfigEntry +from .const import MIN_TIME_BETWEEN_UPDATES from .entity import IPMADevice _LOGGER = logging.getLogger(__name__) @@ -87,12 +87,12 @@ SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IpmaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the IPMA sensor platform.""" - api = hass.data[DOMAIN][entry.entry_id][DATA_API] - location = hass.data[DOMAIN][entry.entry_id][DATA_LOCATION] + location = entry.runtime_data.location + api = entry.runtime_data.api entities = [IPMASensor(api, location, description) for description in SENSOR_TYPES] diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index d285f9e1ad3..74344da8aff 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -23,7 +23,6 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_MODE, UnitOfPressure, @@ -35,14 +34,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import Throttle -from .const import ( - ATTRIBUTION, - CONDITION_MAP, - DATA_API, - DATA_LOCATION, - DOMAIN, - MIN_TIME_BETWEEN_UPDATES, -) +from . import IpmaConfigEntry +from .const import ATTRIBUTION, CONDITION_MAP, MIN_TIME_BETWEEN_UPDATES from .entity import IPMADevice _LOGGER = logging.getLogger(__name__) @@ -50,12 +43,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IpmaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - api = hass.data[DOMAIN][config_entry.entry_id][DATA_API] - location = hass.data[DOMAIN][config_entry.entry_id][DATA_LOCATION] + location = config_entry.runtime_data.location + api = config_entry.runtime_data.api async_add_entities([IPMAWeather(api, location, config_entry)], True) @@ -72,7 +65,7 @@ class IPMAWeather(WeatherEntity, IPMADevice): ) def __init__( - self, api: IPMA_API, location: Location, config_entry: ConfigEntry + self, api: IPMA_API, location: Location, config_entry: IpmaConfigEntry ) -> None: """Initialise the platform with a data instance and station name.""" IPMADevice.__init__(self, api, location) diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json index ac879ef0ab3..b4c092c8ae3 100644 --- a/homeassistant/components/ipp/strings.json +++ b/homeassistant/components/ipp/strings.json @@ -38,7 +38,7 @@ "state": { "printing": "Printing", "idle": "[%key:common::state::idle%]", - "stopped": "Stopped" + "stopped": "[%key:common::state::stopped%]" } }, "uptime": { diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index 3fabb88b041..ad8b78bf9e3 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -3,25 +3,16 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -from datetime import timedelta -from functools import partial -from typing import Any from pyiqvia import Client -from pyiqvia.errors import IQVIAError -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 aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_ZIP_CODE, - DOMAIN, - LOGGER, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, TYPE_ALLERGY_OUTLOOK, @@ -30,14 +21,14 @@ from .const import ( TYPE_DISEASE_FORECAST, TYPE_DISEASE_INDEX, ) +from .coordinator import IqviaConfigEntry, IqviaUpdateCoordinator DEFAULT_ATTRIBUTION = "Data provided by IQVIA™" -DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IqviaConfigEntry) -> bool: """Set up IQVIA as config entry.""" if not entry.unique_id: # If the config entry doesn't already have a unique ID, set one: @@ -52,15 +43,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # blocking) startup: client.disable_request_retries() - async def async_get_data_from_api( - api_coro: Callable[[], Coroutine[Any, Any, dict[str, Any]]], - ) -> dict[str, Any]: - """Get data from a particular API coroutine.""" - try: - return await api_coro() - except IQVIAError as err: - raise UpdateFailed from err - coordinators = {} init_data_update_tasks = [] @@ -73,13 +55,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: (TYPE_DISEASE_FORECAST, client.disease.extended), (TYPE_DISEASE_INDEX, client.disease.current), ): - coordinator = coordinators[sensor_type] = DataUpdateCoordinator( + coordinator = coordinators[sensor_type] = IqviaUpdateCoordinator( hass, - LOGGER, config_entry=entry, name=f"{entry.data[CONF_ZIP_CODE]} {sensor_type}", - update_interval=DEFAULT_SCAN_INTERVAL, - update_method=partial(async_get_data_from_api, api_coro), + update_method=api_coro, ) init_data_update_tasks.append(coordinator.async_refresh()) @@ -93,18 +73,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Once we've successfully authenticated, we re-enable client request retries: client.enable_request_retries() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = 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: IqviaConfigEntry) -> bool: """Unload an OpenUV 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/iqvia/coordinator.py b/homeassistant/components/iqvia/coordinator.py new file mode 100644 index 00000000000..ef926d1112d --- /dev/null +++ b/homeassistant/components/iqvia/coordinator.py @@ -0,0 +1,49 @@ +"""Support for IQVIA.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from datetime import timedelta +from typing import Any + +from pyiqvia.errors import IQVIAError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +type IqviaConfigEntry = ConfigEntry[dict[str, IqviaUpdateCoordinator]] + + +class IqviaUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Custom DataUpdateCoordinator for IQVIA.""" + + config_entry: IqviaConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: IqviaConfigEntry, + name: str, + update_method: Callable[[], Coroutine[Any, Any, dict[str, Any]]], + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + name=name, + config_entry=config_entry, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._update_method = update_method + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the API.""" + try: + return await self._update_method() + except IQVIAError as err: + raise UpdateFailed from err diff --git a/homeassistant/components/iqvia/diagnostics.py b/homeassistant/components/iqvia/diagnostics.py index 64827f183ff..953d42eafc2 100644 --- a/homeassistant/components/iqvia/diagnostics.py +++ b/homeassistant/components/iqvia/diagnostics.py @@ -5,12 +5,11 @@ 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_UNIQUE_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_ZIP_CODE, DOMAIN +from .const import CONF_ZIP_CODE +from .coordinator import IqviaConfigEntry CONF_CITY = "City" CONF_DISPLAY_LOCATION = "DisplayLocation" @@ -33,19 +32,15 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: IqviaConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinators: dict[str, DataUpdateCoordinator[dict[str, Any]]] = hass.data[DOMAIN][ - entry.entry_id - ] - return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), "data": async_redact_data( { data_type: coordinator.data - for data_type, coordinator in coordinators.items() + for data_type, coordinator in entry.runtime_data.items() }, TO_REDACT, ), diff --git a/homeassistant/components/iqvia/entity.py b/homeassistant/components/iqvia/entity.py index e77c0f7e32a..04e92ef9c4d 100644 --- a/homeassistant/components/iqvia/entity.py +++ b/homeassistant/components/iqvia/entity.py @@ -2,28 +2,23 @@ from __future__ import annotations -from typing import Any - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_ZIP_CODE, DOMAIN, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_OUTLOOK +from .const import CONF_ZIP_CODE, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_OUTLOOK +from .coordinator import IqviaConfigEntry, IqviaUpdateCoordinator -class IQVIAEntity(CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]]): +class IQVIAEntity(CoordinatorEntity[IqviaUpdateCoordinator]): """Define a base IQVIA entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[dict[str, Any]], - entry: ConfigEntry, + coordinator: IqviaUpdateCoordinator, + entry: IqviaConfigEntry, description: EntityDescription, ) -> None: """Initialize.""" @@ -49,9 +44,9 @@ class IQVIAEntity(CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]]): if self.entity_description.key == TYPE_ALLERGY_FORECAST: self.async_on_remove( - self.hass.data[DOMAIN][self._entry.entry_id][ - TYPE_ALLERGY_OUTLOOK - ].async_add_listener(self._handle_coordinator_update) + self._entry.runtime_data[TYPE_ALLERGY_OUTLOOK].async_add_listener( + self._handle_coordinator_update + ) ) self.update_from_latest_data() diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 64492c634e9..8b838d35ea1 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -12,13 +12,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, TYPE_ALLERGY_OUTLOOK, @@ -32,6 +30,7 @@ from .const import ( TYPE_DISEASE_INDEX, TYPE_DISEASE_TODAY, ) +from .coordinator import IqviaConfigEntry from .entity import IQVIAEntity ATTR_ALLERGEN_AMOUNT = "allergen_amount" @@ -128,13 +127,13 @@ INDEX_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IqviaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up IQVIA sensors based on a config entry.""" sensors: list[ForecastSensor | IndexSensor] = [ ForecastSensor( - hass.data[DOMAIN][entry.entry_id][ + entry.runtime_data[ API_CATEGORY_MAPPING.get(description.key, description.key) ], entry, @@ -145,7 +144,7 @@ async def async_setup_entry( sensors.extend( [ IndexSensor( - hass.data[DOMAIN][entry.entry_id][ + entry.runtime_data[ API_CATEGORY_MAPPING.get(description.key, description.key) ], entry, @@ -207,9 +206,7 @@ class ForecastSensor(IQVIAEntity, SensorEntity): ) if self.entity_description.key == TYPE_ALLERGY_FORECAST: - outlook_coordinator = self.hass.data[DOMAIN][self._entry.entry_id][ - TYPE_ALLERGY_OUTLOOK - ] + outlook_coordinator = self._entry.runtime_data[TYPE_ALLERGY_OUTLOOK] if not outlook_coordinator.last_update_success: return diff --git a/homeassistant/components/iqvia/strings.json b/homeassistant/components/iqvia/strings.json index 5dc0dea53d5..a0697a6c210 100644 --- a/homeassistant/components/iqvia/strings.json +++ b/homeassistant/components/iqvia/strings.json @@ -4,7 +4,7 @@ "user": { "description": "Fill out your U.S. or Canadian ZIP code.", "data": { - "zip_code": "ZIP Code" + "zip_code": "ZIP code" } } }, diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index 77099e48b41..7a0cf8eaa53 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -7,10 +7,8 @@ from typing import TYPE_CHECKING from pynecil import IronOSUpdate, Pynecil -from homeassistant.components import bluetooth -from homeassistant.const import CONF_NAME, Platform +from homeassistant.const import 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 @@ -35,7 +33,6 @@ PLATFORMS: list[Platform] = [ Platform.UPDATE, ] - CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -60,17 +57,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo """Set up IronOS from a config entry.""" if TYPE_CHECKING: assert entry.unique_id - ble_device = bluetooth.async_ble_device_from_address( - hass, entry.unique_id, connectable=True - ) - if not ble_device: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_device_unavailable_exception", - translation_placeholders={CONF_NAME: entry.title}, - ) - device = Pynecil(ble_device) + device = Pynecil(entry.unique_id) live_data = IronOSLiveDataCoordinator(hass, entry, device) await live_data.async_config_entry_first_refresh() diff --git a/homeassistant/components/iron_os/config_flow.py b/homeassistant/components/iron_os/config_flow.py index 8509577114f..bb80f088c96 100644 --- a/homeassistant/components/iron_os/config_flow.py +++ b/homeassistant/components/iron_os/config_flow.py @@ -2,9 +2,12 @@ from __future__ import annotations +import logging from typing import Any +from bleak.exc import BleakError from habluetooth import BluetoothServiceInfoBleak +from pynecil import CommunicationError, Pynecil import voluptuous as vol from homeassistant.components.bluetooth.api import async_discovered_service_info @@ -13,6 +16,8 @@ from homeassistant.const import CONF_ADDRESS from .const import DISCOVERY_SVC_UUID, DOMAIN +_LOGGER = logging.getLogger(__name__) + class IronOSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for IronOS.""" @@ -36,30 +41,62 @@ class IronOSConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm discovery.""" + + errors: dict[str, str] = {} + assert self._discovery_info is not None discovery_info = self._discovery_info title = discovery_info.name if user_input is not None: - return self.async_create_entry(title=title, data={}) + device = Pynecil(discovery_info.address) + try: + await device.connect() + except (CommunicationError, BleakError, TimeoutError): + _LOGGER.debug("Cannot connect:", exc_info=True) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception:") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=title, data={}) + finally: + await device.disconnect() self._set_confirm_only() placeholders = {"name": title} self.context["title_placeholders"] = placeholders return self.async_show_form( - step_id="bluetooth_confirm", description_placeholders=placeholders + step_id="bluetooth_confirm", + description_placeholders=placeholders, + errors=errors, ) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the user step to pick discovered device.""" + + errors: dict[str, str] = {} + if user_input is not None: address = user_input[CONF_ADDRESS] title = self._discovered_devices[address] await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() - return self.async_create_entry(title=title, data={}) + device = Pynecil(address) + try: + await device.connect() + except (CommunicationError, BleakError, TimeoutError): + _LOGGER.debug("Cannot connect:", exc_info=True) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=title, data={}) + finally: + await device.disconnect() current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, True): @@ -80,4 +117,5 @@ class IronOSConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} ), + errors=errors, ) diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index fc89ecea43c..99c688ea855 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -6,8 +6,9 @@ from dataclasses import dataclass from datetime import timedelta from enum import Enum import logging -from typing import cast +from typing import TYPE_CHECKING, cast +from awesomeversion import AwesomeVersion from pynecil import ( CharSetting, CommunicationError, @@ -24,6 +25,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.debounce import Debouncer +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -34,6 +37,8 @@ SCAN_INTERVAL = timedelta(seconds=5) SCAN_INTERVAL_GITHUB = timedelta(hours=3) SCAN_INTERVAL_SETTINGS = timedelta(seconds=60) +V223 = AwesomeVersion("v2.23") + @dataclass class IronOSCoordinators: @@ -72,14 +77,20 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): ), ) self.device = device + self.v223_features = False 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 + except (CommunicationError, TimeoutError): + self.device_info = DeviceInfoResponse() + + self.v223_features = ( + self.device_info.build is not None + and AwesomeVersion(self.device_info.build) >= V223 + ) class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): @@ -90,19 +101,18 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): ) -> None: """Initialize IronOS coordinator.""" super().__init__(hass, config_entry, device, SCAN_INTERVAL) + self.device_info = DeviceInfoResponse() async def _async_update_data(self) -> LiveDataResponse: """Fetch data from Device.""" try: - # device info is cached and won't be refetched on every - # coordinator refresh, only after the device has disconnected - # the device info is refetched - self.device_info = await self.device.get_device_info() + await self._update_device_info() return await self.device.get_live_data() - except CommunicationError as e: - raise UpdateFailed("Cannot connect to device") from e + except CommunicationError: + _LOGGER.debug("Cannot connect to device", exc_info=True) + return self.data or LiveDataResponse() @property def has_tip(self) -> bool: @@ -115,6 +125,32 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): return self.data.live_temp <= threshold return False + async def _update_device_info(self) -> None: + """Update device info. + + device info is cached and won't be refetched on every + coordinator refresh, only after the device has disconnected + the device info is refetched. + """ + build = self.device_info.build + self.device_info = await self.device.get_device_info() + + if build == self.device_info.build: + return + device_registry = dr.async_get(self.hass) + if TYPE_CHECKING: + assert self.config_entry.unique_id + device = device_registry.async_get_device( + connections={(CONNECTION_BLUETOOTH, self.config_entry.unique_id)} + ) + if device is None: + return + device_registry.async_update_device( + device_id=device.id, + sw_version=self.device_info.build, + serial_number=f"{self.device_info.device_sn} (ID:{self.device_info.device_id})", + ) + class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]): """IronOS coordinator.""" @@ -181,4 +217,7 @@ class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): try: return await self.github.latest_release() except UpdateException as e: - raise UpdateFailed("Failed to check for latest IronOS update") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_check_failed", + ) from e diff --git a/homeassistant/components/iron_os/entity.py b/homeassistant/components/iron_os/entity.py index 190a9f33639..d07ad5a3aa1 100644 --- a/homeassistant/components/iron_os/entity.py +++ b/homeassistant/components/iron_os/entity.py @@ -37,6 +37,16 @@ class IronOSBaseEntity(CoordinatorEntity[IronOSLiveDataCoordinator]): manufacturer=MANUFACTURER, model=MODEL, name="Pinecil", - sw_version=coordinator.device_info.build, - serial_number=f"{coordinator.device_info.device_sn} (ID:{coordinator.device_info.device_id})", ) + if coordinator.device_info.is_synced: + self._attr_device_info.update( + DeviceInfo( + sw_version=coordinator.device_info.build, + serial_number=f"{coordinator.device_info.device_sn} (ID:{coordinator.device_info.device_id})", + ) + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.device.is_connected diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index 6410c561b9d..695b9d16849 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -73,6 +73,9 @@ }, "power_limit": { "default": "mdi:flash-alert" + }, + "hall_effect_sleep_time": { + "default": "mdi:timer-sand" } }, "select": { @@ -105,6 +108,9 @@ }, "usb_pd_mode": { "default": "mdi:meter-electric-outline" + }, + "tip_type": { + "default": "mdi:pencil-outline" } }, "sensor": { @@ -154,7 +160,16 @@ "soldering": "mdi:soldering-iron", "sleeping": "mdi:sleep", "settings": "mdi:menu-open", - "debug": "mdi:bug-play" + "debug": "mdi:bug-play", + "soldering_profile": "mdi:chart-box-outline", + "temperature_adjust": "mdi:thermostat-box", + "usb_pd_debug": "mdi:bug-play", + "thermal_runaway": "mdi:fire-alert", + "startup_logo": "mdi:dots-circle", + "cjc_calibration": "mdi:tune-vertical", + "startup_warnings": "mdi:alert", + "initialisation_done": "mdi:check-circle", + "hibernating": "mdi:sleep" } }, "estimated_power": { diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 462e75c5b6e..58cbdaa3bc6 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -13,5 +13,6 @@ "documentation": "https://www.home-assistant.io/integrations/iron_os", "iot_class": "local_polling", "loggers": ["pynecil"], - "requirements": ["pynecil==4.0.1"] + "quality_scale": "platinum", + "requirements": ["pynecil==4.1.0"] } diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index b8bb3c7d999..6ad5947cb6f 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -65,6 +65,7 @@ class PinecilNumber(StrEnum): VOLTAGE_DIV = "voltage_div" TEMP_INCREMENT_SHORT = "temp_increment_short" TEMP_INCREMENT_LONG = "temp_increment_long" + HALL_EFFECT_SLEEP_TIME = "hall_effect_sleep_time" def multiply(value: float | None, multiplier: float) -> float | None: @@ -323,6 +324,23 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( ), ) +PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = ( + IronOSNumberEntityDescription( + key=PinecilNumber.HALL_EFFECT_SLEEP_TIME, + translation_key=PinecilNumber.HALL_EFFECT_SLEEP_TIME, + value_fn=(lambda _, settings: settings.get("hall_sleep_time")), + characteristic=CharSetting.HALL_SLEEP_TIME, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=60, + native_step=5, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_registry_enabled_default=False, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -331,10 +349,13 @@ async def async_setup_entry( ) -> None: """Set up number entities from a config entry.""" coordinators = entry.runtime_data + descriptions = PINECIL_NUMBER_DESCRIPTIONS + + if coordinators.live_data.v223_features: + descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223 async_add_entities( - IronOSNumberEntity(coordinators, description) - for description in PINECIL_NUMBER_DESCRIPTIONS + IronOSNumberEntity(coordinators, description) for description in descriptions ) diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml index c80b8b5adfe..0a405726231 100644 --- a/homeassistant/components/iron_os/quality_scale.yaml +++ b/homeassistant/components/iron_os/quality_scale.yaml @@ -21,8 +21,10 @@ rules: entity-unique-id: done has-entity-name: done runtime-data: done - test-before-configure: todo - test-before-setup: todo + test-before-configure: done + test-before-setup: + status: exempt + comment: Device is expected to be disconnected most of the time but will connect quickly when reachable unique-config-entry: done # Silver @@ -45,8 +47,8 @@ rules: 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. + status: done + comment: Device is not connected to an ip network. FW version in device info is updated. discovery: done docs-data-update: done docs-examples: done @@ -70,7 +72,9 @@ rules: repair-issues: status: exempt comment: no repairs/issues - stale-devices: todo + stale-devices: + status: exempt + comment: Stale devices are removed with the config entry as there is only one device per entry # Platinum async-dependency: done diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py index a005bf29af2..32652829531 100644 --- a/homeassistant/components/iron_os/select.py +++ b/homeassistant/components/iron_os/select.py @@ -17,6 +17,7 @@ from pynecil import ( ScrollSpeed, SettingsDataResponse, TempUnit, + TipType, USBPDMode, ) @@ -53,6 +54,7 @@ class PinecilSelect(StrEnum): LOCKING_MODE = "locking_mode" LOGO_DURATION = "logo_duration" USB_PD_MODE = "usb_pd_mode" + TIP_TYPE = "tip_type" def enum_to_str(enum: Enum | None) -> str | None: @@ -138,6 +140,8 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), +) +PINECIL_SELECT_DESCRIPTIONS_V222: tuple[IronOSSelectEntityDescription, ...] = ( IronOSSelectEntityDescription( key=PinecilSelect.USB_PD_MODE, translation_key=PinecilSelect.USB_PD_MODE, @@ -149,6 +153,27 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = ( entity_registry_enabled_default=False, ), ) +PINECIL_SELECT_DESCRIPTIONS_V223: tuple[IronOSSelectEntityDescription, ...] = ( + 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=[x.name.lower() for x in USBPDMode], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSSelectEntityDescription( + key=PinecilSelect.TIP_TYPE, + translation_key=PinecilSelect.TIP_TYPE, + characteristic=CharSetting.TIP_TYPE, + value_fn=lambda x: enum_to_str(x.get("tip_type")), + raw_value_fn=lambda value: TipType[value.upper()], + options=[x.name.lower() for x in TipType], + entity_category=EntityCategory.CONFIG, + ), +) async def async_setup_entry( @@ -157,11 +182,17 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities from a config entry.""" - coordinator = entry.runtime_data + coordinators = entry.runtime_data + descriptions = PINECIL_SELECT_DESCRIPTIONS + + descriptions += ( + PINECIL_SELECT_DESCRIPTIONS_V223 + if coordinators.live_data.v223_features + else PINECIL_SELECT_DESCRIPTIONS_V222 + ) async_add_entities( - IronOSSelectEntity(coordinator, description) - for description in PINECIL_SELECT_DESCRIPTIONS + IronOSSelectEntity(coordinators, description) for description in descriptions ) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 60168699427..8a3d9cc5366 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -20,7 +20,13 @@ }, "abort": { "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "entity": { @@ -94,6 +100,9 @@ }, "temp_increment_long": { "name": "Long-press temperature step" + }, + "hall_effect_sleep_time": { + "name": "Hall sensor sleep timeout" } }, "select": { @@ -112,7 +121,7 @@ "state": { "right_handed": "Right-handed", "left_handed": "Left-handed", - "auto": "Auto" + "auto": "[%key:common::state::auto%]" } }, "animation_speed": { @@ -120,7 +129,7 @@ "state": { "off": "[%key:common::state::off%]", "slow": "[%key:component::iron_os::common::slow%]", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "fast": "[%key:component::iron_os::common::fast%]" } }, @@ -173,6 +182,15 @@ "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } + }, + "tip_type": { + "name": "Soldering tip type", + "state": { + "auto": "Auto sense", + "ts100_long": "TS100 long/Hakko T12 tip", + "pine_short": "Pinecil short tip", + "pts200": "PTS200 short tip" + } } }, "sensor": { @@ -223,7 +241,16 @@ "sleeping": "Sleeping", "settings": "Settings", "debug": "Debug", - "boost": "Boost" + "boost": "Boost", + "soldering_profile": "Soldering profile", + "temperature_adjust": "Temperature adjust", + "usb_pd_debug": "USB PD debug", + "thermal_runaway": "Thermal runaway", + "startup_logo": "Booting", + "cjc_calibration": "CJC calibration", + "startup_warnings": "Startup warnings", + "initialisation_done": "Initialisation done", + "hibernating": "Hibernating" } }, "estimated_power": { @@ -255,14 +282,14 @@ } }, "exceptions": { - "setup_device_unavailable_exception": { - "message": "Device {name} is not reachable" - }, - "setup_device_connection_error_exception": { - "message": "Connection to device {name} failed, try again later" - }, "submit_setting_failed": { "message": "Failed to submit setting to device, try again later" + }, + "cannot_connect": { + "message": "Cannot connect to device {name}" + }, + "update_check_failed": { + "message": "Failed to check for latest IronOS update" } } } diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index 4ec626ffc2a..fba60a8ddaf 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.components.update import ( + ATTR_INSTALLED_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, @@ -10,6 +11,7 @@ from homeassistant.components.update import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import IRON_OS_KEY, IronOSConfigEntry, IronOSLiveDataCoordinator from .coordinator import IronOSFirmwareUpdateCoordinator @@ -37,7 +39,7 @@ async def async_setup_entry( ) -class IronOSUpdate(IronOSBaseEntity, UpdateEntity): +class IronOSUpdate(IronOSBaseEntity, UpdateEntity, RestoreEntity): """Representation of an IronOS update entity.""" _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES @@ -56,7 +58,7 @@ class IronOSUpdate(IronOSBaseEntity, UpdateEntity): def installed_version(self) -> str | None: """IronOS version on the device.""" - return self.coordinator.device_info.build + return self.coordinator.device_info.build or self._attr_installed_version @property def title(self) -> str | None: @@ -86,6 +88,9 @@ class IronOSUpdate(IronOSBaseEntity, UpdateEntity): Register extra update listener for the firmware update coordinator. """ + if state := await self.async_get_last_state(): + self._attr_installed_version = state.attributes.get(ATTR_INSTALLED_VERSION) + await super().async_added_to_hass() self.async_on_remove( self.firmware_update.async_add_listener(self._handle_coordinator_update) diff --git a/homeassistant/components/ista_ecotrend/__init__.py b/homeassistant/components/ista_ecotrend/__init__.py index 4262b354acb..e39850d6c51 100644 --- a/homeassistant/components/ista_ecotrend/__init__.py +++ b/homeassistant/components/ista_ecotrend/__init__.py @@ -4,11 +4,11 @@ from __future__ import annotations import logging -from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerError +from pyecotrend_ista import PyEcotrendIsta +from homeassistant.components.recorder import get_instance from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN from .coordinator import IstaConfigEntry, IstaCoordinator @@ -25,19 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool entry.data[CONF_PASSWORD], _LOGGER, ) - try: - await hass.async_add_executor_job(ista.login) - except ServerError as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="connection_exception", - ) from e - except (LoginError, KeycloakError) as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="authentication_exception", - translation_placeholders={CONF_EMAIL: entry.data[CONF_EMAIL]}, - ) from e coordinator = IstaCoordinator(hass, entry, ista) await coordinator.async_config_entry_first_refresh() @@ -52,3 +39,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> None: + """Handle removal of an entry.""" + statistic_ids = [f"{DOMAIN}:{name}" for name in entry.options.values()] + get_instance(hass).async_clear_statistics(statistic_ids) diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py index 1a3b2109d0c..ee69e52e580 100644 --- a/homeassistant/components/ista_ecotrend/config_flow.py +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerError 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_EMAIL, CONF_NAME, CONF_PASSWORD from homeassistant.helpers.selector import ( TextSelector, @@ -93,15 +93,30 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): """Dialog that informs the user that reauth is required.""" errors: dict[str, str] = {} - reauth_entry = self._get_reauth_entry() + reauth_entry = ( + self._get_reauth_entry() + if self.source == SOURCE_REAUTH + else self._get_reconfigure_entry() + ) if user_input is not None: ista = PyEcotrendIsta( user_input[CONF_EMAIL], user_input[CONF_PASSWORD], _LOGGER, ) + + def get_consumption_units() -> set[str]: + ista.login() + consumption_units = ista.get_consumption_unit_details()[ + "consumptionUnits" + ] + return {unit["id"] for unit in consumption_units} + try: - await self.hass.async_add_executor_job(ista.login) + consumption_units = await self.hass.async_add_executor_job( + get_consumption_units + ) + except ServerError: errors["base"] = "cannot_connect" except (LoginError, KeycloakError): @@ -110,10 +125,12 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + if reauth_entry.unique_id not in consumption_units: + return self.async_abort(reason="unique_id_mismatch") return self.async_update_reload_and_abort(reauth_entry, data=user_input) return self.async_show_form( - step_id="reauth_confirm", + step_id="reauth_confirm" if self.source == SOURCE_REAUTH else "reconfigure", data_schema=self.add_suggested_values_to_schema( data_schema=STEP_USER_DATA_SCHEMA, suggested_values={ @@ -128,3 +145,9 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): }, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure flow for ista EcoTrend integration.""" + return await self.async_step_reauth_confirm(user_input) diff --git a/homeassistant/components/ista_ecotrend/coordinator.py b/homeassistant/components/ista_ecotrend/coordinator.py index 53ef4a46d20..13167b9d06c 100644 --- a/homeassistant/components/ista_ecotrend/coordinator.py +++ b/homeassistant/components/ista_ecotrend/coordinator.py @@ -11,7 +11,7 @@ from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerErr 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 @@ -25,6 +25,7 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Ista EcoTrend data update coordinator.""" config_entry: IstaConfigEntry + details: dict[str, Any] def __init__( self, hass: HomeAssistant, config_entry: IstaConfigEntry, ista: PyEcotrendIsta @@ -38,22 +39,35 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=timedelta(days=1), ) self.ista = ista - self.details: dict[str, Any] = {} + + async def _async_setup(self) -> None: + """Set up the ista EcoTrend coordinator.""" + + try: + self.details = await self.hass.async_add_executor_job(self.get_details) + except ServerError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_exception", + ) from e + except (LoginError, KeycloakError) as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_exception", + translation_placeholders={ + CONF_EMAIL: self.config_entry.data[CONF_EMAIL] + }, + ) from e async def _async_update_data(self): """Fetch ista EcoTrend data.""" try: - await self.hass.async_add_executor_job(self.ista.login) - - if not self.details: - self.details = await self.async_get_details() - return await self.hass.async_add_executor_job(self.get_consumption_data) - except ServerError as e: raise UpdateFailed( - "Unable to connect and retrieve data from ista EcoTrend, try again later" + translation_domain=DOMAIN, + translation_key="connection_exception", ) from e except (LoginError, KeycloakError) as e: raise ConfigEntryAuthFailed( @@ -67,17 +81,17 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): def get_consumption_data(self) -> dict[str, Any]: """Get raw json data for all consumption units.""" + self.ista.login() return { consumption_unit: self.ista.get_consumption_data(consumption_unit) for consumption_unit in self.ista.get_uuids() } - async def async_get_details(self) -> dict[str, Any]: + def get_details(self) -> dict[str, Any]: """Retrieve details of consumption units.""" - result = await self.hass.async_add_executor_job( - self.ista.get_consumption_unit_details - ) + self.ista.login() + result = self.ista.get_consumption_unit_details() return { consumption_unit: next( diff --git a/homeassistant/components/ista_ecotrend/diagnostics.py b/homeassistant/components/ista_ecotrend/diagnostics.py new file mode 100644 index 00000000000..4c61c197b5e --- /dev/null +++ b/homeassistant/components/ista_ecotrend/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics platform for ista EcoTrend integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import IstaConfigEntry + +TO_REDACT = { + "firstName", + "lastName", + "street", + "houseNumber", + "documentNumber", + "postalCode", + "city", + "propertyNumber", + "idAtCustomerUser", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: IstaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "details": async_redact_data(config_entry.runtime_data.details, TO_REDACT), + "data": async_redact_data(config_entry.runtime_data.data, TO_REDACT), + } diff --git a/homeassistant/components/ista_ecotrend/quality_scale.yaml b/homeassistant/components/ista_ecotrend/quality_scale.yaml index b942ecba487..a06aef7297f 100644 --- a/homeassistant/components/ista_ecotrend/quality_scale.yaml +++ b/homeassistant/components/ista_ecotrend/quality_scale.yaml @@ -5,12 +5,8 @@ rules: 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, + common-modules: done + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: @@ -47,7 +43,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: The integration is a web service, there are no discoverable devices. @@ -70,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py index ee54e502c26..0a8ed6e9ddb 100644 --- a/homeassistant/components/ista_ecotrend/sensor.py +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -8,6 +8,7 @@ import datetime from enum import StrEnum import logging +from homeassistant.components.recorder.models import StatisticMeanType from homeassistant.components.recorder.models.statistics import ( StatisticData, StatisticMetaData, @@ -270,7 +271,7 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity): ] metadata: StatisticMetaData = { - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": f"{self.device_entry.name} {self.name}", "source": DOMAIN, diff --git a/homeassistant/components/ista_ecotrend/strings.json b/homeassistant/components/ista_ecotrend/strings.json index e7c37461b19..389612c40e7 100644 --- a/homeassistant/components/ista_ecotrend/strings.json +++ b/homeassistant/components/ista_ecotrend/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "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.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -32,6 +34,18 @@ "email": "[%key:component::ista_ecotrend::config::step::user::data_description::email%]", "password": "[%key:component::ista_ecotrend::config::step::user::data_description::password%]" } + }, + "reconfigure": { + "title": "Update ista EcoTrend configuration", + "description": "Update your credentials if you have changed your **ista EcoTrend** account email or password.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::ista_ecotrend::config::step::user::data_description::email%]", + "password": "[%key:component::ista_ecotrend::config::step::user::data_description::password%]" + } } } }, diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 738c7e2d5ad..bed86b2d0fe 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -10,7 +10,6 @@ from pyisy import ISY, ISYConnectionError, ISYInvalidAuthError, ISYResponseParse from pyisy.constants import CONFIG_NETWORKING, CONFIG_PORTAL import voluptuous as vol -from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -46,7 +45,7 @@ from .const import ( SCHEME_HTTPS, ) from .helpers import _categorize_nodes, _categorize_programs -from .models import IsyData +from .models import IsyConfigEntry, IsyData from .services import async_setup_services, async_unload_services from .util import _async_cleanup_registry_entries @@ -56,13 +55,8 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: """Set up the ISY 994 integration.""" - hass.data.setdefault(DOMAIN, {}) - isy_data = hass.data[DOMAIN][entry.entry_id] = IsyData() - isy_config = entry.data isy_options = entry.options @@ -127,6 +121,7 @@ async def async_setup_entry( f"Invalid response ISY, device is likely still starting: {err}" ) from err + isy_data = entry.runtime_data = IsyData() _categorize_nodes(isy_data, isy.nodes, ignore_identifier, sensor_identifier) _categorize_programs(isy_data, isy.programs) # Gather ISY Variables to be added. @@ -138,7 +133,7 @@ async def async_setup_entry( for vtype, _, vid in isy.variables.children: numbers.append(isy.variables[vtype][vid]) if ( - isy.conf[CONFIG_NETWORKING] or isy.conf[CONFIG_PORTAL] + isy.conf[CONFIG_NETWORKING] or isy.conf.get(CONFIG_PORTAL) ) and isy.networking.nobjs: isy_data.devices[CONF_NETWORK] = _create_service_device_info( isy, name=CONFIG_NETWORKING, unique_id=CONF_NETWORK @@ -156,7 +151,7 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Clean-up any old entities that we no longer provide. - _async_cleanup_registry_entries(hass, entry.entry_id) + _async_cleanup_registry_entries(hass, entry) @callback def _async_stop_auto_update(event: Event) -> None: @@ -178,16 +173,14 @@ async def async_setup_entry( return True -async def _async_update_listener( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: IsyConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) @callback def _async_get_or_create_isy_device_in_registry( - hass: HomeAssistant, entry: config_entries.ConfigEntry, isy: ISY + hass: HomeAssistant, entry: IsyConfigEntry, isy: ISY ) -> None: device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -221,34 +214,25 @@ def _create_service_device_info(isy: ISY, name: str, unique_id: str) -> DeviceIn ) -async def async_unload_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - isy_data = hass.data[DOMAIN][entry.entry_id] - - isy: ISY = isy_data.root - _LOGGER.debug("ISY Stopping Event Stream and automatic updates") - isy.websocket.stop() + entry.runtime_data.root.websocket.stop() - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - async_unload_services(hass) + if not hass.config_entries.async_loaded_entries(DOMAIN): + async_unload_services(hass) return unload_ok async def async_remove_config_entry_device( hass: HomeAssistant, - config_entry: config_entries.ConfigEntry, + config_entry: IsyConfigEntry, device_entry: dr.DeviceEntry, ) -> bool: """Remove ISY config entry from a device.""" - isy_data = hass.data[DOMAIN][config_entry.entry_id] return not device_entry.identifiers.intersection( - (DOMAIN, unique_id) for unique_id in isy_data.devices + (DOMAIN, unique_id) for unique_id in config_entry.runtime_data.devices ) diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 8c9ce7dcc12..d452b5bacef 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -19,7 +19,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -31,7 +30,6 @@ from .const import ( _LOGGER, BINARY_SENSOR_DEVICE_TYPES_ISY, BINARY_SENSOR_DEVICE_TYPES_ZWAVE, - DOMAIN, SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_HEAT, SUBNODE_DUSK_DAWN, @@ -44,7 +42,7 @@ from .const import ( TYPE_INSTEON_MOTION, ) from .entity import ISYNodeEntity, ISYProgramEntity -from .models import IsyData +from .models import IsyConfigEntry DEVICE_PARENT_REQUIRED = [ BinarySensorDeviceClass.OPENING, @@ -55,7 +53,7 @@ DEVICE_PARENT_REQUIRED = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY binary sensor platform.""" @@ -82,8 +80,8 @@ async def async_setup_entry( | ISYBinarySensorProgramEntity ) - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices for node in isy_data.nodes[Platform.BINARY_SENSOR]: assert isinstance(node, Node) device_info = devices.get(node.primary_node) diff --git a/homeassistant/components/isy994/button.py b/homeassistant/components/isy994/button.py index a895312c45a..cfb077c7dc0 100644 --- a/homeassistant/components/isy994/button.py +++ b/homeassistant/components/isy994/button.py @@ -15,24 +15,23 @@ from pyisy.networking import NetworkCommand from pyisy.nodes import Node from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_NETWORK, DOMAIN -from .models import IsyData +from .models import IsyConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ISY/IoX button from config entry.""" - isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] - isy: ISY = isy_data.root + isy_data = config_entry.runtime_data + isy = isy_data.root device_info = isy_data.devices entities: list[ ISYNodeQueryButtonEntity diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 57c1b6aa79d..ce39cae5428 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -28,7 +28,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_TENTHS, @@ -42,7 +41,6 @@ from homeassistant.util.enum import try_parse_enum from .const import ( _LOGGER, - DOMAIN, HA_FAN_TO_ISY, HA_HVAC_TO_ISY, ISY_HVAC_MODES, @@ -57,18 +55,18 @@ from .const import ( ) from .entity import ISYNodeEntity from .helpers import convert_isy_value_to_hass -from .models import IsyData +from .models import IsyConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY thermostat platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices async_add_entities( ISYThermostatEntity(node, devices.get(node.primary_node)) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index b44096e2ccd..2acebee8599 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -16,7 +16,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_IGNORE, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -54,6 +53,7 @@ from .const import ( SCHEME_HTTPS, UDN_UUID_PREFIX, ) +from .models import IsyConfigEntry _LOGGER = logging.getLogger(__name__) @@ -137,12 +137,12 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the ISY/IoX config flow.""" self.discovered_conf: dict[str, str] = {} - self._existing_entry: ConfigEntry | None = None + self._existing_entry: IsyConfigEntry | None = None @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: IsyConfigEntry, ) -> OptionsFlow: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py index 6a660aaaf6f..f940fe55332 100644 --- a/homeassistant/components/isy994/cover.py +++ b/homeassistant/components/isy994/cover.py @@ -11,25 +11,23 @@ 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.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import _LOGGER, DOMAIN, UOM_8_BIT_RANGE +from .const import _LOGGER, UOM_8_BIT_RANGE from .entity import ISYNodeEntity, ISYProgramEntity -from .models import IsyData +from .models import IsyConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY cover platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices entities: list[ISYCoverEntity | ISYCoverProgramEntity] = [ ISYCoverEntity(node, devices.get(node.primary_node)) for node in isy_data.nodes[Platform.COVER] diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 1da727fdee8..d170854396c 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -181,6 +181,7 @@ class ISYProgramEntity(ISYEntity): _actions: Program _status: Program + _node: Program def __init__(self, name: str, status: Program, actions: Program = None) -> None: """Initialize the ISY program-based entity.""" diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index aa6059abf49..02542462788 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -8,10 +8,8 @@ from typing import Any from pyisy.constants import ISY_VALUE_UNKNOWN, PROTO_INSTEON from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -19,21 +17,21 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from .const import _LOGGER, DOMAIN +from .const import _LOGGER from .entity import ISYNodeEntity, ISYProgramEntity -from .models import IsyData +from .models import IsyConfigEntry SPEED_RANGE = (1, 255) # off is not included async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY fan platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices entities: list[ISYFanEntity | ISYFanProgramEntity] = [ ISYFanEntity(node, devices.get(node.primary_node)) for node in isy_data.nodes[Platform.FAN] diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 3686a182fe9..587c0544d6c 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -401,8 +401,7 @@ def _categorize_programs(isy_data: IsyData, programs: Programs) -> None: for dtype, _, node_id in folder.children: if dtype != TAG_FOLDER: continue - entity_folder = folder[node_id] - + entity_folder: Programs = folder[node_id] actions = None status = entity_folder.get_by_name(KEY_STATUS) if not status or status.protocol != PROTO_PROGRAM: diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index 29df8398f97..d3edc25c3e2 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -9,28 +9,27 @@ from pyisy.helpers import NodeProperty from pyisy.nodes import Node from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import _LOGGER, CONF_RESTORE_LIGHT_STATE, DOMAIN, UOM_PERCENTAGE +from .const import _LOGGER, CONF_RESTORE_LIGHT_STATE, UOM_PERCENTAGE from .entity import ISYNodeEntity -from .models import IsyData +from .models import IsyConfigEntry ATTR_LAST_BRIGHTNESS = "last_brightness" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY light platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices isy_options = entry.options restore_light_state = isy_options.get(CONF_RESTORE_LIGHT_STATE, False) diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py index d6866a8e00c..056d1d0d492 100644 --- a/homeassistant/components/isy994/lock.py +++ b/homeassistant/components/isy994/lock.py @@ -7,19 +7,16 @@ from typing import Any from pyisy.constants import ISY_VALUE_UNKNOWN from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, async_get_current_platform, ) -from .const import DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity -from .models import IsyData +from .models import IsyConfigEntry from .services import ( SERVICE_DELETE_USER_CODE_SCHEMA, SERVICE_DELETE_ZWAVE_LOCK_USER_CODE, @@ -49,12 +46,12 @@ def async_setup_lock_services(hass: HomeAssistant) -> None: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY lock platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] - devices: dict[str, DeviceInfo] = isy_data.devices + isy_data = entry.runtime_data + devices = isy_data.devices entities: list[ISYLockEntity | ISYLockProgramEntity] = [ ISYLockEntity(node, devices.get(node.primary_node)) for node in isy_data.nodes[Platform.LOCK] diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 3aa81027b4f..bbfc7deb80d 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.1.14"], + "requirements": ["pyisy==3.4.1"], "ssdp": [ { "manufacturer": "Universal Devices Inc.", diff --git a/homeassistant/components/isy994/models.py b/homeassistant/components/isy994/models.py index 5b599df9458..4fc7b96fcd5 100644 --- a/homeassistant/components/isy994/models.py +++ b/homeassistant/components/isy994/models.py @@ -12,6 +12,7 @@ from pyisy.nodes import Group, Node from pyisy.programs import Program from pyisy.variables import Variable +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.helpers.device_registry import DeviceInfo @@ -24,6 +25,8 @@ from .const import ( VARIABLE_PLATFORMS, ) +type IsyConfigEntry = ConfigEntry[IsyData] + @dataclass class IsyData: diff --git a/homeassistant/components/isy994/number.py b/homeassistant/components/isy994/number.py index fc30e6296d4..c5797491e31 100644 --- a/homeassistant/components/isy994/number.py +++ b/homeassistant/components/isy994/number.py @@ -26,7 +26,6 @@ from homeassistant.components.number import ( NumberMode, RestoreNumber, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_VARIABLES, PERCENTAGE, @@ -44,15 +43,10 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import ( - CONF_VAR_SENSOR_STRING, - DEFAULT_VAR_SENSOR_STRING, - DOMAIN, - UOM_8_BIT_RANGE, -) +from .const import CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING, UOM_8_BIT_RANGE from .entity import ISYAuxControlEntity from .helpers import convert_isy_value_to_hass -from .models import IsyData +from .models import IsyConfigEntry ISY_MAX_SIZE = (2**32) / 2 ON_RANGE = (1, 255) # Off is not included @@ -79,11 +73,11 @@ BACKLIGHT_MEMORY_FILTER = {"memory": DEV_BL_ADDR, "cmd1": DEV_CMD_MEMORY_WRITE} async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ISY/IoX number entities from config entry.""" - isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] + isy_data = config_entry.runtime_data device_info = isy_data.devices entities: list[ ISYVariableNumberEntity | ISYAuxControlNumberEntity | ISYBacklightNumberEntity diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py index 868c96375bb..ce5e224bc88 100644 --- a/homeassistant/components/isy994/select.py +++ b/homeassistant/components/isy994/select.py @@ -23,7 +23,6 @@ from pyisy.helpers import EventListener, NodeProperty from pyisy.nodes import Node, NodeChangedEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -37,9 +36,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import _LOGGER, DOMAIN, UOM_INDEX +from .const import _LOGGER, UOM_INDEX from .entity import ISYAuxControlEntity -from .models import IsyData +from .models import IsyConfigEntry def time_string(i: int) -> str: @@ -55,11 +54,11 @@ BACKLIGHT_MEMORY_FILTER = {"memory": DEV_BL_ADDR, "cmd1": DEV_CMD_MEMORY_WRITE} async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ISY/IoX select entities from config entry.""" - isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] + isy_data = config_entry.runtime_data device_info = isy_data.devices entities: list[ ISYAuxControlIndexSelectEntity diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 2655f4d3c4e..6e0b5a89637 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -29,7 +29,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -37,7 +36,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( _LOGGER, - DOMAIN, UOM_DOUBLE_TEMP, UOM_FRIENDLY_NAME, UOM_INDEX, @@ -46,7 +44,7 @@ from .const import ( ) from .entity import ISYNodeEntity from .helpers import convert_isy_value_to_hass -from .models import IsyData +from .models import IsyConfigEntry # Disable general purpose and redundant sensors by default AUX_DISABLED_BY_DEFAULT_MATCH = ["GV", "DO"] @@ -97,9 +95,9 @@ ISY_CONTROL_TO_DEVICE_CLASS = { "WEIGHT": SensorDeviceClass.WEIGHT, "WINDCH": SensorDeviceClass.TEMPERATURE, } -ISY_CONTROL_TO_STATE_CLASS = { - control: SensorStateClass.MEASUREMENT for control in ISY_CONTROL_TO_DEVICE_CLASS -} +ISY_CONTROL_TO_STATE_CLASS = dict.fromkeys( + ISY_CONTROL_TO_DEVICE_CLASS, SensorStateClass.MEASUREMENT +) ISY_CONTROL_TO_ENTITY_CATEGORY = { PROP_RAMP_RATE: EntityCategory.DIAGNOSTIC, PROP_ON_LEVEL: EntityCategory.DIAGNOSTIC, @@ -109,13 +107,13 @@ ISY_CONTROL_TO_ENTITY_CATEGORY = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY sensor platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] + isy_data = entry.runtime_data entities: list[ISYSensorEntity] = [] - devices: dict[str, DeviceInfo] = isy_data.devices + devices = isy_data.devices for node in isy_data.nodes[Platform.SENSOR]: _LOGGER.debug("Loading %s", node.name) diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 6546aec6efa..39f72a5cc2c 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -21,6 +21,7 @@ from homeassistant.helpers.service import entity_service_call from homeassistant.helpers.typing import VolDictType from .const import _LOGGER, DOMAIN +from .models import IsyConfigEntry # Common Services for All Platforms: SERVICE_SEND_PROGRAM_COMMAND = "send_program_command" @@ -148,9 +149,9 @@ def async_setup_services(hass: HomeAssistant) -> None: command = service.data[CONF_COMMAND] isy_name = service.data.get(CONF_ISY) - for config_entry_id in hass.data[DOMAIN]: - isy_data = hass.data[DOMAIN][config_entry_id] - isy = isy_data.root + config_entry: IsyConfigEntry + for config_entry in hass.config_entries.async_loaded_entries(DOMAIN): + isy = config_entry.runtime_data.root if isy_name and isy_name != isy.conf["name"]: continue program = None @@ -234,10 +235,6 @@ def async_setup_services(hass: HomeAssistant) -> None: @callback def async_unload_services(hass: HomeAssistant) -> None: """Unload services for the ISY integration.""" - if hass.data[DOMAIN]: - # There is still another config entry for this domain, don't remove services. - return - existing_services = hass.services.async_services_for_domain(DOMAIN) if not existing_services or SERVICE_SEND_PROGRAM_COMMAND not in existing_services: return diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 8872226daba..73f6cc98b12 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -36,7 +36,7 @@ "options": { "step": { "init": { - "title": "ISY Options", + "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.", "data": { "sensor_string": "Node Sensor String", @@ -49,10 +49,10 @@ }, "system_health": { "info": { - "host_reachable": "Host Reachable", - "device_connected": "ISY Connected", - "last_heartbeat": "Last Heartbeat Time", - "websocket_status": "Event Socket Status" + "host_reachable": "Host reachable", + "device_connected": "ISY connected", + "last_heartbeat": "Last heartbeat time", + "websocket_status": "Event socket status" } }, "services": { @@ -89,8 +89,8 @@ } }, "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.", + "name": "Get Z-Wave parameter", + "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "Parameter", @@ -100,7 +100,7 @@ }, "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.", + "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as an 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%]", @@ -164,7 +164,7 @@ }, "command": { "name": "Command", - "description": "The ISY Program Command to be sent." + "description": "The ISY program command to be sent." }, "isy": { "name": "ISY", diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 946feddcd10..f44613317c5 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -20,16 +20,14 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import ISYAuxControlEntity, ISYNodeEntity, ISYProgramEntity -from .models import IsyData +from .models import IsyConfigEntry @dataclass(frozen=True) @@ -43,11 +41,11 @@ class ISYSwitchEntityDescription(SwitchEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IsyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY switch platform.""" - isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] + isy_data = entry.runtime_data entities: list[ ISYSwitchProgramEntity | ISYSwitchEntity | ISYEnableSwitchEntity ] = [] @@ -157,7 +155,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity): device_info=device_info, ) self._attr_name = description.name # Override super - self._change_handler: EventListener = None + self._change_handler: EventListener | None = None # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/isy994/system_health.py b/homeassistant/components/isy994/system_health.py index dfc45c267dd..9c5a04ba34a 100644 --- a/homeassistant/components/isy994/system_health.py +++ b/homeassistant/components/isy994/system_health.py @@ -4,15 +4,12 @@ from __future__ import annotations from typing import Any -from pyisy import ISY - from homeassistant.components import system_health -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback from .const import DOMAIN, ISY_URL_POSTFIX -from .models import IsyData +from .models import IsyConfigEntry @callback @@ -27,14 +24,9 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" health_info = {} - config_entry_id = next( - iter(hass.data[DOMAIN]) - ) # Only first ISY is supported for now - isy_data: IsyData = hass.data[DOMAIN][config_entry_id] - isy: ISY = isy_data.root + entry: IsyConfigEntry = hass.config_entries.async_loaded_entries(DOMAIN)[0] + isy = entry.runtime_data.root - entry = hass.config_entries.async_get_entry(config_entry_id) - assert isinstance(entry, ConfigEntry) health_info["host_reachable"] = await system_health.async_check_can_reach_url( hass, f"{entry.data[CONF_HOST]}{ISY_URL_POSTFIX}" ) diff --git a/homeassistant/components/isy994/util.py b/homeassistant/components/isy994/util.py index ca5c5ea46a9..87cb450d08b 100644 --- a/homeassistant/components/isy994/util.py +++ b/homeassistant/components/isy994/util.py @@ -5,16 +5,19 @@ from __future__ import annotations from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from .const import _LOGGER, DOMAIN +from .const import _LOGGER +from .models import IsyConfigEntry @callback -def _async_cleanup_registry_entries(hass: HomeAssistant, entry_id: str) -> None: +def _async_cleanup_registry_entries(hass: HomeAssistant, entry: IsyConfigEntry) -> None: """Remove extra entities that are no longer part of the integration.""" entity_registry = er.async_get(hass) - isy_data = hass.data[DOMAIN][entry_id] + isy_data = entry.runtime_data - existing_entries = er.async_entries_for_config_entry(entity_registry, entry_id) + existing_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) entities = { (entity.domain, entity.unique_id): entity.entity_id for entity in existing_entries @@ -31,5 +34,5 @@ def _async_cleanup_registry_entries(hass: HomeAssistant, entry_id: str) -> None: _LOGGER.debug( ("Cleaning up ISY entities: removed %s extra entities for config entry %s"), len(extra_entities), - entry_id, + entry.entry_id, ) diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index e5648b0a34f..9eee4bbb363 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -73,7 +73,7 @@ async def build_root_response( children = [ await item_payload(hass, client, user_id, folder) for folder in folders["Items"] - if folder["CollectionType"] in SUPPORTED_COLLECTION_TYPES + if folder.get("CollectionType") in SUPPORTED_COLLECTION_TYPES ] return BrowseMedia( diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py index ab5d5e7d7f8..91fe0885e4c 100644 --- a/homeassistant/components/jellyfin/client_wrapper.py +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -97,16 +97,27 @@ def get_artwork_url( client: JellyfinClient, item: dict[str, Any], max_width: int = 600 ) -> str | None: """Find a suitable thumbnail for an item.""" - artwork_id: str = item["Id"] - artwork_type = "Primary" + artwork_id: str | None = None + artwork_type: str | None = None parent_backdrop_id: str | None = item.get("ParentBackdropItemId") - if "Backdrop" in item[ITEM_KEY_IMAGE_TAGS]: + if "AlbumPrimaryImageTag" in item: + # jellyfin_apiclient_python doesn't support passing a specific tag to `.artwork`, + # so we don't use the actual value of AlbumPrimaryImageTag. + # However, its mere presence tells us that the album does have primary artwork, + # and the resulting URL will pull the primary album art even if the tag is not specified. + artwork_type = "Primary" + artwork_id = item["AlbumId"] + elif "Backdrop" in item[ITEM_KEY_IMAGE_TAGS]: artwork_type = "Backdrop" + artwork_id = item["Id"] elif parent_backdrop_id: artwork_type = "Backdrop" artwork_id = parent_backdrop_id - elif "Primary" not in item[ITEM_KEY_IMAGE_TAGS]: + elif "Primary" in item[ITEM_KEY_IMAGE_TAGS]: + artwork_type = "Primary" + artwork_id = item["Id"] + else: return None return str(client.jellyfin.artwork(artwork_id, artwork_type, max_width)) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 823e9bd59be..282614df7d3 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from functools import partial +import logging from hdate import Location @@ -14,7 +15,9 @@ from homeassistant.const import ( CONF_TIME_ZONE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CANDLE_LIGHT_MINUTES, @@ -24,10 +27,21 @@ from .const import ( DEFAULT_DIASPORA, DEFAULT_HAVDALAH_OFFSET_MINUTES, DEFAULT_LANGUAGE, + DOMAIN, ) from .entity import JewishCalendarConfigEntry, JewishCalendarData +from .service import async_setup_services +_LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Jewish Calendar service.""" + async_setup_services(hass) + + return True async def async_setup_entry( @@ -80,3 +94,54 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: JewishCalendarConfigEntry +) -> bool: + """Migrate old entry.""" + + _LOGGER.debug("Migrating from version %s", config_entry.version) + + @callback + def update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + key_translations = { + "first_light": "alot_hashachar", + "talit": "talit_and_tefillin", + "sunrise": "netz_hachama", + "gra_end_shma": "sof_zman_shema_gra", + "mga_end_shma": "sof_zman_shema_mga", + "gra_end_tfila": "sof_zman_tfilla_gra", + "mga_end_tfila": "sof_zman_tfilla_mga", + "midday": "chatzot_hayom", + "big_mincha": "mincha_gedola", + "small_mincha": "mincha_ketana", + "plag_mincha": "plag_hamincha", + "sunset": "shkia", + "first_stars": "tset_hakohavim_tsom", + "three_stars": "tset_hakohavim_shabbat", + } + old_keys = tuple(key_translations.keys()) + if entity_entry.unique_id.endswith(old_keys): + old_key = entity_entry.unique_id.split("-")[1] + new_unique_id = f"{config_entry.entry_id}-{key_translations[old_key]}" + return {"new_unique_id": new_unique_id} + return None + + if config_entry.version > 2: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + hass.config_entries.async_update_entry(config_entry, version=2) + + if config_entry.version == 2: + new_data = {**config_entry.data} + new_data[CONF_LANGUAGE] = config_entry.data[CONF_LANGUAGE][:2] + hass.config_entries.async_update_entry(config_entry, data=new_data, version=3) + + return True diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 5ff3171b7de..8d06526c322 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -5,9 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import datetime as dt -from datetime import datetime -import hdate from hdate.zmanim import Zmanim from homeassistant.components.binary_sensor import ( @@ -27,7 +25,7 @@ from .entity import JewishCalendarConfigEntry, JewishCalendarEntity class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): """Binary Sensor description mixin class for Jewish Calendar.""" - is_on: Callable[[Zmanim], bool] = lambda _: False + is_on: Callable[[Zmanim, dt.datetime], bool] = lambda _, __: False @dataclass(frozen=True) @@ -40,20 +38,20 @@ class JewishCalendarBinarySensorEntityDescription( BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = ( JewishCalendarBinarySensorEntityDescription( key="issur_melacha_in_effect", - name="Issur Melacha in Effect", + translation_key="issur_melacha_in_effect", icon="mdi:power-plug-off", - is_on=lambda state: bool(state.issur_melacha_in_effect), + is_on=lambda state, now: bool(state.issur_melacha_in_effect(now)), ), JewishCalendarBinarySensorEntityDescription( key="erev_shabbat_hag", - name="Erev Shabbat/Hag", - is_on=lambda state: bool(state.erev_shabbat_chag), + translation_key="erev_shabbat_hag", + is_on=lambda state, now: bool(state.erev_shabbat_chag(now)), entity_registry_enabled_default=False, ), JewishCalendarBinarySensorEntityDescription( key="motzei_shabbat_hag", - name="Motzei Shabbat/Hag", - is_on=lambda state: bool(state.motzei_shabbat_chag), + translation_key="motzei_shabbat_hag", + is_on=lambda state, now: bool(state.motzei_shabbat_chag(now)), entity_registry_enabled_default=False, ), ) @@ -84,16 +82,15 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): def is_on(self) -> bool: """Return true if sensor is on.""" zmanim = self._get_zmanim() - return self.entity_description.is_on(zmanim) + return self.entity_description.is_on(zmanim, dt_util.now()) def _get_zmanim(self) -> Zmanim: """Return the Zmanim object for now().""" - return hdate.Zmanim( - date=dt_util.now(), + return Zmanim( + date=dt.date.today(), location=self._location, candle_lighting_offset=self._candle_lighting_offset, havdalah_offset=self._havdalah_offset, - hebrew=self._hebrew, ) async def async_added_to_hass(self) -> None: @@ -109,7 +106,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): return await super().async_will_remove_from_hass() @callback - def _update(self, now: datetime | None = None) -> None: + def _update(self, now: dt.datetime | None = None) -> None: """Update the state of the sensor.""" self._update_unsub = None self._schedule_update() @@ -119,7 +116,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): """Schedule the next update of the sensor.""" now = dt_util.now() zmanim = self._get_zmanim() - update = zmanim.zmanim["sunrise"] + dt.timedelta(days=1) + update = zmanim.netz_hachama.local + dt.timedelta(days=1) candle_lighting = zmanim.candle_lighting if candle_lighting is not None and now < candle_lighting < update: update = candle_lighting diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index a2eadbf57bd..4572f87a113 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -3,9 +3,10 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, get_args import zoneinfo +from hdate.translator import Language import voluptuous as vol from homeassistant.config_entries import ( @@ -25,8 +26,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.selector import ( BooleanSelector, + LanguageSelector, + LanguageSelectorConfig, LocationSelector, - SelectOptionDict, SelectSelector, SelectSelectorConfig, ) @@ -43,11 +45,6 @@ from .const import ( DOMAIN, ) -LANGUAGE = [ - SelectOptionDict(value="hebrew", label="Hebrew"), - SelectOptionDict(value="english", label="English"), -] - OPTIONS_SCHEMA = vol.Schema( { vol.Optional(CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT): int, @@ -61,23 +58,24 @@ OPTIONS_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__name__) -def _get_data_schema(hass: HomeAssistant) -> vol.Schema: +async def _get_data_schema(hass: HomeAssistant) -> vol.Schema: default_location = { CONF_LATITUDE: hass.config.latitude, CONF_LONGITUDE: hass.config.longitude, } + get_timezones: list[str] = list( + await hass.async_add_executor_job(zoneinfo.available_timezones) + ) return vol.Schema( { vol.Required(CONF_DIASPORA, default=DEFAULT_DIASPORA): BooleanSelector(), - vol.Required(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): SelectSelector( - SelectSelectorConfig(options=LANGUAGE) + vol.Required(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): LanguageSelector( + LanguageSelectorConfig(languages=list(get_args(Language))) ), vol.Optional(CONF_LOCATION, default=default_location): LocationSelector(), vol.Optional(CONF_ELEVATION, default=hass.config.elevation): int, vol.Optional(CONF_TIME_ZONE, default=hass.config.time_zone): SelectSelector( - SelectSelectorConfig( - options=sorted(zoneinfo.available_timezones()), - ) + SelectSelectorConfig(options=get_timezones, sort=True) ), } ) @@ -86,7 +84,7 @@ def _get_data_schema(hass: HomeAssistant) -> vol.Schema: class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Jewish calendar.""" - VERSION = 1 + VERSION = 3 @staticmethod @callback @@ -109,7 +107,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( - _get_data_schema(self.hass), user_input + await _get_data_schema(self.hass), user_input ), ) @@ -121,7 +119,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): if not user_input: return self.async_show_form( data_schema=self.add_suggested_values_to_schema( - _get_data_schema(self.hass), + await _get_data_schema(self.hass), reconfigure_entry.data, ), step_id="reconfigure", diff --git a/homeassistant/components/jewish_calendar/const.py b/homeassistant/components/jewish_calendar/const.py index 4af76a8927b..3c5b754fee4 100644 --- a/homeassistant/components/jewish_calendar/const.py +++ b/homeassistant/components/jewish_calendar/const.py @@ -2,6 +2,10 @@ DOMAIN = "jewish_calendar" +ATTR_AFTER_SUNSET = "after_sunset" +ATTR_DATE = "date" +ATTR_NUSACH = "nusach" + CONF_DIASPORA = "diaspora" CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" @@ -10,4 +14,6 @@ DEFAULT_NAME = "Jewish Calendar" DEFAULT_CANDLE_LIGHT = 18 DEFAULT_DIASPORA = False DEFAULT_HAVDALAH_OFFSET_MINUTES = 0 -DEFAULT_LANGUAGE = "english" +DEFAULT_LANGUAGE = "en" + +SERVICE_COUNT_OMER = "count_omer" diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index 1d2a6e45c0a..b048b0d4bb7 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from hdate import Location +from hdate.translator import Language, set_language from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -17,7 +18,7 @@ type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] class JewishCalendarData: """Jewish Calendar runtime dataclass.""" - language: str + language: Language diaspora: bool location: Location candle_lighting_offset: int @@ -43,8 +44,7 @@ class JewishCalendarEntity(Entity): ) data = config_entry.runtime_data self._location = data.location - self._hebrew = data.language == "hebrew" - self._language = data.language self._candle_lighting_offset = data.candle_lighting_offset self._havdalah_offset = data.havdalah_offset self._diaspora = data.diaspora + set_language(data.language) diff --git a/homeassistant/components/jewish_calendar/icons.json b/homeassistant/components/jewish_calendar/icons.json new file mode 100644 index 00000000000..24b922df7a2 --- /dev/null +++ b/homeassistant/components/jewish_calendar/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "count_omer": { + "service": "mdi:counter" + } + } +} diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index aca45320002..c93844dd559 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate==0.11.1"], + "requirements": ["hdate[astral]==1.1.0"], "single_config_entry": true } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index eee1d966ae6..063818aedf3 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -2,12 +2,13 @@ from __future__ import annotations -from datetime import date as Date +import datetime as dt import logging -from typing import Any, cast +from typing import Any -from hdate import HDate, HebrewDate, htables -from hdate.zmanim import Zmanim +from hdate import HDateInfo, Zmanim +from hdate.holidays import HolidayDatabase +from hdate.parasha import Parasha from homeassistant.components.sensor import ( SensorDeviceClass, @@ -27,31 +28,30 @@ _LOGGER = logging.getLogger(__name__) INFO_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="date", - name="Date", - icon="mdi:star-david", translation_key="hebrew_date", + icon="mdi:star-david", ), SensorEntityDescription( key="weekly_portion", - name="Parshat Hashavua", + translation_key="weekly_portion", icon="mdi:book-open-variant", device_class=SensorDeviceClass.ENUM, ), SensorEntityDescription( key="holiday", - name="Holiday", + translation_key="holiday", icon="mdi:calendar-star", device_class=SensorDeviceClass.ENUM, ), SensorEntityDescription( key="omer_count", - name="Day of the Omer", + translation_key="omer_count", icon="mdi:counter", entity_registry_enabled_default=False, ), SensorEntityDescription( key="daf_yomi", - name="Daf Yomi", + translation_key="daf_yomi", icon="mdi:book-open-variant", entity_registry_enabled_default=False, ), @@ -59,107 +59,107 @@ INFO_SENSORS: tuple[SensorEntityDescription, ...] = ( TIME_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key="first_light", - name="Alot Hashachar", # codespell:ignore alot + key="alot_hashachar", + translation_key="alot_hashachar", icon="mdi:weather-sunset-up", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="talit", - name="Talit and Tefillin", + key="talit_and_tefillin", + translation_key="talit_and_tefillin", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="sunrise", - name="Hanetz Hachama", + key="netz_hachama", + translation_key="netz_hachama", icon="mdi:calendar-clock", ), SensorEntityDescription( - key="gra_end_shma", - name='Latest time for Shma Gr"a', + key="sof_zman_shema_gra", + translation_key="sof_zman_shema_gra", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="mga_end_shma", - name='Latest time for Shma MG"A', + key="sof_zman_shema_mga", + translation_key="sof_zman_shema_mga", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="gra_end_tfila", - name='Latest time for Tefilla Gr"a', + key="sof_zman_tfilla_gra", + translation_key="sof_zman_tfilla_gra", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="mga_end_tfila", - name='Latest time for Tefilla MG"A', + key="sof_zman_tfilla_mga", + translation_key="sof_zman_tfilla_mga", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="midday", - name="Chatzot Hayom", + key="chatzot_hayom", + translation_key="chatzot_hayom", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="big_mincha", - name="Mincha Gedola", + key="mincha_gedola", + translation_key="mincha_gedola", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="small_mincha", - name="Mincha Ketana", + key="mincha_ketana", + translation_key="mincha_ketana", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="plag_mincha", - name="Plag Hamincha", + key="plag_hamincha", + translation_key="plag_hamincha", icon="mdi:weather-sunset-down", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="sunset", - name="Shkia", + key="shkia", + translation_key="shkia", icon="mdi:weather-sunset", ), SensorEntityDescription( - key="first_stars", - name="T'set Hakochavim", + key="tset_hakohavim_tsom", + translation_key="tset_hakohavim_tsom", icon="mdi:weather-night", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="three_stars", - name="T'set Hakochavim, 3 stars", + key="tset_hakohavim_shabbat", + translation_key="tset_hakohavim_shabbat", icon="mdi:weather-night", entity_registry_enabled_default=False, ), SensorEntityDescription( key="upcoming_shabbat_candle_lighting", - name="Upcoming Shabbat Candle Lighting", + translation_key="upcoming_shabbat_candle_lighting", icon="mdi:candle", entity_registry_enabled_default=False, ), SensorEntityDescription( key="upcoming_shabbat_havdalah", - name="Upcoming Shabbat Havdalah", + translation_key="upcoming_shabbat_havdalah", icon="mdi:weather-night", entity_registry_enabled_default=False, ), SensorEntityDescription( key="upcoming_candle_lighting", - name="Upcoming Candle Lighting", + translation_key="upcoming_candle_lighting", icon="mdi:candle", ), SensorEntityDescription( key="upcoming_havdalah", - name="Upcoming Havdalah", + translation_key="upcoming_havdalah", icon="mdi:weather-night", ), ) @@ -196,6 +196,11 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): super().__init__(config_entry, description) self._attrs: dict[str, str] = {} + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + await self.async_update() + async def async_update(self) -> None: """Update the state of the sensor.""" now = dt_util.now() @@ -212,7 +217,7 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): _LOGGER.debug("Now: %s Sunset: %s", now, sunset) - daytime_date = HDate(today, diaspora=self._diaspora, hebrew=self._hebrew) + daytime_date = HDateInfo(today, diaspora=self._diaspora) # The Jewish day starts after darkness (called "tzais") and finishes at # sunset ("shkia"). The time in between is a gray area @@ -238,14 +243,13 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): "New value for %s: %s", self.entity_description.key, self._attr_native_value ) - def make_zmanim(self, date: Date) -> Zmanim: + def make_zmanim(self, date: dt.date) -> Zmanim: """Create a Zmanim object.""" return Zmanim( date=date, location=self._location, candle_lighting_offset=self._candle_lighting_offset, havdalah_offset=self._havdalah_offset, - hebrew=self._hebrew, ) @property @@ -254,43 +258,37 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): return self._attrs def get_state( - self, daytime_date: HDate, after_shkia_date: HDate, after_tzais_date: HDate + self, + daytime_date: HDateInfo, + after_shkia_date: HDateInfo, + after_tzais_date: HDateInfo, ) -> Any | None: """For a given type of sensor, return the state.""" # Terminology note: by convention in py-libhdate library, "upcoming" # refers to "current" or "upcoming" dates. if self.entity_description.key == "date": - hdate = cast(HebrewDate, after_shkia_date.hdate) - month = htables.MONTHS[hdate.month.value - 1] + hdate = after_shkia_date.hdate self._attrs = { - "hebrew_year": hdate.year, - "hebrew_month_name": month.hebrew if self._hebrew else month.english, - "hebrew_day": hdate.day, + "hebrew_year": str(hdate.year), + "hebrew_month_name": str(hdate.month), + "hebrew_day": str(hdate.day), } - return after_shkia_date.hebrew_date + return after_shkia_date.hdate if self.entity_description.key == "weekly_portion": - self._attr_options = [ - (p.hebrew if self._hebrew else p.english) for p in htables.PARASHAOT - ] + self._attr_options = [str(p) for p in Parasha] # Compute the weekly portion based on the upcoming shabbat. - return after_tzais_date.upcoming_shabbat.parasha + return str(after_tzais_date.upcoming_shabbat.parasha) if self.entity_description.key == "holiday": - _id = _type = _type_id = "" - _holiday_type = after_shkia_date.holiday_type - if isinstance(_holiday_type, list): - _id = ", ".join(after_shkia_date.holiday_name) - _type = ", ".join([_htype.name for _htype in _holiday_type]) - _type_id = ", ".join([str(_htype.value) for _htype in _holiday_type]) - else: - _id = after_shkia_date.holiday_name - _type = _holiday_type.name - _type_id = _holiday_type.value - self._attrs = {"id": _id, "type": _type, "type_id": _type_id} - self._attr_options = htables.get_all_holidays(self._language) - - return after_shkia_date.holiday_description + _holidays = after_shkia_date.holidays + _id = ", ".join(holiday.name for holiday in _holidays) + _type = ", ".join( + dict.fromkeys(_holiday.type.name for _holiday in _holidays) + ) + self._attrs = {"id": _id, "type": _type} + self._attr_options = HolidayDatabase(self._diaspora).get_all_names() + return ", ".join(str(holiday) for holiday in _holidays) if _holidays else "" if self.entity_description.key == "omer_count": - return after_shkia_date.omer_day + return after_shkia_date.omer.total_days if after_shkia_date.omer else 0 if self.entity_description.key == "daf_yomi": return daytime_date.daf_yomi @@ -303,7 +301,10 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): _attr_device_class = SensorDeviceClass.TIMESTAMP def get_state( - self, daytime_date: HDate, after_shkia_date: HDate, after_tzais_date: HDate + self, + daytime_date: HDateInfo, + after_shkia_date: HDateInfo, + after_tzais_date: HDateInfo, ) -> Any | None: """For a given type of sensor, return the state.""" if self.entity_description.key == "upcoming_shabbat_candle_lighting": @@ -325,5 +326,5 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): ) return times.havdalah - times = self.make_zmanim(dt_util.now()).zmanim - return times[self.entity_description.key] + times = self.make_zmanim(dt_util.now().date()) + return times.zmanim[self.entity_description.key].local diff --git a/homeassistant/components/jewish_calendar/service.py b/homeassistant/components/jewish_calendar/service.py new file mode 100644 index 00000000000..06d537b168d --- /dev/null +++ b/homeassistant/components/jewish_calendar/service.py @@ -0,0 +1,83 @@ +"""Services for Jewish Calendar.""" + +import datetime +import logging +from typing import get_args + +from hdate import HebrewDate +from hdate.omer import Nusach, Omer +from hdate.translator import Language, set_language +import voluptuous as vol + +from homeassistant.const import CONF_LANGUAGE, SUN_EVENT_SUNSET +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import LanguageSelector, LanguageSelectorConfig +from homeassistant.helpers.sun import get_astral_event_date +from homeassistant.util import dt as dt_util + +from .const import ATTR_AFTER_SUNSET, ATTR_DATE, ATTR_NUSACH, DOMAIN, SERVICE_COUNT_OMER + +_LOGGER = logging.getLogger(__name__) +OMER_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_DATE): cv.date, + vol.Optional(ATTR_AFTER_SUNSET, default=True): cv.boolean, + vol.Required(ATTR_NUSACH, default="sfarad"): vol.In( + [nusach.name.lower() for nusach in Nusach] + ), + vol.Optional(CONF_LANGUAGE, default="he"): LanguageSelector( + LanguageSelectorConfig(languages=list(get_args(Language))) + ), + } +) + + +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the Jewish Calendar services.""" + + def is_after_sunset(hass: HomeAssistant) -> bool: + """Determine if the current time is after sunset.""" + now = dt_util.now() + today = now.date() + event_date = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) + if event_date is None: + _LOGGER.error("Can't get sunset event date for %s", today) + raise ValueError("Can't get sunset event date") + sunset = dt_util.as_local(event_date) + _LOGGER.debug("Now: %s Sunset: %s", now, sunset) + return now > sunset + + async def get_omer_count(call: ServiceCall) -> ServiceResponse: + """Return the Omer blessing for a given date.""" + date = call.data.get(ATTR_DATE, dt_util.now().date()) + after_sunset = ( + call.data[ATTR_AFTER_SUNSET] + if ATTR_DATE in call.data + else is_after_sunset(hass) + ) + hebrew_date = HebrewDate.from_gdate( + date + datetime.timedelta(days=int(after_sunset)) + ) + nusach = Nusach[call.data[ATTR_NUSACH].upper()] + set_language(call.data[CONF_LANGUAGE]) + omer = Omer(date=hebrew_date, nusach=nusach) + return { + "message": str(omer.count_str()), + "weeks": omer.week, + "days": omer.day, + "total_days": omer.total_days, + } + + hass.services.async_register( + DOMAIN, + SERVICE_COUNT_OMER, + get_omer_count, + schema=OMER_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/jewish_calendar/services.yaml b/homeassistant/components/jewish_calendar/services.yaml new file mode 100644 index 00000000000..a301857fa66 --- /dev/null +++ b/homeassistant/components/jewish_calendar/services.yaml @@ -0,0 +1,35 @@ +count_omer: + fields: + date: + required: false + example: "2025-04-14" + selector: + date: + after_sunset: + required: false + example: true + default: true + selector: + boolean: + nusach: + required: true + example: "sfarad" + default: "sfarad" + selector: + select: + translation_key: "nusach" + options: + - "sfarad" + - "ashkenaz" + - "adot_mizrah" + - "italian" + language: + required: false + default: "he" + example: "he" + selector: + language: + languages: + - "en" + - "he" + - "fr" diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json index 1b7b86c0056..33d58ea3487 100644 --- a/homeassistant/components/jewish_calendar/strings.json +++ b/homeassistant/components/jewish_calendar/strings.json @@ -1,12 +1,90 @@ { "entity": { + "binary_sensor": { + "issur_melacha_in_effect": { + "name": "Issur Melacha in effect" + }, + "erev_shabbat_hag": { + "name": "Erev Shabbat/Hag" + }, + "motzei_shabbat_hag": { + "name": "Motzei Shabbat/Hag" + } + }, "sensor": { "hebrew_date": { + "name": "Date", "state_attributes": { - "hebrew_year": { "name": "Hebrew Year" }, - "hebrew_month_name": { "name": "Hebrew Month Name" }, - "hebrew_day": { "name": "Hebrew Day" } + "hebrew_year": { "name": "Hebrew year" }, + "hebrew_month_name": { "name": "Hebrew month name" }, + "hebrew_day": { "name": "Hebrew day" } } + }, + "weekly_portion": { + "name": "Weekly Torah portion" + }, + "holiday": { + "name": "Holiday" + }, + "omer_count": { + "name": "Day of the Omer" + }, + "daf_yomi": { + "name": "Daf Yomi" + }, + "alot_hashachar": { + "name": "Halachic dawn (Alot Hashachar)" + }, + "talit_and_tefillin": { + "name": "Earliest time for Talit and Tefillin" + }, + "netz_hachama": { + "name": "Halachic sunrise (Netz Hachama)" + }, + "sof_zman_shema_gra": { + "name": "Latest time for Shma Gr\"a" + }, + "sof_zman_shema_mga": { + "name": "Latest time for Shma MG\"A" + }, + "sof_zman_tfilla_gra": { + "name": "Latest time for Tefilla Gr\"a" + }, + "sof_zman_tfilla_mga": { + "name": "Latest time for Tefilla MG\"A" + }, + "chatzot_hayom": { + "name": "Halachic midday (Chatzot Hayom)" + }, + "mincha_gedola": { + "name": "Mincha Gedola" + }, + "mincha_ketana": { + "name": "Mincha Ketana" + }, + "plag_hamincha": { + "name": "Plag Hamincha" + }, + "shkia": { + "name": "Sunset (Shkia)" + }, + "tset_hakohavim_tsom": { + "name": "Nightfall (T'set Hakochavim)" + }, + "tset_hakohavim_shabbat": { + "name": "Nightfall (T'set Hakochavim, 3 stars)" + }, + "upcoming_shabbat_candle_lighting": { + "name": "Upcoming Shabbat candle lighting" + }, + "upcoming_shabbat_havdalah": { + "name": "Upcoming Shabbat Havdalah" + }, + "upcoming_candle_lighting": { + "name": "Upcoming candle lighting" + }, + "upcoming_havdalah": { + "name": "Upcoming Havdalah" } } }, @@ -16,10 +94,10 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "diaspora": "Outside of Israel?", - "language": "Language for Holidays and Dates", + "language": "Language for holidays and dates", "location": "[%key:common::config_flow::data::location%]", "elevation": "[%key:common::config_flow::data::elevation%]", - "time_zone": "Time Zone" + "time_zone": "Time zone" }, "data_description": { "time_zone": "If you specify a location, make sure to specify the time zone for correct calendar times calculations" @@ -36,7 +114,7 @@ "init": { "title": "Configure options for Jewish Calendar", "data": { - "candle_lighting_minutes_before_sunset": "Minutes before sunset for candle lighthing", + "candle_lighting_minutes_before_sunset": "Minutes before sunset for candle lighting", "havdalah_minutes_after_sunset": "Minutes after sunset for Havdalah" }, "data_description": { @@ -45,5 +123,39 @@ } } } + }, + "selector": { + "nusach": { + "options": { + "sfarad": "Sfarad", + "ashkenaz": "Ashkenaz", + "adot_mizrah": "Adot Mizrah", + "italian": "Italian" + } + } + }, + "services": { + "count_omer": { + "name": "Count the Omer", + "description": "Returns the phrase for counting the Omer on a given date.", + "fields": { + "date": { + "name": "Date", + "description": "Date to count the Omer for." + }, + "after_sunset": { + "name": "After sunset", + "description": "Uses the next Hebrew day (starting at sunset) for a given date. This indicator is ignored if the Date field is empty." + }, + "nusach": { + "name": "Nusach", + "description": "Nusach to count the Omer in." + }, + "language": { + "name": "[%key:common::config_flow::data::language%]", + "description": "Language to count the Omer in." + } + } + } } } diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index c6e5736bd2d..ab17ef6e8ff 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -56,7 +56,7 @@ "on": "[%key:common::state::on%]", "warming": "Warming", "cooling": "Cooling", - "error": "Error" + "error": "[%key:common::state::error%]" } } } diff --git a/homeassistant/components/kaiser_nienhaus/__init__.py b/homeassistant/components/kaiser_nienhaus/__init__.py new file mode 100644 index 00000000000..0aef3a37342 --- /dev/null +++ b/homeassistant/components/kaiser_nienhaus/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Kaiser Nienhaus.""" diff --git a/homeassistant/components/kaiser_nienhaus/manifest.json b/homeassistant/components/kaiser_nienhaus/manifest.json new file mode 100644 index 00000000000..ec52e03acd4 --- /dev/null +++ b/homeassistant/components/kaiser_nienhaus/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "kaiser_nienhaus", + "name": "Kaiser Nienhaus", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/kaleidescape/entity.py b/homeassistant/components/kaleidescape/entity.py index 667cba757d6..1c391b6600b 100644 --- a/homeassistant/components/kaleidescape/entity.py +++ b/homeassistant/components/kaleidescape/entity.py @@ -9,7 +9,7 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from .const import DOMAIN as KALEIDESCAPE_DOMAIN, NAME as KALEIDESCAPE_NAME +from .const import DOMAIN, NAME as KALEIDESCAPE_NAME if TYPE_CHECKING: from kaleidescape import Device as KaleidescapeDevice @@ -29,7 +29,7 @@ class KaleidescapeEntity(Entity): self._attr_unique_id = device.serial_number self._attr_device_info = DeviceInfo( - identifiers={(KALEIDESCAPE_DOMAIN, self._device.serial_number)}, + identifiers={(DOMAIN, self._device.serial_number)}, # Instead of setting the device name to the entity name, kaleidescape # should be updated to set has_entity_name = True name=f"{KALEIDESCAPE_NAME} {device.system.friendly_name}", diff --git a/homeassistant/components/kaleidescape/media_player.py b/homeassistant/components/kaleidescape/media_player.py index 88e2e16bef2..cd8aa9d4a8e 100644 --- a/homeassistant/components/kaleidescape/media_player.py +++ b/homeassistant/components/kaleidescape/media_player.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.util.dt import utcnow -from .const import DOMAIN as KALEIDESCAPE_DOMAIN +from .const import DOMAIN from .entity import KaleidescapeEntity if TYPE_CHECKING: @@ -43,7 +43,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - entities = [KaleidescapeMediaPlayer(hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id])] + entities = [KaleidescapeMediaPlayer(hass.data[DOMAIN][entry.entry_id])] async_add_entities(entities) diff --git a/homeassistant/components/kaleidescape/remote.py b/homeassistant/components/kaleidescape/remote.py index ddafd52f220..2b341e0c429 100644 --- a/homeassistant/components/kaleidescape/remote.py +++ b/homeassistant/components/kaleidescape/remote.py @@ -9,7 +9,7 @@ from kaleidescape import const as kaleidescape_const from homeassistant.components.remote import RemoteEntity from homeassistant.exceptions import HomeAssistantError -from .const import DOMAIN as KALEIDESCAPE_DOMAIN +from .const import DOMAIN from .entity import KaleidescapeEntity if TYPE_CHECKING: @@ -27,7 +27,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - entities = [KaleidescapeRemote(hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id])] + entities = [KaleidescapeRemote(hass.data[DOMAIN][entry.entry_id])] async_add_entities(entities) diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py index 8bff5df2e70..ac0f6504daa 100644 --- a/homeassistant/components/kaleidescape/sensor.py +++ b/homeassistant/components/kaleidescape/sensor.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory -from .const import DOMAIN as KALEIDESCAPE_DOMAIN +from .const import DOMAIN from .entity import KaleidescapeEntity if TYPE_CHECKING: @@ -136,7 +136,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - device: KaleidescapeDevice = hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id] + device: KaleidescapeDevice = hass.data[DOMAIN][entry.entry_id] async_add_entities( KaleidescapeSensor(device, description) for description in SENSOR_TYPES ) diff --git a/homeassistant/components/keymitt_ble/strings.json b/homeassistant/components/keymitt_ble/strings.json index 2a1f428603e..5e7e895d222 100644 --- a/homeassistant/components/keymitt_ble/strings.json +++ b/homeassistant/components/keymitt_ble/strings.json @@ -34,7 +34,7 @@ "services": { "calibrate": { "name": "Calibrate", - "description": "Calibration - Set depth, press & hold duration, and operation mode. Warning - this will send a push command to the device.", + "description": "Sets the depth, press or release duration, and operation mode. Warning - this will send a push command to the device.", "fields": { "entity_id": { "name": "Entity", @@ -42,15 +42,15 @@ }, "depth": { "name": "Depth", - "description": "Depth in percent." + "description": "How far to extend the push arm." }, "duration": { "name": "Duration", - "description": "Duration in seconds." + "description": "How long to press or release." }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "Normal | invert | toggle." + "description": "The operation mode of the arm." } } } diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index de8e521f0e8..2f876ca855d 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -12,14 +12,24 @@ from random import random import voluptuous as vol from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, async_import_statistics, get_last_statistics, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume +from homeassistant.const import ( + DEGREE, + Platform, + UnitOfEnergy, + UnitOfTemperature, + UnitOfVolume, +) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -72,6 +82,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set the config entry up.""" + if "recorder" in hass.config.components: + # Insert stats for mean_type_changed issue + await _insert_wrong_wind_direction_statistics(hass) + # Set up demo platforms with config entry await hass.config_entries.async_forward_entry_setups( entry, COMPONENTS_WITH_DEMO_PLATFORM @@ -233,7 +247,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Outdoor temperature", "statistic_id": f"{DOMAIN}:temperature_outdoor", "unit_of_measurement": UnitOfTemperature.CELSIUS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -246,7 +260,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Energy consumption 1", "statistic_id": f"{DOMAIN}:energy_consumption_kwh", "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 1) @@ -258,7 +272,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Energy consumption 2", "statistic_id": f"{DOMAIN}:energy_consumption_mwh", "unit_of_measurement": UnitOfEnergy.MEGA_WATT_HOUR, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics( @@ -272,7 +286,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Gas consumption 1", "statistic_id": f"{DOMAIN}:gas_consumption_m3", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics( @@ -286,7 +300,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Gas consumption 2", "statistic_id": f"{DOMAIN}:gas_consumption_ft3", "unit_of_measurement": UnitOfVolume.CUBIC_FEET, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 15) @@ -298,7 +312,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_1", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -310,7 +324,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_2", "unit_of_measurement": "cats", - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -322,7 +336,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_3", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -334,8 +348,28 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_4", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) async_import_statistics(hass, metadata, statistics) + + +async def _insert_wrong_wind_direction_statistics(hass: HomeAssistant) -> None: + """Insert some fake wind direction statistics.""" + now = dt_util.now() + yesterday = now - datetime.timedelta(days=1) + yesterday_midnight = yesterday.replace(hour=0, minute=0, second=0, microsecond=0) + today_midnight = yesterday_midnight + datetime.timedelta(days=1) + + # Add some statistics required to raise the mean_type_changed issue later + metadata: StatisticMetaData = { + "source": RECORDER_DOMAIN, + "name": None, + "statistic_id": "sensor.statistics_issues_issue_5", + "unit_of_measurement": DEGREE, + "mean_type": StatisticMeanType.ARITHMETIC, + "has_sum": False, + } + statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 0, 360) + async_import_statistics(hass, metadata, statistics) diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py index 44ac0456105..46b204845ad 100644 --- a/homeassistant/components/kitchen_sink/backup.py +++ b/homeassistant/components/kitchen_sink/backup.py @@ -7,7 +7,13 @@ from collections.abc import AsyncIterator, Callable, Coroutine import logging from typing import Any -from homeassistant.components.backup import AddonInfo, AgentBackup, BackupAgent, Folder +from homeassistant.components.backup import ( + AddonInfo, + AgentBackup, + BackupAgent, + BackupNotFound, + Folder, +) from homeassistant.core import HomeAssistant, callback from . import DATA_BACKUP_AGENT_LISTENERS, DOMAIN @@ -110,9 +116,9 @@ class KitchenSinkBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" for backup in self._uploads: if backup.backup_id == backup_id: return backup - return None + raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index e1ffe334038..aa722d27944 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.config_entries import ( SubentryFlowResult, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from . import DOMAIN @@ -80,30 +81,30 @@ class OptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(data=self.config_entry.options | user_input) - return self.async_show_form( - step_id="options_1", - data_schema=vol.Schema( - { - vol.Required("section_1"): data_entry_flow.section( - vol.Schema( - { - vol.Optional( - CONF_BOOLEAN, - default=self.config_entry.options.get( - CONF_BOOLEAN, False - ), - ): bool, - vol.Optional( - CONF_INT, - default=self.config_entry.options.get(CONF_INT, 10), - ): int, - } - ), - {"collapsed": False}, + data_schema = vol.Schema( + { + vol.Required("section_1"): data_entry_flow.section( + vol.Schema( + { + vol.Optional( + CONF_BOOLEAN, + default=self.config_entry.options.get( + CONF_BOOLEAN, False + ), + ): bool, + vol.Optional(CONF_INT): cv.positive_int, + } ), - } - ), + {"collapsed": False}, + ), + } ) + self.add_suggested_values_to_schema( + data_schema, + {"section_1": {"int": self.config_entry.options.get(CONF_INT, 10)}}, + ) + + return self.async_show_form(step_id="options_1", data_schema=data_schema) class SubentryFlowHandler(ConfigSubentryFlow): @@ -146,7 +147,7 @@ class SubentryFlowHandler(ConfigSubentryFlow): if user_input is not None: title = user_input.pop("name") return self.async_update_and_abort( - self._get_reconfigure_entry(), + self._get_entry(), self._get_reconfigure_subentry(), data=user_input, title=title, diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 19d1b31aeab..04cb833f0df 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPower +from homeassistant.const import DEGREE, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -87,6 +87,16 @@ async def async_setup_entry( state_class=None, unit_of_measurement=UnitOfPower.WATT, ), + DemoSensor( + device_unique_id="statistics_issues", + unique_id="statistics_issue_5", + device_name="Statistics issues", + entity_name="Issue 5", + state=100, + device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + unit_of_measurement=DEGREE, + ), ] ) diff --git a/homeassistant/components/knocki/config_flow.py b/homeassistant/components/knocki/config_flow.py index 654dd4a4d1f..7818c752a87 100644 --- a/homeassistant/components/knocki/config_flow.py +++ b/homeassistant/components/knocki/config_flow.py @@ -10,7 +10,9 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.exceptions import HomeAssistantError +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 .const import DOMAIN, LOGGER @@ -62,3 +64,19 @@ class KnockiConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, data_schema=DATA_SCHEMA, ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a DHCP discovery.""" + device_registry = dr.async_get(self.hass) + if device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, discovery_info.hostname)} + ): + device_registry.async_update_device( + device_entry.id, + new_connections={ + (dr.CONNECTION_NETWORK_MAC, discovery_info.macaddress) + }, + ) + return await super().async_step_dhcp(discovery_info) diff --git a/homeassistant/components/knocki/manifest.json b/homeassistant/components/knocki/manifest.json index a91119ca831..18f25f0ab0e 100644 --- a/homeassistant/components/knocki/manifest.json +++ b/homeassistant/components/knocki/manifest.json @@ -3,6 +3,11 @@ "name": "Knocki", "codeowners": ["@joostlek", "@jgatto1", "@JakeBosh"], "config_flow": true, + "dhcp": [ + { + "hostname": "knc*" + } + ], "documentation": "https://www.home-assistant.io/integrations/knocki", "integration_type": "hub", "iot_class": "cloud_push", diff --git a/homeassistant/components/knocki/quality_scale.yaml b/homeassistant/components/knocki/quality_scale.yaml index 45b3764d786..d1c5994b277 100644 --- a/homeassistant/components/knocki/quality_scale.yaml +++ b/homeassistant/components/knocki/quality_scale.yaml @@ -50,10 +50,8 @@ rules: # 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 + discovery-update-info: done + discovery: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index fa3439b02f4..8ad16642e45 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -486,7 +486,7 @@ class KNXModule: transcoder := DPTBase.parse_transcoder(dpt) ): self._address_filter_transcoder.update( - {_filter: transcoder for _filter in _filters} + dict.fromkeys(_filters, transcoder) ) return self.xknx.telegram_queue.register_telegram_received_cb( diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index eda160cd1a6..14a9016bcb9 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -84,9 +84,9 @@ CONF_KEYRING_FILE: Final = "knxkeys_file" CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type" CONF_KNX_TUNNELING_TYPE_LABELS: Final = { - CONF_KNX_TUNNELING: "UDP (Tunnelling v1)", - CONF_KNX_TUNNELING_TCP: "TCP (Tunnelling v2)", - CONF_KNX_TUNNELING_TCP_SECURE: "Secure Tunnelling (TCP)", + CONF_KNX_TUNNELING: "UDP (Tunneling v1)", + CONF_KNX_TUNNELING_TCP: "TCP (Tunneling v2)", + CONF_KNX_TUNNELING_TCP_SECURE: "Secure Tunneling (TCP)", } OPTION_MANUAL_TUNNEL: Final = "Manual" @@ -393,7 +393,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): except (vol.Invalid, XKNXException): errors[CONF_KNX_LOCAL_IP] = "invalid_ip_address" - selected_tunnelling_type = user_input[CONF_KNX_TUNNELING_TYPE] + selected_tunneling_type = user_input[CONF_KNX_TUNNELING_TYPE] if not errors: try: self._selected_tunnel = await request_description( @@ -406,16 +406,16 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): errors["base"] = "cannot_connect" else: if bool(self._selected_tunnel.tunnelling_requires_secure) is not ( - selected_tunnelling_type == CONF_KNX_TUNNELING_TCP_SECURE + selected_tunneling_type == CONF_KNX_TUNNELING_TCP_SECURE ) or ( - selected_tunnelling_type == CONF_KNX_TUNNELING_TCP + selected_tunneling_type == CONF_KNX_TUNNELING_TCP and not self._selected_tunnel.supports_tunnelling_tcp ): errors[CONF_KNX_TUNNELING_TYPE] = "unsupported_tunnel_type" if not errors: self.new_entry_data = KNXConfigEntryData( - connection_type=selected_tunnelling_type, + connection_type=selected_tunneling_type, host=_host, port=user_input[CONF_PORT], route_back=user_input[CONF_KNX_ROUTE_BACK], @@ -426,11 +426,11 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): tunnel_endpoint_ia=None, ) - if selected_tunnelling_type == CONF_KNX_TUNNELING_TCP_SECURE: + if selected_tunneling_type == CONF_KNX_TUNNELING_TCP_SECURE: return await self.async_step_secure_key_source_menu_tunnel() self.new_title = ( "Tunneling " - f"{'UDP' if selected_tunnelling_type == CONF_KNX_TUNNELING else 'TCP'} " + f"{'UDP' if selected_tunneling_type == CONF_KNX_TUNNELING else 'TCP'} " f"@ {_host}" ) return self.finish_flow() @@ -497,7 +497,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): async def async_step_secure_tunnel_manual( self, user_input: dict | None = None ) -> ConfigFlowResult: - """Configure ip secure tunnelling manually.""" + """Configure ip secure tunneling manually.""" errors: dict = {} if user_input is not None: diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index b403018dae3..3ce79b4ca7a 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -104,9 +104,9 @@ class KNXConfigEntryData(TypedDict, total=False): multicast_group: str multicast_port: int route_back: bool # not required - host: str # only required for tunnelling - port: int # only required for tunnelling - tunnel_endpoint_ia: str | None # tunnelling only - not required (use get()) + host: str # only required for tunneling + port: int # only required for tunneling + tunnel_endpoint_ia: str | None # tunneling only - not required (use get()) # KNX secure user_id: int | None # not required user_password: str | None # not required @@ -160,6 +160,7 @@ SUPPORTED_PLATFORMS_YAML: Final = { SUPPORTED_PLATFORMS_UI: Final = { Platform.BINARY_SENSOR, + Platform.COVER, Platform.LIGHT, Platform.SWITCH, } @@ -182,3 +183,13 @@ CURRENT_HVAC_ACTIONS: Final = { HVACMode.FAN_ONLY: HVACAction.FAN, HVACMode.DRY: HVACAction.DRYING, } + + +class CoverConf: + """Common config keys for cover.""" + + TRAVELLING_TIME_DOWN: Final = "travelling_time_down" + TRAVELLING_TIME_UP: Final = "travelling_time_up" + INVERT_UPDOWN: Final = "invert_updown" + INVERT_POSITION: Final = "invert_position" + INVERT_ANGLE: Final = "invert_angle" diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 3c5752b990c..3068e5d7ef1 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any +from typing import Any, Literal +from xknx import XKNX from xknx.devices import Cover as XknxCover from homeassistant import config_entries @@ -22,13 +22,28 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import KNX_MODULE_KEY -from .entity import KnxYamlEntity +from .const import CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY, CoverConf +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .schema import CoverSchema +from .storage.const import ( + CONF_ENTITY, + CONF_GA_ANGLE, + CONF_GA_PASSIVE, + CONF_GA_POSITION_SET, + CONF_GA_POSITION_STATE, + CONF_GA_STATE, + CONF_GA_STEP, + CONF_GA_STOP, + CONF_GA_UP_DOWN, + CONF_GA_WRITE, +) async def async_setup_entry( @@ -36,52 +51,47 @@ async def async_setup_entry( config_entry: config_entries.ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up cover(s) for KNX platform.""" + """Set up the KNX cover platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.COVER] + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.COVER, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiCover, + ), + ) - async_add_entities(KNXCover(knx_module, entity_config) for entity_config in config) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.COVER): + entities.extend( + KnxYamlCover(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.COVER): + entities.extend( + KnxUiCover(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXCover(KnxYamlEntity, CoverEntity): +class _KnxCover(CoverEntity): """Representation of a KNX cover.""" _device: XknxCover - def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Initialize the cover.""" - super().__init__( - knx_module=knx_module, - device=XknxCover( - xknx=knx_module.xknx, - name=config[CONF_NAME], - group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS), - group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS), - group_address_stop=config.get(CoverSchema.CONF_STOP_ADDRESS), - group_address_position_state=config.get( - CoverSchema.CONF_POSITION_STATE_ADDRESS - ), - group_address_angle=config.get(CoverSchema.CONF_ANGLE_ADDRESS), - group_address_angle_state=config.get( - CoverSchema.CONF_ANGLE_STATE_ADDRESS - ), - group_address_position=config.get(CoverSchema.CONF_POSITION_ADDRESS), - travel_time_down=config[CoverSchema.CONF_TRAVELLING_TIME_DOWN], - travel_time_up=config[CoverSchema.CONF_TRAVELLING_TIME_UP], - invert_updown=config[CoverSchema.CONF_INVERT_UPDOWN], - invert_position=config[CoverSchema.CONF_INVERT_POSITION], - invert_angle=config[CoverSchema.CONF_INVERT_ANGLE], - ), - ) - self._unsubscribe_auto_updater: Callable[[], None] | None = None - - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + def init_base(self) -> None: + """Initialize common attributes - may be based on xknx device instance.""" _supports_tilt = False self._attr_supported_features = ( - CoverEntityFeature.CLOSE - | CoverEntityFeature.OPEN - | CoverEntityFeature.SET_POSITION + CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN ) + if self._device.supports_position or self._device.supports_stop: + # when stop is supported, xknx travelcalculator can set position + self._attr_supported_features |= CoverEntityFeature.SET_POSITION if self._device.step.writable: _supports_tilt = True self._attr_supported_features |= ( @@ -97,13 +107,7 @@ class KNXCover(KnxYamlEntity, CoverEntity): if _supports_tilt: self._attr_supported_features |= CoverEntityFeature.STOP_TILT - self._attr_device_class = config.get(CONF_DEVICE_CLASS) or ( - CoverDeviceClass.BLIND if _supports_tilt else None - ) - self._attr_unique_id = ( - f"{self._device.updown.group_address}_" - f"{self._device.position_target.group_address}" - ) + self._attr_device_class = CoverDeviceClass.BLIND if _supports_tilt else None @property def current_cover_position(self) -> int | None: @@ -180,3 +184,102 @@ class KNXCover(KnxYamlEntity, CoverEntity): async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" await self._device.stop() + + +class KnxYamlCover(_KnxCover, KnxYamlEntity): + """Representation of a KNX cover configured from YAML.""" + + _device: XknxCover + + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: + """Initialize the cover.""" + super().__init__( + knx_module=knx_module, + device=XknxCover( + xknx=knx_module.xknx, + name=config[CONF_NAME], + group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS), + group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS), + group_address_stop=config.get(CoverSchema.CONF_STOP_ADDRESS), + group_address_position_state=config.get( + CoverSchema.CONF_POSITION_STATE_ADDRESS + ), + group_address_angle=config.get(CoverSchema.CONF_ANGLE_ADDRESS), + group_address_angle_state=config.get( + CoverSchema.CONF_ANGLE_STATE_ADDRESS + ), + group_address_position=config.get(CoverSchema.CONF_POSITION_ADDRESS), + travel_time_down=config[CoverConf.TRAVELLING_TIME_DOWN], + travel_time_up=config[CoverConf.TRAVELLING_TIME_UP], + invert_updown=config[CoverConf.INVERT_UPDOWN], + invert_position=config[CoverConf.INVERT_POSITION], + invert_angle=config[CoverConf.INVERT_ANGLE], + ), + ) + self.init_base() + + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = ( + f"{self._device.updown.group_address}_" + f"{self._device.position_target.group_address}" + ) + if custom_device_class := config.get(CONF_DEVICE_CLASS): + self._attr_device_class = custom_device_class + + +def _create_ui_cover(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxCover: + """Return a KNX Light device to be used within XKNX.""" + + def get_address( + key: str, address_type: Literal["write", "state"] = CONF_GA_WRITE + ) -> str | None: + """Get a single group address for given key.""" + return knx_config[key][address_type] if key in knx_config else None + + def get_addresses( + key: str, address_type: Literal["write", "state"] = CONF_GA_STATE + ) -> list[Any] | None: + """Get group address including passive addresses as list.""" + return ( + [knx_config[key][address_type], *knx_config[key][CONF_GA_PASSIVE]] + if key in knx_config + else None + ) + + return XknxCover( + xknx=xknx, + name=name, + group_address_long=get_addresses(CONF_GA_UP_DOWN, CONF_GA_WRITE), + group_address_short=get_addresses(CONF_GA_STEP, CONF_GA_WRITE), + group_address_stop=get_addresses(CONF_GA_STOP, CONF_GA_WRITE), + group_address_position=get_addresses(CONF_GA_POSITION_SET, CONF_GA_WRITE), + group_address_position_state=get_addresses(CONF_GA_POSITION_STATE), + group_address_angle=get_address(CONF_GA_ANGLE), + group_address_angle_state=get_addresses(CONF_GA_ANGLE), + travel_time_down=knx_config[CoverConf.TRAVELLING_TIME_DOWN], + travel_time_up=knx_config[CoverConf.TRAVELLING_TIME_UP], + invert_updown=knx_config.get(CoverConf.INVERT_UPDOWN, False), + invert_position=knx_config.get(CoverConf.INVERT_POSITION, False), + invert_angle=knx_config.get(CoverConf.INVERT_ANGLE, False), + sync_state=knx_config[CONF_SYNC_STATE], + ) + + +class KnxUiCover(_KnxCover, KnxUiEntity): + """Representation of a KNX cover configured from the UI.""" + + _device: XknxCover + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize KNX cover.""" + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + self._device = _create_ui_cover( + knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME] + ) + self.init_base() diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 6585b848d8a..461e6f25879 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -30,6 +30,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.util import dt as dt_util from .const import CONF_RESPOND_TO_READ, KNX_ADDRESS from .schema import ExposeSchema @@ -217,7 +218,7 @@ class KNXExposeTime: self.device = xknx_device_cls( self.xknx, name=expose_type.capitalize(), - localtime=True, + localtime=dt_util.get_default_time_zone(), group_address=config[KNX_ADDRESS], ) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 8cfb034a793..36c4bc71273 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.6.0", - "xknxproject==3.8.1", - "knx-frontend==2025.1.30.194235" + "xknx==3.8.0", + "xknxproject==3.8.2", + "knx-frontend==2025.4.1.91934" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index c9fe0bfc34e..e6dc0c1bb3e 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -56,6 +56,7 @@ from .const import ( CONF_SYNC_STATE, KNX_ADDRESS, ColorTempModes, + CoverConf, FanZeroMode, ) from .validation import ( @@ -453,11 +454,6 @@ class CoverSchema(KNXPlatformSchema): CONF_POSITION_STATE_ADDRESS = "position_state_address" CONF_ANGLE_ADDRESS = "angle_address" CONF_ANGLE_STATE_ADDRESS = "angle_state_address" - CONF_TRAVELLING_TIME_DOWN = "travelling_time_down" - CONF_TRAVELLING_TIME_UP = "travelling_time_up" - CONF_INVERT_UPDOWN = "invert_updown" - CONF_INVERT_POSITION = "invert_position" - CONF_INVERT_ANGLE = "invert_angle" DEFAULT_TRAVEL_TIME = 25 DEFAULT_NAME = "KNX Cover" @@ -474,14 +470,14 @@ class CoverSchema(KNXPlatformSchema): vol.Optional(CONF_ANGLE_ADDRESS): ga_list_validator, vol.Optional(CONF_ANGLE_STATE_ADDRESS): ga_list_validator, vol.Optional( - CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME + CoverConf.TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME ): cv.positive_float, vol.Optional( - CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME + CoverConf.TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME ): cv.positive_float, - vol.Optional(CONF_INVERT_UPDOWN, default=False): cv.boolean, - vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, - vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, + vol.Optional(CoverConf.INVERT_UPDOWN, default=False): cv.boolean, + vol.Optional(CoverConf.INVERT_POSITION, default=False): cv.boolean, + vol.Optional(CoverConf.INVERT_ANGLE, default=False): cv.boolean, vol.Optional(CONF_DEVICE_CLASS): COVER_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, } diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index f0f760180f4..fc28e0850ed 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -126,7 +126,7 @@ async def service_event_register_modify(call: ServiceCall) -> None: transcoder := DPTBase.parse_transcoder(dpt) ): knx_module.group_address_transcoder.update( - {_address: transcoder for _address in group_addresses} + dict.fromkeys(group_addresses, transcoder) ) for group_address in group_addresses: if group_address in knx_module.knx_event_callback.group_addresses: diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index cf3f2bb9f95..7cae0e9bbf6 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -27,3 +27,9 @@ CONF_GA_WHITE_BRIGHTNESS: Final = "ga_white_brightness" CONF_GA_WHITE_SWITCH: Final = "ga_white_switch" CONF_GA_HUE: Final = "ga_hue" CONF_GA_SATURATION: Final = "ga_saturation" +CONF_GA_UP_DOWN: Final = "ga_up_down" +CONF_GA_STOP: Final = "ga_stop" +CONF_GA_STEP: Final = "ga_step" +CONF_GA_POSITION_SET: Final = "ga_position_set" +CONF_GA_POSITION_STATE: Final = "ga_position_state" +CONF_GA_ANGLE: Final = "ga_angle" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index d99ffa86f52..85bcbd1809f 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -25,6 +25,7 @@ from ..const import ( DOMAIN, SUPPORTED_PLATFORMS_UI, ColorTempModes, + CoverConf, ) from ..validation import sync_state_validator from .const import ( @@ -33,6 +34,7 @@ from .const import ( CONF_DATA, CONF_DEVICE_INFO, CONF_ENTITY, + CONF_GA_ANGLE, CONF_GA_BLUE_BRIGHTNESS, CONF_GA_BLUE_SWITCH, CONF_GA_BRIGHTNESS, @@ -42,12 +44,17 @@ from .const import ( CONF_GA_GREEN_SWITCH, CONF_GA_HUE, CONF_GA_PASSIVE, + CONF_GA_POSITION_SET, + CONF_GA_POSITION_STATE, CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, CONF_GA_SENSOR, CONF_GA_STATE, + CONF_GA_STEP, + CONF_GA_STOP, CONF_GA_SWITCH, + CONF_GA_UP_DOWN, CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, CONF_GA_WRITE, @@ -114,22 +121,71 @@ BINARY_SENSOR_SCHEMA = vol.Schema( ), vol.Optional(CONF_RESET_AFTER): selector.NumberSelector( selector.NumberSelectorConfig( - min=0, max=10, step=0.1, unit_of_measurement="s" + min=0, max=600, step=0.1, unit_of_measurement="s" ) ), }, } ) -SWITCH_SCHEMA = vol.Schema( +COVER_SCHEMA = vol.Schema( { vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, - vol.Required(DOMAIN): { - vol.Optional(CONF_INVERT, default=False): bool, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - vol.Optional(CONF_RESPOND_TO_READ, default=False): bool, - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - }, + vol.Required(DOMAIN): vol.All( + vol.Schema( + { + **optional_ga_schema(CONF_GA_UP_DOWN, GASelector(state=False)), + vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), + **optional_ga_schema(CONF_GA_STOP, GASelector(state=False)), + **optional_ga_schema(CONF_GA_STEP, GASelector(state=False)), + **optional_ga_schema(CONF_GA_POSITION_SET, GASelector(state=False)), + **optional_ga_schema( + CONF_GA_POSITION_STATE, GASelector(write=False) + ), + vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(), + **optional_ga_schema(CONF_GA_ANGLE, GASelector()), + vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), + vol.Optional( + CoverConf.TRAVELLING_TIME_DOWN, default=25 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=1000, step=0.1, unit_of_measurement="s" + ) + ), + vol.Optional( + CoverConf.TRAVELLING_TIME_UP, default=25 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=1000, step=0.1, unit_of_measurement="s" + ) + ), + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + }, + extra=vol.REMOVE_EXTRA, + ), + vol.Any( + vol.Schema( + { + vol.Required(CONF_GA_UP_DOWN): GASelector( + state=False, write_required=True + ) + }, + extra=vol.ALLOW_EXTRA, + ), + vol.Schema( + { + vol.Required(CONF_GA_POSITION_SET): GASelector( + state=False, write_required=True + ) + }, + extra=vol.ALLOW_EXTRA, + ), + msg=( + "At least one of 'Up/Down control' or" + " 'Position - Set position' is required." + ), + ), + ), } ) @@ -226,6 +282,19 @@ LIGHT_SCHEMA = vol.Schema( } ) + +SWITCH_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + vol.Required(DOMAIN): { + vol.Optional(CONF_INVERT, default=False): bool, + vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Optional(CONF_RESPOND_TO_READ, default=False): bool, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + }, + } +) + ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( vol.Schema( { @@ -243,11 +312,14 @@ ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( Platform.BINARY_SENSOR: vol.Schema( {vol.Required(CONF_DATA): BINARY_SENSOR_SCHEMA}, extra=vol.ALLOW_EXTRA ), - Platform.SWITCH: vol.Schema( - {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA + Platform.COVER: vol.Schema( + {vol.Required(CONF_DATA): COVER_SCHEMA}, extra=vol.ALLOW_EXTRA ), Platform.LIGHT: vol.Schema( - {vol.Required("data"): LIGHT_SCHEMA}, extra=vol.ALLOW_EXTRA + {vol.Required(CONF_DATA): LIGHT_SCHEMA}, extra=vol.ALLOW_EXTRA + ), + Platform.SWITCH: vol.Schema( + {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA ), }, ), diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index 1ac99d192b8..a1510dbb384 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -43,7 +43,20 @@ class GASelector: self._add_group_addresses(schema) self._add_passive(schema) self._add_dpt(schema) - return vol.Schema(schema) + return vol.Schema( + vol.All( + schema, + vol.Schema( # one group address shall be included + vol.Any( + {vol.Required(CONF_GA_WRITE): vol.IsTrue()}, + {vol.Required(CONF_GA_STATE): vol.IsTrue()}, + {vol.Required(CONF_GA_PASSIVE): vol.IsTrue()}, + msg="At least one group address must be set", + ), + extra=vol.ALLOW_EXTRA, + ), + ) + ) def _add_group_addresses(self, schema: dict[vol.Marker, Any]) -> None: """Add basic group address items to the schema.""" diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 10730d87ed1..77228ea34d9 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -85,7 +85,7 @@ } }, "secure_tunnel_manual": { - "title": "Secure tunnelling", + "title": "Secure tunneling", "description": "Please enter your IP Secure information.", "data": { "user_id": "User ID", @@ -140,7 +140,7 @@ "keyfile_not_found": "The specified `.knxkeys` file was not found in the path config/.storage/knx/", "no_router_discovered": "No KNXnet/IP router was discovered on the network.", "no_tunnel_discovered": "Could not find a KNX tunneling server on your network.", - "unsupported_tunnel_type": "Selected tunnelling type not supported by gateway." + "unsupported_tunnel_type": "Selected tunneling type not supported by gateway." } }, "options": { @@ -315,11 +315,11 @@ "preset_mode": { "name": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::name%]", "state": { - "auto": "Auto", + "auto": "[%key:common::state::auto%]", + "building_protection": "Building protection", "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", - "standby": "Standby", "economy": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", - "building_protection": "Building protection" + "standby": "[%key:common::state::standby%]" } } } diff --git a/homeassistant/components/kodi/browse_media.py b/homeassistant/components/kodi/browse_media.py index 60e99d98cb1..3873f385881 100644 --- a/homeassistant/components/kodi/browse_media.py +++ b/homeassistant/components/kodi/browse_media.py @@ -152,7 +152,10 @@ async def item_payload(item, get_thumbnail_url=None): _LOGGER.debug("Unknown media type received: %s", media_content_type) raise UnknownMediaType from err - thumbnail = item.get("thumbnail") + if "art" in item: + thumbnail = item["art"].get("poster", item.get("thumbnail")) + else: + thumbnail = item.get("thumbnail") if thumbnail is not None and get_thumbnail_url is not None: thumbnail = await get_thumbnail_url( media_content_type, media_content_id, thumbnail_url=thumbnail @@ -237,14 +240,16 @@ async def get_media_info(media_library, search_id, search_type): title = None media = None - properties = ["thumbnail"] + properties = ["thumbnail", "art"] if search_type == MediaType.ALBUM: if search_id: album = await media_library.get_album_details( album_id=int(search_id), properties=properties ) thumbnail = media_library.thumbnail_url( - album["albumdetails"].get("thumbnail") + album["albumdetails"]["art"].get( + "poster", album["albumdetails"].get("thumbnail") + ) ) title = album["albumdetails"]["label"] media = await media_library.get_songs( @@ -256,6 +261,7 @@ async def get_media_info(media_library, search_id, search_type): "album", "thumbnail", "track", + "art", ], ) media = media.get("songs") @@ -274,7 +280,9 @@ async def get_media_info(media_library, search_id, search_type): artist_id=int(search_id), properties=properties ) thumbnail = media_library.thumbnail_url( - artist["artistdetails"].get("thumbnail") + artist["artistdetails"]["art"].get( + "poster", artist["artistdetails"].get("thumbnail") + ) ) title = artist["artistdetails"]["label"] else: @@ -293,9 +301,10 @@ async def get_media_info(media_library, search_id, search_type): movie_id=int(search_id), properties=properties ) thumbnail = media_library.thumbnail_url( - movie["moviedetails"].get("thumbnail") + movie["moviedetails"]["art"].get( + "poster", movie["moviedetails"].get("thumbnail") + ) ) - title = movie["moviedetails"]["label"] else: media = await media_library.get_movies(properties) media = media.get("movies") @@ -305,14 +314,16 @@ async def get_media_info(media_library, search_id, search_type): if search_id: media = await media_library.get_seasons( tv_show_id=int(search_id), - properties=["thumbnail", "season", "tvshowid"], + properties=["thumbnail", "season", "tvshowid", "art"], ) media = media.get("seasons") tvshow = await media_library.get_tv_show_details( tv_show_id=int(search_id), properties=properties ) thumbnail = media_library.thumbnail_url( - tvshow["tvshowdetails"].get("thumbnail") + tvshow["tvshowdetails"]["art"].get( + "poster", tvshow["tvshowdetails"].get("thumbnail") + ) ) title = tvshow["tvshowdetails"]["label"] else: @@ -325,7 +336,7 @@ async def get_media_info(media_library, search_id, search_type): media = await media_library.get_episodes( tv_show_id=int(tv_show_id), season_id=int(season_id), - properties=["thumbnail", "tvshowid", "seasonid"], + properties=["thumbnail", "tvshowid", "seasonid", "art"], ) media = media.get("episodes") if media: @@ -333,7 +344,9 @@ async def get_media_info(media_library, search_id, search_type): season_id=int(media[0]["seasonid"]), properties=properties ) thumbnail = media_library.thumbnail_url( - season["seasondetails"].get("thumbnail") + season["seasondetails"]["art"].get( + "poster", season["seasondetails"].get("thumbnail") + ) ) title = season["seasondetails"]["label"] @@ -343,6 +356,7 @@ async def get_media_info(media_library, search_id, search_type): properties=["thumbnail", "channeltype", "channel", "broadcastnow"], ) media = media.get("channels") + title = "Channels" return thumbnail, title, media diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index 3f1a27302d8..d6bdab37a9c 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as KONNECTED_DOMAIN +from .const import DOMAIN async def async_setup_entry( @@ -24,7 +24,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors attached to a Konnected device from a config entry.""" - data = hass.data[KONNECTED_DOMAIN] + data = hass.data[DOMAIN] device_id = config_entry.data["id"] sensors = [ KonnectedBinarySensor(device_id, pin_num, pin_data) @@ -48,7 +48,7 @@ class KonnectedBinarySensor(BinarySensorEntity): self._attr_unique_id = f"{device_id}-{zone_num}" self._attr_name = data.get(CONF_NAME) self._attr_device_info = DeviceInfo( - identifiers={(KONNECTED_DOMAIN, device_id)}, + identifiers={(DOMAIN, device_id)}, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index cd36c217627..155e99a7002 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -22,7 +22,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW +from .const import DOMAIN, SIGNAL_DS18B20_NEW SENSOR_TYPES: dict[str, SensorEntityDescription] = { "temperature": SensorEntityDescription( @@ -46,7 +46,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors attached to a Konnected device from a config entry.""" - data = hass.data[KONNECTED_DOMAIN] + data = hass.data[DOMAIN] device_id = config_entry.data["id"] # Initialize all DHT sensors. @@ -121,7 +121,7 @@ class KonnectedSensor(SensorEntity): name += f" {description.name}" self._attr_name = name - self._attr_device_info = DeviceInfo(identifiers={(KONNECTED_DOMAIN, device_id)}) + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) async def async_added_to_hass(self) -> None: """Store entity_id and register state change callback.""" diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json index e1a6863a199..df92e014f12 100644 --- a/homeassistant/components/konnected/strings.json +++ b/homeassistant/components/konnected/strings.json @@ -2,19 +2,19 @@ "config": { "step": { "import_confirm": { - "title": "Import Konnected Device", - "description": "A Konnected Alarm Panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry." + "title": "Import Konnected device", + "description": "A Konnected alarm panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry." }, "user": { - "description": "Please enter the host information for your Konnected Panel.", + "description": "Please enter the host information for your Konnected panel.", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]" } }, "confirm": { - "title": "Konnected Device Ready", - "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings." + "title": "Konnected device ready", + "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected alarm panel settings." } }, "error": { @@ -45,8 +45,8 @@ } }, "options_io_ext": { - "title": "Configure Extended I/O", - "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.", + "title": "Configure extended I/O", + "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.", "data": { "8": "Zone 8", "9": "Zone 9", @@ -59,25 +59,25 @@ } }, "options_binary": { - "title": "Configure Binary Sensor", + "title": "Configure binary sensor", "description": "{zone} options", "data": { - "type": "Binary Sensor Type", + "type": "Binary sensor type", "name": "[%key:common::config_flow::data::name%]", "inverse": "Invert the open/close state" } }, "options_digital": { - "title": "Configure Digital Sensor", + "title": "Configure digital sensor", "description": "[%key:component::konnected::options::step::options_binary::description%]", "data": { - "type": "Sensor Type", + "type": "Sensor type", "name": "[%key:common::config_flow::data::name%]", - "poll_interval": "Poll Interval (minutes)" + "poll_interval": "Poll interval (minutes)" } }, "options_switch": { - "title": "Configure Switchable Output", + "title": "Configure switchable output", "description": "{zone} options: state {state}", "data": { "name": "[%key:common::config_flow::data::name%]", @@ -89,18 +89,18 @@ } }, "options_misc": { - "title": "Configure Misc", + "title": "Configure misc", "description": "Please select the desired behavior for your panel", "data": { "discovery": "Respond to discovery requests on your network", "blink": "Blink panel LED on when sending state change", - "override_api_host": "Override default Home Assistant API host panel URL", - "api_host": "Override API host URL" + "override_api_host": "Override default Home Assistant API host URL", + "api_host": "Custom API host URL" } } }, "error": { - "bad_host": "Invalid Override API host URL" + "bad_host": "Invalid custom API host URL" }, "abort": { "not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]" diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index 58311502cbe..54f74f0d461 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -22,7 +22,7 @@ from .const import ( CONF_ACTIVATION, CONF_MOMENTARY, CONF_PAUSE, - DOMAIN as KONNECTED_DOMAIN, + DOMAIN, STATE_HIGH, STATE_LOW, ) @@ -36,7 +36,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches attached to a Konnected device from a config entry.""" - data = hass.data[KONNECTED_DOMAIN] + data = hass.data[DOMAIN] device_id = config_entry.data["id"] switches = [ KonnectedSwitch(device_id, zone_data.get(CONF_ZONE), zone_data) @@ -63,12 +63,12 @@ class KonnectedSwitch(SwitchEntity): f"{device_id}-{self._zone_num}-{self._momentary}-" f"{self._pause}-{self._repeat}" ) - self._attr_device_info = DeviceInfo(identifiers={(KONNECTED_DOMAIN, device_id)}) + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) @property def panel(self): """Return the Konnected HTTP client.""" - device_data = self.hass.data[KONNECTED_DOMAIN][CONF_DEVICES][self._device_id] + device_data = self.hass.data[DOMAIN][CONF_DEVICES][self._device_id] return device_data.get("panel") @property diff --git a/homeassistant/components/kostal_plenticore/config_flow.py b/homeassistant/components/kostal_plenticore/config_flow.py index 59c737a0874..cce220006c5 100644 --- a/homeassistant/components/kostal_plenticore/config_flow.py +++ b/homeassistant/components/kostal_plenticore/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_BASE, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .const import CONF_SERVICE_CODE, DOMAIN from .helper import get_hostname_id _LOGGER = logging.getLogger(__name__) @@ -21,6 +21,7 @@ DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_SERVICE_CODE): str, } ) @@ -32,8 +33,10 @@ async def test_connection(hass: HomeAssistant, data) -> str: """ session = async_get_clientsession(hass) - async with ApiClient(session, data["host"]) as client: - await client.login(data["password"]) + async with ApiClient(session, data[CONF_HOST]) as client: + await client.login( + data[CONF_PASSWORD], service_code=data.get(CONF_SERVICE_CODE) + ) hostname_id = await get_hostname_id(client) values = await client.get_setting_values("scb:network", hostname_id) @@ -70,3 +73,30 @@ class KostalPlenticoreConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Add reconfigure step to allow to reconfigure a config entry.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + try: + hostname = await test_connection(self.hass, user_input) + except AuthenticationException as ex: + errors[CONF_PASSWORD] = "invalid_auth" + _LOGGER.error("Error response: %s", ex) + except (ClientError, TimeoutError): + errors[CONF_HOST] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors[CONF_BASE] = "unknown" + else: + return self.async_update_reload_and_abort( + entry=self._get_reconfigure_entry(), title=hostname, data=user_input + ) + + return self.async_show_form( + step_id="reconfigure", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index 668b10e6971..e67f9298438 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -1,3 +1,4 @@ """Constants for the Kostal Plenticore Solar Inverter integration.""" DOMAIN = "kostal_plenticore" +CONF_SERVICE_CODE = "service_code" diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py index a404a997663..f87f8ca630a 100644 --- a/homeassistant/components/kostal_plenticore/coordinator.py +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import CONF_SERVICE_CODE, DOMAIN from .helper import get_hostname_id _LOGGER = logging.getLogger(__name__) @@ -60,7 +60,10 @@ class Plenticore: async_get_clientsession(self.hass), host=self.host ) try: - await self._client.login(self.config_entry.data[CONF_PASSWORD]) + await self._client.login( + self.config_entry.data[CONF_PASSWORD], + service_code=self.config_entry.data.get(CONF_SERVICE_CODE), + ) except AuthenticationException as err: _LOGGER.error( "Authentication exception connecting to %s: %s", self.host, err diff --git a/homeassistant/components/kostal_plenticore/strings.json b/homeassistant/components/kostal_plenticore/strings.json index 30ce5af5a6c..80a6748e327 100644 --- a/homeassistant/components/kostal_plenticore/strings.json +++ b/homeassistant/components/kostal_plenticore/strings.json @@ -4,7 +4,15 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "service_code": "Service code" + } + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "service_code": "[%key:component::kostal_plenticore::config::step::user::data::service_code%]" } } }, @@ -14,7 +22,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "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%]" } } } diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index 9a90e77f2b6..c981f3fd438 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -145,7 +145,10 @@ class KrakenData: await asyncio.sleep(CALL_RATE_LIMIT_SLEEP) def _get_websocket_name_asset_pairs(self) -> str: - return ",".join(wsname for wsname in self.tradable_asset_pairs.values()) + return ",".join( + self.tradable_asset_pairs[tracked_pair] + for tracked_pair in self._config_entry.options[CONF_TRACKED_ASSET_PAIRS] + ) def set_update_interval(self, update_interval: int) -> None: """Set the coordinator update_interval to the supplied update_interval.""" diff --git a/homeassistant/components/kraken/strings.json b/homeassistant/components/kraken/strings.json index c636dbf8d1f..d30e2bb2dff 100644 --- a/homeassistant/components/kraken/strings.json +++ b/homeassistant/components/kraken/strings.json @@ -14,7 +14,7 @@ "init": { "data": { "scan_interval": "Update interval", - "tracked_asset_pairs": "Tracked Asset Pairs" + "tracked_asset_pairs": "Tracked asset pairs" } } } @@ -40,10 +40,10 @@ "name": "Volume last 24h" }, "volume_weighted_average_today": { - "name": "Volume weighted average today" + "name": "Volume-weighted average today" }, "volume_weighted_average_last_24h": { - "name": "Volume weighted average last 24h" + "name": "Volume-weighted average last 24h" }, "number_of_trades_today": { "name": "Number of trades today" diff --git a/homeassistant/components/kulersky/__init__.py b/homeassistant/components/kulersky/__init__.py index 6c8037bdafc..b123a4cc035 100644 --- a/homeassistant/components/kulersky/__init__.py +++ b/homeassistant/components/kulersky/__init__.py @@ -1,21 +1,31 @@ """Kuler Sky lights integration.""" -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +import logging -from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN +from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry +from homeassistant.const import CONF_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN PLATFORMS = [Platform.LIGHT] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Kuler Sky from a config entry.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - if DATA_ADDRESSES not in hass.data[DOMAIN]: - hass.data[DOMAIN][DATA_ADDRESSES] = set() - + ble_device = async_ble_device_from_address( + hass, entry.data[CONF_ADDRESS], connectable=True + ) + if not ble_device: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -23,11 +33,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - # Stop discovery - unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None) - if unregister_discovery: - unregister_discovery() - - hass.data.pop(DOMAIN, None) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + # Version 1 was a single entry instance that started a bluetooth discovery + # thread to add devices. Version 2 has one config entry per device, and + # supports core bluetooth discovery + if config_entry.version == 1: + dev_reg = dr.async_get(hass) + devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id) + + if len(devices) == 0: + _LOGGER.error("Unable to migrate; No devices registered") + return False + + first_device = devices[0] + domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN] + address = next(iter(domain_identifiers))[1] + hass.config_entries.async_update_entry( + config_entry, + title=first_device.name or address, + data={CONF_ADDRESS: address}, + unique_id=address, + version=2, + ) + + # Create new config flows for the remaining devices + for device in devices[1:]: + domain_identifiers = [i for i in device.identifiers if i[0] == DOMAIN] + address = next(iter(domain_identifiers))[1] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={CONF_ADDRESS: address}, + ) + ) + + _LOGGER.debug("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/kulersky/config_flow.py b/homeassistant/components/kulersky/config_flow.py index fca214dd9a3..f27d2ef0ea0 100644 --- a/homeassistant/components/kulersky/config_flow.py +++ b/homeassistant/components/kulersky/config_flow.py @@ -1,26 +1,143 @@ """Config flow for Kuler Sky.""" import logging +from typing import Any +from bluetooth_data_tools import human_readable_name import pykulersky +import voluptuous as vol -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, + async_last_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS -from .const import DOMAIN +from .const import DOMAIN, EXPECTED_SERVICE_UUID _LOGGER = logging.getLogger(__name__) -async def _async_has_devices(hass: HomeAssistant) -> bool: - """Return if there are devices that can be discovered.""" - # Check if there are any devices that can be discovered in the network. - try: - devices = await pykulersky.discover() - except pykulersky.PykulerskyException as exc: - _LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc) - return False - return len(devices) > 0 +class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Kulersky.""" + VERSION = 2 -config_entry_flow.register_discovery_flow(DOMAIN, "Kuler Sky", _async_has_devices) + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + + async def async_step_integration_discovery( + self, discovery_info: dict[str, str] + ) -> ConfigFlowResult: + """Handle the integration discovery step. + + The old version of the integration used to have multiple + device in a single config entry. This is now deprecated. + The integration discovery step is used to create config + entries for each device beyond the first one. + """ + address: str = discovery_info[CONF_ADDRESS] + if service_info := async_last_service_info(self.hass, address): + title = human_readable_name(None, service_info.name, service_info.address) + else: + title = address + await self.async_set_unique_id(address) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=title, + data={CONF_ADDRESS: address}, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._discovery_info = discovery_info + self.context["title_placeholders"] = { + "name": human_readable_name( + None, discovery_info.name, discovery_info.address + ) + } + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + discovery_info = self._discovered_devices[address] + local_name = human_readable_name( + None, discovery_info.name, discovery_info.address + ) + await self.async_set_unique_id( + discovery_info.address, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + kulersky_light = None + try: + kulersky_light = pykulersky.Light(discovery_info.address) + await kulersky_light.connect() + except pykulersky.PykulerskyException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=local_name, + data={ + CONF_ADDRESS: discovery_info.address, + }, + ) + finally: + if kulersky_light: + await kulersky_light.disconnect() + + if discovery := self._discovery_info: + self._discovered_devices[discovery.address] = discovery + else: + current_addresses = self._async_current_ids() + for discovery in async_discovered_service_info(self.hass): + if ( + discovery.address in current_addresses + or discovery.address in self._discovered_devices + or EXPECTED_SERVICE_UUID not in discovery.service_uuids + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + if self._discovery_info: + data_schema = vol.Schema( + {vol.Required(CONF_ADDRESS): self._discovery_info.address} + ) + else: + data_schema = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + service_info.address: ( + f"{service_info.name} ({service_info.address})" + ) + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/kulersky/const.py b/homeassistant/components/kulersky/const.py index 8d0b4380bb3..c735b4774f9 100644 --- a/homeassistant/components/kulersky/const.py +++ b/homeassistant/components/kulersky/const.py @@ -4,3 +4,5 @@ DOMAIN = "kulersky" DATA_ADDRESSES = "addresses" DATA_DISCOVERY_SUBSCRIPTION = "discovery_subscription" + +EXPECTED_SERVICE_UUID = "8d96a001-0002-64c2-0001-9acc4838521c" diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index bcc3f32dceb..d6a45ed1ebe 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -2,12 +2,12 @@ from __future__ import annotations -from datetime import timedelta import logging from typing import Any import pykulersky +from homeassistant.components import bluetooth from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_RGBW_COLOR, @@ -15,18 +15,15 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -DISCOVERY_INTERVAL = timedelta(seconds=60) - async def async_setup_entry( hass: HomeAssistant, @@ -34,32 +31,15 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Kuler sky light devices.""" - - async def discover(*args): - """Attempt to discover new lights.""" - lights = await pykulersky.discover() - - # Filter out already discovered lights - new_lights = [ - light - for light in lights - if light.address not in hass.data[DOMAIN][DATA_ADDRESSES] - ] - - new_entities = [] - for light in new_lights: - hass.data[DOMAIN][DATA_ADDRESSES].add(light.address) - new_entities.append(KulerskyLight(light)) - - async_add_entities(new_entities, update_before_add=True) - - # Start initial discovery - hass.async_create_task(discover()) - - # Perform recurring discovery of new devices - hass.data[DOMAIN][DATA_DISCOVERY_SUBSCRIPTION] = async_track_time_interval( - hass, discover, DISCOVERY_INTERVAL + ble_device = bluetooth.async_ble_device_from_address( + hass, config_entry.data[CONF_ADDRESS], connectable=True ) + entity = KulerskyLight( + config_entry.title, + config_entry.data[CONF_ADDRESS], + pykulersky.Light(ble_device), + ) + async_add_entities([entity], update_before_add=True) class KulerskyLight(LightEntity): @@ -71,37 +51,30 @@ class KulerskyLight(LightEntity): _attr_supported_color_modes = {ColorMode.RGBW} _attr_color_mode = ColorMode.RGBW - def __init__(self, light: pykulersky.Light) -> None: + def __init__(self, name: str, address: str, light: pykulersky.Light) -> None: """Initialize a Kuler Sky light.""" self._light = light - self._attr_unique_id = light.address + self._attr_unique_id = address self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, light.address)}, + identifiers={(DOMAIN, address)}, + connections={(CONNECTION_BLUETOOTH, address)}, manufacturer="Brightech", - name=light.name, + name=name, ) - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - self.async_on_remove( - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self.async_will_remove_from_hass - ) - ) - - async def async_will_remove_from_hass(self, *args) -> None: + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" try: await self._light.disconnect() except pykulersky.PykulerskyException: _LOGGER.debug( - "Exception disconnected from %s", self._light.address, exc_info=True + "Exception disconnected from %s", self._attr_unique_id, exc_info=True ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if light is on.""" - return self.brightness > 0 + return self.brightness is not None and self.brightness > 0 async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" @@ -133,11 +106,13 @@ class KulerskyLight(LightEntity): rgbw = await self._light.get_color() except pykulersky.PykulerskyException as exc: if self._attr_available: - _LOGGER.warning("Unable to connect to %s: %s", self._light.address, exc) + _LOGGER.warning( + "Unable to connect to %s: %s", self._attr_unique_id, exc + ) self._attr_available = False return if self._attr_available is False: - _LOGGER.warning("Reconnected to %s", self._light.address) + _LOGGER.info("Reconnected to %s", self._attr_unique_id) self._attr_available = True brightness = max(rgbw) diff --git a/homeassistant/components/kulersky/manifest.json b/homeassistant/components/kulersky/manifest.json index e0d9ec4fe36..a838c47c698 100644 --- a/homeassistant/components/kulersky/manifest.json +++ b/homeassistant/components/kulersky/manifest.json @@ -1,10 +1,16 @@ { "domain": "kulersky", "name": "Kuler Sky", + "bluetooth": [ + { + "service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c" + } + ], "codeowners": ["@emlove"], "config_flow": true, + "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/kulersky", "iot_class": "local_polling", "loggers": ["bleak", "pykulersky"], - "requirements": ["pykulersky==0.5.2"] + "requirements": ["pykulersky==0.5.8"] } diff --git a/homeassistant/components/kulersky/strings.json b/homeassistant/components/kulersky/strings.json index ad8f0f41ae7..959d7d0690a 100644 --- a/homeassistant/components/kulersky/strings.json +++ b/homeassistant/components/kulersky/strings.json @@ -1,13 +1,23 @@ { "config": { "step": { - "confirm": { - "description": "[%key:common::config_flow::description::confirm_setup%]" + "user": { + "data": { + "address": "[%key:common::config_flow::data::device%]" + } } }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "exceptions": { + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" } } } diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 667fcbb8dcc..dde8dfd54a2 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -106,6 +106,7 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=DEGREE, suggested_display_precision=2, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), "WetDry": LaCrosseSensorEntityDescription( key="WetDry", diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index d20616e1940..ff977438f38 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -1,36 +1,37 @@ """The La Marzocco integration.""" +import asyncio import logging from packaging import version -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 import ( + LaMarzoccoBluetoothClient, + LaMarzoccoCloudClient, + LaMarzoccoMachine, +) +from pylamarzocco.const import FirmwareType from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from homeassistant.components.bluetooth import async_discovered_service_info from homeassistant.const import ( - CONF_HOST, CONF_MAC, - CONF_MODEL, - CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME, Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import ( LaMarzoccoConfigEntry, LaMarzoccoConfigUpdateCoordinator, - LaMarzoccoFirmwareUpdateCoordinator, LaMarzoccoRuntimeData, + LaMarzoccoScheduleUpdateCoordinator, + LaMarzoccoSettingsUpdateCoordinator, LaMarzoccoStatisticsUpdateCoordinator, ) @@ -45,6 +46,8 @@ PLATFORMS = [ Platform.UPDATE, ] +BT_MODEL_PREFIXES = ("MICRA", "MINI", "GS3") + _LOGGER = logging.getLogger(__name__) @@ -54,31 +57,47 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - assert entry.unique_id serial = entry.unique_id - client = async_create_clientsession(hass) + client = async_get_clientsession(hass) cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], client=client, ) - # initialize local API - local_client: LaMarzoccoLocalClient | None = None - if (host := entry.data.get(CONF_HOST)) is not None: - _LOGGER.debug("Initializing local API") - local_client = LaMarzoccoLocalClient( - host=host, - local_bearer=entry.data[CONF_TOKEN], - client=client, + try: + settings = await cloud_client.get_thing_settings(serial) + except AuthFail as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from ex + except (RequestNotSuccessful, TimeoutError) as ex: + _LOGGER.debug(ex, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="api_error" + ) from ex + + gateway_version = version.parse( + settings.firmwares[FirmwareType.GATEWAY].build_version + ) + + if gateway_version < version.parse("v5.0.9"): + # incompatible gateway firmware, create an issue + ir.async_create_issue( + hass, + DOMAIN, + "unsupported_gateway_firmware", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="unsupported_gateway_firmware", + translation_placeholders={"gateway_version": str(gateway_version)}, ) # initialize Bluetooth bluetooth_client: LaMarzoccoBluetoothClient | None = None - if entry.options.get(CONF_USE_BLUETOOTH, True): - - def bluetooth_configured() -> bool: - return entry.data.get(CONF_MAC, "") and entry.data.get(CONF_NAME, "") - - if not bluetooth_configured(): + if entry.options.get(CONF_USE_BLUETOOTH, True) and ( + token := settings.ble_auth_token + ): + if CONF_MAC not in entry.data: for discovery_info in async_discovered_service_info(hass): if ( (name := discovery_info.name) @@ -92,55 +111,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - data={ **entry.data, CONF_MAC: discovery_info.address, - CONF_NAME: discovery_info.name, }, ) - break - if bluetooth_configured(): + if not entry.data[CONF_TOKEN]: + # update the token in the config entry + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_TOKEN: token, + }, + ) + + if CONF_MAC in entry.data: _LOGGER.debug("Initializing Bluetooth device") bluetooth_client = LaMarzoccoBluetoothClient( - username=entry.data[CONF_USERNAME], - serial_number=serial, - token=entry.data[CONF_TOKEN], address_or_ble_device=entry.data[CONF_MAC], + ble_token=token, ) 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, ) coordinators = LaMarzoccoRuntimeData( - LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client), - LaMarzoccoFirmwareUpdateCoordinator(hass, entry, device), + LaMarzoccoConfigUpdateCoordinator(hass, entry, device), + LaMarzoccoSettingsUpdateCoordinator(hass, entry, device), + LaMarzoccoScheduleUpdateCoordinator(hass, entry, device), LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device), ) - # 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() + await asyncio.gather( + coordinators.config_coordinator.async_config_entry_first_refresh(), + coordinators.settings_coordinator.async_config_entry_first_refresh(), + coordinators.schedule_coordinator.async_config_entry_first_refresh(), + 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( - hass, - DOMAIN, - "unsupported_gateway_firmware", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="unsupported_gateway_firmware", - translation_placeholders={"gateway_version": gateway_version}, - ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def update_listener( @@ -162,41 +174,45 @@ async def async_migrate_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry ) -> bool: """Migrate config entry.""" - if entry.version > 2: + if entry.version > 3: # guard against downgrade from a future version return False if entry.version == 1: + _LOGGER.error( + "Migration from version 1 is no longer supported, please remove and re-add the integration" + ) + return False + + if entry.version == 2: cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], ) try: - fleet = await cloud_client.get_customer_fleet() + things = await cloud_client.list_things() except (AuthFail, RequestNotSuccessful) as exc: _LOGGER.error("Migration failed with error %s", exc) return False - - assert entry.unique_id is not None - device = fleet[entry.unique_id] - v2_data = { + v3_data = { CONF_USERNAME: entry.data[CONF_USERNAME], CONF_PASSWORD: entry.data[CONF_PASSWORD], - CONF_MODEL: device.model, - CONF_NAME: device.name, - CONF_TOKEN: device.communication_key, + CONF_TOKEN: next( + ( + thing.ble_auth_token + for thing in things + if thing.serial_number == entry.unique_id + ), + None, + ), } - - if CONF_HOST in entry.data: - v2_data[CONF_HOST] = entry.data[CONF_HOST] - if CONF_MAC in entry.data: - v2_data[CONF_MAC] = entry.data[CONF_MAC] - + v3_data[CONF_MAC] = entry.data[CONF_MAC] hass.config_entries.async_update_entry( entry, - data=v2_data, - version=2, + data=v3_data, + version=3, ) _LOGGER.debug("Migrated La Marzocco config entry to version 2") + return True diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index a98cddcda9c..9bf04129095 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -2,9 +2,11 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import cast -from pylamarzocco.const import MachineModel -from pylamarzocco.models import LaMarzoccoMachineConfig +from pylamarzocco import LaMarzoccoMachine +from pylamarzocco.const import BackFlushStatus, MachineState, WidgetType +from pylamarzocco.models import BackFlush, MachineStatus from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -16,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LaMarzoccoConfigEntry -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -29,7 +31,7 @@ class LaMarzoccoBinarySensorEntityDescription( ): """Description of a La Marzocco binary sensor.""" - is_on_fn: Callable[[LaMarzoccoMachineConfig], bool | None] + is_on_fn: Callable[[LaMarzoccoMachine], bool | None] ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( @@ -37,33 +39,41 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( key="water_tank", translation_key="water_tank", device_class=BinarySensorDeviceClass.PROBLEM, - is_on_fn=lambda config: not config.water_contact, + is_on_fn=lambda machine: WidgetType.CM_NO_WATER in machine.dashboard.config, entity_category=EntityCategory.DIAGNOSTIC, - supported_fn=lambda coordinator: coordinator.local_connection_configured, ), LaMarzoccoBinarySensorEntityDescription( key="brew_active", translation_key="brew_active", device_class=BinarySensorDeviceClass.RUNNING, - is_on_fn=lambda config: config.brew_active, - available_fn=lambda device: device.websocket_connected, + is_on_fn=( + lambda machine: cast( + MachineStatus, machine.dashboard.config[WidgetType.CM_MACHINE_STATUS] + ).status + is MachineState.BREWING + ), + available_fn=lambda coordinator: not coordinator.websocket_terminated, entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoBinarySensorEntityDescription( key="backflush_enabled", translation_key="backflush_enabled", device_class=BinarySensorDeviceClass.RUNNING, - is_on_fn=lambda config: config.backflush_enabled, + is_on_fn=( + lambda machine: cast( + BackFlush, machine.dashboard.config[WidgetType.CM_BACK_FLUSH] + ).status + is BackFlushStatus.REQUESTED + ), entity_category=EntityCategory.DIAGNOSTIC, ), -) - -SCALE_ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( LaMarzoccoBinarySensorEntityDescription( - key="connected", + key="websocket_connected", + translation_key="websocket_connected", device_class=BinarySensorDeviceClass.CONNECTIVITY, - is_on_fn=lambda config: config.scale.connected if config.scale else None, + is_on_fn=(lambda machine: machine.websocket.connected), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), ) @@ -76,30 +86,11 @@ async def async_setup_entry( """Set up binary sensor entities.""" coordinator = entry.runtime_data.config_coordinator - entities = [ + async_add_entities( LaMarzoccoBinarySensorEntity(coordinator, description) for description in ENTITIES if description.supported_fn(coordinator) - ] - - if ( - coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) - 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): @@ -110,12 +101,4 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity): @property 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 + return self.entity_description.is_on_fn(self.coordinator.device) diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py index 4365bf56b2d..e4673372d0a 100644 --- a/homeassistant/components/lamarzocco/calendar.py +++ b/homeassistant/components/lamarzocco/calendar.py @@ -3,7 +3,7 @@ from collections.abc import Iterator from datetime import datetime, timedelta -from pylamarzocco.models import LaMarzoccoWakeUpSleepEntry +from pylamarzocco.const import WeekDay from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -18,15 +18,15 @@ PARALLEL_UPDATES = 0 CALENDAR_KEY = "auto_on_off_schedule" -DAY_OF_WEEK = [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", -] +WEEKDAY_TO_ENUM = { + 0: WeekDay.MONDAY, + 1: WeekDay.TUESDAY, + 2: WeekDay.WEDNESDAY, + 3: WeekDay.THURSDAY, + 4: WeekDay.FRIDAY, + 5: WeekDay.SATURDAY, + 6: WeekDay.SUNDAY, +} async def async_setup_entry( @@ -36,10 +36,12 @@ async def async_setup_entry( ) -> None: """Set up switch entities and services.""" - coordinator = entry.runtime_data.config_coordinator + coordinator = entry.runtime_data.schedule_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() + LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, schedule.identifier) + for schedule in coordinator.device.schedule.smart_wake_up_sleep.schedules + if schedule.identifier ) @@ -52,12 +54,12 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity): self, coordinator: LaMarzoccoUpdateCoordinator, key: str, - wake_up_sleep_entry: LaMarzoccoWakeUpSleepEntry, + identifier: str, ) -> None: """Set up calendar.""" - super().__init__(coordinator, f"{key}_{wake_up_sleep_entry.entry_id}") - self.wake_up_sleep_entry = wake_up_sleep_entry - self._attr_translation_placeholders = {"id": wake_up_sleep_entry.entry_id} + super().__init__(coordinator, f"{key}_{identifier}") + self._identifier = identifier + self._attr_translation_placeholders = {"id": identifier} @property def event(self) -> CalendarEvent | None: @@ -112,24 +114,31 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity): def _async_get_calendar_event(self, date: datetime) -> CalendarEvent | None: """Return calendar event for a given weekday.""" + schedule_entry = ( + self.coordinator.device.schedule.smart_wake_up_sleep.schedules_dict[ + self._identifier + ] + ) # check first if auto/on off is turned on in general - if not self.wake_up_sleep_entry.enabled: + if not schedule_entry.enabled: return None # parse the schedule for the day - if DAY_OF_WEEK[date.weekday()] not in self.wake_up_sleep_entry.days: + if WEEKDAY_TO_ENUM[date.weekday()] not in schedule_entry.days: return None - hour_on, minute_on = self.wake_up_sleep_entry.time_on.split(":") - hour_off, minute_off = self.wake_up_sleep_entry.time_off.split(":") + hour_on = schedule_entry.on_time_minutes // 60 + minute_on = schedule_entry.on_time_minutes % 60 + hour_off = schedule_entry.off_time_minutes // 60 + minute_off = schedule_entry.off_time_minutes % 60 - # if off time is 24:00, then it means the off time is the next day - # only for legacy schedules day_offset = 0 - if hour_off == "24": + if hour_off == 24: + # if the machine is scheduled to turn off at midnight, we need to + # set the end date to the next day day_offset = 1 - hour_off = "0" + hour_off = 0 end_date = date.replace( hour=int(hour_off), diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 87a9824423a..8cb2e4dfc61 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -7,10 +7,9 @@ import logging from typing import Any from aiohttp import ClientSession -from pylamarzocco.clients.cloud import LaMarzoccoCloudClient -from pylamarzocco.clients.local import LaMarzoccoLocalClient +from pylamarzocco import LaMarzoccoCloudClient from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoDeviceInfo +from pylamarzocco.models import Thing import voluptuous as vol from homeassistant.components.bluetooth import ( @@ -26,9 +25,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import ( CONF_ADDRESS, - CONF_HOST, CONF_MAC, - CONF_MODEL, CONF_NAME, CONF_PASSWORD, CONF_TOKEN, @@ -36,7 +33,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_create_clientsession +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -52,6 +49,7 @@ from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import LaMarzoccoConfigEntry CONF_MACHINE = "machine" +BT_MODEL_PREFIXES = ("MICRA", "MINI", "GS3") _LOGGER = logging.getLogger(__name__) @@ -59,14 +57,14 @@ _LOGGER = logging.getLogger(__name__) class LmConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for La Marzocco.""" - VERSION = 2 + VERSION = 3 _client: ClientSession def __init__(self) -> None: """Initialize the config flow.""" self._config: dict[str, Any] = {} - self._fleet: dict[str, LaMarzoccoDeviceInfo] = {} + self._things: dict[str, Thing] = {} self._discovered: dict[str, str] = {} async def async_step_user( @@ -83,17 +81,16 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): data = { **data, **user_input, - **self._discovered, } - self._client = async_create_clientsession(self.hass) + self._client = async_get_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() + things = await cloud_client.list_things() except AuthFail: _LOGGER.debug("Server rejected login credentials") errors["base"] = "invalid_auth" @@ -101,37 +98,30 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error connecting to server: %s", exc) errors["base"] = "cannot_connect" else: - if not self._fleet: + self._things = {thing.serial_number: thing for thing in things} + if not self._things: errors["base"] = "no_machines" if not errors: + self._config = data if self.source == SOURCE_REAUTH: return self.async_update_reload_and_abort( - self._get_reauth_entry(), data=data + self._get_reauth_entry(), data_updates=data ) if self._discovered: - if self._discovered[CONF_MACHINE] not in self._fleet: + if self._discovered[CONF_MACHINE] not in self._things: errors["base"] = "machine_not_found" else: - self._config = data - # if DHCP discovery was used, auto fill machine selection - if CONF_HOST in self._discovered: - return await self.async_step_machine_selection( - user_input={ - CONF_HOST: self._discovered[CONF_HOST], - CONF_MACHINE: self._discovered[CONF_MACHINE], - } - ) - # if Bluetooth discovery was used, only select host - return self.async_show_form( - step_id="machine_selection", - data_schema=vol.Schema( - {vol.Optional(CONF_HOST): cv.string} - ), - ) + # store discovered connection address + if CONF_MAC in self._discovered: + self._config[CONF_MAC] = self._discovered[CONF_MAC] + if CONF_ADDRESS in self._discovered: + self._config[CONF_ADDRESS] = self._discovered[CONF_ADDRESS] + return await self.async_step_machine_selection( + user_input={CONF_MACHINE: self._discovered[CONF_MACHINE]} + ) if not errors: - self._config = data return await self.async_step_machine_selection() placeholders: dict[str, str] | None = None @@ -175,43 +165,35 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): else: serial_number = self._discovered[CONF_MACHINE] - selected_device = self._fleet[serial_number] - - # validate local connection if host is provided - if user_input.get(CONF_HOST): - if not await LaMarzoccoLocalClient.validate_connection( - client=self._client, - host=user_input[CONF_HOST], - token=selected_device.communication_key, - ): - errors[CONF_HOST] = "cannot_connect" - else: - self._config[CONF_HOST] = user_input[CONF_HOST] + selected_device = self._things[serial_number] if not errors: if self.source == SOURCE_RECONFIGURE: for service_info in async_discovered_service_info(self.hass): - self._discovered[service_info.name] = service_info.address + if service_info.name.startswith(BT_MODEL_PREFIXES): + self._discovered[service_info.name] = service_info.address if self._discovered: return await self.async_step_bluetooth_selection() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self._config, + ) return self.async_create_entry( title=selected_device.name, data={ **self._config, - CONF_NAME: selected_device.name, - CONF_MODEL: selected_device.model, - CONF_TOKEN: selected_device.communication_key, + CONF_TOKEN: self._things[serial_number].ble_auth_token, }, ) machine_options = [ SelectOptionDict( - value=device.serial_number, - label=f"{device.model} ({device.serial_number})", + value=thing.serial_number, + label=f"{thing.name} ({thing.serial_number})", ) - for device in self._fleet.values() + for thing in self._things.values() ] machine_selection_schema = vol.Schema( @@ -224,7 +206,6 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): mode=SelectSelectorMode.DROPDOWN, ) ), - vol.Optional(CONF_HOST): cv.string, } ) @@ -242,8 +223,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: return self.async_update_reload_and_abort( self._get_reconfigure_entry(), - data={ - **self._config, + data_updates={ CONF_MAC: user_input[CONF_MAC], }, ) @@ -304,7 +284,6 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(serial) self._abort_if_unique_id_configured( updates={ - CONF_HOST: discovery_info.ip, CONF_ADDRESS: discovery_info.macaddress, } ) @@ -316,8 +295,8 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): discovery_info.ip, ) + self._discovered[CONF_NAME] = discovery_info.hostname self._discovered[CONF_MACHINE] = serial - self._discovered[CONF_HOST] = discovery_info.ip self._discovered[CONF_ADDRESS] = discovery_info.macaddress return await self.async_step_user() diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index dddca6565e4..f0f64e02c28 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -3,28 +3,26 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging from typing import Any -from pylamarzocco.clients.local import LaMarzoccoLocalClient -from pylamarzocco.devices.machine import LaMarzoccoMachine +from pylamarzocco import LaMarzoccoMachine from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, callback +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 DOMAIN -SCAN_INTERVAL = timedelta(seconds=30) -FIRMWARE_UPDATE_INTERVAL = timedelta(hours=1) -STATISTICS_UPDATE_INTERVAL = timedelta(minutes=5) +SCAN_INTERVAL = timedelta(seconds=15) +SETTINGS_UPDATE_INTERVAL = timedelta(hours=1) +SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=5) +STATISTICS_UPDATE_INTERVAL = timedelta(minutes=15) _LOGGER = logging.getLogger(__name__) @@ -33,7 +31,8 @@ class LaMarzoccoRuntimeData: """Runtime data for La Marzocco.""" config_coordinator: LaMarzoccoConfigUpdateCoordinator - firmware_coordinator: LaMarzoccoFirmwareUpdateCoordinator + settings_coordinator: LaMarzoccoSettingsUpdateCoordinator + schedule_coordinator: LaMarzoccoScheduleUpdateCoordinator statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator @@ -45,13 +44,13 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): _default_update_interval = SCAN_INTERVAL config_entry: LaMarzoccoConfigEntry + websocket_terminated = True def __init__( self, hass: HomeAssistant, entry: LaMarzoccoConfigEntry, device: LaMarzoccoMachine, - local_client: LaMarzoccoLocalClient | None = None, ) -> None: """Initialize coordinator.""" super().__init__( @@ -62,9 +61,6 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): update_interval=self._default_update_interval, ) self.device = device - self.local_connection_configured = local_client is not None - self._local_client = local_client - self.new_device_callback: list[Callable] = [] async def _async_update_data(self) -> None: """Do the data update.""" @@ -89,73 +85,66 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator): """Class to handle fetching data from the La Marzocco API centrally.""" - _scale_address: str | None = None + async def _internal_async_update_data(self) -> None: + """Fetch data from API endpoint.""" - async def _async_connect_websocket(self) -> None: - """Set up the coordinator.""" - 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") + if self.device.websocket.connected: + return + await self.device.get_dashboard() + _LOGGER.debug("Current status: %s", self.device.dashboard.to_dict()) - self.config_entry.async_create_background_task( - hass=self.hass, - target=self.device.websocket_connect( - notify_callback=lambda: self.async_set_updated_data(None) - ), - name="lm_websocket_task", - ) + self.config_entry.async_create_background_task( + hass=self.hass, + target=self.connect_websocket(), + name="lm_websocket_task", + ) - async def websocket_close(_: Any | None = None) -> None: - if ( - self._local_client is not None - and self._local_client.websocket is not None - and not self._local_client.websocket.closed - ): - await self._local_client.websocket.close() + async def websocket_close(_: Any | None = None) -> None: + await self.device.websocket.disconnect() - self.config_entry.async_on_unload( - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, websocket_close - ) - ) - self.config_entry.async_on_unload(websocket_close) + self.config_entry.async_on_unload( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, websocket_close) + ) + self.config_entry.async_on_unload(websocket_close) + + async def connect_websocket(self) -> None: + """Connect to the websocket.""" + + _LOGGER.debug("Init WebSocket in background task") + + self.websocket_terminated = False + self.async_update_listeners() + + await self.device.connect_dashboard_websocket( + update_callback=lambda _: self.async_set_updated_data(None), + connect_callback=self.async_update_listeners, + disconnect_callback=self.async_update_listeners, + ) + + self.websocket_terminated = True + self.async_update_listeners() + + +class LaMarzoccoSettingsUpdateCoordinator(LaMarzoccoUpdateCoordinator): + """Coordinator for La Marzocco settings.""" + + _default_update_interval = SETTINGS_UPDATE_INTERVAL async def _internal_async_update_data(self) -> None: """Fetch data from API endpoint.""" - await self.device.get_config() - _LOGGER.debug("Current status: %s", str(self.device.config)) - await self._async_connect_websocket() - self._async_add_remove_scale() - - @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 + await self.device.get_settings() + _LOGGER.debug("Current settings: %s", self.device.settings.to_dict()) -class LaMarzoccoFirmwareUpdateCoordinator(LaMarzoccoUpdateCoordinator): - """Coordinator for La Marzocco firmware.""" +class LaMarzoccoScheduleUpdateCoordinator(LaMarzoccoUpdateCoordinator): + """Coordinator for La Marzocco schedule.""" - _default_update_interval = FIRMWARE_UPDATE_INTERVAL + _default_update_interval = SCHEDULE_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)) + await self.device.get_schedule() + _LOGGER.debug("Current schedule: %s", self.device.schedule.to_dict()) class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator): @@ -165,5 +154,5 @@ class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator): 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)) + await self.device.get_coffee_and_flush_counter() + _LOGGER.debug("Current statistics: %s", self.device.statistics.to_dict()) diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py index 204a8b7142a..7743523e01d 100644 --- a/homeassistant/components/lamarzocco/diagnostics.py +++ b/homeassistant/components/lamarzocco/diagnostics.py @@ -2,14 +2,13 @@ from __future__ import annotations -from dataclasses import asdict -from typing import Any, TypedDict - -from pylamarzocco.const import FirmwareType +from typing import Any from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_MAC, CONF_TOKEN from homeassistant.core import HomeAssistant +from .const import CONF_USE_BLUETOOTH from .coordinator import LaMarzoccoConfigEntry TO_REDACT = { @@ -17,15 +16,6 @@ TO_REDACT = { } -class DiagnosticsData(TypedDict): - """Diagnostic data for La Marzocco.""" - - model: str - config: dict[str, Any] - firmware: list[dict[FirmwareType, dict[str, Any]]] - statistics: dict[str, Any] - - async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: LaMarzoccoConfigEntry, @@ -33,12 +23,12 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator = entry.runtime_data.config_coordinator device = coordinator.device - # collect all data sources - diagnostics_data = DiagnosticsData( - model=device.model, - config=asdict(device.config), - firmware=[{key: asdict(firmware)} for key, firmware in device.firmware.items()], - statistics=asdict(device.statistics), - ) - - return async_redact_data(diagnostics_data, TO_REDACT) + data = { + "device": device.to_dict(), + "bluetooth_available": { + "options_enabled": entry.options.get(CONF_USE_BLUETOOTH, True), + CONF_MAC: CONF_MAC in entry.data, + CONF_TOKEN: CONF_TOKEN in entry.data, + }, + } + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 3e70ff1acdf..6dc024645ce 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -2,10 +2,8 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING from pylamarzocco.const import FirmwareType -from pylamarzocco.devices.machine import LaMarzoccoMachine from homeassistant.const import CONF_ADDRESS, CONF_MAC from homeassistant.helpers.device_registry import ( @@ -24,7 +22,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator class LaMarzoccoEntityDescription(EntityDescription): """Description for all LM entities.""" - available_fn: Callable[[LaMarzoccoMachine], bool] = lambda _: True + available_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True @@ -46,12 +44,12 @@ class LaMarzoccoBaseEntity( self._attr_unique_id = f"{device.serial_number}_{key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.serial_number)}, - name=device.name, + name=device.dashboard.name, manufacturer="La Marzocco", - model=device.full_model_name, - model_id=device.model, + model=device.dashboard.model_name.value, + model_id=device.dashboard.model_code.value, serial_number=device.serial_number, - sw_version=device.firmware[FirmwareType.MACHINE].current_version, + sw_version=device.settings.firmwares[FirmwareType.MACHINE].build_version, ) connections: set[tuple[str, str]] = set() if coordinator.config_entry.data.get(CONF_ADDRESS): @@ -75,7 +73,7 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity): def available(self) -> bool: """Return True if entity is available.""" if super().available: - return self.entity_description.available_fn(self.coordinator.device) + return self.entity_description.available_fn(self.coordinator) return False def __init__( @@ -86,26 +84,3 @@ 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 2be882fafea..fb61397575d 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -34,36 +34,20 @@ "dose": { "default": "mdi:cup-water" }, - "prebrew_off": { - "default": "mdi:water-off" - }, - "prebrew_on": { - "default": "mdi:water" - }, - "preinfusion_off": { - "default": "mdi:water" - }, - "scale_target": { - "default": "mdi:scale-balance" - }, "smart_standby_time": { "default": "mdi:timer" }, - "steam_temp": { - "default": "mdi:thermometer-water" + "preinfusion_time": { + "default": "mdi:water" }, - "tea_water_duration": { - "default": "mdi:timer-sand" + "prebrew_time_on": { + "default": "mdi:water" + }, + "prebrew_time_off": { + "default": "mdi:water-off" } }, "select": { - "active_bbw": { - "default": "mdi:alpha-u", - "state": { - "a": "mdi:alpha-a", - "b": "mdi:alpha-b" - } - }, "smart_standby_mode": { "default": "mdi:power", "state": { @@ -89,23 +73,23 @@ } }, "sensor": { - "drink_stats_coffee": { - "default": "mdi:chart-line" + "coffee_boiler_ready_time": { + "default": "mdi:av-timer" }, - "drink_stats_flushing": { - "default": "mdi:chart-line" + "last_cleaning_time": { + "default": "mdi:spray-bottle" }, - "drink_stats_coffee_key": { - "default": "mdi:chart-scatter-plot" + "steam_boiler_ready_time": { + "default": "mdi:av-timer" }, - "shot_timer": { - "default": "mdi:timer" + "brewing_start_time": { + "default": "mdi:clock-start" }, - "current_temp_coffee": { - "default": "mdi:thermometer" + "total_coffees_made": { + "default": "mdi:coffee" }, - "current_temp_steam": { - "default": "mdi:thermometer" + "total_flushes_done": { + "default": "mdi:water-pump" } }, "switch": { diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index eceb2bbf53b..d948d46ef1f 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -34,8 +34,8 @@ ], "documentation": "https://www.home-assistant.io/integrations/lamarzocco", "integration_type": "device", - "iot_class": "cloud_polling", + "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==1.4.7"] + "requirements": ["pylamarzocco==2.0.3"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 666c57c1866..7c4fe33a041 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -2,18 +2,12 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any +from typing import Any, cast -from pylamarzocco.const import ( - KEYS_PER_MODEL, - BoilerType, - MachineModel, - PhysicalKey, - PrebrewMode, -) -from pylamarzocco.devices.machine import LaMarzoccoMachine +from pylamarzocco import LaMarzoccoMachine +from pylamarzocco.const import ModelName, PreExtractionMode, WidgetType from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoMachineConfig +from pylamarzocco.models import CoffeeBoiler, PreBrewing from homeassistant.components.number import ( NumberDeviceClass, @@ -32,8 +26,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity +from .coordinator import LaMarzoccoConfigEntry +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription PARALLEL_UPDATES = 1 @@ -45,25 +39,10 @@ class LaMarzoccoNumberEntityDescription( ): """Description of a La Marzocco number entity.""" - native_value_fn: Callable[[LaMarzoccoMachineConfig], float | int] + native_value_fn: Callable[[LaMarzoccoMachine], float | int] set_value_fn: Callable[[LaMarzoccoMachine, float | int], Coroutine[Any, Any, bool]] -@dataclass(frozen=True, kw_only=True) -class LaMarzoccoKeyNumberEntityDescription( - LaMarzoccoEntityDescription, - NumberEntityDescription, -): - """Description of an La Marzocco number entity with keys.""" - - native_value_fn: Callable[ - [LaMarzoccoMachineConfig, PhysicalKey], float | int | None - ] - set_value_fn: Callable[ - [LaMarzoccoMachine, float | int, PhysicalKey], Coroutine[Any, Any, bool] - ] - - ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( LaMarzoccoNumberEntityDescription( key="coffee_temp", @@ -73,43 +52,11 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( native_step=PRECISION_TENTHS, native_min_value=85, native_max_value=104, - set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.COFFEE, temp), - native_value_fn=lambda config: config.boilers[ - BoilerType.COFFEE - ].target_temperature, - ), - LaMarzoccoNumberEntityDescription( - key="steam_temp", - translation_key="steam_temp", - device_class=NumberDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - native_step=PRECISION_WHOLE, - native_min_value=126, - native_max_value=131, - set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.STEAM, temp), - native_value_fn=lambda config: config.boilers[ - BoilerType.STEAM - ].target_temperature, - supported_fn=lambda coordinator: coordinator.device.model - in ( - MachineModel.GS3_AV, - MachineModel.GS3_MP, - ), - ), - LaMarzoccoNumberEntityDescription( - key="tea_water_duration", - translation_key="tea_water_duration", - device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - native_step=PRECISION_WHOLE, - native_min_value=0, - native_max_value=30, - set_value_fn=lambda machine, value: machine.set_dose_tea_water(int(value)), - native_value_fn=lambda config: config.dose_hot_water, - supported_fn=lambda coordinator: coordinator.device.model - in ( - MachineModel.GS3_AV, - MachineModel.GS3_MP, + set_value_fn=lambda machine, temp: machine.set_coffee_target_temperature(temp), + native_value_fn=( + lambda machine: cast( + CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] + ).target_temperature ), ), LaMarzoccoNumberEntityDescription( @@ -117,112 +64,136 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( translation_key="smart_standby_time", device_class=NumberDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, - native_step=10, - native_min_value=10, - native_max_value=240, - entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, value: machine.set_smart_standby( - enabled=machine.config.smart_standby.enabled, - mode=machine.config.smart_standby.mode, - minutes=int(value), - ), - native_value_fn=lambda config: config.smart_standby.minutes, - ), -) - - -KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( - LaMarzoccoKeyNumberEntityDescription( - key="prebrew_off", - translation_key="prebrew_off", - device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - native_step=PRECISION_TENTHS, - native_min_value=1, - native_max_value=10, - entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, value, key: machine.set_prebrew_time( - prebrew_off_time=value, key=key - ), - native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time, - available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode == PrebrewMode.PREBREW, - supported_fn=lambda coordinator: coordinator.device.model - != MachineModel.GS3_MP, - ), - LaMarzoccoKeyNumberEntityDescription( - key="prebrew_on", - translation_key="prebrew_on", - device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - native_step=PRECISION_TENTHS, - native_min_value=2, - native_max_value=10, - entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, value, key: machine.set_prebrew_time( - prebrew_on_time=value, key=key - ), - native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time, - available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode == PrebrewMode.PREBREW, - supported_fn=lambda coordinator: coordinator.device.model - != MachineModel.GS3_MP, - ), - LaMarzoccoKeyNumberEntityDescription( - key="preinfusion_off", - translation_key="preinfusion_off", - device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - native_step=PRECISION_TENTHS, - native_min_value=2, - native_max_value=29, - entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, value, key: machine.set_preinfusion_time( - preinfusion_time=value, key=key - ), - native_value_fn=lambda config, key: config.prebrew_configuration[ - key - ].preinfusion_time, - available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode == PrebrewMode.PREINFUSION, - supported_fn=lambda coordinator: coordinator.device.model - != MachineModel.GS3_MP, - ), - LaMarzoccoKeyNumberEntityDescription( - key="dose", - translation_key="dose", - native_unit_of_measurement="ticks", native_step=PRECISION_WHOLE, native_min_value=0, - native_max_value=999, + native_max_value=240, entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, ticks, key: machine.set_dose( - dose=int(ticks), key=key + set_value_fn=( + lambda machine, value: machine.set_smart_standby( + enabled=machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled, + mode=machine.schedule.smart_wake_up_sleep.smart_stand_by_after, + minutes=int(value), + ) ), - native_value_fn=lambda config, key: config.doses[key], - supported_fn=lambda coordinator: coordinator.device.model - == MachineModel.GS3_AV, + native_value_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes, ), -) - -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, + LaMarzoccoNumberEntityDescription( + key="preinfusion_off", + translation_key="preinfusion_time", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_TENTHS, + native_min_value=0, + native_max_value=10, entity_category=EntityCategory.CONFIG, - set_value_fn=lambda machine, weight, key: machine.set_bbw_recipe_target( - key, int(weight) + set_value_fn=( + lambda machine, value: machine.set_pre_extraction_times( + seconds_on=0, + seconds_off=float(value), + ) ), - native_value_fn=lambda config, key: ( - config.bbw_settings.doses[key] if config.bbw_settings else None + native_value_fn=( + lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_infusion[0] + .seconds.seconds_out + ), + available_fn=( + lambda coordinator: cast( + PreBrewing, + coordinator.device.dashboard.config[WidgetType.CM_PRE_BREWING], + ).mode + is PreExtractionMode.PREINFUSION ), supported_fn=( - lambda coordinator: coordinator.device.model - in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) - and coordinator.device.config.scale is not None + lambda coordinator: coordinator.device.dashboard.model_name + in ( + ModelName.LINEA_MICRA, + ModelName.LINEA_MINI, + ModelName.LINEA_MINI_R, + ) + ), + ), + LaMarzoccoNumberEntityDescription( + key="prebrew_on", + translation_key="prebrew_time_on", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_step=PRECISION_TENTHS, + native_min_value=0, + native_max_value=10, + entity_category=EntityCategory.CONFIG, + set_value_fn=( + lambda machine, value: machine.set_pre_extraction_times( + seconds_on=float(value), + seconds_off=cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_out, + ) + ), + native_value_fn=( + lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_in + ), + available_fn=lambda coordinator: cast( + PreBrewing, coordinator.device.dashboard.config[WidgetType.CM_PRE_BREWING] + ).mode + is PreExtractionMode.PREBREWING, + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in ( + ModelName.LINEA_MICRA, + ModelName.LINEA_MINI, + ModelName.LINEA_MINI_R, + ) + ), + ), + LaMarzoccoNumberEntityDescription( + key="prebrew_off", + translation_key="prebrew_time_off", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_step=PRECISION_TENTHS, + native_min_value=0, + native_max_value=10, + entity_category=EntityCategory.CONFIG, + set_value_fn=( + lambda machine, value: machine.set_pre_extraction_times( + seconds_on=cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_in, + seconds_off=float(value), + ) + ), + native_value_fn=( + lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_out + ), + available_fn=( + lambda coordinator: cast( + PreBrewing, + coordinator.device.dashboard.config[WidgetType.CM_PRE_BREWING], + ).mode + is PreExtractionMode.PREBREWING + ), + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in ( + ModelName.LINEA_MICRA, + ModelName.LINEA_MINI, + ModelName.LINEA_MINI_R, + ) ), ), ) @@ -241,34 +212,6 @@ async def async_setup_entry( if description.supported_fn(coordinator) ] - for description in KEY_ENTITIES: - if description.supported_fn(coordinator): - num_keys = KEYS_PER_MODEL[MachineModel(coordinator.device.model)] - entities.extend( - 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) @@ -280,7 +223,7 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): @property def native_value(self) -> float: """Return the current value.""" - return self.entity_description.native_value_fn(self.coordinator.device.config) + return self.entity_description.native_value_fn(self.coordinator.device) async def async_set_native_value(self, value: float) -> None: """Set the value.""" @@ -299,62 +242,3 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): }, ) from exc self.async_write_ha_state() - - -class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity): - """Number representing espresso machine with key support.""" - - entity_description: LaMarzoccoKeyNumberEntityDescription - - def __init__( - self, - coordinator: LaMarzoccoUpdateCoordinator, - description: LaMarzoccoKeyNumberEntityDescription, - pyhsical_key: int, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator, description) - - # Physical Key on the machine the entity represents. - if pyhsical_key == 0: - pyhsical_key = 1 - else: - self._attr_translation_key = f"{description.translation_key}_key" - self._attr_translation_placeholders = {"key": str(pyhsical_key)} - self._attr_unique_id = f"{super()._attr_unique_id}_key{pyhsical_key}" - self._attr_entity_registry_enabled_default = False - self.pyhsical_key = pyhsical_key - - @property - 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) - ) - - async def async_set_native_value(self, value: float) -> None: - """Set the value.""" - if value != self.native_value: - try: - await self.entity_description.set_value_fn( - self.coordinator.device, value, PhysicalKey(self.pyhsical_key) - ) - except RequestNotSuccessful as exc: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="number_exception_key", - translation_placeholders={ - "key": self.entity_description.key, - "value": str(value), - "physical_key": str(self.pyhsical_key), - }, - ) 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/select.py b/homeassistant/components/lamarzocco/select.py index d8217cefaff..44dad6bfb2a 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -2,18 +2,18 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any +from typing import Any, cast from pylamarzocco.const import ( - MachineModel, - PhysicalKey, - PrebrewMode, - SmartStandbyMode, - SteamLevel, + ModelName, + PreExtractionMode, + SmartStandByType, + SteamTargetLevel, + WidgetType, ) -from pylamarzocco.devices.machine import LaMarzoccoMachine +from pylamarzocco.devices import LaMarzoccoMachine from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoMachineConfig +from pylamarzocco.models import PreBrewing, SteamBoilerLevel from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -23,29 +23,29 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMarzoccoConfigEntry -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription PARALLEL_UPDATES = 1 STEAM_LEVEL_HA_TO_LM = { - "1": SteamLevel.LEVEL_1, - "2": SteamLevel.LEVEL_2, - "3": SteamLevel.LEVEL_3, + "1": SteamTargetLevel.LEVEL_1, + "2": SteamTargetLevel.LEVEL_2, + "3": SteamTargetLevel.LEVEL_3, } STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items()} PREBREW_MODE_HA_TO_LM = { - "disabled": PrebrewMode.DISABLED, - "prebrew": PrebrewMode.PREBREW, - "preinfusion": PrebrewMode.PREINFUSION, + "disabled": PreExtractionMode.DISABLED, + "prebrew": PreExtractionMode.PREBREWING, + "preinfusion": PreExtractionMode.PREINFUSION, } PREBREW_MODE_LM_TO_HA = {value: key for key, value in PREBREW_MODE_HA_TO_LM.items()} STANDBY_MODE_HA_TO_LM = { - "power_on": SmartStandbyMode.POWER_ON, - "last_brewing": SmartStandbyMode.LAST_BREWING, + "power_on": SmartStandByType.POWER_ON, + "last_brewing": SmartStandByType.LAST_BREW, } STANDBY_MODE_LM_TO_HA = {value: key for key, value in STANDBY_MODE_HA_TO_LM.items()} @@ -58,7 +58,7 @@ class LaMarzoccoSelectEntityDescription( ): """Description of a La Marzocco select entity.""" - current_option_fn: Callable[[LaMarzoccoMachineConfig], str | None] + current_option_fn: Callable[[LaMarzoccoMachine], str | None] select_option_fn: Callable[[LaMarzoccoMachine, str], Coroutine[Any, Any, bool]] @@ -70,25 +70,36 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( select_option_fn=lambda machine, option: machine.set_steam_level( STEAM_LEVEL_HA_TO_LM[option] ), - current_option_fn=lambda config: STEAM_LEVEL_LM_TO_HA[config.steam_level], - supported_fn=lambda coordinator: coordinator.device.model - == MachineModel.LINEA_MICRA, + current_option_fn=lambda machine: STEAM_LEVEL_LM_TO_HA[ + cast( + SteamBoilerLevel, + machine.dashboard.config[WidgetType.CM_STEAM_BOILER_LEVEL], + ).target_level + ], + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA) + ), ), LaMarzoccoSelectEntityDescription( key="prebrew_infusion_select", translation_key="prebrew_infusion_select", entity_category=EntityCategory.CONFIG, options=["disabled", "prebrew", "preinfusion"], - select_option_fn=lambda machine, option: machine.set_prebrew_mode( + select_option_fn=lambda machine, option: machine.set_pre_extraction_mode( PREBREW_MODE_HA_TO_LM[option] ), - current_option_fn=lambda config: PREBREW_MODE_LM_TO_HA[config.prebrew_mode], - supported_fn=lambda coordinator: coordinator.device.model - in ( - MachineModel.GS3_AV, - MachineModel.LINEA_MICRA, - MachineModel.LINEA_MINI, - MachineModel.LINEA_MINI_R, + current_option_fn=lambda machine: PREBREW_MODE_LM_TO_HA[ + cast(PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]).mode + ], + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in ( + ModelName.LINEA_MICRA, + ModelName.LINEA_MINI, + ModelName.LINEA_MINI_R, + ModelName.GS3_AV, + ) ), ), LaMarzoccoSelectEntityDescription( @@ -97,32 +108,16 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, options=["power_on", "last_brewing"], select_option_fn=lambda machine, option: machine.set_smart_standby( - enabled=machine.config.smart_standby.enabled, + enabled=machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled, mode=STANDBY_MODE_HA_TO_LM[option], - minutes=machine.config.smart_standby.minutes, + minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes, ), - current_option_fn=lambda config: STANDBY_MODE_LM_TO_HA[ - config.smart_standby.mode + current_option_fn=lambda machine: STANDBY_MODE_LM_TO_HA[ + machine.schedule.smart_wake_up_sleep.smart_stand_by_after ], ), ) -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, @@ -132,30 +127,11 @@ async def async_setup_entry( """Set up select entities.""" coordinator = entry.runtime_data.config_coordinator - entities = [ + async_add_entities( LaMarzoccoSelectEntity(coordinator, description) for description in ENTITIES if description.supported_fn(coordinator) - ] - - if ( - coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) - 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): @@ -166,9 +142,7 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity): @property def current_option(self) -> str | None: """Return the current selected option.""" - return str( - self.entity_description.current_option_fn(self.coordinator.device.config) - ) + return self.entity_description.current_option_fn(self.coordinator.device) async def async_select_option(self, option: str) -> None: """Change the selected option.""" @@ -187,9 +161,3 @@ 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 0d4a5e53ebe..aecb2ff7f04 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -2,9 +2,19 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime +from typing import cast -from pylamarzocco.const import KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey -from pylamarzocco.devices.machine import LaMarzoccoMachine +from pylamarzocco.const import ModelName, WidgetType +from pylamarzocco.models import ( + BackFlush, + BaseWidgetOutput, + CoffeeAndFlushCounter, + CoffeeBoiler, + MachineStatus, + SteamBoilerLevel, + SteamBoilerTemperature, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,17 +22,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import ( - PERCENTAGE, - EntityCategory, - UnitOfTemperature, - UnitOfTime, -) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType -from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity +from .coordinator import LaMarzoccoConfigEntry +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -30,104 +36,105 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class LaMarzoccoSensorEntityDescription( - LaMarzoccoEntityDescription, SensorEntityDescription + LaMarzoccoEntityDescription, + SensorEntityDescription, ): """Description of a La Marzocco sensor.""" - value_fn: Callable[[LaMarzoccoMachine], float | int] - - -@dataclass(frozen=True, kw_only=True) -class LaMarzoccoKeySensorEntityDescription( - LaMarzoccoEntityDescription, SensorEntityDescription -): - """Description of a keyed La Marzocco sensor.""" - - value_fn: Callable[[LaMarzoccoMachine, PhysicalKey], int | None] + value_fn: Callable[ + [dict[WidgetType, BaseWidgetOutput]], StateType | datetime | None + ] ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( - key="shot_timer", - translation_key="shot_timer", - native_unit_of_measurement=UnitOfTime.SECONDS, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.DURATION, - value_fn=lambda device: device.config.brew_active_duration, - available_fn=lambda device: device.websocket_connected, + key="coffee_boiler_ready_time", + translation_key="coffee_boiler_ready_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + CoffeeBoiler, config[WidgetType.CM_COFFEE_BOILER] + ).ready_start_time + ), entity_category=EntityCategory.DIAGNOSTIC, - supported_fn=lambda coordinator: coordinator.local_connection_configured, ), LaMarzoccoSensorEntityDescription( - key="current_temp_coffee", - translation_key="current_temp_coffee", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=1, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.TEMPERATURE, - value_fn=lambda device: device.config.boilers[ - BoilerType.COFFEE - ].current_temperature, + key="steam_boiler_ready_time", + translation_key="steam_boiler_ready_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + SteamBoilerLevel, config[WidgetType.CM_STEAM_BOILER_LEVEL] + ).ready_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.LINEA_MICRA, ModelName.LINEA_MINI_R) + ), ), LaMarzoccoSensorEntityDescription( - key="current_temp_steam", - translation_key="current_temp_steam", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=1, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.TEMPERATURE, - value_fn=lambda device: device.config.boilers[ - BoilerType.STEAM - ].current_temperature, - supported_fn=lambda coordinator: coordinator.device.model - not in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R), + key="brewing_start_time", + translation_key="brewing_start_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + MachineStatus, config[WidgetType.CM_MACHINE_STATUS] + ).brewing_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, + available_fn=(lambda coordinator: not coordinator.websocket_terminated), + ), + LaMarzoccoSensorEntityDescription( + key="steam_boiler_ready_time", + translation_key="steam_boiler_ready_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + SteamBoilerTemperature, config[WidgetType.CM_STEAM_BOILER_TEMPERATURE] + ).ready_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI) + ), + ), + LaMarzoccoSensorEntityDescription( + key="last_cleaning_time", + translation_key="last_cleaning_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + BackFlush, config[WidgetType.CM_BACK_FLUSH] + ).last_cleaning_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, ), ) STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="drink_stats_coffee", - translation_key="drink_stats_coffee", + translation_key="total_coffees_made", state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.statistics.total_coffee, - available_fn=lambda device: len(device.statistics.drink_stats) > 0, + value_fn=( + lambda statistics: cast( + CoffeeAndFlushCounter, statistics[WidgetType.COFFEE_AND_FLUSH_COUNTER] + ).total_coffee + ), entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoSensorEntityDescription( key="drink_stats_flushing", - translation_key="drink_stats_flushing", + translation_key="total_flushes_done", 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, - ), -) - -KEY_STATISTIC_ENTITIES: tuple[LaMarzoccoKeySensorEntityDescription, ...] = ( - LaMarzoccoKeySensorEntityDescription( - key="drink_stats_coffee_key", - translation_key="drink_stats_coffee_key", - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device, key: device.statistics.drink_stats.get(key), - available_fn=lambda device: len(device.statistics.drink_stats) > 0, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), -) - -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 - in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) + value_fn=( + lambda statistics: cast( + CoffeeAndFlushCounter, statistics[WidgetType.COFFEE_AND_FLUSH_COUNTER] + ).total_flush ), + entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -139,88 +146,40 @@ async def async_setup_entry( ) -> None: """Set up sensor entities.""" config_coordinator = entry.runtime_data.config_coordinator - - entities: list[LaMarzoccoSensorEntity | LaMarzoccoKeySensorEntity] = [] + statistic_coordinators = entry.runtime_data.statistics_coordinator entities = [ LaMarzoccoSensorEntity(config_coordinator, description) for description in ENTITIES if description.supported_fn(config_coordinator) ] - - if ( - config_coordinator.device.model - in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) - 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) + LaMarzoccoStatisticSensorEntity(statistic_coordinators, description) for description in STATISTIC_ENTITIES - if description.supported_fn(statistics_coordinator) + if description.supported_fn(statistic_coordinators) ) - - num_keys = KEYS_PER_MODEL[MachineModel(config_coordinator.device.model)] - if num_keys > 0: - entities.extend( - LaMarzoccoKeySensorEntity(statistics_coordinator, description, key) - for description in KEY_STATISTIC_ENTITIES - for key in range(1, num_keys + 1) - ) - - 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.""" + """Sensor for La Marzocco.""" entity_description: LaMarzoccoSensorEntityDescription @property - def native_value(self) -> int | float | None: - """State of the sensor.""" - return self.entity_description.value_fn(self.coordinator.device) - - -class LaMarzoccoKeySensorEntity(LaMarzoccoEntity, SensorEntity): - """Sensor for a La Marzocco key.""" - - entity_description: LaMarzoccoKeySensorEntityDescription - - def __init__( - self, - coordinator: LaMarzoccoUpdateCoordinator, - description: LaMarzoccoKeySensorEntityDescription, - key: int, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, description) - self.key = key - self._attr_translation_placeholders = {"key": str(key)} - self._attr_unique_id = f"{super()._attr_unique_id}_key{key}" - - @property - def native_value(self) -> int | None: - """State of the sensor.""" + def native_value(self) -> StateType | datetime | None: + """Return value of the sensor.""" return self.entity_description.value_fn( - self.coordinator.device, PhysicalKey(self.key) + self.coordinator.device.dashboard.config ) -class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity): - """Sensor for a La Marzocco scale.""" +class LaMarzoccoStatisticSensorEntity(LaMarzoccoSensorEntity): + """Sensor for La Marzocco statistics.""" - entity_description: LaMarzoccoSensorEntityDescription + @property + def native_value(self) -> StateType | datetime | None: + """Return the value of the sensor.""" + return self.entity_description.value_fn( + self.coordinator.device.statistics.widgets + ) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 62050685c27..8de62efd284 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -32,13 +32,11 @@ } }, "machine_selection": { - "description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors.", + "description": "Select the machine you want to integrate.", "data": { - "host": "[%key:common::config_flow::data::ip%]", "machine": "Machine" }, "data_description": { - "host": "Local IP address of the machine", "machine": "Select the machine you want to integrate" } }, @@ -85,6 +83,9 @@ }, "water_tank": { "name": "Water tank empty" + }, + "websocket_connected": { + "name": "WebSocket connected" } }, "button": { @@ -101,52 +102,24 @@ "coffee_temp": { "name": "Coffee target temperature" }, - "dose_key": { - "name": "Dose Key {key}" - }, - "prebrew_on": { - "name": "Prebrew on time" - }, - "prebrew_on_key": { - "name": "Prebrew on time Key {key}" - }, - "prebrew_off": { - "name": "Prebrew off time" - }, - "prebrew_off_key": { - "name": "Prebrew off time Key {key}" - }, - "preinfusion_off": { - "name": "Preinfusion time" - }, - "preinfusion_off_key": { - "name": "Preinfusion time Key {key}" - }, - "scale_target_key": { - "name": "Brew by weight target {key}" - }, "smart_standby_time": { "name": "Smart standby time" }, - "steam_temp": { - "name": "Steam target temperature" + "preinfusion_time": { + "name": "Preinfusion time" }, - "tea_water_duration": { - "name": "Tea water duration" + "prebrew_time_on": { + "name": "Prebrew on time" + }, + "prebrew_time_off": { + "name": "Prebrew off time" } }, "select": { - "active_bbw": { - "name": "Active brew by weight recipe", - "state": { - "a": "Recipe A", - "b": "Recipe B" - } - }, "prebrew_infusion_select": { "name": "Prebrew/-infusion mode", "state": { - "disabled": "Disabled", + "disabled": "[%key:common::state::disabled%]", "prebrew": "Prebrew", "preinfusion": "Preinfusion" } @@ -168,26 +141,25 @@ } }, "sensor": { - "current_temp_coffee": { - "name": "Current coffee temperature" + "coffee_boiler_ready_time": { + "name": "Coffee boiler ready time" }, - "current_temp_steam": { - "name": "Current steam temperature" + "steam_boiler_ready_time": { + "name": "Steam boiler ready time" }, - "drink_stats_coffee": { + "brewing_start_time": { + "name": "Brewing start time" + }, + "total_coffees_made": { "name": "Total coffees made", "unit_of_measurement": "coffees" }, - "drink_stats_coffee_key": { - "name": "Coffees made Key {key}", - "unit_of_measurement": "coffees" - }, - "drink_stats_flushing": { - "name": "Total flushes made", + "total_flushes_done": { + "name": "Total flushes done", "unit_of_measurement": "flushes" }, - "shot_timer": { - "name": "Shot timer" + "last_cleaning_time": { + "name": "Last cleaning time" } }, "switch": { @@ -232,9 +204,6 @@ "number_exception": { "message": "Error while setting value {value} for number {key}" }, - "number_exception_key": { - "message": "Error while setting value {value} for number {key}, key {physical_key}" - }, "select_option_error": { "message": "Error while setting select option {option} for {key}" }, diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index ee03ba421d4..ca5fb820150 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -2,12 +2,17 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any +from typing import Any, cast -from pylamarzocco.const import BoilerType -from pylamarzocco.devices.machine import LaMarzoccoMachine +from pylamarzocco import LaMarzoccoMachine +from pylamarzocco.const import MachineMode, ModelName, WidgetType from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoMachineConfig +from pylamarzocco.models import ( + MachineStatus, + SteamBoilerLevel, + SteamBoilerTemperature, + WakeUpScheduleSettings, +) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -30,7 +35,7 @@ class LaMarzoccoSwitchEntityDescription( """Description of a La Marzocco Switch.""" control_fn: Callable[[LaMarzoccoMachine, bool], Coroutine[Any, Any, bool]] - is_on_fn: Callable[[LaMarzoccoMachineConfig], bool] + is_on_fn: Callable[[LaMarzoccoMachine], bool] ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( @@ -39,13 +44,42 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( translation_key="main", name=None, control_fn=lambda machine, state: machine.set_power(state), - is_on_fn=lambda config: config.turned_on, + is_on_fn=( + lambda machine: cast( + MachineStatus, machine.dashboard.config[WidgetType.CM_MACHINE_STATUS] + ).mode + is MachineMode.BREWING_MODE + ), ), LaMarzoccoSwitchEntityDescription( key="steam_boiler_enable", translation_key="steam_boiler", control_fn=lambda machine, state: machine.set_steam(state), - is_on_fn=lambda config: config.boilers[BoilerType.STEAM].enabled, + is_on_fn=( + lambda machine: cast( + SteamBoilerLevel, + machine.dashboard.config[WidgetType.CM_STEAM_BOILER_LEVEL], + ).enabled + ), + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA) + ), + ), + LaMarzoccoSwitchEntityDescription( + key="steam_boiler_enable", + translation_key="steam_boiler", + control_fn=lambda machine, state: machine.set_steam(state), + is_on_fn=( + lambda machine: cast( + SteamBoilerTemperature, + machine.dashboard.config[WidgetType.CM_STEAM_BOILER_TEMPERATURE], + ).enabled + ), + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + not in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA) + ), ), LaMarzoccoSwitchEntityDescription( key="smart_standby_enabled", @@ -53,10 +87,10 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, control_fn=lambda machine, state: machine.set_smart_standby( enabled=state, - mode=machine.config.smart_standby.mode, - minutes=machine.config.smart_standby.minutes, + mode=machine.schedule.smart_wake_up_sleep.smart_stand_by_after, + minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes, ), - is_on_fn=lambda config: config.smart_standby.enabled, + is_on_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled, ), ) @@ -78,8 +112,8 @@ async def async_setup_entry( ) entities.extend( - LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry_id) - for wake_up_sleep_entry_id in coordinator.device.config.wake_up_sleep_entries + LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry) + for wake_up_sleep_entry in coordinator.device.schedule.smart_wake_up_sleep.schedules ) async_add_entities(entities) @@ -117,7 +151,7 @@ class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity): @property def is_on(self) -> bool: """Return true if device is on.""" - return self.entity_description.is_on_fn(self.coordinator.device.config) + return self.entity_description.is_on_fn(self.coordinator.device) class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity): @@ -129,22 +163,21 @@ class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity): def __init__( self, coordinator: LaMarzoccoUpdateCoordinator, - identifier: str, + schedule_entry: WakeUpScheduleSettings, ) -> None: """Initialize the switch.""" - super().__init__(coordinator, f"auto_on_off_{identifier}") - self._identifier = identifier - self._attr_translation_placeholders = {"id": identifier} - self.entity_category = EntityCategory.CONFIG + super().__init__(coordinator, f"auto_on_off_{schedule_entry.identifier}") + assert schedule_entry.identifier + self._schedule_entry = schedule_entry + self._identifier = schedule_entry.identifier + self._attr_translation_placeholders = {"id": schedule_entry.identifier} + self._attr_entity_category = EntityCategory.CONFIG async def _async_enable(self, state: bool) -> None: """Enable or disable the auto on/off schedule.""" - wake_up_sleep_entry = self.coordinator.device.config.wake_up_sleep_entries[ - self._identifier - ] - wake_up_sleep_entry.enabled = state + self._schedule_entry.enabled = state try: - await self.coordinator.device.set_wake_up_sleep(wake_up_sleep_entry) + await self.coordinator.device.set_wakeup_schedule(self._schedule_entry) except RequestNotSuccessful as exc: raise HomeAssistantError( translation_domain=DOMAIN, @@ -164,6 +197,4 @@ class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity): @property def is_on(self) -> bool: """Return true if switch is on.""" - return self.coordinator.device.config.wake_up_sleep_entries[ - self._identifier - ].enabled + return self._schedule_entry.enabled diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index 37960d26e95..632c66a8b66 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -1,9 +1,10 @@ """Support for La Marzocco update entities.""" +import asyncio from dataclasses import dataclass from typing import Any -from pylamarzocco.const import FirmwareType +from pylamarzocco.const import FirmwareType, UpdateCommandStatus from pylamarzocco.exceptions import RequestNotSuccessful from homeassistant.components.update import ( @@ -22,6 +23,7 @@ from .coordinator import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription PARALLEL_UPDATES = 1 +MAX_UPDATE_WAIT = 150 @dataclass(frozen=True, kw_only=True) @@ -59,7 +61,7 @@ async def async_setup_entry( ) -> None: """Create update entities.""" - coordinator = entry.runtime_data.firmware_coordinator + coordinator = entry.runtime_data.settings_coordinator async_add_entities( LaMarzoccoUpdateEntity(coordinator, description) for description in ENTITIES @@ -71,38 +73,67 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): """Entity representing the update state.""" entity_description: LaMarzoccoUpdateEntityDescription - _attr_supported_features = UpdateEntityFeature.INSTALL + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) @property - def installed_version(self) -> str | None: + def installed_version(self) -> str: """Return the current firmware version.""" - return self.coordinator.device.firmware[ + return self.coordinator.device.settings.firmwares[ self.entity_description.component - ].current_version + ].build_version @property def latest_version(self) -> str: """Return the latest firmware version.""" - return self.coordinator.device.firmware[ + if available_update := self.coordinator.device.settings.firmwares[ self.entity_description.component - ].latest_version + ].available_update: + return available_update.build_version + return self.installed_version @property def release_url(self) -> str | None: """Return the release notes URL.""" return "https://support-iot.lamarzocco.com/firmware-updates/" + def release_notes(self) -> str | None: + """Return the release notes for the latest firmware version.""" + if available_update := self.coordinator.device.settings.firmwares[ + self.entity_description.component + ].available_update: + return available_update.change_log + return None + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" + self._attr_in_progress = True self.async_write_ha_state() + + counter = 0 + + def _raise_timeout_error() -> None: # to avoid TRY301 + raise TimeoutError("Update timed out") + try: - success = await self.coordinator.device.update_firmware( - self.entity_description.component - ) - except RequestNotSuccessful as exc: + await self.coordinator.device.update_firmware() + while ( + update_progress := await self.coordinator.device.get_firmware() + ).command_status is UpdateCommandStatus.IN_PROGRESS: + if counter >= MAX_UPDATE_WAIT: + _raise_timeout_error() + self._attr_update_percentage = update_progress.progress_percentage + self.async_write_ha_state() + await asyncio.sleep(3) + counter += 1 + + except (TimeoutError, RequestNotSuccessful) as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="update_failed", @@ -110,13 +141,6 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): "key": self.entity_description.key, }, ) from exc - if not success: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={ - "key": self.entity_description.key, - }, - ) - self._attr_in_progress = False - await self.coordinator.async_request_refresh() + finally: + self._attr_in_progress = False + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 3c2f05fa535..0656454bb01 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -18,7 +18,7 @@ }, "data_description": { "host": "The IP address or hostname of your LaMetric TIME on your network.", - "api_key": "You can find this API key in [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)." + "api_key": "You can find this API key in the [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)." } }, "cloud_select_device": { @@ -83,8 +83,8 @@ "brightness_mode": { "name": "Brightness mode", "state": { - "auto": "Automatic", - "manual": "Manual" + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]" } } }, diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 0e1f680dd63..ca40aebd0d4 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from pylast import LastFMNetwork, PyLastError, User, WSError @@ -32,6 +33,8 @@ CONFIG_SCHEMA: vol.Schema = vol.Schema( } ) +_LOGGER = logging.getLogger(__name__) + def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]: """Get and validate lastFM User.""" @@ -49,7 +52,8 @@ def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]: errors["base"] = "invalid_auth" else: errors["base"] = "unknown" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" return user, errors diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index 0680bfc9d71..f8c3e0cd67d 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -28,6 +28,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) DATA_COMPONENT: HassKey[EntityComponent[LawnMowerEntity]] = HassKey(DOMAIN) +ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index ebaea4ffd6a..9cc56b8a11e 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -4,7 +4,7 @@ "_": { "name": "[%key:component::lawn_mower::title%]", "state": { - "error": "Error", + "error": "[%key:common::state::error%]", "paused": "[%key:common::state::paused%]", "mowing": "Mowing", "docked": "Docked", diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 256e132b30d..b3d2c14794c 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -24,12 +24,17 @@ from homeassistant.const import ( CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, + CONF_RESOURCE, CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -38,6 +43,7 @@ from .const import ( CONF_DIM_MODE, CONF_DOMAIN_DATA, CONF_SK_NUM_TRIES, + CONF_TARGET_VALUE_LOCKED, CONF_TRANSITION, CONNECTION, DEVICE_CONNECTIONS, @@ -155,6 +161,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.minor_version < 2: new_data[CONF_ACKNOWLEDGE] = False + if config_entry.version < 2: # update to 2.1 (fix transitions for lights and switches) new_entities_data = [*new_data[CONF_ENTITIES]] for entity in new_entities_data: @@ -164,8 +171,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> entity[CONF_DOMAIN_DATA][CONF_TRANSITION] /= 1000.0 new_data[CONF_ENTITIES] = new_entities_data + if config_entry.version < 3: + # update to 3.1 (remove resource parameter, add climate target lock value parameter) + for entity in new_data[CONF_ENTITIES]: + entity.pop(CONF_RESOURCE, None) + + if entity[CONF_DOMAIN] == Platform.CLIMATE: + entity[CONF_DOMAIN_DATA].setdefault(CONF_TARGET_VALUE_LOCKED, -1) + + # migrate climate and scene unique ids + await async_migrate_entities(hass, config_entry) + hass.config_entries.async_update_entry( - config_entry, data=new_data, minor_version=1, version=2 + config_entry, data=new_data, minor_version=1, version=3 ) _LOGGER.debug( @@ -176,6 +194,29 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True +async def async_migrate_entities( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate entity registry.""" + + @callback + def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + # fix unique entity ids for climate and scene + if "." in entity_entry.unique_id: + if entity_entry.domain == Platform.CLIMATE: + setpoint = entity_entry.unique_id.split(".")[-1] + return { + "new_unique_id": entity_entry.unique_id.rsplit("-", 1)[0] + + f"-{setpoint}" + } + if entity_entry.domain == Platform.SCENE: + return {"new_unique_id": entity_entry.unique_id.replace(".", "")} + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Close connection to PCHK host represented by config_entry.""" # forward unloading to platforms diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index 63e0d8c8b26..62a9920fb73 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -44,21 +43,6 @@ CONFIG_SCHEMA = vol.Schema(CONFIG_DATA) USER_SCHEMA = vol.Schema(USER_DATA) -def get_config_entry( - hass: HomeAssistant, data: ConfigType -) -> config_entries.ConfigEntry | None: - """Check config entries for already configured entries based on the ip address/port.""" - return next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data[CONF_IP_ADDRESS] == data[CONF_IP_ADDRESS] - and entry.data[CONF_PORT] == data[CONF_PORT] - ), - None, - ) - - async def validate_connection(data: ConfigType) -> str | None: """Validate if a connection to LCN can be established.""" error = None @@ -110,7 +94,7 @@ async def validate_connection(data: ConfigType) -> str | None: class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a LCN config flow.""" - VERSION = 2 + VERSION = 3 MINOR_VERSION = 1 async def async_step_user( @@ -120,19 +104,20 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form(step_id="user", data_schema=USER_SCHEMA) - errors = None - if get_config_entry(self.hass, user_input): - errors = {CONF_BASE: "already_configured"} - elif (error := await validate_connection(user_input)) is not None: - errors = {CONF_BASE: error} + self._async_abort_entries_match( + { + CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS], + CONF_PORT: user_input[CONF_PORT], + } + ) - if errors is not None: + if (error := await validate_connection(user_input)) is not None: return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( USER_SCHEMA, user_input ), - errors=errors, + errors={CONF_BASE: error}, ) data: dict = { @@ -152,15 +137,21 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: user_input[CONF_HOST] = reconfigure_entry.data[CONF_HOST] - await self.hass.config_entries.async_unload(reconfigure_entry.entry_id) - if (error := await validate_connection(user_input)) is not None: - errors = {CONF_BASE: error} + self._async_abort_entries_match( + { + CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS], + CONF_PORT: user_input[CONF_PORT], + } + ) - if errors is None: + await self.hass.config_entries.async_unload(reconfigure_entry.entry_id) + + if (error := await validate_connection(user_input)) is None: return self.async_update_reload_and_abort( reconfigure_entry, data_updates=user_input ) + errors = {CONF_BASE: error} await self.hass.config_entries.async_setup(reconfigure_entry.entry_id) return self.async_show_form( diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index b443e05def7..d67c02ed56a 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -56,6 +56,7 @@ CONF_SCENES = "scenes" CONF_REGISTER = "register" CONF_OUTPUTS = "outputs" CONF_REVERSE_TIME = "reverse_time" +CONF_POSITIONING_MODE = "positioning_mode" DIM_MODES = ["STEPS50", "STEPS200"] @@ -235,4 +236,6 @@ TIME_UNITS = [ "D", ] -MOTOR_REVERSE_TIME = ["RT70", "RT600", "RT1200"] +MOTOR_REVERSE_TIMES = ["RT70", "RT600", "RT1200"] + +MOTOR_POSITIONING_MODES = ["NONE", "BS4", "MODULE"] diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index be713871aae..068d8f5ba11 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -6,7 +6,12 @@ from typing import Any import pypck -from homeassistant.components.cover import DOMAIN as DOMAIN_COVER, CoverEntity +from homeassistant.components.cover import ( + ATTR_POSITION, + DOMAIN as DOMAIN_COVER, + CoverEntity, + CoverEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant @@ -17,6 +22,7 @@ from .const import ( ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_MOTOR, + CONF_POSITIONING_MODE, CONF_REVERSE_TIME, DOMAIN, ) @@ -115,7 +121,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" state = pypck.lcn_defs.MotorStateModifier.DOWN - if not await self.device_connection.control_motors_outputs( + if not await self.device_connection.control_motor_outputs( state, self.reverse_time ): return @@ -126,7 +132,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" state = pypck.lcn_defs.MotorStateModifier.UP - if not await self.device_connection.control_motors_outputs( + if not await self.device_connection.control_motor_outputs( state, self.reverse_time ): return @@ -138,7 +144,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" state = pypck.lcn_defs.MotorStateModifier.STOP - if not await self.device_connection.control_motors_outputs(state): + if not await self.device_connection.control_motor_outputs(state): return self._attr_is_closing = False self._attr_is_opening = False @@ -176,11 +182,25 @@ class LcnRelayCover(LcnEntity, CoverEntity): _attr_is_closing = False _attr_is_opening = False _attr_assumed_state = True + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + positioning_mode: pypck.lcn_defs.MotorPositioningMode def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN cover.""" super().__init__(config, config_entry) + self.positioning_mode = pypck.lcn_defs.MotorPositioningMode( + config[CONF_DOMAIN_DATA].get( + CONF_POSITIONING_MODE, pypck.lcn_defs.MotorPositioningMode.NONE.value + ) + ) + + if self.positioning_mode != pypck.lcn_defs.MotorPositioningMode.NONE: + self._attr_supported_features |= CoverEntityFeature.SET_POSITION + self.motor = pypck.lcn_defs.MotorPort[config[CONF_DOMAIN_DATA][CONF_MOTOR]] self.motor_port_onoff = self.motor.value * 2 self.motor_port_updown = self.motor_port_onoff + 1 @@ -193,7 +213,9 @@ class LcnRelayCover(LcnEntity, CoverEntity): """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.motor) + await self.device_connection.activate_status_request_handler( + self.motor, self.positioning_mode + ) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" @@ -203,9 +225,11 @@ class LcnRelayCover(LcnEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 - states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.DOWN - if not await self.device_connection.control_motors_relays(states): + if not await self.device_connection.control_motor_relays( + self.motor.value, + pypck.lcn_defs.MotorStateModifier.DOWN, + self.positioning_mode, + ): return self._attr_is_opening = False self._attr_is_closing = True @@ -213,9 +237,11 @@ class LcnRelayCover(LcnEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 - states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.UP - if not await self.device_connection.control_motors_relays(states): + if not await self.device_connection.control_motor_relays( + self.motor.value, + pypck.lcn_defs.MotorStateModifier.UP, + self.positioning_mode, + ): return self._attr_is_closed = False self._attr_is_opening = True @@ -224,26 +250,55 @@ class LcnRelayCover(LcnEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 - states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.STOP - if not await self.device_connection.control_motors_relays(states): + if not await self.device_connection.control_motor_relays( + self.motor.value, + pypck.lcn_defs.MotorStateModifier.STOP, + self.positioning_mode, + ): return self._attr_is_closing = False self._attr_is_opening = False self.async_write_ha_state() - def input_received(self, input_obj: InputType) -> None: - """Set cover states when LCN input object (command) is received.""" - if not isinstance(input_obj, pypck.inputs.ModStatusRelays): + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + if not await self.device_connection.control_motor_relays_position( + self.motor.value, position, mode=self.positioning_mode + ): return - - states = input_obj.states # list of boolean values (relay on/off) - if states[self.motor_port_onoff]: # motor is on - self._attr_is_opening = not states[self.motor_port_updown] # set direction - self._attr_is_closing = states[self.motor_port_updown] # set direction - else: # motor is off - self._attr_is_opening = False - self._attr_is_closing = False - self._attr_is_closed = states[self.motor_port_updown] + self._attr_is_closed = (self._attr_current_cover_position == 0) & ( + position == 0 + ) + if self._attr_current_cover_position is not None: + self._attr_is_closing = self._attr_current_cover_position > position + self._attr_is_opening = self._attr_current_cover_position < position + self._attr_current_cover_position = position self.async_write_ha_state() + + def input_received(self, input_obj: InputType) -> None: + """Set cover states when LCN input object (command) is received.""" + if isinstance(input_obj, pypck.inputs.ModStatusRelays): + self._attr_is_opening = input_obj.is_opening(self.motor.value) + self._attr_is_closing = input_obj.is_closing(self.motor.value) + + if self.positioning_mode == pypck.lcn_defs.MotorPositioningMode.NONE: + self._attr_is_closed = input_obj.is_assumed_closed(self.motor.value) + self.async_write_ha_state() + elif ( + isinstance( + input_obj, + ( + pypck.inputs.ModStatusMotorPositionBS4, + pypck.inputs.ModStatusMotorPositionModule, + ), + ) + and input_obj.motor == self.motor.value + ): + self._attr_current_cover_position = input_obj.position + if self._attr_current_cover_position in [0, 100]: + self._attr_is_opening = False + self._attr_is_closing = False + self._attr_is_closed = self._attr_current_cover_position == 0 + self.async_write_ha_state() diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index ffb680c4237..a1940fc7ac3 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -3,18 +3,19 @@ from collections.abc import Callable from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE +from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import CONF_DOMAIN_DATA, DOMAIN from .helpers import ( AddressType, DeviceConnectionType, InputType, generate_unique_id, get_device_connection, + get_resource, ) @@ -22,6 +23,7 @@ class LcnEntity(Entity): """Parent class for all entities associated with the LCN component.""" _attr_should_poll = False + _attr_has_entity_name = True device_connection: DeviceConnectionType def __init__( @@ -48,7 +50,11 @@ class LcnEntity(Entity): def unique_id(self) -> str: """Return a unique ID.""" return generate_unique_id( - self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE] + self.config_entry.entry_id, + self.address, + get_resource( + self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA] + ).lower(), ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 2176c669251..1bc4c6caa41 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_LIGHTS, CONF_NAME, - CONF_RESOURCE, CONF_SENSORS, CONF_SWITCHES, ) @@ -29,6 +28,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CLIMATES, + CONF_DOMAIN_DATA, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, CONF_SCENES, @@ -79,9 +79,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 cast(str, 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") @@ -115,7 +115,9 @@ def purge_entity_registry( references_entry_data = set() for entity_data in imported_entry_data[CONF_ENTITIES]: entity_unique_id = generate_unique_id( - entry_id, entity_data[CONF_ADDRESS], entity_data[CONF_RESOURCE] + entry_id, + entity_data[CONF_ADDRESS], + get_resource(entity_data[CONF_DOMAIN], entity_data[CONF_DOMAIN_DATA]), ) entity_id = entity_registry.async_get_entity_id( entity_data[CONF_DOMAIN], DOMAIN, entity_unique_id @@ -281,26 +283,6 @@ def get_device_config( return None -def is_address(value: str) -> tuple[AddressType, str]: - """Validate the given address string. - - Examples for S000M005 at myhome: - myhome.s000.m005 - myhome.s0.m5 - myhome.0.5 ("m" is implicit if missing) - - Examples for s000g011 - myhome.0.g11 - myhome.s0.g11 - """ - if matcher := PATTERN_ADDRESS.match(value): - is_group = matcher.group("type") == "g" - addr = (int(matcher.group("seg_id")), int(matcher.group("id")), is_group) - conn_id = matcher.group("conn_id") - return addr, conn_id - raise ValueError(f"{value} is not a valid address string") - - def is_states_string(states_string: str) -> list[str]: """Validate the given states string and return states list.""" if len(states_string) != 8: diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index c1dd7751940..0031cbcc947 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.8.5", "lcn-frontend==0.2.3"] + "requirements": ["pypck==0.8.6", "lcn-frontend==0.2.4"] } diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index d90e264692c..fcc6044dd77 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -21,6 +21,7 @@ from .const import ( CONF_MOTOR, CONF_OUTPUT, CONF_OUTPUTS, + CONF_POSITIONING_MODE, CONF_REGISTER, CONF_REVERSE_TIME, CONF_SETPOINT, @@ -30,7 +31,8 @@ from .const import ( LED_PORTS, LOGICOP_PORTS, MOTOR_PORTS, - MOTOR_REVERSE_TIME, + MOTOR_POSITIONING_MODES, + MOTOR_REVERSE_TIMES, OUTPUT_PORTS, RELAY_PORTS, S0_INPUTS, @@ -68,8 +70,11 @@ DOMAIN_DATA_CLIMATE: VolDictType = { DOMAIN_DATA_COVER: VolDictType = { vol.Required(CONF_MOTOR): vol.All(vol.Upper, vol.In(MOTOR_PORTS)), + vol.Optional(CONF_POSITIONING_MODE, default="none"): vol.All( + vol.Upper, vol.In(MOTOR_POSITIONING_MODES) + ), vol.Optional(CONF_REVERSE_TIME, default="rt1200"): vol.All( - vol.Upper, vol.In(MOTOR_REVERSE_TIME) + vol.Upper, vol.In(MOTOR_REVERSE_TIMES) ), } diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 7783df8679a..0c78ea6637a 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE, @@ -49,6 +50,7 @@ DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.METERPERSECOND: SensorDeviceClass.SPEED, pypck.lcn_defs.VarUnit.VOLT: SensorDeviceClass.VOLTAGE, pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT, + pypck.lcn_defs.VarUnit.PPM: SensorDeviceClass.CO2, } UNIT_OF_MEASUREMENT_MAPPING = { @@ -60,6 +62,7 @@ UNIT_OF_MEASUREMENT_MAPPING = { pypck.lcn_defs.VarUnit.METERPERSECOND: UnitOfSpeed.METERS_PER_SECOND, pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT, pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE, + pypck.lcn_defs.VarUnit.PPM: CONCENTRATION_PARTS_PER_MILLION, } diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 2694bed31d2..fdc5359d300 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -6,10 +6,8 @@ import pypck import voluptuous as vol from homeassistant.const import ( - CONF_ADDRESS, CONF_BRIGHTNESS, CONF_DEVICE_ID, - CONF_HOST, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, ) @@ -21,7 +19,6 @@ from homeassistant.core import ( ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import ( CONF_KEYS, @@ -51,12 +48,7 @@ from .const import ( VAR_UNITS, VARIABLES, ) -from .helpers import ( - DeviceConnectionType, - get_device_connection, - is_address, - is_states_string, -) +from .helpers import DeviceConnectionType, is_states_string class LcnServiceCall: @@ -64,8 +56,7 @@ class LcnServiceCall: schema = vol.Schema( { - vol.Optional(CONF_DEVICE_ID): cv.string, - vol.Optional(CONF_ADDRESS): is_address, + vol.Required(CONF_DEVICE_ID): cv.string, } ) supports_response = SupportsResponse.NONE @@ -76,46 +67,18 @@ class LcnServiceCall: def get_device_connection(self, service: ServiceCall) -> DeviceConnectionType: """Get address connection object.""" - if CONF_DEVICE_ID not in service.data and CONF_ADDRESS not 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="no_device_identifier", + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, ) - 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( - self.hass, address, config_entry - ) - if device_connection is None: - raise ValueError("Wrong address.") - return device_connection - raise ValueError("Invalid host name.") + return self.hass.data[DOMAIN][device.primary_config_entry][DEVICE_CONNECTIONS][ + device_id + ] async def async_call_service(self, service: ServiceCall) -> ServiceResponse: """Execute service call.""" diff --git a/homeassistant/components/lcn/services.yaml b/homeassistant/components/lcn/services.yaml index f58e79b9f40..ad0e7dfec86 100644 --- a/homeassistant/components/lcn/services.yaml +++ b/homeassistant/components/lcn/services.yaml @@ -2,9 +2,10 @@ output_abs: fields: - device_id: + device_id: &device_id + required: true example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: &device_selector + selector: device: filter: - integration: lcn @@ -71,10 +72,6 @@ output_abs: model: LCN-UMF - integration: lcn model: LCN-WBH - address: - example: "myhome.s0.m7" - selector: - text: output: required: true selector: @@ -102,13 +99,7 @@ output_abs: output_rel: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id output: required: true selector: @@ -128,13 +119,7 @@ output_rel: output_toggle: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id output: required: true selector: @@ -155,13 +140,7 @@ output_toggle: relays: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id state: required: true example: "t---001-" @@ -170,13 +149,7 @@ relays: led: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id led: required: true selector: @@ -206,13 +179,7 @@ led: var_abs: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id variable: required: true default: native @@ -275,13 +242,7 @@ var_abs: var_reset: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id variable: required: true selector: @@ -310,13 +271,7 @@ var_reset: var_rel: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id variable: required: true selector: @@ -403,13 +358,7 @@ var_rel: lock_regulator: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id setpoint: required: true selector: @@ -439,13 +388,7 @@ lock_regulator: send_keys: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id keys: required: true example: "a1a5d8" @@ -488,13 +431,7 @@ send_keys: lock_keys: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id table: example: "a" default: a @@ -533,13 +470,7 @@ lock_keys: dyn_text: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id row: required: true selector: @@ -554,13 +485,7 @@ dyn_text: pck: fields: - device_id: - example: "91aa039a2fb6e0b9f9ec7eb219a6b7d2" - selector: *device_selector - address: - example: "myhome.s0.m7" - selector: - text: + device_id: *device_id pck: required: true example: "PIN4" diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 0bdd85a3678..9d806bce104 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -66,11 +66,11 @@ "error": { "authentication_error": "Authentication failed. Wrong username or password.", "license_error": "Maximum number of connections was reached. An additional licence key is required.", - "connection_refused": "Unable to connect to PCHK. Check IP and port.", - "already_configured": "PCHK connection using the same ip address/port is already configured." + "connection_refused": "Unable to connect to PCHK. Check IP and port." }, "abort": { - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "already_configured": "PCHK connection using the same ip address/port is already configured." } }, "issues": { @@ -81,10 +81,6 @@ "deprecated_keylock_sensor": { "title": "Deprecated LCN key lock binary sensor", "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": { @@ -396,19 +392,19 @@ }, "address_to_device_id": { "name": "Address to device ID", - "description": "Convert LCN address to device ID.", + "description": "Converts an LCN address into a device ID.", "fields": { "id": { "name": "Module or group ID", - "description": "Target module or group ID." + "description": "Module or group number of the target." }, "segment_id": { "name": "Segment ID", - "description": "Target segment ID." + "description": "Segment number of the target." }, "type": { "name": "Type", - "description": "Target type." + "description": "Module type of the target." }, "host": { "name": "Host name", @@ -418,9 +414,6 @@ } }, "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." }, diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 9084ec838d9..545ee1e0043 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITIES, CONF_NAME, - CONF_RESOURCE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( @@ -343,7 +342,6 @@ async def websocket_add_entity( entity_config = { CONF_ADDRESS: msg[CONF_ADDRESS], CONF_NAME: msg[CONF_NAME], - CONF_RESOURCE: resource, CONF_DOMAIN: domain_name, CONF_DOMAIN_DATA: domain_data, } @@ -371,7 +369,15 @@ async def websocket_add_entity( vol.Required("entry_id"): cv.string, vol.Required(CONF_ADDRESS): ADDRESS_SCHEMA, vol.Required(CONF_DOMAIN): cv.string, - vol.Required(CONF_RESOURCE): cv.string, + vol.Required(CONF_DOMAIN_DATA): vol.Any( + DOMAIN_DATA_BINARY_SENSOR, + DOMAIN_DATA_SENSOR, + DOMAIN_DATA_SWITCH, + DOMAIN_DATA_LIGHT, + DOMAIN_DATA_CLIMATE, + DOMAIN_DATA_COVER, + DOMAIN_DATA_SCENE, + ), } ) @websocket_api.async_response @@ -390,7 +396,10 @@ async def websocket_delete_entity( if ( tuple(entity_config[CONF_ADDRESS]) == msg[CONF_ADDRESS] and entity_config[CONF_DOMAIN] == msg[CONF_DOMAIN] - and entity_config[CONF_RESOURCE] == msg[CONF_RESOURCE] + and get_resource( + entity_config[CONF_DOMAIN], entity_config[CONF_DOMAIN_DATA] + ) + == get_resource(msg[CONF_DOMAIN], msg[CONF_DOMAIN_DATA]) ) ), None, diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 36d0150642e..ba5ca3bdba4 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.23.4", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.28.1", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/leaone/manifest.json b/homeassistant/components/leaone/manifest.json index 97ac8a06e97..b7b9b5b1c38 100644 --- a/homeassistant/components/leaone/manifest.json +++ b/homeassistant/components/leaone/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/leaone", "iot_class": "local_push", - "requirements": ["leaone-ble==0.1.0"] + "requirements": ["leaone-ble==0.3.0"] } diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 14f2f228e13..2facda734d5 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from led_ble import LEDBLE @@ -83,7 +83,7 @@ class LEDBLEEntity(CoordinatorEntity[DataUpdateCoordinator[None]], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) + brightness = cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness)) if effect := kwargs.get(ATTR_EFFECT): await self._async_set_effect(effect, brightness) return diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 309399e6958..49daafeca25 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -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.23.4", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.28.1", "led-ble==1.1.7"] } diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index 3b4417c346a..23aac0b3059 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -24,7 +24,7 @@ "entity": { "binary_sensor": { "state_e_activated": { - "name": "Ev error" + "name": "EV error" }, "overtemp": { "name": "Thermal throttling" @@ -45,10 +45,10 @@ "name": "Overvoltage" }, "rcd_error": { - "name": "Rcd error" + "name": "RCD error" }, "cp_diode_failure": { - "name": "Ev diode short" + "name": "EV diode short" }, "contactor_failure": { "name": "Relay contacts welded" @@ -64,7 +64,7 @@ }, "number": { "led_max_brightness": { - "name": "Led brightness" + "name": "LED brightness" }, "dynamic_limit": { "name": "Dynamic limit" @@ -87,11 +87,11 @@ "state": { "available": "Available", "charging": "[%key:common::state::charging%]", - "connected": "Connected", - "error": "Error", - "locked": "Locked", + "connected": "[%key:common::state::connected%]", + "error": "[%key:common::state::error%]", + "locked": "[%key:common::state::locked%]", "need_auth": "Waiting for authentication", - "paused": "Paused", + "paused": "[%key:common::state::paused%]", "paused_by_scheduler": "Paused by scheduler", "updating_firmware": "Updating firmware" } @@ -118,7 +118,7 @@ "ocpp": "OCPP", "overtemperature": "Overtemperature", "switching_phases": "Switching phases", - "1p_charging_disabled": "1p charging disabled" + "1p_charging_disabled": "1P charging disabled" } }, "breaker_current": { diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index 72d81af4ff0..47282b6cc22 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -22,7 +22,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval -from .const import CONF_CONNECT_CLIENT_ID, MQTT_SUBSCRIPTION_INTERVAL +from .const import CONF_CONNECT_CLIENT_ID, DOMAIN, MQTT_SUBSCRIPTION_INTERVAL from .coordinator import DeviceDataUpdateCoordinator, async_setup_device_coordinator from .mqtt import ThinQMQTT @@ -47,6 +47,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, Platform.VACUUM, + Platform.WATER_HEATER, ] _LOGGER = logging.getLogger(__name__) @@ -136,7 +137,15 @@ async def async_setup_mqtt( entry.runtime_data.mqtt_client = mqtt_client # Try to connect. - result = await mqtt_client.async_connect() + try: + result = await mqtt_client.async_connect() + except (AttributeError, ThinQAPIException, TypeError, ValueError) as exc: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_connect_mqtt", + translation_placeholders={"error": str(exc)}, + ) from exc + if not result: _LOGGER.error("Failed to set up mqtt connection") return diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 73678e209f7..98a86a8d355 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -110,7 +110,9 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_hvac_modes = [HVACMode.OFF] self._attr_hvac_mode = HVACMode.OFF self._attr_preset_modes = [] - self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) self._requested_hvac_mode: str | None = None # Set up HVAC modes. @@ -182,6 +184,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_target_temperature_high = self.data.target_temp_high self._attr_target_temperature_low = self.data.target_temp_low + # Update unit. + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + _LOGGER.debug( "[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s", self.coordinator.device_name, diff --git a/homeassistant/components/lg_thinq/const.py b/homeassistant/components/lg_thinq/const.py index a65dee715db..20c6455241a 100644 --- a/homeassistant/components/lg_thinq/const.py +++ b/homeassistant/components/lg_thinq/const.py @@ -3,6 +3,8 @@ from datetime import timedelta from typing import Final +from homeassistant.const import UnitOfTemperature + # Config flow DOMAIN = "lg_thinq" COMPANY = "LGE" @@ -18,3 +20,10 @@ MQTT_SUBSCRIPTION_INTERVAL: Final = timedelta(days=1) # MQTT: Message types DEVICE_PUSH_MESSAGE: Final = "DEVICE_PUSH" DEVICE_STATUS_MESSAGE: Final = "DEVICE_STATUS" + +# Unit conversion map +DEVICE_UNIT_TO_HA: dict[str, str] = { + "F": UnitOfTemperature.FAHRENHEIT, + "C": UnitOfTemperature.CELSIUS, +} +REVERSE_DEVICE_UNIT_TO_HA = {v: k for k, v in DEVICE_UNIT_TO_HA.items()} diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index d6991d15297..9f84c422277 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -2,19 +2,21 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any from thinqconnect import ThinQAPIException from thinqconnect.integration import HABridge -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_CORE_CONFIG_UPDATE +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed if TYPE_CHECKING: from . import ThinqConfigEntry -from .const import DOMAIN +from .const import DOMAIN, REVERSE_DEVICE_UNIT_TO_HA _LOGGER = logging.getLogger(__name__) @@ -54,6 +56,42 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): f"{self.device_id}_{self.sub_id}" if self.sub_id else self.device_id ) + # Set your preferred temperature unit. This will allow us to retrieve + # temperature values from the API in a converted value corresponding to + # preferred unit. + self._update_preferred_temperature_unit() + + # Add a callback to handle core config update. + self.unit_system: str | None = None + self.config_entry.async_on_unload( + self.hass.bus.async_listen( + event_type=EVENT_CORE_CONFIG_UPDATE, + listener=self._handle_update_config, + event_filter=self.async_config_update_filter, + ) + ) + + async def _handle_update_config(self, _: Event) -> None: + """Handle update core config.""" + self._update_preferred_temperature_unit() + + await self.async_refresh() + + @callback + def async_config_update_filter(self, event_data: Mapping[str, Any]) -> bool: + """Filter out unwanted events.""" + if (unit_system := event_data.get("unit_system")) != self.unit_system: + self.unit_system = unit_system + return True + + return False + + def _update_preferred_temperature_unit(self) -> None: + """Update preferred temperature unit.""" + self.api.set_preferred_temperature_unit( + REVERSE_DEVICE_UNIT_TO_HA.get(self.hass.config.units.temperature_unit) + ) + async def _async_update_data(self) -> dict[str, Any]: """Request to the server to update the status from full response data.""" try: diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 7856506559b..61d8199f321 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -10,25 +10,19 @@ from thinqconnect import ThinQAPIException from thinqconnect.devices.const import Location from thinqconnect.integration import PropertyState -from homeassistant.const import UnitOfTemperature from homeassistant.core import callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import COMPANY, DOMAIN +from .const import COMPANY, DEVICE_UNIT_TO_HA, DOMAIN from .coordinator import DeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) EMPTY_STATE = PropertyState() -UNIT_CONVERSION_MAP: dict[str, str] = { - "F": UnitOfTemperature.FAHRENHEIT, - "C": UnitOfTemperature.CELSIUS, -} - class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """The base implementation of all lg thinq entities.""" @@ -75,7 +69,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): if unit is None: return None - return UNIT_CONVERSION_MAP.get(unit) + return DEVICE_UNIT_TO_HA.get(unit) def _update_status(self) -> None: """Update status itself. diff --git a/homeassistant/components/lg_thinq/fan.py b/homeassistant/components/lg_thinq/fan.py index 6d07c98744a..7d20be68b01 100644 --- a/homeassistant/components/lg_thinq/fan.py +++ b/homeassistant/components/lg_thinq/fan.py @@ -2,11 +2,13 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any from thinqconnect import DeviceType -from thinqconnect.integration import ExtendedProperty +from thinqconnect.devices.const import Property as ThinQProperty +from thinqconnect.integration import ActiveMode from homeassistant.components.fan import ( FanEntity, @@ -24,16 +26,35 @@ from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator from .entity import ThinQEntity -DEVICE_TYPE_FAN_MAP: dict[DeviceType, tuple[FanEntityDescription, ...]] = { + +@dataclass(frozen=True, kw_only=True) +class ThinQFanEntityDescription(FanEntityDescription): + """Describes ThinQ fan entity.""" + + operation_key: str + preset_modes: list[str] | None = None + + +DEVICE_TYPE_FAN_MAP: dict[DeviceType, tuple[ThinQFanEntityDescription, ...]] = { DeviceType.CEILING_FAN: ( - FanEntityDescription( - key=ExtendedProperty.FAN, + ThinQFanEntityDescription( + key=ThinQProperty.WIND_STRENGTH, name=None, + operation_key=ThinQProperty.CEILING_FAN_OPERATION_MODE, + ), + ), + DeviceType.VENTILATOR: ( + ThinQFanEntityDescription( + key=ThinQProperty.WIND_STRENGTH, + name=None, + translation_key=ThinQProperty.WIND_STRENGTH, + operation_key=ThinQProperty.VENTILATOR_OPERATION_MODE, + preset_modes=["auto"], ), ), } -FOUR_STEP_SPEEDS = ["low", "mid", "high", "turbo"] +ORDERED_NAMED_FAN_SPEEDS = ["low", "mid", "high", "turbo", "power"] _LOGGER = logging.getLogger(__name__) @@ -52,7 +73,9 @@ async def async_setup_entry( for description in descriptions: entities.extend( ThinQFanEntity(coordinator, description, property_id) - for property_id in coordinator.api.get_active_idx(description.key) + for property_id in coordinator.api.get_active_idx( + description.key, ActiveMode.READ_WRITE + ) ) if entities: @@ -65,48 +88,76 @@ class ThinQFanEntity(ThinQEntity, FanEntity): def __init__( self, coordinator: DeviceDataUpdateCoordinator, - entity_description: FanEntityDescription, + entity_description: ThinQFanEntityDescription, property_id: str, ) -> None: """Initialize fan platform.""" super().__init__(coordinator, entity_description, property_id) - self._ordered_named_fan_speeds = [] + self._ordered_named_fan_speeds = ORDERED_NAMED_FAN_SPEEDS.copy() self._attr_supported_features = ( FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF ) - if (fan_modes := self.data.fan_modes) is not None: - self._attr_speed_count = len(fan_modes) - if self.speed_count == 4: - self._ordered_named_fan_speeds = FOUR_STEP_SPEEDS + self._attr_preset_modes = [] + for option in self.data.options: + if ( + entity_description.preset_modes is not None + and option in entity_description.preset_modes + ): + self._attr_supported_features |= FanEntityFeature.PRESET_MODE + self._attr_preset_modes.append(option) + else: + for ordered_step in ORDERED_NAMED_FAN_SPEEDS: + if ( + ordered_step in self._ordered_named_fan_speeds + and ordered_step not in self.data.options + ): + self._ordered_named_fan_speeds.remove(ordered_step) + self._attr_speed_count = len(self._ordered_named_fan_speeds) + self._operation_id = entity_description.operation_key def _update_status(self) -> None: """Update status itself.""" super()._update_status() # Update power on state. - self._attr_is_on = self.data.is_on + self._attr_is_on = _is_on = self.coordinator.data[self._operation_id].is_on # Update fan speed. - if ( - self.data.is_on - and (mode := self.data.fan_mode) in self._ordered_named_fan_speeds - ): - self._attr_percentage = ordered_list_item_to_percentage( - self._ordered_named_fan_speeds, mode - ) + if _is_on and (mode := self.data.value) is not None: + if self.preset_modes is not None and mode in self.preset_modes: + self._attr_preset_mode = mode + self._attr_percentage = 0 + elif mode in self._ordered_named_fan_speeds: + self._attr_percentage = ordered_list_item_to_percentage( + self._ordered_named_fan_speeds, mode + ) + self._attr_preset_mode = None else: + self._attr_preset_mode = None self._attr_percentage = 0 _LOGGER.debug( - "[%s:%s] update status: %s -> %s (percentage=%s)", + "[%s:%s] update status: is_on=%s, percentage=%s, preset_mode=%s", self.coordinator.device_name, self.property_id, - self.data.is_on, - self.is_on, + _is_on, self.percentage, + self.preset_mode, + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + _LOGGER.debug( + "[%s:%s] async_set_preset_mode. preset_mode=%s", + self.coordinator.device_name, + self.property_id, + preset_mode, + ) + await self.async_call_api( + self.coordinator.api.post(self.property_id, preset_mode) ) async def async_set_percentage(self, percentage: int) -> None: @@ -129,9 +180,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity): percentage, value, ) - await self.async_call_api( - self.coordinator.api.async_set_fan_mode(self.property_id, value) - ) + await self.async_call_api(self.coordinator.api.post(self.property_id, value)) async def async_turn_on( self, @@ -141,13 +190,25 @@ class ThinQFanEntity(ThinQEntity, FanEntity): ) -> None: """Turn on the fan.""" _LOGGER.debug( - "[%s:%s] async_turn_on", self.coordinator.device_name, self.property_id + "[%s:%s] async_turn_on percentage=%s, preset_mode=%s, kwargs=%s", + self.coordinator.device_name, + self._operation_id, + percentage, + preset_mode, + kwargs, + ) + await self.async_call_api( + self.coordinator.api.async_turn_on(self._operation_id) ) - await self.async_call_api(self.coordinator.api.async_turn_on(self.property_id)) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" _LOGGER.debug( - "[%s:%s] async_turn_off", self.coordinator.device_name, self.property_id + "[%s:%s] async_turn_off kwargs=%s", + self.coordinator.device_name, + self._operation_id, + kwargs, + ) + await self.async_call_api( + self.coordinator.api.async_turn_off(self._operation_id) ) - await self.async_call_api(self.coordinator.api.async_turn_off(self.property_id)) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 787b50167c1..3b0baaaaf75 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -169,6 +169,9 @@ "current_job_mode": { "default": "mdi:format-list-bulleted" }, + "current_job_mode_dehumidifier": { + "default": "mdi:format-list-bulleted" + }, "operation_mode": { "default": "mdi:gesture-tap-button" }, diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index b00d28c1d4f..cffc61cb1c4 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.4"] + "requirements": ["thinqconnect==1.0.5"] } diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py index 025f80f78b1..d6ff1f72b8f 100644 --- a/homeassistant/components/lg_thinq/mqtt.py +++ b/homeassistant/components/lg_thinq/mqtt.py @@ -43,19 +43,16 @@ class ThinQMQTT: async def async_connect(self) -> bool: """Create a mqtt client and then try to connect.""" - try: - self.client = await ThinQMQTTClient( - self.thinq_api, self.client_id, self.on_message_received - ) - if self.client is None: - return False - # Connect to server and create certificate. - return await self.client.async_prepare_mqtt() - except (ThinQAPIException, TypeError, ValueError): - _LOGGER.exception("Failed to connect") + self.client = await ThinQMQTTClient( + self.thinq_api, self.client_id, self.on_message_received + ) + if self.client is None: return False + # Connect to server and create certificate. + return await self.client.async_prepare_mqtt() + async def async_disconnect(self, event: Event | None = None) -> None: """Unregister client and disconnects handlers.""" await self.async_end_subscribes() diff --git a/homeassistant/components/lg_thinq/number.py b/homeassistant/components/lg_thinq/number.py index 7003519e0ce..ac8991d6bb5 100644 --- a/homeassistant/components/lg_thinq/number.py +++ b/homeassistant/components/lg_thinq/number.py @@ -123,6 +123,9 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] = NUMBER_DESC[ThinQProperty.LIGHT_STATUS], NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE], ), + DeviceType.VENTILATOR: ( + TIMER_NUMBER_DESC[ThinQProperty.SLEEP_TIMER_RELATIVE_HOUR_TO_STOP], + ), } _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lg_thinq/select.py b/homeassistant/components/lg_thinq/select.py index 929fa0b1d28..3f29ee9e5c8 100644 --- a/homeassistant/components/lg_thinq/select.py +++ b/homeassistant/components/lg_thinq/select.py @@ -98,7 +98,13 @@ DEVICE_TYPE_SELECT_MAP: dict[DeviceType, tuple[SelectEntityDescription, ...]] = AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH], SELECT_DESC[ThinQProperty.CURRENT_JOB_MODE], ), - DeviceType.DEHUMIDIFIER: (AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],), + DeviceType.DEHUMIDIFIER: ( + AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH], + SelectEntityDescription( + key=ThinQProperty.CURRENT_JOB_MODE, + translation_key="current_job_mode_dehumidifier", + ), + ), DeviceType.DISH_WASHER: ( OPERATION_SELECT_DESC[ThinQProperty.DISH_WASHER_OPERATION_MODE], ), diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index e1d3779f44b..a5fb81e3818 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -19,7 +19,7 @@ "description": "Please enter a ThinQ [PAT(Personal Access Token)]({pat_url}) created with your LG ThinQ account.", "data": { "access_token": "Personal Access Token", - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" } } } @@ -119,11 +119,11 @@ "fan_mode": { "state": { "slow": "Slow", - "low": "Low", - "mid": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "mid": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]" + "auto": "[%key:common::state::auto%]" } }, "preset_mode": { @@ -303,7 +303,7 @@ "state": { "invalid": "Invalid", "weak": "Weak", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "strong": "Strong", "very_strong": "Very strong" } @@ -343,7 +343,7 @@ "growth_mode": { "name": "Mode", "state": { - "standard": "Auto", + "standard": "[%key:common::state::auto%]", "ext_leaf": "Vegetables", "ext_herb": "Herbs", "ext_flower": "Flowers", @@ -353,7 +353,7 @@ "growth_mode_for_location": { "name": "{location} mode", "state": { - "standard": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "standard": "[%key:common::state::auto%]", "ext_leaf": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::ext_leaf%]", "ext_herb": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::ext_herb%]", "ext_flower": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::ext_flower%]", @@ -390,17 +390,17 @@ "temperature_state": { "name": "[%key:component::sensor::entity_component::temperature::name%]", "state": { - "high": "High", + "high": "[%key:common::state::high%]", "normal": "Good", - "low": "Low" + "low": "[%key:common::state::low%]" } }, "temperature_state_for_location": { "name": "[%key:component::lg_thinq::entity::number::target_temperature_for_location::name%]", "state": { - "high": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::high%]", + "high": "[%key:common::state::high%]", "normal": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::normal%]", - "low": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::low%]" + "low": "[%key:common::state::low%]" } }, "current_state": { @@ -581,7 +581,7 @@ "name": "[%key:component::lg_thinq::entity::binary_sensor::one_touch_filter::name%]", "state": { "off": "[%key:common::state::off%]", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "replace": "Replace filter", "smart_power": "Smart safe storage", @@ -599,7 +599,7 @@ "name": "Operating mode", "state": { "air_clean": "Purify", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "clothes_dry": "Laundry", "edge": "Edge cleaning", "heat_pump": "Heat pump", @@ -607,7 +607,7 @@ "intensive_dry": "Spot", "macro": "Custom mode", "mop": "Mop", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]", "quiet_humidity": "Silent", "rapid_humidity": "Jet", @@ -626,7 +626,7 @@ "auto": "Low power", "high": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "mop": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::mop%]", - "normal": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::normal%]", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]" } @@ -649,11 +649,11 @@ "current_dish_washing_course": { "name": "Current cycle", "state": { - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "heavy": "Intensive", "delicate": "Delicate", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "rinse": "Rinse", "refresh": "Refresh", "express": "Express", @@ -781,8 +781,8 @@ "name": "Battery", "state": { "high": "Full", - "mid": "Medium", - "low": "Low", + "mid": "[%key:common::state::medium%]", + "low": "[%key:common::state::low%]", "warning": "Empty" } }, @@ -876,12 +876,12 @@ "name": "Speed", "state": { "slow": "[%key:component::lg_thinq::entity::climate::climate_air_conditioner::state_attributes::fan_mode::state::slow%]", - "low": "Low", - "mid": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "mid": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "power": "Turbo", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "wind_1": "Step 1", "wind_2": "Step 2", "wind_3": "Step 3", @@ -905,7 +905,7 @@ "name": "Operating mode", "state": { "air_clean": "Purifying", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "baby_care": "[%key:component::lg_thinq::entity::sensor::personalization_mode::state::baby%]", "circulator": "Booster", "clean": "Single", @@ -928,6 +928,17 @@ "vacation": "Vacation" } }, + "current_job_mode_dehumidifier": { + "name": "[%key:component::lg_thinq::entity::sensor::current_job_mode::name%]", + "state": { + "air_clean": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::air_clean%]", + "clothes_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::clothes_dry%]", + "intensive_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::intensive_dry%]", + "quiet_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::quiet_humidity%]", + "rapid_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::rapid_humidity%]", + "smart_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::smart_humidity%]" + } + }, "operation_mode": { "name": "Operation", "state": { @@ -1005,7 +1016,7 @@ "name": "[%key:component::lg_thinq::entity::binary_sensor::one_touch_filter::name%]", "state": { "off": "[%key:common::state::off%]", - "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", + "auto": "[%key:common::state::auto%]", "power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "replace": "[%key:component::lg_thinq::entity::sensor::fresh_air_filter::state::replace%]", "smart_power": "[%key:component::lg_thinq::entity::sensor::fresh_air_filter::state::smart_power%]", @@ -1023,5 +1034,10 @@ } } } + }, + "exceptions": { + "failed_to_connect_mqtt": { + "message": "Failed to connect MQTT: {error}" + } } } diff --git a/homeassistant/components/lg_thinq/water_heater.py b/homeassistant/components/lg_thinq/water_heater.py new file mode 100644 index 00000000000..5a5c8d024b6 --- /dev/null +++ b/homeassistant/components/lg_thinq/water_heater.py @@ -0,0 +1,201 @@ +"""Support for waterheater entities.""" + +from __future__ import annotations + +import logging +from typing import Any + +from thinqconnect import DeviceType +from thinqconnect.integration import ExtendedProperty + +from homeassistant.components.water_heater import ( + ATTR_OPERATION_MODE, + STATE_ECO, + STATE_HEAT_PUMP, + STATE_OFF, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityDescription, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ThinqConfigEntry +from .coordinator import DeviceDataUpdateCoordinator +from .entity import ThinQEntity + +DEVICE_TYPE_WH_MAP: dict[DeviceType, WaterHeaterEntityDescription] = { + DeviceType.WATER_HEATER: WaterHeaterEntityDescription( + key=ExtendedProperty.WATER_HEATER, + name=None, + ), + DeviceType.SYSTEM_BOILER: WaterHeaterEntityDescription( + key=ExtendedProperty.WATER_BOILER, + name=None, + ), +} + +# Mapping between device and HA operation modes +DEVICE_OP_MODE_TO_HA = { + "auto": STATE_ECO, + "heat_pump": STATE_HEAT_PUMP, + "turbo": STATE_PERFORMANCE, + "vacation": STATE_OFF, +} +HA_STATE_TO_DEVICE_OP_MODE = {v: k for k, v in DEVICE_OP_MODE_TO_HA.items()} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThinqConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up an entry for water_heater platform.""" + entities: list[ThinQWaterHeaterEntity] = [] + for coordinator in entry.runtime_data.coordinators.values(): + if ( + description := DEVICE_TYPE_WH_MAP.get(coordinator.api.device.device_type) + ) is not None: + if coordinator.api.device.device_type == DeviceType.WATER_HEATER: + entities.append( + ThinQWaterHeaterEntity( + coordinator, description, ExtendedProperty.WATER_HEATER + ) + ) + elif coordinator.api.device.device_type == DeviceType.SYSTEM_BOILER: + entities.append( + ThinQWaterBoilerEntity( + coordinator, description, ExtendedProperty.WATER_BOILER + ) + ) + if entities: + async_add_entities(entities) + + +class ThinQWaterHeaterEntity(ThinQEntity, WaterHeaterEntity): + """Represent a ThinQ water heater entity.""" + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: WaterHeaterEntityDescription, + property_id: str, + ) -> None: + """Initialize a water_heater entity.""" + super().__init__(coordinator, entity_description, property_id) + self._attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + if modes := self.data.job_modes: + self._attr_operation_list = [ + DEVICE_OP_MODE_TO_HA.get(mode, mode) for mode in modes + ] + else: + self._attr_operation_list = [STATE_HEAT_PUMP] + + def _update_status(self) -> None: + """Update status itself.""" + super()._update_status() + self._attr_current_temperature = self.data.current_temp + self._attr_target_temperature = self.data.target_temp + + if self.data.max is not None: + self._attr_max_temp = self.data.max + if self.data.min is not None: + self._attr_min_temp = self.data.min + if self.data.step is not None: + self._attr_target_temperature_step = self.data.step + + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + if self.data.is_on: + self._attr_current_operation = ( + DEVICE_OP_MODE_TO_HA.get(job_mode, job_mode) + if (job_mode := self.data.job_mode) is not None + else STATE_HEAT_PUMP + ) + else: + self._attr_current_operation = STATE_OFF + + _LOGGER.debug( + "[%s:%s] update status: c:%s, t:%s, op_mode:%s, op_list:%s, is_on:%s", + self.coordinator.device_name, + self.property_id, + self.current_temperature, + self.target_temperature, + self.current_operation, + self.operation_list, + self.data.is_on, + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + _LOGGER.debug( + "[%s:%s] async_set_temperature: %s", + self.coordinator.device_name, + self.property_id, + kwargs, + ) + if (operation_mode := kwargs.get(ATTR_OPERATION_MODE)) is not None: + await self.async_set_operation_mode(str(operation_mode)) + if operation_mode == STATE_OFF: + return + + if ( + temperature := kwargs.get(ATTR_TEMPERATURE) + ) is not None and temperature != self.target_temperature: + await self.async_call_api( + self.coordinator.api.async_set_target_temperature( + self.property_id, temperature + ) + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + mode = HA_STATE_TO_DEVICE_OP_MODE.get(operation_mode, operation_mode) + _LOGGER.debug( + "[%s:%s] async_set_operation_mode: %s", + self.coordinator.device_name, + self.property_id, + mode, + ) + await self.async_call_api( + self.coordinator.api.async_set_job_mode(self.property_id, mode) + ) + + +class ThinQWaterBoilerEntity(ThinQWaterHeaterEntity): + """Represent a ThinQ water boiler entity.""" + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: WaterHeaterEntityDescription, + property_id: str, + ) -> None: + """Initialize a water_heater entity.""" + super().__init__(coordinator, entity_description, property_id) + self._attr_supported_features |= WaterHeaterEntityFeature.ON_OFF + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + _LOGGER.debug( + "[%s:%s] async_turn_on", self.coordinator.device_name, self.property_id + ) + await self.async_call_api(self.coordinator.api.async_turn_on(self.property_id)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + _LOGGER.debug( + "[%s:%s] async_turn_off", self.coordinator.device_name, self.property_id + ) + await self.async_call_api(self.coordinator.api.async_turn_off(self.property_id)) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 8d460c25322..18b9457ebf4 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -51,7 +51,7 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.1.2", + "aiolifx==1.1.4", "aiolifx-effects==0.3.2", "aiolifx-themes==0.6.4" ] diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index c407489d52d..be0485c6dff 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -201,7 +201,7 @@ }, "effect_morph": { "name": "Morph effect", - "description": "Starts the firmware-based Morph effect on LIFX Tiles on Candle.", + "description": "Starts the firmware-based Morph effect on LIFX Tiles or Candle.", "fields": { "speed": { "name": "Speed", @@ -223,23 +223,23 @@ }, "effect_sky": { "name": "Sky effect", - "description": "Starts the firmware-based Sky effect on LIFX Ceiling.", + "description": "Starts a firmware-based effect on LIFX Ceiling lights that animates a sky scene across the device.", "fields": { "speed": { "name": "Speed", - "description": "How long the Sunrise and Sunset sky types will take to complete. For the Cloud sky type, it is the speed of the clouds across the device." + "description": "How long the Sunrise and Sunset sky types will take to complete. For the Clouds sky type, it is the speed of the clouds across the device." }, "sky_type": { "name": "Sky type", "description": "The style of sky that will be animated by the effect." }, "cloud_saturation_min": { - "name": "Cloud saturation Minimum", - "description": "Minimum cloud saturation." + "name": "Cloud saturation minimum", + "description": "The minimum cloud saturation for the Clouds sky type." }, "cloud_saturation_max": { - "name": "Cloud Saturation maximum", - "description": "Maximum cloud saturation." + "name": "Cloud saturation maximum", + "description": "The maximum cloud saturation for the Clouds sky type." }, "palette": { "name": "Palette", diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 637ba45c7d9..d2869670ba4 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -442,7 +442,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: brightness += params.pop(ATTR_BRIGHTNESS_STEP) else: - brightness += round(params.pop(ATTR_BRIGHTNESS_STEP_PCT) / 100 * 255) + brightness_pct = round(brightness / 255 * 100) + brightness = round( + (brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 + ) params[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) @@ -465,7 +468,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ): params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value) color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) - brightness = params.get(ATTR_BRIGHTNESS, light.brightness) + brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness)) params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww( color_temp, brightness, diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index df98def090e..6218c733f4c 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -1,7 +1,15 @@ { "entity_component": { "_": { - "default": "mdi:lightbulb" + "default": "mdi:lightbulb", + "state_attributes": { + "effect": { + "default": "mdi:circle-medium", + "state": { + "off": "mdi:star-off" + } + } + } } }, "services": { diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index 83f2ee58b5e..250e1f5b2c1 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -28,13 +28,21 @@ async def async_setup_intents(hass: HomeAssistant) -> None: DOMAIN, SERVICE_TURN_ON, optional_slots={ - ("color", ATTR_RGB_COLOR): color_util.color_name_to_rgb, - ("temperature", ATTR_COLOR_TEMP_KELVIN): cv.positive_int, - ("brightness", ATTR_BRIGHTNESS_PCT): vol.All( - vol.Coerce(int), vol.Range(0, 100) + "color": intent.IntentSlotInfo( + service_data_name=ATTR_RGB_COLOR, + value_schema=color_util.color_name_to_rgb, + ), + "temperature": intent.IntentSlotInfo( + service_data_name=ATTR_COLOR_TEMP_KELVIN, + value_schema=cv.positive_int, + ), + "brightness": intent.IntentSlotInfo( + service_data_name=ATTR_BRIGHTNESS_PCT, + description="The brightness percentage of the light between 0 and 100, where 0 is off and 100 is fully lit", + value_schema=vol.All(vol.Coerce(int), vol.Range(0, 100)), ), }, - description="Sets the brightness or color of a light", + description="Sets the brightness percentage or color of a light", platforms={DOMAIN}, ), ) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 2a1fbd11afd..2cd5921d794 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -199,7 +199,7 @@ turn_on: example: "[255, 100, 100]" selector: color_rgb: - kelvin: &kelvin + color_temp_kelvin: &color_temp_kelvin filter: *color_temp_support selector: color_temp: @@ -293,11 +293,10 @@ turn_on: - light.LightEntityFeature.FLASH selector: select: + translation_key: flash options: - - label: "Long" - value: "long" - - label: "Short" - value: "short" + - long + - short turn_off: target: @@ -317,7 +316,7 @@ toggle: fields: transition: *transition rgb_color: *rgb_color - kelvin: *kelvin + color_temp_kelvin: *color_temp_kelvin brightness_pct: *brightness_pct effect: *effect advanced_fields: diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index c0f658c3a44..7a53f2569e7 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -19,8 +19,8 @@ "field_flash_name": "Flash", "field_hs_color_description": "Color in hue/sat format. A list of two integers. Hue is 0-360 and Sat is 0-100.", "field_hs_color_name": "Hue/Sat color", - "field_kelvin_description": "Color temperature in Kelvin.", - "field_kelvin_name": "Color temperature", + "field_color_temp_kelvin_description": "Color temperature in Kelvin.", + "field_color_temp_kelvin_name": "Color temperature", "field_profile_description": "Name of a light profile to use.", "field_profile_name": "Profile", "field_rgb_color_description": "The color in RGB format. A list of three integers between 0 and 255 representing the values of red, green, and blue.", @@ -93,7 +93,10 @@ "name": "Color temperature (Kelvin)" }, "effect": { - "name": "Effect" + "name": "Effect", + "state": { + "off": "[%key:common::state::off%]" + } }, "effect_list": { "name": "Available effects" @@ -280,6 +283,12 @@ "yellow": "Yellow", "yellowgreen": "Yellow green" } + }, + "flash": { + "options": { + "short": "Short", + "long": "Long" + } } }, "services": { @@ -319,9 +328,9 @@ "name": "[%key:component::light::common::field_color_temp_name%]", "description": "[%key:component::light::common::field_color_temp_description%]" }, - "kelvin": { - "name": "[%key:component::light::common::field_kelvin_name%]", - "description": "[%key:component::light::common::field_kelvin_description%]" + "color_temp_kelvin": { + "name": "[%key:component::light::common::field_color_temp_kelvin_name%]", + "description": "[%key:component::light::common::field_color_temp_kelvin_description%]" }, "brightness": { "name": "[%key:component::light::common::field_brightness_name%]", @@ -417,9 +426,9 @@ "name": "[%key:component::light::common::field_color_temp_name%]", "description": "[%key:component::light::common::field_color_temp_description%]" }, - "kelvin": { - "name": "[%key:component::light::common::field_kelvin_name%]", - "description": "[%key:component::light::common::field_kelvin_description%]" + "color_temp_kelvin": { + "name": "[%key:component::light::common::field_color_temp_kelvin_name%]", + "description": "[%key:component::light::common::field_color_temp_kelvin_description%]" }, "brightness": { "name": "[%key:component::light::common::field_brightness_name%]", diff --git a/homeassistant/components/linkedgo/__init__.py b/homeassistant/components/linkedgo/__init__.py new file mode 100644 index 00000000000..e26fefa6b96 --- /dev/null +++ b/homeassistant/components/linkedgo/__init__.py @@ -0,0 +1 @@ +"""LinkedGo virtual integration.""" diff --git a/homeassistant/components/linkedgo/manifest.json b/homeassistant/components/linkedgo/manifest.json new file mode 100644 index 00000000000..03c650cac08 --- /dev/null +++ b/homeassistant/components/linkedgo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "linkedgo", + "name": "LinkedGo", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/components/linkplay/__init__.py b/homeassistant/components/linkplay/__init__.py index 918e52a755d..2da73666cc4 100644 --- a/homeassistant/components/linkplay/__init__.py +++ b/homeassistant/components/linkplay/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONTROLLER, CONTROLLER_KEY, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS, SHARED_DATA, LinkPlaySharedData from .utils import async_get_client_session @@ -44,11 +44,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: LinkPlayConfigEntry) -> # setup the controller and discover multirooms controller: LinkPlayController | None = None hass.data.setdefault(DOMAIN, {}) - if CONTROLLER not in hass.data[DOMAIN]: + if SHARED_DATA not in hass.data[DOMAIN]: controller = LinkPlayController(session) - hass.data[DOMAIN][CONTROLLER_KEY] = controller + hass.data[DOMAIN][SHARED_DATA] = LinkPlaySharedData(controller, {}) else: - controller = hass.data[DOMAIN][CONTROLLER_KEY] + controller = hass.data[DOMAIN][SHARED_DATA].controller await controller.add_bridge(bridge) await controller.discover_multirooms() @@ -62,4 +62,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LinkPlayConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: LinkPlayConfigEntry) -> bool: """Unload a config entry.""" + # remove the bridge from the controller and discover multirooms + bridge: LinkPlayBridge | None = entry.runtime_data.bridge + controller: LinkPlayController = hass.data[DOMAIN][SHARED_DATA].controller + await controller.remove_bridge(bridge) + await controller.discover_multirooms() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/linkplay/const.py b/homeassistant/components/linkplay/const.py index e10450cf255..74b87f4aae9 100644 --- a/homeassistant/components/linkplay/const.py +++ b/homeassistant/components/linkplay/const.py @@ -1,12 +1,23 @@ """LinkPlay constants.""" +from dataclasses import dataclass + from linkplay.controller import LinkPlayController from homeassistant.const import Platform from homeassistant.util.hass_dict import HassKey + +@dataclass +class LinkPlaySharedData: + """Shared data for LinkPlay.""" + + controller: LinkPlayController + entity_to_bridge: dict[str, str] + + DOMAIN = "linkplay" -CONTROLLER = "controller" -CONTROLLER_KEY: HassKey[LinkPlayController] = HassKey(CONTROLLER) +SHARED_DATA = "shared_data" +SHARED_DATA_KEY: HassKey[LinkPlaySharedData] = HassKey(SHARED_DATA) PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] DATA_SESSION = "session" diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py index 74e067f5eb3..0bfb34af42c 100644 --- a/homeassistant/components/linkplay/entity.py +++ b/homeassistant/components/linkplay/entity.py @@ -4,13 +4,13 @@ from collections.abc import Callable, Coroutine from typing import Any, Concatenate from linkplay.bridge import LinkPlayBridge +from linkplay.manufacturers import MANUFACTURER_GENERIC, get_info_from_project from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity from . import DOMAIN, LinkPlayRequestException -from .utils import MANUFACTURER_GENERIC, get_info_from_project def exception_wrap[_LinkPlayEntityT: LinkPlayBaseEntity, **_P, _R]( diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index ec9a8759a30..ac89d2ff399 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.1.3"], + "requirements": ["python-linkplay==0.2.5"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 2986db76520..89cc498ed01 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import timedelta import logging from typing import Any @@ -22,19 +23,14 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) -from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - entity_registry as er, -) +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow -from . import LinkPlayConfigEntry, LinkPlayData -from .const import CONTROLLER_KEY, DOMAIN +from . import SHARED_DATA, LinkPlayConfigEntry +from .const import DOMAIN from .entity import LinkPlayBaseEntity, exception_wrap _LOGGER = logging.getLogger(__name__) @@ -86,16 +82,10 @@ REPEAT_MAP: dict[LoopMode, RepeatMode] = { REPEAT_MAP_INV: dict[RepeatMode, LoopMode] = {v: k for k, v in REPEAT_MAP.items()} -EQUALIZER_MAP: dict[EqualizerMode, str] = { - EqualizerMode.NONE: "None", - EqualizerMode.CLASSIC: "Classic", - EqualizerMode.POP: "Pop", - EqualizerMode.JAZZ: "Jazz", - EqualizerMode.VOCAL: "Vocal", +EQUALIZER_MAP_INV: dict[str, EqualizerMode] = { + mode.value: mode for mode in EqualizerMode } -EQUALIZER_MAP_INV: dict[str, EqualizerMode] = {v: k for k, v in EQUALIZER_MAP.items()} - DEFAULT_FEATURES: MediaPlayerEntityFeature = ( MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA @@ -125,6 +115,10 @@ SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema( } ) +RETRY_POLL_MAXIMUM = 3 +SCAN_INTERVAL = timedelta(seconds=5) +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, @@ -146,7 +140,6 @@ async def async_setup_entry( class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): """Representation of a LinkPlay media player.""" - _attr_sound_mode_list = list(EQUALIZER_MAP.values()) _attr_device_class = MediaPlayerDeviceClass.RECEIVER _attr_media_content_type = MediaType.MUSIC _attr_name = None @@ -156,19 +149,33 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): super().__init__(bridge) self._attr_unique_id = bridge.device.uuid + self._retry_count = 0 self._attr_source_list = [ SOURCE_MAP[playing_mode] for playing_mode in bridge.device.playmode_support ] + self._attr_sound_mode_list = [ + mode.value for mode in bridge.player.available_equalizer_modes + ] + + async def async_added_to_hass(self) -> None: + """Handle common setup when added to hass.""" + await super().async_added_to_hass() + self.hass.data[DOMAIN][SHARED_DATA].entity_to_bridge[self.entity_id] = ( + self._bridge.device.uuid + ) @exception_wrap async def async_update(self) -> None: """Update the state of the media player.""" try: await self._bridge.player.update_status() + self._retry_count = 0 self._update_properties() except LinkPlayRequestException: - self._attr_available = False + self._retry_count += 1 + if self._retry_count >= RETRY_POLL_MAXIMUM: + self._attr_available = False @exception_wrap async def async_select_source(self, source: str) -> None: @@ -271,62 +278,63 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" - controller: LinkPlayController = self.hass.data[DOMAIN][CONTROLLER_KEY] + controller: LinkPlayController = self.hass.data[DOMAIN][SHARED_DATA].controller multiroom = self._bridge.multiroom if multiroom is None: multiroom = LinkPlayMultiroom(self._bridge) for group_member in group_members: - bridge = self._get_linkplay_bridge(group_member) + bridge = await self._get_linkplay_bridge(group_member) if bridge: await multiroom.add_follower(bridge) await controller.discover_multirooms() - def _get_linkplay_bridge(self, entity_id: str) -> LinkPlayBridge: + async def _get_linkplay_bridge(self, entity_id: str) -> LinkPlayBridge: """Get linkplay bridge from entity_id.""" - entity_registry = er.async_get(self.hass) + shared_data = self.hass.data[DOMAIN][SHARED_DATA] + controller = shared_data.controller + bridge_uuid = shared_data.entity_to_bridge.get(entity_id, None) + bridge = await controller.find_bridge(bridge_uuid) - # Check for valid linkplay media_player entity - entity_entry = entity_registry.async_get(entity_id) - - if ( - entity_entry is None - or entity_entry.domain != Platform.MEDIA_PLAYER - or entity_entry.platform != DOMAIN - or entity_entry.config_entry_id is None - ): + if bridge is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_grouping_entity", translation_placeholders={"entity_id": entity_id}, ) - config_entry = self.hass.config_entries.async_get_entry( - entity_entry.config_entry_id - ) - assert config_entry - - # Return bridge - data: LinkPlayData = config_entry.runtime_data - return data.bridge + return bridge @property def group_members(self) -> list[str]: """List of players which are grouped together.""" multiroom = self._bridge.multiroom - if multiroom is not None: - return [multiroom.leader.device.uuid] + [ - follower.device.uuid for follower in multiroom.followers - ] + if multiroom is None: + return [] - return [] + shared_data = self.hass.data[DOMAIN][SHARED_DATA] + + return [ + entity_id + for entity_id, bridge in shared_data.entity_to_bridge.items() + if bridge + in [multiroom.leader.device.uuid] + + [follower.device.uuid for follower in multiroom.followers] + ] + + @property + def media_image_url(self) -> str | None: + """Image url of playing media.""" + if self._bridge.player.status in [PlayingStatus.PLAYING, PlayingStatus.PAUSED]: + return str(self._bridge.player.album_art) + return None @exception_wrap async def async_unjoin_player(self) -> None: """Remove this player from any group.""" - controller: LinkPlayController = self.hass.data[DOMAIN][CONTROLLER_KEY] + controller: LinkPlayController = self.hass.data[DOMAIN][SHARED_DATA].controller multiroom = self._bridge.multiroom if multiroom is not None: @@ -342,7 +350,7 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): self._attr_is_volume_muted = self._bridge.player.muted self._attr_repeat = REPEAT_MAP[self._bridge.player.loop_mode] self._attr_shuffle = self._bridge.player.loop_mode == LoopMode.RANDOM_PLAYBACK - self._attr_sound_mode = EQUALIZER_MAP[self._bridge.player.equalizer_mode] + self._attr_sound_mode = self._bridge.player.equalizer_mode.value self._attr_supported_features = DEFAULT_FEATURES if self._bridge.player.status == PlayingStatus.PLAYING: diff --git a/homeassistant/components/linkplay/strings.json b/homeassistant/components/linkplay/strings.json index 31b4649e131..5d68754879c 100644 --- a/homeassistant/components/linkplay/strings.json +++ b/homeassistant/components/linkplay/strings.json @@ -11,7 +11,7 @@ } }, "discovery_confirm": { - "description": "Do you want to setup {name}?" + "description": "Do you want to set up {name}?" } }, "abort": { @@ -26,11 +26,11 @@ "services": { "play_preset": { "name": "Play preset", - "description": "Play the preset number on the device.", + "description": "Plays a preset on the device.", "fields": { "preset_number": { "name": "Preset number", - "description": "The preset number on the device to play." + "description": "The number of the preset to play." } } } @@ -44,7 +44,7 @@ }, "exceptions": { "invalid_grouping_entity": { - "message": "Entity with id {entity_id} can't be added to the LinkPlay multiroom. Is the entity a LinkPlay mediaplayer?" + "message": "Entity with ID {entity_id} can't be added to the LinkPlay multiroom. Is the entity a LinkPlay media player?" } } } diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index 7151ed1537a..63d04a3afc4 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -1,7 +1,5 @@ """Utilities for the LinkPlay component.""" -from typing import Final - from aiohttp import ClientSession from linkplay.utils import async_create_unverified_client_session @@ -10,75 +8,6 @@ from homeassistant.core import Event, HomeAssistant, callback from .const import DATA_SESSION, DOMAIN -MANUFACTURER_ARTSOUND: Final[str] = "ArtSound" -MANUFACTURER_ARYLIC: Final[str] = "Arylic" -MANUFACTURER_IEAST: Final[str] = "iEAST" -MANUFACTURER_WIIM: Final[str] = "WiiM" -MANUFACTURER_GGMM: Final[str] = "GGMM" -MANUFACTURER_MEDION: Final[str] = "Medion" -MANUFACTURER_GENERIC: Final[str] = "Generic" -MODELS_ARTSOUND_SMART_ZONE4: Final[str] = "Smart Zone 4 AMP" -MODELS_ARTSOUND_SMART_HYDE: Final[str] = "Smart Hyde" -MODELS_ARYLIC_S50: Final[str] = "S50+" -MODELS_ARYLIC_S50_PRO: Final[str] = "S50 Pro" -MODELS_ARYLIC_A30: Final[str] = "A30" -MODELS_ARYLIC_A50: Final[str] = "A50" -MODELS_ARYLIC_A50S: Final[str] = "A50+" -MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0" -MODELS_ARYLIC_UP2STREAM_AMP_2P1: Final[str] = "Up2Stream Amp 2.1" -MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3" -MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4" -MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1" -MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3" -MODELS_ARYLIC_S10P: Final[str] = "Arylic S10+" -MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp" -MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5" -MODELS_WIIM_AMP: Final[str] = "WiiM Amp" -MODELS_WIIM_MINI: Final[str] = "WiiM Mini" -MODELS_GGMM_GGMM_E2: Final[str] = "GGMM E2" -MODELS_MEDION_MD_43970: Final[str] = "Life P66970 (MD 43970)" -MODELS_GENERIC: Final[str] = "Generic" - -PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = { - "SMART_ZONE4_AMP": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4), - "SMART_HYDE": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE), - "ARYLIC_S50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50), - "RP0016_S50PRO_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO), - "RP0011_WB60_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30), - "X-50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50), - "ARYLIC_A50S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S), - "RP0011_WB60": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP), - "UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3), - "UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4), - "UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3), - "S10P_WIFI": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S10P), - "ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP), - "UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_2P1), - "RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_S50A": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0010_D5_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0001": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0013_WA31S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0010_D5": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0013_WA31S_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0014_A50D_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_A50TE": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_A50N": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "iEAST-02": (MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5), - "WiiM_Amp_4layer": (MANUFACTURER_WIIM, MODELS_WIIM_AMP), - "Muzo_Mini": (MANUFACTURER_WIIM, MODELS_WIIM_MINI), - "GGMM_E2A": (MANUFACTURER_GGMM, MODELS_GGMM_GGMM_E2), - "A16": (MANUFACTURER_MEDION, MODELS_MEDION_MD_43970), -} - - -def get_info_from_project(project: str) -> tuple[str, str]: - """Get manufacturer and model info based on given project.""" - return PROJECTID_LOOKUP.get(project, (MANUFACTURER_GENERIC, MODELS_GENERIC)) - async def async_get_client_session(hass: HomeAssistant) -> ClientSession: """Get a ClientSession that can be used with LinkPlay devices.""" diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index ca9af22f1e9..d4df011d0aa 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic -from pylitterbot import LitterRobot, Robot +from pylitterbot import LitterRobot, LitterRobot4, Robot from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -47,6 +47,15 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, . is_on_fn=lambda robot: robot.sleep_mode_enabled, ), ), + LitterRobot4: ( + RobotBinarySensorEntityDescription[LitterRobot4]( + key="hopper_connected", + translation_key="hopper_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_registry_enabled_default=False, + is_on_fn=lambda robot: not robot.is_hopper_removed, + ), + ), Robot: ( # type: ignore[type-abstract] # only used for isinstance check RobotBinarySensorEntityDescription[Robot]( key="power_status", diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py index 9e9cc8f0740..4117069aa0e 100644 --- a/homeassistant/components/litterrobot/entity.py +++ b/homeassistant/components/litterrobot/entity.py @@ -17,7 +17,7 @@ from .coordinator import LitterRobotDataUpdateCoordinator _WhiskerEntityT = TypeVar("_WhiskerEntityT", bound=Robot | Pet) -def get_device_info(whisker_entity: _WhiskerEntityT) -> DeviceInfo: +def get_device_info(whisker_entity: Robot | Pet) -> DeviceInfo: """Get device info for a robot or pet.""" if isinstance(whisker_entity, Robot): return DeviceInfo( diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index ba3df2114b7..163ad80c0a8 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -6,6 +6,9 @@ }, "sleep_mode": { "default": "mdi:sleep" + }, + "hopper_connected": { + "default": "mdi:filter-check" } }, "button": { @@ -32,6 +35,19 @@ "default": "mdi:scale" } }, + "sensor": { + "hopper_status": { + "default": "mdi:filter", + "state": { + "disabled": "mdi:filter-remove", + "empty": "mdi:filter-minus-outline", + "enabled": "mdi:filter-check", + "motor_disconnected": "mdi:engine-off", + "motor_fault_short": "mdi:flash-off", + "motor_ot_amps": "mdi:flash-alert" + } + } + }, "switch": { "night_light_mode": { "default": "mdi:lightbulb-off", diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index a638f24cf2a..cdd9a1c08a5 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -57,9 +57,9 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { translation_key="sleep_mode_start_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=( - lambda robot: robot.sleep_mode_start_time - if robot.sleep_mode_enabled - else None + lambda robot: ( + robot.sleep_mode_start_time if robot.sleep_mode_enabled else None + ) ), ), RobotSensorEntityDescription[LitterRobot]( @@ -67,9 +67,9 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { translation_key="sleep_mode_end_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=( - lambda robot: robot.sleep_mode_end_time - if robot.sleep_mode_enabled - else None + lambda robot: ( + robot.sleep_mode_end_time if robot.sleep_mode_enabled else None + ) ), ), RobotSensorEntityDescription[LitterRobot]( @@ -117,6 +117,24 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { ), ], LitterRobot4: [ + RobotSensorEntityDescription[LitterRobot4]( + key="hopper_status", + translation_key="hopper_status", + device_class=SensorDeviceClass.ENUM, + options=[ + "enabled", + "disabled", + "motor_fault_short", + "motor_ot_amps", + "motor_disconnected", + "empty", + ], + value_fn=( + lambda robot: ( + status.name.lower() if (status := robot.hopper_status) else None + ) + ), + ), RobotSensorEntityDescription[LitterRobot4]( key="litter_level", translation_key="litter_level", diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 19b007de068..ba5472918d3 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -34,6 +34,9 @@ }, "entity": { "binary_sensor": { + "hopper_connected": { + "name": "Hopper connected" + }, "sleeping": { "name": "Sleeping" }, @@ -59,6 +62,17 @@ "food_level": { "name": "Food level" }, + "hopper_status": { + "name": "Hopper status", + "state": { + "enabled": "[%key:common::state::enabled%]", + "disabled": "[%key:common::state::disabled%]", + "motor_fault_short": "Motor shorted", + "motor_ot_amps": "Motor overtorqued", + "motor_disconnected": "Motor disconnected", + "empty": "Empty" + } + }, "last_seen": { "name": "Last seen" }, @@ -77,31 +91,31 @@ "status_code": { "name": "Status code", "state": { - "br": "Bonnet Removed", - "ccc": "Clean Cycle Complete", - "ccp": "Clean Cycle In Progress", - "cd": "Cat Detected", - "csf": "Cat Sensor Fault", - "csi": "Cat Sensor Interrupted", - "cst": "Cat Sensor Timing", - "df1": "Drawer Almost Full - 2 Cycles Left", - "df2": "Drawer Almost Full - 1 Cycle Left", - "dfs": "Drawer Full", - "dhf": "Dump + Home Position Fault", - "dpf": "Dump Position Fault", - "ec": "Empty Cycle", - "hpf": "Home Position Fault", + "br": "Bonnet removed", + "ccc": "Clean cycle complete", + "ccp": "Clean cycle in progress", + "cd": "Cat detected", + "csf": "Cat sensor fault", + "csi": "Cat sensor interrupted", + "cst": "Cat sensor timing", + "df1": "Drawer almost full - 2 cycles left", + "df2": "Drawer almost full - 1 cycle left", + "dfs": "Drawer full", + "dhf": "Dump + home position fault", + "dpf": "Dump position fault", + "ec": "Empty cycle", + "hpf": "Home position fault", "off": "[%key:common::state::off%]", "offline": "Offline", - "otf": "Over Torque Fault", + "otf": "Overtorque fault", "p": "[%key:common::state::paused%]", - "pd": "Pinch Detect", - "pwrd": "Powering Down", - "pwru": "Powering Up", + "pd": "Pinch detect", + "pwrd": "Powering down", + "pwru": "Powering up", "rdy": "Ready", - "scf": "Cat Sensor Fault At Startup", - "sdf": "Drawer Full At Startup", - "spf": "Pinch Detect At Startup" + "scf": "Cat sensor fault at startup", + "sdf": "Drawer full at startup", + "spf": "Pinch detect at startup" } }, "waste_drawer": { @@ -118,9 +132,9 @@ "brightness_level": { "name": "Panel brightness", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json index 1077cacf2c4..46ffad162f3 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": ["livisi==0.0.24"] + "requirements": ["livisi==0.0.25"] } diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index df6f994a46c..8534cc1bfbf 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -36,6 +36,11 @@ _LOGGER = logging.getLogger(__name__) PRODID = "-//homeassistant.io//local_calendar 1.0//EN" +# The calendar on disk is only changed when this entity is updated, so there +# is no need to poll for changes. The calendar enttiy base class will handle +# refreshing the entity state based on the start or end time of the event. +SCAN_INTERVAL = timedelta(days=1) + async def async_setup_entry( hass: HomeAssistant, @@ -89,20 +94,27 @@ class LocalCalendarEntity(CalendarEntity): self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - events = self._calendar.timeline_tz(start_date.tzinfo).overlapping( - start_date, - end_date, - ) - return [_get_calendar_event(event) for event in events] + + def events_in_range() -> list[CalendarEvent]: + events = self._calendar.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, + ) + return [_get_calendar_event(event) for event in events] + + return await self.hass.async_add_executor_job(events_in_range) async def async_update(self) -> None: """Update entity state with the next upcoming event.""" - now = dt_util.now() - events = self._calendar.timeline_tz(now.tzinfo).active_after(now) - if event := next(events, None): - self._event = _get_calendar_event(event) - else: - self._event = None + + def next_event() -> CalendarEvent | None: + now = dt_util.now() + events = self._calendar.timeline_tz(now.tzinfo).active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None + + self._event = await self.hass.async_add_executor_job(next_event) async def _async_store(self) -> None: """Persist the calendar to disk.""" diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index fef45f786f9..f5b3220fb8c 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -97,8 +97,7 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_ICS_FILE], self.data[CONF_STORAGE_KEY], ) - except HomeAssistantError as err: - _LOGGER.debug("Error saving uploaded file: %s", err) + except InvalidIcsFile: errors[CONF_ICS_FILE] = "invalid_ics_file" else: return self.async_create_entry( @@ -112,6 +111,10 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN): ) +class InvalidIcsFile(HomeAssistantError): + """Error to indicate that the uploaded file is not a valid ICS file.""" + + def save_uploaded_ics_file( hass: HomeAssistant, uploaded_file_id: str, storage_key: str ): @@ -122,6 +125,10 @@ def save_uploaded_ics_file( try: CalendarStream.from_ics(ics) except CalendarParseError as err: - raise HomeAssistantError("Failed to upload file: Invalid ICS file") from err + _LOGGER.error("Error reading the calendar information: %s", err.message) + _LOGGER.debug( + "Additional calendar error detail: %s", str(err.detailed_error) + ) + raise InvalidIcsFile("Failed to upload file: Invalid ICS file") from err dest_path = Path(hass.config.path(STORAGE_PATH.format(key=storage_key))) shutil.move(file, dest_path) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 21a4134a8b6..07de4a82244 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.3.0"] + "requirements": ["ical==9.2.4"] } diff --git a/homeassistant/components/local_calendar/strings.json b/homeassistant/components/local_calendar/strings.json index 2b61fc9ab3e..6d68b46b5b0 100644 --- a/homeassistant/components/local_calendar/strings.json +++ b/homeassistant/components/local_calendar/strings.json @@ -17,7 +17,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { - "invalid_ics_file": "Invalid .ics file" + "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details." } }, "selector": { diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index 8be0389678d..4544f69dbee 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -7,38 +7,19 @@ import mimetypes import voluptuous as vol -from homeassistant.components.camera import ( - PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA, - Camera, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.camera import Camera +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILE_PATH, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - issue_registry as ir, -) -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DEFAULT_NAME, DOMAIN, SERVICE_UPDATE_FILE_PATH +from .const import SERVICE_UPDATE_FILE_PATH from .util import check_file_path_access _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = CAMERA_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_FILE_PATH): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - async def async_setup_entry( hass: HomeAssistant, @@ -67,57 +48,6 @@ async def async_setup_entry( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Camera that works with local files.""" - file_path: str = config[CONF_FILE_PATH] - file_path_slug = slugify(file_path) - - if not await hass.async_add_executor_job(check_file_path_access, file_path): - ir.async_create_issue( - hass, - DOMAIN, - f"no_access_path_{file_path_slug}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - learn_more_url="https://www.home-assistant.io/integrations/local_file/", - severity=ir.IssueSeverity.WARNING, - translation_key="no_access_path", - translation_placeholders={ - "file_path": file_path_slug, - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - learn_more_url="https://www.home-assistant.io/integrations/local_file/", - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Local file", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - class LocalFile(Camera): """Representation of a local file camera.""" diff --git a/homeassistant/components/local_file/config_flow.py b/homeassistant/components/local_file/config_flow.py index 36a41c03543..c4b83f9407a 100644 --- a/homeassistant/components/local_file/config_flow.py +++ b/homeassistant/components/local_file/config_flow.py @@ -50,18 +50,12 @@ DATA_SCHEMA_SETUP = vol.Schema( CONFIG_FLOW = { "user": SchemaFlowFormStep( - schema=DATA_SCHEMA_SETUP, - validate_user_input=validate_options, - ), - "import": SchemaFlowFormStep( - schema=DATA_SCHEMA_SETUP, - validate_user_input=validate_options, - ), + schema=DATA_SCHEMA_SETUP, validate_user_input=validate_options + ) } OPTIONS_FLOW = { "init": SchemaFlowFormStep( - DATA_SCHEMA_OPTIONS, - validate_user_input=validate_options, + DATA_SCHEMA_OPTIONS, validate_user_input=validate_options ) } diff --git a/homeassistant/components/local_file/strings.json b/homeassistant/components/local_file/strings.json index 393cc5f2e46..ebf4c9d7fbf 100644 --- a/homeassistant/components/local_file/strings.json +++ b/homeassistant/components/local_file/strings.json @@ -53,11 +53,5 @@ "file_path_not_accessible": { "message": "Path {file_path} is not accessible" } - }, - "issues": { - "no_access_path": { - "title": "Incorrect file path", - "description": "While trying to import your configuration the provided file path {file_path} could not be read.\nPlease update your configuration to a correct file path and restart to fix this issue." - } } } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 68154f10885..367c75d5755 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.3.0"] + "requirements": ["ical==9.2.4"] } diff --git a/homeassistant/components/local_todo/strings.json b/homeassistant/components/local_todo/strings.json index 2403fae60a5..ebf7810494c 100644 --- a/homeassistant/components/local_todo/strings.json +++ b/homeassistant/components/local_todo/strings.json @@ -6,7 +6,8 @@ "description": "Please choose a name for your new To-do list", "data": { "todo_list_name": "To-do list name" - } + }, + "submit": "Create" } }, "abort": { diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index f7ae9039729..9663efdd76e 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as LT_DOMAIN, TRACKER_UPDATE +from . import DOMAIN, TRACKER_UPDATE async def async_setup_entry( @@ -19,14 +19,14 @@ async def async_setup_entry( @callback def _receive_data(device, location, location_name): """Receive set location.""" - if device in hass.data[LT_DOMAIN]["devices"]: + if device in hass.data[DOMAIN]["devices"]: return - hass.data[LT_DOMAIN]["devices"].add(device) + hass.data[DOMAIN]["devices"].add(device) async_add_entities([LocativeEntity(device, location, location_name)]) - hass.data[LT_DOMAIN]["unsub_device_tracker"][entry.entry_id] = ( + hass.data[DOMAIN]["unsub_device_tracker"][entry.entry_id] = ( async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) ) diff --git a/homeassistant/components/locative/strings.json b/homeassistant/components/locative/strings.json index 7cc53f18428..9d6c07ee442 100644 --- a/homeassistant/components/locative/strings.json +++ b/homeassistant/components/locative/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up the Locative Webhook", + "title": "Set up the Locative webhook", "description": "[%key:common::config_flow::description::confirm_setup%]" } }, diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index fd8636acf97..46788e5a310 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -9,7 +9,11 @@ "condition_type": { "is_locked": "{entity_name} is locked", "is_unlocked": "{entity_name} is unlocked", - "is_open": "{entity_name} is open" + "is_open": "{entity_name} is open", + "is_jammed": "{entity_name} is jammed", + "is_locking": "{entity_name} is locking", + "is_unlocking": "{entity_name} is unlocking", + "is_opening": "{entity_name} is opening" }, "trigger_type": { "locked": "{entity_name} locked", @@ -28,7 +32,7 @@ "locked": "[%key:common::state::locked%]", "locking": "Locking", "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "unlocked": "[%key:common::state::unlocked%]", "unlocking": "Unlocking" }, diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 40b904c1279..f27a470a23d 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast +from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast, final from propcache.api import cached_property from sqlalchemy.engine.row import Row @@ -114,6 +114,7 @@ DATA_POS: Final = 11 CONTEXT_POS: Final = 12 +@final # Final to allow direct checking of the type instead of using isinstance class EventAsRow(NamedTuple): """Convert an event to a row. diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index e3d0d8a29fa..4b767f66d69 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -47,7 +47,7 @@ class LogbookLiveStream: subscriptions: list[CALLBACK_TYPE] end_time_unsub: CALLBACK_TYPE | None = None task: asyncio.Task | None = None - wait_sync_task: asyncio.Task | None = None + wait_sync_future: asyncio.Future[None] | None = None @callback @@ -329,8 +329,8 @@ async def ws_event_stream( subscriptions.clear() if live_stream.task: live_stream.task.cancel() - if live_stream.wait_sync_task: - live_stream.wait_sync_task.cancel() + if live_stream.wait_sync_future: + live_stream.wait_sync_future.cancel() if live_stream.end_time_unsub: live_stream.end_time_unsub() live_stream.end_time_unsub = None @@ -399,10 +399,12 @@ async def ws_event_stream( ) ) - live_stream.wait_sync_task = create_eager_task( - get_instance(hass).async_block_till_done() - ) - await live_stream.wait_sync_task + if sync_future := get_instance(hass).async_get_commit_future(): + # Set the future so we can cancel it if the client + # unsubscribes before the commit is done so we don't + # query the database needlessly + live_stream.wait_sync_future = sync_future + await live_stream.wait_sync_future # # Fetch any events from the database that have diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index 15283b246b2..8593b3c478e 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -24,8 +24,10 @@ from .const import ( SERVICE_SET_LEVEL, ) from .helpers import ( + DATA_LOGGER, LoggerDomainConfig, LoggerSettings, + _clear_logger_overwrites, # noqa: F401 set_default_log_level, set_log_levels, ) @@ -54,7 +56,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: settings = LoggerSettings(hass, config) - domain_config = hass.data[DOMAIN] = LoggerDomainConfig({}, settings) + domain_config = hass.data[DATA_LOGGER] = LoggerDomainConfig({}, settings) logging.setLoggerClass(_get_logger_class(domain_config.overrides)) websocket_api.async_load_websocket_api(hass) diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 034266428a3..19afe18e3fe 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -9,13 +9,14 @@ from dataclasses import asdict, dataclass from enum import StrEnum from functools import lru_cache import logging -from typing import Any, cast +from typing import Any from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.util.hass_dict import HassKey from .const import ( DOMAIN, @@ -28,6 +29,8 @@ from .const import ( STORAGE_VERSION, ) +DATA_LOGGER: HassKey[LoggerDomainConfig] = HassKey(DOMAIN) + SAVE_DELAY = 15.0 # At startup, we want to save after a long delay to avoid # saving while the system is still starting up. If the system @@ -39,12 +42,6 @@ SAVE_DELAY = 15.0 SAVE_DELAY_LONG = 180.0 -@callback -def async_get_domain_config(hass: HomeAssistant) -> LoggerDomainConfig: - """Return the domain config.""" - return cast(LoggerDomainConfig, hass.data[DOMAIN]) - - @callback def set_default_log_level(hass: HomeAssistant, level: int) -> None: """Set the default log level for components.""" @@ -55,7 +52,7 @@ def set_default_log_level(hass: HomeAssistant, level: int) -> None: @callback def set_log_levels(hass: HomeAssistant, logpoints: Mapping[str, int]) -> None: """Set the specified log levels.""" - async_get_domain_config(hass).overrides.update(logpoints) + hass.data[DATA_LOGGER].overrides.update(logpoints) for key, value in logpoints.items(): _set_log_level(logging.getLogger(key), value) hass.bus.async_fire(EVENT_LOGGING_CHANGED) @@ -78,6 +75,12 @@ def _chattiest_log_level(level1: int, level2: int) -> int: return min(level1, level2) +@callback +def _clear_logger_overwrites(hass: HomeAssistant) -> None: + """Clear logger overwrites. Used for testing.""" + hass.data[DATA_LOGGER].overrides.clear() + + async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]: """Get loggers for an integration.""" loggers: set[str] = {f"homeassistant.components.{domain}"} @@ -203,7 +206,7 @@ class LoggerSettings: else: loggers = {domain} - combined_logs = {logger: LOGSEVERITY[settings.level] for logger in loggers} + combined_logs = dict.fromkeys(loggers, LOGSEVERITY[settings.level]) # Don't override the log levels with the ones from YAML # since we want whatever the user is asking for to be honored. diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 2430f187a6f..041fe417698 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -12,10 +12,10 @@ from homeassistant.setup import async_get_loaded_integrations from .const import LOGSEVERITY from .helpers import ( + DATA_LOGGER, LoggerSetting, LogPersistance, LogSettingsType, - async_get_domain_config, get_logger, ) @@ -68,7 +68,7 @@ async def handle_integration_log_level( msg["id"], websocket_api.ERR_NOT_FOUND, "Integration not found" ) return - await async_get_domain_config(hass).settings.async_update( + await hass.data[DATA_LOGGER].settings.async_update( hass, msg["integration"], LoggerSetting( @@ -93,7 +93,7 @@ async def handle_module_log_level( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle setting integration log level.""" - await async_get_domain_config(hass).settings.async_update( + await hass.data[DATA_LOGGER].settings.async_update( hass, msg["module"], LoggerSetting( diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 4d8472da9a2..c0262f42f6c 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol -from homeassistant.components import frontend, websocket_api +from homeassistant.components import frontend, onboarding, websocket_api from homeassistant.config import ( async_hass_config_yaml, async_process_component_and_handle_errors, @@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.frame import report_usage 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 @@ -282,6 +283,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: STORAGE_DASHBOARD_UPDATE_FIELDS, ).async_setup(hass) + def create_map_dashboard() -> None: + """Create a map dashboard.""" + hass.async_create_task(_create_map_dashboard(hass, dashboards_collection)) + + if not onboarding.async_is_onboarded(hass): + onboarding.async_add_listener(hass, create_map_dashboard) + return True @@ -323,3 +331,25 @@ def _register_panel( 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, dashboards_collection: dashboard.DashboardsCollection +) -> None: + """Create a map dashboard.""" + translations = await async_get_translations( + hass, hass.config.language, "dashboard", {onboarding.DOMAIN} + ) + title = translations["component.onboarding.dashboard.map.title"] + + await dashboards_collection.async_create_item( + { + CONF_ALLOW_SINGLE_WORD: True, + CONF_ICON: "mdi:map", + CONF_TITLE: title, + CONF_URL_PATH: "map", + } + ) + + map_store = hass.data[LOVELACE_DATA].dashboards["map"] + await map_store.async_save({"strategy": {"type": "map"}}) diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 82bdfad4774..8d3da47795a 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/lutron", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.2.16"], + "requirements": ["pylutron==0.2.18"], "single_config_entry": true } diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index d697d6244b5..b489fe9dba7 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import contextlib from itertools import chain import logging import ssl @@ -37,11 +36,12 @@ from .const import ( ATTR_SERIAL, ATTR_TYPE, BRIDGE_DEVICE_ID, - BRIDGE_TIMEOUT, CONF_CA_CERTS, CONF_CERTFILE, CONF_KEYFILE, CONF_SUBTYPE, + CONFIGURE_TIMEOUT, + CONNECT_TIMEOUT, DOMAIN, LUTRON_CASETA_BUTTON_EVENT, MANUFACTURER, @@ -161,28 +161,40 @@ async def async_setup_entry( keyfile = hass.config.path(entry.data[CONF_KEYFILE]) certfile = hass.config.path(entry.data[CONF_CERTFILE]) ca_certs = hass.config.path(entry.data[CONF_CA_CERTS]) - bridge = None + connected_future: asyncio.Future[None] = hass.loop.create_future() + + def _on_connect() -> None: + nonlocal connected_future + if not connected_future.done(): + connected_future.set_result(None) try: bridge = Smartbridge.create_tls( - hostname=host, keyfile=keyfile, certfile=certfile, ca_certs=ca_certs + hostname=host, + keyfile=keyfile, + certfile=certfile, + ca_certs=ca_certs, + on_connect_callback=_on_connect, ) except ssl.SSLError: _LOGGER.error("Invalid certificate used to connect to bridge at %s", host) return False - timed_out = True - with contextlib.suppress(TimeoutError): - async with asyncio.timeout(BRIDGE_TIMEOUT): - await bridge.connect() - timed_out = False + connect_task = hass.async_create_task(bridge.connect()) + for future, name, timeout in ( + (connected_future, "connect", CONNECT_TIMEOUT), + (connect_task, "configure", CONFIGURE_TIMEOUT), + ): + try: + async with asyncio.timeout(timeout): + await future + except TimeoutError as ex: + connect_task.cancel() + await bridge.close() + raise ConfigEntryNotReady(f"Timed out on {name} for {host}") from ex - if timed_out or not bridge.is_connected(): - await bridge.close() - if timed_out: - raise ConfigEntryNotReady(f"Timed out while trying to connect to {host}") - if not bridge.is_connected(): - raise ConfigEntryNotReady(f"Cannot connect to {host}") + if not bridge.is_connected(): + raise ConfigEntryNotReady(f"Connection failed to {host}") _LOGGER.debug("Connected to Lutron Caseta bridge via LEAP at %s", host) await _async_migrate_unique_ids(hass, entry) diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index cb0f0da5227..4a92eb5c3b7 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as CASETA_DOMAIN +from . import DOMAIN from .const import CONFIG_URL, MANUFACTURER, UNASSIGNED_AREA from .entity import LutronCasetaEntity from .models import LutronCasetaConfigEntry @@ -49,11 +49,11 @@ class LutronOccupancySensor(LutronCasetaEntity, BinarySensorEntity): name = f"{area} {device['device_name']}" self._attr_name = name self._attr_device_info = DeviceInfo( - identifiers={(CASETA_DOMAIN, self.unique_id)}, + identifiers={(DOMAIN, self.unique_id)}, manufacturer=MANUFACTURER, model="Lutron Occupancy", name=self.name, - via_device=(CASETA_DOMAIN, self._bridge_device["serial"]), + via_device=(DOMAIN, self._bridge_device["serial"]), configuration_url=CONFIG_URL, entry_type=DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 767c3d2f2b7..115da5cb101 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -20,10 +20,11 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( ABORT_REASON_CANNOT_CONNECT, BRIDGE_DEVICE_ID, - BRIDGE_TIMEOUT, CONF_CA_CERTS, CONF_CERTFILE, CONF_KEYFILE, + CONFIGURE_TIMEOUT, + CONNECT_TIMEOUT, DOMAIN, ERROR_CANNOT_CONNECT, STEP_IMPORT_FAILED, @@ -122,7 +123,8 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): assets = None try: assets = await async_pair(self.data[CONF_HOST]) - except (TimeoutError, OSError): + except (TimeoutError, OSError) as exc: + _LOGGER.debug("Pairing failed", exc_info=exc) errors["base"] = "cannot_connect" if not errors: @@ -232,7 +234,7 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): return None try: - async with asyncio.timeout(BRIDGE_TIMEOUT): + async with asyncio.timeout(CONNECT_TIMEOUT + CONFIGURE_TIMEOUT): await bridge.connect() except TimeoutError: _LOGGER.error( diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py index 809b9e8d007..26a83de6f4b 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -34,7 +34,8 @@ ACTION_RELEASE = "release" CONF_SUBTYPE = "subtype" -BRIDGE_TIMEOUT = 35 +CONNECT_TIMEOUT = 9 +CONFIGURE_TIMEOUT = 50 UNASSIGNED_AREA = "Unassigned" diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 3727dbf17ba..e05fddb996f 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -108,6 +108,7 @@ PYLUTRON_TYPE_TO_CLASSES = { "QsWirelessHorizontalSheerBlind": LutronCasetaShade, "Shade": LutronCasetaShade, "PalladiomWireFreeShade": LutronCasetaShade, + "SerenaEssentialsRollerShade": LutronCasetaShade, } diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index bbb6df41a89..96b00a1f392 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.23.0"], + "requirements": ["pylutron-caseta==0.24.0"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py index 671df82d8e0..4838064eaaf 100644 --- a/homeassistant/components/lutron_caseta/scene.py +++ b/homeassistant/components/lutron_caseta/scene.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as CASETA_DOMAIN +from .const import DOMAIN from .util import serial_to_unique_id @@ -39,7 +39,7 @@ class LutronCasetaScene(Scene): self._bridge: Smartbridge = data.bridge bridge_unique_id = serial_to_unique_id(data.bridge_device["serial"]) self._attr_device_info = DeviceInfo( - identifiers={(CASETA_DOMAIN, data.bridge_device["serial"])}, + identifiers={(DOMAIN, data.bridge_device["serial"])}, ) self._attr_name = scene["name"] self._attr_unique_id = f"scene_{bridge_unique_id}_{self._scene_id}" diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 83c65359643..bc48a791e70 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -53,12 +53,12 @@ }, "services": { "set_hold_time": { - "name": "Set Hold Time", - "description": "Sets the time to hold until.", + "name": "Set hold time", + "description": "Sets the time period to keep the temperature and override the schedule.", "fields": { "time_period": { - "name": "Time Period", - "description": "Time to hold until." + "name": "Time period", + "description": "Duration for which to override the schedule." } } } diff --git a/homeassistant/components/mailgun/notify.py b/homeassistant/components/mailgun/notify.py index 26ff13f2a6f..b839e184810 100644 --- a/homeassistant/components/mailgun/notify.py +++ b/homeassistant/components/mailgun/notify.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_RECIPIENT, CONF_ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_SANDBOX, DOMAIN as MAILGUN_DOMAIN +from . import CONF_SANDBOX, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> MailgunNotificationService | None: """Get the Mailgun notification service.""" - data = hass.data[MAILGUN_DOMAIN] + data = hass.data[DOMAIN] mailgun_service = MailgunNotificationService( data.get(CONF_DOMAIN), data.get(CONF_SANDBOX), diff --git a/homeassistant/components/mailgun/strings.json b/homeassistant/components/mailgun/strings.json index 0c44dc63aae..e962dedd273 100644 --- a/homeassistant/components/mailgun/strings.json +++ b/homeassistant/components/mailgun/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up the Mailgun Webhook", + "title": "Set up the Mailgun webhook", "description": "Are you sure you want to set up Mailgun?" } }, @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to set up [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "default": "To send events to Home Assistant, you will need to set up a [webhook with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } } } diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index ab8514c8321..17b8614a2e9 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from mastodon.Mastodon import Mastodon, MastodonError +from mastodon.Mastodon import Account, Instance, InstanceV2, Mastodon, MastodonError from homeassistant.const import ( CONF_ACCESS_TOKEN, @@ -107,7 +107,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: MastodonConfigEntry) - return True -def setup_mastodon(entry: MastodonConfigEntry) -> tuple[Mastodon, dict, dict]: +def setup_mastodon( + entry: MastodonConfigEntry, +) -> tuple[Mastodon, InstanceV2 | Instance, Account]: """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 1b93cbecd98..1ae1e6b229e 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -4,7 +4,12 @@ from __future__ import annotations from typing import Any -from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError +from mastodon.Mastodon import ( + Account, + Instance, + MastodonNetworkError, + MastodonUnauthorizedError, +) import voluptuous as vol from yarl import URL @@ -56,8 +61,8 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): client_secret: str, access_token: str, ) -> tuple[ - dict[str, str] | None, - dict[str, str] | None, + Instance | None, + Account | None, dict[str, str], ]: """Check connection to the Mastodon instance.""" diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index a4af49a27a6..2efda329467 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -12,14 +12,6 @@ DATA_HASS_CONFIG = "mastodon_hass_config" DEFAULT_URL: Final = "https://mastodon.social" DEFAULT_NAME: Final = "Mastodon" -INSTANCE_VERSION: Final = "version" -INSTANCE_URI: Final = "uri" -INSTANCE_DOMAIN: Final = "domain" -ACCOUNT_USERNAME: Final = "username" -ACCOUNT_FOLLOWERS_COUNT: Final = "followers_count" -ACCOUNT_FOLLOWING_COUNT: Final = "following_count" -ACCOUNT_STATUSES_COUNT: Final = "statuses_count" - ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_STATUS = "status" ATTR_VISIBILITY = "visibility" diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py index 5d2b193b4a8..99785eca80b 100644 --- a/homeassistant/components/mastodon/coordinator.py +++ b/homeassistant/components/mastodon/coordinator.py @@ -4,10 +4,9 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from typing import Any from mastodon import Mastodon -from mastodon.Mastodon import MastodonError +from mastodon.Mastodon import Account, Instance, InstanceV2, MastodonError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,15 +20,15 @@ class MastodonData: """Mastodon data type.""" client: Mastodon - instance: dict - account: dict + instance: InstanceV2 | Instance + account: Account coordinator: MastodonCoordinator type MastodonConfigEntry = ConfigEntry[MastodonData] -class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class MastodonCoordinator(DataUpdateCoordinator[Account]): """Class to manage fetching Mastodon data.""" config_entry: MastodonConfigEntry @@ -47,9 +46,9 @@ class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.client = client - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> Account: try: - account: dict = await self.hass.async_add_executor_job( + account: Account = await self.hass.async_add_executor_job( self.client.account_verify_credentials ) except MastodonError as ex: diff --git a/homeassistant/components/mastodon/diagnostics.py b/homeassistant/components/mastodon/diagnostics.py index dc7c1b785ab..31444413dfd 100644 --- a/homeassistant/components/mastodon/diagnostics.py +++ b/homeassistant/components/mastodon/diagnostics.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from mastodon.Mastodon import Account, Instance + from homeassistant.core import HomeAssistant from .coordinator import MastodonConfigEntry @@ -25,7 +27,7 @@ async def async_get_config_entry_diagnostics( } -def get_diagnostics(config_entry: MastodonConfigEntry) -> tuple[dict, dict]: +def get_diagnostics(config_entry: MastodonConfigEntry) -> tuple[Instance, Account]: """Get mastodon diagnostics.""" client = config_entry.runtime_data.client diff --git a/homeassistant/components/mastodon/entity.py b/homeassistant/components/mastodon/entity.py index 2ae8c0d852e..60224e75e41 100644 --- a/homeassistant/components/mastodon/entity.py +++ b/homeassistant/components/mastodon/entity.py @@ -4,7 +4,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN, INSTANCE_VERSION +from .const import DEFAULT_NAME, DOMAIN from .coordinator import MastodonConfigEntry, MastodonCoordinator from .utils import construct_mastodon_username @@ -40,7 +40,7 @@ class MastodonEntity(CoordinatorEntity[MastodonCoordinator]): manufacturer="Mastodon gGmbH", model=full_account_name, entry_type=DeviceEntryType.SERVICE, - sw_version=data.runtime_data.instance[INSTANCE_VERSION], + sw_version=data.runtime_data.instance.version, name=name, ) diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 20c506e7766..d7b21ad3a0c 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["mastodon"], - "requirements": ["Mastodon.py==1.8.1"] + "requirements": ["Mastodon.py==2.0.1"] } diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 8e7e9dc1947..149ef1f6a48 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any, cast from mastodon import Mastodon -from mastodon.Mastodon import MastodonAPIError +from mastodon.Mastodon import MastodonAPIError, MediaAttachment import voluptuous as vol from homeassistant.components.notify import ( @@ -52,7 +52,7 @@ async def async_get_service( if discovery_info is None: return None - client: Mastodon = discovery_info.get("client") + client = cast(Mastodon, discovery_info.get("client")) return MastodonNotificationService(hass, client) @@ -114,7 +114,7 @@ class MastodonNotificationService(BaseNotificationService): message, visibility=target, spoiler_text=content_warning, - media_ids=mediadata["id"], + media_ids=mediadata.id, sensitive=sensitive, ) except MastodonAPIError as err: @@ -134,12 +134,14 @@ class MastodonNotificationService(BaseNotificationService): translation_key="unable_to_send_message", ) from err - def _upload_media(self, media_path: Any = None) -> Any: + def _upload_media(self, media_path: Any = None) -> MediaAttachment: """Upload media.""" with open(media_path, "rb"): media_type = get_media_type(media_path) try: - mediadata = self.client.media_post(media_path, mime_type=media_type) + mediadata: MediaAttachment = self.client.media_post( + media_path, mime_type=media_type + ) except MastodonAPIError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index 43636ed6924..f07f7e0a8ad 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -93,7 +93,4 @@ rules: # Platinum async-dependency: todo inject-websession: todo - strict-typing: - status: todo - comment: | - Requirement 'Mastodon.py==1.8.1' appears untyped + strict-typing: done diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py index 74537e33cae..bfdc9c90333 100644 --- a/homeassistant/components/mastodon/sensor.py +++ b/homeassistant/components/mastodon/sensor.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any + +from mastodon.Mastodon import Account from homeassistant.components.sensor import ( SensorEntity, @@ -15,11 +16,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import ( - ACCOUNT_FOLLOWERS_COUNT, - ACCOUNT_FOLLOWING_COUNT, - ACCOUNT_STATUSES_COUNT, -) from .coordinator import MastodonConfigEntry from .entity import MastodonEntity @@ -31,7 +27,7 @@ PARALLEL_UPDATES = 0 class MastodonSensorEntityDescription(SensorEntityDescription): """Describes Mastodon sensor entity.""" - value_fn: Callable[[dict[str, Any]], StateType] + value_fn: Callable[[Account], StateType] ENTITY_DESCRIPTIONS = ( @@ -39,19 +35,19 @@ ENTITY_DESCRIPTIONS = ( key="followers", translation_key="followers", state_class=SensorStateClass.TOTAL, - value_fn=lambda data: data.get(ACCOUNT_FOLLOWERS_COUNT), + value_fn=lambda data: data.followers_count, ), MastodonSensorEntityDescription( key="following", translation_key="following", state_class=SensorStateClass.TOTAL, - value_fn=lambda data: data.get(ACCOUNT_FOLLOWING_COUNT), + value_fn=lambda data: data.following_count, ), MastodonSensorEntityDescription( key="posts", translation_key="posts", state_class=SensorStateClass.TOTAL, - value_fn=lambda data: data.get(ACCOUNT_STATUSES_COUNT), + value_fn=lambda data: data.statuses_count, ), ) diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 7ab351f8c29..68e95e726a1 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -5,7 +5,7 @@ from functools import partial from typing import Any, cast from mastodon import Mastodon -from mastodon.Mastodon import MastodonAPIError +from mastodon.Mastodon import MastodonAPIError, MediaAttachment import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -104,7 +104,7 @@ def setup_services(hass: HomeAssistant) -> None: def _post(client: Mastodon, **kwargs: Any) -> None: """Post to Mastodon.""" - media_data: dict[str, Any] | None = None + media_data: MediaAttachment | None = None media_path = kwargs.get("media_path") if media_path: @@ -137,7 +137,7 @@ def setup_services(hass: HomeAssistant) -> None: try: media_ids: str | None = None if media_data: - media_ids = media_data["id"] + media_ids = media_data.id client.status_post(media_ids=media_ids, **kwargs) except MastodonAPIError as err: raise HomeAssistantError( diff --git a/homeassistant/components/mastodon/utils.py b/homeassistant/components/mastodon/utils.py index e9c2567b675..898578c931b 100644 --- a/homeassistant/components/mastodon/utils.py +++ b/homeassistant/components/mastodon/utils.py @@ -6,8 +6,9 @@ import mimetypes from typing import Any from mastodon import Mastodon +from mastodon.Mastodon import Account, Instance, InstanceV2 -from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI +from .const import DEFAULT_NAME def create_mastodon_client( @@ -23,14 +24,13 @@ def create_mastodon_client( def construct_mastodon_username( - instance: dict[str, str] | None, account: dict[str, str] | None + instance: InstanceV2 | Instance | None, account: Account | None ) -> str: """Construct a mastodon username from the account and instance.""" if instance and account: - return ( - f"@{account[ACCOUNT_USERNAME]}@" - f"{instance.get(INSTANCE_URI, instance.get(INSTANCE_DOMAIN))}" - ) + if type(instance) is InstanceV2: + return f"@{account.username}@{instance.domain}" + return f"@{account.username}@{instance.uri}" return DEFAULT_NAME diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index b173a2c850b..6cab2c39c97 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.1.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==11.2.1"] } diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index b5665e5d47a..95375d5fc49 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -265,4 +265,73 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterBinarySensor, required_attributes=(clusters.SmokeCoAlarm.Attributes.InterconnectCOAlarm,), ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvseChargingStatusSensor", + translation_key="evse_charging_status", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + measurement_to_ha={ + clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: False, + clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False, + clusters.EnergyEvse.Enums.StateEnum.kFault: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.State,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvsePlugStateSensor", + translation_key="evse_plug_state", + device_class=BinarySensorDeviceClass.PLUG, + measurement_to_ha={ + clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: True, + clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False, + clusters.EnergyEvse.Enums.StateEnum.kFault: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.State,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvseSupplyStateSensor", + translation_key="evse_supply_charging_state", + device_class=BinarySensorDeviceClass.RUNNING, + measurement_to_ha={ + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.SupplyState,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="WaterHeaterManagementBoostStateSensor", + translation_key="boost_state", + measurement_to_ha=lambda x: ( + x == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive + ), + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.WaterHeaterManagement.Attributes.BoostState,), + ), ] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 7102b693e45..8042b7505f4 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -27,6 +27,7 @@ from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS from .update import DISCOVERY_SCHEMAS as UPDATE_SCHEMAS from .vacuum import DISCOVERY_SCHEMAS as VACUUM_SCHEMAS from .valve import DISCOVERY_SCHEMAS as VALVE_SCHEMAS +from .water_heater import DISCOVERY_SCHEMAS as WATER_HEATER_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, @@ -44,6 +45,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.UPDATE: UPDATE_SCHEMAS, Platform.VACUUM: VACUUM_SCHEMAS, Platform.VALVE: VALVE_SCHEMAS, + Platform.WATER_HEATER: WATER_HEATER_SCHEMAS, } SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 96696193466..fded57d34f5 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -61,6 +61,7 @@ class MatterEntityDescription(EntityDescription): # convert the value from the primary attribute to the value used by HA measurement_to_ha: Callable[[Any], Any] | None = None ha_to_native_value: Callable[[Any], Any] | None = None + command_timeout: int | None = None class MatterEntity(Entity): diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 6fa775fd1b9..fa7d96ed1ae 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -69,7 +69,7 @@ class MatterEventEntity(MatterEntity, EventEntity): max_presses_supported = self.get_matter_attribute_value( clusters.Switch.Attributes.MultiPressMax ) - max_presses_supported = min(max_presses_supported or 1, 8) + max_presses_supported = min(max_presses_supported or 2, 8) for i in range(max_presses_supported): event_types.append(f"multi_press_{i + 1}") # noqa: PERF401 elif feature_map & SwitchFeature.kMomentarySwitch: diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index f9217cabcc4..82e45e0383a 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -66,11 +66,26 @@ "operational_state": { "default": "mdi:play-pause" }, + "tank_volume": { + "default": "mdi:water-boiler" + }, + "tank_percentage": { + "default": "mdi:water-boiler" + }, "valve_position": { "default": "mdi:valve" }, "battery_replacement_description": { "default": "mdi:battery-sync-outline" + }, + "evse_state": { + "default": "mdi:ev-station" + }, + "evse_supply_state": { + "default": "mdi:ev-station" + }, + "evse_fault_state": { + "default": "mdi:ev-station" } }, "switch": { @@ -80,6 +95,9 @@ "on": "mdi:lock", "off": "mdi:lock-off" } + }, + "evse_charging_switch": { + "default": "mdi:ev-station" } } } diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 669fa1af8c4..48f0bfa2e67 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -1,6 +1,6 @@ { "domain": "matter", - "name": "Matter (BETA)", + "name": "Matter", "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/matter"], "config_flow": true, diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 44538f46856..2c7a9651c60 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -169,8 +169,8 @@ DISCOVERY_SCHEMAS = [ device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, translation_key="temperature_offset", - native_max_value=25, - native_min_value=-25, + native_max_value=50, + native_min_value=-50, native_step=0.5, native_unit_of_measurement=UnitOfTemperature.CELSIUS, measurement_to_ha=lambda x: None if x is None else x / 10, diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index e78c34391cd..6e77be93705 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -41,6 +41,7 @@ type SelectCluster = ( | clusters.DishwasherMode | clusters.EnergyEvseMode | clusters.DeviceEnergyManagementMode + | clusters.WaterHeaterMode ) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 10f8db275f5..e0d2050c833 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -37,6 +37,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfVolume, UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback @@ -65,7 +66,6 @@ CONTAMINATION_STATE_MAP = { clusters.SmokeCoAlarm.Enums.ContaminationStateEnum.kCritical: "critical", } - OPERATIONAL_STATE_MAP = { # enum with known Operation state values which we can translate clusters.OperationalState.Enums.OperationalStateEnum.kStopped: "stopped", @@ -77,6 +77,31 @@ OPERATIONAL_STATE_MAP = { clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", } +BOOST_STATE_MAP = { + clusters.WaterHeaterManagement.Enums.BoostStateEnum.kInactive: "inactive", + clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: "active", + clusters.WaterHeaterManagement.Enums.BoostStateEnum.kUnknownEnumValue: None, +} + +EVSE_FAULT_STATE_MAP = { + clusters.EnergyEvse.Enums.FaultStateEnum.kNoError: "no_error", + clusters.EnergyEvse.Enums.FaultStateEnum.kMeterFailure: "meter_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverVoltage: "over_voltage", + clusters.EnergyEvse.Enums.FaultStateEnum.kUnderVoltage: "under_voltage", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverCurrent: "over_current", + clusters.EnergyEvse.Enums.FaultStateEnum.kContactWetFailure: "contact_wet_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kContactDryFailure: "contact_dry_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kPowerLoss: "power_loss", + clusters.EnergyEvse.Enums.FaultStateEnum.kPowerQuality: "power_quality", + clusters.EnergyEvse.Enums.FaultStateEnum.kPilotShortCircuit: "pilot_short_circuit", + clusters.EnergyEvse.Enums.FaultStateEnum.kEmergencyStop: "emergency_stop", + clusters.EnergyEvse.Enums.FaultStateEnum.kEVDisconnected: "ev_disconnected", + clusters.EnergyEvse.Enums.FaultStateEnum.kWrongPowerSupply: "wrong_power_supply", + clusters.EnergyEvse.Enums.FaultStateEnum.kLiveNeutralSwap: "live_neutral_swap", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverTemperature: "over_temperature", + clusters.EnergyEvse.Enums.FaultStateEnum.kOther: "other", +} + async def async_setup_entry( hass: HomeAssistant, @@ -719,6 +744,25 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalEnergyMeasurementCumulativeEnergyExported", + translation_key="energy_exported", + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + 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, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyExported, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -904,4 +948,117 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported state list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseFaultState", + translation_key="evse_fault_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(EVSE_FAULT_STATE_MAP.values()), + measurement_to_ha=EVSE_FAULT_STATE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.FaultState,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseCircuitCapacity", + translation_key="evse_circuit_capacity", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.CircuitCapacity,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseMinimumChargeCurrent", + translation_key="evse_min_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.MinimumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseMaximumChargeCurrent", + translation_key="evse_max_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.MaximumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseUserMaximumChargeCurrent", + translation_key="evse_user_max_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.UserMaximumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="WaterHeaterManagementTankVolume", + translation_key="tank_volume", + device_class=SensorDeviceClass.VOLUME_STORAGE, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.WaterHeaterManagement.Attributes.TankVolume,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="WaterHeaterManagementTankPercentage", + translation_key="tank_percentage", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.WaterHeaterManagement.Attributes.TankPercentage,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="WaterHeaterManagementEstimatedHeatRequired", + translation_key="estimated_heat_required", + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=3, + state_class=SensorStateClass.TOTAL, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.WaterHeaterManagement.Attributes.EstimatedHeatRequired, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 1404d0a9076..129c6a3ab54 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -76,6 +76,18 @@ }, "muted": { "name": "Muted" + }, + "evse_charging_status": { + "name": "Charging status" + }, + "evse_plug": { + "name": "Plug state" + }, + "evse_supply_charging_state": { + "name": "Supply charging state" + }, + "boost_state": { + "name": "Boost state" } }, "button": { @@ -135,10 +147,10 @@ "state_attributes": { "preset_mode": { "state": { - "low": "Low", - "medium": "Medium", - "high": "High", - "auto": "Auto", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "auto": "[%key:common::state::auto%]", "natural_wind": "Natural wind", "sleep_wind": "Sleep wind" } @@ -160,7 +172,7 @@ "name": "On/Off transition time" }, "altitude": { - "name": "Altitude above Sea Level" + "name": "Altitude above sea level" }, "temperature_offset": { "name": "Temperature offset" @@ -189,9 +201,9 @@ "sensitivity_level": { "name": "Sensitivity", "state": { - "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "low": "[%key:common::state::low%]", "standard": "Standard", - "high": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::high%]" + "high": "[%key:common::state::high%]" } }, "startup_on_off": { @@ -213,13 +225,16 @@ "name": "Number of rinses", "state": { "off": "[%key:common::state::off%]", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "extra": "Extra", "max": "Max" } }, "laundry_washer_spin_speed": { "name": "Spin speed" + }, + "water_heater_mode": { + "name": "Water heater mode" } }, "sensor": { @@ -229,8 +244,8 @@ "contamination_state": { "name": "Contamination state", "state": { - "normal": "Normal", - "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "normal": "[%key:common::state::normal%]", + "low": "[%key:common::state::low%]", "warning": "Warning", "critical": "Critical" } @@ -258,10 +273,10 @@ "operational_state": { "name": "Operational state", "state": { - "stopped": "Stopped", + "stopped": "[%key:common::state::stopped%]", "running": "Running", "paused": "[%key:common::state::paused%]", - "error": "Error", + "error": "[%key:common::state::error%]", "seeking_charger": "Seeking charger", "charging": "[%key:common::state::charging%]", "docked": "Docked" @@ -270,6 +285,15 @@ "switch_current_position": { "name": "Current switch position" }, + "estimated_heat_required": { + "name": "Required heating energy" + }, + "tank_volume": { + "name": "Tank volume" + }, + "tank_percentage": { + "name": "Hot water level" + }, "valve_position": { "name": "Valve position" }, @@ -278,6 +302,45 @@ }, "current_phase": { "name": "Current phase" + }, + "energy_exported": { + "name": "Energy exported" + }, + "evse_fault_state": { + "name": "Fault state", + "state": { + "no_error": "OK", + "meter_failure": "Meter failure", + "over_voltage": "Overvoltage", + "under_voltage": "Undervoltage", + "over_current": "Overcurrent", + "contact_wet_failure": "Contact wet failure", + "contact_dry_failure": "Contact dry failure", + "power_loss": "Power loss", + "power_quality": "Power quality", + "pilot_short_circuit": "Pilot short circuit", + "emergency_stop": "Emergency stop", + "ev_disconnected": "EV disconnected", + "wrong_power_supply": "Wrong power supply", + "live_neutral_swap": "Live/neutral swap", + "over_temperature": "Overtemperature", + "other": "Other fault" + } + }, + "evse_circuit_capacity": { + "name": "Circuit capacity" + }, + "evse_charge_current": { + "name": "Charge current" + }, + "evse_min_charge_current": { + "name": "Min charge current" + }, + "evse_max_charge_current": { + "name": "Max charge current" + }, + "evse_user_max_charge_current": { + "name": "User max charge current" } }, "switch": { @@ -289,6 +352,9 @@ }, "child_lock": { "name": "Child lock" + }, + "evse_charging_switch": { + "name": "Enable charging" } }, "vacuum": { @@ -300,6 +366,11 @@ "valve": { "name": "[%key:component::valve::title%]" } + }, + "water_heater": { + "water_heater": { + "name": "[%key:component::water_heater::title%]" + } } }, "issues": { diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index af4803af9a1..870a9098492 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any from chip.clusters import Objects as clusters +from chip.clusters.Objects import ClusterCommand, NullValue from matter_server.client.models import device_types from homeassistant.components.switch import ( @@ -22,6 +24,13 @@ from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema +EVSE_SUPPLY_STATE_MAP = { + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False, +} + async def async_setup_entry( hass: HomeAssistant, @@ -58,6 +67,66 @@ class MatterSwitch(MatterEntity, SwitchEntity): ) +class MatterGenericCommandSwitch(MatterSwitch): + """Representation of a Matter switch.""" + + entity_description: MatterGenericCommandSwitchEntityDescription + + _platform_translation_key = "switch" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + if self.entity_description.on_command: + # custom command defined to set the new value + await self.send_device_command( + self.entity_description.on_command(), + self.entity_description.command_timeout, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + if self.entity_description.off_command: + await self.send_device_command( + self.entity_description.off_command(), + self.entity_description.command_timeout, + ) + + @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 + + async def send_device_command( + self, + command: ClusterCommand, + command_timeout: int | None = None, + **kwargs: Any, + ) -> None: + """Send device command with timeout.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + timed_request_timeout_ms=command_timeout, + **kwargs, + ) + + +@dataclass(frozen=True) +class MatterGenericCommandSwitchEntityDescription( + SwitchEntityDescription, MatterEntityDescription +): + """Describe Matter Generic command Switch entities.""" + + # command: a custom callback to create the command to send to the device + on_command: Callable[[], Any] | None = None + off_command: Callable[[], Any] | None = None + command_timeout: int | None = None + + @dataclass(frozen=True) class MatterNumericSwitchEntityDescription( SwitchEntityDescription, MatterEntityDescription @@ -194,4 +263,26 @@ DISCOVERY_SCHEMAS = [ ), vendor_id=(4874,), ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=MatterGenericCommandSwitchEntityDescription( + key="EnergyEvseChargingSwitch", + translation_key="evse_charging_switch", + on_command=lambda: clusters.EnergyEvse.Commands.EnableCharging( + chargingEnabledUntil=NullValue, + minimumChargeCurrent=0, + maximumChargeCurrent=0, + ), + off_command=clusters.EnergyEvse.Commands.Disable, + command_timeout=3000, + measurement_to_ha=EVSE_SUPPLY_STATE_MAP.get, + ), + entity_class=MatterGenericCommandSwitch, + required_attributes=( + clusters.EnergyEvse.Attributes.SupplyState, + clusters.EnergyEvse.Attributes.AcceptedCommandList, + ), + value_contains=clusters.EnergyEvse.Commands.EnableCharging.command_id, + allow_multi=True, + ), ] diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index 7c9ca991914..cea4fe0c810 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -251,7 +251,7 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.UPDATE, entity_description=UpdateEntityDescription( - key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE, name=None + key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE ), entity_class=MatterUpdate, required_attributes=( diff --git a/homeassistant/components/matter/water_heater.py b/homeassistant/components/matter/water_heater.py new file mode 100644 index 00000000000..07c011554fa --- /dev/null +++ b/homeassistant/components/matter/water_heater.py @@ -0,0 +1,189 @@ +"""Matter water heater platform.""" + +from __future__ import annotations + +from typing import Any, cast + +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.water_heater import ( + STATE_ECO, + STATE_HIGH_DEMAND, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityDescription, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_WHOLE, + Platform, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +TEMPERATURE_SCALING_FACTOR = 100 + +# Map HA WH system mode to Matter ThermostatRunningMode attribute of the Thermostat cluster (Heat = 4) +WATER_HEATER_SYSTEM_MODE_MAP = { + STATE_ECO: 4, + STATE_HIGH_DEMAND: 4, + STATE_OFF: 0, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Matter WaterHeater platform from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.WATER_HEATER, async_add_entities) + + +class MatterWaterHeater(MatterEntity, WaterHeaterEntity): + """Representation of a Matter WaterHeater entity.""" + + _attr_current_temperature: float | None = None + _attr_current_operation: str + _attr_operation_list = [ + STATE_ECO, + STATE_HIGH_DEMAND, + STATE_OFF, + ] + _attr_precision = PRECISION_WHOLE + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE + ) + _attr_target_temperature: float | None = None + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _platform_translation_key = "water_heater" + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + target_temperature: float | None = kwargs.get(ATTR_TEMPERATURE) + if ( + target_temperature is not None + and self.target_temperature != target_temperature + ): + matter_attribute = clusters.Thermostat.Attributes.OccupiedHeatingSetpoint + await self.write_attribute( + value=round(target_temperature * TEMPERATURE_SCALING_FACTOR), + matter_attribute=matter_attribute, + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + self._attr_current_operation = operation_mode + # Boost 1h (3600s) + boost_info: type[ + clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct + ] = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct( + duration=3600 + ) + system_mode_value = WATER_HEATER_SYSTEM_MODE_MAP[operation_mode] + await self.write_attribute( + value=system_mode_value, + matter_attribute=clusters.Thermostat.Attributes.SystemMode, + ) + system_mode_path = create_attribute_path_from_attribute( + endpoint_id=self._endpoint.endpoint_id, + attribute=clusters.Thermostat.Attributes.SystemMode, + ) + self._endpoint.set_attribute_value(system_mode_path, system_mode_value) + self._update_from_device() + # Trigger Boost command + if operation_mode == STATE_HIGH_DEMAND: + await self.send_device_command( + clusters.WaterHeaterManagement.Commands.Boost(boostInfo=boost_info) + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on water heater.""" + await self.async_set_operation_mode("eco") + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off water heater.""" + await self.async_set_operation_mode("off") + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + self._attr_current_temperature = self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.LocalTemperature + ) + self._attr_target_temperature = self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint + ) + boost_state = self.get_matter_attribute_value( + clusters.WaterHeaterManagement.Attributes.BoostState + ) + if boost_state == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: + self._attr_current_operation = STATE_HIGH_DEMAND + else: + self._attr_current_operation = STATE_ECO + self._attr_temperature = cast( + float, + self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint + ), + ) + self._attr_min_temp = cast( + float, + self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.AbsMinHeatSetpointLimit + ), + ) + self._attr_max_temp = cast( + float, + self._get_temperature_in_degrees( + clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit + ), + ) + + @callback + def _get_temperature_in_degrees( + self, attribute: type[clusters.ClusterAttributeDescriptor] + ) -> float | None: + """Return the scaled temperature value for the given attribute.""" + if (value := self.get_matter_attribute_value(attribute)) is not None: + return float(value) / TEMPERATURE_SCALING_FACTOR + return None + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.WATER_HEATER, + entity_description=WaterHeaterEntityDescription( + key="MatterWaterHeater", + name=None, + ), + entity_class=MatterWaterHeater, + required_attributes=( + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.Attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.Attributes.LocalTemperature, + clusters.WaterHeaterManagement.Attributes.FeatureMap, + ), + optional_attributes=( + clusters.WaterHeaterManagement.Attributes.HeaterTypes, + clusters.WaterHeaterManagement.Attributes.BoostState, + clusters.WaterHeaterManagement.Attributes.HeatDemand, + ), + device_type=(device_types.WaterHeater,), + allow_multi=True, # also used for sensor entity + ), +] diff --git a/homeassistant/components/maytag/__init__.py b/homeassistant/components/maytag/__init__.py new file mode 100644 index 00000000000..675fae98697 --- /dev/null +++ b/homeassistant/components/maytag/__init__.py @@ -0,0 +1 @@ +"""Maytag virtual integration.""" diff --git a/homeassistant/components/maytag/manifest.json b/homeassistant/components/maytag/manifest.json new file mode 100644 index 00000000000..3cbc8f0f61a --- /dev/null +++ b/homeassistant/components/maytag/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "maytag", + "name": "Maytag", + "integration_type": "virtual", + "supported_by": "whirlpool" +} diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py index 4a2b4da990d..a2a148dffd5 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -3,12 +3,15 @@ from __future__ import annotations from dataclasses import dataclass +from typing import cast +from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from homeassistant.helpers import llm +from homeassistant.helpers import config_entry_oauth2_flow, llm -from .const import DOMAIN -from .coordinator import ModelContextProtocolCoordinator +from .application_credentials import authorization_server_context +from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .coordinator import ModelContextProtocolCoordinator, TokenManager from .types import ModelContextProtocolConfigEntry __all__ = [ @@ -20,11 +23,45 @@ __all__ = [ API_PROMPT = "The following tools are available from a remote server named {name}." +async def async_get_config_entry_implementation( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> config_entry_oauth2_flow.AbstractOAuth2Implementation | None: + """OAuth implementation for the config entry.""" + if "auth_implementation" not in entry.data: + return None + with authorization_server_context( + AuthorizationServer( + authorize_url=entry.data[CONF_AUTHORIZATION_URL], + token_url=entry.data[CONF_TOKEN_URL], + ) + ): + return await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + + +async def _create_token_manager( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> TokenManager | None: + """Create a OAuth token manager for the config entry if the server requires authentication.""" + if not (implementation := await async_get_config_entry_implementation(hass, entry)): + return None + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + async def token_manager() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) + + return token_manager + + async def async_setup_entry( hass: HomeAssistant, entry: ModelContextProtocolConfigEntry ) -> bool: """Set up Model Context Protocol from a config entry.""" - coordinator = ModelContextProtocolCoordinator(hass, entry) + token_manager = await _create_token_manager(hass, entry) + coordinator = ModelContextProtocolCoordinator(hass, entry, token_manager) await coordinator.async_config_entry_first_refresh() unsub = llm.async_register_api( @@ -39,7 +76,6 @@ async def async_setup_entry( entry.async_on_unload(unsub) entry.runtime_data = coordinator - entry.async_on_unload(coordinator.close) return True diff --git a/homeassistant/components/mcp/application_credentials.py b/homeassistant/components/mcp/application_credentials.py new file mode 100644 index 00000000000..9b8bed894e4 --- /dev/null +++ b/homeassistant/components/mcp/application_credentials.py @@ -0,0 +1,35 @@ +"""Application credentials platform for Model Context Protocol.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +import contextvars + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +CONF_ACTIVE_AUTHORIZATION_SERVER = "active_authorization_server" + +_mcp_context: contextvars.ContextVar[AuthorizationServer] = contextvars.ContextVar( + "mcp_authorization_server_context" +) + + +@contextmanager +def authorization_server_context( + authorization_server: AuthorizationServer, +) -> Generator[None]: + """Context manager for setting the active authorization server.""" + token = _mcp_context.set(authorization_server) + try: + yield + finally: + _mcp_context.reset(token) + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server, for the default auth implementation.""" + if _mcp_context.get() is None: + raise RuntimeError("No MCP authorization server set in context") + return _mcp_context.get() diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 92e0052c665..0f34962f7ee 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -2,20 +2,29 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from typing import Any +from typing import Any, cast import httpx import voluptuous as vol +from yarl import URL -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_URL +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_entry_oauth2_flow import ( + AbstractOAuth2FlowHandler, + async_get_implementations, +) -from .const import DOMAIN -from .coordinator import mcp_client +from . import async_get_config_entry_implementation +from .application_credentials import authorization_server_context +from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .coordinator import TokenManager, mcp_client _LOGGER = logging.getLogger(__name__) @@ -25,8 +34,62 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +# OAuth server discovery endpoint for rfc8414 +OAUTH_DISCOVERY_ENDPOINT = ".well-known/oauth-authorization-server" +MCP_DISCOVERY_HEADERS = { + "MCP-Protocol-Version": "2025-03-26", +} -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + +async def async_discover_oauth_config( + hass: HomeAssistant, mcp_server_url: str +) -> AuthorizationServer: + """Discover the OAuth configuration for the MCP server. + + This implements the functionality in the MCP spec for discovery. If the MCP server URL + is https://api.example.com/v1/mcp, then: + - The authorization base URL is https://api.example.com + - The metadata endpoint MUST be at https://api.example.com/.well-known/oauth-authorization-server + - For servers that do not implement OAuth 2.0 Authorization Server Metadata, the client uses + default paths relative to the authorization base URL. + """ + parsed_url = URL(mcp_server_url) + discovery_endpoint = str(parsed_url.with_path(OAUTH_DISCOVERY_ENDPOINT)) + try: + async with httpx.AsyncClient(headers=MCP_DISCOVERY_HEADERS) as client: + response = await client.get(discovery_endpoint) + response.raise_for_status() + except httpx.TimeoutException as error: + _LOGGER.info("Timeout connecting to MCP server: %s", error) + raise TimeoutConnectError from error + except httpx.HTTPStatusError as error: + if error.response.status_code == 404: + _LOGGER.info("Authorization Server Metadata not found, using default paths") + return AuthorizationServer( + authorize_url=str(parsed_url.with_path("/authorize")), + token_url=str(parsed_url.with_path("/token")), + ) + raise CannotConnect from error + except httpx.HTTPError as error: + _LOGGER.info("Cannot discover OAuth configuration: %s", error) + raise CannotConnect from error + + data = response.json() + authorize_url = data["authorization_endpoint"] + token_url = data["token_endpoint"] + if authorize_url.startswith("/"): + authorize_url = str(parsed_url.with_path(authorize_url)) + if token_url.startswith("/"): + token_url = str(parsed_url.with_path(token_url)) + return AuthorizationServer( + authorize_url=authorize_url, + token_url=token_url, + ) + + +async def validate_input( + hass: HomeAssistant, data: dict[str, Any], token_manager: TokenManager | None = None +) -> dict[str, Any]: """Validate the user input and connect to the MCP server.""" url = data[CONF_URL] try: @@ -34,7 +97,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except vol.Invalid as error: raise InvalidUrl from error try: - async with mcp_client(url) as session: + async with mcp_client(url, token_manager=token_manager) as session: response = await session.initialize() except httpx.TimeoutException as error: _LOGGER.info("Timeout connecting to MCP server: %s", error) @@ -56,10 +119,17 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return {"title": response.serverInfo.name} -class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): +class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle a config flow for Model Context Protocol.""" VERSION = 1 + DOMAIN = DOMAIN + logger = _LOGGER + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.data: dict[str, Any] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -76,7 +146,8 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: - return self.async_abort(reason="invalid_auth") + self.data[CONF_URL] = user_input[CONF_URL] + return await self.async_step_auth_discovery() except MissingCapabilities: return self.async_abort(reason="missing_capabilities") except Exception: @@ -90,6 +161,130 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_auth_discovery( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the OAuth server discovery step. + + Since this OAuth server requires authentication, this step will attempt + to find the OAuth medata then run the OAuth authentication flow. + """ + try: + authorization_server = await async_discover_oauth_config( + self.hass, self.data[CONF_URL] + ) + except TimeoutConnectError: + return self.async_abort(reason="timeout_connect") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + else: + _LOGGER.info("OAuth configuration: %s", authorization_server) + self.data.update( + { + CONF_AUTHORIZATION_URL: authorization_server.authorize_url, + CONF_TOKEN_URL: authorization_server.token_url, + } + ) + return await self.async_step_credentials_choice() + + def authorization_server(self) -> AuthorizationServer: + """Return the authorization server provided by the MCP server.""" + return AuthorizationServer( + self.data[CONF_AUTHORIZATION_URL], + self.data[CONF_TOKEN_URL], + ) + + async def async_step_credentials_choice( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to ask they user if they would like to add credentials. + + This is needed since we can't automatically assume existing credentials + should be used given they may be for another existing server. + """ + with authorization_server_context(self.authorization_server()): + if not await async_get_implementations(self.hass, self.DOMAIN): + return await self.async_step_new_credentials() + return self.async_show_menu( + step_id="credentials_choice", + menu_options=["pick_implementation", "new_credentials"], + ) + + async def async_step_new_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to take the frontend flow to enter new credentials.""" + return self.async_abort(reason="missing_credentials") + + async def async_step_pick_implementation( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the pick implementation step. + + This exists to dynamically set application credentials Authorization Server + based on the values form the OAuth discovery step. + """ + with authorization_server_context(self.authorization_server()): + return await super().async_step_pick_implementation(user_input) + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an entry for the flow. + + Ok to override if you want to fetch extra info or even add another step. + """ + config_entry_data = { + **self.data, + **data, + } + + async def token_manager() -> str: + return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + + try: + info = await validate_input(self.hass, config_entry_data, token_manager) + except TimeoutConnectError: + return self.async_abort(reason="timeout_connect") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except MissingCapabilities: + return self.async_abort(reason="missing_capabilities") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + # Unique id based on the application credentials OAuth Client ID + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=config_entry_data + ) + await self.async_set_unique_id(config_entry_data["auth_implementation"]) + return self.async_create_entry( + title=info["title"], + data=config_entry_data, + ) + + 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: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + config_entry = self._get_reauth_entry() + self.data = {**config_entry.data} + self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment] + self.hass, config_entry + ) + return await self.async_step_auth() + class InvalidUrl(HomeAssistantError): """Error to indicate the URL format is invalid.""" diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py index 675b2d7031c..13f63b02c73 100644 --- a/homeassistant/components/mcp/const.py +++ b/homeassistant/components/mcp/const.py @@ -1,3 +1,7 @@ """Constants for the Model Context Protocol integration.""" DOMAIN = "mcp" + +CONF_ACCESS_TOKEN = "access_token" +CONF_AUTHORIZATION_URL = "authorization_url" +CONF_TOKEN_URL = "token_url" diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index a5c5ee55dbf..f560875292f 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -1,7 +1,7 @@ """Types for the Model Context Protocol integration.""" import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager import datetime import logging @@ -15,7 +15,7 @@ from voluptuous_openapi import convert_to_voluptuous from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import llm from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.json import JsonObjectType @@ -27,19 +27,32 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = datetime.timedelta(minutes=30) TIMEOUT = 10 +TokenManager = Callable[[], Awaitable[str]] + @asynccontextmanager -async def mcp_client(url: str) -> AsyncGenerator[ClientSession]: +async def mcp_client( + url: str, + token_manager: TokenManager | None = None, +) -> AsyncGenerator[ClientSession]: """Create a server-sent event MCP client. This is an asynccontext manager that exists to wrap other async context managers so that the coordinator has a single object to manage. """ + headers: dict[str, str] = {} + if token_manager is not None: + token = await token_manager() + headers["Authorization"] = f"Bearer {token}" try: - async with sse_client(url=url) as streams, ClientSession(*streams) as session: + async with ( + sse_client(url=url, headers=headers) as streams, + ClientSession(*streams) as session, + ): await session.initialize() yield session except ExceptionGroup as err: + _LOGGER.debug("Error creating MCP client: %s", err) raise err.exceptions[0] from err @@ -51,13 +64,15 @@ class ModelContextProtocolTool(llm.Tool): name: str, description: str | None, parameters: vol.Schema, - session: ClientSession, + server_url: str, + token_manager: TokenManager | None = None, ) -> None: """Initialize the tool.""" self.name = name self.description = description self.parameters = parameters - self.session = session + self.server_url = server_url + self.token_manager = token_manager async def async_call( self, @@ -67,10 +82,16 @@ class ModelContextProtocolTool(llm.Tool): ) -> JsonObjectType: """Call the tool.""" try: - result = await self.session.call_tool( - tool_input.tool_name, tool_input.tool_args - ) + async with asyncio.timeout(TIMEOUT): + async with mcp_client(self.server_url, self.token_manager) as session: + result = await session.call_tool( + tool_input.tool_name, tool_input.tool_args + ) + except TimeoutError as error: + _LOGGER.debug("Timeout when calling tool: %s", error) + raise HomeAssistantError(f"Timeout when calling tool: {error}") from error except httpx.HTTPStatusError as error: + _LOGGER.debug("Error when calling tool: %s", error) raise HomeAssistantError(f"Error when calling tool: {error}") from error return result.model_dump(exclude_unset=True, exclude_none=True) @@ -79,10 +100,13 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): """Define an object to hold MCP data.""" config_entry: ConfigEntry - _session: ClientSession | None = None - _setup_error: Exception | None = None - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + token_manager: TokenManager | None = None, + ) -> None: """Initialize ModelContextProtocolCoordinator.""" super().__init__( hass, @@ -91,52 +115,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): config_entry=config_entry, update_interval=UPDATE_INTERVAL, ) - self._stop = asyncio.Event() - - async def _async_setup(self) -> None: - """Set up the client connection.""" - connected = asyncio.Event() - stop = asyncio.Event() - self.config_entry.async_create_background_task( - self.hass, self._connect(connected, stop), "mcp-client" - ) - try: - async with asyncio.timeout(TIMEOUT): - await connected.wait() - self._stop = stop - finally: - if self._setup_error is not None: - raise self._setup_error - - async def _connect(self, connected: asyncio.Event, stop: asyncio.Event) -> None: - """Create a server-sent event MCP client.""" - url = self.config_entry.data[CONF_URL] - try: - async with ( - sse_client(url=url) as streams, - ClientSession(*streams) as session, - ): - await session.initialize() - self._session = session - connected.set() - await stop.wait() - except httpx.HTTPStatusError as err: - self._setup_error = err - _LOGGER.debug("Error connecting to MCP server: %s", err) - raise UpdateFailed(f"Error connecting to MCP server: {err}") from err - except ExceptionGroup as err: - self._setup_error = err.exceptions[0] - _LOGGER.debug("Error connecting to MCP server: %s", err) - raise UpdateFailed( - "Error connecting to MCP server: {err.exceptions[0]}" - ) from err.exceptions[0] - finally: - self._session = None - - async def close(self) -> None: - """Close the client connection.""" - if self._stop is not None: - self._stop.set() + self.token_manager = token_manager async def _async_update_data(self) -> list[llm.Tool]: """Fetch data from API endpoint. @@ -144,11 +123,24 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): This is the place to pre-process the data to lookup tables so entities can quickly look up their data. """ - if self._session is None: - raise UpdateFailed("No session available") try: - result = await self._session.list_tools() + async with asyncio.timeout(TIMEOUT): + async with mcp_client( + self.config_entry.data[CONF_URL], self.token_manager + ) as session: + result = await session.list_tools() + except TimeoutError as error: + _LOGGER.debug("Timeout when listing tools: %s", error) + raise UpdateFailed(f"Timeout when listing tools: {error}") from error + except httpx.HTTPStatusError as error: + _LOGGER.debug("Error communicating with API: %s", error) + if error.response.status_code == 401 and self.token_manager is not None: + raise ConfigEntryAuthFailed( + "The MCP server requires authentication" + ) from error + raise UpdateFailed(f"Error communicating with API: {error}") from error except httpx.HTTPError as err: + _LOGGER.debug("Error communicating with API: %s", err) raise UpdateFailed(f"Error communicating with API: {err}") from err _LOGGER.debug("Received tools: %s", result.tools) @@ -165,7 +157,8 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): tool.name, tool.description, parameters, - self._session, + self.config_entry.data[CONF_URL], + self.token_manager, ) ) return tools diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json index ee4baf04802..7ff64d29aa4 100644 --- a/homeassistant/components/mcp/manifest.json +++ b/homeassistant/components/mcp/manifest.json @@ -3,8 +3,9 @@ "name": "Model Context Protocol", "codeowners": ["@allenporter"], "config_flow": true, + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/mcp", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["mcp==1.1.2"] + "requirements": ["mcp==1.5.0"] } diff --git a/homeassistant/components/mcp/quality_scale.yaml b/homeassistant/components/mcp/quality_scale.yaml index 76afdf5860d..f22343c8d0e 100644 --- a/homeassistant/components/mcp/quality_scale.yaml +++ b/homeassistant/components/mcp/quality_scale.yaml @@ -44,9 +44,7 @@ rules: parallel-updates: status: exempt comment: Integration does not have platforms. - reauthentication-flow: - status: exempt - comment: Integration does not support authentication. + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index 97a75fc6f85..2b59d4ffa51 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -8,6 +8,15 @@ "data_description": { "url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse" } + }, + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "Credentials" + }, + "data_description": { + "implementation": "The credentials to use for the OAuth2 flow" + } } }, "error": { @@ -17,9 +26,15 @@ "invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse" }, "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_capabilities": "The MCP server does not support a required capability (Tools)", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/homeassistant/components/mcp_server/__init__.py b/homeassistant/components/mcp_server/__init__.py index 941eccbe528..e523f46228f 100644 --- a/homeassistant/components/mcp_server/__init__.py +++ b/homeassistant/components/mcp_server/__init__.py @@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from . import http, llm_api +from . import http from .const import DOMAIN from .session import SessionManager from .types import MCPServerConfigEntry @@ -25,7 +25,6 @@ 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) - llm_api.async_register_api(hass) return True diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py index 8d8d311b874..e8df68de5e2 100644 --- a/homeassistant/components/mcp_server/config_flow.py +++ b/homeassistant/components/mcp_server/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) -from .const import DOMAIN, LLM_API, LLM_API_NAME +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,13 +33,6 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" llm_apis = {api.id: api.name for api in llm.async_get_apis(self.hass)} - if LLM_API not in llm_apis: - # MCP server component is not loaded yet, so make the LLM API a choice. - llm_apis = { - LLM_API: LLM_API_NAME, - **llm_apis, - } - if user_input is not None: return self.async_create_entry( title=llm_apis[user_input[CONF_LLM_HASS_API]], data=user_input diff --git a/homeassistant/components/mcp_server/const.py b/homeassistant/components/mcp_server/const.py index 8958ac36616..3f2e12cbb6a 100644 --- a/homeassistant/components/mcp_server/const.py +++ b/homeassistant/components/mcp_server/const.py @@ -2,5 +2,6 @@ DOMAIN = "mcp_server" TITLE = "Model Context Protocol Server" -LLM_API = "stateless_assist" -LLM_API_NAME = "Stateless Assist" +# The Stateless API is no longer registered explicitly, but this name may still exist in the +# users config entry. +STATELESS_LLM_API = "stateless_assist" diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py deleted file mode 100644 index 5c29b29153e..00000000000 --- a/homeassistant/components/mcp_server/llm_api.py +++ /dev/null @@ -1,48 +0,0 @@ -"""LLM API for MCP Server.""" - -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import llm -from homeassistant.util import yaml as yaml_util - -from .const import LLM_API, LLM_API_NAME - -EXPOSED_ENTITY_FIELDS = {"name", "domain", "description", "areas", "names"} - - -def async_register_api(hass: HomeAssistant) -> None: - """Register the LLM API.""" - llm.async_register_api(hass, StatelessAssistAPI(hass)) - - -class StatelessAssistAPI(llm.AssistAPI): - """LLM API for MCP Server that provides the Assist API without state information in the prompt. - - Syncing the state information is possible, but may put unnecessary load on - the system so we are instead providing the prompt without entity state. Since - actions don't care about the current state, there is little quality loss. - """ - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the StatelessAssistAPI.""" - super().__init__(hass) - self.id = LLM_API - self.name = LLM_API_NAME - - @callback - def _async_get_exposed_entities_prompt( - self, llm_context: llm.LLMContext, exposed_entities: dict | None - ) -> list[str]: - """Return the prompt for the exposed entities.""" - prompt = [] - - if exposed_entities and exposed_entities["entities"]: - prompt.append( - "An overview of the areas and the devices in this smart home:" - ) - entities = [ - {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} - for entity_info in exposed_entities["entities"].values() - ] - prompt.append(yaml_util.dump(list(entities))) - - return prompt diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index 18b2e5bc417..b5fb1bdcd87 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.8.0"], + "requirements": ["mcp==1.5.0", "aiohttp_sse==2.2.0", "anyio==4.9.0"], "single_config_entry": true } diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index ba21abd722c..affa4faecd6 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -21,6 +21,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import llm +from .const import STATELESS_LLM_API + _LOGGER = logging.getLogger(__name__) @@ -47,16 +49,23 @@ async def create_server( A Model Context Protocol Server object is associated with a single session. The MCP SDK handles the details of the protocol. """ + if llm_api_id == STATELESS_LLM_API: + llm_api_id = llm.LLM_API_ASSIST - server = Server("home-assistant") + server = Server[Any]("home-assistant") + + async def get_api_instance() -> llm.APIInstance: + """Get the LLM API selected.""" + # Backwards compatibility with old MCP Server config + return await llm.async_get_api(hass, llm_api_id, llm_context) @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) + llm_api = await get_api_instance() return [ types.Prompt( name=llm_api.api.name, - description=f"Default prompt for the Home Assistant LLM API {llm_api.api.name}", + description=f"Default prompt for Home Assistant {llm_api.api.name} API", ) ] @@ -64,12 +73,12 @@ async def create_server( 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) + llm_api = await get_api_instance() 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}", + description=f"Default prompt for Home Assistant {llm_api.api.name} API", messages=[ types.PromptMessage( role="assistant", @@ -84,13 +93,13 @@ async def create_server( @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) + llm_api = await get_api_instance() 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) + llm_api = await get_api_instance() tool_input = llm.ToolInput(tool_name=name, tool_args=arguments) _LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args) diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index fa63252e837..186fc4c4ac0 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -146,11 +146,11 @@ "services": { "get_mealplan": { "name": "Get mealplan", - "description": "Get mealplan from Mealie", + "description": "Gets a mealplan from Mealie", "fields": { "config_entry_id": { "name": "Mealie instance", - "description": "Select the Mealie instance to get mealplan from" + "description": "The Mealie instance to use for this action." }, "start_date": { "name": "Start date", @@ -164,7 +164,7 @@ }, "get_recipe": { "name": "Get recipe", - "description": "Get recipe from Mealie", + "description": "Gets a recipe from Mealie", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", @@ -178,7 +178,7 @@ }, "import_recipe": { "name": "Import recipe", - "description": "Import recipe from an URL", + "description": "Imports a recipe from an URL", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", @@ -196,7 +196,7 @@ }, "set_random_mealplan": { "name": "Set random mealplan", - "description": "Set a random mealplan for a specific date", + "description": "Sets a random mealplan for a specific date", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", @@ -214,7 +214,7 @@ }, "set_mealplan": { "name": "Set a mealplan", - "description": "Set a mealplan for a specific date", + "description": "Sets a mealplan for a specific date", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", diff --git a/homeassistant/components/meater/config_flow.py b/homeassistant/components/meater/config_flow.py index a7ba3ba1498..5c11b10755c 100644 --- a/homeassistant/components/meater/config_flow.py +++ b/homeassistant/components/meater/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from meater import AuthenticationError, MeaterApi, ServiceUnavailableError @@ -14,6 +15,8 @@ from homeassistant.helpers import aiohttp_client from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) USER_SCHEMA = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} @@ -84,7 +87,8 @@ class MeaterConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except ServiceUnavailableError: errors["base"] = "service_unavailable_error" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown_auth_error" else: data = {"username": username, "password": password} diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index f0f8ee03ad0..a6663b089ac 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]==2025.01.26"], + "requirements": ["yt-dlp[default]==2025.03.31"], "single_config_entry": true } diff --git a/homeassistant/components/media_extractor/strings.json b/homeassistant/components/media_extractor/strings.json index 125aa08337a..11b5a884e4d 100644 --- a/homeassistant/components/media_extractor/strings.json +++ b/homeassistant/components/media_extractor/strings.json @@ -17,12 +17,12 @@ }, "media_content_type": { "name": "Media content type", - "description": "The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC." + "description": "The type of the content to play." } } }, "extract_media_url": { - "name": "Get Media URL", + "name": "Get media URL", "description": "Extract media URL from a service.", "fields": { "url": { diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index a30b01694fa..0979852ecce 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -68,7 +68,12 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey -from .browse_media import BrowseMedia, async_process_play_media_url # noqa: F401 +from .browse_media import ( # noqa: F401 + BrowseMedia, + SearchMedia, + SearchMediaQuery, + async_process_play_media_url, +) from .const import ( # noqa: F401 _DEPRECATED_MEDIA_CLASS_DIRECTORY, _DEPRECATED_SUPPORT_BROWSE_MEDIA, @@ -107,10 +112,12 @@ from .const import ( # noqa: F401 ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_EPISODE, ATTR_MEDIA_EXTRA, + ATTR_MEDIA_FILTER_CLASSES, ATTR_MEDIA_PLAYLIST, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEARCH_QUERY, ATTR_MEDIA_SEASON, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SERIES_TITLE, @@ -128,6 +135,7 @@ from .const import ( # noqa: F401 SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, SERVICE_PLAY_MEDIA, + SERVICE_SEARCH_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, @@ -137,7 +145,7 @@ from .const import ( # noqa: F401 MediaType, RepeatMode, ) -from .errors import BrowseError +from .errors import BrowseError, SearchError _LOGGER = logging.getLogger(__name__) @@ -291,6 +299,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) websocket_api.async_register_command(hass, websocket_browse_media) + websocket_api.async_register_command(hass, websocket_search_media) hass.http.register_view(MediaPlayerImageView(component)) await component.async_setup(config) @@ -447,6 +456,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_browse_media", supports_response=SupportsResponse.ONLY, ) + component.async_register_entity_service( + SERVICE_SEARCH_MEDIA, + { + vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string, + vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string, + vol.Required(ATTR_MEDIA_SEARCH_QUERY): cv.string, + vol.Optional(ATTR_MEDIA_FILTER_CLASSES): vol.All( + cv.ensure_list, + [vol.In([m.value for m in MediaClass])], + lambda x: {MediaClass(item) for item in x}, + ), + }, + "async_internal_search_media", + [MediaPlayerEntityFeature.SEARCH_MEDIA], + SupportsResponse.ONLY, + ) component.async_register_entity_service( SERVICE_SHUFFLE_SET, {vol.Required(ATTR_MEDIA_SHUFFLE): cv.boolean}, @@ -1031,7 +1056,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self.state in { MediaPlayerState.OFF, - MediaPlayerState.IDLE, MediaPlayerState.STANDBY, }: await self.async_turn_on() @@ -1158,6 +1182,29 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ raise NotImplementedError + async def async_internal_search_media( + self, + search_query: str, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + media_filter_classes: list[MediaClass] | None = None, + ) -> SearchMedia: + return await self.async_search_media( + query=SearchMediaQuery( + search_query=search_query, + media_content_type=media_content_type, + media_content_id=media_content_id, + media_filter_classes=media_filter_classes, + ) + ) + + async def async_search_media( + self, + query: SearchMediaQuery, + ) -> SearchMedia: + """Search the media player.""" + raise NotImplementedError + def join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" raise NotImplementedError @@ -1361,6 +1408,75 @@ async def websocket_browse_media( connection.send_result(msg["id"], result) +@websocket_api.websocket_command( + { + vol.Required("type"): "media_player/search_media", + vol.Required("entity_id"): cv.entity_id, + vol.Inclusive( + ATTR_MEDIA_CONTENT_TYPE, + "media_ids", + "media_content_type and media_content_id must be provided together", + ): str, + vol.Inclusive( + ATTR_MEDIA_CONTENT_ID, + "media_ids", + "media_content_type and media_content_id must be provided together", + ): str, + vol.Required(ATTR_MEDIA_SEARCH_QUERY): str, + vol.Optional(ATTR_MEDIA_FILTER_CLASSES): vol.All( + cv.ensure_list, + [vol.In([m.value for m in MediaClass])], + lambda x: {MediaClass(item) for item in x}, + ), + } +) +@websocket_api.async_response +async def websocket_search_media( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Search media available to the media_player entity. + + To use, media_player integrations can implement + MediaPlayerEntity.async_search_media() + """ + player = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"]) + + if player is None: + connection.send_error(msg["id"], "entity_not_found", "Entity not found") + return + + if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features_compat: + connection.send_message( + websocket_api.error_message( + msg["id"], ERR_NOT_SUPPORTED, "Player does not support searching media" + ) + ) + return + + media_content_type = msg.get(ATTR_MEDIA_CONTENT_TYPE) + media_content_id = msg.get(ATTR_MEDIA_CONTENT_ID) + query = str(msg.get(ATTR_MEDIA_SEARCH_QUERY)) + media_filter_classes = msg.get(ATTR_MEDIA_FILTER_CLASSES, []) + + try: + payload = await player.async_internal_search_media( + query, + media_content_type, + media_content_id, + media_filter_classes, + ) + except SearchError as err: + connection.send_message( + websocket_api.error_message(msg["id"], ERR_UNKNOWN_ERROR, str(err)) + ) + return + + result = payload.as_dict() + connection.send_result(msg["id"], result) + + _FETCH_TIMEOUT = aiohttp.ClientTimeout(total=10) diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index c917164a2ee..ec9d70476a3 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import dataclass, field from datetime import timedelta import logging from typing import Any @@ -23,7 +24,11 @@ from homeassistant.helpers.network import ( from .const import CONTENT_AUTH_EXPIRY_TIME, MediaClass, MediaType # Paths that we don't need to sign -PATHS_WITHOUT_AUTH = ("/api/tts_proxy/", "/api/esphome/ffmpeg_proxy/") +PATHS_WITHOUT_AUTH = ( + "/api/tts_proxy/", + "/api/esphome/ffmpeg_proxy/", + "/api/assist_satellite/static/", +) @callback @@ -105,6 +110,7 @@ class BrowseMedia: children_media_class: MediaClass | str | None = None, thumbnail: str | None = None, not_shown: int = 0, + can_search: bool = False, ) -> None: """Initialize browse media item.""" self.media_class = media_class @@ -117,6 +123,7 @@ class BrowseMedia: self.children_media_class = children_media_class self.thumbnail = thumbnail self.not_shown = not_shown + self.can_search = can_search def as_dict(self, *, parent: bool = True) -> dict[str, Any]: """Convert Media class to browse media dictionary.""" @@ -131,6 +138,7 @@ class BrowseMedia: "children_media_class": self.children_media_class, "can_play": self.can_play, "can_expand": self.can_expand, + "can_search": self.can_search, "thumbnail": self.thumbnail, } @@ -159,3 +167,27 @@ class BrowseMedia: def __repr__(self) -> str: """Return representation of browse media.""" return f"" + + +@dataclass(kw_only=True, frozen=True) +class SearchMedia: + """Represent search results.""" + + version: int = field(default=1) + result: list[BrowseMedia] + + def as_dict(self, *, parent: bool = True) -> dict[str, Any]: + """Convert SearchMedia class to browse media dictionary.""" + return { + "result": [item.as_dict(parent=parent) for item in self.result], + } + + +@dataclass(kw_only=True, frozen=True) +class SearchMediaQuery: + """Represent a search media file.""" + + search_query: str + media_content_type: MediaType | str | None = field(default=None) + media_content_id: str | None = None + media_filter_classes: list[MediaClass] | None = field(default=None) diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 387fdb05401..8d85d7cd106 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -26,6 +26,8 @@ ATTR_MEDIA_ARTIST = "media_artist" ATTR_MEDIA_CHANNEL = "media_channel" ATTR_MEDIA_CONTENT_ID = "media_content_id" ATTR_MEDIA_CONTENT_TYPE = "media_content_type" +ATTR_MEDIA_SEARCH_QUERY = "search_query" +ATTR_MEDIA_FILTER_CLASSES = "media_filter_classes" ATTR_MEDIA_DURATION = "media_duration" ATTR_MEDIA_ENQUEUE = "enqueue" ATTR_MEDIA_EXTRA = "extra" @@ -174,6 +176,7 @@ SERVICE_CLEAR_PLAYLIST = "clear_playlist" SERVICE_JOIN = "join" SERVICE_PLAY_MEDIA = "play_media" SERVICE_BROWSE_MEDIA = "browse_media" +SERVICE_SEARCH_MEDIA = "search_media" SERVICE_SELECT_SOUND_MODE = "select_sound_mode" SERVICE_SELECT_SOURCE = "select_source" SERVICE_UNJOIN = "unjoin" @@ -220,6 +223,7 @@ class MediaPlayerEntityFeature(IntFlag): GROUPING = 524288 MEDIA_ANNOUNCE = 1048576 MEDIA_ENQUEUE = 2097152 + SEARCH_MEDIA = 4194304 # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. diff --git a/homeassistant/components/media_player/errors.py b/homeassistant/components/media_player/errors.py index 5888ba6b5b0..23db94a330e 100644 --- a/homeassistant/components/media_player/errors.py +++ b/homeassistant/components/media_player/errors.py @@ -9,3 +9,7 @@ class MediaPlayerException(HomeAssistantError): class BrowseError(MediaPlayerException): """Error while browsing.""" + + +class SearchError(MediaPlayerException): + """Error while searching.""" diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index 5008ea62d2e..fb45a821062 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -68,6 +68,9 @@ "repeat_set": { "service": "mdi:repeat" }, + "search_media": { + "service": "mdi:text-search" + }, "select_sound_mode": { "service": "mdi:surround-sound" }, diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index edfab2a668f..85f0598695b 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -2,7 +2,9 @@ from collections.abc import Iterable from dataclasses import dataclass, field +import logging import time +from typing import cast import voluptuous as vol @@ -14,9 +16,17 @@ from homeassistant.const import ( SERVICE_VOLUME_SET, ) from homeassistant.core import Context, HomeAssistant, State -from homeassistant.helpers import intent +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, intent -from . import ATTR_MEDIA_VOLUME_LEVEL, DOMAIN, MediaPlayerDeviceClass +from . import ( + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN, + SERVICE_PLAY_MEDIA, + SERVICE_SEARCH_MEDIA, + MediaPlayerDeviceClass, + SearchMedia, +) from .const import MediaPlayerEntityFeature, MediaPlayerState INTENT_MEDIA_PAUSE = "HassMediaPause" @@ -24,6 +34,9 @@ INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" INTENT_MEDIA_NEXT = "HassMediaNext" INTENT_MEDIA_PREVIOUS = "HassMediaPrevious" INTENT_SET_VOLUME = "HassSetVolume" +INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay" + +_LOGGER = logging.getLogger(__name__) @dataclass @@ -93,18 +106,23 @@ async def async_setup_intents(hass: HomeAssistant) -> None: DOMAIN, SERVICE_VOLUME_SET, required_domains={DOMAIN}, - required_states={MediaPlayerState.PLAYING}, required_features=MediaPlayerEntityFeature.VOLUME_SET, required_slots={ - ATTR_MEDIA_VOLUME_LEVEL: vol.All( - vol.Coerce(int), vol.Range(min=0, max=100), lambda val: val / 100 - ) + ATTR_MEDIA_VOLUME_LEVEL: intent.IntentSlotInfo( + description="The volume percentage of the media player", + value_schema=vol.All( + vol.Coerce(int), + vol.Range(min=0, max=100), + lambda val: val / 100, + ), + ), }, - description="Sets the volume of a media player", + description="Sets the volume percentage of a media player", platforms={DOMAIN}, device_classes={MediaPlayerDeviceClass}, ), ) + intent.async_register(hass, MediaSearchAndPlayHandler()) class MediaPauseHandler(intent.ServiceIntentHandler): @@ -154,7 +172,6 @@ class MediaUnpauseHandler(intent.ServiceIntentHandler): DOMAIN, SERVICE_MEDIA_PLAY, required_domains={DOMAIN}, - required_states={MediaPlayerState.PAUSED}, description="Resumes a media player", platforms={DOMAIN}, device_classes={MediaPlayerDeviceClass}, @@ -204,3 +221,121 @@ class MediaUnpauseHandler(intent.ServiceIntentHandler): return await super().async_handle_states( intent_obj, match_result, match_constraints ) + + +class MediaSearchAndPlayHandler(intent.IntentHandler): + """Handle HassMediaSearchAndPlay intents.""" + + description = "Searches for media and plays the first result" + + intent_type = INTENT_MEDIA_SEARCH_AND_PLAY + slot_schema = { + vol.Required("search_query"): cv.string, + # Optional name/area/floor slots handled by intent matcher + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + vol.Optional("floor"): cv.string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + search_query = slots["search_query"]["value"] + + # Entity name to match + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + + # Get area/floor info + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + + floor_slot = slots.get("floor", {}) + floor_id = floor_slot.get("value") + + # Find matching entities + match_constraints = intent.MatchTargetsConstraints( + name=entity_name, + area_name=area_id, + floor_name=floor_id, + domains={DOMAIN}, + assistant=intent_obj.assistant, + features=MediaPlayerEntityFeature.SEARCH_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA, + single_target=True, + ) + match_result = intent.async_match_targets( + hass, + match_constraints, + intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ), + ) + + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + target_entity = match_result.states[0] + target_entity_id = target_entity.entity_id + + # 1. Search Media + try: + search_response = await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH_MEDIA, + { + "search_query": search_query, + }, + target={ + "entity_id": target_entity_id, + }, + blocking=True, + context=intent_obj.context, + return_response=True, + ) + except HomeAssistantError as err: + _LOGGER.error("Error calling search_media: %s", err) + raise intent.IntentHandleError(f"Error searching media: {err}") from err + + if ( + not search_response + or not ( + entity_response := cast( + SearchMedia, search_response.get(target_entity_id) + ) + ) + or not (results := entity_response.result) + ): + # No results found + return intent_obj.create_response() + + # 2. Play Media (first result) + first_result = results[0] + try: + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": target_entity_id, + "media_content_id": first_result.media_content_id, + "media_content_type": first_result.media_content_type, + }, + blocking=True, + context=intent_obj.context, + ) + except HomeAssistantError as err: + _LOGGER.error("Error calling play_media: %s", err) + raise intent.IntentHandleError(f"Error playing media: {err}") from err + + # Success + response = intent_obj.create_response() + response.async_set_speech_slots({"media": first_result}) + response.response_type = intent.IntentResponseType.ACTION_DONE + return response diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 6b13a6b9c09..ac359de1a5b 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -169,6 +169,8 @@ browse_media: target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.BROWSE_MEDIA fields: media_content_type: required: false @@ -181,6 +183,35 @@ browse_media: selector: text: +search_media: + target: + entity: + domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.SEARCH_MEDIA + fields: + search_query: + required: true + example: "Beatles" + selector: + text: + media_content_type: + required: false + example: "music" + selector: + text: + media_content_id: + required: false + example: "A:ALBUMARTIST/Beatles" + selector: + text: + media_filter_classes: + required: false + example: ["album", "artist"] + selector: + text: + multiple: true + select_source: target: entity: diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 87b5ec692af..617cb258af7 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -274,6 +274,28 @@ } } }, + "search_media": { + "name": "Search media", + "description": "Searches the available media.", + "fields": { + "media_content_id": { + "name": "[%key:component::media_player::services::browse_media::fields::media_content_id::name%]", + "description": "[%key:component::media_player::services::browse_media::fields::media_content_id::description%]" + }, + "media_content_type": { + "name": "[%key:component::media_player::services::browse_media::fields::media_content_type::name%]", + "description": "[%key:component::media_player::services::browse_media::fields::media_content_type::description%]" + }, + "search_query": { + "name": "Search query", + "description": "The term to search for." + }, + "media_filter_classes": { + "name": "Media class filter", + "description": "List of media classes to filter the search results by." + } + } + }, "select_source": { "name": "Select source", "description": "Sends the media player the command to change input source.", @@ -344,7 +366,7 @@ }, "repeat": { "options": { - "off": "Off", + "off": "[%key:common::state::off%]", "all": "Repeat all", "one": "Repeat one" } diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 5c6165a3477..e1e9a4feb4b 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -33,7 +33,7 @@ from .const import ( URI_SCHEME, URI_SCHEME_REGEX, ) -from .error import MediaSourceError, Unresolvable +from .error import MediaSourceError, UnknownMediaSource, Unresolvable from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia __all__ = [ @@ -113,7 +113,11 @@ def _get_media_item( return MediaSourceItem(hass, domain, "", target_media_player) if item.domain is not None and item.domain not in hass.data[DOMAIN]: - raise ValueError("Unknown media source") + raise UnknownMediaSource( + translation_domain=DOMAIN, + translation_key="unknown_media_source", + translation_placeholders={"domain": item.domain}, + ) return item @@ -132,7 +136,14 @@ async def async_browse_media( try: item = await _get_media_item(hass, media_content_id, None).async_browse() except ValueError as err: - raise BrowseError(str(err)) from err + raise BrowseError( + translation_domain=DOMAIN, + translation_key="browse_media_failed", + translation_placeholders={ + "media_content_id": str(media_content_id), + "error": str(err), + }, + ) from err if content_filter is None or item.children is None: return item @@ -165,7 +176,14 @@ async def async_resolve_media( try: item = _get_media_item(hass, media_content_id, target_media_player) except ValueError as err: - raise Unresolvable(str(err)) from err + raise Unresolvable( + translation_domain=DOMAIN, + translation_key="resolve_media_failed", + translation_placeholders={ + "media_content_id": str(media_content_id), + "error": str(err), + }, + ) from err return await item.async_resolve() diff --git a/homeassistant/components/media_source/error.py b/homeassistant/components/media_source/error.py index 120e7583e23..66e8842e08a 100644 --- a/homeassistant/components/media_source/error.py +++ b/homeassistant/components/media_source/error.py @@ -9,3 +9,7 @@ class MediaSourceError(HomeAssistantError): class Unresolvable(MediaSourceError): """When media ID is not resolvable.""" + + +class UnknownMediaSource(MediaSourceError, ValueError): + """When media source is unknown.""" diff --git a/homeassistant/components/media_source/strings.json b/homeassistant/components/media_source/strings.json new file mode 100644 index 00000000000..40204fc32db --- /dev/null +++ b/homeassistant/components/media_source/strings.json @@ -0,0 +1,13 @@ +{ + "exceptions": { + "browse_media_failed": { + "message": "Failed to browse media with content id {media_content_id}: {error}" + }, + "resolve_media_failed": { + "message": "Failed to resolve media with content id {media_content_id}: {error}" + }, + "unknown_media_source": { + "message": "Unknown media source: {domain}" + } + } +} diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index 4561c38ce80..bccbe9f66ac 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -165,7 +165,7 @@ class MediaroomDevice(MediaPlayerEntity): self._unique_id = None @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._available diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 9c2ee60b12c..19c333e5825 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from typing import Any +from typing import Any, cast from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice import pymelcloud.ata_device as ata @@ -57,8 +57,8 @@ ATA_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATA_HVAC_MODE_LOOKUP.items()} ATW_ZONE_HVAC_MODE_LOOKUP = { - atw.ZONE_OPERATION_MODE_HEAT: HVACMode.HEAT, - atw.ZONE_OPERATION_MODE_COOL: HVACMode.COOL, + atw.ZONE_STATUS_HEAT: HVACMode.HEAT, + atw.ZONE_STATUS_COOL: HVACMode.COOL, } ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATW_ZONE_HVAC_MODE_LOOKUP.items()} @@ -236,7 +236,7 @@ class AtaDeviceClimate(MelCloudClimate): set_dict: dict[str, Any] = {} if ATTR_HVAC_MODE in kwargs: self._apply_set_hvac_mode( - kwargs.get(ATTR_HVAC_MODE, self.hvac_mode), set_dict + cast(HVACMode, kwargs.get(ATTR_HVAC_MODE, self.hvac_mode)), set_dict ) if ATTR_TEMPERATURE in kwargs: diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index f61ed412be1..a9440ad8300 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/melcloud", "iot_class": "cloud_polling", "loggers": ["pymelcloud"], - "requirements": ["pymelcloud==2.5.9"] + "requirements": ["python-melcloud==0.1.0"] } diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index 19ef0b76aad..8c168295e88 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -11,20 +11,20 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The Melcloud integration needs to re-authenticate your connection details", + "description": "The MELCloud integration needs to re-authenticate your connection details", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } }, "reconfigure": { - "title": "Reconfigure your MelCloud", + "title": "Reconfigure your MELCloud", "description": "Reconfigure the entry to obtain a new token, for your account: `{username}`.", "data": { "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "password": "Enter the (new) password for MelCloud." + "password": "Enter the (new) password for MELCloud." } } }, @@ -70,7 +70,7 @@ }, "deprecated_yaml_import_issue_cannot_connect": { "title": "The MELCloud YAML configuration import failed", - "description": "Configuring MELCloud using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to MELCloud works and restart Home Assistant to try again or remove the MELCloud YAML configuration from your configuration.yaml file and continue to [set up the integration](/config/integrations/dashboard/add?domain=melcoud) manually." + "description": "Configuring MELCloud using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to MELCloud works and restart Home Assistant to try again or remove the MELCloud YAML configuration from your configuration.yaml file and continue to [set up the integration](/config/integrations/dashboard/add?domain=melcoud) manually." } }, "entity": { diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py index de27da7a07f..8b6243d9daf 100644 --- a/homeassistant/components/met/coordinator.py +++ b/homeassistant/components/met/coordinator.py @@ -2,11 +2,10 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import timedelta import logging from random import randrange -from types import MappingProxyType from typing import Any, Self import metno @@ -41,7 +40,7 @@ class CannotConnect(HomeAssistantError): class MetWeatherData: """Keep data for Met.no weather entities.""" - def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: + def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]) -> None: """Initialise the weather entity data.""" self.hass = hass self._config = config diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index c4f9c8e6885..8d8317607be 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from homeassistant.components.weather import ( @@ -82,7 +82,7 @@ async def async_setup_entry( async_add_entities(entities) -def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> str: +def _calculate_unique_id(config: Mapping[str, Any], hourly: bool) -> str: """Calculate unique ID.""" name_appendix = "" if hourly: diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index 01917707bf7..62d7d21134c 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -1,8 +1,8 @@ """The met_eireann component.""" +from collections.abc import Mapping from datetime import timedelta import logging -from types import MappingProxyType from typing import Any, Self import meteireann @@ -74,7 +74,7 @@ class MetEireannWeatherData: """Keep data for Met Éireann weather entities.""" def __init__( - self, config: MappingProxyType[str, Any], weather_data: meteireann.WeatherData + self, config: Mapping[str, Any], weather_data: meteireann.WeatherData ) -> None: """Initialise the weather entity data.""" self._config = config diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 72706ccb70f..97bbd952740 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -1,7 +1,7 @@ """Support for Met Éireann weather service.""" +from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any, cast from homeassistant.components.weather import ( @@ -64,7 +64,7 @@ async def async_setup_entry( async_add_entities([MetEireannWeather(coordinator, config_entry.data)]) -def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> str: +def _calculate_unique_id(config: Mapping[str, Any], hourly: bool) -> str: """Calculate unique ID.""" name_appendix = "" if hourly: @@ -90,7 +90,7 @@ class MetEireannWeather( def __init__( self, coordinator: DataUpdateCoordinator[MetEireannWeatherData], - config: MappingProxyType[str, Any], + config: Mapping[str, Any], ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 5c4ada6b5f1..5f1d5269538 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert isinstance(department, str) return await hass.async_add_executor_job( - client.get_warning_current_phenomenoms, department, 0, True + client.get_warning_current_phenomenons, department, 0, True ) coordinator_forecast = DataUpdateCoordinator( diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 2230f43b754..382a56d50d7 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -40,7 +40,7 @@ ATTR_NEXT_RAIN_DT_REF = "forecast_time_ref" CONDITION_CLASSES: dict[str, list[str]] = { - ATTR_CONDITION_CLEAR_NIGHT: ["Nuit Claire", "Nuit claire"], + ATTR_CONDITION_CLEAR_NIGHT: ["Nuit Claire", "Nuit claire", "Ciel clair"], ATTR_CONDITION_CLOUDY: ["Très nuageux", "Couvert"], ATTR_CONDITION_FOG: [ "Brume ou bancs de brouillard", @@ -48,9 +48,10 @@ CONDITION_CLASSES: dict[str, list[str]] = { "Brouillard", "Brouillard givrant", "Bancs de Brouillard", + "Brouillard dense", ], ATTR_CONDITION_HAIL: ["Risque de grêle", "Risque de grèle"], - ATTR_CONDITION_LIGHTNING: ["Risque d'orages", "Orages"], + ATTR_CONDITION_LIGHTNING: ["Risque d'orages", "Orages", "Orage avec grêle"], ATTR_CONDITION_LIGHTNING_RAINY: [ "Pluie orageuses", "Pluies orageuses", @@ -62,6 +63,7 @@ CONDITION_CLASSES: dict[str, list[str]] = { "Éclaircies", "Eclaircies", "Peu nuageux", + "Variable", ], ATTR_CONDITION_POURING: ["Pluie forte"], ATTR_CONDITION_RAINY: [ @@ -74,6 +76,7 @@ CONDITION_CLASSES: dict[str, list[str]] = { "Pluie modérée", "Pluie / Averses", "Averses", + "Averses faibles", "Pluie", ], ATTR_CONDITION_SNOWY: [ @@ -81,6 +84,8 @@ CONDITION_CLASSES: dict[str, list[str]] = { "Neige", "Averses de neige", "Neige forte", + "Neige faible", + "Averses de neige faible", "Quelques flocons", ], ATTR_CONDITION_SNOWY_RAINY: ["Pluie et neige", "Pluie verglaçante"], diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 567788ec479..d82d0c3f91b 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteo_france", "iot_class": "cloud_polling", "loggers": ["meteofrance_api"], - "requirements": ["meteofrance-api==1.3.0"] + "requirements": ["meteofrance-api==1.4.0"] } diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index c29cc1ceda9..7333f7b0c19 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -7,7 +7,7 @@ from typing import Any from meteofrance_api.helpers import ( get_warning_text_status_from_indice_color, - readeable_phenomenoms_dict, + readable_phenomenons_dict, ) from meteofrance_api.model.forecast import Forecast from meteofrance_api.model.rain import Rain @@ -336,7 +336,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor[CurrentPhenomenons]): def extra_state_attributes(self): """Return the state attributes.""" return { - **readeable_phenomenoms_dict(self.coordinator.data.phenomenons_max_colors), + **readable_phenomenons_dict(self.coordinator.data.phenomenons_max_colors), } diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 67a56271c2b..e2df35f21f3 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -161,6 +161,11 @@ class MeteoFranceWeather( """Return the wind speed.""" return self.coordinator.data.current_forecast["wind"]["speed"] + @property + def native_wind_gust_speed(self): + """Return the wind gust speed.""" + return self.coordinator.data.current_forecast["wind"].get("gust") + @property def wind_bearing(self): """Return the wind bearing.""" diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index 169da7a0a18..6e508bd63d8 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -102,6 +102,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, icon="mdi:weather-windy", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key="rain", diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py new file mode 100644 index 00000000000..9b9ec81bea9 --- /dev/null +++ b/homeassistant/components/miele/__init__.py @@ -0,0 +1,90 @@ +"""The Miele integration.""" + +from __future__ import annotations + +from aiohttp import ClientError, ClientResponseError + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.FAN, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, + Platform.VACUUM, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool: + """Set up Miele from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + + session = OAuth2Session(hass, entry, implementation) + auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session) + try: + await auth.async_get_access_token() + except ClientResponseError as err: + if 400 <= err.status < 500: + 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( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from err + + # Setup MieleAPI and coordinator for data fetch + coordinator = MieleDataUpdateCoordinator(hass, auth) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + entry.async_create_background_task( + hass, + coordinator.api.listen_events( + data_callback=coordinator.callback_update_data, + actions_callback=coordinator.callback_update_actions, + ), + "pymiele event listener", + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: MieleConfigEntry, device_entry: dr.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.data.devices + ) diff --git a/homeassistant/components/miele/api.py b/homeassistant/components/miele/api.py new file mode 100644 index 00000000000..632314f405c --- /dev/null +++ b/homeassistant/components/miele/api.py @@ -0,0 +1,27 @@ +"""API for Miele bound to Home Assistant OAuth.""" + +from typing import cast + +from aiohttp import ClientSession +from pymiele import MIELE_API, AbstractAuth + +from homeassistant.helpers import config_entry_oauth2_flow + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Miele authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Miele auth.""" + super().__init__(websession, MIELE_API) + 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 cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/miele/application_credentials.py b/homeassistant/components/miele/application_credentials.py new file mode 100644 index 00000000000..d40ef765ce0 --- /dev/null +++ b/homeassistant/components/miele/application_credentials.py @@ -0,0 +1,21 @@ +"""Application credentials platform for the Miele integration.""" + +from pymiele import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "register_url": "https://www.miele.com/f/com/en/register_api.aspx", + } diff --git a/homeassistant/components/miele/binary_sensor.py b/homeassistant/components/miele/binary_sensor.py new file mode 100644 index 00000000000..b43bd86010e --- /dev/null +++ b/homeassistant/components/miele/binary_sensor.py @@ -0,0 +1,295 @@ +"""Binary sensor platform for Miele integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Final, cast + +from pymiele import MieleDevice + +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 AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import MieleAppliance +from .coordinator import MieleConfigEntry +from .entity import MieleEntity + +PARALLEL_UPDATES = 0 + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleBinarySensorDescription(BinarySensorEntityDescription): + """Class describing Miele binary sensor entities.""" + + value_fn: Callable[[MieleDevice], StateType] + + +@dataclass +class MieleBinarySensorDefinition: + """Class for defining binary sensor entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleBinarySensorDescription + + +BINARY_SENSOR_TYPES: Final[tuple[MieleBinarySensorDefinition, ...]] = ( + MieleBinarySensorDefinition( + types=( + MieleAppliance.DISH_WARMER, + MieleAppliance.DISHWASHER, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.FRIDGE, + MieleAppliance.MICROWAVE, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.OVEN, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.STEAM_OVEN, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + ), + description=MieleBinarySensorDescription( + key="state_signal_door", + value_fn=lambda value: value.state_signal_door, + device_class=BinarySensorDeviceClass.DOOR, + ), + ), + MieleBinarySensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.DISH_WARMER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleBinarySensorDescription( + key="state_signal_info", + value_fn=lambda value: value.state_signal_info, + device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="notification_active", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleBinarySensorDefinition( + types=( + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.DISH_WARMER, + MieleAppliance.DISHWASHER, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.FRIDGE, + MieleAppliance.HOB_HIGHLIGHT, + MieleAppliance.HOB_INDUCT_EXTR, + MieleAppliance.HOB_INDUCTION, + MieleAppliance.HOOD, + MieleAppliance.MICROWAVE, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.OVEN, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.STEAM_OVEN, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + ), + description=MieleBinarySensorDescription( + key="state_signal_failure", + value_fn=lambda value: value.state_signal_failure, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleBinarySensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.HOB_INDUCT_EXTR, + ), + description=MieleBinarySensorDescription( + key="state_full_remote_control", + translation_key="remote_control", + value_fn=lambda value: value.state_full_remote_control, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleBinarySensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.HOB_INDUCT_EXTR, + ), + description=MieleBinarySensorDescription( + key="state_smart_grid", + value_fn=lambda value: value.state_smart_grid, + translation_key="smart_grid", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleBinarySensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.HOB_INDUCT_EXTR, + ), + description=MieleBinarySensorDescription( + key="state_mobile_start", + value_fn=lambda value: value.state_mobile_start, + translation_key="mobile_start", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the binary sensor platform.""" + coordinator = config_entry.runtime_data + added_devices: set[str] = set() + + def _async_add_new_devices() -> None: + nonlocal added_devices + + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + async_add_entities( + MieleBinarySensor(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in BINARY_SENSOR_TYPES + if device_id in new_devices_set and device.device_type in definition.types + ) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() + + +class MieleBinarySensor(MieleEntity, BinarySensorEntity): + """Representation of a Binary Sensor.""" + + entity_description: MieleBinarySensorDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return cast(bool, self.entity_description.value_fn(self.device)) diff --git a/homeassistant/components/miele/button.py b/homeassistant/components/miele/button.py new file mode 100644 index 00000000000..4086c002743 --- /dev/null +++ b/homeassistant/components/miele/button.py @@ -0,0 +1,163 @@ +"""Platform for Miele button integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Final + +import aiohttp + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, PROCESS_ACTION, MieleActions, MieleAppliance +from .coordinator import MieleConfigEntry +from .entity import MieleEntity + +PARALLEL_UPDATES = 1 + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleButtonDescription(ButtonEntityDescription): + """Class describing Miele button entities.""" + + press_data: MieleActions + + +@dataclass +class MieleButtonDefinition: + """Class for defining button entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleButtonDescription + + +BUTTON_TYPES: Final[tuple[MieleButtonDefinition, ...]] = ( + MieleButtonDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.DIALOG_OVEN, + ), + description=MieleButtonDescription( + key="start", + translation_key="start", + press_data=MieleActions.START, + entity_registry_enabled_default=False, + ), + ), + MieleButtonDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.HOOD, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.DIALOG_OVEN, + ), + description=MieleButtonDescription( + key="stop", + translation_key="stop", + press_data=MieleActions.STOP, + entity_registry_enabled_default=False, + ), + ), + MieleButtonDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.WASHER_DRYER, + ), + description=MieleButtonDescription( + key="pause", + translation_key="pause", + press_data=MieleActions.PAUSE, + entity_registry_enabled_default=False, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the button platform.""" + coordinator = config_entry.runtime_data + added_devices: set[str] = set() + + def _async_add_new_devices() -> None: + nonlocal added_devices + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + async_add_entities( + MieleButton(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in BUTTON_TYPES + if device_id in new_devices_set and device.device_type in definition.types + ) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() + + +class MieleButton(MieleEntity, ButtonEntity): + """Representation of a Button.""" + + entity_description: MieleButtonDescription + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + + return ( + super().available + and self.entity_description.press_data in self.action.process_actions + ) + + async def async_press(self) -> None: + """Press the button.""" + _LOGGER.debug("Press: %s", self.entity_description.key) + try: + await self.api.send_action( + self._device_id, + {PROCESS_ACTION: self.entity_description.press_data}, + ) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex diff --git a/homeassistant/components/miele/climate.py b/homeassistant/components/miele/climate.py new file mode 100644 index 00000000000..85235322616 --- /dev/null +++ b/homeassistant/components/miele/climate.py @@ -0,0 +1,251 @@ +"""Platform for Miele integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Final, cast + +import aiohttp +from pymiele import MieleDevice + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityDescription, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DEVICE_TYPE_TAGS, DISABLED_TEMP_ENTITIES, DOMAIN, MieleAppliance +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .entity import MieleEntity + +PARALLEL_UPDATES = 1 + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleClimateDescription(ClimateEntityDescription): + """Class describing Miele climate entities.""" + + value_fn: Callable[[MieleDevice], StateType] + target_fn: Callable[[MieleDevice], StateType] + zone: int = 1 + + +@dataclass +class MieleClimateDefinition: + """Class for defining climate entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleClimateDescription + + +CLIMATE_TYPES: Final[tuple[MieleClimateDefinition, ...]] = ( + MieleClimateDefinition( + types=( + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleClimateDescription( + key="thermostat", + value_fn=( + lambda value: cast(int, value.state_temperatures[0].temperature) / 100.0 + ), + target_fn=( + lambda value: cast(int, value.state_target_temperature[0].temperature) + / 100.0 + ), + zone=1, + ), + ), + MieleClimateDefinition( + types=( + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleClimateDescription( + key="thermostat2", + value_fn=( + lambda value: cast(int, value.state_temperatures[1].temperature) / 100.0 + ), + target_fn=( + lambda value: cast(int, value.state_target_temperature[1].temperature) + / 100.0 + ), + translation_key="zone_2", + zone=2, + ), + ), + MieleClimateDefinition( + types=( + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleClimateDescription( + key="thermostat3", + value_fn=( + lambda value: cast(int, value.state_temperatures[2].temperature) / 100.0 + ), + target_fn=( + lambda value: cast(int, value.state_target_temperature[2].temperature) + / 100.0 + ), + translation_key="zone_3", + zone=3, + ), + ), +) + +ZONE1_DEVICES = { + MieleAppliance.FRIDGE: DEVICE_TYPE_TAGS[MieleAppliance.FRIDGE], + MieleAppliance.FRIDGE_FREEZER: DEVICE_TYPE_TAGS[MieleAppliance.FRIDGE], + MieleAppliance.FREEZER: DEVICE_TYPE_TAGS[MieleAppliance.FREEZER], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the climate platform.""" + coordinator = config_entry.runtime_data + added_devices: set[str] = set() + + def _async_add_new_devices() -> None: + nonlocal added_devices + + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + async_add_entities( + MieleClimate(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in CLIMATE_TYPES + if ( + device_id in new_devices_set + and device.device_type in definition.types + and ( + definition.description.value_fn(device) + not in DISABLED_TEMP_ENTITIES + ) + ) + ) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() + + +class MieleClimate(MieleEntity, ClimateEntity): + """Representation of a climate entity.""" + + entity_description: MieleClimateDescription + _attr_precision = PRECISION_WHOLE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 1.0 + _attr_hvac_modes = [HVACMode.COOL] + _attr_hvac_mode = HVACMode.COOL + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return cast(float, self.entity_description.value_fn(self.device)) + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleClimateDescription, + ) -> None: + """Initialize the climate entity.""" + super().__init__(coordinator, device_id, description) + + t_key = self.entity_description.translation_key + + if description.zone == 1: + t_key = ZONE1_DEVICES.get( + cast(MieleAppliance, self.device.device_type), "zone_1" + ) + if self.device.device_type in ( + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + ): + self._attr_name = None + + if description.zone == 2: + if self.device.device_type in ( + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.WINE_CABINET_FREEZER, + ): + t_key = DEVICE_TYPE_TAGS[MieleAppliance.FREEZER] + else: + t_key = "zone_2" + elif description.zone == 3: + t_key = "zone_3" + + self._attr_translation_key = t_key + self._attr_unique_id = f"{device_id}-{description.key}-{description.zone}" + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + + return cast(float | None, self.entity_description.target_fn(self.device)) + + @property + def max_temp(self) -> float: + """Return the maximum target temperature.""" + return cast( + float, + self.action.target_temperature[self.entity_description.zone - 1].max, + ) + + @property + def min_temp(self) -> float: + """Return the minimum target temperature.""" + return cast( + float, + self.action.target_temperature[self.entity_description.zone - 1].min, + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + try: + await self.api.set_target_temperature( + self._device_id, temperature, self.entity_description.zone + ) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from err + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/miele/config_flow.py b/homeassistant/components/miele/config_flow.py new file mode 100644 index 00000000000..d3c7dbba12b --- /dev/null +++ b/homeassistant/components/miele/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for Miele.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, +) +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Miele OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + # "vg" is mandatory but the value doesn't seem to matter + return { + "vg": "sv-SE", + } + + 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.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + ) + + 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.""" + + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data=data + ) + return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py new file mode 100644 index 00000000000..338e8138352 --- /dev/null +++ b/homeassistant/components/miele/const.py @@ -0,0 +1,1145 @@ +"""Constants for the Miele integration.""" + +from enum import IntEnum + +from pymiele import MieleEnum + +DOMAIN = "miele" +MANUFACTURER = "Miele" + +ACTIONS = "actions" +POWER_ON = "powerOn" +POWER_OFF = "powerOff" +PROCESS_ACTION = "processAction" +PROGRAM_ID = "programId" +VENTILATION_STEP = "ventilationStep" +TARGET_TEMPERATURE = "targetTemperature" +AMBIENT_LIGHT = "ambientLight" +LIGHT = "light" +LIGHT_ON = 1 +LIGHT_OFF = 2 + +DISABLED_TEMP_ENTITIES = ( + -32768 / 100, + -32766 / 100, +) + + +class MieleAppliance(IntEnum): + """Define appliance types.""" + + WASHING_MACHINE = 1 + TUMBLE_DRYER = 2 + WASHING_MACHINE_SEMI_PROFESSIONAL = 3 + TUMBLE_DRYER_SEMI_PROFESSIONAL = 4 + WASHING_MACHINE_PROFESSIONAL = 5 + DRYER_PROFESSIONAL = 6 + DISHWASHER = 7 + DISHWASHER_SEMI_PROFESSIONAL = 8 + DISHWASHER_PROFESSIONAL = 9 + OVEN = 12 + OVEN_MICROWAVE = 13 + HOB_HIGHLIGHT = 14 + STEAM_OVEN = 15 + MICROWAVE = 16 + COFFEE_SYSTEM = 17 + HOOD = 18 + FRIDGE = 19 + FREEZER = 20 + FRIDGE_FREEZER = 21 + ROBOT_VACUUM_CLEANER = 23 + WASHER_DRYER = 24 + DISH_WARMER = 25 + HOB_INDUCTION = 27 + STEAM_OVEN_COMBI = 31 + WINE_CABINET = 32 + WINE_CONDITIONING_UNIT = 33 + WINE_STORAGE_CONDITIONING_UNIT = 34 + STEAM_OVEN_MICRO = 45 + DIALOG_OVEN = 67 + WINE_CABINET_FREEZER = 68 + STEAM_OVEN_MK2 = 73 + HOB_INDUCT_EXTR = 74 + + +DEVICE_TYPE_TAGS = { + MieleAppliance.WASHING_MACHINE: "washing_machine", + MieleAppliance.TUMBLE_DRYER: "tumble_dryer", + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL: "washing_machine", + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: "tumble_dryer", + MieleAppliance.WASHING_MACHINE_PROFESSIONAL: "washing_machine", + MieleAppliance.DRYER_PROFESSIONAL: "tumble_dryer", + MieleAppliance.DISHWASHER: "dishwasher", + MieleAppliance.DISHWASHER_SEMI_PROFESSIONAL: "dishwasher", + MieleAppliance.DISHWASHER_PROFESSIONAL: "dishwasher", + MieleAppliance.OVEN: "oven", + MieleAppliance.OVEN_MICROWAVE: "oven_microwave", + MieleAppliance.HOB_HIGHLIGHT: "hob", + MieleAppliance.STEAM_OVEN: "steam_oven", + MieleAppliance.MICROWAVE: "microwave", + MieleAppliance.COFFEE_SYSTEM: "coffee_system", + MieleAppliance.HOOD: "hood", + MieleAppliance.FRIDGE: "refrigerator", + MieleAppliance.FREEZER: "freezer", + MieleAppliance.FRIDGE_FREEZER: "fridge_freezer", + MieleAppliance.ROBOT_VACUUM_CLEANER: "robot_vacuum_cleaner", + MieleAppliance.WASHER_DRYER: "washer_dryer", + MieleAppliance.DISH_WARMER: "warming_drawer", + MieleAppliance.HOB_INDUCTION: "hob", + MieleAppliance.STEAM_OVEN_COMBI: "steam_oven_combi", + MieleAppliance.WINE_CABINET: "wine_cabinet", + MieleAppliance.WINE_CONDITIONING_UNIT: "wine_conditioning_unit", + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT: "wine_unit", + MieleAppliance.STEAM_OVEN_MICRO: "steam_oven_micro", + MieleAppliance.DIALOG_OVEN: "dialog_oven", + MieleAppliance.WINE_CABINET_FREEZER: "wine_cabinet_freezer", + MieleAppliance.STEAM_OVEN_MK2: "steam_oven", + MieleAppliance.HOB_INDUCT_EXTR: "hob_extraction", +} + + +class StateStatus(IntEnum): + """Define appliance states.""" + + RESERVED = 0 + OFF = 1 + ON = 2 + PROGRAMMED = 3 + WAITING_TO_START = 4 + IN_USE = 5 + PAUSE = 6 + PROGRAM_ENDED = 7 + FAILURE = 8 + PROGRAM_INTERRUPTED = 9 + IDLE = 10 + RINSE_HOLD = 11 + SERVICE = 12 + SUPERFREEZING = 13 + SUPERCOOLING = 14 + SUPERHEATING = 15 + SUPERCOOLING_SUPERFREEZING = 146 + AUTOCLEANING = 147 + NOT_CONNECTED = 255 + + +STATE_STATUS_TAGS = { + StateStatus.OFF: "off", + StateStatus.ON: "on", + StateStatus.PROGRAMMED: "programmed", + StateStatus.WAITING_TO_START: "waiting_to_start", + StateStatus.IN_USE: "in_use", + StateStatus.PAUSE: "pause", + StateStatus.PROGRAM_ENDED: "program_ended", + StateStatus.FAILURE: "failure", + StateStatus.PROGRAM_INTERRUPTED: "program_interrupted", + StateStatus.IDLE: "idle", + StateStatus.RINSE_HOLD: "rinse_hold", + StateStatus.SERVICE: "service", + StateStatus.SUPERFREEZING: "superfreezing", + StateStatus.SUPERCOOLING: "supercooling", + StateStatus.SUPERHEATING: "superheating", + StateStatus.SUPERCOOLING_SUPERFREEZING: "supercooling_superfreezing", + StateStatus.AUTOCLEANING: "autocleaning", + StateStatus.NOT_CONNECTED: "not_connected", +} + + +class MieleActions(IntEnum): + """Define appliance actions.""" + + START = 1 + STOP = 2 + PAUSE = 3 + START_SUPERFREEZE = 4 + STOP_SUPERFREEZE = 5 + START_SUPERCOOL = 6 + STOP_SUPERCOOL = 7 + + +# Possible actions +PROCESS_ACTIONS = { + "start": MieleActions.START, + "stop": MieleActions.STOP, + "pause": MieleActions.PAUSE, + "start_superfreezing": MieleActions.START_SUPERFREEZE, + "stop_superfreezing": MieleActions.STOP_SUPERFREEZE, + "start_supercooling": MieleActions.START_SUPERCOOL, + "stop_supercooling": MieleActions.STOP_SUPERCOOL, +} + +STATE_PROGRAM_PHASE_WASHING_MACHINE = { + 0: "not_running", # Returned by the API when the machine is switched off entirely. + 256: "not_running", + 257: "pre_wash", + 258: "soak", + 259: "pre_wash", + 260: "main_wash", + 261: "rinse", + 262: "rinse_hold", + 263: "cleaning", + 264: "cooling_down", + 265: "drain", + 266: "spin", + 267: "anti_crease", + 268: "finished", + 269: "venting", + 270: "starch_stop", + 271: "freshen_up_and_moisten", + 272: "steam_smoothing", + 279: "hygiene", + 280: "drying", + 285: "disinfecting", + 295: "steam_smoothing", + 65535: "not_running", # Seems to be default for some devices. +} + +STATE_PROGRAM_PHASE_TUMBLE_DRYER = { + 0: "not_running", + 512: "not_running", + 513: "program_running", + 514: "drying", + 515: "machine_iron", + 516: "hand_iron_2", + 517: "normal", + 518: "normal_plus", + 519: "cooling_down", + 520: "hand_iron_1", + 521: "anti_crease", + 522: "finished", + 523: "extra_dry", + 524: "hand_iron", + 526: "moisten", + 527: "thermo_spin", + 528: "timed_drying", + 529: "warm_air", + 530: "steam_smoothing", + 531: "comfort_cooling", + 532: "rinse_out_lint", + 533: "rinses", + 535: "not_running", + 534: "smoothing", + 536: "not_running", + 537: "not_running", + 538: "slightly_dry", + 539: "safety_cooling", + 65535: "not_running", +} + +STATE_PROGRAM_PHASE_DISHWASHER = { + 1792: "not_running", + 1793: "reactivating", + 1794: "pre_dishwash", + 1795: "main_dishwash", + 1796: "rinse", + 1797: "interim_rinse", + 1798: "final_rinse", + 1799: "drying", + 1800: "finished", + 1801: "pre_dishwash", + 65535: "not_running", +} + +STATE_PROGRAM_PHASE_OVEN = { + 0: "not_running", + 3073: "heating_up", + 3074: "process_running", + 3078: "process_finished", + 3084: "energy_save", + 65535: "not_running", +} +STATE_PROGRAM_PHASE_WARMING_DRAWER = { + 0: "not_running", + 3073: "heating_up", + 3075: "door_open", + 3094: "keeping_warm", + 3088: "cooling_down", + 65535: "not_running", +} +STATE_PROGRAM_PHASE_MICROWAVE = { + 0: "not_running", + 3329: "heating", + 3330: "process_running", + 3334: "process_finished", + 3340: "energy_save", + 65535: "not_running", +} +STATE_PROGRAM_PHASE_COFFEE_SYSTEM = { + # Coffee system + 3073: "heating_up", + 4352: "not_running", + 4353: "espresso", + 4355: "milk_foam", + 4361: "dispensing", + 4369: "pre_brewing", + 4377: "grinding", + 4401: "2nd_grinding", + 4354: "hot_milk", + 4393: "2nd_pre_brewing", + 4385: "2nd_espresso", + 4404: "dispensing", + 4405: "rinse", + 65535: "not_running", +} +STATE_PROGRAM_PHASE_ROBOT_VACUUM_CLEANER = { + 0: "not_running", + 5889: "vacuum_cleaning", + 5890: "returning", + 5891: "vacuum_cleaning_paused", + 5892: "going_to_target_area", + 5893: "wheel_lifted", # F1 + 5894: "dirty_sensors", # F2 + 5895: "dust_box_missing", # F3 + 5896: "blocked_drive_wheels", # F4 + 5897: "blocked_brushes", # F5 + 5898: "motor_overload", # F6 + 5899: "internal_fault", # F7 + 5900: "blocked_front_wheel", # F8 + 5903: "docked", + 5904: "docked", + 5910: "remote_controlled", + 65535: "not_running", +} +STATE_PROGRAM_PHASE_STEAM_OVEN = { + 0: "not_running", + 3863: "steam_reduction", + 7938: "process_running", + 7939: "waiting_for_start", + 7940: "heating_up_phase", + 7942: "process_finished", + 65535: "not_running", +} + +STATE_PROGRAM_PHASE: dict[int, dict[int, str]] = { + MieleAppliance.WASHING_MACHINE: STATE_PROGRAM_PHASE_WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_PROFESSIONAL: STATE_PROGRAM_PHASE_WASHING_MACHINE, + MieleAppliance.TUMBLE_DRYER: STATE_PROGRAM_PHASE_TUMBLE_DRYER, + MieleAppliance.DRYER_PROFESSIONAL: STATE_PROGRAM_PHASE_TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_TUMBLE_DRYER, + MieleAppliance.WASHER_DRYER: STATE_PROGRAM_PHASE_WASHING_MACHINE + | STATE_PROGRAM_PHASE_TUMBLE_DRYER, + MieleAppliance.DISHWASHER: STATE_PROGRAM_PHASE_DISHWASHER, + MieleAppliance.DISHWASHER_SEMI_PROFESSIONAL: STATE_PROGRAM_PHASE_DISHWASHER, + MieleAppliance.DISHWASHER_PROFESSIONAL: STATE_PROGRAM_PHASE_DISHWASHER, + MieleAppliance.OVEN: STATE_PROGRAM_PHASE_OVEN, + MieleAppliance.OVEN_MICROWAVE: STATE_PROGRAM_PHASE_MICROWAVE, + MieleAppliance.STEAM_OVEN: STATE_PROGRAM_PHASE_STEAM_OVEN, + MieleAppliance.STEAM_OVEN_COMBI: STATE_PROGRAM_PHASE_OVEN + | STATE_PROGRAM_PHASE_STEAM_OVEN, + MieleAppliance.STEAM_OVEN_MICRO: STATE_PROGRAM_PHASE_MICROWAVE + | STATE_PROGRAM_PHASE_STEAM_OVEN, + MieleAppliance.STEAM_OVEN_MK2: STATE_PROGRAM_PHASE_OVEN + | STATE_PROGRAM_PHASE_STEAM_OVEN, + MieleAppliance.DIALOG_OVEN: STATE_PROGRAM_PHASE_OVEN, + MieleAppliance.MICROWAVE: STATE_PROGRAM_PHASE_MICROWAVE, + MieleAppliance.COFFEE_SYSTEM: STATE_PROGRAM_PHASE_COFFEE_SYSTEM, + MieleAppliance.ROBOT_VACUUM_CLEANER: STATE_PROGRAM_PHASE_ROBOT_VACUUM_CLEANER, + MieleAppliance.DISH_WARMER: STATE_PROGRAM_PHASE_WARMING_DRAWER, +} + + +class StateProgramType(MieleEnum): + """Defines program types.""" + + normal_operation_mode = 0 + own_program = 1 + automatic_program = 2 + cleaning_care_program = 3 + maintenance_program = 4 + missing2none = -9999 + + +class StateDryingStep(MieleEnum): + """Defines drying steps.""" + + extra_dry = 0 + normal_plus = 1 + normal = 2 + slightly_dry = 3 + hand_iron_1 = 4 + hand_iron_2 = 5 + machine_iron = 6 + smoothing = 7 + missing2none = -9999 + + +WASHING_MACHINE_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Extrapolated from other device types. + 0: "no_program", # Returned by the API when no program is selected. + 1: "cottons", + 3: "minimum_iron", + 4: "delicates", + 8: "woollens", + 9: "silks", + 17: "starch", + 18: "rinse", + 21: "drain_spin", + 22: "curtains", + 23: "shirts", + 24: "denim", + 27: "proofing", + 29: "sportswear", + 31: "automatic_plus", + 37: "outerwear", + 39: "pillows", + 45: "cool_air", # washer-dryer + 46: "warm_air", # washer-dryer + 48: "rinse_out_lint", # washer-dryer + 50: "dark_garments", + 52: "separate_rinse_starch", + 53: "first_wash", + 69: "cottons_hygiene", + 75: "steam_care", # washer-dryer + 76: "freshen_up", # washer-dryer + 77: "trainers", + 91: "clean_machine", + 95: "down_duvets", + 122: "express_20", + 123: "denim", + 129: "down_filled_items", + 133: "cottons_eco", + 146: "quick_power_wash", + 190: "eco_40_60", +} + +DISHWASHER_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Sometimes returned by the API when the machine is switched off entirely, in conjunection with program phase 65535. + 0: "no_program", # Returned by the API when the machine is switched off entirely. + 1: "intensive", + 2: "maintenance", + 3: "eco", + 6: "automatic", + 7: "automatic", + 9: "solar_save", + 10: "gentle", + 11: "extra_quiet", + 12: "hygiene", + 13: "quick_power_wash", + 14: "pasta_paela", + 17: "tall_items", + 19: "glasses_warm", + 26: "intensive", + 27: "maintenance", # or maintenance_program? + 28: "eco", + 30: "normal", + 31: "automatic", + 32: "automatic", # sources disagree on ID + 34: "solar_save", + 35: "gentle", + 36: "extra_quiet", + 37: "hygiene", + 38: "quick_power_wash", + 42: "tall_items", + 44: "power_wash", +} +TUMBLE_DRYER_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Extrapolated from other device types. + 0: "no_program", # Extrapolated from other device types + 2: "cottons", + 3: "minimum_iron", + 4: "woollens_handcare", + 5: "delicates", + 6: "warm_air", + 8: "express", + 10: "automatic_plus", + 20: "cottons", + 23: "cottons_hygiene", + 30: "minimum_iron", + 31: "bed_linen", + 40: "woollens_handcare", + 50: "delicates", + 60: "warm_air", + 66: "eco", + 70: "cool_air", + 80: "express", + 90: "cottons", + 100: "gentle_smoothing", + 120: "proofing", + 130: "denim", + 131: "gentle_denim", + 150: "sportswear", + 160: "outerwear", + 170: "silks_handcare", + 190: "standard_pillows", + 220: "basket_program", + 240: "smoothing", + 99001: "steam_smoothing", + 99002: "bed_linen", + 99003: "cottons_eco", + 99004: "shirts", + 99005: "large_pillows", +} + +OVEN_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Extrapolated from other device types. + 0: "no_program", # Extrapolated from other device types + 1: "defrost", + 6: "eco_fan_heat", + 7: "auto_roast", + 10: "full_grill", + 11: "economy_grill", + 13: "fan_plus", + 14: "intensive_bake", + 19: "microwave", + 24: "conventional_heat", + 25: "top_heat", + 29: "fan_grill", + 31: "bottom_heat", + 35: "moisture_plus_auto_roast", + 40: "moisture_plus_fan_plus", + 48: "moisture_plus_auto_roast", + 49: "moisture_plus_fan_plus", + 50: "moisture_plus_intensive_bake", + 51: "moisture_plus_conventional_heat", + 74: "moisture_plus_intensive_bake", + 76: "moisture_plus_conventional_heat", + 323: "pyrolytic", + 326: "descale", + 335: "shabbat_program", + 336: "yom_tov", + 356: "defrost", + 357: "drying", + 358: "heat_crockery", + 360: "low_temperature_cooking", + 361: "steam_cooking", + 362: "keeping_warm", + 512: "1_tray", + 513: "2_trays", + 529: "baking_tray", + 554: "baiser_one_large", + 555: "baiser_several_small", + 556: "lemon_meringue_pie", + 557: "viennese_apple_strudel", + 621: "prove_15_min", + 622: "prove_30_min", + 623: "prove_45_min", + 99001: "steam_bake", + 17003: "no_program", +} +DISH_WARMER_PROGRAM_ID: dict[int, str] = { + -1: "no_program", + 0: "no_program", + 1: "warm_cups_glasses", + 2: "warm_dishes_plates", + 3: "keep_warm", + 4: "slow_roasting", +} +ROBOT_VACUUM_CLEANER_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Extrapolated from other device types + 0: "no_program", # Extrapolated from other device types + 1: "auto", + 2: "spot", + 3: "turbo", + 4: "silent", +} +COFFEE_SYSTEM_PROGRAM_ID: dict[int, str] = { + -1: "no_program", # Extrapolated from other device types + 0: "no_program", # Extrapolated from other device types + 16016: "appliance_settings", # display brightness + 16018: "appliance_settings", # volume + 16019: "appliance_settings", # buttons volume + 16020: "appliance_settings", # child lock + 16021: "appliance_settings", # water hardness + 16027: "appliance_settings", # welcome sound + 16033: "appliance_settings", # connection status + 16035: "appliance_settings", # remote control + 16037: "appliance_settings", # remote update + 17004: "check_appliance", + # profile 1 + 24000: "ristretto", + 24001: "espresso", + 24002: "coffee", + 24003: "long_coffee", + 24004: "cappuccino", + 24005: "cappuccino_italiano", + 24006: "latte_macchiato", + 24007: "espresso_macchiato", + 24008: "cafe_au_lait", + 24009: "caffe_latte", + 24012: "flat_white", + 24013: "very_hot_water", + 24014: "hot_water", + 24015: "hot_milk", + 24016: "milk_foam", + 24017: "black_tea", + 24018: "herbal_tea", + 24019: "fruit_tea", + 24020: "green_tea", + 24021: "white_tea", + 24022: "japanese_tea", + # profile 2 + 24032: "ristretto", + 24033: "espresso", + 24034: "coffee", + 24035: "long_coffee", + 24036: "cappuccino", + 24037: "cappuccino_italiano", + 24038: "latte_macchiato", + 24039: "espresso_macchiato", + 24040: "cafe_au_lait", + 24041: "caffe_latte", + 24044: "flat_white", + 24045: "very_hot_water", + 24046: "hot_water", + 24047: "hot_milk", + 24048: "milk_foam", + 24049: "black_tea", + 24050: "herbal_tea", + 24051: "fruit_tea", + 24052: "green_tea", + 24053: "white_tea", + 24054: "japanese_tea", + # profile 3 + 24064: "ristretto", + 24065: "espresso", + 24066: "coffee", + 24067: "long_coffee", + 24068: "cappuccino", + 24069: "cappuccino_italiano", + 24070: "latte_macchiato", + 24071: "espresso_macchiato", + 24072: "cafe_au_lait", + 24073: "caffe_latte", + 24076: "flat_white", + 24077: "very_hot_water", + 24078: "hot_water", + 24079: "hot_milk", + 24080: "milk_foam", + 24081: "black_tea", + 24082: "herbal_tea", + 24083: "fruit_tea", + 24084: "green_tea", + 24085: "white_tea", + 24086: "japanese_tea", + # profile 4 + 24096: "ristretto", + 24097: "espresso", + 24098: "coffee", + 24099: "long_coffee", + 24100: "cappuccino", + 24101: "cappuccino_italiano", + 24102: "latte_macchiato", + 24103: "espresso_macchiato", + 24104: "cafe_au_lait", + 24105: "caffe_latte", + 24108: "flat_white", + 24109: "very_hot_water", + 24110: "hot_water", + 24111: "hot_milk", + 24112: "milk_foam", + 24113: "black_tea", + 24114: "herbal_tea", + 24115: "fruit_tea", + 24116: "green_tea", + 24117: "white_tea", + 24118: "japanese_tea", + # profile 5 + 24128: "ristretto", + 24129: "espresso", + 24130: "coffee", + 24131: "long_coffee", + 24132: "cappuccino", + 24133: "cappuccino_italiano", + 24134: "latte_macchiato", + 24135: "espresso_macchiato", + 24136: "cafe_au_lait", + 24137: "caffe_latte", + 24140: "flat_white", + 24141: "very_hot_water", + 24142: "hot_water", + 24143: "hot_milk", + 24144: "milk_foam", + 24145: "black_tea", + 24146: "herbal_tea", + 24147: "fruit_tea", + 24148: "green_tea", + 24149: "white_tea", + 24150: "japanese_tea", + # special programs + 24400: "coffee_pot", + 24407: "barista_assistant", + # machine settings menu + 24500: "appliance_settings", # total dispensed + 24502: "appliance_settings", # lights appliance on + 24503: "appliance_settings", # lights appliance off + 24504: "appliance_settings", # turn off lights after + 24506: "appliance_settings", # altitude + 24513: "appliance_settings", # performance mode + 24516: "appliance_settings", # turn off after + 24537: "appliance_settings", # advanced mode + 24542: "appliance_settings", # tea timer + 24549: "appliance_settings", # total coffee dispensed + 24550: "appliance_settings", # total tea dispensed + 24551: "appliance_settings", # total ristretto + 24552: "appliance_settings", # total cappuccino + 24553: "appliance_settings", # total espresso + 24554: "appliance_settings", # total coffee + 24555: "appliance_settings", # total long coffee + 24556: "appliance_settings", # total italian cappuccino + 24557: "appliance_settings", # total latte macchiato + 24558: "appliance_settings", # total caffe latte + 24560: "appliance_settings", # total espresso macchiato + 24562: "appliance_settings", # total flat white + 24563: "appliance_settings", # total coffee with milk + 24564: "appliance_settings", # total black tea + 24565: "appliance_settings", # total herbal tea + 24566: "appliance_settings", # total fruit tea + 24567: "appliance_settings", # total green tea + 24568: "appliance_settings", # total white tea + 24569: "appliance_settings", # total japanese tea + 24571: "appliance_settings", # total milk foam + 24572: "appliance_settings", # total hot milk + 24573: "appliance_settings", # total hot water + 24574: "appliance_settings", # total very hot water + 24575: "appliance_settings", # counter to descaling + 24576: "appliance_settings", # counter to brewing unit degreasing + # maintenance + 24750: "appliance_rinse", + 24751: "descaling", + 24753: "brewing_unit_degrease", + 24754: "milk_pipework_rinse", + 24759: "appliance_rinse", + 24773: "appliance_rinse", + 24787: "appliance_rinse", + 24788: "appliance_rinse", + 24789: "milk_pipework_clean", + # profiles settings menu + 24800: "appliance_settings", # add profile + 24801: "appliance_settings", # ask profile settings + 24813: "appliance_settings", # modify profile name +} + +STEAM_OVEN_MICRO_PROGRAM_ID: dict[int, str] = { + 8: "steam_cooking", + 19: "microwave", + 53: "popcorn", + 54: "quick_mw", + 72: "sous_vide", + 75: "eco_steam_cooking", + 77: "rapid_steam_cooking", + 326: "descale", + 330: "menu_cooking", + 2018: "reheating_with_steam", + 2019: "defrosting_with_steam", + 2020: "blanching", + 2021: "bottling", + 2022: "sterilize_crockery", + 2023: "prove_dough", + 2027: "soak", + 2029: "reheating_with_microwave", + 2030: "defrosting_with_microwave", + 2031: "artichokes_small", + 2032: "artichokes_medium", + 2033: "artichokes_large", + 2034: "eggplant_sliced", + 2035: "eggplant_diced", + 2036: "cauliflower_whole_small", + 2039: "cauliflower_whole_medium", + 2042: "cauliflower_whole_large", + 2046: "cauliflower_florets_small", + 2048: "cauliflower_florets_medium", + 2049: "cauliflower_florets_large", + 2051: "green_beans_whole", + 2052: "green_beans_cut", + 2053: "yellow_beans_whole", + 2054: "yellow_beans_cut", + 2055: "broad_beans", + 2056: "common_beans", + 2057: "runner_beans_whole", + 2058: "runner_beans_pieces", + 2059: "runner_beans_sliced", + 2060: "broccoli_whole_small", + 2061: "broccoli_whole_medium", + 2062: "broccoli_whole_large", + 2064: "broccoli_florets_small", + 2066: "broccoli_florets_medium", + 2068: "broccoli_florets_large", + 2069: "endive_halved", + 2070: "endive_quartered", + 2071: "endive_strips", + 2072: "chinese_cabbage_cut", + 2073: "peas", + 2074: "fennel_halved", + 2075: "fennel_quartered", + 2076: "fennel_strips", + 2077: "kale_cut", + 2080: "potatoes_in_the_skin_waxy_small_steam_cooking", + 2081: "potatoes_in_the_skin_waxy_small_rapid_steam_cooking", + 2083: "potatoes_in_the_skin_waxy_medium_steam_cooking", + 2084: "potatoes_in_the_skin_waxy_medium_rapid_steam_cooking", + 2086: "potatoes_in_the_skin_waxy_large_steam_cooking", + 2087: "potatoes_in_the_skin_waxy_large_rapid_steam_cooking", + 2088: "potatoes_in_the_skin_floury_small", + 2091: "potatoes_in_the_skin_floury_medium", + 2094: "potatoes_in_the_skin_floury_large", + 2097: "potatoes_in_the_skin_mainly_waxy_small", + 2100: "potatoes_in_the_skin_mainly_waxy_medium", + 2103: "potatoes_in_the_skin_mainly_waxy_large", + 2106: "potatoes_waxy_whole_small", + 2109: "potatoes_waxy_whole_medium", + 2112: "potatoes_waxy_whole_large", + 2115: "potatoes_waxy_halved", + 2116: "potatoes_waxy_quartered", + 2117: "potatoes_waxy_diced", + 2118: "potatoes_mainly_waxy_small", + 2119: "potatoes_mainly_waxy_medium", + 2120: "potatoes_mainly_waxy_large", + 2121: "potatoes_mainly_waxy_halved", + 2122: "potatoes_mainly_waxy_quartered", + 2123: "potatoes_mainly_waxy_diced", + 2124: "potatoes_floury_whole_small", + 2125: "potatoes_floury_whole_medium", + 2126: "potatoes_floury_whole_large", + 2127: "potatoes_floury_halved", + 2128: "potatoes_floury_quartered", + 2129: "potatoes_floury_diced", + 2130: "german_turnip_sliced", + 2131: "german_turnip_cut_into_batons", + 2132: "german_turnip_diced", + 2133: "pumpkin_diced", + 2134: "corn_on_the_cob", + 2135: "mangel_cut", + 2136: "bunched_carrots_whole_small", + 2137: "bunched_carrots_whole_medium", + 2138: "bunched_carrots_whole_large", + 2139: "bunched_carrots_halved", + 2140: "bunched_carrots_quartered", + 2141: "bunched_carrots_diced", + 2142: "bunched_carrots_cut_into_batons", + 2143: "bunched_carrots_sliced", + 2144: "parisian_carrots_small", + 2145: "parisian_carrots_medium", + 2146: "parisian_carrots_large", + 2147: "carrots_whole_small", + 2148: "carrots_whole_medium", + 2149: "carrots_whole_large", + 2150: "carrots_halved", + 2151: "carrots_quartered", + 2152: "carrots_diced", + 2153: "carrots_cut_into_batons", + 2155: "carrots_sliced", + 2156: "pepper_halved", + 2157: "pepper_quartered", + 2158: "pepper_strips", + 2159: "pepper_diced", + 2160: "parsnip_sliced", + 2161: "parsnip_diced", + 2162: "parsnip_cut_into_batons", + 2163: "parsley_root_sliced", + 2164: "parsley_root_diced", + 2165: "parsley_root_cut_into_batons", + 2166: "leek_pieces", + 2167: "leek_rings", + 2168: "romanesco_whole_small", + 2169: "romanesco_whole_medium", + 2170: "romanesco_whole_large", + 2171: "romanesco_florets_small", + 2172: "romanesco_florets_medium", + 2173: "romanesco_florets_large", + 2175: "brussels_sprout", + 2176: "beetroot_whole_small", + 2177: "beetroot_whole_medium", + 2178: "beetroot_whole_large", + 2179: "red_cabbage_cut", + 2180: "black_salsify_thin", + 2181: "black_salsify_medium", + 2182: "black_salsify_thick", + 2183: "celery_pieces", + 2184: "celery_sliced", + 2185: "celeriac_sliced", + 2186: "celeriac_cut_into_batons", + 2187: "celeriac_diced", + 2188: "white_asparagus_thin", + 2189: "white_asparagus_medium", + 2190: "white_asparagus_thick", + 2192: "green_asparagus_thin", + 2194: "green_asparagus_medium", + 2196: "green_asparagus_thick", + 2197: "spinach", + 2198: "pointed_cabbage_cut", + 2199: "yam_halved", + 2200: "yam_quartered", + 2201: "yam_strips", + 2202: "swede_diced", + 2203: "swede_cut_into_batons", + 2204: "teltow_turnip_sliced", + 2205: "teltow_turnip_diced", + 2206: "jerusalem_artichoke_sliced", + 2207: "jerusalem_artichoke_diced", + 2208: "green_cabbage_cut", + 2209: "savoy_cabbage_cut", + 2210: "courgette_sliced", + 2211: "courgette_diced", + 2212: "snow_pea", + 2214: "perch_whole", + 2215: "perch_fillet_2_cm", + 2216: "perch_fillet_3_cm", + 2217: "gilt_head_bream_whole", + 2220: "gilt_head_bream_fillet", + 2221: "codfish_piece", + 2222: "codfish_fillet", + 2224: "trout", + 2225: "pike_fillet", + 2226: "pike_piece", + 2227: "halibut_fillet_2_cm", + 2230: "halibut_fillet_3_cm", + 2231: "codfish_fillet", + 2232: "codfish_piece", + 2233: "carp", + 2234: "salmon_fillet_2_cm", + 2235: "salmon_fillet_3_cm", + 2238: "salmon_steak_2_cm", + 2239: "salmon_steak_3_cm", + 2240: "salmon_piece", + 2241: "salmon_trout", + 2244: "iridescent_shark_fillet", + 2245: "red_snapper_fillet_2_cm", + 2248: "red_snapper_fillet_3_cm", + 2249: "redfish_fillet_2_cm", + 2250: "redfish_fillet_3_cm", + 2251: "redfish_piece", + 2252: "char", + 2253: "plaice_whole_2_cm", + 2254: "plaice_whole_3_cm", + 2255: "plaice_whole_4_cm", + 2256: "plaice_fillet_1_cm", + 2259: "plaice_fillet_2_cm", + 2260: "coalfish_fillet_2_cm", + 2261: "coalfish_fillet_3_cm", + 2262: "coalfish_piece", + 2263: "sea_devil_fillet_3_cm", + 2266: "sea_devil_fillet_4_cm", + 2267: "common_sole_fillet_1_cm", + 2270: "common_sole_fillet_2_cm", + 2271: "atlantic_catfish_fillet_1_cm", + 2272: "atlantic_catfish_fillet_2_cm", + 2273: "turbot_fillet_2_cm", + 2276: "turbot_fillet_3_cm", + 2277: "tuna_steak", + 2278: "tuna_fillet_2_cm", + 2279: "tuna_fillet_3_cm", + 2280: "tilapia_fillet_1_cm", + 2281: "tilapia_fillet_2_cm", + 2282: "nile_perch_fillet_2_cm", + 2283: "nile_perch_fillet_3_cm", + 2285: "zander_fillet", + 2288: "soup_hen", + 2291: "poularde_whole", + 2292: "poularde_breast", + 2294: "turkey_breast", + 2302: "chicken_tikka_masala_with_rice", + 2312: "veal_fillet_whole", + 2313: "veal_fillet_medaillons_1_cm", + 2315: "veal_fillet_medaillons_2_cm", + 2317: "veal_fillet_medaillons_3_cm", + 2324: "goulash_soup", + 2327: "dutch_hash", + 2328: "stuffed_cabbage", + 2330: "beef_tenderloin", + 2333: "beef_tenderloin_medaillons_1_cm_steam_cooking", + 2334: "beef_tenderloin_medaillons_2_cm_steam_cooking", + 2335: "beef_tenderloin_medaillons_3_cm_steam_cooking", + 2339: "silverside_5_cm", + 2342: "silverside_7_5_cm", + 2345: "silverside_10_cm", + 2348: "meat_for_soup_back_or_top_rib", + 2349: "meat_for_soup_leg_steak", + 2350: "meat_for_soup_brisket", + 2353: "viennese_silverside", + 2354: "whole_ham_steam_cooking", + 2355: "whole_ham_reheating", + 2359: "kasseler_piece", + 2361: "kasseler_slice", + 2363: "knuckle_of_pork_fresh", + 2364: "knuckle_of_pork_cured", + 2367: "pork_tenderloin_medaillons_3_cm", + 2368: "pork_tenderloin_medaillons_4_cm", + 2369: "pork_tenderloin_medaillons_5_cm", + 2429: "pumpkin_soup", + 2430: "meat_with_rice", + 2431: "beef_casserole", + 2450: "risotto", + 2451: "risotto", + 2453: "rice_pudding_steam_cooking", + 2454: "rice_pudding_rapid_steam_cooking", + 2461: "amaranth", + 2462: "bulgur", + 2463: "spelt_whole", + 2464: "spelt_cracked", + 2465: "green_spelt_whole", + 2466: "green_spelt_cracked", + 2467: "oats_whole", + 2468: "oats_cracked", + 2469: "millet", + 2470: "quinoa", + 2471: "polenta_swiss_style_fine_polenta", + 2472: "polenta_swiss_style_medium_polenta", + 2473: "polenta_swiss_style_coarse_polenta", + 2474: "polenta", + 2475: "rye_whole", + 2476: "rye_cracked", + 2477: "wheat_whole", + 2478: "wheat_cracked", + 2480: "gnocchi_fresh", + 2481: "yeast_dumplings_fresh", + 2482: "potato_dumplings_raw_boil_in_bag", + 2483: "potato_dumplings_raw_deep_frozen", + 2484: "potato_dumplings_half_half_boil_in_bag", + 2485: "potato_dumplings_half_half_deep_frozen", + 2486: "bread_dumplings_boil_in_the_bag", + 2487: "bread_dumplings_fresh", + 2488: "ravioli_fresh", + 2489: "spaetzle_fresh", + 2490: "tagliatelli_fresh", + 2491: "schupfnudeln_potato_noodels", + 2492: "tortellini_fresh", + 2493: "red_lentils", + 2494: "brown_lentils", + 2495: "beluga_lentils", + 2496: "green_split_peas", + 2497: "yellow_split_peas", + 2498: "chick_peas", + 2499: "white_beans", + 2500: "pinto_beans", + 2501: "red_beans", + 2502: "black_beans", + 2503: "hens_eggs_size_s_soft", + 2504: "hens_eggs_size_s_medium", + 2505: "hens_eggs_size_s_hard", + 2506: "hens_eggs_size_m_soft", + 2507: "hens_eggs_size_m_medium", + 2508: "hens_eggs_size_m_hard", + 2509: "hens_eggs_size_l_soft", + 2510: "hens_eggs_size_l_medium", + 2511: "hens_eggs_size_l_hard", + 2512: "hens_eggs_size_xl_soft", + 2513: "hens_eggs_size_xl_medium", + 2514: "hens_eggs_size_xl_hard", + 2515: "swiss_toffee_cream_100_ml", + 2516: "swiss_toffee_cream_150_ml", + 2518: "toffee_date_dessert_several_small", + 2520: "cheesecake_several_small", + 2521: "cheesecake_one_large", + 2522: "christmas_pudding_cooking", + 2523: "christmas_pudding_heating", + 2524: "treacle_sponge_pudding_several_small", + 2525: "treacle_sponge_pudding_one_large", + 2526: "sweet_cheese_dumplings", + 2527: "apples_whole", + 2528: "apples_halved", + 2529: "apples_quartered", + 2530: "apples_sliced", + 2531: "apples_diced", + 2532: "apricots_halved_steam_cooking", + 2533: "apricots_halved_skinning", + 2534: "apricots_quartered", + 2535: "apricots_wedges", + 2536: "pears_halved", + 2537: "pears_quartered", + 2538: "pears_wedges", + 2539: "sweet_cherries", + 2540: "sour_cherries", + 2541: "pears_to_cook_small_whole", + 2542: "pears_to_cook_small_halved", + 2543: "pears_to_cook_small_quartered", + 2544: "pears_to_cook_medium_whole", + 2545: "pears_to_cook_medium_halved", + 2546: "pears_to_cook_medium_quartered", + 2547: "pears_to_cook_large_whole", + 2548: "pears_to_cook_large_halved", + 2549: "pears_to_cook_large_quartered", + 2550: "mirabelles", + 2551: "nectarines_peaches_halved_steam_cooking", + 2552: "nectarines_peaches_halved_skinning", + 2553: "nectarines_peaches_quartered", + 2554: "nectarines_peaches_wedges", + 2555: "plums_whole", + 2556: "plums_halved", + 2557: "cranberries", + 2558: "quinces_diced", + 2559: "greenage_plums", + 2560: "rhubarb_chunks", + 2561: "gooseberries", + 2562: "mushrooms_whole", + 2563: "mushrooms_halved", + 2564: "mushrooms_sliced", + 2565: "mushrooms_quartered", + 2566: "mushrooms_diced", + 2567: "cep", + 2568: "chanterelle", + 2569: "oyster_mushroom_whole", + 2570: "oyster_mushroom_strips", + 2571: "oyster_mushroom_diced", + 2572: "saucisson", + 2573: "bruehwurst_sausages", + 2574: "bologna_sausage", + 2575: "veal_sausages", + 2577: "crevettes", + 2579: "prawns", + 2581: "king_prawns", + 2583: "small_shrimps", + 2585: "large_shrimps", + 2587: "mussels", + 2589: "scallops", + 2591: "venus_clams", + 2592: "goose_barnacles", + 2593: "cockles", + 2594: "razor_clams_small", + 2595: "razor_clams_medium", + 2596: "razor_clams_large", + 2597: "mussels_in_sauce", + 2598: "bottling_soft", + 2599: "bottling_medium", + 2600: "bottling_hard", + 2601: "melt_chocolate", + 2602: "dissolve_gelatine", + 2603: "sweat_onions", + 2604: "cook_bacon", + 2605: "heating_damp_flannels", + 2606: "decrystallise_honey", + 2607: "make_yoghurt", + 2687: "toffee_date_dessert_one_large", + 2694: "beef_tenderloin_medaillons_1_cm_low_temperature_cooking", + 2695: "beef_tenderloin_medaillons_2_cm_low_temperature_cooking", + 2696: "beef_tenderloin_medaillons_3_cm_low_temperature_cooking", + 3373: "wild_rice", + 3376: "wholegrain_rice", + 3380: "parboiled_rice_steam_cooking", + 3381: "parboiled_rice_rapid_steam_cooking", + 3383: "basmati_rice_steam_cooking", + 3384: "basmati_rice_rapid_steam_cooking", + 3386: "jasmine_rice_steam_cooking", + 3387: "jasmine_rice_rapid_steam_cooking", + 3389: "huanghuanian_steam_cooking", + 3390: "huanghuanian_rapid_steam_cooking", + 3392: "simiao_steam_cooking", + 3393: "simiao_rapid_steam_cooking", + 3395: "long_grain_rice_general_steam_cooking", + 3396: "long_grain_rice_general_rapid_steam_cooking", + 3398: "chongming_steam_cooking", + 3399: "chongming_rapid_steam_cooking", + 3401: "wuchang_steam_cooking", + 3402: "wuchang_rapid_steam_cooking", + 3404: "uonumma_koshihikari_steam_cooking", + 3405: "uonumma_koshihikari_rapid_steam_cooking", + 3407: "sheyang_steam_cooking", + 3408: "sheyang_rapid_steam_cooking", + 3410: "round_grain_rice_general_steam_cooking", + 3411: "round_grain_rice_general_rapid_steam_cooking", +} + +STATE_PROGRAM_ID: dict[int, dict[int, str]] = { + MieleAppliance.WASHING_MACHINE: WASHING_MACHINE_PROGRAM_ID, + MieleAppliance.TUMBLE_DRYER: TUMBLE_DRYER_PROGRAM_ID, + MieleAppliance.DISHWASHER: DISHWASHER_PROGRAM_ID, + MieleAppliance.DISH_WARMER: DISH_WARMER_PROGRAM_ID, + MieleAppliance.OVEN: OVEN_PROGRAM_ID, + MieleAppliance.OVEN_MICROWAVE: OVEN_PROGRAM_ID, + MieleAppliance.STEAM_OVEN_MK2: OVEN_PROGRAM_ID, + MieleAppliance.STEAM_OVEN: OVEN_PROGRAM_ID, + MieleAppliance.STEAM_OVEN_COMBI: OVEN_PROGRAM_ID, + MieleAppliance.STEAM_OVEN_MICRO: STEAM_OVEN_MICRO_PROGRAM_ID, + MieleAppliance.WASHER_DRYER: WASHING_MACHINE_PROGRAM_ID, + MieleAppliance.ROBOT_VACUUM_CLEANER: ROBOT_VACUUM_CLEANER_PROGRAM_ID, + MieleAppliance.COFFEE_SYSTEM: COFFEE_SYSTEM_PROGRAM_ID, +} diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py new file mode 100644 index 00000000000..27456ffe04c --- /dev/null +++ b/homeassistant/components/miele/coordinator.py @@ -0,0 +1,101 @@ +"""Coordinator module for Miele integration.""" + +from __future__ import annotations + +import asyncio.timeouts +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta +import logging + +from pymiele import MieleAction, MieleDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +type MieleConfigEntry = ConfigEntry[MieleDataUpdateCoordinator] + + +@dataclass +class MieleCoordinatorData: + """Data class for storing coordinator data.""" + + devices: dict[str, MieleDevice] + actions: dict[str, MieleAction] + + +class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]): + """Coordinator for Miele data.""" + + config_entry: MieleConfigEntry + new_device_callbacks: list[Callable[[dict[str, MieleDevice]], None]] = [] + known_devices: set[str] = set() + devices: dict[str, MieleDevice] = {} + + def __init__( + self, + hass: HomeAssistant, + api: AsyncConfigEntryAuth, + ) -> None: + """Initialize the Miele data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=120), + ) + self.api = api + + async def _async_update_data(self) -> MieleCoordinatorData: + """Fetch data from the Miele API.""" + async with asyncio.timeout(10): + # Get devices + devices_json = await self.api.get_devices() + devices = { + device_id: MieleDevice(device) + for device_id, device in devices_json.items() + } + self.devices = devices + actions = {} + for device_id in devices: + actions_json = await self.api.get_actions(device_id) + actions[device_id] = MieleAction(actions_json) + return MieleCoordinatorData(devices=devices, actions=actions) + + def async_add_devices(self, added_devices: set[str]) -> tuple[set[str], set[str]]: + """Add devices.""" + current_devices = set(self.devices) + new_devices: set[str] = current_devices - added_devices + + return (new_devices, current_devices) + + async def callback_update_data(self, devices_json: dict[str, dict]) -> None: + """Handle data update from the API.""" + devices = { + device_id: MieleDevice(device) for device_id, device in devices_json.items() + } + self.async_set_updated_data( + MieleCoordinatorData( + devices=devices, + actions=self.data.actions, + ) + ) + + async def callback_update_actions(self, actions_json: dict[str, dict]) -> None: + """Handle data update from the API.""" + actions = { + device_id: MieleAction(action) for device_id, action in actions_json.items() + } + self.async_set_updated_data( + MieleCoordinatorData( + devices=self.data.devices, + actions=actions, + ) + ) diff --git a/homeassistant/components/miele/diagnostics.py b/homeassistant/components/miele/diagnostics.py new file mode 100644 index 00000000000..eb0a1fe49c3 --- /dev/null +++ b/homeassistant/components/miele/diagnostics.py @@ -0,0 +1,90 @@ +"""Diagnostics support for Miele.""" + +from __future__ import annotations + +import hashlib +from typing import Any, cast + +from pymiele import completed_warnings + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .coordinator import MieleConfigEntry + +TO_REDACT = {"access_token", "refresh_token", "fabNumber"} + + +def hash_identifier(key: str) -> str: + """Hash the identifier string.""" + return f"**REDACTED_{hashlib.sha256(key.encode()).hexdigest()[:16]}" + + +def redact_identifiers(in_data: dict[str, Any]) -> dict[str, Any]: + """Redact identifiers from the data.""" + out_data = {} + for key, value in in_data.items(): + out_data[hash_identifier(key)] = value + return out_data + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: MieleConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + miele_data: dict[str, Any] = { + "devices": redact_identifiers( + { + device_id: device_data.raw + for device_id, device_data in config_entry.runtime_data.data.devices.items() + } + ), + "actions": redact_identifiers( + { + device_id: action_data.raw + for device_id, action_data in config_entry.runtime_data.data.actions.items() + } + ), + } + miele_data["missing_code_warnings"] = ( + sorted(completed_warnings) if len(completed_warnings) > 0 else ["None"] + ) + + return { + "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), + "miele_data": async_redact_data(miele_data, TO_REDACT), + } + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: MieleConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + info = { + "manufacturer": device.manufacturer, + "model": device.model, + } + + coordinator = config_entry.runtime_data + + device_id = cast(str, device.serial_number) + miele_data: dict[str, Any] = { + "devices": { + hash_identifier(device_id): coordinator.data.devices[device_id].raw + }, + "actions": { + hash_identifier(device_id): coordinator.data.actions[device_id].raw + }, + "programs": "Not implemented", + } + miele_data["missing_code_warnings"] = ( + sorted(completed_warnings) if len(completed_warnings) > 0 else ["None"] + ) + + return { + "info": async_redact_data(info, TO_REDACT), + "data": async_redact_data(config_entry.data, TO_REDACT), + "miele_data": async_redact_data(miele_data, TO_REDACT), + } diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py new file mode 100644 index 00000000000..f9ed4f0bf48 --- /dev/null +++ b/homeassistant/components/miele/entity.py @@ -0,0 +1,67 @@ +"""Entity base class for the Miele integration.""" + +from pymiele import MieleAction, MieleDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .api import AsyncConfigEntryAuth +from .const import DEVICE_TYPE_TAGS, DOMAIN, MANUFACTURER, MieleAppliance, StateStatus +from .coordinator import MieleDataUpdateCoordinator + + +class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): + """Base class for Miele entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._device_id = device_id + self.entity_description = description + self._attr_unique_id = f"{device_id}-{description.key}" + + device = self.device + appliance_type = DEVICE_TYPE_TAGS.get(MieleAppliance(device.device_type)) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + serial_number=device_id, + name=appliance_type or device.tech_type, + translation_key=appliance_type, + manufacturer=MANUFACTURER, + model=device.tech_type, + hw_version=device.xkm_tech_type, + sw_version=device.xkm_release_version, + ) + + @property + def device(self) -> MieleDevice: + """Return the device object.""" + return self.coordinator.data.devices[self._device_id] + + @property + def action(self) -> MieleAction: + """Return the actions object.""" + return self.coordinator.data.actions[self._device_id] + + @property + def api(self) -> AsyncConfigEntryAuth: + """Return the api object.""" + return self.coordinator.api + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + + return ( + super().available + and self._device_id in self.coordinator.data.devices + and (self.device.state_status is not StateStatus.NOT_CONNECTED) + ) diff --git a/homeassistant/components/miele/fan.py b/homeassistant/components/miele/fan.py new file mode 100644 index 00000000000..5faaa46b33c --- /dev/null +++ b/homeassistant/components/miele/fan.py @@ -0,0 +1,195 @@ +"""Platform for Miele fan entity.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import math +from typing import Any, Final + +from aiohttp import ClientResponseError + +from homeassistant.components.fan import ( + FanEntity, + FanEntityDescription, + FanEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) +from homeassistant.util.scaling import int_states_in_range + +from .const import DOMAIN, POWER_OFF, POWER_ON, VENTILATION_STEP, MieleAppliance +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .entity import MieleEntity + +PARALLEL_UPDATES = 1 + +_LOGGER = logging.getLogger(__name__) + +SPEED_RANGE = (1, 4) + + +@dataclass(frozen=True, kw_only=True) +class MieleFanDefinition: + """Class for defining fan entities.""" + + types: tuple[MieleAppliance, ...] + description: FanEntityDescription + + +FAN_TYPES: Final[tuple[MieleFanDefinition, ...]] = ( + MieleFanDefinition( + types=(MieleAppliance.HOOD,), + description=FanEntityDescription( + key="fan", + translation_key="fan", + ), + ), + MieleFanDefinition( + types=(MieleAppliance.HOB_INDUCT_EXTR,), + description=FanEntityDescription( + key="fan_readonly", + translation_key="fan", + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the fan platform.""" + coordinator = config_entry.runtime_data + added_devices: set[str] = set() + + def _async_add_new_devices() -> None: + nonlocal added_devices + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + async_add_entities( + MieleFan(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in FAN_TYPES + if device_id in new_devices_set and device.device_type in definition.types + ) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() + + +class MieleFan(MieleEntity, FanEntity): + """Representation of a Fan.""" + + entity_description: FanEntityDescription + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: FanEntityDescription, + ) -> None: + """Initialize the fan.""" + + self._attr_supported_features: FanEntityFeature = ( + FanEntityFeature(0) + if description.key == "fan_readonly" + else FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + super().__init__(coordinator, device_id, description) + + @property + def is_on(self) -> bool: + """Return current on/off state.""" + return ( + self.device.state_ventilation_step is not None + and self.device.state_ventilation_step > 0 + ) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + return ranged_value_to_percentage( + SPEED_RANGE, + (self.device.state_ventilation_step or 0), + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + _LOGGER.debug("Set_percentage: %s", percentage) + ventilation_step = math.ceil( + percentage_to_ranged_value(SPEED_RANGE, percentage) + ) + _LOGGER.debug("Calc ventilation_step: %s", ventilation_step) + if ventilation_step == 0: + await self.async_turn_off() + else: + try: + await self.api.send_action( + self._device_id, {VENTILATION_STEP: ventilation_step} + ) + except ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex + self.device.state_ventilation_step = ventilation_step + self.async_write_ha_state() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + _LOGGER.debug( + "Turn_on -> percentage: %s, preset_mode: %s", percentage, preset_mode + ) + try: + await self.api.send_action(self._device_id, {POWER_ON: True}) + except ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex + + if percentage is not None: + await self.async_set_percentage(percentage) + return + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + try: + await self.api.send_action(self._device_id, {POWER_OFF: True}) + except ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex + + self.device.state_ventilation_step = 0 + self.async_write_ha_state() diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json new file mode 100644 index 00000000000..1806fe688d6 --- /dev/null +++ b/homeassistant/components/miele/icons.json @@ -0,0 +1,110 @@ +{ + "entity": { + "binary_sensor": { + "notification_active": { + "default": "mdi:information" + }, + "mobile_start": { + "default": "mdi:cellphone-wireless" + }, + "remote_control": { + "default": "mdi:remote" + }, + "smart_grid": { + "default": "mdi:view-grid-plus-outline" + } + }, + "button": { + "start": { + "default": "mdi:play" + }, + "stop": { + "default": "mdi:stop" + }, + "pause": { + "default": "mdi:pause" + } + }, + "sensor": { + "core_temperature": { + "default": "mdi:thermometer-probe" + }, + "core_target_temperature": { + "default": "mdi:thermometer-probe" + }, + "target_temperature": { + "default": "mdi:thermometer-check" + }, + "drying_step": { + "default": "mdi:water-outline" + }, + "program_id": { + "default": "mdi:selection-ellipse-arrow-inside" + }, + "program_phase": { + "default": "mdi:tray-full" + }, + "elapsed_time": { + "default": "mdi:timelapse" + }, + "start_time": { + "default": "mdi:clock-start" + }, + "spin_speed": { + "default": "mdi:sync" + }, + "plate": { + "default": "mdi:circle-outline", + "state": { + "0": "mdi:circle-outline", + "110": "mdi:alpha-w-circle-outline", + "220": "mdi:alpha-w-circle-outline", + "1": "mdi:circle-slice-1", + "2": "mdi:circle-slice-1", + "3": "mdi:circle-slice-2", + "4": "mdi:circle-slice-2", + "5": "mdi:circle-slice-3", + "6": "mdi:circle-slice-3", + "7": "mdi:circle-slice-4", + "8": "mdi:circle-slice-4", + "9": "mdi:circle-slice-5", + "10": "mdi:circle-slice-5", + "11": "mdi:circle-slice-5", + "12": "mdi:circle-slice-6", + "13": "mdi:circle-slice-6", + "14": "mdi:circle-slice-6", + "15": "mdi:circle-slice-7", + "16": "mdi:circle-slice-7", + "17": "mdi:circle-slice-8", + "18": "mdi:circle-slice-8", + "117": "mdi:alpha-b-circle-outline", + "118": "mdi:alpha-b-circle-outline", + "217": "mdi:alpha-b-circle-outline" + } + }, + "program_type": { + "default": "mdi:state-machine" + }, + "remaining_time": { + "default": "mdi:clock-end" + }, + "energy_forecast": { + "default": "mdi:lightning-bolt-outline" + }, + "water_forecast": { + "default": "mdi:water-outline" + } + }, + "switch": { + "power": { + "default": "mdi:power" + }, + "supercooling": { + "default": "mdi:snowflake-variant" + }, + "superfreezing": { + "default": "mdi:snowflake" + } + } + } +} diff --git a/homeassistant/components/miele/light.py b/homeassistant/components/miele/light.py new file mode 100644 index 00000000000..e918b93b12a --- /dev/null +++ b/homeassistant/components/miele/light.py @@ -0,0 +1,141 @@ +"""Platform for Miele light entity.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Final + +import aiohttp + +from homeassistant.components.light import ( + ColorMode, + LightEntity, + LightEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import AMBIENT_LIGHT, DOMAIN, LIGHT, LIGHT_OFF, LIGHT_ON, MieleAppliance +from .coordinator import MieleConfigEntry +from .entity import MieleDevice, MieleEntity + +PARALLEL_UPDATES = 1 + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleLightDescription(LightEntityDescription): + """Class describing Miele light entities.""" + + value_fn: Callable[[MieleDevice], StateType] + light_type: str + + +@dataclass +class MieleLightDefinition: + """Class for defining light entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleLightDescription + + +LIGHT_TYPES: Final[tuple[MieleLightDefinition, ...]] = ( + MieleLightDefinition( + types=( + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleLightDescription( + key="light", + value_fn=lambda value: value.state_light, + light_type=LIGHT, + translation_key="light", + ), + ), + MieleLightDefinition( + types=(MieleAppliance.HOOD,), + description=MieleLightDescription( + key="ambient_light", + value_fn=lambda value: value.state_ambient_light, + light_type=AMBIENT_LIGHT, + translation_key="ambient_light", + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the light platform.""" + coordinator = config_entry.runtime_data + added_devices: set[str] = set() + + def _async_add_new_devices() -> None: + nonlocal added_devices + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + async_add_entities( + MieleLight(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in LIGHT_TYPES + if device_id in new_devices_set and device.device_type in definition.types + ) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() + + +class MieleLight(MieleEntity, LightEntity): + """Representation of a Light.""" + + entity_description: MieleLightDescription + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + + @property + def is_on(self) -> bool: + """Return current on/off state.""" + return self.entity_description.value_fn(self.device) == LIGHT_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + await self.async_turn_light(LIGHT_ON) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.async_turn_light(LIGHT_OFF) + + async def async_turn_light(self, mode: int) -> None: + """Set light to mode.""" + try: + await self.api.send_action( + self._device_id, {self.entity_description.light_type: mode} + ) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from err diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json new file mode 100644 index 00000000000..c9a20e977f9 --- /dev/null +++ b/homeassistant/components/miele/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "miele", + "name": "Miele", + "codeowners": ["@astrandb"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/miele", + "iot_class": "cloud_push", + "loggers": ["pymiele"], + "quality_scale": "bronze", + "requirements": ["pymiele==0.5.2"], + "single_config_entry": true, + "zeroconf": ["_mieleathome._tcp.local."] +} diff --git a/homeassistant/components/miele/quality_scale.yaml b/homeassistant/components/miele/quality_scale.yaml new file mode 100644 index 00000000000..d0c3677db40 --- /dev/null +++ b/homeassistant/components/miele/quality_scale.yaml @@ -0,0 +1,81 @@ +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: + status: done + comment: | + Handled by a setting in manifest.json as there is no account information in API + + # Silver + action-exceptions: + status: done + comment: No custom actions are defined + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration parameters + docs-installation-parameters: + status: exempt + comment: | + Integration uses account linking via Nabu casa so no installation parameters are needed. + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by DataUpdateCoordinator + parallel-updates: done + reauthentication-flow: done + 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: done + 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: done + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py new file mode 100644 index 00000000000..d5085ae606f --- /dev/null +++ b/homeassistant/components/miele/sensor.py @@ -0,0 +1,768 @@ +"""Sensor platform for Miele integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Final, cast + +from pymiele import MieleDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + EntityCategory, + UnitOfEnergy, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import ( + STATE_PROGRAM_ID, + STATE_PROGRAM_PHASE, + STATE_STATUS_TAGS, + MieleAppliance, + StateDryingStep, + StateProgramType, + StateStatus, +) +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .entity import MieleEntity + +PARALLEL_UPDATES = 0 + +_LOGGER = logging.getLogger(__name__) + +DISABLED_TEMPERATURE = -32768 + +PLATE_POWERS = [ + "0", + "110", + "220", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "117", + "118", + "217", +] + + +DEFAULT_PLATE_COUNT = 4 + +PLATE_COUNT = { + "KM7678": 6, + "KM7697": 6, + "KM7878": 6, + "KM7897": 6, + "KMDA7633": 5, + "KMDA7634": 5, + "KMDA7774": 5, + "KMX": 6, +} + + +def _get_plate_count(tech_type: str) -> int: + """Get number of zones for hob.""" + stripped = tech_type.replace(" ", "") + for prefix, plates in PLATE_COUNT.items(): + if stripped.startswith(prefix): + return plates + return DEFAULT_PLATE_COUNT + + +def _convert_duration(value_list: list[int]) -> int | None: + """Convert duration to minutes.""" + return value_list[0] * 60 + value_list[1] if value_list else None + + +@dataclass(frozen=True, kw_only=True) +class MieleSensorDescription(SensorEntityDescription): + """Class describing Miele sensor entities.""" + + value_fn: Callable[[MieleDevice], StateType] + zone: int = 1 + + +@dataclass +class MieleSensorDefinition: + """Class for defining sensor entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleSensorDescription + + +SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.HOB_HIGHLIGHT, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.DISH_WARMER, + MieleAppliance.HOB_INDUCTION, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.HOB_INDUCT_EXTR, + ), + description=MieleSensorDescription( + key="state_status", + translation_key="status", + value_fn=lambda value: value.state_status, + device_class=SensorDeviceClass.ENUM, + options=sorted(set(STATE_STATUS_TAGS.values())), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_program_id", + translation_key="program_id", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: value.state_program_id, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_program_phase", + translation_key="program_phase", + value_fn=lambda value: value.state_program_phase, + device_class=SensorDeviceClass.ENUM, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_program_type", + translation_key="program_type", + value_fn=lambda value: StateProgramType(value.state_program_type).name, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=sorted(set(StateProgramType.keys())), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.WASHER_DRYER, + ), + description=MieleSensorDescription( + key="current_energy_consumption", + translation_key="energy_consumption", + value_fn=lambda value: value.current_energy_consumption, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.WASHER_DRYER, + ), + description=MieleSensorDescription( + key="energy_forecast", + translation_key="energy_forecast", + value_fn=( + lambda value: value.energy_forecast * 100 + if value.energy_forecast is not None + else None + ), + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.DISHWASHER, + MieleAppliance.WASHER_DRYER, + ), + description=MieleSensorDescription( + key="current_water_consumption", + translation_key="water_consumption", + value_fn=lambda value: value.current_water_consumption, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfVolume.LITERS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.DISHWASHER, + MieleAppliance.WASHER_DRYER, + ), + description=MieleSensorDescription( + key="water_forecast", + translation_key="water_forecast", + value_fn=( + lambda value: value.water_forecast * 100 + if value.water_forecast is not None + else None + ), + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.WASHER_DRYER, + ), + description=MieleSensorDescription( + key="state_spinning_speed", + translation_key="spin_speed", + value_fn=lambda value: value.state_spinning_speed, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_remaining_time", + translation_key="remaining_time", + value_fn=lambda value: _convert_duration(value.state_remaining_time), + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_elapsed_time", + translation_key="elapsed_time", + value_fn=lambda value: _convert_duration(value.state_elapsed_time), + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_start_time", + translation_key="start_time", + value_fn=lambda value: _convert_duration(value.state_start_time), + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfTime.HOURS, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.DISH_WARMER, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_temperature_1", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda value: cast(int, value.state_temperatures[0].temperature) + / 100.0, + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.DISH_WARMER, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_temperature_2", + zone=2, + device_class=SensorDeviceClass.TEMPERATURE, + translation_key="temperature_zone_2", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda value: value.state_temperatures[1].temperature / 100.0, # type: ignore [operator] + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_core_target_temperature", + translation_key="core_target_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda value: cast( + int, value.state_core_target_temperature[0].temperature + ) + / 100.0 + ), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHER_DRYER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_target_temperature", + translation_key="target_temperature", + zone=1, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda value: cast(int, value.state_target_temperature[0].temperature) + / 100.0 + ), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN_COMBI, + ), + description=MieleSensorDescription( + key="state_core_temperature", + translation_key="core_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda value: cast(int, value.state_core_temperature[0].temperature) + / 100.0 + ), + ), + ), + *( + MieleSensorDefinition( + types=( + MieleAppliance.HOB_HIGHLIGHT, + MieleAppliance.HOB_INDUCT_EXTR, + MieleAppliance.HOB_INDUCTION, + ), + description=MieleSensorDescription( + key="state_plate_step", + translation_key="plate", + translation_placeholders={"plate_no": str(i)}, + zone=i, + device_class=SensorDeviceClass.ENUM, + options=PLATE_POWERS, + value_fn=lambda value: value.state_plate_step[0].value_raw, + ), + ) + for i in range(1, 7) + ), + MieleSensorDefinition( + types=( + MieleAppliance.WASHER_DRYER, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + ), + description=MieleSensorDescription( + key="state_drying_step", + translation_key="drying_step", + value_fn=lambda value: StateDryingStep( + cast(int, value.state_drying_step) + ).name, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=sorted(StateDryingStep.keys()), + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + added_devices: set[str] = set() + + def _async_add_new_devices() -> None: + nonlocal added_devices + entities: list = [] + entity_class: type[MieleSensor] + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + for device_id, device in coordinator.data.devices.items(): + for definition in SENSOR_TYPES: + if ( + device_id in new_devices_set + and device.device_type in definition.types + ): + match definition.description.key: + case "state_status": + entity_class = MieleStatusSensor + case "state_program_id": + entity_class = MieleProgramIdSensor + case "state_program_phase": + entity_class = MielePhaseSensor + case "state_plate_step": + entity_class = MielePlateSensor + case _: + entity_class = MieleSensor + if ( + definition.description.device_class + == SensorDeviceClass.TEMPERATURE + and definition.description.value_fn(device) + == DISABLED_TEMPERATURE / 100 + ) or ( + definition.description.key == "state_plate_step" + and definition.description.zone + > _get_plate_count(device.tech_type) + ): + # Don't create entity if API signals that datapoint is disabled + continue + entities.append( + entity_class(coordinator, device_id, definition.description) + ) + async_add_entities(entities) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() + + +APPLIANCE_ICONS = { + MieleAppliance.WASHING_MACHINE: "mdi:washing-machine", + MieleAppliance.TUMBLE_DRYER: "mdi:tumble-dryer", + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: "mdi:tumble-dryer", + MieleAppliance.DISHWASHER: "mdi:dishwasher", + MieleAppliance.OVEN: "mdi:chef-hat", + MieleAppliance.OVEN_MICROWAVE: "mdi:chef-hat", + MieleAppliance.HOB_HIGHLIGHT: "mdi:pot-steam-outline", + MieleAppliance.STEAM_OVEN: "mdi:chef-hat", + MieleAppliance.MICROWAVE: "mdi:microwave", + MieleAppliance.COFFEE_SYSTEM: "mdi:coffee-maker", + MieleAppliance.HOOD: "mdi:turbine", + MieleAppliance.FRIDGE: "mdi:fridge-industrial-outline", + MieleAppliance.FREEZER: "mdi:fridge-industrial-outline", + MieleAppliance.FRIDGE_FREEZER: "mdi:fridge-outline", + MieleAppliance.ROBOT_VACUUM_CLEANER: "mdi:robot-vacuum", + MieleAppliance.WASHER_DRYER: "mdi:washing-machine", + MieleAppliance.DISH_WARMER: "mdi:heat-wave", + MieleAppliance.HOB_INDUCTION: "mdi:pot-steam-outline", + MieleAppliance.STEAM_OVEN_COMBI: "mdi:chef-hat", + MieleAppliance.WINE_CABINET: "mdi:glass-wine", + MieleAppliance.WINE_CONDITIONING_UNIT: "mdi:glass-wine", + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT: "mdi:glass-wine", + MieleAppliance.STEAM_OVEN_MICRO: "mdi:chef-hat", + MieleAppliance.DIALOG_OVEN: "mdi:chef-hat", + MieleAppliance.WINE_CABINET_FREEZER: "mdi:glass-wine", + MieleAppliance.HOB_INDUCT_EXTR: "mdi:pot-steam-outline", +} + + +class MieleSensor(MieleEntity, SensorEntity): + """Representation of a Sensor.""" + + entity_description: MieleSensorDescription + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) + + +class MielePlateSensor(MieleSensor): + """Representation of a Sensor.""" + + entity_description: MieleSensorDescription + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleSensorDescription, + ) -> None: + """Initialize the plate sensor.""" + super().__init__(coordinator, device_id, description) + self._attr_unique_id = f"{device_id}-{description.key}-{description.zone}" + + @property + def native_value(self) -> StateType: + """Return the state of the plate sensor.""" + # state_plate_step is [] if all zones are off + plate_power = ( + self.device.state_plate_step[self.entity_description.zone - 1].value_raw + if self.device.state_plate_step + else 0 + ) + return str(plate_power) + + +class MieleStatusSensor(MieleSensor): + """Representation of the status sensor.""" + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id, description) + self._attr_name = None + self._attr_icon = APPLIANCE_ICONS.get( + MieleAppliance(self.device.device_type), + "mdi:state-machine", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return STATE_STATUS_TAGS.get(StateStatus(self.device.state_status)) + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + # This sensor should always be available + return True + + +class MielePhaseSensor(MieleSensor): + """Representation of the program phase sensor.""" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + ret_val = STATE_PROGRAM_PHASE.get(self.device.device_type, {}).get( + self.device.state_program_phase + ) + if ret_val is None: + _LOGGER.debug( + "Unknown program phase: %s on device type: %s", + self.device.state_program_phase, + self.device.device_type, + ) + return ret_val + + @property + def options(self) -> list[str]: + """Return the options list for the actual device type.""" + return sorted( + set(STATE_PROGRAM_PHASE.get(self.device.device_type, {}).values()) + ) + + +class MieleProgramIdSensor(MieleSensor): + """Representation of the program id sensor.""" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + ret_val = STATE_PROGRAM_ID.get(self.device.device_type, {}).get( + self.device.state_program_id + ) + if ret_val is None: + _LOGGER.debug( + "Unknown program id: %s on device type: %s", + self.device.state_program_id, + self.device.device_type, + ) + return ret_val + + @property + def options(self) -> list[str]: + """Return the options list for the actual device type.""" + return sorted(set(STATE_PROGRAM_ID.get(self.device.device_type, {}).values())) diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json new file mode 100644 index 00000000000..2cbc4f2f5f4 --- /dev/null +++ b/homeassistant/components/miele/strings.json @@ -0,0 +1,944 @@ +{ + "application_credentials": { + "description": "Navigate to [\"Get involved\" at Miele developer site]({register_url}) to request credentials then enter them below." + }, + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Miele integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "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%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "device": { + "coffee_system": { + "name": "Coffee system" + }, + "dishwasher": { + "name": "Dishwasher" + }, + "tumble_dryer": { + "name": "Tumble dryer" + }, + "fridge_freezer": { + "name": "Fridge freezer" + }, + "induction_hob": { + "name": "Induction hob" + }, + "oven": { + "name": "Oven" + }, + "oven_microwave": { + "name": "Oven microwave" + }, + "hob_highlight": { + "name": "Hob highlight" + }, + "steam_oven": { + "name": "Steam oven" + }, + "microwave": { + "name": "Microwave" + }, + "hood": { + "name": "Hood" + }, + "warming_drawer": { + "name": "Warming drawer" + }, + "steam_oven_combi": { + "name": "Steam oven combi" + }, + "wine_cabinet": { + "name": "Wine cabinet" + }, + "wine_conditioning_unit": { + "name": "Wine conditioning unit" + }, + "wine_unit": { + "name": "Wine unit" + }, + "refrigerator": { + "name": "Refrigerator" + }, + "freezer": { + "name": "Freezer" + }, + "robot_vacuum_cleaner": { + "name": "Robot vacuum cleaner" + }, + "steam_oven_micro": { + "name": "Steam oven micro" + }, + "dialog_oven": { + "name": "Dialog oven" + }, + "wine_cabinet_freezer": { + "name": "Wine cabinet freezer" + }, + "hob_extraction": { + "name": "Hob with extraction" + }, + "washer_dryer": { + "name": "Washer dryer" + }, + "washing_machine": { + "name": "Washing machine" + } + }, + "entity": { + "binary_sensor": { + "failure": { + "name": "Failure" + }, + "info": { + "name": "Info" + }, + "notification_active": { + "name": "Notification active" + }, + "mobile_start": { + "name": "Mobile start" + }, + "remote_control": { + "name": "Remote control" + }, + "smart_grid": { + "name": "Smart grid" + } + }, + "button": { + "start": { + "name": "[%key:common::action::start%]" + }, + "stop": { + "name": "[%key:common::action::stop%]" + }, + "pause": { + "name": "[%key:common::action::pause%]" + } + }, + "fan": { + "fan": { + "name": "[%key:component::fan::title%]" + } + }, + "light": { + "ambient_light": { + "name": "Ambient light" + }, + "light": { + "name": "[%key:component::light::title%]" + } + }, + "climate": { + "freezer": { + "name": "[%key:component::miele::device::freezer::name%]" + }, + "refrigerator": { + "name": "[%key:component::miele::device::refrigerator::name%]" + }, + "wine_cabinet": { + "name": "[%key:component::miele::device::wine_cabinet::name%]" + }, + "zone_1": { + "name": "Zone 1" + }, + "zone_2": { + "name": "Zone 2" + }, + "zone_3": { + "name": "Zone 3" + } + }, + "sensor": { + "elapsed_time": { + "name": "Elapsed time" + }, + "remaining_time": { + "name": "Remaining time" + }, + "start_time": { + "name": "Start in" + }, + "energy_consumption": { + "name": "Energy consumption" + }, + "plate": { + "name": "Plate {plate_no}", + "state": { + "0": "0", + "110": "Warming", + "220": "[%key:component::miele::entity::sensor::plate::state::110%]", + "1": "1", + "2": "1\u2022", + "3": "2", + "4": "2\u2022", + "5": "3", + "6": "3\u2022", + "7": "4", + "8": "4\u2022", + "9": "5", + "10": "5\u2022", + "11": "6", + "12": "6\u2022", + "13": "7", + "14": "7\u2022", + "15": "8", + "16": "8\u2022", + "17": "9", + "18": "9\u2022", + "117": "Boost", + "118": "[%key:component::miele::entity::sensor::plate::state::117%]", + "217": "[%key:component::miele::entity::sensor::plate::state::117%]" + } + }, + "drying_step": { + "name": "Drying step", + "state": { + "extra_dry": "Extra dry", + "hand_iron_1": "Hand iron 1", + "hand_iron_2": "Hand iron 2", + "machine_iron": "Machine iron", + "normal_plus": "Normal plus", + "normal": "Normal", + "slightly_dry": "Slightly dry", + "smoothing": "Smoothing" + } + }, + "program_phase": { + "name": "Program phase", + "state": { + "2nd_espresso": "2nd espresso coffee", + "2nd_grinding": "2nd grinding", + "2nd_pre_brewing": "2nd pre-brewing", + "anti_crease": "Anti-crease", + "blocked_brushes": "Brushes blocked", + "blocked_drive_wheels": "Drive wheels blocked", + "blocked_front_wheel": "Front wheel blocked", + "cleaning": "Cleaning", + "comfort_cooling": "Comfort cooling", + "cooling_down": "Cooling down", + "dirty_sensors": "Dirty sensors", + "disinfecting": "Disinfecting", + "dispensing": "Dispensing", + "docked": "Docked", + "door_open": "Door open", + "drain": "Drain", + "drying": "Drying", + "dust_box_missing": "Missing dust box", + "energy_save": "Energy save", + "espresso": "Espresso coffee", + "extra_dry": "Extra dry", + "final_rinse": "Final rinse", + "finished": "Finished", + "freshen_up_and_moisten": "Freshen up & moisten", + "going_to_target_area": "Going to target area", + "grinding": "Grinding", + "hand_iron": "Hand iron", + "hand_iron_1": "Hand iron 1", + "hand_iron_2": "Hand iron 2", + "heating": "Heating", + "heating_up": "Heating up", + "heating_up_phase": "Heating up phase", + "hot_milk": "Hot milk", + "hygiene": "Hygiene", + "interim_rinse": "Interim rinse", + "keep_warm": "Keep warm", + "keeping_warm": "Keeping warm", + "machine_iron": "Machine iron", + "main_dishwash": "Cleaning", + "main_wash": "Main wash", + "milk_foam": "Milk foam", + "moisten": "Moisten", + "motor_overload": "Check dust box and filter", + "normal": "Normal", + "normal_plus": "Normal plus", + "not_running": "Not running", + "pre_brewing": "Pre-brewing", + "pre_dishwash": "Pre-cleaning", + "pre_wash": "Pre-wash", + "process_finished": "Process finished", + "process_running": "Process running", + "program_running": "Program running", + "reactivating": "Reactivating", + "remote_controlled": "Remote controlled", + "returning": "Returning", + "rinse": "Rinse", + "rinse_hold": "Rinse hold", + "rinse_out_lint": "Rinse out lint", + "rinses": "Rinses", + "safety_cooling": "Safety cooling", + "slightly_dry": "Slightly dry", + "slow_roasting": "Slow roasting", + "smoothing": "Smoothing", + "soak": "Soak", + "spin": "Spin", + "starch_stop": "Starch stop", + "steam_reduction": "Steam reduction", + "steam_smoothing": "Steam smoothing", + "thermo_spin": "Thermo spin", + "timed_drying": "Timed drying", + "vacuum_cleaning": "Cleaning", + "vacuum_cleaning_paused": "Cleaning paused", + "vacuum_internal_fault": "Internal fault - reboot", + "venting": "Venting", + "waiting_for_start": "Waiting for start", + "warm_air": "Warm air", + "warm_cups_glasses": "Warm cups/glasses", + "warm_dishes_plates": "Warm dishes/plates", + "wheel_lifted": "Wheel lifted" + } + }, + "program_type": { + "name": "Program type", + "state": { + "automatic_program": "Automatic program", + "cleaning_care_program": "Cleaning/care program", + "maintenance_program": "Maintenance program", + "normal_operation_mode": "Normal operation mode", + "own_program": "Own program" + } + }, + "program_id": { + "name": "Program", + "state": { + "1_tray": "1 tray", + "2_trays": "2 trays", + "amaranth": "Amaranth", + "apples_diced": "Apples (diced)", + "apples_halved": "Apples (halved)", + "apples_quartered": "Apples (quartered)", + "apples_sliced": "Apples (sliced)", + "apples_whole": "Apples (whole)", + "appliance_rinse": "Appliance rinse", + "appliance_settings": "Appliance settings menu", + "apricots_halved_skinning": "Apricots (halved, skinning)", + "apricots_halved_steam_cooking": "Apricots (halved, steam cooking)", + "apricots_quartered": "Apricots (quartered)", + "apricots_wedges": "Apricots (wedges)", + "artichokes_large": "Artichokes large", + "artichokes_medium": "Artichokes medium", + "artichokes_small": "Artichokes small", + "atlantic_catfish_fillet_1_cm": "Atlantic catfish (fillet, 1 cm)", + "atlantic_catfish_fillet_2_cm": "Atlantic catfish (fillet, 2 cm)", + "auto": "[%key:common::state::auto%]", + "auto_roast": "Auto roast", + "automatic": "Automatic", + "automatic_plus": "Automatic plus", + "baking_tray": "Baking tray", + "barista_assistant": "BaristaAssistant", + "baser_one_large": "Baiser one large", + "baser_severall_small": "Baiser several small", + "basket_program": "Basket program", + "basmati_rice_rapid_steam_cooking": "Basmati rice (rapid steam cooking)", + "basmati_rice_steam_cooking": "Basmati rice (steam cooking)", + "bed_linen": "Bed linen", + "beef_casserole": "Beef casserole", + "beef_tenderloin": "Beef tenderloin", + "beef_tenderloin_medaillons_1_cm_low_temperature_cooking": "Beef tenderloin (medaillons, 1 cm, low-temperature cooking)", + "beef_tenderloin_medaillons_1_cm_steam_cooking": "Beef tenderloin (medaillons, 1 cm, steam cooking)", + "beef_tenderloin_medaillons_2_cm_low_temperature_cooking": "Beef tenderloin (medaillons, 2 cm, low-temperature cooking)", + "beef_tenderloin_medaillons_2_cm_steam_cooking": "Beef tenderloin (medaillons, 2 cm, steam cooking)", + "beef_tenderloin_medaillons_3_cm_low_temperature_cooking": "Beef tenderloin (medaillons, 3 cm, low-temperature cooking)", + "beef_tenderloin_medaillons_3_cm_steam_cooking": "Beef tenderloin (medaillons, 3 cm, steam cooking)", + "beetroot_whole_large": "Beetroot (whole, large)", + "beetroot_whole_medium": "Beetroot (whole, medium)", + "beetroot_whole_small": "Beetroot (whole, small)", + "beluga_lentils": "Beluga lentils", + "black_beans": "Black beans", + "black_salsify_medium": "Black salsify (medium)", + "black_salsify_thick": "Black salsify (thick)", + "black_salsify_thin": "Black salsify (thin)", + "black_tea": "Black tea", + "blanching": "Blanching", + "bologna_sausage": "Bologna sausage", + "bottling": "Bottling", + "bottling_hard": "Bottling (hard)", + "bottling_medium": "Bottling (medium)", + "bottling_soft": "Bottling (soft)", + "bottom_heat": "Bottom heat", + "bread_dumplings_boil_in_the_bag": "Bread dumplings (boil-in-the-bag)", + "bread_dumplings_fresh": "Bread dumplings (fresh)", + "brewing_unit_degrease": "Brewing unit degrease", + "broad_beans": "Broad beans", + "broccoli_florets_large": "Broccoli florets (large)", + "broccoli_florets_medium": "Broccoli florets (medium)", + "broccoli_florets_small": "Broccoli florets (small)", + "broccoli_whole_large": "Broccoli (whole, large)", + "broccoli_whole_medium": "Broccoli (whole, medium)", + "broccoli_whole_small": "Broccoli (whole, small)", + "brown_lentils": "Brown lentils", + "bruehwurst_sausages": "Brühwurst sausages", + "brussels_sprout": "Brussels sprout", + "bulgur": "Bulgur", + "bunched_carrots_cut_into_batons": "Bunched carrots (cut into batons)", + "bunched_carrots_diced": "Bunched carrots (diced)", + "bunched_carrots_halved": "Bunched carrots (halved)", + "bunched_carrots_quartered": "Bunched carrots (quartered)", + "bunched_carrots_sliced": "Bunched carrots (sliced)", + "bunched_carrots_whole_large": "Bunched carrots (whole, large)", + "bunched_carrots_whole_medium": "Bunched carrots (whole, medium)", + "bunched_carrots_whole_small": "Bunched carrots (whole, small)", + "cafe_au_lait": "Café au lait", + "caffe_latte": "Caffè latte", + "cappuccino": "Cappuccino", + "cappuccino_italiano": "Cappuccino Italiano", + "carp": "Carp", + "carrots_cut_into_batons": "Carrots (cut into batons)", + "carrots_diced": "Carrots (diced)", + "carrots_halved": "Carrots (halved)", + "carrots_quartered": "Carrots (quartered)", + "carrots_sliced": "Carrots (sliced)", + "carrots_whole_large": "Carrots (whole, large)", + "carrots_whole_medium": "Carrots (whole, medium)", + "carrots_whole_small": "Carrots (whole, small)", + "cauliflower_florets_large": "Cauliflower florets (large)", + "cauliflower_florets_medium": "Cauliflower florets (medium)", + "cauliflower_florets_small": "Cauliflower florets (small)", + "cauliflower_whole_large": "Cauliflower (whole, large)", + "cauliflower_whole_medium": "Cauliflower (whole, medium)", + "cauliflower_whole_small": "Cauliflower (whole, small)", + "celeriac_cut_into_batons": "Celeriac (cut into batons)", + "celeriac_diced": "Celeriac (diced)", + "celeriac_sliced": "Celeriac (sliced)", + "celery_pieces": "Celery (pieces)", + "celery_sliced": "Celery (sliced)", + "cep": "Cep", + "chanterelle": "Chanterelle", + "char": "Char", + "check_appliance": "Check appliance", + "cheesecake_one_large": "Cheesecake (one large)", + "cheesecake_several_small": "Cheesecake (several small)", + "chick_peas": "Chick peas", + "chicken_tikka_masala_with_rice": "Chicken Tikka Masala with rice", + "chinese_cabbage_cut": "Chinese cabbage (cut)", + "chongming_rapid_steam_cooking": "Chongming (rapid steam cooking)", + "chongming_steam_cooking": "Chongming (steam cooking)", + "christmas_pudding_cooking": "Christmas pudding (cooking)", + "christmas_pudding_heating": "Christmas pudding (heating)", + "clean_machine": "Clean machine", + "coalfish_fillet_2_cm": "Coalfish (fillet, 2 cm)", + "coalfish_fillet_3_cm": "Coalfish (fillet, 3 cm)", + "coalfish_piece": "Coalfish (piece)", + "cockles": "Cockles", + "codfish_fillet": "Codfish (fillet)", + "codfish_piece": "Codfish (piece)", + "coffee": "Coffee", + "coffee_pot": "Coffee pot", + "common_beans": "Common beans", + "common_sole_fillet_1_cm": "Common sole (fillet, 1 cm)", + "common_sole_fillet_2_cm": "Common sole (fillet, 2 cm)", + "conventional_heat": "Conventional heat", + "cook_bacon": "Cook bacon", + "cool_air": "Cool air", + "corn_on_the_cob": "Corn on the cob", + "cottons": "Cottons", + "cottons_eco": "Cottons ECO", + "cottons_hygiene": "Cottons hygiene", + "courgette_diced": "Courgette (diced)", + "courgette_sliced": "Courgette (sliced)", + "cranberries": "Cranberries", + "crevettes": "Crevettes", + "curtains": "Curtains", + "dark_garments": "Dark garments", + "decrystallise_honey": "Decrystallise honey", + "defrost": "Defrost", + "defrosting_with_microwave": "Defrosting with microwave", + "defrosting_with_steam": "Defrosting with steam", + "delicates": "Delicates", + "denim": "Denim", + "descale": "Descale", + "descaling": "Appliance descaling", + "dissolve_gelatine": "Dissolve gelatine", + "down_duvets": "Down duvets", + "down_filled_items": "Down-filled items", + "drain_spin": "Drain/spin", + "dutch_hash": "Dutch hash", + "eco": "ECO", + "eco_40_60": "ECO 40-60", + "eco_fan_heat": "ECO fan heat", + "eco_steam_cooking": "ECO steam cooking", + "economy_grill": "Economy grill", + "eggplant_diced": "Eggplant (diced)", + "eggplant_sliced": "Eggplant (sliced)", + "endive_halved": "Endive (halved)", + "endive_quartered": "Endive (quartered)", + "endive_strips": "Endive (strips)", + "espresso": "Espresso", + "espresso_macchiato": "Espresso macchiato", + "express": "Express", + "express_20": "Express 20'", + "extra_quiet": "Extra quiet", + "fan_grill": "Fan grill", + "fan_plus": "Fan plus", + "fennel_halved": "Fennel (halved)", + "fennel_quartered": "Fennel (quartered)", + "fennel_strips": "Fennel (strips)", + "first_wash": "First wash", + "flat_white": "Flat white", + "freshen_up": "Freshen up", + "fruit_tea": "Fruit tea", + "full_grill": "Full grill", + "gentle": "Gentle", + "gentle_denim": "Gentle denim", + "gentle_minimum_iron": "Gentle minimum iron", + "gentle_smoothing": "Gentle smoothing", + "german_turnip_cut_into_batons": "German turnip (cut into batons)", + "german_turnip_diced": "German turnip (diced)", + "gilt_head_bream_fillet": "Gilt-head bream (fillet)", + "gilt_head_bream_whole": "Gilt-head bream (whole)", + "glasses_warm": "Glasses warm", + "gnocchi_fresh": "Gnocchi (fresh)", + "goose_barnacles": "Goose barnacles", + "gooseberries": "Gooseberries", + "goulash_soup": "Goulash soup", + "green_asparagus_medium": "Green asparagus (medium)", + "green_asparagus_thick": "Green asparagus (thick)", + "green_asparagus_thin": "Green asparagus (thin)", + "green_beans_cut": "Green beans (cut)", + "green_beans_whole": "Green beans (whole)", + "green_cabbage_cut": "Green cabbage (cut)", + "green_spelt_cracked": "Green spelt (cracked)", + "green_spelt_whole": "Green spelt (whole)", + "green_split_peas": "Green split peas", + "green_tea": "Green tea", + "greenage_plums": "Greenage plums", + "halibut_fillet_2_cm": "Halibut (fillet, 2 cm)", + "halibut_fillet_3_cm": "Halibut (fillet, 3 cm)", + "heating_damp_flannels": "Heating damp flannels", + "hens_eggs_size_l_hard": "Hen’s eggs (size „L“, hard)", + "hens_eggs_size_l_medium": "Hen’s eggs (size „L“, medium)", + "hens_eggs_size_l_soft": "Hen’s eggs (size „L“, soft)", + "hens_eggs_size_m_hard": "Hen’s eggs (size „M“, hard)", + "hens_eggs_size_m_medium": "Hen’s eggs (size „M“, medium)", + "hens_eggs_size_m_soft": "Hen’s eggs (size „M“, soft)", + "hens_eggs_size_s_hard": "Hen’s eggs (size „S“, hard)", + "hens_eggs_size_s_medium": "Hen’s eggs (size „S“, medium)", + "hens_eggs_size_s_soft": "Hen’s eggs (size „S“, soft)", + "hens_eggs_size_xl_hard": "Hen’s eggs (size „XL“, hard)", + "hens_eggs_size_xl_medium": "Hen’s eggs (size „XL“, medium)", + "hens_eggs_size_xl_soft": "Hen’s eggs (size „XL“, soft)", + "herbal_tea": "Herbal tea", + "hot_milk": "Hot milk", + "hot_water": "Hot water", + "huanghuanian_rapid_steam_cooking": "Huanghuanian (rapid steam cooking)", + "huanghuanian_steam_cooking": "Huanghuanian (steam cooking)", + "hygiene": "Hygiene", + "intensive": "Intensive", + "intensive_bake": "Intensive bake", + "iridescent_shark_fillet": "Iridescent shark (fillet)", + "japanese_tea": "Japanese tea", + "jasmine_rice_rapid_steam_cooking": "Jasmine rice (rapid steam cooking)", + "jasmine_rice_steam_cooking": "Jasmine rice (steam cooking)", + "jerusalem_artichoke_diced": "Jerusalem artichoke (diced)", + "jerusalem_artichoke_sliced": "Jerusalem artichoke (sliced)", + "kale_cut": "Kale (cut)", + "kasseler_piece": "Kasseler (piece)", + "kasseler_slice": "Kasseler (slice)", + "keeping_warm": "Keeping warm", + "king_prawns": "King prawns", + "knuckle_of_pork_cured": "Knuckle of pork (cured)", + "knuckle_of_pork_fresh": "Knuckle of pork (fresh)", + "large_pillows": "Large pillows", + "large_shrimps": "Large shrimps", + "latte_macchiato": "Latte macchiato", + "leek_pieces": "Leek (pieces)", + "leek_rings": "Leek (rings)", + "lemon_meringue_pie": "Lemon meringue pie", + "long_coffee": "Long coffee", + "long_grain_rice_general_rapid_steam_cooking": "Long grain rice (general, rapid steam cooking)", + "long_grain_rice_general_steam_cooking": "Long grain rice (general, steam cooking)", + "low_temperature_cooking": "Low temperature cooking", + "maintenance": "Maintenance program", + "make_yoghurt": "Make yoghurt", + "mangel_cut": "Mangel (cut)", + "meat_for_soup_back_or_top_rib": "Meat for soup (back or top rib)", + "meat_for_soup_brisket": "Meat for soup (brisket)", + "meat_for_soup_leg_steak": "Meat for soup (leg steak)", + "meat_with_rice": "Meat with rice", + "melt_chocolate": "Melt chocolate", + "menu_cooking": "Menu cooking", + "microwave": "Microwave", + "milk_foam": "Milk foam", + "milk_pipework_clean": "Milk pipework clean", + "milk_pipework_rinse": "Milk pipework rinse", + "millet": "Millet", + "minimum_iron": "Minimum iron", + "mirabelles": "Mirabelles", + "moisture_plus_auto_roast": "Moisture plus + Auto roast", + "moisture_plus_conventional_heat": "Moisture plus + Conventional heat", + "moisture_plus_fan_plus": "Moisture plus + Fan plus", + "moisture_plus_intensive_bake": "Moisture plus + Intensive bake", + "mushrooms_diced": "Mushrooms (diced)", + "mushrooms_halved": "Mushrooms (halved)", + "mushrooms_quartered": "Mushrooms (quartered)", + "mushrooms_sliced": "Mushrooms (sliced)", + "mushrooms_whole": "Mushrooms (whole)", + "mussels": "Mussels", + "mussels_in_sauce": "Mussels in sauce", + "nectarines_peaches_halved_skinning": "Nectarines/peaches (halved, skinning)", + "nectarines_peaches_halved_steam_cooking": "Nectarines/peaches (halved, steam cooking)", + "nectarines_peaches_quartered": "Nectarines/peaches (quartered)", + "nectarines_peaches_wedges": "Nectarines/peaches (wedges)", + "nile_perch_fillet_2_cm": "Nile perch (fillet, 2 cm)", + "nile_perch_fillet_3_cm": "Nile perch (fillet, 3 cm)", + "no_program": "No program", + "normal": "[%key:common::state::normal%]", + "oats_cracked": "Oats (cracked)", + "oats_whole": "Oats (whole)", + "outerwear": "Outerwear", + "oyster_mushroom_diced": "Oyster mushroom (diced)", + "oyster_mushroom_strips": "Oyster mushroom (strips)", + "oyster_mushroom_whole": "Oyster mushroom (whole)", + "parboiled_rice_rapid_steam_cooking": "Parboiled rice (rapid steam cooking)", + "parboiled_rice_steam_cooking": "Parboiled rice (steam cooking)", + "parisian_carrots_large": "Parisian carrots (large)", + "parisian_carrots_medium": "Parisian carrots (medium)", + "parisian_carrots_small": "Parisian carrots (small)", + "parsley_root_cut_into_batons": "Parsley root (cut into batons)", + "parsley_root_diced": "Parsley root (diced)", + "parsley_root_sliced": "Parsley root (sliced)", + "parsnip_cut_into_batons": "Parsnip (cut into batons)", + "parsnip_diced": "Parsnip (diced)", + "parsnip_sliced": "Parsnip (sliced)", + "pasta_paela": "Pasta/Paela", + "pears_halved": "Pears (halved)", + "pears_quartered": "Pears (quartered)", + "pears_to_cook_large_halved": "Pears to cook (large, halved)", + "pears_to_cook_large_quartered": "Pears to cook (large, quartered)", + "pears_to_cook_large_whole": "Pears to cook (large, whole)", + "pears_to_cook_medium_halved": "Pears to cook (medium, halved)", + "pears_to_cook_medium_quartered": "Pears to cook (medium, quartered)", + "pears_to_cook_medium_whole": "Pears to cook (medium, whole)", + "pears_to_cook_small_halved": "Pears to cook (small, halved)", + "pears_to_cook_small_quartered": "Pears to cook (small, quartered)", + "pears_to_cook_small_whole": "Pears to cook (small, whole)", + "pears_wedges": "Pears (wedges)", + "peas": "Peas", + "pepper_diced": "Pepper (diced)", + "pepper_halved": "Pepper (halved)", + "pepper_quartered": "Pepper (quartered)", + "pepper_strips": "Pepper (strips)", + "perch_fillet_2_cm": "Perch (fillet, 2 cm)", + "perch_fillet_3_cm": "Perch (fillet, 3 cm)", + "perch_whole": "Perch (whole)", + "pike_fillet": "Pike (fillet)", + "pike_piece": "Pike (piece)", + "pillows": "Pillows", + "pinto_beans": "Pinto beans", + "plaice_fillet_1_cm": "Plaice (fillet, 1 cm)", + "plaice_fillet_2_cm": "Plaice (fillet, 2 cm)", + "plaice_whole_2_cm": "Plaice (whole, 2 cm)", + "plaice_whole_3_cm": "Plaice (whole, 3 cm)", + "plaice_whole_4_cm": "Plaice (whole, 4 cm)", + "plums_halved": "Plums (halved)", + "plums_whole": "Plums (whole)", + "pointed_cabbage_cut": "Pointed cabbage (cut)", + "polenta": "Polenta", + "polenta_swiss_style_coarse_polenta": "Polenta Swiss style (coarse polenta)", + "polenta_swiss_style_fine_polenta": "Polenta Swiss style (fine polenta)", + "polenta_swiss_style_medium_polenta": "Polenta Swiss style (medium polenta)", + "popcorn": "Popcorn", + "pork_tenderloin_medaillons_3_cm": "Pork tenderloin (medaillons, 3 cm)", + "pork_tenderloin_medaillons_4_cm": "Pork tenderloin (medaillons, 4 cm)", + "pork_tenderloin_medaillons_5_cm": "Pork tenderloin (medaillons, 5 cm)", + "potato_dumplings_half_half_boil_in_bag": "Potato dumplings (half/half, boil-in-bag)", + "potato_dumplings_half_half_deep_frozen": "Potato dumplings (half/half, deep-frozen)", + "potato_dumplings_raw_boil_in_bag": "Potato dumplings (raw, boil-in-bag)", + "potato_dumplings_raw_deep_frozen": "Potato dumplings (raw, deep-frozen)", + "potatoes_floury_diced": "Potatoes (floury, diced)", + "potatoes_floury_halved": "Potatoes (floury, halved)", + "potatoes_floury_quartered": "Potatoes (floury, quartered)", + "potatoes_floury_whole_large": "Potatoes (floury, whole, large)", + "potatoes_floury_whole_medium": "Potatoes (floury, whole, medium)", + "potatoes_floury_whole_small": "Potatoes (floury, whole, small)", + "potatoes_in_the_skin_floury_large": "Potatoes (in the skin, floury, large)", + "potatoes_in_the_skin_floury_medium": "Potatoes (in the skin, floury, medium)", + "potatoes_in_the_skin_floury_small": "Potatoes (in the skin, floury, small)", + "potatoes_in_the_skin_mainly_waxy_large": "Potatoes (in the skin, mainly waxy, large)", + "potatoes_in_the_skin_mainly_waxy_medium": "Potatoes (in the skin, mainly waxy, medium)", + "potatoes_in_the_skin_mainly_waxy_small": "Potatoes (in the skin, mainly waxy, small)", + "potatoes_in_the_skin_waxy_large_rapid_steam_cooking": "Potatoes (in the skin, waxy, large, rapid steam cooking)", + "potatoes_in_the_skin_waxy_large_steam_cooking": "Potatoes (in the skin, waxy, large, steam cooking)", + "potatoes_in_the_skin_waxy_medium_rapid_steam_cooking": "Potatoes (in the skin, waxy, medium, rapid steam cooking)", + "potatoes_in_the_skin_waxy_medium_steam_cooking": "Potatoes (in the skin, waxy, medium, steam cooking)", + "potatoes_in_the_skin_waxy_small_rapid_steam_cooking": "Potatoes (in the skin, waxy, small, rapid steam cooking)", + "potatoes_in_the_skin_waxy_small_steam_cooking": "Potatoes (in the skin, waxy, small, steam cooking)", + "potatoes_mainly_waxy_diced": "Potatoes (mainly waxy, diced)", + "potatoes_mainly_waxy_halved": "Potatoes (mainly waxy, halved)", + "potatoes_mainly_waxy_large": "Potatoes (mainly waxy, large)", + "potatoes_mainly_waxy_medium": "Potatoes (mainly waxy, medium)", + "potatoes_mainly_waxy_quartered": "Potatoes (mainly waxy, quartered)", + "potatoes_mainly_waxy_small": "Potatoes (mainly waxy, small)", + "potatoes_waxy_diced": "Potatoes (waxy, diced)", + "potatoes_waxy_halved": "Potatoes (waxy, halved)", + "potatoes_waxy_quartered": "Potatoes (waxy, quartered)", + "potatoes_waxy_whole_large": "Potatoes (waxy, whole, large)", + "potatoes_waxy_whole_medium": "Potatoes (waxy, whole, medium)", + "potatoes_waxy_whole_small": "Potatoes (waxy, whole, small)", + "poularde_breast": "Poularde breast", + "poularde_whole": "Poularde (whole)", + "power_wash": "PowerWash", + "prawns": "Prawns", + "proofing": "Proofing", + "prove_15_min": "Prove for 15 min", + "prove_30_min": "Prove for 30 min", + "prove_45_min": "Prove for 45 min", + "prove_dough": "Prove dough", + "pumpkin_diced": "Pumpkin (diced)", + "pumpkin_soup": "Pumpkin soup", + "pyrolytic": "Pyrolytic", + "quick_mw": "Quick MW", + "quick_power_wash": "QuickPowerWash", + "quinces_diced": "Quinces (diced)", + "quinoa": "Quinoa", + "rapid_steam_cooking": "Rapid steam cooking", + "ravioli_fresh": "Ravioli (fresh)", + "razor_clams_large": "Razor clams (large)", + "razor_clams_medium": "Razor clams (medium)", + "razor_clams_small": "Razor clams (small)", + "red_beans": "Red beans", + "red_cabbage_cut": "Red cabbage (cut)", + "red_lentils": "Red lentils", + "red_snapper_fillet_2_cm": "Red snapper (fillet, 2 cm)", + "red_snapper_fillet_3_cm": "Red snapper (fillet, 3 cm)", + "redfish_fillet_2_cm": "Redfish (fillet, 2 cm)", + "redfish_fillet_3_cm": "Redfish (fillet, 3 cm)", + "redfish_piece": "Redfish (piece)", + "reheating_with_microwave": "Reheating with microwave", + "reheating_with_steam": "Reheating with steam", + "rhubarb_chunks": "Rhubarb chunks", + "rice_pudding_rapid_steam_cooking": "Rice pudding (rapid steam cooking)", + "rice_pudding_steam_cooking": "Rice pudding (steam cooking)", + "rinse": "Rinse", + "rinse_out_lint": "Rinse out lint", + "risotto": "Risotto", + "ristretto": "Ristretto", + "romanesco_florets_large": "Romanesco florets (large)", + "romanesco_florets_medium": "Romanesco florets (medium)", + "romanesco_florets_small": "Romanesco florets (small)", + "romanesco_whole_large": "Romanesco (whole, large)", + "romanesco_whole_medium": "Romanesco (whole, medium)", + "romanesco_whole_small": "Romanesco (whole, small)", + "round_grain_rice_general_rapid_steam_cooking": "Round grain rice (general, rapid steam cooking)", + "round_grain_rice_general_steam_cooking": "Round grain rice (general, steam cooking)", + "runner_beans_pieces": "Runner beans (pieces)", + "runner_beans_sliced": "Runner beans (sliced)", + "runner_beans_whole": "Runner beans (whole)", + "rye_cracked": "Rye (cracked)", + "rye_whole": "Rye (whole)", + "salmon_fillet_2_cm": "Salmon (fillet, 2 cm)", + "salmon_fillet_3_cm": "Salmon (fillet, 3 cm)", + "salmon_piece": "Salmon (piece)", + "salmon_steak_2_cm": "Salmon (steak, 2 cm)", + "salmon_steak_3_cm": "Salmon (steak, 3 cm)", + "salmon_trout": "Salmon trout", + "saucisson": "Saucisson", + "savoy_cabbage_cut": "Savoy cabbage (cut)", + "scallops": "Scallops", + "schupfnudeln_potato_noodels": "Schupfnudeln (potato noodels)", + "sea_devil_fillet_3_cm": "Sea devil (fillet, 3 cm)", + "sea_devil_fillet_4_cm": "Sea devil (fillet, 4 cm)", + "separate_rinse_starch": "Separate rinse/starch", + "shabbat_program": "Shabbat program", + "sheyang_rapid_steam_cooking": "Sheyang (rapid steam cooking)", + "sheyang_steam_cooking": "Sheyang (steam cooking)", + "shirts": "Shirts", + "silent": "Silent", + "silks": "Silks", + "silks_handcare": "Silks handcare", + "silverside_10_cm": "Silverside (10 cm)", + "silverside_5_cm": "Silverside (5 cm)", + "silverside_7_5_cm": "Silverside (7.5 cm)", + "simiao_rapid_steam_cooking": "Simiao (rapid steam cooking)", + "simiao_steam_cooking": "Simiao (steam cooking)", + "small_shrimps": "Small shrimps", + "smoothing": "Smoothing", + "snow_pea": "Snow pea", + "soak": "Soak", + "solar_save": "SolarSave", + "soup_hen": "Soup hen", + "sour_cherries": "Sour cherries", + "sous_vide": "Sous-vide", + "spaetzle_fresh": "Spätzle (fresh)", + "spelt_cracked": "Spelt (cracked)", + "spelt_whole": "Spelt (whole)", + "spinach": "Spinach", + "sportswear": "Sportswear", + "spot": "Spot", + "standard_pillows": "Standard pillows", + "starch": "Starch", + "steam_care": "Steam care", + "steam_cooking": "Steam cooking", + "steam_smoothing": "Steam smoothing", + "sterilize_crockery": "Sterilize crockery", + "stuffed_cabbage": "Stuffed cabbage", + "sweat_onions": "Sweat onions", + "swede_cut_into_batons": "Swede (cut into batons)", + "swede_diced": "Swede (diced)", + "sweet_cheese_dumplings": "Sweet cheese dumplings", + "sweet_cherries": "Sweet cherries", + "swiss_toffee_cream_100_ml": "Swiss toffee cream (100 ml)", + "swiss_toffee_cream_150_ml": "Swiss toffee cream (150 ml)", + "tagliatelli_fresh": "Tagliatelli (fresh)", + "tall_items": "Tall items", + "teltow_turnip_diced": "Teltow turnip (diced)", + "teltow_turnip_sliced": "Teltow turnip (sliced)", + "tilapia_fillet_1_cm": "Tilapia (fillet, 1 cm)", + "tilapia_fillet_2_cm": "Tilapia (fillet, 2 cm)", + "toffee_date_dessert_one_large": "Toffee-date dessert (one large)", + "toffee_date_dessert_several_small": "Toffee-date dessert (several small)", + "top_heat": "Top heat", + "tortellini_fresh": "Tortellini (fresh)", + "trainers": "Trainers", + "treacle_sponge_pudding_one_large": "Treacle sponge pudding (one large)", + "treacle_sponge_pudding_several_small": "Treacle sponge pudding (several small)", + "trout": "Trout", + "tuna_fillet_2_cm": "Tuna (fillet, 2 cm)", + "tuna_fillet_3_cm": "Tuna (fillet, 3 cm)", + "tuna_steak": "Tuna (steak)", + "turbo": "Turbo", + "turbot_fillet_2_cm": "Turbot (fillet, 2 cm)", + "turbot_fillet_3_cm": "Turbot (fillet, 3 cm)", + "turkey_breast": "Turkey breast", + "uonumma_koshihikari_rapid_steam_cooking": "Uonumma Koshihikari (rapid steam cooking)", + "uonumma_koshihikari_steam_cooking": "Uonumma Koshihikari (steam cooking)", + "veal_fillet_medaillons_1_cm": "Veal fillet (medaillons, 1 cm)", + "veal_fillet_medaillons_2_cm": "Veal fillet (medaillons, 2 cm)", + "veal_fillet_medaillons_3_cm": "Veal fillet (medaillons, 3 cm)", + "veal_fillet_whole": "Veal fillet (whole)", + "veal_sausages": "Veal sausages", + "venus_clams": "Venus clams", + "very_hot_water": "Very hot water", + "viennese_apple_strudel": "Viennese apple strudel", + "viennese_silverside": "Viennese silverside", + "warm_air": "Warm air", + "wheat_cracked": "Wheat (cracked)", + "wheat_whole": "Wheat (whole)", + "white_asparagus_medium": "White asparagus (medium)", + "white_asparagus_thick": "White asparagus (thick)", + "white_asparagus_thin": "White asparagus (thin)", + "white_beans": "White beans", + "white_tea": "White tea", + "whole_ham_reheating": "Whole ham (reheating)", + "whole_ham_steam_cooking": "Whole ham (steam cooking)", + "wholegrain_rice": "Wholegrain rice", + "wild_rice": "Wild rice", + "woollens": "Woollens", + "woollens_handcare": "Woollens hand care", + "wuchang_rapid_steam_cooking": "Wuchang (rapid steam cooking)", + "wuchang_steam_cooking": "Wuchang (steam cooking)", + "yam_halved": "Yam (halved)", + "yam_quartered": "Yam (quartered)", + "yam_strips": "Yam (strips)", + "yeast_dumplings_fresh": "Yeast dumplings (fresh)", + "yellow_beans_cut": "Yellow beans (cut)", + "yellow_beans_whole": "Yellow beans (whole)", + "yellow_split_peas": "Yellow split peas", + "yom_tov": "Yom tov", + "zander_fillet": "Zander (fillet)" + } + }, + "spin_speed": { + "name": "Spin speed" + }, + "status": { + "name": "Status", + "state": { + "autocleaning": "Automatic cleaning", + "failure": "Failure", + "idle": "[%key:common::state::idle%]", + "not_connected": "Not connected", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "pause": "Pause", + "program_ended": "Program ended", + "program_interrupted": "Program interrupted", + "programmed": "Programmed", + "rinse_hold": "Rinse hold", + "in_use": "In use", + "service": "Service", + "supercooling": "Supercooling", + "supercooling_superfreezing": "Supercooling/superfreezing", + "superfreezing": "Superfreezing", + "superheating": "Superheating", + "waiting_to_start": "Waiting to start" + } + }, + "temperature_zone_2": { + "name": "Temperature zone 2" + }, + "temperature_zone_3": { + "name": "Temperature zone 3" + }, + "water_consumption": { + "name": "Water consumption" + }, + "core_temperature": { + "name": "Core temperature" + }, + "target_temperature": { + "name": "Target temperature" + }, + "core_target_temperature": { + "name": "Core target temperature" + }, + "energy_forecast": { + "name": "Energy forecast" + }, + "water_forecast": { + "name": "Water forecast" + } + }, + "switch": { + "power": { + "name": "Power" + }, + "supercooling": { + "name": "Supercooling" + }, + "superfreezing": { + "name": "Superfreezing" + } + } + }, + "exceptions": { + "config_entry_auth_failed": { + "message": "Authentication failed. Please log in again." + }, + "config_entry_not_ready": { + "message": "Error while loading the integration." + }, + "set_state_error": { + "message": "Failed to set state for {entity}." + } + } +} diff --git a/homeassistant/components/miele/switch.py b/homeassistant/components/miele/switch.py new file mode 100644 index 00000000000..af46ef2c917 --- /dev/null +++ b/homeassistant/components/miele/switch.py @@ -0,0 +1,224 @@ +"""Switch platform for Miele switch integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Final, cast + +import aiohttp +from pymiele import MieleDevice + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import ( + DOMAIN, + POWER_OFF, + POWER_ON, + PROCESS_ACTION, + MieleActions, + MieleAppliance, + StateStatus, +) +from .coordinator import MieleConfigEntry +from .entity import MieleEntity + +PARALLEL_UPDATES = 1 + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleSwitchDescription(SwitchEntityDescription): + """Class describing Miele switch entities.""" + + value_fn: Callable[[MieleDevice], StateType] + on_value: int = 0 + off_value: int = 0 + on_cmd_data: dict[str, str | int | bool] + off_cmd_data: dict[str, str | int | bool] + + +@dataclass +class MieleSwitchDefinition: + """Class for defining switch entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleSwitchDescription + + +SWITCH_TYPES: Final[tuple[MieleSwitchDefinition, ...]] = ( + MieleSwitchDefinition( + types=(MieleAppliance.FRIDGE, MieleAppliance.FRIDGE_FREEZER), + description=MieleSwitchDescription( + key="supercooling", + value_fn=lambda value: value.state_status, + on_value=StateStatus.SUPERCOOLING, + translation_key="supercooling", + on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERCOOL}, + off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERCOOL}, + ), + ), + MieleSwitchDefinition( + types=( + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleSwitchDescription( + key="superfreezing", + value_fn=lambda value: value.state_status, + on_value=StateStatus.SUPERFREEZING, + translation_key="superfreezing", + on_cmd_data={PROCESS_ACTION: MieleActions.START_SUPERFREEZE}, + off_cmd_data={PROCESS_ACTION: MieleActions.STOP_SUPERFREEZE}, + ), + ), + MieleSwitchDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.DISH_WARMER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.WASHER_DRYER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSwitchDescription( + key="poweronoff", + value_fn=lambda value: value.state_status, + off_value=1, + translation_key="power", + on_cmd_data={POWER_ON: True}, + off_cmd_data={POWER_OFF: True}, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switch platform.""" + coordinator = config_entry.runtime_data + added_devices: set[str] = set() + + def _async_add_new_devices() -> None: + nonlocal added_devices + new_devices_set, current_devices = coordinator.async_add_devices(added_devices) + added_devices = current_devices + + entities = [] + for device_id, device in coordinator.data.devices.items(): + for definition in SWITCH_TYPES: + if ( + device_id in new_devices_set + and device.device_type in definition.types + ): + entity_class: type[MieleSwitch] = MieleSwitch + match definition.description.key: + case "poweronoff": + entity_class = MielePowerSwitch + case "supercooling" | "superfreezing": + entity_class = MieleSuperSwitch + + entities.append( + entity_class(coordinator, device_id, definition.description) + ) + async_add_entities(entities) + + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) + _async_add_new_devices() + + +class MieleSwitch(MieleEntity, SwitchEntity): + """Representation of a Switch.""" + + entity_description: MieleSwitchDescription + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + await self.async_turn_switch(self.entity_description.on_cmd_data) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + await self.async_turn_switch(self.entity_description.off_cmd_data) + + async def async_turn_switch(self, mode: dict[str, str | int | bool]) -> None: + """Set switch to mode.""" + try: + await self.api.send_action(self._device_id, mode) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from err + + +class MielePowerSwitch(MieleSwitch): + """Representation of a power switch.""" + + entity_description: MieleSwitchDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self.action.power_off_enabled + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + + return ( + self.action.power_off_enabled or self.action.power_on_enabled + ) and super().available + + async def async_turn_switch(self, mode: dict[str, str | int | bool]) -> None: + """Set switch to mode.""" + try: + await self.api.send_action(self._device_id, mode) + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from err + self.action.power_on_enabled = cast(bool, mode) + self.action.power_off_enabled = not cast(bool, mode) + self.async_write_ha_state() + + +class MieleSuperSwitch(MieleSwitch): + """Representation of a supercool/superfreeze switch.""" + + entity_description: MieleSwitchDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return ( + self.entity_description.value_fn(self.device) + == self.entity_description.on_value + ) diff --git a/homeassistant/components/miele/vacuum.py b/homeassistant/components/miele/vacuum.py new file mode 100644 index 00000000000..29a89e39bdb --- /dev/null +++ b/homeassistant/components/miele/vacuum.py @@ -0,0 +1,226 @@ +"""Platform for Miele vacuum integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +import logging +from typing import Any, Final + +from aiohttp import ClientResponseError +from pymiele import MieleEnum + +from homeassistant.components.vacuum import ( + StateVacuumEntity, + StateVacuumEntityDescription, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, PROCESS_ACTION, PROGRAM_ID, MieleActions, MieleAppliance +from .coordinator import MieleConfigEntry +from .entity import MieleEntity + +PARALLEL_UPDATES = 1 + +_LOGGER = logging.getLogger(__name__) + +# The following const classes define program speeds and programs for the vacuum cleaner. +# Miele have used the same and overlapping names for fan_speeds and programs even +# if the contexts are different. This is an attempt to make it clearer in the integration. + + +class FanSpeed(IntEnum): + """Define fan speeds.""" + + normal = 0 + turbo = 1 + silent = 2 + + +class FanProgram(IntEnum): + """Define fan programs.""" + + auto = 1 + spot = 2 + turbo = 3 + silent = 4 + + +PROGRAM_MAP = { + "normal": FanProgram.auto, + "turbo": FanProgram.turbo, + "silent": FanProgram.silent, +} + +PROGRAM_TO_SPEED: dict[int, str] = { + FanProgram.auto: "normal", + FanProgram.turbo: "turbo", + FanProgram.silent: "silent", + FanProgram.spot: "normal", +} + + +class MieleVacuumStateCode(MieleEnum): + """Define vacuum state codes.""" + + idle = 0 + cleaning = 5889 + returning = 5890 + paused = 5891 + going_to_target_area = 5892 + wheel_lifted = 5893 + dirty_sensors = 5894 + dust_box_missing = 5895 + blocked_drive_wheels = 5896 + blocked_brushes = 5897 + check_dust_box_and_filter = 5898 + internal_fault_reboot = 5899 + blocked_front_wheel = 5900 + docked = 5903, 5904 + remote_controlled = 5910 + missing2none = -9999 + + +SUPPORTED_FEATURES = ( + VacuumEntityFeature.STATE + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.CLEAN_SPOT +) + + +@dataclass(frozen=True, kw_only=True) +class MieleVacuumDescription(StateVacuumEntityDescription): + """Class describing Miele vacuum entities.""" + + on_value: int + + +@dataclass +class MieleVacuumDefinition: + """Class for defining vacuum entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleVacuumDescription + + +VACUUM_TYPES: Final[tuple[MieleVacuumDefinition, ...]] = ( + MieleVacuumDefinition( + types=(MieleAppliance.ROBOT_VACUUM_CLEANER,), + description=MieleVacuumDescription( + key="vacuum", + on_value=14, + name=None, + translation_key="vacuum", + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the vacuum platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + MieleVacuum(coordinator, device_id, definition.description) + for device_id, device in coordinator.data.devices.items() + for definition in VACUUM_TYPES + if device.device_type in definition.types + ) + + +VACUUM_PHASE_TO_ACTIVITY = { + MieleVacuumStateCode.idle.value: VacuumActivity.IDLE, + MieleVacuumStateCode.docked.value: VacuumActivity.DOCKED, + MieleVacuumStateCode.cleaning.value: VacuumActivity.CLEANING, + MieleVacuumStateCode.going_to_target_area.value: VacuumActivity.CLEANING, + MieleVacuumStateCode.returning.value: VacuumActivity.RETURNING, + MieleVacuumStateCode.wheel_lifted.value: VacuumActivity.ERROR, + MieleVacuumStateCode.dirty_sensors.value: VacuumActivity.ERROR, + MieleVacuumStateCode.dust_box_missing.value: VacuumActivity.ERROR, + MieleVacuumStateCode.blocked_drive_wheels.value: VacuumActivity.ERROR, + MieleVacuumStateCode.blocked_brushes.value: VacuumActivity.ERROR, + MieleVacuumStateCode.check_dust_box_and_filter.value: VacuumActivity.ERROR, + MieleVacuumStateCode.internal_fault_reboot.value: VacuumActivity.ERROR, + MieleVacuumStateCode.blocked_front_wheel.value: VacuumActivity.ERROR, + MieleVacuumStateCode.paused.value: VacuumActivity.PAUSED, + MieleVacuumStateCode.remote_controlled.value: VacuumActivity.PAUSED, +} + + +class MieleVacuum(MieleEntity, StateVacuumEntity): + """Representation of a Vacuum entity.""" + + entity_description: MieleVacuumDescription + _attr_supported_features = SUPPORTED_FEATURES + _attr_fan_speed_list = [fan_speed.name for fan_speed in FanSpeed] + _attr_name = None + + @property + def activity(self) -> VacuumActivity | None: + """Return activity.""" + return VACUUM_PHASE_TO_ACTIVITY.get( + MieleVacuumStateCode(self.device.state_program_phase).value + ) + + @property + def battery_level(self) -> int | None: + """Return the battery level.""" + return self.device.state_battery_level + + @property + def fan_speed(self) -> str | None: + """Return the fan speed.""" + return PROGRAM_TO_SPEED.get(self.device.state_program_id) + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + + return ( + self.action.power_off_enabled or self.action.power_on_enabled + ) and super().available + + async def send(self, device_id: str, action: dict[str, Any]) -> None: + """Send action to the device.""" + try: + await self.api.send_action(device_id, action) + except ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_state_error", + translation_placeholders={ + "entity": self.entity_id, + }, + ) from ex + + async def async_clean_spot(self, **kwargs: Any) -> None: + """Clean spot.""" + await self.send(self._device_id, {PROGRAM_ID: FanProgram.spot}) + + async def async_start(self, **kwargs: Any) -> None: + """Start cleaning.""" + await self.send(self._device_id, {PROCESS_ACTION: MieleActions.START}) + + async def async_stop(self, **kwargs: Any) -> None: + """Stop cleaning.""" + await self.send(self._device_id, {PROCESS_ACTION: MieleActions.STOP}) + + async def async_pause(self, **kwargs: Any) -> None: + """Pause cleaning.""" + await self.send(self._device_id, {PROCESS_ACTION: MieleActions.PAUSE}) + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + await self.send(self._device_id, {PROGRAM_ID: PROGRAM_MAP[fan_speed]}) diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 2fcf2033930..246ea778916 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL -from .coordinator import MillDataUpdateCoordinator +from .coordinator import MillDataUpdateCoordinator, MillHistoricDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] @@ -41,6 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key = entry.data[CONF_USERNAME] conn_type = CLOUD + historic_data_coordinator = MillHistoricDataUpdateCoordinator( + hass, + mill_data_connection=mill_data_connection, + ) + historic_data_coordinator.async_add_listener(lambda: None) + await historic_data_coordinator.async_config_entry_first_refresh() try: if not await mill_data_connection.connect(): raise ConfigEntryNotReady diff --git a/homeassistant/components/mill/coordinator.py b/homeassistant/components/mill/coordinator.py index ae527f8cce5..a701acb8ddb 100644 --- a/homeassistant/components/mill/coordinator.py +++ b/homeassistant/components/mill/coordinator.py @@ -4,18 +4,30 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import cast -from mill import Mill +from mill import Heater, Mill from mill_local import Mill as MillLocal +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util, slugify from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +TWO_YEARS_DAYS = 2 * 365 + class MillDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Mill data.""" @@ -40,3 +52,104 @@ class MillDataUpdateCoordinator(DataUpdateCoordinator): update_method=mill_data_connection.fetch_heater_and_sensor_data, update_interval=update_interval, ) + + +class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Mill historic data.""" + + def __init__( + self, + hass: HomeAssistant, + *, + mill_data_connection: Mill, + ) -> None: + """Initialize global Mill data updater.""" + self.mill_data_connection = mill_data_connection + + super().__init__( + hass, + _LOGGER, + name="MillHistoricDataUpdateCoordinator", + ) + + async def _async_update_data(self): + """Update historic data via API.""" + now = dt_util.utcnow() + self.update_interval = ( + timedelta(hours=1) + now.replace(minute=1, second=0) - now + ) + + recoder_instance = get_instance(self.hass) + for dev_id, heater in self.mill_data_connection.devices.items(): + if not isinstance(heater, Heater): + continue + statistic_id = f"{DOMAIN}:energy_{slugify(dev_id)}" + + last_stats = await recoder_instance.async_add_executor_job( + get_last_statistics, self.hass, 1, statistic_id, True, set() + ) + if not last_stats or not last_stats.get(statistic_id): + hourly_data = ( + await self.mill_data_connection.fetch_historic_energy_usage( + dev_id, n_days=TWO_YEARS_DAYS + ) + ) + hourly_data = dict(sorted(hourly_data.items(), key=lambda x: x[0])) + _sum = 0.0 + last_stats_time = None + else: + hourly_data = ( + await self.mill_data_connection.fetch_historic_energy_usage( + dev_id, + n_days=( + now + - dt_util.utc_from_timestamp( + last_stats[statistic_id][0]["start"] + ) + ).days + + 2, + ) + ) + if not hourly_data: + continue + hourly_data = dict(sorted(hourly_data.items(), key=lambda x: x[0])) + start_time = next(iter(hourly_data)) + stats = await recoder_instance.async_add_executor_job( + statistics_during_period, + self.hass, + start_time, + None, + {statistic_id}, + "hour", + None, + {"sum", "state"}, + ) + stat = stats[statistic_id][0] + + _sum = cast(float, stat["sum"]) - cast(float, stat["state"]) + last_stats_time = dt_util.utc_from_timestamp(stat["start"]) + + statistics = [] + + for start, state in hourly_data.items(): + if state is None: + continue + if (last_stats_time and start < last_stats_time) or start > now: + continue + _sum += state + statistics.append( + StatisticData( + start=start, + state=state, + sum=_sum, + ) + ) + metadata = StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"{heater.name}", + source=DOMAIN, + statistic_id=statistic_id, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ) + async_add_external_statistics(self.hass, metadata, statistics) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 44c1136b7d5..c5cc94ead30 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -1,10 +1,11 @@ { "domain": "mill", "name": "Mill", + "after_dependencies": ["recorder"], "codeowners": ["@danielhiversen"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.12.3", "mill-local==0.3.0"] + "requirements": ["millheater==0.12.5", "mill-local==0.3.0"] } diff --git a/homeassistant/components/mjpeg/config_flow.py b/homeassistant/components/mjpeg/config_flow.py index e0150f8c461..a0efb56c224 100644 --- a/homeassistant/components/mjpeg/config_flow.py +++ b/homeassistant/components/mjpeg/config_flow.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping from http import HTTPStatus -from types import MappingProxyType from typing import Any import requests @@ -34,7 +34,7 @@ from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER @callback def async_get_schema( - defaults: dict[str, Any] | MappingProxyType[str, Any], show_name: bool = False + defaults: Mapping[str, Any], show_name: bool = False ) -> vol.Schema: """Return MJPEG IP Camera schema.""" schema = { diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 61df7206402..ab387030af8 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -62,8 +62,10 @@ from .const import ( CALL_TYPE_X_COILS, CALL_TYPE_X_REGISTER_HOLDINGS, CONF_BAUDRATE, + CONF_BRIGHTNESS_REGISTER, CONF_BYTESIZE, CONF_CLIMATES, + CONF_COLOR_TEMP_REGISTER, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_FAN_MODE_AUTO, @@ -79,6 +81,16 @@ from .const import ( CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, CONF_FANS, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -297,6 +309,45 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), + vol.Optional(CONF_HVAC_ACTION_REGISTER): vol.Maybe( + { + CONF_ADDRESS: cv.positive_int, + CONF_HVAC_ACTION_VALUES: { + vol.Optional(CONF_HVAC_ACTION_COOLING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_DEFROSTING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_DRYING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_FAN): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_HEATING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_IDLE): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_OFF): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_PREHEATING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + }, + vol.Optional( + CONF_INPUT_TYPE, default=CALL_TYPE_REGISTER_HOLDING + ): vol.In( + [ + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + ] + ), + } + ), vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( vol.All( { @@ -366,7 +417,14 @@ SWITCH_SCHEMA = BASE_SWITCH_SCHEMA.extend( } ) -LIGHT_SCHEMA = BASE_SWITCH_SCHEMA.extend({}) +LIGHT_SCHEMA = BASE_SWITCH_SCHEMA.extend( + { + vol.Optional(CONF_BRIGHTNESS_REGISTER): cv.positive_int, + vol.Optional(CONF_COLOR_TEMP_REGISTER): cv.positive_int, + vol.Optional(CONF_MIN_TEMP): cv.positive_int, + vol.Optional(CONF_MAX_TEMP): cv.positive_int, + } +) FAN_SCHEMA = BASE_SWITCH_SCHEMA.extend({}) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index fca1b94611a..be10a9495c6 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -24,6 +24,7 @@ from homeassistant.components.climate import ( SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ( @@ -61,6 +62,16 @@ from .const import ( CONF_FAN_MODE_REGISTER, CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -74,6 +85,7 @@ from .const import ( CONF_HVAC_ON_VALUE, CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, + CONF_INPUT_TYPE, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_STEP, @@ -188,6 +200,34 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.AUTO] + if CONF_HVAC_ACTION_REGISTER in config: + action_config = config[CONF_HVAC_ACTION_REGISTER] + self._hvac_action_register = action_config[CONF_ADDRESS] + self._hvac_action_type = action_config[CONF_INPUT_TYPE] + + self._attr_hvac_action = None + self._hvac_action_mapping: list[tuple[int, HVACAction]] = [] + action_value_config = action_config[CONF_HVAC_ACTION_VALUES] + + for hvac_action_kw, hvac_action in ( + (CONF_HVAC_ACTION_COOLING, HVACAction.COOLING), + (CONF_HVAC_ACTION_DEFROSTING, HVACAction.DEFROSTING), + (CONF_HVAC_ACTION_DRYING, HVACAction.DRYING), + (CONF_HVAC_ACTION_FAN, HVACAction.FAN), + (CONF_HVAC_ACTION_HEATING, HVACAction.HEATING), + (CONF_HVAC_ACTION_IDLE, HVACAction.IDLE), + (CONF_HVAC_ACTION_OFF, HVACAction.OFF), + (CONF_HVAC_ACTION_PREHEATING, HVACAction.PREHEATING), + ): + if hvac_action_kw in action_value_config: + values = action_value_config[hvac_action_kw] + if not isinstance(values, list): + values = [values] + for value in values: + self._hvac_action_mapping.append((value, hvac_action)) + else: + self._hvac_action_register = None + if CONF_FAN_MODE_REGISTER in config: self._attr_supported_features = ( self._attr_supported_features | ClimateEntityFeature.FAN_MODE @@ -216,7 +256,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._fan_mode_mapping_from_modbus[value] = fan_mode self._fan_mode_mapping_to_modbus[fan_mode] = value self._attr_fan_modes.append(fan_mode) - else: # No FAN modes defined self._fan_mode_register = None @@ -457,6 +496,20 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = mode break + # Read the HVAC action register if defined + if self._hvac_action_register is not None: + hvac_action = await self._async_read_register( + self._hvac_action_type, self._hvac_action_register, raw=True + ) + + # Translate the value received + if hvac_action is not None: + self._attr_hvac_action = None + for value, action in self._hvac_action_mapping: + if hvac_action == value: + self._attr_hvac_action = action + break + # Read the Fan mode register if defined if self._fan_mode_register is not None: fan_mode = await self._async_read_register( diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 5926569040d..068a46b1f81 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -16,6 +16,8 @@ from homeassistant.const import ( CONF_BAUDRATE = "baudrate" CONF_BYTESIZE = "bytesize" CONF_CLIMATES = "climates" +CONF_BRIGHTNESS_REGISTER = "brightness_address" +CONF_COLOR_TEMP_REGISTER = "color_temp_address" CONF_DATA_TYPE = "data_type" CONF_DEVICE_ADDRESS = "device_address" CONF_FANS = "fans" @@ -63,6 +65,16 @@ CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_ON_VALUE = "hvac_on_value" CONF_HVAC_OFF_VALUE = "hvac_off_value" CONF_HVAC_ONOFF_COIL = "hvac_onoff_coil" +CONF_HVAC_ACTION_REGISTER = "hvac_action_register" +CONF_HVAC_ACTION_COOLING = "action_cooling" +CONF_HVAC_ACTION_DEFROSTING = "action_defrosting" +CONF_HVAC_ACTION_DRYING = "action_drying" +CONF_HVAC_ACTION_FAN = "action_fan" +CONF_HVAC_ACTION_HEATING = "action_heating" +CONF_HVAC_ACTION_IDLE = "action_idle" +CONF_HVAC_ACTION_OFF = "action_off" +CONF_HVAC_ACTION_PREHEATING = "action_preheating" +CONF_HVAC_ACTION_VALUES = "values" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" CONF_HVAC_MODE_COOL = "state_cool" @@ -157,3 +169,11 @@ PLATFORMS = ( (Platform.SENSOR, CONF_SENSORS), (Platform.SWITCH, CONF_SWITCHES), ) + +LIGHT_DEFAULT_MIN_KELVIN = 2000 +LIGHT_DEFAULT_MAX_KELVIN = 7000 +LIGHT_MIN_BRIGHTNESS = 0 +LIGHT_MAX_BRIGHTNESS = 255 +LIGHT_MODBUS_SCALE_MIN = 0 +LIGHT_MODBUS_SCALE_MAX = 100 +LIGHT_MODBUS_INVALID_VALUE = 0xFFFF diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index ce1c881733e..c025eefe0e4 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -2,18 +2,40 @@ from __future__ import annotations +import logging from typing import Any -from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ColorMode, + LightEntity, +) from homeassistant.const import CONF_LIGHTS, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub +from .const import ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_WRITE_REGISTER, + CONF_BRIGHTNESS_REGISTER, + CONF_COLOR_TEMP_REGISTER, + CONF_MAX_TEMP, + CONF_MIN_TEMP, + LIGHT_DEFAULT_MAX_KELVIN, + LIGHT_DEFAULT_MIN_KELVIN, + LIGHT_MAX_BRIGHTNESS, + LIGHT_MODBUS_INVALID_VALUE, + LIGHT_MODBUS_SCALE_MAX, + LIGHT_MODBUS_SCALE_MIN, +) from .entity import BaseSwitch +from .modbus import ModbusHub PARALLEL_UPDATES = 1 +_LOGGER = logging.getLogger(__name__) async def async_setup_platform( @@ -32,9 +54,176 @@ async def async_setup_platform( class ModbusLight(BaseSwitch, LightEntity): """Class representing a Modbus light.""" - _attr_color_mode = ColorMode.ONOFF - _attr_supported_color_modes = {ColorMode.ONOFF} + def __init__( + self, hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any] + ) -> None: + """Initialize the Modbus light entity.""" + super().__init__(hass, hub, config) + self._brightness_address: int | None = config.get(CONF_BRIGHTNESS_REGISTER) + self._color_temp_address: int | None = config.get(CONF_COLOR_TEMP_REGISTER) + + # Determine color mode dynamically + self._attr_color_mode = self._detect_color_mode(config) + self._attr_supported_color_modes = {self._attr_color_mode} + + # Set min/max kelvin values if the mode is COLOR_TEMP + if self._attr_color_mode == ColorMode.COLOR_TEMP: + self._attr_min_color_temp_kelvin = config.get( + CONF_MIN_TEMP, LIGHT_DEFAULT_MIN_KELVIN + ) + self._attr_max_color_temp_kelvin = config.get( + CONF_MAX_TEMP, LIGHT_DEFAULT_MAX_KELVIN + ) + + 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 None: + return + + if (brightness := state.attributes.get(ATTR_BRIGHTNESS)) is not None: + self._attr_brightness = brightness + + if (color_temp := state.attributes.get(ATTR_COLOR_TEMP_KELVIN)) is not None: + self._attr_color_temp_kelvin = color_temp + + @staticmethod + def _detect_color_mode(config: dict[str, Any]) -> ColorMode: + """Determine the appropriate color mode for the light based on configuration.""" + if CONF_COLOR_TEMP_REGISTER in config: + return ColorMode.COLOR_TEMP + if CONF_BRIGHTNESS_REGISTER in config: + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF async def async_turn_on(self, **kwargs: Any) -> None: - """Set light on.""" + """Turn light on and set brightness if provided.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + if brightness and isinstance(brightness, int): + await self.async_set_brightness(brightness) + color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + if color_temp and isinstance(color_temp, int): + await self.async_set_color_temp(color_temp) await self.async_turn(self.command_on) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn light off.""" + await self.async_turn(self._command_off) + + async def async_set_brightness(self, brightness: int) -> None: + """Set the brightness of the light.""" + if not self._brightness_address: + return + + conv_brightness = self._convert_brightness_to_modbus(brightness) + + await self._hub.async_pb_call( + unit=self._slave, + address=self._brightness_address, + value=conv_brightness, + use_call=CALL_TYPE_WRITE_REGISTER, + ) + if not self._verify_active: + self._attr_brightness = brightness + + async def async_set_color_temp(self, color_temp_kelvin: int) -> None: + """Send Modbus command to set color temperature.""" + if not self._color_temp_address: + return + + conv_color_temp_kelvin = self._convert_color_temp_to_modbus(color_temp_kelvin) + + await self._hub.async_pb_call( + unit=self._slave, + address=self._color_temp_address, + value=conv_color_temp_kelvin, + use_call=CALL_TYPE_WRITE_REGISTER, + ) + if not self._verify_active: + self._attr_color_temp_kelvin = color_temp_kelvin + + async def _async_update(self) -> None: + """Update the entity state, including brightness and color temperature.""" + await super()._async_update() + + if not self._verify_active: + return + + if self._brightness_address: + brightness_result = await self._hub.async_pb_call( + unit=self._slave, + value=1, + address=self._brightness_address, + use_call=CALL_TYPE_REGISTER_HOLDING, + ) + + if ( + brightness_result + and brightness_result.registers + and brightness_result.registers[0] != LIGHT_MODBUS_INVALID_VALUE + ): + self._attr_brightness = self._convert_modbus_percent_to_brightness( + brightness_result.registers[0] + ) + + if self._color_temp_address: + color_result = await self._hub.async_pb_call( + unit=self._slave, + value=1, + address=self._color_temp_address, + use_call=CALL_TYPE_REGISTER_HOLDING, + ) + if ( + color_result + and color_result.registers + and color_result.registers[0] != LIGHT_MODBUS_INVALID_VALUE + ): + self._attr_color_temp_kelvin = ( + self._convert_modbus_percent_to_temperature( + color_result.registers[0] + ) + ) + + @staticmethod + def _convert_modbus_percent_to_brightness(percent: int) -> int: + """Convert Modbus scale (0-100) to the brightness (0-255).""" + return round( + percent + / (LIGHT_MODBUS_SCALE_MAX - LIGHT_MODBUS_SCALE_MIN) + * LIGHT_MAX_BRIGHTNESS + ) + + def _convert_modbus_percent_to_temperature(self, percent: int) -> int: + """Convert Modbus scale (0-100) to the color temperature in Kelvin (2000-7000 К).""" + assert isinstance(self._attr_min_color_temp_kelvin, int) and isinstance( + self._attr_max_color_temp_kelvin, int + ) + return round( + self._attr_min_color_temp_kelvin + + ( + percent + / (LIGHT_MODBUS_SCALE_MAX - LIGHT_MODBUS_SCALE_MIN) + * (self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin) + ) + ) + + @staticmethod + def _convert_brightness_to_modbus(brightness: int) -> int: + """Convert brightness (0-255) to Modbus scale (0-100).""" + return round( + brightness + / LIGHT_MAX_BRIGHTNESS + * (LIGHT_MODBUS_SCALE_MAX - LIGHT_MODBUS_SCALE_MIN) + ) + + def _convert_color_temp_to_modbus(self, kelvin: int) -> int: + """Convert color temperature from Kelvin to the Modbus scale (0-100).""" + assert isinstance(self._attr_min_color_temp_kelvin, int) and isinstance( + self._attr_max_color_temp_kelvin, int + ) + return round( + LIGHT_MODBUS_SCALE_MIN + + (kelvin - self._attr_min_color_temp_kelvin) + * (LIGHT_MODBUS_SCALE_MAX - LIGHT_MODBUS_SCALE_MIN) + / (self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin) + ) diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 347549dc837..7d1578558b0 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -88,11 +88,11 @@ }, "duplicate_entity_entry": { "title": "Modbus {sub_1} address {sub_2} is duplicate, second entry not loaded.", - "description": "An address can only be associated with one entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An address can only be associated with one entity. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "duplicate_entity_name": { "title": "Modbus {sub_1} is duplicate, second entry not loaded.", - "description": "A entity name must be unique, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An entity name must be unique. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "no_entities": { "title": "Modbus {sub_1} contain no entities, entry not loaded.", diff --git a/homeassistant/components/moehlenhoff_alpha2/manifest.json b/homeassistant/components/moehlenhoff_alpha2/manifest.json index 14f40991a84..45b7f8c9565 100644 --- a/homeassistant/components/moehlenhoff_alpha2/manifest.json +++ b/homeassistant/components/moehlenhoff_alpha2/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/moehlenhoff_alpha2", "iot_class": "local_push", - "requirements": ["moehlenhoff-alpha2==1.3.1"] + "requirements": ["moehlenhoff-alpha2==1.4.0"] } diff --git a/homeassistant/components/mopeka/strings.json b/homeassistant/components/mopeka/strings.json index 2455eea2f76..23feb554772 100644 --- a/homeassistant/components/mopeka/strings.json +++ b/homeassistant/components/mopeka/strings.json @@ -6,7 +6,7 @@ "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { "address": "[%key:common::config_flow::data::device%]", - "medium_type": "Medium Type" + "medium_type": "Medium type" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index df06ffb75fc..2abcc273e23 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import ( + CONF_BLIND_TYPE_LIST, CONF_INTERFACE, CONF_WAIT_FOR_PUSH, DEFAULT_INTERFACE, @@ -39,6 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key = entry.data[CONF_API_KEY] multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE) wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH) + blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST) # Create multicast Listener async with setup_lock: @@ -81,7 +83,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Connect to motion gateway multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER] connect_gateway_class = ConnectMotionGateway(hass, multicast) - if not await connect_gateway_class.async_connect_gateway(host, key): + if not await connect_gateway_class.async_connect_gateway( + host, key, blind_type_list + ): raise ConfigEntryNotReady motion_gateway = connect_gateway_class.gateway_device api_lock = asyncio.Lock() @@ -95,6 +99,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry, _LOGGER, coordinator_info ) + # store blind type list for next time + if entry.data.get(CONF_BLIND_TYPE_LIST) != motion_gateway.blind_type_list: + data = { + **entry.data, + CONF_BLIND_TYPE_LIST: motion_gateway.blind_type_list, + } + hass.config_entries.async_update_entry(entry, data=data) + # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index d8d1e7c21f1..954f9e25c21 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from motionblinds import MotionDiscovery, MotionGateway @@ -28,6 +29,8 @@ from .const import ( ) from .gateway import ConnectMotionGateway +_LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema( { vol.Optional(CONF_HOST): str, @@ -93,7 +96,8 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): try: # key not needed for GetDeviceList request await self.hass.async_add_executor_job(gateway.GetDeviceList) - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Failed to connect to Motion Gateway") return self.async_abort(reason="not_motionblinds") if not gateway.available: @@ -156,6 +160,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: key = user_input[CONF_API_KEY] + assert self._host connect_gateway_class = ConnectMotionGateway(self.hass) if not await connect_gateway_class.async_connect_gateway(self._host, key): diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 96067d7ceb0..950fa3ab4c7 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -8,6 +8,7 @@ DEFAULT_GATEWAY_NAME = "Motionblinds Gateway" PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] +CONF_BLIND_TYPE_LIST = "blind_type_list" CONF_WAIT_FOR_PUSH = "wait_for_push" CONF_INTERFACE = "interface" DEFAULT_WAIT_FOR_PUSH = False diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index dbf43e3d30f..165c4c19675 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -51,6 +51,7 @@ POSITION_DEVICE_MAP = { BlindType.CurtainRight: CoverDeviceClass.CURTAIN, BlindType.SkylightBlind: CoverDeviceClass.SHADE, BlindType.InsectScreen: CoverDeviceClass.SHADE, + BlindType.RadioReceiver: CoverDeviceClass.SHADE, } TILT_DEVICE_MAP = { diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py index 44f7caa74b2..9826557919c 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -42,11 +42,16 @@ class ConnectMotionGateway: for blind in self.gateway_device.device_list.values(): blind.Update_from_cache() - async def async_connect_gateway(self, host, key): + async def async_connect_gateway( + self, + host: str, + key: str, + blind_type_list: dict[str, int] | None = None, + ) -> bool: """Connect to the Motion Gateway.""" _LOGGER.debug("Initializing with host %s (key %s)", host, key[:3]) self._gateway_device = MotionGateway( - ip=host, key=key, multicast=self._multicast + ip=host, key=key, multicast=self._multicast, blind_type_list=blind_type_list ) try: # update device info and get the connected sub devices diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index b327c146300..1a6c9c5f82f 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.25"] + "requirements": ["motionblinds==0.6.27"] } diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index ddbf928462a..12060cd69f0 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -3,20 +3,20 @@ "flow_title": "{short_mac} ({ip_address})", "step": { "user": { - "description": "Connect to your Motion Gateway, if the IP address is not set, auto-discovery is used", + "description": "Connect to your Motionblinds gateway. If the IP address is not set, auto-discovery is used", "data": { "host": "[%key:common::config_flow::data::ip%]" } }, "connect": { - "description": "You will need the 16 character API Key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key for instructions", + "description": "You will need the 16 character API key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-api-key for instructions", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } }, "select": { - "title": "Select the Motion Gateway that you wish to connect", - "description": "Run the setup again if you want to connect additional Motion Gateways", + "title": "Select the Motionblinds gateway that you wish to connect", + "description": "Run the setup again if you want to connect additional Motionblinds gateways", "data": { "select_ip": "[%key:common::config_flow::data::ip%]" } @@ -29,7 +29,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "connection_error": "[%key:common::config_flow::error::cannot_connect%]", - "not_motionblinds": "Discovered device is not a Motion gateway" + "not_motionblinds": "Discovered device is not a Motionblinds gateway" } }, "options": { diff --git a/homeassistant/components/motionblinds_ble/strings.json b/homeassistant/components/motionblinds_ble/strings.json index d6532f12386..4589c2d873b 100644 --- a/homeassistant/components/motionblinds_ble/strings.json +++ b/homeassistant/components/motionblinds_ble/strings.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_bluetooth_adapter": "No bluetooth adapter found", - "no_devices_found": "Could not find any bluetooth devices" + "no_bluetooth_adapter": "No Bluetooth adapter found", + "no_devices_found": "Could not find any Bluetooth devices" }, "error": { "could_not_find_motor": "Could not find a motor with that MAC code", @@ -62,9 +62,9 @@ "speed": { "name": "Speed", "state": { - "1": "Low", - "2": "Medium", - "3": "High" + "1": "[%key:common::state::low%]", + "2": "[%key:common::state::medium%]", + "3": "[%key:common::state::high%]" } } }, @@ -72,8 +72,8 @@ "connection": { "name": "Connection status", "state": { - "connected": "Connected", - "disconnected": "Disconnected", + "connected": "[%key:common::state::connected%]", + "disconnected": "[%key:common::state::disconnected%]", "connecting": "Connecting", "disconnecting": "Disconnecting" } diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 159956277a8..adf380bf9eb 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping from contextlib import suppress -from types import MappingProxyType from typing import Any import aiohttp @@ -154,7 +154,7 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, str], + options: Mapping[str, str], ) -> None: """Initialize a MJPEG camera.""" self._surveillance_username = username diff --git a/homeassistant/components/motioneye/entity.py b/homeassistant/components/motioneye/entity.py index 49739f2fca3..e279533f080 100644 --- a/homeassistant/components/motioneye/entity.py +++ b/homeassistant/components/motioneye/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import Any from motioneye_client.client import MotionEyeClient @@ -37,7 +37,7 @@ class MotionEyeEntity(CoordinatorEntity): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], entity_description: EntityDescription | None = None, ) -> None: """Initialize a motionEye entity.""" diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py index c160b77c16a..c8d05c6bb4d 100644 --- a/homeassistant/components/motioneye/sensor.py +++ b/homeassistant/components/motioneye/sensor.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any from motioneye_client.client import MotionEyeClient @@ -60,7 +60,7 @@ class MotionEyeActionSensor(MotionEyeEntity, SensorEntity): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, str], + options: Mapping[str, str], ) -> None: """Initialize an action sensor.""" super().__init__( diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index 89d3b8a8727..afa0b9481d1 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -2,7 +2,7 @@ from __future__ import annotations -from types import MappingProxyType +from collections.abc import Mapping from typing import Any from motioneye_client.client import MotionEyeClient @@ -103,7 +103,7 @@ class MotionEyeSwitch(MotionEyeEntity, SwitchEntity): camera: dict[str, Any], client: MotionEyeClient, coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, str], + options: Mapping[str, str], entity_description: SwitchEntityDescription, ) -> None: """Initialize the switch.""" diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index a8fcc84f2ec..861faa319cd 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -46,6 +46,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-preset" self._presets: list[motionmount.Preset] = [] + self._attr_current_option = None def _update_options(self, presets: list[motionmount.Preset]) -> None: """Convert presets to select options.""" diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 75fd0773322..2c951a7aefe 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -68,7 +68,7 @@ }, "sensor": { "motionmount_error_status": { - "name": "Error Status", + "name": "Error status", "state": { "none": "None", "motor": "Motor", diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 6656afe2c8a..ae010bf18c9 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -13,7 +13,7 @@ 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, SERVICE_RELOAD +from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigValidationError, @@ -81,6 +81,7 @@ from .const import ( ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, TEMPLATE_ERRORS, + Platform, ) from .models import ( DATA_MQTT, @@ -293,6 +294,21 @@ async def async_check_config_schema( ) from exc +def _platforms_in_use(hass: HomeAssistant, entry: ConfigEntry) -> set[str | Platform]: + """Return a set of platforms in use.""" + domains: set[str | Platform] = { + entry.domain + for entry in er.async_entries_for_config_entry( + er.async_get(hass), entry.entry_id + ) + } + # Update with domains from subentries + for subentry in entry.subentries.values(): + components = subentry.data["components"].values() + domains.update(component[CONF_PLATFORM] for component in components) + return domains + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the actions and websocket API for the MQTT component.""" @@ -434,12 +450,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mqtt_data, conf = await _setup_client() platforms_used = platforms_from_config(mqtt_data.config) - platforms_used.update( - entry.domain - for entry in er.async_entries_for_config_entry( - er.async_get(hass), entry.entry_id - ) - ) + platforms_used.update(_platforms_in_use(hass, entry)) integration = async_get_loaded_integration(hass, DOMAIN) # Preload platforms we know we are going to use so # discovery can setup each platform synchronously diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 2d73cc5865c..f0d000f79db 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -56,6 +56,7 @@ ABBREVIATIONS = { "ent_pic": "entity_picture", "evt_typ": "event_types", "fanspd_lst": "fan_speed_list", + "flsh": "flash", "flsh_tlng": "flash_time_long", "flsh_tsht": "flash_time_short", "fx_cmd_tpl": "effect_command_template", @@ -150,6 +151,7 @@ ABBREVIATIONS = { "pl_rst_pct": "payload_reset_percentage", "pl_rst_pr_mode": "payload_reset_preset_mode", "pl_stop": "payload_stop", + "pl_stop_tilt": "payload_stop_tilt", "pl_strt": "payload_start", "pl_ret": "payload_return_to_base", "pl_toff": "payload_turn_off", @@ -252,6 +254,7 @@ ABBREVIATIONS = { "tilt_status_tpl": "tilt_status_template", "tit": "title", "t": "topic", + "trns": "transition", "uniq_id": "unique_id", "unit_of_meas": "unit_of_measurement", "url_t": "url_topic", diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index a1e146d4e36..0ac3cb7f786 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -35,7 +35,7 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_STATE_TOPIC, PAYLOAD_NONE +from .const import CONF_OFF_DELAY, CONF_STATE_TOPIC, PAYLOAD_NONE from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -45,7 +45,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 DEFAULT_NAME = "MQTT Binary sensor" -CONF_OFF_DELAY = "off_delay" DEFAULT_PAYLOAD_OFF = "OFF" DEFAULT_PAYLOAD_ON = "ON" DEFAULT_FORCE_UPDATE = False diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 5b2bcc8920f..f5821896071 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -14,7 +14,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA -from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_RETAIN +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_PAYLOAD_PRESS, + CONF_RETAIN, + DEFAULT_PAYLOAD_PRESS, +) from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -22,9 +28,7 @@ from .util import valid_publish_topic PARALLEL_UPDATES = 0 -CONF_PAYLOAD_PRESS = "payload_press" DEFAULT_NAME = "MQTT Button" -DEFAULT_PAYLOAD_PRESS = "PRESS" PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index d35b3db7518..f6f53599363 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -15,6 +15,7 @@ import socket import ssl import time from typing import TYPE_CHECKING, Any +from uuid import uuid4 import certifi @@ -292,7 +293,7 @@ class MqttClientSetup: """ # We don't import on the top because some integrations # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + from paho.mqtt import client as mqtt # pylint: disable=import-outside-toplevel # pylint: disable-next=import-outside-toplevel from .async_client import AsyncMQTTClient @@ -309,9 +310,10 @@ class MqttClientSetup: clean_session = True if (client_id := config.get(CONF_CLIENT_ID)) is None: - # PAHO MQTT relies on the MQTT server to generate random client IDs. - # However, that feature is not mandatory so we generate our own. - client_id = None + # PAHO MQTT relies on the MQTT server to generate random client ID + # for protocol version 3.1, however, that feature is not mandatory + # so we generate our own. + client_id = mqtt._base62(uuid4().int, padding=22) # noqa: SLF001 transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) self._client = AsyncMQTTClient( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, @@ -1020,8 +1022,6 @@ class MQTT: Resubscribe to all topics we were subscribed to and publish birth message. """ - # pylint: disable-next=import-outside-toplevel - if reason_code.is_failure: # 24: Continue authentication # 25: Re-authenticate diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 22568b0f2b8..ca5c597dfaf 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5,38 +5,83 @@ from __future__ import annotations import asyncio from collections import OrderedDict from collections.abc import Callable, Mapping +from copy import deepcopy +from dataclasses import dataclass +from enum import IntEnum import logging import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast +from uuid import uuid4 -from cryptography.hazmat.primitives.serialization import load_pem_private_key -from cryptography.x509 import load_pem_x509_certificate +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + load_der_private_key, + load_pem_private_key, +) +from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate import voluptuous as vol +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.button import ButtonDeviceClass from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.light import ( + DEFAULT_MAX_KELVIN, + DEFAULT_MIN_KELVIN, + VALID_COLOR_MODES, + valid_supported_color_modes, +) +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, + ConfigSubentryFlow, OptionsFlow, + SubentryFlowResult, ) from homeassistant.const import ( + ATTR_CONFIGURATION_URL, + ATTR_HW_VERSION, + ATTR_MODEL, + ATTR_MODEL_ID, + ATTR_NAME, + ATTR_SW_VERSION, + CONF_BRIGHTNESS, CONF_CLIENT_ID, + CONF_DEVICE, + CONF_DEVICE_CLASS, CONF_DISCOVERY, + CONF_EFFECT, CONF_HOST, + CONF_NAME, + CONF_OPTIMISTIC, CONF_PASSWORD, CONF_PAYLOAD, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, + CONF_PLATFORM, CONF_PORT, CONF_PROTOCOL, + CONF_STATE_TEMPLATE, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import AbortFlow -from homeassistant.helpers import config_validation as cv +from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.json import json_dumps from homeassistant.helpers.selector import ( @@ -47,9 +92,12 @@ from homeassistant.helpers.selector import ( NumberSelectorConfig, NumberSelectorMode, SelectOptionDict, + Selector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TemplateSelector, + TemplateSelectorConfig, TextSelector, TextSelectorConfig, TextSelectorType, @@ -64,27 +112,107 @@ from .const import ( ATTR_QOS, ATTR_RETAIN, ATTR_TOPIC, + CONF_AVAILABILITY_TEMPLATE, + CONF_AVAILABILITY_TOPIC, CONF_BIRTH_MESSAGE, + CONF_BLUE_TEMPLATE, + CONF_BRIGHTNESS_COMMAND_TEMPLATE, + CONF_BRIGHTNESS_COMMAND_TOPIC, + CONF_BRIGHTNESS_SCALE, + CONF_BRIGHTNESS_STATE_TOPIC, + CONF_BRIGHTNESS_TEMPLATE, + CONF_BRIGHTNESS_VALUE_TEMPLATE, CONF_BROKER, CONF_CERTIFICATE, CONF_CLIENT_CERT, CONF_CLIENT_KEY, + CONF_COLOR_MODE_STATE_TOPIC, + CONF_COLOR_MODE_VALUE_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TOPIC, + CONF_COLOR_TEMP_KELVIN, + CONF_COLOR_TEMP_STATE_TOPIC, + CONF_COLOR_TEMP_TEMPLATE, + CONF_COLOR_TEMP_VALUE_TEMPLATE, + CONF_COMMAND_OFF_TEMPLATE, + CONF_COMMAND_ON_TEMPLATE, + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, CONF_DISCOVERY_PREFIX, + CONF_EFFECT_COMMAND_TEMPLATE, + CONF_EFFECT_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_EFFECT_STATE_TOPIC, + CONF_EFFECT_TEMPLATE, + CONF_EFFECT_VALUE_TEMPLATE, + CONF_ENTITY_PICTURE, + CONF_EXPIRE_AFTER, + CONF_FLASH, + CONF_FLASH_TIME_LONG, + CONF_FLASH_TIME_SHORT, + CONF_GREEN_TEMPLATE, + CONF_HS_COMMAND_TEMPLATE, + CONF_HS_COMMAND_TOPIC, + CONF_HS_STATE_TOPIC, + CONF_HS_VALUE_TEMPLATE, CONF_KEEPALIVE, + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, + CONF_OFF_DELAY, + CONF_ON_COMMAND_TYPE, + CONF_OPTIONS, + CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, + CONF_PAYLOAD_PRESS, + CONF_QOS, + CONF_RED_TEMPLATE, + CONF_RETAIN, + CONF_RGB_COMMAND_TEMPLATE, + CONF_RGB_COMMAND_TOPIC, + CONF_RGB_STATE_TOPIC, + CONF_RGB_VALUE_TEMPLATE, + CONF_RGBW_COMMAND_TEMPLATE, + CONF_RGBW_COMMAND_TOPIC, + CONF_RGBW_STATE_TOPIC, + CONF_RGBW_VALUE_TEMPLATE, + CONF_RGBWW_COMMAND_TEMPLATE, + CONF_RGBWW_COMMAND_TOPIC, + CONF_RGBWW_STATE_TOPIC, + CONF_RGBWW_VALUE_TEMPLATE, + CONF_SCHEMA, + CONF_STATE_TOPIC, + CONF_STATE_VALUE_TEMPLATE, + CONF_SUGGESTED_DISPLAY_PRECISION, + CONF_SUPPORTED_COLOR_MODES, CONF_TLS_INSECURE, + CONF_TRANSITION, CONF_TRANSPORT, + CONF_WHITE_COMMAND_TOPIC, + CONF_WHITE_SCALE, CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + CONF_XY_COMMAND_TEMPLATE, + CONF_XY_COMMAND_TOPIC, + CONF_XY_STATE_TOPIC, + CONF_XY_VALUE_TEMPLATE, CONFIG_ENTRY_MINOR_VERSION, CONFIG_ENTRY_VERSION, DEFAULT_BIRTH, DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_KEEPALIVE, + DEFAULT_ON_COMMAND_TYPE, + DEFAULT_PAYLOAD_AVAILABLE, + DEFAULT_PAYLOAD_NOT_AVAILABLE, + DEFAULT_PAYLOAD_OFF, + DEFAULT_PAYLOAD_ON, + DEFAULT_PAYLOAD_PRESS, DEFAULT_PORT, DEFAULT_PREFIX, DEFAULT_PROTOCOL, + DEFAULT_QOS, DEFAULT_TRANSPORT, DEFAULT_WILL, DEFAULT_WS_PATH, @@ -92,12 +220,18 @@ from .const import ( SUPPORTED_PROTOCOLS, TRANSPORT_TCP, TRANSPORT_WEBSOCKETS, + VALUES_ON_COMMAND_TYPE, + Platform, ) +from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData from .util import ( async_create_certificate_temp_files, get_file_path, + learn_more_url, valid_birth_will, valid_publish_topic, + valid_subscribe_topic, + valid_subscribe_topic_template, ) _LOGGER = logging.getLogger(__name__) @@ -105,6 +239,8 @@ _LOGGER = logging.getLogger(__name__) ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 5 +CONF_CLIENT_KEY_PASSWORD = "client_key_password" + MQTT_TIMEOUT = 5 ADVANCED_OPTIONS = "advanced_options" @@ -119,9 +255,8 @@ PORT_SELECTOR = vol.All( vol.Coerce(int), ) PASSWORD_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)) -QOS_SELECTOR = vol.All( - NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2)), - vol.Coerce(int), +QOS_SELECTOR = NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2) ) KEEPALIVE_SELECTOR = vol.All( NumberSelector( @@ -165,12 +300,981 @@ BROKER_VERIFICATION_SELECTOR = SelectSelector( # mime configuration from https://pki-tutorial.readthedocs.io/en/latest/mime.html CA_CERT_UPLOAD_SELECTOR = FileSelector( - FileSelectorConfig(accept=".crt,application/x-x509-ca-cert") + FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-ca-cert") ) CERT_UPLOAD_SELECTOR = FileSelector( - FileSelectorConfig(accept=".crt,application/x-x509-user-cert") + FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-user-cert") ) -KEY_UPLOAD_SELECTOR = FileSelector(FileSelectorConfig(accept=".key,application/pkcs8")) +KEY_UPLOAD_SELECTOR = FileSelector( + FileSelectorConfig(accept=".pem,.key,.der,.pk8,application/pkcs8") +) + +# Subentry selectors +SUBENTRY_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.LIGHT, + Platform.NOTIFY, + Platform.SENSOR, + Platform.SWITCH, +] +SUBENTRY_PLATFORM_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[platform.value for platform in SUBENTRY_PLATFORMS], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_PLATFORM, + ) +) +TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) + +SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_AVAILABILITY_TOPIC): TEXT_SELECTOR, + vol.Optional(CONF_AVAILABILITY_TEMPLATE): TEMPLATE_SELECTOR, + vol.Optional( + CONF_PAYLOAD_AVAILABLE, default=DEFAULT_PAYLOAD_AVAILABLE + ): TEXT_SELECTOR, + vol.Optional( + CONF_PAYLOAD_NOT_AVAILABLE, default=DEFAULT_PAYLOAD_NOT_AVAILABLE + ): TEXT_SELECTOR, + } +) + +# Sensor specific selectors +SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in SensorDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_sensor", + sort=True, + ) +) +BINARY_SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in BinarySensorDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_binary_sensor", + sort=True, + ) +) +BUTTON_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in ButtonDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_button", + sort=True, + ) +) +SENSOR_STATE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in SensorStateClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_STATE_CLASS, + ) +) +OPTIONS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[], + custom_value=True, + multiple=True, + ) +) +SUGGESTED_DISPLAY_PRECISION_SELECTOR = NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=9) +) +TIMEOUT_SELECTOR = NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0) +) + +# Switch specific selectors +SWITCH_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in SwitchDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_switch", + ) +) + +# Light specific selectors +LIGHT_SCHEMA_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["basic", "json", "template"], + translation_key="light_schema", + ) +) +KELVIN_SELECTOR = NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=1000, + max=10000, + step="any", + unit_of_measurement="K", + ) +) +SCALE_SELECTOR = NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=1, + max=255, + step=1, + ) +) +FLASH_TIME_SELECTOR = NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=1, + ) +) +ON_COMMAND_TYPE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=VALUES_ON_COMMAND_TYPE, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_ON_COMMAND_TYPE, + sort=True, + ) +) +SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[platform.value for platform in VALID_COLOR_MODES], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_SUPPORTED_COLOR_MODES, + multiple=True, + sort=True, + ) +) + + +@callback +def validate_sensor_platform_config( + config: dict[str, Any], +) -> dict[str, str]: + """Validate the sensor options, state and device class config.""" + errors: dict[str, str] = {} + # Only allow `options` to be set for `enum` sensors + # to limit the possible sensor values + if config.get(CONF_OPTIONS) is not None: + if config.get(CONF_STATE_CLASS) or config.get(CONF_UNIT_OF_MEASUREMENT): + errors[CONF_OPTIONS] = "options_not_allowed_with_state_class_or_uom" + + if (device_class := config.get(CONF_DEVICE_CLASS)) != SensorDeviceClass.ENUM: + errors[CONF_DEVICE_CLASS] = "options_device_class_enum" + + if ( + (device_class := config.get(CONF_DEVICE_CLASS)) == SensorDeviceClass.ENUM + and errors is not None + and CONF_OPTIONS not in config + ): + errors[CONF_OPTIONS] = "options_with_enum_device_class" + + if ( + device_class in DEVICE_CLASS_UNITS + and (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is None + and errors is not None + ): + # Do not allow an empty unit of measurement in a subentry data flow + errors[CONF_UNIT_OF_MEASUREMENT] = "uom_required_for_device_class" + return errors + + if ( + device_class is not None + and device_class in DEVICE_CLASS_UNITS + and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] + ): + errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom" + + return errors + + +@dataclass(frozen=True, kw_only=True) +class PlatformField: + """Stores a platform config field schema, required flag and validator.""" + + selector: Selector[Any] | Callable[..., Selector[Any]] + required: bool + validator: Callable[..., Any] + error: str | None = None + default: str | int | bool | None | vol.Undefined = vol.UNDEFINED + is_schema_default: bool = False + exclude_from_reconfig: bool = False + conditions: tuple[dict[str, Any], ...] | None = None + custom_filtering: bool = False + section: str | None = None + + +@callback +def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: + """Return a context based unit of measurement selector.""" + if ( + user_data is None + or (device_class := user_data.get(CONF_DEVICE_CLASS)) is None + or device_class not in DEVICE_CLASS_UNITS + ): + return TEXT_SELECTOR + return SelectSelector( + SelectSelectorConfig( + options=[str(uom) for uom in DEVICE_CLASS_UNITS[device_class]], + sort=True, + custom_value=True, + ) + ) + + +@callback +def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]: + """Validate MQTT light configuration.""" + errors: dict[str, Any] = {} + if user_data.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) >= user_data.get( + CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN + ): + errors["advanced_settings"] = "max_below_min_kelvin" + return errors + + +COMMON_ENTITY_FIELDS = { + CONF_PLATFORM: PlatformField( + selector=SUBENTRY_PLATFORM_SELECTOR, + required=True, + validator=str, + exclude_from_reconfig=True, + ), + CONF_NAME: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + exclude_from_reconfig=True, + default=None, + ), + CONF_ENTITY_PICTURE: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url" + ), +} + +PLATFORM_ENTITY_FIELDS = { + Platform.BINARY_SENSOR.value: { + CONF_DEVICE_CLASS: PlatformField( + selector=BINARY_SENSOR_DEVICE_CLASS_SELECTOR, + required=False, + validator=str, + ), + }, + Platform.BUTTON.value: { + CONF_DEVICE_CLASS: PlatformField( + selector=BUTTON_DEVICE_CLASS_SELECTOR, + required=False, + validator=str, + ), + }, + Platform.NOTIFY.value: {}, + Platform.SENSOR.value: { + CONF_DEVICE_CLASS: PlatformField( + selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False, validator=str + ), + CONF_STATE_CLASS: PlatformField( + selector=SENSOR_STATE_CLASS_SELECTOR, required=False, validator=str + ), + CONF_UNIT_OF_MEASUREMENT: PlatformField( + selector=unit_of_measurement_selector, + required=False, + validator=str, + custom_filtering=True, + ), + CONF_SUGGESTED_DISPLAY_PRECISION: PlatformField( + selector=SUGGESTED_DISPLAY_PRECISION_SELECTOR, + required=False, + validator=cv.positive_int, + section="advanced_settings", + ), + CONF_OPTIONS: PlatformField( + selector=OPTIONS_SELECTOR, + required=False, + validator=cv.ensure_list, + conditions=({"device_class": "enum"},), + ), + }, + Platform.SWITCH.value: { + CONF_DEVICE_CLASS: PlatformField( + selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False, validator=str + ), + }, + Platform.LIGHT.value: { + CONF_SCHEMA: PlatformField( + selector=LIGHT_SCHEMA_SELECTOR, + required=True, + validator=str, + default="basic", + exclude_from_reconfig=True, + ), + CONF_COLOR_TEMP_KELVIN: PlatformField( + selector=BOOLEAN_SELECTOR, + required=True, + validator=bool, + default=True, + is_schema_default=True, + ), + }, +} +PLATFORM_MQTT_FIELDS = { + Platform.BINARY_SENSOR.value: { + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_PAYLOAD_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + default=DEFAULT_PAYLOAD_OFF, + ), + CONF_PAYLOAD_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + default=DEFAULT_PAYLOAD_ON, + ), + CONF_EXPIRE_AFTER: PlatformField( + selector=TIMEOUT_SELECTOR, + required=False, + validator=cv.positive_int, + section="advanced_settings", + ), + CONF_OFF_DELAY: PlatformField( + selector=TIMEOUT_SELECTOR, + required=False, + validator=cv.positive_int, + section="advanced_settings", + ), + }, + Platform.BUTTON.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_PAYLOAD_PRESS: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + default=DEFAULT_PAYLOAD_PRESS, + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + }, + Platform.NOTIFY.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + }, + Platform.SENSOR.value: { + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_STATE_CLASS: "total"},), + ), + CONF_EXPIRE_AFTER: PlatformField( + selector=TIMEOUT_SELECTOR, + required=False, + validator=cv.positive_int, + section="advanced_settings", + ), + }, + Platform.SWITCH.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + CONF_OPTIMISTIC: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + }, + Platform.LIGHT.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_ON_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=True, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_COMMAND_OFF_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=True, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_ON_COMMAND_TYPE: PlatformField( + selector=ON_COMMAND_TYPE_SELECTOR, + required=False, + validator=str, + default=DEFAULT_ON_COMMAND_TYPE, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_STATE_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_SUPPORTED_COLOR_MODES: PlatformField( + selector=SUPPORTED_COLOR_MODES_SELECTOR, + required=False, + validator=valid_supported_color_modes, + error="invalid_supported_color_modes", + conditions=({CONF_SCHEMA: "json"},), + ), + CONF_OPTIMISTIC: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + validator=bool, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_BRIGHTNESS: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + validator=bool, + conditions=({CONF_SCHEMA: "json"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_PAYLOAD_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + default=DEFAULT_PAYLOAD_OFF, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_PAYLOAD_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + default=DEFAULT_PAYLOAD_ON, + conditions=({CONF_SCHEMA: "basic"},), + ), + CONF_BRIGHTNESS_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_brightness_settings", + ), + CONF_BRIGHTNESS_SCALE: PlatformField( + selector=SCALE_SELECTOR, + required=False, + validator=cv.positive_int, + default=255, + conditions=( + {CONF_SCHEMA: "basic"}, + {CONF_SCHEMA: "json"}, + ), + section="light_brightness_settings", + ), + CONF_COLOR_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_mode_settings", + ), + CONF_COLOR_MODE_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_mode_settings", + ), + CONF_COLOR_TEMP_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_COLOR_TEMP_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_COLOR_TEMP_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_COLOR_TEMP_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_color_temp_settings", + ), + CONF_BRIGHTNESS_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_RED_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_GREEN_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_BLUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_COLOR_TEMP_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + ), + CONF_HS_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_HS_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_HS_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_HS_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_hs_settings", + ), + CONF_RGB_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGB_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGB_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGB_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgb_settings", + ), + CONF_RGBW_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBW_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBW_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBW_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbw_settings", + ), + CONF_RGBWW_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_RGBWW_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_RGBWW_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_RGBWW_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_rgbww_settings", + ), + CONF_XY_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_XY_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_XY_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_XY_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_xy_settings", + ), + CONF_WHITE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_white_settings", + ), + CONF_WHITE_SCALE: PlatformField( + selector=SCALE_SELECTOR, + required=False, + validator=cv.positive_int, + default=255, + conditions=( + {CONF_SCHEMA: "basic"}, + {CONF_SCHEMA: "json"}, + ), + section="light_white_settings", + ), + CONF_EFFECT: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + validator=bool, + conditions=({CONF_SCHEMA: "json"},), + section="light_effect_settings", + ), + CONF_EFFECT_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "template"},), + section="light_effect_settings", + ), + CONF_EFFECT_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + conditions=({CONF_SCHEMA: "basic"},), + section="light_effect_settings", + ), + CONF_EFFECT_LIST: PlatformField( + selector=OPTIONS_SELECTOR, + required=False, + validator=cv.ensure_list, + section="light_effect_settings", + ), + CONF_FLASH: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + default=False, + validator=cv.boolean, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_FLASH_TIME_SHORT: PlatformField( + selector=FLASH_TIME_SELECTOR, + required=False, + validator=cv.positive_int, + default=2, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_FLASH_TIME_LONG: PlatformField( + selector=FLASH_TIME_SELECTOR, + required=False, + validator=cv.positive_int, + default=10, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_TRANSITION: PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + default=False, + validator=cv.boolean, + conditions=({CONF_SCHEMA: "json"},), + section="advanced_settings", + ), + CONF_MAX_KELVIN: PlatformField( + selector=KELVIN_SELECTOR, + required=False, + validator=cv.positive_int, + default=DEFAULT_MAX_KELVIN, + section="advanced_settings", + ), + CONF_MIN_KELVIN: PlatformField( + selector=KELVIN_SELECTOR, + required=False, + validator=cv.positive_int, + default=DEFAULT_MIN_KELVIN, + section="advanced_settings", + ), + }, +} +ENTITY_CONFIG_VALIDATOR: dict[ + str, + Callable[[dict[str, Any]], dict[str, str]] | None, +] = { + Platform.BINARY_SENSOR.value: None, + Platform.BUTTON.value: None, + Platform.LIGHT.value: validate_light_platform_config, + Platform.NOTIFY.value: None, + Platform.SENSOR.value: validate_sensor_platform_config, + Platform.SWITCH.value: None, +} + +MQTT_DEVICE_PLATFORM_FIELDS = { + ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True, validator=str), + ATTR_SW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=str + ), + ATTR_HW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=str + ), + ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str), + ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str), + ATTR_CONFIGURATION_URL: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url" + ), + CONF_QOS: PlatformField( + selector=QOS_SELECTOR, + required=False, + validator=int, + default=DEFAULT_QOS, + section="mqtt_settings", + ), +} REAUTH_SCHEMA = vol.Schema( { @@ -204,6 +1308,198 @@ def update_password_from_user_input( return substituted_used_data +@callback +def validate_field( + field: str, + validator: Callable[..., Any], + user_input: dict[str, Any] | None, + errors: dict[str, str], + error: str, +) -> None: + """Validate a single field.""" + if user_input is None or field not in user_input: + return + try: + validator(user_input[field]) + except (ValueError, vol.Error, vol.Invalid): + errors[field] = error + + +@callback +def _check_conditions( + platform_field: PlatformField, component_data: dict[str, Any] | None = None +) -> bool: + """Only include field if one of conditions match, or no conditions are set.""" + if platform_field.conditions is None or component_data is None: + return True + return any( + all(component_data.get(key) == value for key, value in condition.items()) + for condition in platform_field.conditions + ) + + +@callback +def calculate_merged_config( + merged_user_input: dict[str, Any], + data_schema_fields: dict[str, PlatformField], + component_data: dict[str, Any], +) -> dict[str, Any]: + """Calculate merged config.""" + base_schema_fields = { + key + for key, platform_field in data_schema_fields.items() + if _check_conditions(platform_field, component_data) + } - set(merged_user_input) + return { + key: value + for key, value in component_data.items() + if key not in base_schema_fields + } | merged_user_input + + +@callback +def validate_user_input( + user_input: dict[str, Any], + data_schema_fields: dict[str, PlatformField], + *, + component_data: dict[str, Any] | None = None, + config_validator: Callable[[dict[str, Any]], dict[str, str]] | None = None, +) -> tuple[dict[str, Any], dict[str, str]]: + """Validate user input.""" + errors: dict[str, str] = {} + # Merge sections + merged_user_input: dict[str, Any] = {} + for key, value in user_input.items(): + if isinstance(value, dict): + merged_user_input.update(value) + else: + merged_user_input[key] = value + + for field, value in merged_user_input.items(): + validator = data_schema_fields[field].validator + try: + validator(value) + except (ValueError, vol.Error, vol.Invalid): + data_schema_field = data_schema_fields[field] + errors[data_schema_field.section or field] = ( + data_schema_field.error or "invalid_input" + ) + + if config_validator is not None: + if TYPE_CHECKING: + assert component_data is not None + + errors |= config_validator( + calculate_merged_config( + merged_user_input, data_schema_fields, component_data + ), + ) + + return merged_user_input, errors + + +@callback +def data_schema_from_fields( + data_schema_fields: dict[str, PlatformField], + reconfig: bool, + component_data: dict[str, Any] | None = None, + user_input: dict[str, Any] | None = None, + device_data: MqttDeviceData | None = None, +) -> vol.Schema: + """Generate custom data schema from platform fields or device data.""" + if device_data is not None: + component_data_with_user_input: dict[str, Any] | None = dict(device_data) + if TYPE_CHECKING: + assert component_data_with_user_input is not None + component_data_with_user_input.update( + component_data_with_user_input.pop("mqtt_settings", {}) + ) + else: + component_data_with_user_input = deepcopy(component_data) + if component_data_with_user_input is not None and user_input is not None: + component_data_with_user_input |= user_input + + sections: dict[str | None, None] = { + field_details.section: None + for field_details in data_schema_fields.values() + if not field_details.is_schema_default + } + data_schema: dict[Any, Any] = {} + all_data_element_options: set[Any] = set() + no_reconfig_options: set[Any] = set() + for schema_section in sections: + data_schema_element = { + vol.Required(field_name, default=field_details.default) + if field_details.required + else vol.Optional( + field_name, + default=field_details.default + if field_details.default is not None + else vol.UNDEFINED, + ): field_details.selector(component_data_with_user_input) # type: ignore[operator] + if field_details.custom_filtering + else field_details.selector + for field_name, field_details in data_schema_fields.items() + if not field_details.is_schema_default + and field_details.section == schema_section + and (not field_details.exclude_from_reconfig or not reconfig) + and _check_conditions(field_details, component_data_with_user_input) + } + data_element_options = set(data_schema_element) + all_data_element_options |= data_element_options + no_reconfig_options |= { + field_name + for field_name, field_details in data_schema_fields.items() + if field_details.section == schema_section + and field_details.exclude_from_reconfig + } + if not data_element_options: + continue + if schema_section is None: + data_schema.update(data_schema_element) + continue + collapsed = ( + not any( + (default := data_schema_fields[str(option)].default) is vol.UNDEFINED + or component_data_with_user_input[str(option)] != default + for option in data_element_options + if option in component_data_with_user_input + ) + if component_data_with_user_input is not None + else True + ) + data_schema[vol.Optional(schema_section)] = section( + vol.Schema(data_schema_element), SectionConfig({"collapsed": collapsed}) + ) + + # Reset all fields from the component_data not in the schema + if component_data: + filtered_fields = ( + set(data_schema_fields) - all_data_element_options - no_reconfig_options + ) + for field in filtered_fields: + if field in component_data: + del component_data[field] + return vol.Schema(data_schema) + + +@callback +def subentry_schema_default_data_from_fields( + data_schema_fields: dict[str, PlatformField], + component_data: dict[str, Any], +) -> dict[str, Any]: + """Generate custom data schema from platform fields or device data.""" + return { + key: field.default + for key, field in data_schema_fields.items() + if _check_conditions(field, component_data) + and ( + field.is_schema_default + or (field.default is not vol.UNDEFINED and key not in component_data) + ) + } + + class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -219,6 +1515,14 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {CONF_DEVICE: MQTTSubentryFlowHandler} + @staticmethod @callback def async_get_options_flow( @@ -710,17 +2014,531 @@ class MQTTOptionsFlowHandler(OptionsFlow): ) -async def _get_uploaded_file(hass: HomeAssistant, id: str) -> str: - """Get file content from uploaded file.""" +class MQTTSubentryFlowHandler(ConfigSubentryFlow): + """Handle MQTT subentry flow.""" - def _proces_uploaded_file() -> str: + _subentry_data: MqttSubentryData + _component_id: str | None = None + + @callback + def update_component_fields( + self, + data_schema_fields: dict[str, PlatformField], + merged_user_input: dict[str, Any], + ) -> None: + """Update the componment fields.""" + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + # Remove the fields from the component data + # if they are not in the schema and not in the user input + config = calculate_merged_config( + merged_user_input, data_schema_fields, component_data + ) + for field in ( + field + for field, platform_field in data_schema_fields.items() + if field in (set(component_data) - set(config)) + and not platform_field.exclude_from_reconfig + ): + component_data.pop(field) + component_data.update(merged_user_input) + + @callback + def generate_names(self) -> tuple[str, str]: + """Generate the device and full entity name.""" + if TYPE_CHECKING: + assert self._component_id is not None + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + if entity_name := self._subentry_data["components"][self._component_id].get( + CONF_NAME + ): + full_entity_name: str = f"{device_name} {entity_name}" + else: + full_entity_name = device_name + return device_name, full_entity_name + + @callback + def get_suggested_values_from_component( + self, data_schema: vol.Schema + ) -> dict[str, Any]: + """Get suggestions from component data based on the data schema.""" + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + return { + field_key: self.get_suggested_values_from_component(value.schema) + if isinstance(value, section) + else component_data.get(field_key) + for field_key, value in data_schema.schema.items() + } + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a subentry.""" + self._subentry_data = MqttSubentryData(device=MqttDeviceData(), components={}) + return await self.async_step_device() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Reconfigure a subentry.""" + reconfigure_subentry = self._get_reconfigure_subentry() + self._subentry_data = cast( + MqttSubentryData, deepcopy(dict(reconfigure_subentry.data)) + ) + return await self.async_step_summary_menu() + + async def async_step_device( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a new MQTT device.""" + errors: dict[str, Any] = {} + device_data = self._subentry_data[CONF_DEVICE] + data_schema = data_schema_from_fields( + MQTT_DEVICE_PLATFORM_FIELDS, + device_data=device_data, + reconfig=True, + ) + if user_input is not None: + _, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS) + if not errors: + self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input) + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_summary_menu() + return await self.async_step_entity() + data_schema = self.add_suggested_values_to_schema( + data_schema, device_data if user_input is None else user_input + ) + return self.async_show_form( + step_id=CONF_DEVICE, + data_schema=data_schema, + errors=errors, + last_step=False, + ) + + async def async_step_entity( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add or edit an mqtt entity.""" + errors: dict[str, str] = {} + data_schema_fields = COMMON_ENTITY_FIELDS + entity_name_label: str = "" + platform_label: str = "" + component_data: dict[str, Any] | None = None + if reconfig := (self._component_id is not None): + component_data = self._subentry_data["components"][self._component_id] + name: str | None = component_data.get(CONF_NAME) + platform_label = f"{self._subentry_data['components'][self._component_id][CONF_PLATFORM]} " + entity_name_label = f" ({name})" if name is not None else "" + data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig) + if user_input is not None: + merged_user_input, errors = validate_user_input( + user_input, data_schema_fields, component_data=component_data + ) + if not errors: + if self._component_id is None: + self._component_id = uuid4().hex + self._subentry_data["components"].setdefault(self._component_id, {}) + self.update_component_fields(data_schema_fields, merged_user_input) + return await self.async_step_entity_platform_config() + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + elif self.source == SOURCE_RECONFIGURE and self._component_id is not None: + data_schema = self.add_suggested_values_to_schema( + data_schema, + self.get_suggested_values_from_component(data_schema), + ) + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + return self.async_show_form( + step_id="entity", + data_schema=data_schema, + description_placeholders={ + "mqtt_device": device_name, + "entity_name_label": entity_name_label, + "platform_label": platform_label, + }, + errors=errors, + last_step=False, + ) + + def _show_update_or_delete_form(self, step_id: str) -> SubentryFlowResult: + """Help selecting an entity to update or delete.""" + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + entities = [ + SelectOptionDict( + value=key, + label=f"{device_name} {component_data.get(CONF_NAME, '-') or '-'}" + f" ({component_data[CONF_PLATFORM]})", + ) + for key, component_data in self._subentry_data["components"].items() + ] + data_schema = vol.Schema( + { + vol.Required("component"): SelectSelector( + SelectSelectorConfig( + options=entities, + mode=SelectSelectorMode.LIST, + ) + ) + } + ) + return self.async_show_form( + step_id=step_id, data_schema=data_schema, last_step=False + ) + + async def async_step_update_entity( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Select the entity to update.""" + if user_input: + self._component_id = user_input["component"] + return await self.async_step_entity() + if len(self._subentry_data["components"]) == 1: + # Return first key + self._component_id = next(iter(self._subentry_data["components"])) + return await self.async_step_entity() + return self._show_update_or_delete_form("update_entity") + + async def async_step_delete_entity( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Select the entity to delete.""" + if user_input: + del self._subentry_data["components"][user_input["component"]] + return await self.async_step_summary_menu() + return self._show_update_or_delete_form("delete_entity") + + async def async_step_entity_platform_config( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Configure platform entity details.""" + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + platform = component_data[CONF_PLATFORM] + data_schema_fields = PLATFORM_ENTITY_FIELDS[platform] + errors: dict[str, str] = {} + + data_schema = data_schema_from_fields( + data_schema_fields, + reconfig=bool( + {field for field in data_schema_fields if field in component_data} + ), + component_data=component_data, + user_input=user_input, + ) + if not data_schema.schema: + return await self.async_step_mqtt_platform_config() + if user_input is not None: + # Test entity fields against the validator + merged_user_input, errors = validate_user_input( + user_input, + data_schema_fields, + component_data=component_data, + config_validator=ENTITY_CONFIG_VALIDATOR[platform], + ) + if not errors: + self.update_component_fields(data_schema_fields, merged_user_input) + return await self.async_step_mqtt_platform_config() + + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + else: + data_schema = self.add_suggested_values_to_schema( + data_schema, + self.get_suggested_values_from_component(data_schema), + ) + + device_name, full_entity_name = self.generate_names() + return self.async_show_form( + step_id="entity_platform_config", + data_schema=data_schema, + description_placeholders={ + "mqtt_device": device_name, + CONF_PLATFORM: platform, + "entity": full_entity_name, + "url": learn_more_url(platform), + } + | (user_input or {}), + errors=errors, + last_step=False, + ) + + async def async_step_mqtt_platform_config( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Configure entity platform MQTT details.""" + errors: dict[str, str] = {} + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + platform = component_data[CONF_PLATFORM] + data_schema_fields = PLATFORM_MQTT_FIELDS[platform] + data_schema = data_schema_from_fields( + data_schema_fields, + reconfig=bool( + {field for field in data_schema_fields if field in component_data} + ), + component_data=component_data, + ) + if user_input is not None: + # Test entity fields against the validator + merged_user_input, errors = validate_user_input( + user_input, + data_schema_fields, + component_data=component_data, + config_validator=ENTITY_CONFIG_VALIDATOR[platform], + ) + if not errors: + self.update_component_fields(data_schema_fields, merged_user_input) + self._component_id = None + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_summary_menu() + return self._async_create_subentry() + + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + else: + data_schema = self.add_suggested_values_to_schema( + data_schema, + self.get_suggested_values_from_component(data_schema), + ) + device_name, full_entity_name = self.generate_names() + return self.async_show_form( + step_id="mqtt_platform_config", + data_schema=data_schema, + description_placeholders={ + "mqtt_device": device_name, + CONF_PLATFORM: platform, + "entity": full_entity_name, + "url": learn_more_url(platform), + }, + errors=errors, + last_step=False, + ) + + @callback + def _async_update_component_data_defaults(self) -> None: + """Update component data defaults.""" + for component_data in self._subentry_data["components"].values(): + platform = component_data[CONF_PLATFORM] + subentry_default_data = subentry_schema_default_data_from_fields( + COMMON_ENTITY_FIELDS + | PLATFORM_ENTITY_FIELDS[platform] + | PLATFORM_MQTT_FIELDS[platform], + component_data, + ) + component_data.update(subentry_default_data) + + @callback + def _async_create_subentry( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Create a subentry for a new MQTT device.""" + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + component_data: dict[str, Any] = next( + iter(self._subentry_data["components"].values()) + ) + platform = component_data[CONF_PLATFORM] + entity_name: str | None + if entity_name := component_data.get(CONF_NAME): + full_entity_name: str = f"{device_name} {entity_name}" + else: + full_entity_name = device_name + + self._async_update_component_data_defaults() + return self.async_create_entry( + data=self._subentry_data, + title=self._subentry_data[CONF_DEVICE][CONF_NAME], + description_placeholders={ + "entity": full_entity_name, + CONF_PLATFORM: platform, + }, + ) + + async def async_step_availability( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Configure availability options.""" + errors: dict[str, str] = {} + validate_field( + "availability_topic", + valid_subscribe_topic, + user_input, + errors, + "invalid_subscribe_topic", + ) + validate_field( + "availability_template", + valid_subscribe_topic_template, + user_input, + errors, + "invalid_template", + ) + if not errors and user_input is not None: + self._subentry_data.setdefault("availability", MqttAvailabilityData()) + self._subentry_data["availability"] = cast(MqttAvailabilityData, user_input) + return await self.async_step_summary_menu() + + data_schema = SUBENTRY_AVAILABILITY_SCHEMA + data_schema = self.add_suggested_values_to_schema( + data_schema, + dict(self._subentry_data.setdefault("availability", {})) + if self.source == SOURCE_RECONFIGURE + else user_input, + ) + return self.async_show_form( + step_id="availability", + data_schema=data_schema, + errors=errors, + last_step=False, + ) + + async def async_step_summary_menu( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Show summary menu and decide to add more entities or to finish the flow.""" + self._component_id = None + mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME] + mqtt_items = ", ".join( + f"{mqtt_device} {component_data.get(CONF_NAME, '-') or '-'} " + f"({component_data[CONF_PLATFORM]})" + for component_data in self._subentry_data["components"].values() + ) + menu_options = [ + "entity", + "update_entity", + ] + if len(self._subentry_data["components"]) > 1: + menu_options.append("delete_entity") + menu_options.extend(["device", "availability"]) + self._async_update_component_data_defaults() + if self._subentry_data != self._get_reconfigure_subentry().data: + menu_options.append("save_changes") + return self.async_show_menu( + step_id="summary_menu", + menu_options=menu_options, + description_placeholders={ + "mqtt_device": mqtt_device, + "mqtt_items": mqtt_items, + }, + ) + + async def async_step_save_changes( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Save the changes made to the subentry.""" + entry = self._get_entry() + subentry = self._get_reconfigure_subentry() + entity_registry = er.async_get(self.hass) + + # When a component is removed from the MQTT device, + # And we save the changes to the subentry, + # we need to clean up stale entity registry entries. + # The component id is used as a part of the unique id of the entity. + for unique_id, platform in [ + ( + f"{subentry.subentry_id}_{component_id}", + subentry.data["components"][component_id][CONF_PLATFORM], + ) + for component_id in subentry.data["components"] + if component_id not in self._subentry_data["components"] + ]: + if entity_id := entity_registry.async_get_entity_id( + platform, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + + return self.async_update_and_abort( + entry, + subentry, + data=self._subentry_data, + title=self._subentry_data[CONF_DEVICE][CONF_NAME], + ) + + +@callback +def async_is_pem_data(data: bytes) -> bool: + """Return True if data is in PEM format.""" + return ( + b"-----BEGIN CERTIFICATE-----" in data + or b"-----BEGIN PRIVATE KEY-----" in data + or b"-----BEGIN EC PRIVATE KEY-----" in data + or b"-----BEGIN RSA PRIVATE KEY-----" in data + or b"-----BEGIN ENCRYPTED PRIVATE KEY-----" in data + ) + + +class PEMType(IntEnum): + """Type of PEM data.""" + + CERTIFICATE = 1 + PRIVATE_KEY = 2 + + +@callback +def async_convert_to_pem( + data: bytes, pem_type: PEMType, password: str | None = None +) -> str | None: + """Convert data to PEM format.""" + try: + if async_is_pem_data(data): + if not password: + # Assume unencrypted PEM encoded private key + return data.decode(DEFAULT_ENCODING) + # Return decrypted PEM encoded private key + return ( + load_pem_private_key(data, password=password.encode(DEFAULT_ENCODING)) + .private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + .decode(DEFAULT_ENCODING) + ) + # Convert from DER encoding to PEM + if pem_type == PEMType.CERTIFICATE: + return ( + load_der_x509_certificate(data) + .public_bytes( + encoding=Encoding.PEM, + ) + .decode(DEFAULT_ENCODING) + ) + # Assume DER encoded private key + pem_key_data: bytes = load_der_private_key( + data, password.encode(DEFAULT_ENCODING) if password else None + ).private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + return pem_key_data.decode("utf-8") + except (TypeError, ValueError, SSLError): + _LOGGER.exception("Error converting %s file data to PEM format", pem_type.name) + return None + + +async def _get_uploaded_file(hass: HomeAssistant, id: str) -> bytes: + """Get file content from uploaded certificate or key file.""" + + def _proces_uploaded_file() -> bytes: with process_uploaded_file(hass, id) as file_path: - return file_path.read_text(encoding=DEFAULT_ENCODING) + return file_path.read_bytes() return await hass.async_add_executor_job(_proces_uploaded_file) -async def async_get_broker_settings( +def _validate_pki_file( + file_id: str | None, pem_data: str | None, errors: dict[str, str], error: str +) -> bool: + """Return False if uploaded file could not be converted to PEM format.""" + if file_id and not pem_data: + errors["base"] = error + return False + return True + + +async def async_get_broker_settings( # noqa: C901 flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, @@ -768,6 +2586,10 @@ 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) + # We do not store the private key password in the entry data + client_key_password: str | None = validated_user_input.pop( + CONF_CLIENT_KEY_PASSWORD, None + ) if (client_certificate_id and not client_key_id) or ( not client_certificate_id and client_key_id ): @@ -775,7 +2597,14 @@ async def async_get_broker_settings( return False certificate_id: str | None = user_input.get(CONF_CERTIFICATE) if certificate_id: - certificate = await _get_uploaded_file(hass, certificate_id) + certificate_data_raw = await _get_uploaded_file(hass, certificate_id) + certificate = async_convert_to_pem( + certificate_data_raw, PEMType.CERTIFICATE + ) + if not _validate_pki_file( + certificate_id, certificate, errors, "bad_certificate" + ): + return False # Return to form for file upload CA cert or client cert and key if ( @@ -797,9 +2626,26 @@ async def async_get_broker_settings( return False if client_certificate_id: - client_certificate = await _get_uploaded_file(hass, client_certificate_id) + client_certificate_data = await _get_uploaded_file( + hass, client_certificate_id + ) + client_certificate = async_convert_to_pem( + client_certificate_data, PEMType.CERTIFICATE + ) + if not _validate_pki_file( + client_certificate_id, client_certificate, errors, "bad_client_cert" + ): + return False + if client_key_id: - client_key = await _get_uploaded_file(hass, client_key_id) + client_key_data = await _get_uploaded_file(hass, client_key_id) + client_key = async_convert_to_pem( + client_key_data, PEMType.PRIVATE_KEY, password=client_key_password + ) + if not _validate_pki_file( + client_key_id, client_key, errors, "client_key_error" + ): + return False certificate_data: dict[str, Any] = {} if certificate: @@ -956,6 +2802,14 @@ async def async_get_broker_settings( description={"suggested_value": user_input_basic.get(CONF_CLIENT_KEY)}, ) ] = KEY_UPLOAD_SELECTOR + fields[ + vol.Optional( + CONF_CLIENT_KEY_PASSWORD, + description={ + "suggested_value": user_input_basic.get(CONF_CLIENT_KEY_PASSWORD) + }, + ) + ] = PASSWORD_SELECTOR verification_mode = current_config.get(SET_CA_CERT) or ( "off" if current_ca_certificate is None @@ -1060,7 +2914,7 @@ def check_certicate_chain() -> str | None: with open(private_key, "rb") as client_key_file: load_pem_private_key(client_key_file.read(), password=None) except (TypeError, ValueError): - return "bad_client_key" + return "client_key_error" # Check the certificate chain context = SSLContext(PROTOCOL_TLS_CLIENT) if client_certificate and private_key: diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 007b3b7e576..89e721f022b 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -56,32 +56,85 @@ CONF_SUPPORTED_FEATURES = "supported_features" CONF_ACTION_TEMPLATE = "action_template" CONF_ACTION_TOPIC = "action_topic" +CONF_BLUE_TEMPLATE = "blue_template" +CONF_BRIGHTNESS_COMMAND_TEMPLATE = "brightness_command_template" +CONF_BRIGHTNESS_COMMAND_TOPIC = "brightness_command_topic" +CONF_BRIGHTNESS_SCALE = "brightness_scale" +CONF_BRIGHTNESS_STATE_TOPIC = "brightness_state_topic" +CONF_BRIGHTNESS_TEMPLATE = "brightness_template" +CONF_BRIGHTNESS_VALUE_TEMPLATE = "brightness_value_template" +CONF_COLOR_MODE = "color_mode" +CONF_COLOR_MODE_STATE_TOPIC = "color_mode_state_topic" +CONF_COLOR_MODE_VALUE_TEMPLATE = "color_mode_value_template" +CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template" +CONF_COLOR_TEMP_COMMAND_TOPIC = "color_temp_command_topic" CONF_COLOR_TEMP_KELVIN = "color_temp_kelvin" +CONF_COLOR_TEMP_TEMPLATE = "color_temp_template" +CONF_COLOR_TEMP_STATE_TOPIC = "color_temp_state_topic" +CONF_COLOR_TEMP_VALUE_TEMPLATE = "color_temp_value_template" +CONF_COMMAND_OFF_TEMPLATE = "command_off_template" +CONF_COMMAND_ON_TEMPLATE = "command_on_template" 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_EFFECT_COMMAND_TEMPLATE = "effect_command_template" +CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic" +CONF_EFFECT_LIST = "effect_list" +CONF_EFFECT_STATE_TOPIC = "effect_state_topic" +CONF_EFFECT_TEMPLATE = "effect_template" +CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" CONF_ENTITY_PICTURE = "entity_picture" +CONF_EXPIRE_AFTER = "expire_after" +CONF_FLASH = "flash" +CONF_FLASH_TIME_LONG = "flash_time_long" +CONF_FLASH_TIME_SHORT = "flash_time_short" +CONF_GREEN_TEMPLATE = "green_template" +CONF_HS_COMMAND_TEMPLATE = "hs_command_template" +CONF_HS_COMMAND_TOPIC = "hs_command_topic" +CONF_HS_STATE_TOPIC = "hs_state_topic" +CONF_HS_VALUE_TEMPLATE = "hs_value_template" +CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" CONF_MAX_KELVIN = "max_kelvin" +CONF_MAX_MIREDS = "max_mireds" CONF_MIN_KELVIN = "min_kelvin" +CONF_MIN_MIREDS = "min_mireds" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" CONF_MODE_STATE_TEMPLATE = "mode_state_template" CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_OFF_DELAY = "off_delay" +CONF_ON_COMMAND_TYPE = "on_command_type" CONF_PAYLOAD_CLOSE = "payload_close" CONF_PAYLOAD_OPEN = "payload_open" +CONF_PAYLOAD_PRESS = "payload_press" CONF_PAYLOAD_STOP = "payload_stop" CONF_POSITION_CLOSED = "position_closed" CONF_POSITION_OPEN = "position_open" CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRECISION = "precision" +CONF_RED_TEMPLATE = "red_template" +CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template" +CONF_RGB_COMMAND_TOPIC = "rgb_command_topic" +CONF_RGB_STATE_TOPIC = "rgb_state_topic" +CONF_RGB_VALUE_TEMPLATE = "rgb_value_template" +CONF_RGBW_COMMAND_TEMPLATE = "rgbw_command_template" +CONF_RGBW_COMMAND_TOPIC = "rgbw_command_topic" +CONF_RGBW_STATE_TOPIC = "rgbw_state_topic" +CONF_RGBW_VALUE_TEMPLATE = "rgbw_value_template" +CONF_RGBWW_COMMAND_TEMPLATE = "rgbww_command_template" +CONF_RGBWW_COMMAND_TOPIC = "rgbww_command_topic" +CONF_RGBWW_STATE_TOPIC = "rgbww_state_topic" +CONF_RGBWW_VALUE_TEMPLATE = "rgbww_value_template" CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OPEN = "state_open" CONF_STATE_OPENING = "state_opening" +CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" +CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" @@ -89,7 +142,15 @@ CONF_TEMP_STATE_TOPIC = "temperature_state_topic" CONF_TEMP_INITIAL = "initial" CONF_TEMP_MAX = "max_temp" CONF_TEMP_MIN = "min_temp" +CONF_TRANSITION = "transition" +CONF_XY_COMMAND_TEMPLATE = "xy_command_template" +CONF_XY_COMMAND_TOPIC = "xy_command_topic" +CONF_XY_STATE_TOPIC = "xy_state_topic" +CONF_XY_VALUE_TEMPLATE = "xy_value_template" +CONF_WHITE_COMMAND_TOPIC = "white_command_topic" +CONF_WHITE_SCALE = "white_scale" +# Config flow constants CONF_CERTIFICATE = "certificate" CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" @@ -110,16 +171,25 @@ CONF_CONFIGURATION_URL = "configuration_url" CONF_OBJECT_ID = "object_id" CONF_SUPPORT_URL = "support_url" +DEFAULT_BRIGHTNESS = False +DEFAULT_BRIGHTNESS_SCALE = 255 DEFAULT_PREFIX = "homeassistant" DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status" DEFAULT_DISCOVERY = True +DEFAULT_EFFECT = False DEFAULT_ENCODING = "utf-8" +DEFAULT_FLASH_TIME_LONG = 10 +DEFAULT_FLASH_TIME_SHORT = 2 DEFAULT_OPTIMISTIC = False +DEFAULT_ON_COMMAND_TYPE = "last" DEFAULT_QOS = 0 DEFAULT_PAYLOAD_AVAILABLE = "online" DEFAULT_PAYLOAD_CLOSE = "CLOSE" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" +DEFAULT_PAYLOAD_OFF = "OFF" +DEFAULT_PAYLOAD_ON = "ON" DEFAULT_PAYLOAD_OPEN = "OPEN" +DEFAULT_PAYLOAD_PRESS = "PRESS" DEFAULT_PORT = 1883 DEFAULT_RETAIN = False DEFAULT_WS_HEADERS: dict[str, str] = {} @@ -127,6 +197,9 @@ DEFAULT_WS_PATH = "/" DEFAULT_POSITION_CLOSED = 0 DEFAULT_POSITION_OPEN = 100 DEFAULT_RETAIN = False +DEFAULT_WHITE_SCALE = 255 + +VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"] PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c93fdd9c760..428c4d0e205 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -81,6 +81,7 @@ CONF_TILT_STATUS_TOPIC = "tilt_status_topic" CONF_TILT_STATUS_TEMPLATE = "tilt_status_template" CONF_STATE_STOPPED = "state_stopped" +CONF_PAYLOAD_STOP_TILT = "payload_stop_tilt" CONF_TILT_CLOSED_POSITION = "tilt_closed_value" CONF_TILT_MAX = "tilt_max" CONF_TILT_MIN = "tilt_min" @@ -203,6 +204,9 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_GET_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_PAYLOAD_STOP_TILT, default=DEFAULT_PAYLOAD_STOP): vol.Any( + cv.string, None + ), } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) @@ -592,6 +596,12 @@ class MqttCover(MqttEntity, CoverEntity): self._attr_current_cover_tilt_position = tilt_percentage self.async_write_ha_state() + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop moving the cover tilt.""" + await self.async_publish_with_config( + self._config[CONF_TILT_COMMAND_TOPIC], self._config[CONF_PAYLOAD_STOP_TILT] + ) + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position_percentage = kwargs[ATTR_POSITION] diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 4017245cf51..141e0478f2f 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -28,8 +28,13 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_PAYLOAD_RESET, CONF_STATE_TOPIC -from .entity import CONF_JSON_ATTRS_TOPIC, MqttEntity, async_setup_entity_entry_helper +from .const import ( + CONF_JSON_ATTRS_TEMPLATE, + CONF_JSON_ATTRS_TOPIC, + CONF_PAYLOAD_RESET, + CONF_STATE_TOPIC, +) +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_subscribe_topic @@ -111,6 +116,7 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): self._value_template = MqttValueTemplate( config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value + self._attr_source_type = self._config[CONF_SOURCE_TYPE] @callback def _tracker_message_received(self, msg: ReceiveMessage) -> None: @@ -124,72 +130,82 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): ) return if payload == self._config[CONF_PAYLOAD_HOME]: - self._location_name = STATE_HOME + self._attr_location_name = STATE_HOME elif payload == self._config[CONF_PAYLOAD_NOT_HOME]: - self._location_name = STATE_NOT_HOME + self._attr_location_name = STATE_NOT_HOME elif payload == self._config[CONF_PAYLOAD_RESET]: - self._location_name = None + self._attr_location_name = None else: if TYPE_CHECKING: assert isinstance(msg.payload, str) - self._location_name = msg.payload + self._attr_location_name = msg.payload @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" self.add_subscription( - CONF_STATE_TOPIC, self._tracker_message_received, {"_location_name"} + CONF_STATE_TOPIC, self._tracker_message_received, {"_attr_location_name"} ) - @property - def force_update(self) -> bool: - """Do not force updates if the state is the same.""" - return False - async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) - @property - def latitude(self) -> float | None: - """Return latitude if provided in extra_state_attributes or None.""" + @callback + def _process_update_extra_state_attributes( + self, extra_state_attributes: dict[str, Any] + ) -> None: + """Extract the location from the extra state attributes.""" if ( - self.extra_state_attributes is not None - and ATTR_LATITUDE in self.extra_state_attributes + ATTR_LATITUDE in extra_state_attributes + or ATTR_LONGITUDE in extra_state_attributes ): - latitude: float = self.extra_state_attributes[ATTR_LATITUDE] - return latitude - return None + latitude: float | None + longitude: float | None + gps_accuracy: float + # Reset manually set location to allow automatic zone detection + self._attr_location_name = None + if isinstance( + latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float) + ) and isinstance( + longitude := extra_state_attributes.get(ATTR_LONGITUDE), (int, float) + ): + self._attr_latitude = latitude + self._attr_longitude = longitude + else: + # Invalid or incomplete coordinates, reset location + self._attr_latitude = None + self._attr_longitude = None + _LOGGER.warning( + "Extra state attributes received at % and template %s " + "contain invalid or incomplete location info. Got %s", + self._config.get(CONF_JSON_ATTRS_TEMPLATE), + self._config.get(CONF_JSON_ATTRS_TOPIC), + extra_state_attributes, + ) - @property - def location_accuracy(self) -> int: - """Return location accuracy if provided in extra_state_attributes or None.""" - if ( - self.extra_state_attributes is not None - and ATTR_GPS_ACCURACY in self.extra_state_attributes - ): - accuracy: int = self.extra_state_attributes[ATTR_GPS_ACCURACY] - return accuracy - return 0 + if ATTR_GPS_ACCURACY in extra_state_attributes: + if isinstance( + gps_accuracy := extra_state_attributes[ATTR_GPS_ACCURACY], + (int, float), + ): + self._attr_location_accuracy = gps_accuracy + else: + _LOGGER.warning( + "Extra state attributes received at % and template %s " + "contain invalid GPS accuracy setting, " + "gps_accuracy was set to 0 as the default. Got %s", + self._config.get(CONF_JSON_ATTRS_TEMPLATE), + self._config.get(CONF_JSON_ATTRS_TOPIC), + extra_state_attributes, + ) + self._attr_location_accuracy = 0 - @property - def longitude(self) -> float | None: - """Return longitude if provided in extra_state_attributes or None.""" - if ( - self.extra_state_attributes is not None - and ATTR_LONGITUDE in self.extra_state_attributes - ): - longitude: float = self.extra_state_attributes[ATTR_LONGITUDE] - return longitude - return None + else: + self._attr_location_accuracy = 0 - @property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - return self._location_name - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - source_type: SourceType = self._config[CONF_SOURCE_TYPE] - return source_type + self._attr_extra_state_attributes = { + attribute: value + for attribute, value in extra_state_attributes.items() + if attribute not in {ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE} + } diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a14240ce008..4ebdbbb6236 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -154,18 +154,14 @@ def get_origin_support_url(discovery_payload: MQTTDiscoveryPayload) -> str | Non @callback def async_log_discovery_origin_info( - message: str, discovery_payload: MQTTDiscoveryPayload, level: int = logging.INFO + message: str, discovery_payload: MQTTDiscoveryPayload ) -> None: """Log information about the discovery and origin.""" - # We only log origin info once per device discovery - if not _LOGGER.isEnabledFor(level): - # bail out early if logging is disabled + if not _LOGGER.isEnabledFor(logging.DEBUG): + # bail out early if debug logging is disabled return - _LOGGER.log( - level, - "%s%s", - message, - get_origin_log_string(discovery_payload, include_url=True), + _LOGGER.debug( + "%s%s", message, get_origin_log_string(discovery_payload, include_url=True) ) @@ -258,7 +254,7 @@ def _generate_device_config( comp_config = config[CONF_COMPONENTS] for platform, discover_id in mqtt_data.discovery_already_discovered: ids = discover_id.split(" ") - component_node_id = ids.pop(0) + component_node_id = f"{ids.pop(1)} {ids.pop(0)}" if len(ids) > 2 else ids.pop(0) component_object_id = " ".join(ids) if not ids: continue @@ -562,7 +558,7 @@ async def async_start( # noqa: C901 elif already_discovered: # Dispatch update message = f"Component has already been discovered: {component} {discovery_id}, sending update" - async_log_discovery_origin_info(message, payload, logging.DEBUG) + async_log_discovery_origin_info(message, payload) async_dispatcher_send( hass, MQTT_DISCOVERY_UPDATED.format(*discovery_hash), payload ) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index fb047cc8d5e..1202f04ed42 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -43,7 +43,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity, async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, @@ -111,6 +111,7 @@ from .discovery import ( from .models import ( DATA_MQTT, MessageCallbackType, + MqttSubentryData, MqttValueTemplate, MqttValueTemplateException, PublishPayloadType, @@ -122,7 +123,7 @@ from .subscription import ( async_subscribe_topics_internal, async_unsubscribe_topics, ) -from .util import mqtt_config_entry_enabled +from .util import learn_more_url, mqtt_config_entry_enabled _LOGGER = logging.getLogger(__name__) @@ -238,7 +239,7 @@ def async_setup_entity_entry_helper( entry: ConfigEntry, entity_class: type[MqttEntity] | None, domain: str, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, discovery_schema: VolSchemaType, platform_schema_modern: VolSchemaType, schema_class_mapping: dict[str, type[MqttEntity]] | None = None, @@ -282,11 +283,10 @@ def async_setup_entity_entry_helper( @callback def _async_setup_entities() -> None: - """Set up MQTT items from configuration.yaml.""" + """Set up MQTT items from subentries and configuration.yaml.""" nonlocal entity_class mqtt_data = hass.data[DATA_MQTT] - if not (config_yaml := mqtt_data.config): - return + config_yaml = mqtt_data.config yaml_configs: list[ConfigType] = [ config for config_item in config_yaml @@ -294,6 +294,45 @@ def async_setup_entity_entry_helper( for config in configs if config_domain == domain ] + # process subentry entity setup + for config_subentry_id, subentry in entry.subentries.items(): + subentry_data = cast(MqttSubentryData, subentry.data) + availability_config = subentry_data.get("availability", {}) + subentry_entities: list[Entity] = [] + device_config = subentry_data["device"].copy() + device_mqtt_options = device_config.pop("mqtt_settings", {}) + device_config["identifiers"] = config_subentry_id + for component_id, component_data in subentry_data["components"].items(): + if component_data["platform"] != domain: + continue + component_config: dict[str, Any] = component_data.copy() + component_config[CONF_UNIQUE_ID] = ( + f"{config_subentry_id}_{component_id}" + ) + component_config[CONF_DEVICE] = device_config + component_config.pop("platform") + component_config.update(availability_config) + component_config.update(device_mqtt_options) + + try: + config = platform_schema_modern(component_config) + if schema_class_mapping is not None: + entity_class = schema_class_mapping[config[CONF_SCHEMA]] + if TYPE_CHECKING: + assert entity_class is not None + subentry_entities.append(entity_class(hass, config, entry, None)) + except vol.Invalid as exc: + _LOGGER.error( + "Schema violation occurred when trying to set up " + "entity from subentry %s %s %s: %s", + config_subentry_id, + subentry.title, + subentry.data, + exc, + ) + + async_add_entities(subentry_entities, config_subentry_id=config_subentry_id) + entities: list[Entity] = [] for yaml_config in yaml_configs: try: @@ -309,9 +348,6 @@ def async_setup_entity_entry_helper( line = getattr(yaml_config, "__line__", "?") issue_id = hex(hash(frozenset(yaml_config))) yaml_config_str = yaml_dump(yaml_config) - learn_more_url = ( - f"https://www.home-assistant.io/integrations/{domain}.mqtt/" - ) async_create_issue( hass, DOMAIN, @@ -319,7 +355,7 @@ def async_setup_entity_entry_helper( issue_domain=domain, is_fixable=False, severity=IssueSeverity.ERROR, - learn_more_url=learn_more_url, + learn_more_url=learn_more_url(domain), translation_placeholders={ "domain": domain, "config_file": config_file, @@ -363,6 +399,10 @@ class MqttAttributesMixin(Entity): _attributes_extra_blocked: frozenset[str] = frozenset() _attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None + _message_callback: Callable[ + [MessageCallbackType, set[str] | None, ReceiveMessage], None + ] + _process_update_extra_state_attributes: Callable[[dict[str, Any]], None] def __init__(self, config: ConfigType) -> None: """Initialize the JSON attributes mixin.""" @@ -397,9 +437,15 @@ class MqttAttributesMixin(Entity): CONF_JSON_ATTRS_TOPIC: { "topic": self._attributes_config.get(CONF_JSON_ATTRS_TOPIC), "msg_callback": partial( - self._message_callback, # type: ignore[attr-defined] + self._message_callback, self._attributes_message_received, - {"_attr_extra_state_attributes"}, + { + "_attr_extra_state_attributes", + "_attr_gps_accuracy", + "_attr_latitude", + "_attr_location_name", + "_attr_longitude", + }, ), "entity_id": self.entity_id, "qos": self._attributes_config.get(CONF_QOS), @@ -438,7 +484,11 @@ class MqttAttributesMixin(Entity): if k not in MQTT_ATTRIBUTES_BLOCKED and k not in self._attributes_extra_blocked } - self._attr_extra_state_attributes = filtered_dict + if hasattr(self, "_process_update_extra_state_attributes"): + self._process_update_extra_state_attributes(filtered_dict) + else: + self._attr_extra_state_attributes = filtered_dict + else: _LOGGER.warning("JSON result was not a dictionary") @@ -446,6 +496,10 @@ class MqttAttributesMixin(Entity): class MqttAvailabilityMixin(Entity): """Mixin used for platforms that report availability.""" + _message_callback: Callable[ + [MessageCallbackType, set[str] | None, ReceiveMessage], None + ] + def __init__(self, config: ConfigType) -> None: """Initialize the availability mixin.""" self._availability_sub_state: dict[str, EntitySubscription] = {} @@ -511,7 +565,7 @@ class MqttAvailabilityMixin(Entity): f"availability_{topic}": { "topic": topic, "msg_callback": partial( - self._message_callback, # type: ignore[attr-defined] + self._message_callback, self._availability_message_received, {"available"}, ), diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 7727efcf04d..1917c56f209 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components import lawn_mower from homeassistant.components.lawn_mower import ( + ENTITY_ID_FORMAT, LawnMowerActivity, LawnMowerEntity, LawnMowerEntityFeature, @@ -50,7 +51,6 @@ CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic" CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template" DEFAULT_NAME = "MQTT Lawn Mower" -ENTITY_ID_FORMAT = lawn_mower.DOMAIN + ".{}" MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index a2f424b247d..61a55d64049 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -51,13 +51,60 @@ from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( + CONF_BRIGHTNESS_COMMAND_TEMPLATE, + CONF_BRIGHTNESS_COMMAND_TOPIC, + CONF_BRIGHTNESS_SCALE, + CONF_BRIGHTNESS_STATE_TOPIC, + CONF_BRIGHTNESS_VALUE_TEMPLATE, + CONF_COLOR_MODE_STATE_TOPIC, + CONF_COLOR_MODE_VALUE_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TOPIC, CONF_COLOR_TEMP_KELVIN, + CONF_COLOR_TEMP_STATE_TOPIC, + CONF_COLOR_TEMP_VALUE_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_EFFECT_COMMAND_TEMPLATE, + CONF_EFFECT_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_EFFECT_STATE_TOPIC, + CONF_EFFECT_VALUE_TEMPLATE, + CONF_HS_COMMAND_TEMPLATE, + CONF_HS_COMMAND_TOPIC, + CONF_HS_STATE_TOPIC, + CONF_HS_VALUE_TEMPLATE, CONF_MAX_KELVIN, + CONF_MAX_MIREDS, CONF_MIN_KELVIN, + CONF_MIN_MIREDS, + CONF_ON_COMMAND_TYPE, + CONF_RGB_COMMAND_TEMPLATE, + CONF_RGB_COMMAND_TOPIC, + CONF_RGB_STATE_TOPIC, + CONF_RGB_VALUE_TEMPLATE, + CONF_RGBW_COMMAND_TEMPLATE, + CONF_RGBW_COMMAND_TOPIC, + CONF_RGBW_STATE_TOPIC, + CONF_RGBW_VALUE_TEMPLATE, + CONF_RGBWW_COMMAND_TEMPLATE, + CONF_RGBWW_COMMAND_TOPIC, + CONF_RGBWW_STATE_TOPIC, + CONF_RGBWW_VALUE_TEMPLATE, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, + CONF_WHITE_COMMAND_TOPIC, + CONF_WHITE_SCALE, + CONF_XY_COMMAND_TEMPLATE, + CONF_XY_COMMAND_TOPIC, + CONF_XY_STATE_TOPIC, + CONF_XY_VALUE_TEMPLATE, + DEFAULT_BRIGHTNESS_SCALE, + DEFAULT_ON_COMMAND_TYPE, + DEFAULT_PAYLOAD_OFF, + DEFAULT_PAYLOAD_ON, + DEFAULT_WHITE_SCALE, PAYLOAD_NONE, + VALUES_ON_COMMAND_TYPE, ) from ..entity import MqttEntity from ..models import ( @@ -74,47 +121,7 @@ from .schema import MQTT_LIGHT_SCHEMA_SCHEMA _LOGGER = logging.getLogger(__name__) -CONF_BRIGHTNESS_COMMAND_TEMPLATE = "brightness_command_template" -CONF_BRIGHTNESS_COMMAND_TOPIC = "brightness_command_topic" -CONF_BRIGHTNESS_SCALE = "brightness_scale" -CONF_BRIGHTNESS_STATE_TOPIC = "brightness_state_topic" -CONF_BRIGHTNESS_VALUE_TEMPLATE = "brightness_value_template" -CONF_COLOR_MODE_STATE_TOPIC = "color_mode_state_topic" -CONF_COLOR_MODE_VALUE_TEMPLATE = "color_mode_value_template" -CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template" -CONF_COLOR_TEMP_COMMAND_TOPIC = "color_temp_command_topic" -CONF_COLOR_TEMP_STATE_TOPIC = "color_temp_state_topic" -CONF_COLOR_TEMP_VALUE_TEMPLATE = "color_temp_value_template" -CONF_EFFECT_COMMAND_TEMPLATE = "effect_command_template" -CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic" -CONF_EFFECT_LIST = "effect_list" -CONF_EFFECT_STATE_TOPIC = "effect_state_topic" -CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" -CONF_HS_COMMAND_TEMPLATE = "hs_command_template" -CONF_HS_COMMAND_TOPIC = "hs_command_topic" -CONF_HS_STATE_TOPIC = "hs_state_topic" -CONF_HS_VALUE_TEMPLATE = "hs_value_template" -CONF_MAX_MIREDS = "max_mireds" -CONF_MIN_MIREDS = "min_mireds" -CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template" -CONF_RGB_COMMAND_TOPIC = "rgb_command_topic" -CONF_RGB_STATE_TOPIC = "rgb_state_topic" -CONF_RGB_VALUE_TEMPLATE = "rgb_value_template" -CONF_RGBW_COMMAND_TEMPLATE = "rgbw_command_template" -CONF_RGBW_COMMAND_TOPIC = "rgbw_command_topic" -CONF_RGBW_STATE_TOPIC = "rgbw_state_topic" -CONF_RGBW_VALUE_TEMPLATE = "rgbw_value_template" -CONF_RGBWW_COMMAND_TEMPLATE = "rgbww_command_template" -CONF_RGBWW_COMMAND_TOPIC = "rgbww_command_topic" -CONF_RGBWW_STATE_TOPIC = "rgbww_state_topic" -CONF_RGBWW_VALUE_TEMPLATE = "rgbww_value_template" -CONF_XY_COMMAND_TEMPLATE = "xy_command_template" -CONF_XY_COMMAND_TOPIC = "xy_command_topic" -CONF_XY_STATE_TOPIC = "xy_state_topic" -CONF_XY_VALUE_TEMPLATE = "xy_value_template" -CONF_WHITE_COMMAND_TOPIC = "white_command_topic" -CONF_WHITE_SCALE = "white_scale" -CONF_ON_COMMAND_TYPE = "on_command_type" +DEFAULT_NAME = "MQTT LightEntity" MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset( { @@ -137,15 +144,6 @@ MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset( } ) -DEFAULT_BRIGHTNESS_SCALE = 255 -DEFAULT_NAME = "MQTT LightEntity" -DEFAULT_PAYLOAD_OFF = "OFF" -DEFAULT_PAYLOAD_ON = "ON" -DEFAULT_WHITE_SCALE = 255 -DEFAULT_ON_COMMAND_TYPE = "last" - -VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"] - COMMAND_TEMPLATE_KEYS = [ CONF_BRIGHTNESS_COMMAND_TEMPLATE, CONF_COLOR_TEMP_COMMAND_TEMPLATE, diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 14e21e61d48..fc76d4bcf6c 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -31,7 +31,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, brightness_supported, - color_supported, valid_supported_color_modes, ) from homeassistant.const import ( @@ -56,13 +55,28 @@ from homeassistant.util.json import json_loads_object from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( + CONF_COLOR_MODE, CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_FLASH, + CONF_FLASH_TIME_LONG, + CONF_FLASH_TIME_SHORT, CONF_MAX_KELVIN, + CONF_MAX_MIREDS, CONF_MIN_KELVIN, + CONF_MIN_MIREDS, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + CONF_SUPPORTED_COLOR_MODES, + CONF_TRANSITION, + DEFAULT_BRIGHTNESS, + DEFAULT_BRIGHTNESS_SCALE, + DEFAULT_EFFECT, + DEFAULT_FLASH_TIME_LONG, + DEFAULT_FLASH_TIME_SHORT, + DEFAULT_WHITE_SCALE, ) from ..entity import MqttEntity from ..models import ReceiveMessage @@ -79,25 +93,10 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "mqtt_json" -DEFAULT_BRIGHTNESS = False -DEFAULT_EFFECT = False -DEFAULT_FLASH_TIME_LONG = 10 -DEFAULT_FLASH_TIME_SHORT = 2 DEFAULT_NAME = "MQTT JSON Light" -DEFAULT_BRIGHTNESS_SCALE = 255 -DEFAULT_WHITE_SCALE = 255 - -CONF_COLOR_MODE = "color_mode" -CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" - -CONF_EFFECT_LIST = "effect_list" - -CONF_FLASH_TIME_LONG = "flash_time_long" -CONF_FLASH_TIME_SHORT = "flash_time_short" - -CONF_MAX_MIREDS = "max_mireds" -CONF_MIN_MIREDS = "min_mireds" +DEFAULT_FLASH = True +DEFAULT_TRANSITION = True _PLATFORM_SCHEMA_BASE = ( MQTT_RW_SCHEMA.extend( @@ -109,6 +108,7 @@ _PLATFORM_SCHEMA_BASE = ( 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(CONF_FLASH, default=DEFAULT_FLASH): cv.boolean, vol.Optional( CONF_FLASH_TIME_LONG, default=DEFAULT_FLASH_TIME_LONG ): cv.positive_int, @@ -131,6 +131,7 @@ _PLATFORM_SCHEMA_BASE = ( vol.Unique(), valid_supported_color_modes, ), + vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.boolean, vol.Optional(CONF_WHITE_SCALE, default=DEFAULT_WHITE_SCALE): vol.All( vol.Coerce(int), vol.Range(min=1) ), @@ -205,18 +206,23 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): for key in (CONF_FLASH_TIME_SHORT, CONF_FLASH_TIME_LONG) } - self._attr_supported_features = ( - LightEntityFeature.TRANSITION | LightEntityFeature.FLASH - ) self._attr_supported_features |= ( config[CONF_EFFECT] and LightEntityFeature.EFFECT ) + self._attr_supported_features |= config[CONF_FLASH] and LightEntityFeature.FLASH + self._attr_supported_features |= ( + config[CONF_TRANSITION] and LightEntityFeature.TRANSITION + ) if supported_color_modes := self._config.get(CONF_SUPPORTED_COLOR_MODES): self._attr_supported_color_modes = supported_color_modes if self.supported_color_modes and len(self.supported_color_modes) == 1: self._attr_color_mode = next(iter(self.supported_color_modes)) else: self._attr_color_mode = ColorMode.UNKNOWN + elif config.get(CONF_BRIGHTNESS): + # Brightness is supported and no supported_color_modes are set, + # so set brightness as the supported color mode. + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} def _update_color(self, values: dict[str, Any]) -> None: color_mode: str = values["color_mode"] @@ -289,7 +295,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): elif values["state"] is None: self._attr_is_on = None - if color_supported(self.supported_color_modes) and "color_mode" in values: + if "color_mode" in values: self._update_color(values) if brightness_supported(self.supported_color_modes): diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 901cee6f14c..f561f15fb51 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -40,10 +40,21 @@ from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( + CONF_BLUE_TEMPLATE, + CONF_BRIGHTNESS_TEMPLATE, CONF_COLOR_TEMP_KELVIN, + CONF_COLOR_TEMP_TEMPLATE, + CONF_COMMAND_OFF_TEMPLATE, + CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_EFFECT_TEMPLATE, + CONF_GREEN_TEMPLATE, CONF_MAX_KELVIN, + CONF_MAX_MIREDS, CONF_MIN_KELVIN, + CONF_MIN_MIREDS, + CONF_RED_TEMPLATE, CONF_STATE_TOPIC, PAYLOAD_NONE, ) @@ -51,6 +62,7 @@ from ..entity import MqttEntity from ..models import ( MqttCommandTemplate, MqttValueTemplate, + PayloadSentinel, PublishPayloadType, ReceiveMessage, ) @@ -64,18 +76,6 @@ DOMAIN = "mqtt_template" DEFAULT_NAME = "MQTT Template Light" -CONF_BLUE_TEMPLATE = "blue_template" -CONF_BRIGHTNESS_TEMPLATE = "brightness_template" -CONF_COLOR_TEMP_TEMPLATE = "color_temp_template" -CONF_COMMAND_OFF_TEMPLATE = "command_off_template" -CONF_COMMAND_ON_TEMPLATE = "command_on_template" -CONF_EFFECT_LIST = "effect_list" -CONF_EFFECT_TEMPLATE = "effect_template" -CONF_GREEN_TEMPLATE = "green_template" -CONF_MAX_MIREDS = "max_mireds" -CONF_MIN_MIREDS = "min_mireds" -CONF_RED_TEMPLATE = "red_template" - COMMAND_TEMPLATES = (CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_OFF_TEMPLATE) VALUE_TEMPLATES = ( CONF_BLUE_TEMPLATE, @@ -127,7 +127,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): _command_templates: dict[ str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType] ] - _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] + _value_templates: dict[ + str, Callable[[ReceivePayloadType, ReceivePayloadType], ReceivePayloadType] + ] _fixed_color_mode: ColorMode | str | None _topics: dict[str, str | None] @@ -204,73 +206,133 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): @callback def _state_received(self, msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" - state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload) - if state == STATE_ON: + state_value = self._value_templates[CONF_STATE_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not state_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty state value", msg.topic + ) + elif state_value == STATE_ON: self._attr_is_on = True - elif state == STATE_OFF: + elif state_value == STATE_OFF: self._attr_is_on = False - elif state == PAYLOAD_NONE: + elif state_value == PAYLOAD_NONE: self._attr_is_on = None else: - _LOGGER.warning("Invalid state value received") + _LOGGER.warning( + "Invalid state value '%s' received from %s", + state_value, + msg.topic, + ) if CONF_BRIGHTNESS_TEMPLATE in self._config: - try: - if brightness := int( - self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload) - ): - self._attr_brightness = brightness - else: - _LOGGER.debug( - "Ignoring zero brightness value for entity %s", - self.entity_id, + brightness_value = self._value_templates[CONF_BRIGHTNESS_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not brightness_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty brightness value", + msg.topic, + ) + else: + try: + if brightness := int(brightness_value): + self._attr_brightness = brightness + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + except ValueError: + _LOGGER.warning( + "Invalid brightness value '%s' received from %s", + brightness_value, + msg.topic, ) - except ValueError: - _LOGGER.warning("Invalid brightness value received from %s", msg.topic) - if CONF_COLOR_TEMP_TEMPLATE in self._config: - try: - color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( - msg.payload + color_temp_value = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not color_temp_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty color temperature value", + msg.topic, ) - 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") + else: + try: + self._attr_color_temp_kelvin = ( + int(color_temp_value) + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( + int(color_temp_value) + ) + if color_temp_value != "None" + else None + ) + except ValueError: + _LOGGER.warning( + "Invalid color temperature value '%s' received from %s", + color_temp_value, + msg.topic, + ) if ( CONF_RED_TEMPLATE in self._config and CONF_GREEN_TEMPLATE in self._config and CONF_BLUE_TEMPLATE in self._config ): - try: - red = self._value_templates[CONF_RED_TEMPLATE](msg.payload) - green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload) - blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload) - if red == "None" and green == "None" and blue == "None": - self._attr_hs_color = None - else: - self._attr_hs_color = color_util.color_RGB_to_hs( - int(red), int(green), int(blue) - ) + red_value = self._value_templates[CONF_RED_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + green_value = self._value_templates[CONF_GREEN_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + blue_value = self._value_templates[CONF_BLUE_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not red_value or not green_value or not blue_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty color value", msg.topic + ) + elif red_value == "None" and green_value == "None" and blue_value == "None": + self._attr_hs_color = None self._update_color_mode() - except ValueError: - _LOGGER.warning("Invalid color value received") + else: + try: + self._attr_hs_color = color_util.color_RGB_to_hs( + int(red_value), int(green_value), int(blue_value) + ) + self._update_color_mode() + except ValueError: + _LOGGER.warning("Invalid color value received from %s", msg.topic) if CONF_EFFECT_TEMPLATE in self._config: - effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload)) - if ( - effect_list := self._config[CONF_EFFECT_LIST] - ) and effect in effect_list: - self._attr_effect = effect + effect_value = self._value_templates[CONF_EFFECT_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not effect_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty effect value", msg.topic + ) + elif (effect_list := self._config[CONF_EFFECT_LIST]) and str( + effect_value + ) in effect_list: + self._attr_effect = str(effect_value) else: - _LOGGER.warning("Unsupported effect value received") + _LOGGER.warning( + "Unsupported effect value '%s' received from %s", + effect_value, + msg.topic, + ) @callback def _prepare_subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 34c1f304944..8a42797b0f2 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -420,5 +420,41 @@ class MqttComponentConfig: discovery_payload: MQTTDiscoveryPayload +class DeviceMqttOptions(TypedDict, total=False): + """Hold the shared MQTT specific options for an MQTT device.""" + + qos: int + + +class MqttDeviceData(TypedDict, total=False): + """Hold the data for an MQTT device.""" + + name: str + identifiers: str + configuration_url: str + sw_version: str + hw_version: str + model: str + model_id: str + mqtt_settings: DeviceMqttOptions + + +class MqttAvailabilityData(TypedDict, total=False): + """Hold the availability configuration for a device.""" + + availability_topic: str + availability_template: str + payload_available: str + payload_not_available: str + + +class MqttSubentryData(TypedDict, total=False): + """Hold the data for a MQTT subentry.""" + + device: MqttDeviceData + components: dict[str, dict[str, Any]] + availability: MqttAvailabilityData + + DATA_MQTT: HassKey[MqttData] = HassKey("mqtt") DATA_MQTT_AVAILABLE: HassKey[asyncio.Future[bool]] = HassKey("mqtt_client_available") diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 5ee93cfba07..c3cc31bf04f 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -70,8 +70,8 @@ MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset( def validate_config(config: ConfigType) -> ConfigType: """Validate that the configuration is valid, throws if it isn't.""" - if config[CONF_MIN] >= config[CONF_MAX]: - raise vol.Invalid(f"'{CONF_MAX}' must be > '{CONF_MIN}'") + if config[CONF_MIN] > config[CONF_MAX]: + raise vol.Invalid(f"{CONF_MAX} must be >= {CONF_MIN}") return config diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 3e8a4fef0fa..b27ef68368a 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, STATE_CLASSES_SCHEMA, @@ -33,13 +34,22 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_OPTIONS, CONF_STATE_TOPIC, PAYLOAD_NONE +from .const import ( + CONF_EXPIRE_AFTER, + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_OPTIONS, + CONF_STATE_TOPIC, + CONF_SUGGESTED_DISPLAY_PRECISION, + DOMAIN, + PAYLOAD_NONE, +) from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -49,10 +59,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -CONF_EXPIRE_AFTER = "expire_after" -CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" -CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" - MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( { sensor.ATTR_LAST_RESET, @@ -63,6 +69,10 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False +URL_DOCS_SUPPORTED_SENSOR_UOM = ( + "https://www.home-assistant.io/integrations/sensor/#device-class" +) + _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), @@ -107,6 +117,23 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"got `{CONF_DEVICE_CLASS}` '{device_class}'" ) + if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( + unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) + ) is None: + return config + + if ( + device_class in DEVICE_CLASS_UNITS + and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] + ): + _LOGGER.warning( + "The unit of measurement `%s` is not valid " + "together with device class `%s`. " + "this will stop working in HA Core 2025.7.0", + unit_of_measurement, + device_class, + ) + return config @@ -155,8 +182,40 @@ class MqttSensor(MqttEntity, RestoreSensor): None ) + @callback + def async_check_uom(self) -> None: + """Check if the unit of measurement is valid with the device class.""" + if ( + self._discovery_data is not None + or self.device_class is None + or self.native_unit_of_measurement is None + ): + return + if ( + self.device_class in DEVICE_CLASS_UNITS + and self.native_unit_of_measurement + not in DEVICE_CLASS_UNITS[self.device_class] + ): + async_create_issue( + self.hass, + DOMAIN, + self.entity_id, + issue_domain=sensor.DOMAIN, + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url=URL_DOCS_SUPPORTED_SENSOR_UOM, + translation_placeholders={ + "uom": self.native_unit_of_measurement, + "device_class": self.device_class.value, + "entity_id": self.entity_id, + }, + translation_key="invalid_unit_of_measurement", + breaks_in_ha_version="2025.7.0", + ) + async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" + self.async_check_uom() last_state: State | None last_sensor_data: SensorExtraStoredData | None if ( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index fc316306d56..7006df09897 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,8 +1,12 @@ { "issues": { "invalid_platform_config": { - "title": "Invalid config found for mqtt {domain} item", + "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." + }, + "invalid_unit_of_measurement": { + "title": "Sensor with invalid unit of measurement", + "description": "Manual configured Sensor entity **{entity_id}** has a configured unit of measurement **{uom}** which is not valid with configured device class **{device_class}**. Make sure a valid unit of measurement is configured or remove the device class, and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." } }, "config": { @@ -26,6 +30,7 @@ "client_id": "Client ID (leave empty to randomly generated one)", "client_cert": "Upload client certificate file", "client_key": "Upload private key file", + "client_key_password": "[%key:common::config_flow::data::password%]", "keepalive": "The time between sending keep alive messages", "tls_insecure": "Ignore broker certificate validation", "protocol": "MQTT protocol", @@ -38,20 +43,21 @@ "data_description": { "broker": "The hostname or IP address of your MQTT broker.", "port": "The port your MQTT broker listens to. For example 1883.", - "username": "The username to login to your MQTT broker.", - "password": "The password to login to your MQTT broker.", + "username": "The username to log in to your MQTT broker.", + "password": "The password to log in to your MQTT broker.", "advanced_options": "Enable and select **Next** to set advanced options.", "certificate": "The custom CA certificate file to validate your MQTT brokers certificate.", "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", "client_cert": "The client certificate to authenticate against your MQTT broker.", "client_key": "The private key file that belongs to your client certificate.", + "client_key_password": "The password for the private key file (if set).", "keepalive": "A value less than 90 seconds is advised.", "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", "set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT brokers certificate.", "set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.", "transport": "The transport to be used for the connection to your MQTT broker.", - "ws_headers": "The WebSocket headers to pass through the WebSocket based connection to your MQTT broker.", + "ws_headers": "The WebSocket headers to pass through the WebSocket-based connection to your MQTT broker.", "ws_path": "The WebSocket path to be used for the connection to your MQTT broker." } }, @@ -62,7 +68,7 @@ "title": "Starting add-on" }, "hassio_confirm": { - "title": "MQTT Broker via Home Assistant add-on", + "title": "MQTT broker via Home Assistant add-on", "description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the add-on {addon}?" }, "reauth_confirm": { @@ -93,8 +99,8 @@ "bad_will": "Invalid will topic", "bad_discovery_prefix": "Invalid discovery prefix", "bad_certificate": "The CA certificate is invalid", - "bad_client_cert": "Invalid client certificate, ensure a PEM coded file is supplied", - "bad_client_key": "Invalid private key, ensure a PEM coded file is supplied without password", + "bad_client_cert": "Invalid client certificate, ensure a valid file is supplied", + "client_key_error": "Invalid private key file or invalid password supplied", "bad_client_cert_key": "Client certificate and private key are not a valid pair", "bad_ws_headers": "Supply valid HTTP headers as a JSON object", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -102,6 +108,389 @@ "invalid_inclusion": "The client certificate and private key must be configured together" } }, + "config_subentries": { + "device": { + "initiate_flow": { + "user": "Add MQTT Device", + "reconfigure": "Reconfigure MQTT Device" + }, + "entry_type": "MQTT Device", + "step": { + "availability": { + "title": "Availability options", + "description": "The availability feature allows a device to report it's availability.", + "data": { + "availability_topic": "Availability topic", + "availability_template": "Availability template", + "payload_available": "Payload available", + "payload_not_available": "Payload not available" + }, + "data_description": { + "availability_topic": "Topic to receive the availability payload on", + "availability_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-templates-with-the-mqtt-integration) to render the availability payload received on the availability topic", + "payload_available": "The payload that indicates the device is available (defaults to 'online')", + "payload_not_available": "The payload that indicates the device is not available (defaults to 'offline')" + } + }, + "device": { + "title": "Configure MQTT device details", + "description": "Enter the MQTT device details:", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "configuration_url": "Configuration URL", + "sw_version": "Software version", + "hw_version": "Hardware version", + "model": "Model", + "model_id": "Model ID" + }, + "data_description": { + "name": "The name of the manually added MQTT device.", + "configuration_url": "A link to the webpage that can manage the configuration of this device. Can be either a 'http://', 'https://' or an internal 'homeassistant://' URL.", + "sw_version": "The software version of the device. E.g. '2025.1.0'.", + "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.", + "model": "E.g. 'Cleanmaster Pro'.", + "model_id": "E.g. '123NK2PRO'." + }, + "sections": { + "mqtt_settings": { + "name": "MQTT settings", + "data": { + "qos": "QoS" + }, + "data_description": { + "qos": "The Quality of Service value the device's entities should use." + } + } + } + }, + "summary_menu": { + "title": "Reconfigure \"{mqtt_device}\"", + "description": "Entities set up:\n{mqtt_items}\n\nDecide what to do next:", + "menu_options": { + "entity": "Add another entity to \"{mqtt_device}\"", + "update_entity": "Update entity properties", + "delete_entity": "Delete an entity", + "availability": "Configure availability", + "device": "Update device properties", + "save_changes": "Save changes" + } + }, + "entity": { + "title": "Configure MQTT device \"{mqtt_device}\"", + "description": "Configure the basic {platform_label}entity settings{entity_name_label}", + "data": { + "platform": "Type of entity", + "name": "Entity name", + "entity_picture": "Entity picture" + }, + "data_description": { + "platform": "The type of the entity to configure.", + "name": "The name of the entity. Leave empty to set it to `None` to [mark it as main feature of the MQTT device](https://www.home-assistant.io/integrations/mqtt/#naming-of-mqtt-entities).", + "entity_picture": "An URL to a picture to be assigned." + } + }, + "delete_entity": { + "title": "Delete entity", + "description": "Delete an entity. The entity will be removed from the device. Removing an entity will break any automations or scripts that depend on it.", + "data": { + "component": "Entity" + }, + "data_description": { + "component": "Select the entity you want to delete. Minimal one entity is required." + } + }, + "update_entity": { + "title": "Select entity", + "description": "Select the entity you want to update", + "data": { + "component": "Entity" + }, + "data_description": { + "component": "Select the entity you want to update." + } + }, + "entity_platform_config": { + "title": "Configure MQTT device \"{mqtt_device}\"", + "description": "Please configure specific details for {platform} entity \"{entity}\":", + "data": { + "device_class": "Device class", + "options": "Add option", + "schema": "Schema", + "state_class": "State class", + "suggested_display_precision": "Suggested display precision", + "unit_of_measurement": "Unit of measurement" + }, + "data_description": { + "device_class": "The Device class of the {platform} entity. [Learn more.]({url}#device_class)", + "options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement.", + "schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)", + "state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)", + "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)", + "unit_of_measurement": "Defines the unit of measurement of the sensor, if any." + }, + "sections": { + "advanced_settings": { + "name": "Advanced options", + "data": { + "suggested_display_precision": "Suggested display precision" + }, + "data_description": { + "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)" + } + } + } + }, + "mqtt_platform_config": { + "title": "Configure MQTT device \"{mqtt_device}\"", + "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":", + "data": { + "blue_template": "Blue template", + "brightness_template": "Brightness template", + "command_template": "Command template", + "command_topic": "Command topic", + "command_off_template": "Command \"off\" template", + "command_on_template": "Command \"on\" template", + "color_temp_template": "Color temperature template", + "force_update": "Force update", + "green_template": "Green template", + "last_reset_value_template": "Last reset value template", + "on_command_type": "ON command type", + "optimistic": "Optimistic", + "payload_off": "Payload \"off\"", + "payload_on": "Payload \"on\"", + "payload_press": "Payload \"press\"", + "qos": "QoS", + "red_template": "Red template", + "retain": "Retain", + "state_template": "State template", + "state_topic": "State topic", + "state_value_template": "State value template", + "supported_color_modes": "Supported color modes", + "value_template": "Value template" + }, + "data_description": { + "blue_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract blue color from the state payload value. Expected result of the template is an integer from 0-255 range.", + "brightness_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract brightness from the state payload value. Expected result of the template is an integer from 0-255 range.", + "command_off_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"off\" state changes. Available variables are: `state` and `transition`.", + "command_on_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"on\" state changes. Available variables: `state`, `brightness`, `color_temp`, `red`, `green`, `blue`, `hue`, `sat`, `flash`, `transition` and `effect`. Values `red`, `green`, `blue` and `brightness` are provided as integers from range 0-255. Value of `hue` is provided as float from range 0-360. Value of `sat` is provided as float from range 0-100. Value of `color_temp` is provided as integer representing Kelvin units.", + "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic.", + "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", + "color_temp_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract color temperature in Kelvin from the state payload value. Expected result of the template is an integer.", + "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", + "green_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract green color from the state payload value. Expected result of the template is an integer from 0-255 range.", + "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", + "on_command_type": "Defines when the payload \"on\" is sent. Using \"Last\" (the default) will send any style (brightness, color, etc) topics first and then a payload \"on\" to the command topic. Using \"First\" will send the payload \"on\" and then any style topics. Using \"Brightness\" will only send brightness commands instead of the payload \"on\" to turn the light on.", + "optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)", + "payload_off": "The payload that represents the \"off\" state.", + "payload_on": "The payload that represents the \"on\" state.", + "payload_press": "The payload to send when the button is triggered.", + "qos": "The QoS value a {platform} entity should use.", + "red_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract red color from the state payload value. Expected result of the template is an integer from 0-255 range.", + "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", + "state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.", + "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", + "supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)", + "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value. [Learn more.]({url}#value_template)" + }, + "sections": { + "advanced_settings": { + "name": "Advanced settings", + "data": { + "expire_after": "Expire after", + "flash": "Flash support", + "flash_time_long": "Flash time long", + "flash_time_short": "Flash time short", + "max_kelvin": "Max Kelvin", + "min_kelvin": "Min Kelvin", + "off_delay": "OFF delay", + "transition": "Transition support" + }, + "data_description": { + "expire_after": "If set, it defines the number of seconds after the sensor’s state expires, if it’s not updated. After expiry, the sensor’s state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)", + "flash": "Enable the flash feature for this light", + "flash_time_long": "The duration, in seconds, of a \"long\" flash.", + "flash_time_short": "The duration, in seconds, of a \"short\" flash.", + "max_kelvin": "The maximum color temperature in Kelvin.", + "min_kelvin": "The minimum color temperature in Kelvin.", + "off_delay": "For sensors that only send \"on\" state updates (like PIRs), this variable sets a delay in seconds after which the sensor’s state will be updated back to \"off\".", + "transition": "Enable the transition feature for this light" + } + }, + "light_brightness_settings": { + "name": "Brightness settings", + "data": { + "brightness": "Separate brightness", + "brightness_command_template": "Brightness command template", + "brightness_command_topic": "Brightness command topic", + "brightness_scale": "Brightness scale", + "brightness_state_topic": "Brightness state topic", + "brightness_value_template": "Brightness value template" + }, + "data_description": { + "brightness": "Flag that defines if light supports brightness when the RGB, RGBW, or RGBWW color mode is supported.", + "brightness_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the brightness command topic.", + "brightness_command_topic": "The publishing topic that will be used to control the brightness. [Learn more.]({url}#brightness_command_topic)", + "brightness_scale": "Defines the maximum brightness value (i.e., 100%) of the maximum brightness.", + "brightness_state_topic": "The MQTT topic subscribed to receive brightness state values. [Learn more.]({url}#brightness_state_topic)", + "brightness_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the brightness value." + } + }, + "light_color_mode_settings": { + "name": "Color mode settings", + "data": { + "color_mode_state_topic": "Color mode state topic", + "color_mode_value_template": "Color mode value template" + }, + "data_description": { + "color_mode_state_topic": "The MQTT topic subscribed to receive color mode updates. If this is not configured, the color mode will be automatically set according to the last received valid color or color temperature.", + "color_mode_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the color mode value." + } + }, + "light_color_temp_settings": { + "name": "Color temperature settings", + "data": { + "color_temp_command_template": "Color temperature command template", + "color_temp_command_topic": "Color temperature command topic", + "color_temp_state_topic": "Color temperature state topic", + "color_temp_value_template": "Color temperature value template" + }, + "data_description": { + "color_temp_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the color temperature command topic.", + "color_temp_command_topic": "The publishing topic that will be used to control the color temperature. [Learn more.]({url}#color_temp_command_topic)", + "color_temp_state_topic": "The MQTT topic subscribed to receive color temperature state updates. [Learn more.]({url}#color_temp_state_topic)", + "color_temp_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the color temperature value." + } + }, + "light_effect_settings": { + "name": "Effect settings", + "data": { + "effect": "Effect", + "effect_command_template": "Effect command template", + "effect_command_topic": "Effect command topic", + "effect_list": "Effect list", + "effect_state_topic": "Effect state topic", + "effect_template": "Effect template", + "effect_value_template": "Effect value template" + }, + "data_description": { + "effect": "Flag that defines if the light supports effects.", + "effect_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the effect command topic.", + "effect_command_topic": "The publishing topic that will be used to control the light's effect state. [Learn more.]({url}#effect_command_topic)", + "effect_list": "The list of effects the light supports.", + "effect_state_topic": "The MQTT topic subscribed to receive effect state updates. [Learn more.]({url}#effect_state_topic)" + } + }, + "light_hs_settings": { + "name": "HS color mode settings", + "data": { + "hs_command_template": "HS command template", + "hs_command_topic": "HS command topic", + "hs_state_topic": "HS state topic", + "hs_value_template": "HS value template" + }, + "data_description": { + "hs_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to HS command topic. Available variables: `hue` and `sat`.", + "hs_command_topic": "The MQTT topic to publish commands to change the light’s color state in HS format (Hue Saturation). Range for Hue: 0° .. 360°, Range of Saturation: 0..100. Note: Brightness is sent separately in the brightness command topic. [Learn more.]({url}#hs_command_topic)", + "hs_state_topic": "The MQTT topic subscribed to receive color state updates in HS format. The expected payload is the hue and saturation values separated by commas, for example, `359.5,100.0`. Note: Brightness is received separately in the brightness state topic. [Learn more.]({url}#hs_state_topic)", + "hs_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the HS value." + } + }, + "light_rgb_settings": { + "name": "RGB color mode settings", + "data": { + "rgb_command_template": "RGB command template", + "rgb_command_topic": "RGB command topic", + "rgb_state_topic": "RGB state topic", + "rgb_value_template": "RGB value template" + }, + "data_description": { + "rgb_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to RGB command topic. Available variables: `red`, `green` and `blue`.", + "rgb_command_topic": "The MQTT topic to publish commands to change the light’s RGB state. [Learn more.]({url}#rgb_command_topic)", + "rgb_state_topic": "The MQTT topic subscribed to receive RGB state updates. The expected payload is the RGB values separated by commas, for example, `255,0,127`. [Learn more.]({url}#rgb_state_topic)", + "rgb_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the RGB value." + } + }, + "light_rgbw_settings": { + "name": "RGBW color mode settings", + "data": { + "rgbw_command_template": "RGBW command template", + "rgbw_command_topic": "RGBW command topic", + "rgbw_state_topic": "RGBW state topic", + "rgbw_value_template": "RGBW value template" + }, + "data_description": { + "rgbw_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to RGBW command topic. Available variables: `red`, `green`, `blue` and `white`.", + "rgbw_command_topic": "The MQTT topic to publish commands to change the light’s RGBW state. [Learn more.]({url}#rgbw_command_topic)", + "rgbw_state_topic": "The MQTT topic subscribed to receive RGBW state updates. The expected payload is the RGBW values separated by commas, for example, `255,0,127,64`. [Learn more.]({url}#rgbw_state_topic)", + "rgbw_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the RGBW value." + } + }, + "light_rgbww_settings": { + "name": "RGBWW color mode settings", + "data": { + "rgbww_command_template": "RGBWW command template", + "rgbww_command_topic": "RGBWW command topic", + "rgbww_state_topic": "RGBWW state topic", + "rgbww_value_template": "RGBWW value template" + }, + "data_description": { + "rgbww_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to RGBWW command topic. Available variables: `red`, `green`, `blue`, `cold_white` and `warm_white`.", + "rgbww_command_topic": "The MQTT topic to publish commands to change the light’s RGBWW state. [Learn more.]({url}#rgbww_command_topic)", + "rgbww_state_topic": "The MQTT topic subscribed to receive RGBWW state updates. The expected payload is the RGBWW values separated by commas, for example, `255,0,127,64,32`. [Learn more.]({url}#rgbww_state_topic)", + "rgbww_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the RGBWW value." + } + }, + "light_white_settings": { + "name": "White color mode settings", + "data": { + "white_command_topic": "White command topic", + "white_scale": "White scale" + }, + "data_description": { + "white_command_topic": "The MQTT topic to publish commands to change the light to white mode with a given brightness. [Learn more.]({url}#white_command_topic)", + "white_scale": "Defines the maximum white level (i.e., 100%) of the maximum." + } + }, + "light_xy_settings": { + "name": "XY color mode settings", + "data": { + "xy_command_template": "XY command template", + "xy_command_topic": "XY command topic", + "xy_state_topic": "XY state topic", + "xy_value_template": "XY value template" + }, + "data_description": { + "xy_command_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose message which will be sent to XY command topic. Available variables: `x` and `y`.", + "xy_command_topic": "The MQTT topic to publish commands to change the light’s XY state. [Learn more.]({url}#xy_command_topic)", + "xy_state_topic": "The MQTT topic subscribed to receive XY state updates. The expected payload is the X and Y color values separated by commas, for example, `0.675,0.322`. [Learn more.]({url}#xy_state_topic)", + "xy_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the XY value." + } + } + } + } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "create_entry": { + "default": "MQTT device with {platform} entity \"{entity}\" was set up successfully.\n\nNote that you can reconfigure the MQTT device at any time, e.g. to add more entities." + }, + "error": { + "invalid_input": "Invalid value", + "invalid_subscribe_topic": "Invalid subscribe topic", + "invalid_template": "Invalid template", + "invalid_supported_color_modes": "Invalid supported color modes selection", + "invalid_uom": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected device class, please either remove the device class, select a device class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list", + "invalid_url": "Invalid URL", + "last_reset_not_with_state_class_total": "The last reset value template option should be used with state class 'Total' only", + "max_below_min_kelvin": "Max Kelvin value should be greater than min Kelvin value", + "options_not_allowed_with_state_class_or_uom": "The 'Options' setting is not allowed when state class or unit of measurement are used", + "options_device_class_enum": "The 'Options' setting must be used with the Enumeration device class. If you continue, the existing options will be reset", + "options_with_enum_device_class": "Configure options for the enumeration sensor", + "uom_required_for_device_class": "The selected device class requires a unit" + } + } + }, "device_automation": { "trigger_type": { "button_short_press": "\"{subtype}\" pressed", @@ -189,15 +578,15 @@ "discovery": "Option to enable MQTT automatic discovery.", "discovery_prefix": "The prefix of configuration topics the MQTT integration will subscribe to.", "birth_enable": "When set, Home Assistant will publish an online message to your MQTT broker when MQTT is ready.", - "birth_topic": "The MQTT topic where Home Assistant will publish a `birth` message.", - "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 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.", - "will_retain": "When set, your MQTT broker will retain the `will` message." + "birth_topic": "The MQTT topic where Home Assistant will publish a \"birth\" message.", + "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 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.", + "will_retain": "When set, your MQTT broker will retain the \"will\" message." } } }, @@ -207,7 +596,7 @@ "bad_discovery_prefix": "[%key:component::mqtt::config::error::bad_discovery_prefix%]", "bad_certificate": "[%key:component::mqtt::config::error::bad_certificate%]", "bad_client_cert": "[%key:component::mqtt::config::error::bad_client_cert%]", - "bad_client_key": "[%key:component::mqtt::config::error::bad_client_key%]", + "client_key_error": "[%key:component::mqtt::config::error::client_key_error%]", "bad_client_cert_key": "[%key:component::mqtt::config::error::bad_client_cert_key%]", "bad_ws_headers": "[%key:component::mqtt::config::error::bad_ws_headers%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -215,12 +604,161 @@ } }, "selector": { + "device_class_binary_sensor": { + "options": { + "battery": "[%key:component::binary_sensor::entity_component::battery::name%]", + "battery_charging": "[%key:component::binary_sensor::entity_component::battery_charging::name%]", + "carbon_monoxide": "[%key:component::binary_sensor::entity_component::carbon_monoxide::name%]", + "cold": "[%key:component::binary_sensor::entity_component::cold::name%]", + "connectivity": "[%key:component::binary_sensor::entity_component::connectivity::name%]", + "door": "[%key:component::binary_sensor::entity_component::door::name%]", + "garage_door": "[%key:component::binary_sensor::entity_component::garage_door::name%]", + "gas": "[%key:component::binary_sensor::entity_component::gas::name%]", + "heat": "[%key:component::binary_sensor::entity_component::heat::name%]", + "light": "[%key:component::binary_sensor::entity_component::light::name%]", + "lock": "[%key:component::binary_sensor::entity_component::lock::name%]", + "moisture": "[%key:component::binary_sensor::entity_component::moisture::name%]", + "motion": "[%key:component::binary_sensor::entity_component::motion::name%]", + "moving": "[%key:component::binary_sensor::entity_component::moving::name%]", + "occupancy": "[%key:component::binary_sensor::entity_component::occupancy::name%]", + "opening": "[%key:component::binary_sensor::entity_component::opening::name%]", + "plug": "[%key:component::binary_sensor::entity_component::plug::name%]", + "power": "[%key:component::binary_sensor::entity_component::power::name%]", + "presence": "[%key:component::binary_sensor::entity_component::presence::name%]", + "problem": "[%key:component::binary_sensor::entity_component::problem::name%]", + "running": "[%key:component::binary_sensor::entity_component::running::name%]", + "safety": "[%key:component::binary_sensor::entity_component::safety::name%]", + "smoke": "[%key:component::binary_sensor::entity_component::smoke::name%]", + "sound": "[%key:component::binary_sensor::entity_component::sound::name%]", + "tamper": "[%key:component::binary_sensor::entity_component::tamper::name%]", + "update": "[%key:component::binary_sensor::entity_component::update::name%]", + "vibration": "[%key:component::binary_sensor::entity_component::vibration::name%]", + "window": "[%key:component::binary_sensor::entity_component::window::name%]" + } + }, + "device_class_button": { + "options": { + "identify": "[%key:component::button::entity_component::identify::name%]", + "restart": "[%key:common::action::restart%]", + "update": "[%key:component::button::entity_component::update::name%]" + } + }, + "device_class_sensor": { + "options": { + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::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%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "date": "[%key:component::sensor::entity_component::date::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "enum": "Enumeration", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "ph": "[%key:component::sensor::entity_component::ph::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "device_class_switch": { + "options": { + "outlet": "[%key:component::switch::entity_component::outlet::name%]", + "switch": "[%key:component::switch::title%]" + } + }, + "light_schema": { + "options": { + "basic": "Default schema", + "json": "JSON", + "template": "Template" + } + }, + "on_command_type": { + "options": { + "brightness": "Brightness", + "first": "First", + "last": "Last" + } + }, + "platform": { + "options": { + "binary_sensor": "[%key:component::binary_sensor::title%]", + "button": "[%key:component::button::title%]", + "light": "[%key:component::light::title%]", + "notify": "[%key:component::notify::title%]", + "sensor": "[%key:component::sensor::title%]", + "switch": "[%key:component::switch::title%]" + } + }, "set_ca_cert": { "options": { "off": "[%key:common::state::off%]", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "custom": "Custom" } + }, + "state_class": { + "options": { + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + }, + "supported_color_modes": { + "options": { + "onoff": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::onoff%]", + "brightness": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::brightness%]", + "color_temp": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::color_temp%]", + "hs": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::hs%]", + "xy": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::xy%]", + "rgb": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgb%]", + "rgbw": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbw%]", + "rgbww": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbww%]", + "white": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::white%]" + } } }, "services": { @@ -230,7 +768,7 @@ "fields": { "evaluate_payload": { "name": "Evaluate payload", - "description": "When `payload` is a Python bytes literal, evaluate the bytes literal and publish the raw data." + "description": "If 'Payload' is a Python bytes literal, evaluate the bytes literal and publish the raw data." }, "topic": { "name": "Topic", diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index c4916b5010c..5591e5d801d 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -26,7 +26,7 @@ from . import subscription from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON from .entity import MqttEntity, async_setup_entity_entry_helper -from .models import MqttValueTemplate, ReceiveMessage +from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic @@ -105,10 +105,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): @property def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend.""" - if self._attr_entity_picture is not None: - return self._attr_entity_picture - - return super().entity_picture + return self._attr_entity_picture @staticmethod def config_schema() -> VolSchemaType: @@ -136,7 +133,18 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): @callback def _handle_state_message_received(self, msg: ReceiveMessage) -> None: """Handle receiving state message via MQTT.""" - payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) + payload = self._templates[CONF_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + + if payload is PayloadSentinel.DEFAULT: + _LOGGER.warning( + "Unable to process payload '%s' for topic %s, with value template '%s'", + msg.payload, + msg.topic, + self._config.get(CONF_VALUE_TEMPLATE), + ) + return if not payload or payload == PAYLOAD_EMPTY_JSON: _LOGGER.debug( diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 27bdb4f2a35..e3996c80a8a 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -411,3 +411,9 @@ def migrate_certificate_file_to_content(file_name_or_auto: str) -> str | None: return certificate_file.read() except OSError: return None + + +@callback +def learn_more_url(platform: str) -> str: + """Return the URL for the platform specific MQTT documentation.""" + return f"https://www.home-assistant.io/integrations/{platform}.mqtt/" diff --git a/homeassistant/components/mullvad/config_flow.py b/homeassistant/components/mullvad/config_flow.py index c16f8879a7b..b179c5605ef 100644 --- a/homeassistant/components/mullvad/config_flow.py +++ b/homeassistant/components/mullvad/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Mullvad VPN integration.""" +import logging from typing import Any from mullvad_api import MullvadAPI, MullvadAPIError @@ -8,6 +9,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class MullvadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Mullvad VPN.""" @@ -24,7 +27,8 @@ class MullvadConfigFlow(ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job(MullvadAPI) except MullvadAPIError: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry(title="Mullvad VPN", data=user_input) diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index e569bb93a42..a2d2dae9e3f 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from music_assistant_client import MusicAssistantClient from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion from music_assistant_models.enums import EventType -from music_assistant_models.errors import MusicAssistantError +from music_assistant_models.errors import ActionUnavailable, MusicAssistantError from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform @@ -23,7 +23,7 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) -from .actions import register_actions +from .actions import get_music_assistant_client, register_actions from .const import DOMAIN, LOGGER if TYPE_CHECKING: @@ -137,6 +137,18 @@ async def async_setup_entry( mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED) ) + # check if any playerconfigs have been removed while we were disconnected + all_player_configs = await mass.config.get_player_configs() + player_ids = {player.player_id for player in all_player_configs} + dev_reg = dr.async_get(hass) + dev_entries = dr.async_entries_for_config_entry(dev_reg, entry.entry_id) + for device in dev_entries: + for identifier in device.identifiers: + if identifier[0] == DOMAIN and identifier[1] not in player_ids: + dev_reg.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + return True @@ -174,3 +186,31 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await mass_entry_data.mass.disconnect() return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + player_id = next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if player_id is None: + # this should not be possible at all, but guard it anyways + return False + mass = get_music_assistant_client(hass, config_entry.entry_id) + if mass.players.get(player_id) is None: + # player is already removed on the server, this is an orphaned device + return True + # try to remove the player from the server + try: + await mass.config.remove_player_config(player_id) + except ActionUnavailable: + return False + else: + return True diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index bf9a1260362..031229d1544 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -23,6 +23,7 @@ from .const import ( ATTR_ALBUM_TYPE, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_ITEMS, @@ -32,6 +33,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_RADIO, ATTR_SEARCH, ATTR_SEARCH_ALBUM, @@ -48,7 +50,15 @@ from .schemas import ( if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient - from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track + from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, + ) from . import MusicAssistantConfigEntry @@ -155,6 +165,14 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: media_item_dict_from_mass_item(mass, item) for item in search_results.radio ], + ATTR_AUDIOBOOKS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.audiobooks + ], + ATTR_PODCASTS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.podcasts + ], } ) return response @@ -175,7 +193,13 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: "order_by": order_by, } library_result: ( - list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist] + list[Album] + | list[Artist] + | list[Track] + | list[Radio] + | list[Playlist] + | list[Audiobook] + | list[Podcast] ) if media_type == MediaType.ALBUM: library_result = await mass.music.get_library_albums( @@ -199,6 +223,14 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: library_result = await mass.music.get_library_playlists( **base_params, ) + elif media_type == MediaType.AUDIOBOOK: + library_result = await mass.music.get_library_audiobooks( + **base_params, + ) + elif media_type == MediaType.PODCAST: + library_result = await mass.music.get_library_podcasts( + **base_params, + ) else: raise ServiceValidationError(f"Unsupported media type {media_type}") diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 1980c495278..d2ee1f75028 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -34,6 +34,8 @@ ATTR_ARTISTS = "artists" ATTR_ALBUMS = "albums" ATTR_TRACKS = "tracks" ATTR_PLAYLISTS = "playlists" +ATTR_AUDIOBOOKS = "audiobooks" +ATTR_PODCASTS = "podcasts" ATTR_RADIO = "radio" ATTR_ITEMS = "items" ATTR_RADIO_MODE = "radio_mode" diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index fb8bb9c3ac2..28e8587e90c 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.1.1"], + "requirements": ["music-assistant-client==1.2.0"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index a926e2a0595..11cbbd3f655 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -2,9 +2,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import logging +from typing import TYPE_CHECKING, Any, cast -from music_assistant_models.media_items import MediaItemType +from music_assistant_models.enums import MediaType as MASSMediaType +from music_assistant_models.media_items import ( + BrowseFolder, + MediaItemType, + SearchResults, +) from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -12,6 +18,9 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaType, + SearchError, + SearchMedia, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant @@ -20,13 +29,17 @@ from .const import DEFAULT_NAME, DOMAIN if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient +MEDIA_TYPE_AUDIOBOOK = "audiobook" MEDIA_TYPE_RADIO = "radio" PLAYABLE_MEDIA_TYPES = [ - MediaType.PLAYLIST, MediaType.ALBUM, MediaType.ARTIST, + MEDIA_TYPE_AUDIOBOOK, + MediaType.PLAYLIST, + MediaType.PODCAST, MEDIA_TYPE_RADIO, + MediaType.PODCAST, MediaType.TRACK, ] @@ -35,6 +48,8 @@ LIBRARY_ALBUMS = "albums" LIBRARY_TRACKS = "tracks" LIBRARY_PLAYLISTS = "playlists" LIBRARY_RADIO = "radio" +LIBRARY_PODCASTS = "podcasts" +LIBRARY_AUDIOBOOKS = "audiobooks" LIBRARY_TITLE_MAP = { @@ -43,6 +58,8 @@ LIBRARY_TITLE_MAP = { LIBRARY_TRACKS: "Tracks", LIBRARY_PLAYLISTS: "Playlists", LIBRARY_RADIO: "Radio stations", + LIBRARY_PODCASTS: "Podcasts", + LIBRARY_AUDIOBOOKS: "Audiobooks", } LIBRARY_MEDIA_CLASS_MAP = { @@ -51,10 +68,14 @@ LIBRARY_MEDIA_CLASS_MAP = { LIBRARY_TRACKS: MediaClass.TRACK, LIBRARY_PLAYLISTS: MediaClass.PLAYLIST, LIBRARY_RADIO: MediaClass.MUSIC, # radio is not accepted by HA + LIBRARY_PODCASTS: MediaClass.PODCAST, + LIBRARY_AUDIOBOOKS: MediaClass.DIRECTORY, # audiobook is not accepted by HA } MEDIA_CONTENT_TYPE_FLAC = "audio/flac" THUMB_SIZE = 200 +SORT_NAME_DESC = "sort_name_desc" +LOGGER = logging.getLogger(__name__) def media_source_filter(item: BrowseMedia) -> bool: @@ -89,13 +110,16 @@ async def async_browse_media( return await build_playlists_listing(mass) if media_content_id == LIBRARY_RADIO: return await build_radio_listing(mass) + if media_content_id == LIBRARY_PODCASTS: + return await build_podcasts_listing(mass) + if media_content_id == LIBRARY_AUDIOBOOKS: + return await build_audiobooks_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}") @@ -148,16 +172,15 @@ async def build_playlists_listing(mass: MusicAssistantClient) -> BrowseMedia: 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, - ), + children=[ + 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, order_by=SORT_NAME_DESC + ) + if item.available + ], ) @@ -201,16 +224,15 @@ async def build_artists_listing(mass: MusicAssistantClient) -> BrowseMedia: 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, - ), + children=[ + 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, order_by=SORT_NAME_DESC + ) + if artist.available + ], ) @@ -252,16 +274,15 @@ async def build_albums_listing(mass: MusicAssistantClient) -> BrowseMedia: 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, - ), + children=[ + 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, order_by=SORT_NAME_DESC + ) + if album.available + ], ) @@ -301,16 +322,61 @@ async def build_tracks_listing(mass: MusicAssistantClient) -> BrowseMedia: 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, - ), + children=[ + 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, order_by=SORT_NAME_DESC + ) + if track.available + ], + ) + + +async def build_podcasts_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Podcasts browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_PODCASTS] + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_PODCASTS, + media_content_type=MediaType.PODCAST, + title=LIBRARY_TITLE_MAP[LIBRARY_PODCASTS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=[ + build_item(mass, podcast, can_expand=False) + # we only grab the first page here because the + # HA media browser does not support paging + for podcast in await mass.music.get_library_podcasts( + limit=500, order_by=SORT_NAME_DESC + ) + if podcast.available + ], + ) + + +async def build_audiobooks_listing(mass: MusicAssistantClient) -> BrowseMedia: + """Build Audiobooks browse listing.""" + media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_AUDIOBOOKS] + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=LIBRARY_AUDIOBOOKS, + media_content_type=DOMAIN, + title=LIBRARY_TITLE_MAP[LIBRARY_AUDIOBOOKS], + can_play=False, + can_expand=True, + children_media_class=media_class, + children=[ + build_item(mass, audiobook, can_expand=False) + # we only grab the first page here because the + # HA media browser does not support paging + for audiobook in await mass.music.get_library_audiobooks( + limit=500, order_by=SORT_NAME_DESC + ) + if audiobook.available + ], ) @@ -329,7 +395,9 @@ async def build_radio_listing(mass: MusicAssistantClient) -> BrowseMedia: 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) + for track in await mass.music.get_library_radios( + limit=500, order_by=SORT_NAME_DESC + ) if track.available ], ) @@ -360,3 +428,205 @@ def build_item( can_expand=can_expand, thumbnail=img_url, ) + + +async def _search_within_album( + mass: MusicAssistantClient, album_uri: str, search_query: str, limit: int +) -> SearchMedia: + """Search for tracks within a specific album.""" + album = await mass.music.get_item_by_uri(album_uri) + tracks = await mass.music.get_album_tracks(album.item_id, album.provider) + + # Filter tracks by search query + filtered_tracks = [ + track + for track in tracks + if search_query.lower() in track.name.lower() and track.available + ] + + return SearchMedia( + result=[ + build_item(mass, track, can_expand=False) + for track in filtered_tracks[:limit] + ] + ) + + +async def _search_within_artist( + mass: MusicAssistantClient, artist_uri: str, search_query: str, limit: int +) -> SearchResults: + """Search for content within an artist's catalog.""" + artist = await mass.music.get_item_by_uri(artist_uri) + search_query = f"{artist.name} - {search_query}" + return await mass.music.search( + search_query, + media_types=[MASSMediaType.ALBUM, MASSMediaType.TRACK], + limit=limit, + ) + + +def _get_media_types_from_query(query: SearchMediaQuery) -> list[MASSMediaType]: + """Map query to Music Assistant media types.""" + media_types: list[MASSMediaType] = [] + + match query.media_content_type: + case MediaType.ARTIST: + media_types = [MASSMediaType.ARTIST] + case MediaType.ALBUM: + media_types = [MASSMediaType.ALBUM] + case MediaType.TRACK: + media_types = [MASSMediaType.TRACK] + case MediaType.PLAYLIST: + media_types = [MASSMediaType.PLAYLIST] + case "radio": + media_types = [MASSMediaType.RADIO] + case "audiobook": + media_types = [MASSMediaType.AUDIOBOOK] + case MediaType.PODCAST: + media_types = [MASSMediaType.PODCAST] + case _: + # No specific type selected + if query.media_filter_classes: + # Map MediaClass to search types + mapping = { + MediaClass.ARTIST: MASSMediaType.ARTIST, + MediaClass.ALBUM: MASSMediaType.ALBUM, + MediaClass.TRACK: MASSMediaType.TRACK, + MediaClass.PLAYLIST: MASSMediaType.PLAYLIST, + MediaClass.MUSIC: MASSMediaType.RADIO, + MediaClass.DIRECTORY: MASSMediaType.AUDIOBOOK, + MediaClass.PODCAST: MASSMediaType.PODCAST, + } + media_types = [ + mapping[cls] for cls in query.media_filter_classes if cls in mapping + ] + + # Default to all types if none specified + if not media_types: + media_types = [ + MASSMediaType.ARTIST, + MASSMediaType.ALBUM, + MASSMediaType.TRACK, + MASSMediaType.PLAYLIST, + MASSMediaType.RADIO, + MASSMediaType.AUDIOBOOK, + MASSMediaType.PODCAST, + ] + + return media_types + + +def _process_search_results( + mass: MusicAssistantClient, + search_results: SearchResults, + media_types: list[MASSMediaType], +) -> list[BrowseMedia]: + """Process search results into BrowseMedia items.""" + result: list[BrowseMedia] = [] + + # Process search results for each media type + for media_type in media_types: + # Get items for each media type using pattern matching + items: list[MediaItemType] = [] + match media_type: + case MASSMediaType.ARTIST if search_results.artists: + # Cast to ensure type safety + items = cast(list[MediaItemType], search_results.artists) + case MASSMediaType.ALBUM if search_results.albums: + items = cast(list[MediaItemType], search_results.albums) + case MASSMediaType.TRACK if search_results.tracks: + items = cast(list[MediaItemType], search_results.tracks) + case MASSMediaType.PLAYLIST if search_results.playlists: + items = cast(list[MediaItemType], search_results.playlists) + case MASSMediaType.RADIO if search_results.radio: + items = cast(list[MediaItemType], search_results.radio) + case MASSMediaType.PODCAST if search_results.podcasts: + items = cast(list[MediaItemType], search_results.podcasts) + case MASSMediaType.AUDIOBOOK if search_results.audiobooks: + items = cast(list[MediaItemType], search_results.audiobooks) + case _: + continue + + # Add available items to results + for item in items: + if TYPE_CHECKING: + assert not isinstance(item, BrowseFolder) + if not item.available: + continue + + # Create browse item + # Convert to string to get the original value since we're using MASSMediaType enum + str_media_type = media_type.value.lower() + can_expand = _should_expand_media_type(str_media_type) + media_class = _get_media_class_for_type(str_media_type) + + browse_item = build_item( + mass, + item, + can_expand=can_expand, + media_class=media_class, + ) + result.append(browse_item) + + return result + + +def _should_expand_media_type(media_type: str) -> bool: + """Determine if a media type should be expandable.""" + return media_type in ("artist", "album", "playlist", "podcast") + + +def _get_media_class_for_type(media_type: str) -> MediaClass | None: + """Get the appropriate media class for a given media type.""" + mapping = { + "artist": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ARTISTS], + "album": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ALBUMS], + "track": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_TRACKS], + "playlist": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_PLAYLISTS], + "radio": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_RADIO], + "podcast": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_PODCASTS], + "audiobook": LIBRARY_MEDIA_CLASS_MAP[LIBRARY_AUDIOBOOKS], + } + return mapping.get(media_type) + + +async def async_search_media( + mass: MusicAssistantClient, + query: SearchMediaQuery, +) -> SearchMedia: + """Search media.""" + try: + search_query = query.search_query + limit = 5 # Default limit per media type + search_results: SearchResults | None = None + + # Handle media_content_id if provided (for contextual searches) + if query.media_content_id: + if "album/" in query.media_content_id: + return await _search_within_album( + mass, query.media_content_id, search_query, limit + ) + if "artist/" in query.media_content_id: + # For artists, we already run a search, so save the results + search_results = await _search_within_artist( + mass, query.media_content_id, search_query, limit + ) + + # Determine which media types to search + media_types = _get_media_types_from_query(query) + + # Execute search using the Music Assistant API if we haven't already done so + if search_results is None: + search_results = await mass.music.search( + search_query, media_types=media_types, limit=limit + ) + + # Process the search results + result = _process_search_results(mass, search_results, media_types) + return SearchMedia(result=result) + + except Exception as err: + LOGGER.debug( + "Search error details for %s: %s", query.search_query, err, exc_info=True + ) + raise SearchError(f"Error searching for {query.search_query}") from err diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index bbbda095302..5dc8ab2ec00 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -9,6 +9,7 @@ import functools import os from typing import TYPE_CHECKING, Any, Concatenate +from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( EventType, MediaType, @@ -35,6 +36,8 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType as HAMediaType, RepeatMode, + SearchMedia, + SearchMediaQuery, async_process_play_media_url, ) from homeassistant.const import ATTR_NAME, STATE_OFF @@ -73,31 +76,33 @@ from .const import ( DOMAIN, ) from .entity import MusicAssistantEntity -from .media_browser import async_browse_media +from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient from music_assistant_models.player import Player -SUPPORTED_FEATURES = ( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.STOP +SUPPORTED_FEATURES_BASE = ( + MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.REPEAT_SET - | MediaPlayerEntityFeature.TURN_ON - | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.SEARCH_MEDIA | MediaPlayerEntityFeature.MEDIA_ENQUEUE | MediaPlayerEntityFeature.MEDIA_ANNOUNCE | MediaPlayerEntityFeature.SEEK + # we always add pause support, + # regardless if the underlying player actually natively supports pause + # because the MA behavior is to internally handle pause with stop + # (and a resume position) and we'd like to keep the UX consistent + # background info: https://github.com/home-assistant/core/issues/140118 + | MediaPlayerEntityFeature.PAUSE ) QUEUE_OPTION_MAP = { @@ -149,6 +154,11 @@ async def async_setup_entry( assert event.object_id is not None if event.object_id in added_ids: return + player = mass.players.get(event.object_id) + if TYPE_CHECKING: + assert player is not None + if not player.expose_to_ha: + return added_ids.add(event.object_id) async_add_entities([MusicAssistantPlayer(mass, event.object_id)]) @@ -157,6 +167,8 @@ async def async_setup_entry( mass_players = [] # add all current players for player in mass.players: + if not player.expose_to_ha: + continue added_ids.add(player.player_id) mass_players.append(MusicAssistantPlayer(mass, player.player_id)) @@ -212,11 +224,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Initialize MediaPlayer entity.""" super().__init__(mass, player_id) self._attr_icon = self.player.icon.replace("mdi-", "mdi:") - self._attr_supported_features = 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 + self._set_supported_features() self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 @@ -241,6 +249,19 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): ) ) + # we subscribe to the player config changed event to update + # the supported features of the player + async def player_config_changed(event: MassEvent) -> None: + self._set_supported_features() + await self.async_on_update() + self.async_write_ha_state() + + self.async_on_remove( + self.mass.subscribe( + player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id + ) + ) + @property def active_queue(self) -> PlayerQueue | None: """Return the active queue for this player (if any).""" @@ -271,22 +292,26 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState(player.state.value) else: self._attr_state = MediaPlayerState(STATE_OFF) - group_members_entity_ids: list[str] = [] + + group_members: list[str] = [] if player.group_childs: - # translate MA group_childs to HA group_members as entity id's - entity_registry = er.async_get(self.hass) - group_members_entity_ids = [ - entity_id - for child_id in player.group_childs - if ( - entity_id := entity_registry.async_get_entity_id( - self.platform.domain, DOMAIN, child_id - ) + group_members = player.group_childs + elif player.synced_to and (parent := self.mass.players.get(player.synced_to)): + group_members = parent.group_childs + + # translate MA group_childs to HA group_members as entity id's + entity_registry = er.async_get(self.hass) + group_members_entity_ids: list[str] = [ + entity_id + for child_id in group_members + if ( + entity_id := entity_registry.async_get_entity_id( + self.platform.domain, DOMAIN, child_id ) - ] - # NOTE: we sort the group_members for now, - # until the MA API returns them sorted (group_childs is now a set) - self._attr_group_members = sorted(group_members_entity_ids) + ) + ] + + self._attr_group_members = group_members_entity_ids self._attr_volume_level = ( player.volume_level / 100 if player.volume_level is not None else None ) @@ -574,20 +599,34 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): media_content_type, ) + async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia: + """Search media.""" + return await async_search_media( + self.mass, + query, + ) + def _update_media_image_url( self, player: Player, queue: PlayerQueue | None ) -> None: - """Update image URL for the active queue item.""" - if queue is None or queue.current_item is None: - self._attr_media_image_url = None - return - if image_url := self.mass.get_media_item_image_url(queue.current_item): + """Update image URL.""" + if queue and queue.current_item: + # image_url is provided by an music-assistant queue + image_url = self.mass.get_media_item_image_url(queue.current_item) + elif player.current_media and player.current_media.image_url: + # image_url is provided by an external source + image_url = player.current_media.image_url + else: + image_url = None + + # check if the image is provided via music-assistant and therefore + # not accessible from the outside + if image_url: self._attr_media_image_remotely_accessible = ( self.mass.server_url not in image_url ) - self._attr_media_image_url = image_url - return - self._attr_media_image_url = None + + self._attr_media_image_url = image_url def _update_media_attributes( self, player: Player, queue: PlayerQueue | None @@ -682,3 +721,18 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): if isinstance(queue_option, MediaPlayerEnqueue): queue_option = QUEUE_OPTION_MAP.get(queue_option) return queue_option + + def _set_supported_features(self) -> None: + """Set supported features based on player capabilities.""" + supported_features = SUPPORTED_FEATURES_BASE + if PlayerFeature.SET_MEMBERS in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.GROUPING + if self.player.mute_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + if self.player.volume_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_STEP + supported_features |= MediaPlayerEntityFeature.VOLUME_SET + if self.player.power_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.TURN_ON + supported_features |= MediaPlayerEntityFeature.TURN_OFF + self._attr_supported_features = supported_features diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index 0954d1573e7..7501d3d2038 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -15,6 +15,7 @@ from .const import ( ATTR_ALBUM, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_BIT_DEPTH, ATTR_CONTENT_TYPE, ATTR_CURRENT_INDEX, @@ -31,6 +32,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_PROVIDER, ATTR_QUEUE_ID, ATTR_QUEUE_ITEM_ID, @@ -101,6 +103,12 @@ SEARCH_RESULT_SCHEMA = vol.Schema( vol.Required(ATTR_RADIO): vol.All( cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] ), + vol.Required(ATTR_AUDIOBOOKS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_PODCASTS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), }, ) diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index 73e8e2d7521..a3715ea2580 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -21,7 +21,10 @@ play_media: options: - artist - album + - audiobook + - folder - playlist + - podcast - track - radio artist: @@ -118,7 +121,9 @@ search: options: - artist - album + - audiobook - playlist + - podcast - track - radio artist: @@ -160,7 +165,9 @@ get_library: options: - artist - album + - audiobook - playlist + - podcast - track - radio favorite: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 32b72088518..c7e7baf88f6 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -25,9 +25,9 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "Configuration flow is already in progress", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reconfiguration_successful": "Successfully reconfigured the Music Assistant integration.", - "cannot_connect": "Failed to connect", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, @@ -139,8 +139,8 @@ } }, "get_library": { - "name": "Get Library items", - "description": "Get items from a Music Assistant library.", + "name": "Get library items", + "description": "Retrieves items from a Music Assistant library.", "fields": { "config_entry_id": { "name": "[%key:component::music_assistant::services::search::fields::config_entry_id::name%]", @@ -167,7 +167,7 @@ "description": "Offset to start the list from." }, "order_by": { - "name": "Order By", + "name": "Order by", "description": "Sort the list by this field." }, "album_type": { @@ -176,7 +176,7 @@ }, "album_artists_only": { "name": "Enable album artists filter (only for artist library)", - "description": "Only return Album Artists when listing the Artists library items." + "description": "Only return album artists when listing the artists library items." } } } @@ -195,8 +195,11 @@ "options": { "artist": "Artist", "album": "Album", + "audiobook": "Audiobook", + "folder": "Folder", "track": "Track", "playlist": "Playlist", + "podcast": "Podcast", "radio": "Radio" } }, diff --git a/homeassistant/components/mutesync/config_flow.py b/homeassistant/components/mutesync/config_flow.py index ef03df39968..a2aacfc927e 100644 --- a/homeassistant/components/mutesync/config_flow.py +++ b/homeassistant/components/mutesync/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from typing import Any import aiohttp @@ -16,6 +17,8 @@ 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("host"): str}) @@ -60,7 +63,8 @@ class MuteSyncConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry( diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 19dcce78446..e2aca8b9f01 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -17,7 +17,6 @@ from .const import ( DOMAIN, MYSENSORS_DISCOVERED_NODES, MYSENSORS_GATEWAYS, - MYSENSORS_ON_UNLOAD, PLATFORMS, DevId, DiscoveryInfo, @@ -62,13 +61,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not unload_ok: return False - key = MYSENSORS_ON_UNLOAD.format(entry.entry_id) - if key in hass.data[DOMAIN]: - for fnct in hass.data[DOMAIN][key]: - fnct() - - hass.data[DOMAIN].pop(key) - del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] hass.data[DOMAIN].pop(MYSENSORS_DISCOVERED_NODES.format(entry.entry_id), None) diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index d42b2194315..e950f083b5b 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload @dataclass(frozen=True) @@ -86,9 +85,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.BINARY_SENSOR), diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index d1504f3afab..eb54a76b8a8 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -21,7 +21,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload DICT_HA_TO_MYS = { HVACMode.AUTO: "AutoChangeOver", @@ -57,9 +56,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.CLIMATE), @@ -85,7 +82,10 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): and set_req.V_HVAC_SETPOINT_HEAT in self._values ): features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - else: + elif ( + set_req.V_HVAC_SETPOINT_COOL in self._values + or set_req.V_HVAC_SETPOINT_HEAT in self._values + ): features = features | ClimateEntityFeature.TARGET_TEMPERATURE return features @@ -111,13 +111,11 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): @property def target_temperature(self) -> float | None: - """Return the temperature we try to reach.""" + """Return the temperature we try to reach. + + Either V_HVAC_SETPOINT_COOL or V_HVAC_SETPOINT_HEAT may be used. + """ set_req = self.gateway.const.SetReq - if ( - set_req.V_HVAC_SETPOINT_COOL in self._values - and set_req.V_HVAC_SETPOINT_HEAT in self._values - ): - return None temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) if temp is None: temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) @@ -127,21 +125,13 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq - if set_req.V_HVAC_SETPOINT_HEAT in self._values: - temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) - return float(temp) if temp is not None else None - - return None + return float(self._values[set_req.V_HVAC_SETPOINT_COOL]) @property def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq - if set_req.V_HVAC_SETPOINT_COOL in self._values: - temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) - return float(temp) if temp is not None else None - - return None + return float(self._values[set_req.V_HVAC_SETPOINT_HEAT]) @property def hvac_mode(self) -> HVACMode: @@ -185,10 +175,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): self.gateway.set_child_value( self.node_id, self.child_id, value_type, value, ack=1 ) - if self.assumed_state: - # Optimistically assume that device has changed state - self._values[value_type] = value - self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target temperature.""" @@ -196,10 +182,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode, ack=1 ) - if self.assumed_state: - # Optimistically assume that device has changed state - self._values[set_req.V_HVAC_SPEED] = fan_mode - self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target temperature.""" @@ -210,10 +192,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): DICT_HA_TO_MYS[hvac_mode], ack=1, ) - if self.assumed_state: - # Optimistically assume that device has changed state - self._values[self.value_type] = hvac_mode - self.async_write_ha_state() @callback def _async_update(self) -> None: diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index a65b46616d3..a87b78b549e 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -34,7 +34,6 @@ CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}" NODE_CALLBACK: str = "mysensors_node_callback_{}_{}" MYSENSORS_DISCOVERY: str = "mysensors_discovery_{}_{}" MYSENSORS_NODE_DISCOVERY: str = "mysensors_node_discovery" -MYSENSORS_ON_UNLOAD: str = "mysensors_on_unload_{}" TYPE: Final = "type" UPDATE_DELAY: float = 0.1 diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index 14e6ff6dc15..84346a5d10a 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -15,7 +15,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload @unique @@ -45,9 +44,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.COVER), @@ -113,13 +110,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_UP, 1, ack=1 ) - if self.assumed_state: - # Optimistically assume that cover has changed state. - if set_req.V_DIMMER in self._values: - self._values[set_req.V_DIMMER] = 100 - else: - self._values[set_req.V_LIGHT] = STATE_ON - self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" @@ -127,13 +117,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_DOWN, 1, ack=1 ) - if self.assumed_state: - # Optimistically assume that cover has changed state. - if set_req.V_DIMMER in self._values: - self._values[set_req.V_DIMMER] = 0 - else: - self._values[set_req.V_LIGHT] = STATE_OFF - self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -142,10 +125,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_DIMMER, position, ack=1 ) - if self.assumed_state: - # Optimistically assume that cover has changed state. - self._values[set_req.V_DIMMER] = position - self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device.""" diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 56d8b2f5923..e6368b0b81d 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -33,9 +32,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.DEVICE_TRACKER), diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index bdc83f30b21..91453ea3306 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -47,7 +47,6 @@ from .handler import HANDLERS from .helpers import ( discover_mysensors_node, discover_mysensors_platform, - on_unload, validate_child, validate_node, ) @@ -293,9 +292,7 @@ async def _gw_start( """Stop the gateway.""" await gw_stop(hass, entry, gateway) - on_unload( - hass, - entry.entry_id, + entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw), ) diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index c96ad6cea8e..9ed41dfe4e9 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -27,7 +27,6 @@ from .const import ( MYSENSORS_DISCOVERED_NODES, MYSENSORS_DISCOVERY, MYSENSORS_NODE_DISCOVERY, - MYSENSORS_ON_UNLOAD, TYPE_TO_PLATFORMS, DevId, GatewayId, @@ -41,18 +40,6 @@ SCHEMAS: Registry[ ] = Registry() -@callback -def on_unload(hass: HomeAssistant, gateway_id: GatewayId, fnct: Callable) -> None: - """Register a callback to be called when entry is unloaded. - - This function is used by platforms to cleanup after themselves. - """ - key = MYSENSORS_ON_UNLOAD.format(gateway_id) - if key not in hass.data[DOMAIN]: - hass.data[DOMAIN][key] = [] - hass.data[DOMAIN][key].append(fnct) - - @callback def discover_mysensors_platform( hass: HomeAssistant, gateway_id: GatewayId, platform: str, new_devices: list[DevId] diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 9e4054ca3d0..fa5e625c72b 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -12,7 +12,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -21,7 +21,6 @@ from homeassistant.util.color import rgb_hex_to_rgb_list from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -46,9 +45,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.LIGHT), @@ -80,11 +77,6 @@ class MySensorsLight(MySensorsChildEntity, LightEntity): self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._state = True - self._values[set_req.V_LIGHT] = STATE_ON - def _turn_on_dimmer(self, **kwargs: Any) -> None: """Turn on dimmer child device.""" set_req = self.gateway.const.SetReq @@ -101,20 +93,10 @@ class MySensorsLight(MySensorsChildEntity, LightEntity): self.node_id, self.child_id, set_req.V_DIMMER, percent, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._attr_brightness = brightness - self._values[set_req.V_DIMMER] = percent - async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value(self.node_id, self.child_id, value_type, 0, ack=1) - if self.assumed_state: - # optimistically assume that light has changed state - self._state = False - self._values[value_type] = STATE_OFF - self.async_write_ha_state() @callback def _async_update_light(self) -> None: @@ -142,8 +124,6 @@ class MySensorsLightDimmer(MySensorsLight): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - if self.assumed_state: - self.async_write_ha_state() @callback def _async_update(self) -> None: @@ -164,8 +144,6 @@ class MySensorsLightRGB(MySensorsLight): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb(**kwargs) - if self.assumed_state: - self.async_write_ha_state() def _turn_on_rgb(self, **kwargs: Any) -> None: """Turn on RGB child device.""" @@ -179,11 +157,6 @@ class MySensorsLightRGB(MySensorsLight): self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._attr_rgb_color = new_rgb - self._values[self.value_type] = hex_color - @callback def _async_update(self) -> None: """Update the controller with the latest value from a sensor.""" @@ -212,8 +185,6 @@ class MySensorsLightRGBW(MySensorsLightRGB): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgbw(**kwargs) - if self.assumed_state: - self.async_write_ha_state() def _turn_on_rgbw(self, **kwargs: Any) -> None: """Turn on RGBW child device.""" @@ -227,11 +198,6 @@ class MySensorsLightRGBW(MySensorsLightRGB): self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._attr_rgbw_color = new_rgbw - self._values[self.value_type] = hex_color - @callback def _async_update_rgb_or_w(self) -> None: """Update the controller with values from RGBW child.""" diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py index ada801f92ab..ccb67f78eba 100644 --- a/homeassistant/components/mysensors/remote.py +++ b/homeassistant/components/mysensors/remote.py @@ -19,7 +19,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -40,9 +39,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.REMOTE), diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 759cf7b010f..3793bed8af2 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -50,7 +50,6 @@ from .const import ( NodeDiscoveryInfo, ) from .entity import MySensorNodeEntity, MySensorsChildEntity -from .helpers import on_unload SENSORS: dict[str, SensorEntityDescription] = { "V_TEMP": SensorEntityDescription( @@ -103,6 +102,7 @@ SENSORS: dict[str, SensorEntityDescription] = { native_unit_of_measurement=DEGREE, icon="mdi:compass", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), "V_WEIGHT": SensorEntityDescription( key="V_WEIGHT", @@ -233,9 +233,7 @@ async def async_setup_entry( gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][gateway_id] async_add_entities([MyBatterySensor(gateway_id, gateway, node_id)]) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.SENSOR), @@ -243,9 +241,7 @@ async def async_setup_entry( ), ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_NODE_DISCOVERY, diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index 30fe5f46d6b..1636cb076cc 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -21,16 +21,16 @@ "device": "IP address of the gateway", "tcp_port": "[%key:common::config_flow::data::port%]", "version": "MySensors version", - "persistence_file": "persistence file (leave empty to auto-generate)" + "persistence_file": "Persistence file (leave empty to auto-generate)" } }, "gw_serial": { "description": "Serial gateway setup", "data": { "device": "Serial port", - "baud_rate": "baud rate", + "baud_rate": "Baud rate", "version": "[%key:component::mysensors::config::step::gw_tcp::data::version%]", - "persistence_file": "Persistence file (leave empty to auto-generate)" + "persistence_file": "[%key:component::mysensors::config::step::gw_tcp::data::persistence_file%]" } }, "gw_mqtt": { @@ -40,7 +40,7 @@ "topic_in_prefix": "Prefix for input topics (topic_in_prefix)", "topic_out_prefix": "Prefix for output topics (topic_out_prefix)", "version": "[%key:component::mysensors::config::step::gw_tcp::data::version%]", - "persistence_file": "[%key:component::mysensors::config::step::gw_serial::data::persistence_file%]" + "persistence_file": "[%key:component::mysensors::config::step::gw_tcp::data::persistence_file%]" } } }, diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 52207c21f77..9b57102a94c 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -14,7 +14,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -48,9 +47,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.SWITCH), @@ -72,17 +69,9 @@ class MySensorsSwitch(MySensorsChildEntity, SwitchEntity): self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 1, ack=1 ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[self.value_type] = STATE_ON - self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 0, ack=1 ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[self.value_type] = STATE_OFF - self.async_write_ha_state() diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index 8eff7a255e7..9fdd9da5345 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -33,9 +32,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.TEXT), diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 6b4ca6ff324..d297443c059 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from aiohttp.client_exceptions import ClientConnectorError, ClientError +from aiohttp.client_exceptions import ClientError from nettigo_air_monitor import ( ApiError, AuthFailedError, @@ -38,15 +38,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: options = ConnectionOptions(host=host, username=username, password=password) try: nam = await NettigoAirMonitor.create(websession, options) - except (ApiError, ClientError, ClientConnectorError, TimeoutError) as err: - raise ConfigEntryNotReady from err + except (ApiError, ClientError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err try: await nam.async_check_credentials() - except ApiError as err: - raise ConfigEntryNotReady from err + except (ApiError, ClientError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err except AuthFailedError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"device": entry.title}, + ) from err coordinator = NAMDataUpdateCoordinator(hass, entry, nam) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py index 60145e4fe27..791a5fdc27c 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -4,6 +4,9 @@ from __future__ import annotations import logging +from aiohttp.client_exceptions import ClientError +from nettigo_air_monitor import ApiError, AuthFailedError + from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, @@ -11,9 +14,11 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import NAMConfigEntry, NAMDataUpdateCoordinator PARALLEL_UPDATES = 1 @@ -59,4 +64,16 @@ class NAMButton(CoordinatorEntity[NAMDataUpdateCoordinator], ButtonEntity): async def async_press(self) -> None: """Triggers the restart.""" - await self.coordinator.nam.async_restart() + try: + await self.coordinator.nam.async_restart() + except (ApiError, ClientError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.config_entry.title, + }, + ) from err + except AuthFailedError: + self.coordinator.config_entry.async_start_reauth(self.hass) diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py index 4b7b50b309a..2dedcf3c68a 100644 --- a/homeassistant/components/nam/const.py +++ b/homeassistant/components/nam/const.py @@ -11,6 +11,7 @@ SUFFIX_P1: Final = "_p1" SUFFIX_P2: Final = "_p2" SUFFIX_P4: Final = "_p4" +ATTR_BH1750_ILLUMINANCE: Final = "bh1750_illuminance" ATTR_BME280_HUMIDITY: Final = "bme280_humidity" ATTR_BME280_PRESSURE: Final = "bme280_pressure" ATTR_BME280_TEMPERATURE: Final = "bme280_temperature" diff --git a/homeassistant/components/nam/coordinator.py b/homeassistant/components/nam/coordinator.py index 3e2c9c24474..8a898dee378 100644 --- a/homeassistant/components/nam/coordinator.py +++ b/homeassistant/components/nam/coordinator.py @@ -64,6 +64,10 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): # We do not need to catch AuthFailed exception here because sensor data is # always available without authorization. except (ApiError, InvalidSensorDataError, RetryError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.config_entry.title}, + ) from error return data diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index c3a559de50b..1c3b9db7a86 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==4.0.0"], + "requirements": ["nettigo-air-monitor==4.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 4478507dc59..45cfd313e8f 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, @@ -33,6 +34,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from .const import ( + ATTR_BH1750_ILLUMINANCE, ATTR_BME280_HUMIDITY, ATTR_BME280_PRESSURE, ATTR_BME280_TEMPERATURE, @@ -83,6 +85,15 @@ class NAMSensorEntityDescription(SensorEntityDescription): SENSORS: tuple[NAMSensorEntityDescription, ...] = ( + NAMSensorEntityDescription( + key=ATTR_BH1750_ILLUMINANCE, + translation_key="bh1750_illuminance", + suggested_display_precision=0, + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda sensors: sensors.bh1750_illuminance, + ), NAMSensorEntityDescription( key=ATTR_BME280_HUMIDITY, translation_key="bme280_humidity", diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 2caa4d8bd97..b02eecaa41e 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -54,6 +54,9 @@ }, "entity": { "sensor": { + "bh1750_illuminance": { + "name": "BH1750 illuminance" + }, "bme280_humidity": { "name": "BME280 humidity" }, @@ -93,11 +96,22 @@ "pmsx003_caqi_level": { "name": "PMSx003 common air quality index level", "state": { - "very_low": "Very low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very high" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" + }, + "state_attributes": { + "options": { + "state": { + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" + } + } } }, "pmsx003_pm1": { @@ -115,11 +129,22 @@ "sds011_caqi_level": { "name": "SDS011 common air quality index level", "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" + }, + "state_attributes": { + "options": { + "state": { + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" + } + } } }, "sds011_pm10": { @@ -140,11 +165,22 @@ "sps30_caqi_level": { "name": "SPS30 common air quality index level", "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" + }, + "state_attributes": { + "options": { + "state": { + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" + } + } } }, "sps30_pm1": { @@ -169,5 +205,19 @@ "name": "Last restart" } } + }, + "exceptions": { + "auth_error": { + "message": "Authentication failed for {device}, please update your credentials" + }, + "device_communication_error": { + "message": "An error occurred while communicating with {device}" + }, + "device_communication_action_error": { + "message": "An error occurred while calling action for {entity} for {device}" + }, + "update_error": { + "message": "An error occurred while retrieving data from {device}" + } } } diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 6d42110d53e..214b63d6668 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -125,8 +125,10 @@ class NanoleafLight(NanoleafEntity, LightEntity): await self._nanoleaf.turn_on() if brightness: await self._nanoleaf.set_brightness(int(brightness / 2.55)) + await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" transition: float | None = kwargs.get(ATTR_TRANSITION) await self._nanoleaf.turn_off(None if transition is None else int(transition)) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/nasweb/config_flow.py b/homeassistant/components/nasweb/config_flow.py index 3a9ad3f7d49..298210903dc 100644 --- a/homeassistant/components/nasweb/config_flow.py +++ b/homeassistant/components/nasweb/config_flow.py @@ -103,7 +103,7 @@ class NASwebConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "missing_status" except AbortFlow: raise - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/national_grid_us/__init__.py b/homeassistant/components/national_grid_us/__init__.py new file mode 100644 index 00000000000..7db5e6e8160 --- /dev/null +++ b/homeassistant/components/national_grid_us/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: National Grid US.""" diff --git a/homeassistant/components/national_grid_us/manifest.json b/homeassistant/components/national_grid_us/manifest.json new file mode 100644 index 00000000000..88041ba2964 --- /dev/null +++ b/homeassistant/components/national_grid_us/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "national_grid_us", + "name": "National Grid US", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index ff3eea9252c..1e7fc54f4f7 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -20,7 +20,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -119,6 +119,8 @@ class NSDepartureSensor(SensorEntity): self._time = time self._state = None self._trips = None + self._first_trip = None + self._next_trip = None @property def name(self): @@ -133,44 +135,44 @@ class NSDepartureSensor(SensorEntity): @property def extra_state_attributes(self): """Return the state attributes.""" - if not self._trips: + if not self._trips or self._first_trip is None: return None - if self._trips[0].trip_parts: - route = [self._trips[0].departure] - route.extend(k.destination for k in self._trips[0].trip_parts) + if self._first_trip.trip_parts: + route = [self._first_trip.departure] + route.extend(k.destination for k in self._first_trip.trip_parts) # Static attributes attributes = { - "going": self._trips[0].going, + "going": self._first_trip.going, "departure_time_planned": None, "departure_time_actual": None, "departure_delay": False, - "departure_platform_planned": self._trips[0].departure_platform_planned, - "departure_platform_actual": self._trips[0].departure_platform_actual, + "departure_platform_planned": self._first_trip.departure_platform_planned, + "departure_platform_actual": self._first_trip.departure_platform_actual, "arrival_time_planned": None, "arrival_time_actual": None, "arrival_delay": False, - "arrival_platform_planned": self._trips[0].arrival_platform_planned, - "arrival_platform_actual": self._trips[0].arrival_platform_actual, + "arrival_platform_planned": self._first_trip.arrival_platform_planned, + "arrival_platform_actual": self._first_trip.arrival_platform_actual, "next": None, - "status": self._trips[0].status.lower(), - "transfers": self._trips[0].nr_transfers, + "status": self._first_trip.status.lower(), + "transfers": self._first_trip.nr_transfers, "route": route, "remarks": None, } # Planned departure attributes - if self._trips[0].departure_time_planned is not None: - attributes["departure_time_planned"] = self._trips[ - 0 - ].departure_time_planned.strftime("%H:%M") + if self._first_trip.departure_time_planned is not None: + attributes["departure_time_planned"] = ( + self._first_trip.departure_time_planned.strftime("%H:%M") + ) # Actual departure attributes - if self._trips[0].departure_time_actual is not None: - attributes["departure_time_actual"] = self._trips[ - 0 - ].departure_time_actual.strftime("%H:%M") + if self._first_trip.departure_time_actual is not None: + attributes["departure_time_actual"] = ( + self._first_trip.departure_time_actual.strftime("%H:%M") + ) # Delay departure attributes if ( @@ -182,16 +184,16 @@ class NSDepartureSensor(SensorEntity): attributes["departure_delay"] = True # Planned arrival attributes - if self._trips[0].arrival_time_planned is not None: - attributes["arrival_time_planned"] = self._trips[ - 0 - ].arrival_time_planned.strftime("%H:%M") + if self._first_trip.arrival_time_planned is not None: + attributes["arrival_time_planned"] = ( + self._first_trip.arrival_time_planned.strftime("%H:%M") + ) # Actual arrival attributes - if self._trips[0].arrival_time_actual is not None: - attributes["arrival_time_actual"] = self._trips[ - 0 - ].arrival_time_actual.strftime("%H:%M") + if self._first_trip.arrival_time_actual is not None: + attributes["arrival_time_actual"] = ( + self._first_trip.arrival_time_actual.strftime("%H:%M") + ) # Delay arrival attributes if ( @@ -202,15 +204,14 @@ class NSDepartureSensor(SensorEntity): attributes["arrival_delay"] = True # Next attributes - if len(self._trips) > 1: - if self._trips[1].departure_time_actual is not None: - attributes["next"] = self._trips[1].departure_time_actual.strftime( - "%H:%M" - ) - elif self._trips[1].departure_time_planned is not None: - attributes["next"] = self._trips[1].departure_time_planned.strftime( - "%H:%M" - ) + if self._next_trip.departure_time_actual is not None: + attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M") + elif self._next_trip.departure_time_planned is not None: + attributes["next"] = self._next_trip.departure_time_planned.strftime( + "%H:%M" + ) + else: + attributes["next"] = None return attributes @@ -225,6 +226,7 @@ class NSDepartureSensor(SensorEntity): ): self._state = None self._trips = None + self._first_trip = None return # Set the search parameter to search from a specific trip time @@ -236,19 +238,51 @@ class NSDepartureSensor(SensorEntity): .strftime("%d-%m-%Y %H:%M") ) else: - trip_time = datetime.now().strftime("%d-%m-%Y %H:%M") + trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M") try: self._trips = self._nsapi.get_trips( trip_time, self._departure, self._via, self._heading, True, 0, 2 ) if self._trips: - if self._trips[0].departure_time_actual is None: - planned_time = self._trips[0].departure_time_planned - self._state = planned_time.strftime("%H:%M") + all_times = [] + + # If a train is delayed we can observe this through departure_time_actual. + for trip in self._trips: + if trip.departure_time_actual is None: + all_times.append(trip.departure_time_planned) + else: + all_times.append(trip.departure_time_actual) + + # Remove all trains that already left. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > dt_util.now() + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._first_trip = self._trips[sorted_times[0][0]] + self._state = sorted_times[0][1].strftime("%H:%M") + + # Filter again to remove trains that leave at the exact same time. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > sorted_times[0][1] + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._next_trip = self._trips[sorted_times[0][0]] + else: + self._next_trip = None + else: - actual_time = self._trips[0].departure_time_actual - self._state = actual_time.strftime("%H:%M") + self._first_trip = None + self._state = None + except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, diff --git a/homeassistant/components/neff/__init__.py b/homeassistant/components/neff/__init__.py new file mode 100644 index 00000000000..211ce088834 --- /dev/null +++ b/homeassistant/components/neff/__init__.py @@ -0,0 +1 @@ +"""Neff virtual integration.""" diff --git a/homeassistant/components/neff/manifest.json b/homeassistant/components/neff/manifest.json new file mode 100644 index 00000000000..1dfc91f94c9 --- /dev/null +++ b/homeassistant/components/neff/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "neff", + "name": "Neff", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/ness_alarm/services.yaml b/homeassistant/components/ness_alarm/services.yaml index b02d5e36805..aed1e1836bd 100644 --- a/homeassistant/components/ness_alarm/services.yaml +++ b/homeassistant/components/ness_alarm/services.yaml @@ -7,7 +7,7 @@ aux: selector: number: min: 1 - max: 4 + max: 8 state: default: true selector: diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index a0d8bc06640..d9383533300 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==7.1.3"] + "requirements": ["google-nest-sdm==7.1.4"] } diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index 146b6f2479e..a3d2901e911 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -20,8 +20,10 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass +import datetime import logging import os +import pathlib from typing import Any from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait @@ -46,6 +48,7 @@ from homeassistant.components.media_source import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.storage import Store from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.util import dt as dt_util @@ -72,6 +75,9 @@ MEDIA_PATH = f"{DOMAIN}/event_media" # Size of small in-memory disk cache to avoid excessive disk reads DISK_READ_LRU_MAX_SIZE = 32 +# Remove orphaned media files that are older than this age +ORPHANED_MEDIA_AGE_CUTOFF = datetime.timedelta(days=7) + async def async_get_media_event_store( hass: HomeAssistant, subscriber: GoogleNestSubscriber @@ -123,6 +129,12 @@ class NestEventMediaStore(EventMediaStore): self._media_path = media_path self._data: dict[str, Any] | None = None self._devices: Mapping[str, str] | None = {} + # Invoke garbage collection for orphaned files one per + async_track_time_interval( + hass, + self.async_remove_orphaned_media, + datetime.timedelta(days=1), + ) async def async_load(self) -> dict | None: """Load data.""" @@ -249,6 +261,68 @@ class NestEventMediaStore(EventMediaStore): devices[device.name] = device_entry.id return devices + async def async_remove_orphaned_media(self, now: datetime.datetime) -> None: + """Remove any media files that are orphaned and not referenced by the active event data. + + The event media store handles garbage collection, but there may be cases where files are + left around or unable to be removed. This is a scheduled event that will also check for + old orphaned files and remove them when the events are not referenced in the active list + of event data. + + Event media files are stored with the format -.suffix. We extract + the list of valid timestamps from the event data and remove any files that are not in that list + or are older than the cutoff time. + """ + _LOGGER.debug("Checking for orphaned media at %s", now) + + def _cleanup(event_timestamps: dict[str, set[int]]) -> None: + time_cutoff = (now - ORPHANED_MEDIA_AGE_CUTOFF).timestamp() + media_path = pathlib.Path(self._media_path) + for device_id, valid_timestamps in event_timestamps.items(): + media_files = list(media_path.glob(f"{device_id}/*")) + _LOGGER.debug("Found %d files (device=%s)", len(media_files), device_id) + for media_file in media_files: + if "-" not in media_file.name: + continue + try: + timestamp = int(media_file.name.split("-")[0]) + except ValueError: + continue + if timestamp in valid_timestamps or timestamp > time_cutoff: + continue + _LOGGER.debug("Removing orphaned media file: %s", media_file) + try: + os.remove(media_file) + except OSError as err: + _LOGGER.error( + "Unable to remove orphaned media file: %s %s", + media_file, + err, + ) + + # Nest device id mapped to home assistant device id + event_timestamps = await self._get_valid_event_timestamps() + await self._hass.async_add_executor_job(_cleanup, event_timestamps) + + async def _get_valid_event_timestamps(self) -> dict[str, set[int]]: + """Return a mapping of home assistant device id to valid timestamps.""" + device_map = await self._get_devices() + event_data = await self.async_load() or {} + valid_device_timestamps = {} + for nest_device_id, device_id in device_map.items(): + if (device_events := event_data.get(nest_device_id, {})) is None: + continue + valid_device_timestamps[device_id] = { + int( + datetime.datetime.fromisoformat( + camera_event["timestamp"] + ).timestamp() + ) + for events in device_events + for camera_event in events["events"].values() + } + return valid_device_timestamps + async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up Nest media source.""" diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 23da524ab7e..4a8689ff04c 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -6,7 +6,7 @@ "step": { "create_cloud_project": { "title": "Nest: Create and configure Cloud Project", - "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, select **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then select **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and select **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and select **Enable**.\n\nProceed when your cloud project is set up." + "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one-time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, select **Create Project** then **New Project**.\n1. Give your Cloud Project a name and then select **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and select **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and select **Enable**.\n\nProceed when your Cloud Project is set up." }, "cloud_project": { "title": "Nest: Enter Cloud Project ID", @@ -29,7 +29,7 @@ "title": "Configure Cloud Pub/Sub topic", "description": "Nest devices publish updates on a Cloud Pub/Sub topic. You can select an existing topic if one exists, or choose to create a new topic and the next step will create it for you with the necessary permissions. See the integration documentation for [more info]({more_info_url}).", "data": { - "topic_name": "Pub/Sub topic Name" + "topic_name": "Pub/Sub topic name" } }, "pubsub_topic_confirm": { @@ -41,7 +41,7 @@ "title": "Configure Cloud Pub/Sub subscription", "description": "Home Assistant receives realtime Nest device updates with a Cloud Pub/Sub subscription for topic `{topic}`.\n\nSelect an existing subscription below if one already exists, or the next step will create a new one for you. See the integration documentation for [more info]({more_info_url}).", "data": { - "subscription_name": "Pub/Sub subscription Name" + "subscription_name": "Pub/Sub subscription name" } }, "reauth_confirm": { @@ -58,6 +58,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "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%]", diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 2e3d8c6bcb8..f8f89ffd06b 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -248,19 +248,22 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): if self.home.entity_id != data["home_id"]: return - if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data: - self._selected_schedule = getattr( - self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id].get( - data["schedule_id"] - ), - "name", - None, - ) - self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = ( - self._selected_schedule - ) - self.async_write_ha_state() - self.data_handler.async_force_update(self._signal_name) + if data["event_type"] == EVENT_TYPE_SCHEDULE: + # handle schedule change + if "schedule_id" in data: + self._selected_schedule = getattr( + self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id].get( + data["schedule_id"] + ), + "name", + None, + ) + self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = ( + self._selected_schedule + ) + self.async_write_ha_state() + self.data_handler.async_force_update(self._signal_name) + # ignore other schedule events return home = data["home"] diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index d853694ffea..02d9c2fa3a6 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -135,7 +135,7 @@ class NetatmoOptionsFlowHandler(OptionsFlow): vol.Optional( CONF_WEATHER_AREAS, default=weather_areas, - ): cv.multi_select({wa: None for wa in weather_areas}), + ): cv.multi_select(dict.fromkeys(weather_areas)), vol.Optional(CONF_NEW_AREA): str, } ) diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 283ccc3740e..0164d673619 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -236,7 +236,7 @@ class NetatmoDataHandler: **self.publisher[signal_name].kwargs ) - except (pyatmo.NoDevice, pyatmo.ApiError) as err: + except (pyatmo.NoDeviceError, pyatmo.ApiError) as err: _LOGGER.debug(err) has_error = True diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 0a32777b527..13beb1330e4 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.1.0"] + "requirements": ["pyatmo==9.2.0"] } diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 5f8084d542c..56b8233912f 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -213,7 +213,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="wind_angle", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), NetatmoSensorEntityDescription( key="windstrength", @@ -235,7 +236,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="gust_angle", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), NetatmoSensorEntityDescription( key="guststrength", @@ -345,7 +347,8 @@ PUBLIC_WEATHER_STATION_TYPES: tuple[ key="windangle_value", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, value_fn=lambda area: area.get_latest_wind_angles(), ), NetatmoPublicWeatherSensorEntityDescription( @@ -360,7 +363,8 @@ PUBLIC_WEATHER_STATION_TYPES: tuple[ translation_key="gust_angle", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, value_fn=lambda area: area.get_latest_gust_angles(), ), NetatmoPublicWeatherSensorEntityDescription( diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 23b800e460d..580b49ea646 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -29,10 +29,10 @@ "public_weather": { "data": { "area_name": "Name of the area", - "lat_ne": "North-East corner latitude", - "lon_ne": "North-East corner longitude", - "lat_sw": "South-West corner latitude", - "lon_sw": "South-West corner longitude", + "lat_ne": "Northeast corner latitude", + "lon_ne": "Northeast corner longitude", + "lat_sw": "Southwest corner latitude", + "lon_sw": "Southwest corner longitude", "mode": "Calculation", "show_on_map": "Show on map" }, @@ -175,7 +175,7 @@ "state": { "frost_guard": "Frost guard", "schedule": "Schedule", - "manual": "Manual" + "manual": "[%key:common::state::manual%]" } } } @@ -206,13 +206,13 @@ "name": "Wind direction", "state": { "n": "North", - "ne": "North-east", + "ne": "Northeast", "e": "East", - "se": "South-east", + "se": "Southeast", "s": "South", - "sw": "South-west", + "sw": "Southwest", "w": "West", - "nw": "North-west" + "nw": "Northwest" } }, "wind_angle": { @@ -241,10 +241,10 @@ "name": "Reachability" }, "rf_strength": { - "name": "Radio" + "name": "RF strength" }, "wifi_strength": { - "name": "Wi-Fi" + "name": "Wi-Fi strength" }, "health_idx": { "name": "Health index", diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index d81f556193b..23ee47e7a2d 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -150,7 +150,11 @@ class NetgearRouter: if device_entry.via_device_id is None: continue # do not add the router itself - device_mac = dict(device_entry.connections)[dr.CONNECTION_NETWORK_MAC] + device_mac = dict(device_entry.connections).get( + dr.CONNECTION_NETWORK_MAC + ) + if device_mac is None: + continue self.devices[device_mac] = { "mac": device_mac, "name": device_entry.name, diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 200cce86997..14c7dc55cf0 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -4,12 +4,14 @@ from __future__ import annotations from ipaddress import IPv4Address, IPv6Address, ip_interface import logging +from pathlib import Path from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.loader import bind_hass +from homeassistant.util import package from . import util from .const import ( @@ -27,6 +29,19 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +def _check_docker_without_host_networking() -> bool: + """Check if we are not using host networking in Docker.""" + if not package.is_docker_env(): + # We are not in Docker, so we don't need to check for host networking + return True + + if Path("/proc/sys/net/ipv4/ip_forward").exists(): + # If we can read this file, we likely have host networking + return True + + return False + + @bind_hass async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: """Get the network adapter configuration.""" @@ -166,5 +181,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_get_network(hass) + if not await hass.async_add_executor_job(_check_docker_without_host_networking): + docs_url = "https://docs.docker.com/network/network-tutorial-host/" + install_url = "https://www.home-assistant.io/installation/linux#install-home-assistant-container" + ir.async_create_issue( + hass, + DOMAIN, + "docker_host_network", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="docker_host_network", + learn_more_url=install_url, + translation_placeholders={"docs_url": docs_url, "install_url": install_url}, + ) + async_register_websocket_commands(hass) return True diff --git a/homeassistant/components/network/strings.json b/homeassistant/components/network/strings.json index 6aca7343221..3e135fff60b 100644 --- a/homeassistant/components/network/strings.json +++ b/homeassistant/components/network/strings.json @@ -6,5 +6,11 @@ "ipv6_addresses": "IPv6 addresses", "announce_addresses": "Announce addresses" } + }, + "issues": { + "docker_host_network": { + "title": "Home Assistant is not using host networking", + "description": "Home Assistant is running in a container without host networking mode. This can cause networking issues with device discovery, multicast, broadcast, other network features, and incorrectly detecting its own URL and IP addresses, causing issues with media players and sending audio responses to voice assistants.\n\nIt is recommended to run Home Assistant with host networking by adding the `--network host` flag to your Docker run command or setting `network_mode: host` in your `docker-compose.yml` file.\n\nSee the [Docker documentation]({docs_url}) for more information about Docker host networking and refer to the [Home Assistant installation guide]({install_url}) for our recommended and supported setup." + } } } diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index e9de81cca7c..52ff87e11c7 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -34,7 +34,6 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import VolDictType from .const import ( @@ -42,7 +41,6 @@ from .const import ( ATTR_DEHUMIDIFY_SETPOINT, ATTR_HUMIDIFY_SETPOINT, ATTR_RUN_MODE, - DOMAIN, ) from .coordinator import NexiaDataUpdateCoordinator from .entity import NexiaThermostatZoneEntity @@ -53,13 +51,18 @@ PARALLEL_UPDATES = 1 # keep data in sync with only one connection at a time SERVICE_SET_AIRCLEANER_MODE = "set_aircleaner_mode" SERVICE_SET_HUMIDIFY_SETPOINT = "set_humidify_setpoint" +SERVICE_SET_DEHUMIDIFY_SETPOINT = "set_dehumidify_setpoint" SERVICE_SET_HVAC_RUN_MODE = "set_hvac_run_mode" SET_AIRCLEANER_SCHEMA: VolDictType = { vol.Required(ATTR_AIRCLEANER_MODE): cv.string, } -SET_HUMIDITY_SCHEMA: VolDictType = { +SET_HUMIDIFY_SCHEMA: VolDictType = { + vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=10, max=45)), +} + +SET_DEHUMIDIFY_SCHEMA: VolDictType = { vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=35, max=65)), } @@ -126,9 +129,14 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_SET_HUMIDIFY_SETPOINT, - SET_HUMIDITY_SCHEMA, + SET_HUMIDIFY_SCHEMA, f"async_{SERVICE_SET_HUMIDIFY_SETPOINT}", ) + platform.async_register_entity_service( + SERVICE_SET_DEHUMIDIFY_SETPOINT, + SET_DEHUMIDIFY_SCHEMA, + f"async_{SERVICE_SET_DEHUMIDIFY_SETPOINT}", + ) platform.async_register_entity_service( SERVICE_SET_AIRCLEANER_MODE, SET_AIRCLEANER_SCHEMA, @@ -173,8 +181,6 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): self._attr_supported_features = NEXIA_SUPPORTED if self._has_humidify_support or self._has_dehumidify_support: self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY - if self._has_emergency_heat: - self._attr_supported_features |= ClimateEntityFeature.AUX_HEAT self._attr_preset_modes = zone.get_presets() self._attr_fan_modes = thermostat.get_fan_modes() self._attr_hvac_modes = HVAC_MODES @@ -224,20 +230,48 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): return self._zone.get_preset() async def async_set_humidity(self, humidity: int) -> None: - """Dehumidify target.""" - if self._thermostat.has_dehumidify_support(): - await self.async_set_dehumidify_setpoint(humidity) + """Set humidity targets. + + HA doesn't support separate humidify and dehumidify targets. + Set the target for the current mode if in [heat, cool] + otherwise set both targets to the clamped values. + """ + zone_current_mode = self._zone.get_current_mode() + if zone_current_mode == OPERATION_MODE_HEAT: + if self._thermostat.has_humidify_support(): + await self.async_set_humidify_setpoint(humidity) + elif zone_current_mode == OPERATION_MODE_COOL: + if self._thermostat.has_dehumidify_support(): + await self.async_set_dehumidify_setpoint(humidity) else: - await self.async_set_humidify_setpoint(humidity) + if self._thermostat.has_humidify_support(): + await self.async_set_humidify_setpoint(humidity) + if self._thermostat.has_dehumidify_support(): + await self.async_set_dehumidify_setpoint(humidity) self._signal_thermostat_update() @property - def target_humidity(self): - """Humidity indoors setpoint.""" + def target_humidity(self) -> float | None: + """Humidity indoors setpoint. + + In systems that support both humidification and dehumidification, + two values for target exist. We must choose one to return. + + :return: The target humidity setpoint. + """ + + # If heat is on, always return humidify value first + if ( + self._has_humidify_support + and self._zone.get_current_mode() == OPERATION_MODE_HEAT + ): + return percent_conv(self._thermostat.get_humidify_setpoint()) + # Fall back to previous behavior of returning dehumidify value then humidify if self._has_dehumidify_support: return percent_conv(self._thermostat.get_dehumidify_setpoint()) if self._has_humidify_support: return percent_conv(self._thermostat.get_humidify_setpoint()) + return None @property @@ -349,11 +383,6 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): ) self._signal_zone_update() - @property - def is_aux_heat(self) -> bool: - """Emergency heat state.""" - return self._thermostat.is_emergency_heat_active() - @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" @@ -376,36 +405,6 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): await self._zone.set_preset(preset_mode) self._signal_zone_update() - async def async_turn_aux_heat_off(self) -> None: - """Turn Aux Heat off.""" - async_create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2025.4.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - await self._thermostat.set_emergency_heat(False) - self._signal_thermostat_update() - - async def async_turn_aux_heat_on(self) -> None: - """Turn Aux Heat on.""" - async_create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2025.4.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - await self._thermostat.set_emergency_heat(True) - self._signal_thermostat_update() - async def async_turn_off(self) -> None: """Turn off the zone.""" await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/homeassistant/components/nexia/icons.json b/homeassistant/components/nexia/icons.json index a2157f5c035..c9434a332df 100644 --- a/homeassistant/components/nexia/icons.json +++ b/homeassistant/components/nexia/icons.json @@ -26,6 +26,9 @@ "set_humidify_setpoint": { "service": "mdi:water-percent" }, + "set_dehumidify_setpoint": { + "service": "mdi:water-percent" + }, "set_hvac_run_mode": { "service": "mdi:hvac" } diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 6a439f869c9..939b0b62284 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.0.9"] + "requirements": ["nexia==2.10.0"] } diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index 293a9308cb4..648b5dc3eeb 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -114,6 +114,35 @@ async def async_setup_entry( percent_conv, ) ) + # Heating Humidification Setpoint + if thermostat.has_humidify_support(): + entities.append( + NexiaThermostatSensor( + coordinator, + thermostat, + "get_humidify_setpoint", + "get_humidify_setpoint", + SensorDeviceClass.HUMIDITY, + PERCENTAGE, + SensorStateClass.MEASUREMENT, + percent_conv, + ) + ) + + # Cooling Dehumidification Setpoint + if thermostat.has_dehumidify_support(): + entities.append( + NexiaThermostatSensor( + coordinator, + thermostat, + "get_dehumidify_setpoint", + "get_dehumidify_setpoint", + SensorDeviceClass.HUMIDITY, + PERCENTAGE, + SensorStateClass.MEASUREMENT, + percent_conv, + ) + ) # Zone Sensors for zone_id in thermostat.get_zone_ids(): diff --git a/homeassistant/components/nexia/services.yaml b/homeassistant/components/nexia/services.yaml index ede1f311acf..d010676d14a 100644 --- a/homeassistant/components/nexia/services.yaml +++ b/homeassistant/components/nexia/services.yaml @@ -14,6 +14,20 @@ set_aircleaner_mode: - "quick" set_humidify_setpoint: + target: + entity: + integration: nexia + domain: climate + fields: + humidity: + required: true + selector: + number: + min: 10 + max: 45 + unit_of_measurement: "%" + +set_dehumidify_setpoint: target: entity: integration: nexia diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index d88ce0b898d..d8ec2112fe4 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -53,11 +53,23 @@ }, "zone_setpoint_status": { "name": "Zone setpoint status" + }, + "get_humidify_setpoint": { + "name": "Heating humidify setpoint" + }, + "get_dehumidify_setpoint": { + "name": "Cooling dehumidify setpoint" } }, "switch": { "hold": { "name": "Hold" + }, + "room_iq_sensor": { + "name": "Include {sensor_name}" + }, + "emergency_heat": { + "name": "Emergency heat" } } }, @@ -73,18 +85,28 @@ } }, "set_humidify_setpoint": { - "name": "Set humidify set point", - "description": "Sets the target humidity.", + "name": "Set humidify setpoint", + "description": "Sets the target humidity for heating.", "fields": { "humidity": { "name": "Humidity", - "description": "The humidification setpoint." + "description": "The setpoint for humidification when heating." + } + } + }, + "set_dehumidify_setpoint": { + "name": "Set dehumidify setpoint", + "description": "Sets the target humidity for cooling.", + "fields": { + "humidity": { + "name": "Humidity", + "description": "The setpoint for dehumidification when cooling." } } }, "set_hvac_run_mode": { - "name": "Set hvac run mode", - "description": "Sets the HVAC operation mode.", + "name": "Set HVAC run mode", + "description": "Sets the run and/or operation mode of the HVAC system.", "fields": { "run_mode": { "name": "Run mode", @@ -96,18 +118,5 @@ } } } - }, - "issues": { - "migrate_aux_heat": { - "title": "Migration of Nexia set_aux_heat action", - "fix_flow": { - "step": { - "confirm": { - "description": "The Nexia `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new Emergency heat switch entity. When this is done, select **Submit** to fix this issue.", - "title": "[%key:component::nexia::issues::migrate_aux_heat::title%]" - } - } - } - } } } diff --git a/homeassistant/components/nexia/switch.py b/homeassistant/components/nexia/switch.py index 1897ad67414..bf1495217a7 100644 --- a/homeassistant/components/nexia/switch.py +++ b/homeassistant/components/nexia/switch.py @@ -2,14 +2,19 @@ from __future__ import annotations +from collections.abc import Iterable +import functools as ft from typing import Any from nexia.const import OPERATION_MODE_OFF +from nexia.roomiq import NexiaRoomIQHarmonizer +from nexia.sensor import NexiaSensor from nexia.thermostat import NexiaThermostat from nexia.zone import NexiaThermostatZone from homeassistant.components.switch import SwitchEntity -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NexiaDataUpdateCoordinator @@ -17,6 +22,14 @@ from .entity import NexiaThermostatEntity, NexiaThermostatZoneEntity from .types import NexiaConfigEntry +async def _stop_harmonizers( + _: Event, harmonizers: Iterable[NexiaRoomIQHarmonizer] +) -> None: + """Run the shutdown methods when preparing to stop.""" + for harmonizer in harmonizers: + await harmonizer.async_shutdown() # Never suspends + + async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, @@ -25,7 +38,8 @@ async def async_setup_entry( """Set up switches for a Nexia device.""" coordinator = config_entry.runtime_data nexia_home = coordinator.nexia_home - entities: list[NexiaHoldSwitch | NexiaEmergencyHeatSwitch] = [] + entities: list[SwitchEntity] = [] + room_iq_zones: dict[int, NexiaRoomIQHarmonizer] = {} for thermostat_id in nexia_home.get_thermostat_ids(): thermostat: NexiaThermostat = nexia_home.get_thermostat_by_id(thermostat_id) if thermostat.has_emergency_heat(): @@ -33,8 +47,18 @@ async def async_setup_entry( for zone_id in thermostat.get_zone_ids(): zone: NexiaThermostatZone = thermostat.get_zone_by_id(zone_id) entities.append(NexiaHoldSwitch(coordinator, zone)) + if len(zone_sensors := zone.get_sensors()) > 1: + entities.extend( + NexiaRoomIQSwitch(coordinator, zone, sensor, room_iq_zones) + for sensor in zone_sensors + ) async_add_entities(entities) + if room_iq_zones: + listener = ft.partial(_stop_harmonizers, harmonizers=room_iq_zones.values()) + config_entry.async_on_unload( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, listener) + ) class NexiaHoldSwitch(NexiaThermostatZoneEntity, SwitchEntity): @@ -68,6 +92,49 @@ class NexiaHoldSwitch(NexiaThermostatZoneEntity, SwitchEntity): self._signal_zone_update() +class NexiaRoomIQSwitch(NexiaThermostatZoneEntity, SwitchEntity): + """Provides Nexia RoomIQ sensor switch support.""" + + _attr_translation_key = "room_iq_sensor" + + def __init__( + self, + coordinator: NexiaDataUpdateCoordinator, + zone: NexiaThermostatZone, + sensor: NexiaSensor, + room_iq_zones: dict[int, NexiaRoomIQHarmonizer], + ) -> None: + """Initialize the RoomIQ sensor switch.""" + super().__init__(coordinator, zone, f"{sensor.id}_room_iq_sensor") + self._attr_translation_placeholders = {"sensor_name": sensor.name} + self._sensor_id = sensor.id + if zone.zone_id in room_iq_zones: + self._harmonizer = room_iq_zones[zone.zone_id] + else: + self._harmonizer = NexiaRoomIQHarmonizer( + zone, coordinator.async_refresh, self._signal_zone_update + ) + room_iq_zones[zone.zone_id] = self._harmonizer + + @property + def is_on(self) -> bool: + """Return if the sensor is part of the zone average temperature.""" + if self._harmonizer.request_pending(): + return self._sensor_id in self._harmonizer.selected_sensor_ids + + return self._zone.get_sensor_by_id(self._sensor_id).weight > 0.0 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Include this sensor.""" + self._harmonizer.trigger_add_sensor(self._sensor_id) + self._signal_zone_update() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Remove this sensor.""" + self._harmonizer.trigger_remove_sensor(self._sensor_id) + self._signal_zone_update() + + class NexiaEmergencyHeatSwitch(NexiaThermostatEntity, SwitchEntity): """Provides Nexia emergency heat switch support.""" diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 6300dc1cdc9..a4f6d54f58c 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py-nextbusnext==2.0.5"] + "requirements": ["py-nextbusnext==2.1.2"] } diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index 9b22a6924bc..75950e94211 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -88,7 +88,7 @@ "name": "Cache start time" }, "nextcloud_cache_ttl": { - "name": "Cache ttl" + "name": "Cache TTL" }, "nextcloud_database_size": { "name": "Database size" @@ -259,7 +259,7 @@ "name": "Task updates" }, "nextcloud_system_apps_app_updates_twofactor_totp": { - "name": "Two factor authentication updates" + "name": "Two-factor authentication updates" }, "nextcloud_system_apps_num_installed": { "name": "Apps installed" @@ -268,13 +268,13 @@ "name": "Updates available" }, "nextcloud_system_cpuload_1": { - "name": "CPU Load last 1 minute" + "name": "CPU load last 1 minute" }, "nextcloud_system_cpuload_15": { - "name": "CPU Load last 15 minutes" + "name": "CPU load last 15 minutes" }, "nextcloud_system_cpuload_5": { - "name": "CPU Load last 5 minutes" + "name": "CPU load last 5 minutes" }, "nextcloud_system_freespace": { "name": "Free space" diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 478ff215c30..eb8bd26cb9b 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -36,6 +36,7 @@ from .const import ( ATTR_SETTINGS, ATTR_STATUS, CONF_PROFILE_ID, + DOMAIN, UPDATE_INTERVAL_ANALYTICS, UPDATE_INTERVAL_CONNECTION, UPDATE_INTERVAL_SETTINGS, @@ -88,9 +89,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b try: nextdns = await NextDns.create(websession, api_key) except (ApiError, ClientConnectorError, RetryError, TimeoutError) as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "entry": entry.title, + "error": repr(err), + }, + ) from err except InvalidApiKeyError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"entry": entry.title}, + ) from err tasks = [] coordinators = {} diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index b36c243a463..2adccaa304f 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -2,15 +2,19 @@ from __future__ import annotations -from nextdns import AnalyticsStatus +from aiohttp import ClientError +from aiohttp.client_exceptions import ClientConnectorError +from nextdns import AnalyticsStatus, ApiError, InvalidApiKeyError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry +from .const import DOMAIN from .coordinator import NextDnsUpdateCoordinator PARALLEL_UPDATES = 1 @@ -53,4 +57,21 @@ class NextDnsButton( async def async_press(self) -> None: """Trigger cleaning logs.""" - await self.coordinator.nextdns.clear_logs(self.coordinator.profile_id) + try: + await self.coordinator.nextdns.clear_logs(self.coordinator.profile_id) + except ( + ApiError, + ClientConnectorError, + TimeoutError, + ClientError, + ) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="method_error", + translation_placeholders={ + "entity": self.entity_id, + "error": repr(err), + }, + ) from err + except InvalidApiKeyError: + self.coordinator.config_entry.async_start_reauth(self.hass) diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index d3327c4c08b..d36064d8fb0 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from aiohttp.client_exceptions import ClientConnectorError @@ -19,6 +20,8 @@ from .const import CONF_PROFILE_ID, DOMAIN AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) +_LOGGER = logging.getLogger(__name__) + async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns: """Check if credentials are valid.""" @@ -51,7 +54,8 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_api_key" except (ApiError, ClientConnectorError, RetryError, TimeoutError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return await self.async_step_profiles() @@ -111,7 +115,8 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_api_key" except (ApiError, ClientConnectorError, RetryError, TimeoutError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_update_reload_and_abort( diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 850702e4488..41f6ff43a2a 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -79,9 +79,20 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): ClientConnectorError, RetryError, ) as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(err), + }, + ) from err except InvalidApiKeyError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"entry": self.config_entry.title}, + ) from err async def _async_update_data_internal(self) -> CoordinatorDataT: """Update data via library.""" diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index f2a5fa2816d..38944a0711e 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -359,5 +359,19 @@ "name": "Force YouTube restricted mode" } } + }, + "exceptions": { + "auth_error": { + "message": "Authentication failed for {entry}, please update your API key" + }, + "cannot_connect": { + "message": "An error occurred while connecting to the NextDNS API for {entry}: {error}" + }, + "method_error": { + "message": "An error occurred while calling the NextDNS API method for {entity}: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the NextDNS API for {entry}: {error}" + } } } diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index b7c77bd9dbd..8bdca76b955 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -8,7 +8,7 @@ from typing import Any from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError -from nextdns import ApiError, Settings +from nextdns import ApiError, InvalidApiKeyError, Settings from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry +from .const import DOMAIN from .coordinator import NextDnsUpdateCoordinator PARALLEL_UPDATES = 1 @@ -582,9 +583,16 @@ class NextDnsSwitch( ClientError, ) as err: raise HomeAssistantError( - "NextDNS API returned an error calling set_setting for" - f" {self.entity_id}: {err}" + translation_domain=DOMAIN, + translation_key="method_error", + translation_placeholders={ + "entity": self.entity_id, + "error": repr(err), + }, ) from err + except InvalidApiKeyError: + self.coordinator.config_entry.async_start_reauth(self.hass) + return if result: self._attr_is_on = new_state diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 049ba905f04..a8441fb90d8 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.14.0"] + "requirements": ["nibe==2.17.0"] } diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index ac4f9eba308..54cd0f7ea34 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -13,14 +13,17 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + PERCENTAGE, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, UnitOfPower, + UnitOfPressure, UnitOfTemperature, UnitOfTime, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -114,6 +117,20 @@ UNIT_DESCRIPTIONS = { state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.HOURS, ), + "min": SensorEntityDescription( + key="min", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), + "s": SensorEntityDescription( + key="s", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.SECONDS, + ), "Hz": SensorEntityDescription( key="Hz", entity_category=EntityCategory.DIAGNOSTIC, @@ -121,6 +138,48 @@ UNIT_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, ), + "Pa": SensorEntityDescription( + key="Pa", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.PA, + ), + "kPa": SensorEntityDescription( + key="kPa", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.KPA, + ), + "bar": SensorEntityDescription( + key="bar", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + ), + "l/m": SensorEntityDescription( + key="l/m", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + ), + "m³/h": SensorEntityDescription( + key="m³/h", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + "%RH": SensorEntityDescription( + key="%RH", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), } diff --git a/homeassistant/components/nibe_heatpump/strings.json b/homeassistant/components/nibe_heatpump/strings.json index 6fa421e0855..c65a76d3364 100644 --- a/homeassistant/components/nibe_heatpump/strings.json +++ b/homeassistant/components/nibe_heatpump/strings.json @@ -10,13 +10,13 @@ }, "modbus": { "data": { - "model": "Model of Heat Pump", + "model": "Model of heat pump", "modbus_url": "Modbus URL", - "modbus_unit": "Modbus Unit Identifier" + "modbus_unit": "Modbus unit identifier" }, "data_description": { - "modbus_url": "Modbus URL that describes the connection to your Heat Pump or MODBUS40 unit. It should be on the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote telnet based Modbus RTU connection.", - "modbus_unit": "Unit identification for your Heat Pump. Can usually be left at 0." + "modbus_url": "Modbus URL that describes the connection to your heat pump or MODBUS40 unit. It should be in the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote Telnet-based Modbus RTU connection.", + "modbus_unit": "Unit identification for your heat pump. Can usually be left at 0." } }, "nibegw": { diff --git a/homeassistant/components/niko_home_control/config_flow.py b/homeassistant/components/niko_home_control/config_flow.py index f37e5e9248a..76e71bc1690 100644 --- a/homeassistant/components/niko_home_control/config_flow.py +++ b/homeassistant/components/niko_home_control/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from nhc.controller import NHCController @@ -12,6 +13,8 @@ from homeassistant.const import CONF_HOST from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -25,7 +28,8 @@ async def test_connection(host: str) -> str | None: controller = NHCController(host, 8000) try: await controller.connect() - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return "cannot_connect" return None diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index b0a2d12b004..853fae342f4 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -110,11 +110,11 @@ class NikoHomeControlLight(NikoHomeControlEntity, LightEntity): 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) + self._attr_brightness = action.state async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - await self._action.turn_on(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) + await self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255)) async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" @@ -125,4 +125,4 @@ class NikoHomeControlLight(NikoHomeControlEntity, LightEntity): state = self._action.state self._attr_is_on = state > 0 if brightness_supported(self.supported_color_modes): - self._attr_brightness = round(state * 2.55) + self._attr_brightness = state diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index 83fca0ca2d6..1193d33d435 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.4.10"] + "requirements": ["nhc==0.4.12"] } diff --git a/homeassistant/components/niko_home_control/quality_scale.yaml b/homeassistant/components/niko_home_control/quality_scale.yaml new file mode 100644 index 00000000000..390efb8fc90 --- /dev/null +++ b/homeassistant/components/niko_home_control/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not require polling. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + Be more specific in the config flow with catching exceptions. + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: todo + 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 additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: + status: exempt + comment: No options to configure + 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: 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: done + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: done + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not require a websession. + strict-typing: todo diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index 45212c0220b..7383bd5932a 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["PyNINA==0.3.4"], + "requirements": ["pynina==0.3.6"], "single_config_entry": true } diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index 3cbbea007b1..5605ce82ac3 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -23,9 +23,9 @@ "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).", "data": { - "hosts": "Network addresses (comma separated) to scan", + "hosts": "Network addresses (comma-separated) to scan", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", - "exclude": "Network addresses (comma separated) to exclude from scanning", + "exclude": "Network addresses (comma-separated) to exclude from scanning", "scan_options": "Raw configurable scan options for Nmap" } } diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 7d06baf37b6..4a2783143ca 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -8,6 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import 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.typing import ConfigType from .const import DOMAIN @@ -22,13 +23,13 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NMBS component.""" - api_client = iRail() + api_client = iRail(session=async_get_clientsession(hass)) hass.data.setdefault(DOMAIN, {}) - station_response = await hass.async_add_executor_job(api_client.get_stations) - if station_response == -1: + station_response = await api_client.get_stations() + if station_response is None: return False - hass.data[DOMAIN] = station_response["station"] + hass.data[DOMAIN] = station_response.stations return True diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py index e45b2d9adeb..60ab015e22b 100644 --- a/homeassistant/components/nmbs/config_flow.py +++ b/homeassistant/components/nmbs/config_flow.py @@ -3,11 +3,13 @@ from typing import Any from pyrail import iRail +from pyrail.models import StationDetails 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.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( BooleanSelector, SelectOptionDict, @@ -31,17 +33,15 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize.""" - self.api_client = iRail() - self.stations: list[dict[str, Any]] = [] + self.stations: list[StationDetails] = [] - async def _fetch_stations(self) -> list[dict[str, Any]]: + async def _fetch_stations(self) -> list[StationDetails]: """Fetch the stations.""" - stations_response = await self.hass.async_add_executor_job( - self.api_client.get_stations - ) - if stations_response == -1: + api_client = iRail(session=async_get_clientsession(self.hass)) + stations_response = await api_client.get_stations() + if stations_response is None: raise CannotConnect("The API is currently unavailable.") - return stations_response["station"] + return stations_response.stations async def _fetch_stations_choices(self) -> list[SelectOptionDict]: """Fetch the stations options.""" @@ -50,7 +50,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): self.stations = await self._fetch_stations() return [ - SelectOptionDict(value=station["id"], label=station["standardname"]) + SelectOptionDict(value=station.id, label=station.standard_name) for station in self.stations ] @@ -72,12 +72,12 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): [station_from] = [ station for station in self.stations - if station["id"] == user_input[CONF_STATION_FROM] + if station.id == user_input[CONF_STATION_FROM] ] [station_to] = [ station for station in self.stations - if station["id"] == user_input[CONF_STATION_TO] + if station.id == user_input[CONF_STATION_TO] ] vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS) else "" await self.async_set_unique_id( @@ -85,7 +85,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - config_entry_name = f"Train from {station_from['standardname']} to {station_to['standardname']}" + config_entry_name = f"Train from {station_from.standard_name} to {station_to.standard_name}" return self.async_create_entry( title=config_entry_name, data=user_input, @@ -127,18 +127,18 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): station_live = None for station in self.stations: if user_input[CONF_STATION_FROM] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_from = station if user_input[CONF_STATION_TO] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_to = station if CONF_STATION_LIVE in user_input and user_input[CONF_STATION_LIVE] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_live = station @@ -148,29 +148,29 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): 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"] + 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"] + user_input[CONF_STATION_LIVE] = station_live.id entity_registry = er.async_get(self.hass) prefix = "live" vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS, False) else "" if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, - f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}", + f"{prefix}_{station_live.standard_name}_{station_from.standard_name}_{station_to.standard_name}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}" 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']}", + 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']}{vias}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) diff --git a/homeassistant/components/nmbs/const.py b/homeassistant/components/nmbs/const.py index fddb7365501..04c8beb327d 100644 --- a/homeassistant/components/nmbs/const.py +++ b/homeassistant/components/nmbs/const.py @@ -19,11 +19,7 @@ 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"]) - ), + (s for s in hass.data[DOMAIN] if station_name in (s.standard_name, s.name)), None, ) @@ -31,6 +27,6 @@ def find_station_by_name(hass: HomeAssistant, station_name: str): 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"]), + (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 9016eff11f8..37ff9429a54 100644 --- a/homeassistant/components/nmbs/manifest.json +++ b/homeassistant/components/nmbs/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pyrail"], "quality_scale": "legacy", - "requirements": ["pyrail==0.0.3"] + "requirements": ["pyrail==0.4.1"] } diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index c6dea2d0843..3552ac3c26d 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -2,10 +2,12 @@ from __future__ import annotations +from datetime import datetime import logging from typing import Any from pyrail import iRail +from pyrail.models import ConnectionDetails, LiveboardDeparture, StationDetails import voluptuous as vol from homeassistant.components.sensor import ( @@ -23,6 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -44,8 +47,6 @@ from .const import ( # noqa: F401 _LOGGER = logging.getLogger(__name__) -API_FAILURE = -1 - DEFAULT_NAME = "NMBS" DEFAULT_ICON = "mdi:train" @@ -63,12 +64,12 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( ) -def get_time_until(departure_time=None): +def get_time_until(departure_time: datetime | None = None): """Calculate the time between now and a train's departure time.""" if departure_time is None: return 0 - delta = dt_util.utc_from_timestamp(int(departure_time)) - dt_util.now() + delta = dt_util.as_utc(departure_time) - dt_util.utcnow() return round(delta.total_seconds() / 60) @@ -77,11 +78,9 @@ def get_delay_in_minutes(delay=0): return round(int(delay) / 60) -def get_ride_duration(departure_time, arrival_time, delay=0): +def get_ride_duration(departure_time: datetime, arrival_time: datetime, delay=0): """Calculate the total travel time in minutes.""" - duration = dt_util.utc_from_timestamp( - int(arrival_time) - ) - dt_util.utc_from_timestamp(int(departure_time)) + duration = arrival_time - departure_time duration_time = int(round(duration.total_seconds() / 60)) return duration_time + get_delay_in_minutes(delay) @@ -157,7 +156,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NMBS sensor entities based on a config entry.""" - api_client = iRail() + api_client = iRail(session=async_get_clientsession(hass)) name = config_entry.data.get(CONF_NAME, None) show_on_map = config_entry.data.get(CONF_SHOW_ON_MAP, False) @@ -189,9 +188,9 @@ class NMBSLiveBoard(SensorEntity): def __init__( self, api_client: iRail, - live_station: dict[str, Any], - station_from: dict[str, Any], - station_to: dict[str, Any], + live_station: StationDetails, + station_from: StationDetails, + station_to: StationDetails, excl_vias: bool, ) -> None: """Initialize the sensor for getting liveboard data.""" @@ -201,7 +200,8 @@ class NMBSLiveBoard(SensorEntity): self._station_to = station_to self._excl_vias = excl_vias - self._attrs: dict[str, Any] | None = {} + self._attrs: LiveboardDeparture | None = None + self._state: str | None = None self.entity_registry_enabled_default = False @@ -209,22 +209,20 @@ class NMBSLiveBoard(SensorEntity): @property def name(self) -> str: """Return the sensor default name.""" - return f"Trains in {self._station['standardname']}" + return f"Trains in {self._station.standard_name}" @property def unique_id(self) -> str: """Return the unique ID.""" - unique_id = ( - f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}" - ) + unique_id = f"{self._station.id}_{self._station_from.id}_{self._station_to.id}" vias = "_excl_vias" if self._excl_vias else "" return f"nmbs_live_{unique_id}{vias}" @property def icon(self) -> str: """Return the default icon or an alert icon if delays.""" - if self._attrs and int(self._attrs["delay"]) > 0: + if self._attrs and int(self._attrs.delay) > 0: return DEFAULT_ICON_ALERT return DEFAULT_ICON @@ -240,15 +238,15 @@ class NMBSLiveBoard(SensorEntity): if self._state is None or not self._attrs: return None - delay = get_delay_in_minutes(self._attrs["delay"]) - departure = get_time_until(self._attrs["time"]) + delay = get_delay_in_minutes(self._attrs.delay) + departure = get_time_until(self._attrs.time) attrs = { "departure": f"In {departure} minutes", "departure_minutes": departure, - "extra_train": int(self._attrs["isExtra"]) > 0, - "vehicle_id": self._attrs["vehicle"], - "monitored_station": self._station["standardname"], + "extra_train": self._attrs.is_extra, + "vehicle_id": self._attrs.vehicle, + "monitored_station": self._station.standard_name, } if delay > 0: @@ -257,28 +255,26 @@ class NMBSLiveBoard(SensorEntity): return attrs - def update(self) -> None: + async def async_update(self, **kwargs: Any) -> None: """Set the state equal to the next departure.""" - liveboard = self._api_client.get_liveboard(self._station["id"]) + liveboard = await self._api_client.get_liveboard(self._station.id) - if liveboard == API_FAILURE: + if liveboard is None: _LOGGER.warning("API failed in NMBSLiveBoard") return - if not (departures := liveboard.get("departures")): + if not (departures := liveboard.departures): _LOGGER.warning("API returned invalid departures: %r", liveboard) return _LOGGER.debug("API returned departures: %r", departures) - if departures["number"] == "0": + if len(departures) == 0: # No trains are scheduled return - next_departure = departures["departure"][0] + next_departure = departures[0] self._attrs = next_departure - self._state = ( - f"Track {next_departure['platform']} - {next_departure['station']}" - ) + self._state = f"Track {next_departure.platform} - {next_departure.station}" class NMBSSensor(SensorEntity): @@ -292,8 +288,8 @@ class NMBSSensor(SensorEntity): api_client: iRail, name: str, show_on_map: bool, - station_from: dict[str, Any], - station_to: dict[str, Any], + station_from: StationDetails, + station_to: StationDetails, excl_vias: bool, ) -> None: """Initialize the NMBS connection sensor.""" @@ -304,13 +300,13 @@ class NMBSSensor(SensorEntity): self._station_to = station_to self._excl_vias = excl_vias - self._attrs: dict[str, Any] | None = {} + self._attrs: ConnectionDetails | None = None self._state = None @property def unique_id(self) -> str: """Return the unique ID.""" - unique_id = f"{self._station_from['id']}_{self._station_to['id']}" + unique_id = f"{self._station_from.id}_{self._station_to.id}" vias = "_excl_vias" if self._excl_vias else "" return f"nmbs_connection_{unique_id}{vias}" @@ -319,14 +315,14 @@ class NMBSSensor(SensorEntity): 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 f"Train from {self._station_from.standard_name} to {self._station_to.standard_name}" return self._name @property 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"]) + delay = get_delay_in_minutes(self._attrs.departure.delay) if delay > 0: return "mdi:alert-octagon" @@ -338,19 +334,19 @@ class NMBSSensor(SensorEntity): if self._state is None or not self._attrs: return None - delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) - departure = get_time_until(self._attrs["departure"]["time"]) - canceled = int(self._attrs["departure"]["canceled"]) + delay = get_delay_in_minutes(self._attrs.departure.delay) + departure = get_time_until(self._attrs.departure.time) + canceled = self._attrs.departure.canceled attrs = { - "destination": self._attrs["departure"]["station"], - "direction": self._attrs["departure"]["direction"]["name"], - "platform_arriving": self._attrs["arrival"]["platform"], - "platform_departing": self._attrs["departure"]["platform"], - "vehicle_id": self._attrs["departure"]["vehicle"], + "destination": self._attrs.departure.station, + "direction": self._attrs.departure.direction.name, + "platform_arriving": self._attrs.arrival.platform, + "platform_departing": self._attrs.departure.platform, + "vehicle_id": self._attrs.departure.vehicle, } - if canceled != 1: + if not canceled: attrs["departure"] = f"In {departure} minutes" attrs["departure_minutes"] = departure attrs["canceled"] = False @@ -364,14 +360,14 @@ class NMBSSensor(SensorEntity): attrs[ATTR_LONGITUDE] = self.station_coordinates[1] if self.is_via_connection and not self._excl_vias: - via = self._attrs["vias"]["via"][0] + via = self._attrs.vias[0] - attrs["via"] = via["station"] - attrs["via_arrival_platform"] = via["arrival"]["platform"] - attrs["via_transfer_platform"] = via["departure"]["platform"] + attrs["via"] = via.station + attrs["via_arrival_platform"] = via.arrival.platform + attrs["via_transfer_platform"] = via.departure.platform attrs["via_transfer_time"] = get_delay_in_minutes( - via["timebetween"] - ) + get_delay_in_minutes(via["departure"]["delay"]) + via.timebetween + ) + get_delay_in_minutes(via.departure.delay) if delay > 0: attrs["delay"] = f"{delay} minutes" @@ -390,8 +386,8 @@ class NMBSSensor(SensorEntity): if self._state is None or not self._attrs: return [] - latitude = float(self._attrs["departure"]["stationinfo"]["locationY"]) - longitude = float(self._attrs["departure"]["stationinfo"]["locationX"]) + latitude = float(self._attrs.departure.station_info.latitude) + longitude = float(self._attrs.departure.station_info.longitude) return [latitude, longitude] @property @@ -400,24 +396,24 @@ class NMBSSensor(SensorEntity): if not self._attrs: return False - return "vias" in self._attrs and int(self._attrs["vias"]["number"]) > 0 + return self._attrs.vias is not None and len(self._attrs.vias) > 0 - def update(self) -> None: + async def async_update(self, **kwargs: Any) -> None: """Set the state to the duration of a connection.""" - connections = self._api_client.get_connections( - self._station_from["id"], self._station_to["id"] + connections = await self._api_client.get_connections( + self._station_from.id, self._station_to.id ) - if connections == API_FAILURE: + if connections is None: _LOGGER.warning("API failed in NMBSSensor") return - if not (connection := connections.get("connection")): + if not (connection := connections.connections): _LOGGER.warning("API returned invalid connection: %r", connections) return _LOGGER.debug("API returned connection: %r", connection) - if int(connection[0]["departure"]["left"]) > 0: + if connection[0].departure.left: next_connection = connection[1] else: next_connection = connection[0] @@ -431,9 +427,9 @@ class NMBSSensor(SensorEntity): return duration = get_ride_duration( - next_connection["departure"]["time"], - next_connection["arrival"]["time"], - next_connection["departure"]["delay"], + next_connection.departure.time, + next_connection.arrival.time, + next_connection.departure.delay, ) self._state = duration diff --git a/homeassistant/components/nmbs/strings.json b/homeassistant/components/nmbs/strings.json index 3e7aa8d05bd..ac11026577a 100644 --- a/homeassistant/components/nmbs/strings.json +++ b/homeassistant/components/nmbs/strings.json @@ -29,7 +29,7 @@ "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." + "description": "Configuring {integration_title} using YAML is being removed but there was a 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/strings.json b/homeassistant/components/nobo_hub/strings.json index 28be01862e9..5d1b8350edf 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -44,16 +44,16 @@ "entity": { "select": { "global_override": { - "name": "global override", + "name": "Global override", "state": { - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", "eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" } }, "week_profile": { - "name": "week profile" + "name": "Week profile" } } } diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py index c6993826239..4bde12afc3c 100644 --- a/homeassistant/components/nordpool/sensor.py +++ b/homeassistant/components/nordpool/sensor.py @@ -34,7 +34,7 @@ def validate_prices( index: int, ) -> float | None: """Validate and return.""" - if result := func(entity)[area][index]: + if (result := func(entity)[area][index]) is not None: return result / 1000 return None diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index cc10a1a0640..7b33f032de1 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -15,7 +15,7 @@ }, "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." + "areas": "Areas to display prices for according to Nord Pool market areas." } }, "reconfigure": { @@ -95,11 +95,11 @@ "services": { "get_prices_for_date": { "name": "Get prices for date", - "description": "Retrieve the prices for a specific date.", + "description": "Retrieves the prices for a specific date.", "fields": { "config_entry": { - "name": "Select Nord Pool configuration entry", - "description": "Choose the configuration entry." + "name": "Config entry", + "description": "The Nord Pool configuration entry for this action." }, "date": { "name": "Date", diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py new file mode 100644 index 00000000000..cd9c35ca4e6 --- /dev/null +++ b/homeassistant/components/ntfy/__init__.py @@ -0,0 +1,78 @@ +"""The ntfy integration.""" + +from __future__ import annotations + +import logging + +from aiontfy import Ntfy +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, 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 + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.NOTIFY] + + +type NtfyConfigEntry = ConfigEntry[Ntfy] + + +async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: + """Set up ntfy from a config entry.""" + + session = async_get_clientsession(hass, entry.data.get(CONF_VERIFY_SSL, True)) + ntfy = Ntfy(entry.data[CONF_URL], session, token=entry.data.get(CONF_TOKEN)) + + try: + await ntfy.account() + except NtfyUnauthorizedAuthenticationError as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from e + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="server_error", + translation_placeholders={"error_msg": str(e.error)}, + ) from e + except NtfyConnectionError as e: + _LOGGER.debug("Error", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from e + except NtfyTimeoutError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_error", + ) from e + + entry.runtime_data = ntfy + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: NtfyConfigEntry) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py new file mode 100644 index 00000000000..04a6730aa73 --- /dev/null +++ b/homeassistant/components/ntfy/config_flow.py @@ -0,0 +1,305 @@ +"""Config flow for the ntfy integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +import random +import re +import string +from typing import TYPE_CHECKING, Any + +from aiontfy import Ntfy +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +import voluptuous as vol +from yarl import URL + +from homeassistant import data_entry_flow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import ( + ATTR_CREDENTIALS, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_TOPIC, DEFAULT_URL, DOMAIN, SECTION_AUTH + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), + vol.Required(CONF_VERIFY_SSL, default=True): bool, + vol.Required(SECTION_AUTH): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="username", + ), + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + } + ), + {"collapsed": True}, + ), + } +) + +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Exclusive(CONF_PASSWORD, ATTR_CREDENTIALS): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + vol.Exclusive(CONF_TOKEN, ATTR_CREDENTIALS): str, + } +) + +STEP_USER_TOPIC_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOPIC): str, + vol.Optional(CONF_NAME): str, + } +) + +RE_TOPIC = re.compile("^[-_a-zA-Z0-9]{1,64}$") + + +class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ntfy.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"topic": TopicSubentryFlowHandler} + + 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: + url = URL(user_input[CONF_URL]) + username = user_input[SECTION_AUTH].get(CONF_USERNAME) + self._async_abort_entries_match( + { + CONF_URL: url.human_repr(), + CONF_USERNAME: username, + } + ) + session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) + if username: + ntfy = Ntfy( + user_input[CONF_URL], + session, + username, + user_input[SECTION_AUTH].get(CONF_PASSWORD, ""), + ) + else: + ntfy = Ntfy(user_input[CONF_URL], session) + + try: + account = await ntfy.account() + token = ( + (await ntfy.generate_token("Home Assistant")).token + if account.username != "*" + else None + ) + except NtfyUnauthorizedAuthenticationError: + errors["base"] = "invalid_auth" + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + errors["base"] = "cannot_connect" + except NtfyException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if TYPE_CHECKING: + assert url.host + return self.async_create_entry( + title=url.host, + data={ + CONF_URL: url.human_repr(), + CONF_USERNAME: username, + CONF_TOKEN: token, + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + 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 reauthentication dialog.""" + errors: dict[str, str] = {} + + entry = self._get_reauth_entry() + + if user_input is not None: + session = async_get_clientsession(self.hass) + if token := user_input.get(CONF_TOKEN): + ntfy = Ntfy( + entry.data[CONF_URL], + session, + token=user_input[CONF_TOKEN], + ) + else: + ntfy = Ntfy( + entry.data[CONF_URL], + session, + username=entry.data[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + + try: + account = await ntfy.account() + token = ( + (await ntfy.generate_token("Home Assistant")).token + if not user_input.get(CONF_TOKEN) + else user_input[CONF_TOKEN] + ) + except NtfyUnauthorizedAuthenticationError: + errors["base"] = "invalid_auth" + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + errors["base"] = "cannot_connect" + except NtfyException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if entry.data[CONF_USERNAME] != account.username: + return self.async_abort( + reason="account_mismatch", + description_placeholders={ + CONF_USERNAME: entry.data[CONF_USERNAME], + "wrong_username": account.username, + }, + ) + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_TOKEN: token}, + ) + + 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=user_input + ), + errors=errors, + description_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]}, + ) + + +class TopicSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding and modifying a topic.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + + return self.async_show_menu( + step_id="user", + menu_options=["add_topic", "generate_topic"], + ) + + async def async_step_generate_topic( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + topic = "".join( + random.choices( + string.ascii_lowercase + string.ascii_uppercase + string.digits, + k=16, + ) + ) + return self.async_show_form( + step_id="add_topic", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_TOPIC_SCHEMA, + suggested_values={CONF_TOPIC: topic}, + ), + ) + + async def async_step_add_topic( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to add a new topic.""" + config_entry = self._get_entry() + errors: dict[str, str] = {} + + if user_input is not None: + if not RE_TOPIC.match(user_input[CONF_TOPIC]): + errors["base"] = "invalid_topic" + else: + for existing_subentry in config_entry.subentries.values(): + if existing_subentry.unique_id == user_input[CONF_TOPIC]: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=user_input.get(CONF_NAME, user_input[CONF_TOPIC]), + data=user_input, + unique_id=user_input[CONF_TOPIC], + ) + return self.async_show_form( + step_id="add_topic", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_TOPIC_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/ntfy/const.py b/homeassistant/components/ntfy/const.py new file mode 100644 index 00000000000..78355f7e828 --- /dev/null +++ b/homeassistant/components/ntfy/const.py @@ -0,0 +1,9 @@ +"""Constants for the ntfy integration.""" + +from typing import Final + +DOMAIN = "ntfy" +DEFAULT_URL: Final = "https://ntfy.sh" + +CONF_TOPIC = "topic" +SECTION_AUTH = "auth" diff --git a/homeassistant/components/ntfy/diagnostics.py b/homeassistant/components/ntfy/diagnostics.py new file mode 100644 index 00000000000..5be239dfef6 --- /dev/null +++ b/homeassistant/components/ntfy/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics platform for ntfy integration.""" + +from __future__ import annotations + +from typing import Any + +from yarl import URL + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from . import NtfyConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: NtfyConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + url = URL(config_entry.data[CONF_URL]) + return { + CONF_URL: ( + url.human_repr() + if url.host == "ntfy.sh" + else url.with_host(REDACTED).human_repr() + ), + "topics": dict(config_entry.subentries), + } diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json new file mode 100644 index 00000000000..9fe617880af --- /dev/null +++ b/homeassistant/components/ntfy/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "notify": { + "publish": { + "default": "mdi:console-line" + } + } + } +} diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json new file mode 100644 index 00000000000..fde1569d622 --- /dev/null +++ b/homeassistant/components/ntfy/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ntfy", + "name": "ntfy", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ntfy", + "iot_class": "cloud_push", + "loggers": ["aionfty"], + "quality_scale": "bronze", + "requirements": ["aiontfy==0.5.2"] +} diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py new file mode 100644 index 00000000000..7328a1533c2 --- /dev/null +++ b/homeassistant/components/ntfy/notify.py @@ -0,0 +1,97 @@ +"""ntfy notification entity.""" + +from __future__ import annotations + +from aiontfy import Message +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +from yarl import URL + +from homeassistant.components.notify import ( + NotifyEntity, + NotifyEntityDescription, + NotifyEntityFeature, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_NAME, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NtfyConfigEntry +from .const import CONF_TOPIC, DOMAIN + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NtfyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the ntfy notification entity platform.""" + + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [NtfyNotifyEntity(config_entry, subentry)], config_subentry_id=subentry_id + ) + + +class NtfyNotifyEntity(NotifyEntity): + """Representation of a ntfy notification entity.""" + + entity_description = NotifyEntityDescription( + key="publish", + translation_key="publish", + name=None, + has_entity_name=True, + ) + _attr_supported_features = NotifyEntityFeature.TITLE + + def __init__( + self, + config_entry: NtfyConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize a notification entity.""" + + self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{self.entity_description.key}" + self.topic = subentry.data[CONF_TOPIC] + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + name=subentry.data.get(CONF_NAME, self.topic), + configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, + identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, + ) + self.config_entry = config_entry + self.ntfy = config_entry.runtime_data + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Publish a message to a topic.""" + msg = Message(topic=self.topic, message=message, title=title) + try: + await self.ntfy.publish(msg) + except NtfyUnauthorizedAuthenticationError as e: + self.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from e + except NtfyHTTPError as e: + raise HomeAssistantError( + translation_key="publish_failed_request_error", + translation_domain=DOMAIN, + translation_placeholders={"error_msg": e.error}, + ) from e + except NtfyException as e: + raise HomeAssistantError( + translation_key="publish_failed_exception", + translation_domain=DOMAIN, + ) from e diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml new file mode 100644 index 00000000000..0d075f0014b --- /dev/null +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: only entity actions + appropriate-polling: + status: exempt + comment: the integration does not poll + brands: done + common-modules: + status: exempt + comment: the integration currently implements only one platform and has no coordinator + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: integration has only entity actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: the 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: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: the integration only implements a stateless notify entity. + integration-owner: done + log-when-unavailable: + status: exempt + comment: the integration only integrates state-less entities + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + 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: + status: exempt + comment: no suitable device class for the notify entity + entity-disabled-by-default: + status: exempt + comment: only one entity + entity-translations: + status: exempt + comment: the notify entity uses the device name as entity name, no translation required + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json new file mode 100644 index 00000000000..13704d960be --- /dev/null +++ b/homeassistant/components/ntfy/strings.json @@ -0,0 +1,117 @@ +{ + "common": { + "topic": "Topic", + "add_topic_description": "Set up a topic for notifications." + }, + "config": { + "step": { + "user": { + "description": "Set up **ntfy** push notification service", + "data": { + "url": "Service URL", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "Address of the ntfy service. Modify this if you want to use a different server", + "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a ntfy instance using a self-signed certificate" + }, + "sections": { + "auth": { + "name": "Authentication", + "description": "Depending on whether the server is configured to support access control, some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. To publish/subscribe to protected topics, you can provide a username and password. Home Assistant will automatically generate an access token to authenticate with ntfy.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Enter the username required to authenticate with protected ntfy topics", + "password": "Enter the password corresponding to the provided username for authentication" + } + } + } + }, + "reauth_confirm": { + "title": "Re-authenticate with ntfy ({name})", + "description": "The access token for **{username}** is invalid. To re-authenticate with the ntfy service, you can either log in with your password (a new access token will be created automatically) or you can directly provide a valid access token", + "data": { + "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "password": "Enter the password corresponding to the aforementioned username to automatically create an access token", + "token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and click create access token" + } + } + }, + "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_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**" + } + }, + "config_subentries": { + "topic": { + "step": { + "user": { + "title": "[%key:component::ntfy::common::topic%]", + "description": "[%key:component::ntfy::common::add_topic_description%]", + "menu_options": { + "add_topic": "Enter topic", + "generate_topic": "Generate topic name" + } + }, + "add_topic": { + "title": "[%key:component::ntfy::common::topic%]", + "description": "[%key:component::ntfy::common::add_topic_description%]", + "data": { + "topic": "[%key:component::ntfy::common::topic%]", + "name": "Display name" + }, + "data_description": { + "topic": "Enter the name of the topic you want to use for notifications. Topics may not be password-protected, so choose a name that's not easy to guess.", + "name": "Set an alternative name to display instead of the topic name. This helps identify topics with complex or hard-to-read names more easily." + } + } + }, + "initiate_flow": { + "user": "Add topic" + }, + "entry_type": "[%key:component::ntfy::common::topic%]", + "error": { + "publish_forbidden": "Publishing to this topic is forbidden", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_topic": "Invalid topic. Only letters, numbers, underscores, or dashes allowed." + }, + "abort": { + "already_configured": "Topic is already configured" + } + } + }, + "exceptions": { + "publish_failed_request_error": { + "message": "Failed to publish notification: {error_msg}" + }, + + "publish_failed_exception": { + "message": "Failed to publish notification due to a connection error" + }, + "authentication_error": { + "message": "Failed to authenticate with ntfy service. Please verify your credentials" + }, + "server_error": { + "message": "Failed to connect to ntfy service due to a server error: {error_msg}" + }, + "connection_error": { + "message": "Failed to connect to ntfy service due to a connection error" + }, + "timeout_error": { + "message": "Failed to connect to ntfy service due to a connection timeout" + } + } +} diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 2785c46ca17..4bdc2a15156 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NukiEntryData -from .const import DOMAIN as NUKI_DOMAIN +from .const import DOMAIN from .entity import NukiEntity @@ -25,7 +25,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki binary sensors.""" - entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] + entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] entities: list[NukiEntity] = [] diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index b2e039ec122..cfc147661ae 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -1,6 +1,6 @@ { "domain": "nuki", - "name": "Nuki", + "name": "Nuki Bridge", "codeowners": ["@pschmitt", "@pvizeli", "@pree"], "config_flow": true, "dependencies": ["webhook"], diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 4f3890a10cf..809e97d6ce9 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NukiEntryData -from .const import DOMAIN as NUKI_DOMAIN +from .const import DOMAIN from .entity import NukiEntity @@ -21,7 +21,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki lock sensor.""" - entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] + entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] async_add_entities( NukiBatterySensor(entry_data.coordinator, lock) for lock in entry_data.locks diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index daf47bc7de1..84e66c3db96 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -48,8 +48,8 @@ "state_attributes": { "battery_critical": { "state": { - "on": "[%key:component::binary_sensor::entity_component::battery::state::on%]", - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]" + "on": "[%key:common::state::low%]", + "off": "[%key:common::state::normal%]" } } } diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 61a4fa644b0..6a5809610ee 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -33,6 +33,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPrecipitationDepth, UnitOfPressure, + UnitOfReactiveEnergy, UnitOfReactivePower, UnitOfSoundPressure, UnitOfSpeed, @@ -44,6 +45,7 @@ from homeassistant.const import ( ) from homeassistant.util.unit_conversion import ( BaseUnitConverter, + ReactiveEnergyConverter, TemperatureConverter, VolumeFlowRateConverter, ) @@ -159,7 +161,7 @@ class NumberDeviceClass(StrEnum): DURATION = "duration" """Fixed duration. - Unit of measurement: `d`, `h`, `min`, `s`, `ms` + Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs` """ ENERGY = "energy" @@ -196,7 +198,7 @@ class NumberDeviceClass(StrEnum): """Gas. Unit of measurement: - - SI / metric: `m³` + - SI / metric: `L`, `m³` - USCS / imperial: `ft³`, `CCF` """ @@ -320,10 +322,16 @@ class NumberDeviceClass(StrEnum): - `psi` """ + REACTIVE_ENERGY = "reactive_energy" + """Reactive energy. + + Unit of measurement: `varh`, `kvarh` + """ + REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var` + Unit of measurement: `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -462,6 +470,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MINUTES, UnitOfTime.SECONDS, UnitOfTime.MILLISECONDS, + UnitOfTime.MICROSECONDS, }, NumberDeviceClass.ENERGY: set(UnitOfEnergy), NumberDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), @@ -471,6 +480,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS, + UnitOfVolume.LITERS, }, NumberDeviceClass.HUMIDITY: {PERCENTAGE}, NumberDeviceClass.ILLUMINANCE: {LIGHT_LUX}, @@ -486,6 +496,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, NumberDeviceClass.POWER: { + UnitOfPower.MILLIWATT, UnitOfPower.WATT, UnitOfPower.KILO_WATT, UnitOfPower.MEGA_WATT, @@ -495,7 +506,8 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), NumberDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), NumberDeviceClass.PRESSURE: set(UnitOfPressure), - NumberDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE}, + NumberDeviceClass.REACTIVE_ENERGY: set(UnitOfReactiveEnergy), + NumberDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower), NumberDeviceClass.SIGNAL_STRENGTH: { SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -528,6 +540,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { } UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = { + NumberDeviceClass.REACTIVE_ENERGY: ReactiveEnergyConverter, NumberDeviceClass.TEMPERATURE: TemperatureConverter, NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter, } diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index 49103f5cd41..dcce09984bd 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -111,6 +111,9 @@ "pressure": { "default": "mdi:gauge" }, + "reactive_energy": { + "default": "mdi:lightning-bolt" + }, "reactive_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/number/services.yaml b/homeassistant/components/number/services.yaml index dcbb955d739..6a7083a7613 100644 --- a/homeassistant/components/number/services.yaml +++ b/homeassistant/components/number/services.yaml @@ -7,5 +7,6 @@ set_value: fields: value: example: 42 + required: true selector: text: diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 993120ef3ad..998b9ffba38 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -130,6 +130,9 @@ "pressure": { "name": "[%key:component::sensor::entity_component::pressure::name%]" }, + "reactive_energy": { + "name": "[%key:component::sensor::entity_component::reactive_energy::name%]" + }, "reactive_power": { "name": "[%key:component::sensor::entity_component::reactive_power::name%]" }, diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 169dbbbff5d..2f2c6badc4c 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -23,14 +23,10 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - INTEGRATION_SUPPORTED_COMMANDS, - PLATFORMS, -) +from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS NUT_FAKE_SERIAL = ["unknown", "blank"] @@ -68,7 +64,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: alias = config.get(CONF_ALIAS) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + if CONF_SCAN_INTERVAL in entry.options: + current_options = {**entry.options} + current_options.pop(CONF_SCAN_INTERVAL) + hass.config_entries.async_update_entry(entry, options=current_options) data = PyNUTData(host, port, alias, username, password) @@ -79,9 +78,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: try: return await data.async_update() except NUTLoginError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "err": str(err), + }, + ) from err except NUTError as err: - raise UpdateFailed(f"Error fetching UPS state: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="data_fetch_error", + translation_placeholders={ + "err": str(err), + }, + ) from err coordinator = DataUpdateCoordinator( hass, @@ -89,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: config_entry=entry, name="NUT resource status", update_method=async_update_data, - update_interval=timedelta(seconds=scan_interval), + update_interval=timedelta(seconds=60), always_update=False, ) @@ -103,30 +114,58 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: ) status = coordinator.data - _LOGGER.debug("NUT Sensors Available: %s", status) + _LOGGER.debug("NUT Sensors Available: %s", status if status else None) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) unique_id = _unique_id_from_status(status) if unique_id is None: unique_id = entry.entry_id + elif entry.unique_id is None: + hass.config_entries.async_update_entry(entry, unique_id=unique_id) + if username is not None and password is not None: + # Dynamically add outlet integration commands + additional_integration_commands = set() + if (num_outlets := status.get("outlet.count")) is not None: + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str: str = str(outlet_num) + additional_integration_commands |= { + f"outlet.{outlet_num_str}.load.cycle", + f"outlet.{outlet_num_str}.load.on", + f"outlet.{outlet_num_str}.load.off", + } + + valid_integration_commands = ( + INTEGRATION_SUPPORTED_COMMANDS | additional_integration_commands + ) + user_available_commands = { - device_supported_command - for device_supported_command in await data.async_list_commands() or {} - if device_supported_command in INTEGRATION_SUPPORTED_COMMANDS + device_command + for device_command in await data.async_list_commands() or {} + if device_command in valid_integration_commands } else: user_available_commands = set() + _LOGGER.debug( + "NUT Commands Available: %s", + user_available_commands if user_available_commands else None, + ) + entry.runtime_data = NutRuntimeData( coordinator, data, unique_id, user_available_commands ) + connections: set[tuple[str, str]] | None = None + if data.device_info.mac_address is not None: + connections = {(CONNECTION_NETWORK_MAC, data.device_info.mac_address)} + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, unique_id)}, + connections=connections, name=data.name.title(), manufacturer=data.device_info.manufacturer, model=data.device_info.model, @@ -141,12 +180,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: NutConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove NUT 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.unique_id + ) + + +async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) @@ -214,6 +267,7 @@ class NUTDeviceInfo: model_id: str | None = None firmware: str | None = None serial: str | None = None + mac_address: str | None = None device_location: str | None = None @@ -240,6 +294,7 @@ class PyNUTData: self._client = AIONUTClient(self._host, port, username, password, 5, persistent) self.ups_list: dict[str, str] | None = None + self.device_name: str | None = None self._status: dict[str, str] | None = None self._device_info: NUTDeviceInfo | None = None @@ -250,7 +305,7 @@ class PyNUTData: @property def name(self) -> str: - """Return the name of the ups.""" + """Return the name of the NUT device.""" return self._alias or f"Nut-{self._host}" @property @@ -276,9 +331,18 @@ class PyNUTData: model_id: str | None = self._status.get("device.part") firmware = _firmware_from_status(self._status) serial = _serial_from_status(self._status) + mac_address: str | None = self._status.get("device.macaddr") + if mac_address is not None: + mac_address = format_mac(mac_address.rstrip().replace(" ", ":")) device_location: str | None = self._status.get("device.location") return NUTDeviceInfo( - manufacturer, model, model_id, firmware, serial, device_location + manufacturer, + model, + model_id, + firmware, + serial, + mac_address, + device_location, ) async def _async_get_status(self) -> dict[str, str]: @@ -294,6 +358,8 @@ class PyNUTData: self._status = await self._async_get_status() if self._device_info is None: self._device_info = self._get_device_info() + if self.device_name is None: + self.device_name = self.name.title() return self._status async def async_run_command(self, command_name: str) -> None: @@ -305,7 +371,12 @@ class PyNUTData: await self._client.run_command(self._alias, command_name) except NUTError as err: raise HomeAssistantError( - f"Error running command {command_name}, {err}" + translation_domain=DOMAIN, + translation_key="nut_command_error", + translation_placeholders={ + "command_name": command_name, + "err": str(err), + }, ) from err async def async_list_commands(self) -> set[str] | None: diff --git a/homeassistant/components/nut/button.py b/homeassistant/components/nut/button.py new file mode 100644 index 00000000000..0708056b2e3 --- /dev/null +++ b/homeassistant/components/nut/button.py @@ -0,0 +1,67 @@ +"""Provides a switch for switchable NUT outlets.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NutConfigEntry +from .entity import NUTBaseEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NutConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the NUT buttons.""" + pynut_data = config_entry.runtime_data + coordinator = pynut_data.coordinator + status = coordinator.data + + # Dynamically add outlet button types + if (num_outlets := status.get("outlet.count")) is None: + return + + data = pynut_data.data + unique_id = pynut_data.unique_id + valid_button_types: dict[str, ButtonEntityDescription] = {} + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str = str(outlet_num) + outlet_name: str = status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str + valid_button_types |= { + f"outlet.{outlet_num_str}.load.cycle": ButtonEntityDescription( + key=f"outlet.{outlet_num_str}.load.cycle", + translation_key="outlet_number_load_cycle", + translation_placeholders={"outlet_name": outlet_name}, + device_class=ButtonDeviceClass.RESTART, + entity_registry_enabled_default=True, + ), + } + + async_add_entities( + NUTButton(coordinator, description, data, unique_id) + for button_id, description in valid_button_types.items() + if button_id in pynut_data.user_available_commands + ) + + +class NUTButton(NUTBaseEntity, ButtonEntity): + """Representation of a button entity for NUT.""" + + async def async_press(self) -> None: + """Press the button.""" + name_list = self.entity_description.key.split(".") + command_name = f"{name_list[0]}.{name_list[1]}.load.cycle" + await self.pynut_data.async_run_command(command_name) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index b1b44966d14..69281e852a8 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -9,40 +9,43 @@ from typing import Any from aionut import NUTError, NUTLoginError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_ALIAS, CONF_BASE, CONF_HOST, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_USERNAME, ) -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 +from . import PyNUTData, _unique_id_from_status +from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) -AUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} +REAUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} + +PASSWORD_NOT_CHANGED = "__**password_not_changed**__" -def _base_schema(nut_config: dict[str, Any]) -> vol.Schema: +def _base_schema( + nut_config: Mapping[str, Any], + use_password_not_changed: bool = False, +) -> vol.Schema: """Generate base schema.""" base_schema = { vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str, vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int, + vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str, + vol.Optional( + CONF_PASSWORD, + default=PASSWORD_NOT_CHANGED if use_password_not_changed else "", + ): str, } - base_schema.update(AUTH_SCHEMA) + return vol.Schema(base_schema) @@ -51,7 +54,7 @@ def _ups_schema(ups_list: dict[str, str]) -> vol.Schema: return vol.Schema({vol.Required(CONF_ALIAS): vol.In(ups_list)}) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input(data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from _base_schema with values provided by the user. @@ -72,6 +75,26 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return {"ups_list": nut_data.ups_list, "available_resources": status} +def _check_host_port_alias_match( + first: Mapping[str, Any], second: Mapping[str, Any] +) -> bool: + """Check if first and second have the same host, port and alias.""" + + if first[CONF_HOST] != second[CONF_HOST] or first[CONF_PORT] != second[CONF_PORT]: + return False + + first_alias = first.get(CONF_ALIAS) + second_alias = second.get(CONF_ALIAS) + if (first_alias is None and second_alias is None) or ( + first_alias is not None + and second_alias is not None + and first_alias == second_alias + ): + return True + + return False + + def _format_host_port_alias(user_input: Mapping[str, Any]) -> str: """Format a host, port, and alias so it can be used for comparison or display.""" host = user_input[CONF_HOST] @@ -125,6 +148,11 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): if self._host_port_alias_already_configured(nut_config): return self.async_abort(reason="already_configured") + + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + title = _format_host_port_alias(nut_config) return self.async_create_entry(title=title, data=nut_config) @@ -138,7 +166,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_ups( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the picking the ups.""" + """Handle selecting the NUT device alias.""" errors: dict[str, str] = {} placeholders: dict[str, str] = {} nut_config = self.nut_config @@ -147,8 +175,13 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self.nut_config.update(user_input) if self._host_port_alias_already_configured(nut_config): return self.async_abort(reason="already_configured") - _, errors, placeholders = await self._async_validate_or_error(nut_config) + + info, errors, placeholders = await self._async_validate_or_error(nut_config) if not errors: + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + title = _format_host_port_alias(nut_config) return self.async_create_entry(title=title, data=nut_config) @@ -159,6 +192,99 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders=placeholders, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + nut_config = self.nut_config + + if user_input is not None: + nut_config.update(user_input) + + info, errors, placeholders = await self._async_validate_or_error(nut_config) + + if not errors: + if len(info["ups_list"]) > 1: + self.ups_list = info["ups_list"] + return await self.async_step_reconfigure_ups() + + if not _check_host_port_alias_match( + reconfigure_entry.data, + nut_config, + ) and (self._host_port_alias_already_configured(nut_config)): + return self.async_abort(reason="already_configured") + + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED: + nut_config.pop(CONF_PASSWORD) + + new_title = _format_host_port_alias(nut_config) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + unique_id=unique_id, + title=new_title, + data_updates=nut_config, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=_base_schema( + reconfigure_entry.data, + use_password_not_changed=True, + ), + errors=errors, + description_placeholders=placeholders, + ) + + async def async_step_reconfigure_ups( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle selecting the NUT device alias.""" + + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + nut_config = self.nut_config + + if user_input is not None: + self.nut_config.update(user_input) + + if not _check_host_port_alias_match( + reconfigure_entry.data, + nut_config, + ) and (self._host_port_alias_already_configured(nut_config)): + return self.async_abort(reason="already_configured") + + info, errors, placeholders = await self._async_validate_or_error(nut_config) + if not errors: + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + + if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED: + nut_config.pop(CONF_PASSWORD) + + new_title = _format_host_port_alias(nut_config) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + unique_id=unique_id, + title=new_title, + data_updates=nut_config, + ) + + return self.async_show_form( + step_id="reconfigure_ups", + data_schema=_ups_schema(self.ups_list or {}), + errors=errors, + description_placeholders=placeholders, + ) + def _host_port_alias_already_configured(self, user_input: dict[str, Any]) -> bool: """See if we already have a nut entry matching user input configured.""" existing_host_port_aliases = { @@ -175,7 +301,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): info: dict[str, Any] = {} description_placeholders: dict[str, str] = {} try: - info = await validate_input(self.hass, config) + info = await validate_input(config) except NUTLoginError: errors[CONF_PASSWORD] = "invalid_auth" except NUTError as ex: @@ -192,25 +318,24 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth.""" - entry_id = self.context["entry_id"] - self.reauth_entry = self.hass.config_entries.async_get_entry(entry_id) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reauth input.""" + errors: dict[str, str] = {} - existing_entry = self.reauth_entry - assert existing_entry - existing_data = existing_entry.data + reauth_entry = self._get_reauth_entry() + reauth_data = reauth_entry.data description_placeholders: dict[str, str] = { - CONF_HOST: existing_data[CONF_HOST], - CONF_PORT: existing_data[CONF_PORT], + CONF_HOST: reauth_data[CONF_HOST], + CONF_PORT: reauth_data[CONF_PORT], } + if user_input is not None: new_config = { - **existing_data, + **reauth_data, # Username/password are optional and some servers # use ip based authentication and will fail if # username/password are provided @@ -219,43 +344,12 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): } _, errors, placeholders = await self._async_validate_or_error(new_config) if not errors: - return self.async_update_reload_and_abort( - existing_entry, data=new_config - ) + return self.async_update_reload_and_abort(reauth_entry, data=new_config) description_placeholders.update(placeholders) return self.async_show_form( - description_placeholders=description_placeholders, step_id="reauth_confirm", - data_schema=vol.Schema(AUTH_SCHEMA), + data_schema=vol.Schema(REAUTH_SCHEMA), errors=errors, + description_placeholders=description_placeholders, ) - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: - """Get the options flow for this handler.""" - return OptionsFlowHandler() - - -class OptionsFlowHandler(OptionsFlow): - """Handle a option flow for nut.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - scan_interval = self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - - base_schema = { - vol.Optional(CONF_SCAN_INTERVAL, default=scan_interval): vol.All( - vol.Coerce(int), vol.Clamp(min=10, max=300) - ) - } - - return self.async_show_form(step_id="init", data_schema=vol.Schema(base_schema)) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 6db40a910a0..175e971a12a 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -6,7 +6,11 @@ from homeassistant.const import Platform DOMAIN = "nut" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [ + Platform.BUTTON, + Platform.SENSOR, + Platform.SWITCH, +] DEFAULT_NAME = "NUT UPS" DEFAULT_HOST = "localhost" @@ -15,8 +19,6 @@ DEFAULT_PORT = 3493 KEY_STATUS = "ups.status" KEY_STATUS_DISPLAY = "ups.status.display" -DEFAULT_SCAN_INTERVAL = 60 - STATE_TYPES = { "OL": "Online", "OB": "On Battery", diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py index ffaa195deaf..c622e63a12c 100644 --- a/homeassistant/components/nut/device_action.py +++ b/homeassistant/components/nut/device_action.py @@ -2,15 +2,18 @@ from __future__ import annotations +from typing import cast + import voluptuous as vol from homeassistant.components.device_automation import InvalidDeviceAutomationConfig +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import NutRuntimeData +from . import NutConfigEntry, NutRuntimeData from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS ACTION_TYPES = {cmd.replace(".", "_") for cmd in INTEGRATION_SUPPORTED_COMMANDS} @@ -48,12 +51,11 @@ async def async_call_action_from_config( device_action_name: str = config[CONF_TYPE] command_name = _get_command_name(device_action_name) device_id: str = config[CONF_DEVICE_ID] - runtime_data = _get_runtime_data_from_device_id(hass, device_id) - if not runtime_data: - raise InvalidDeviceAutomationConfig( - f"Unable to find a NUT device with id {device_id}" - ) - await runtime_data.data.async_run_command(command_name) + + if runtime_data := _get_runtime_data_from_device_id_exception_on_failure( + hass, device_id + ): + await runtime_data.data.async_run_command(command_name) def _get_device_action_name(command_name: str) -> str: @@ -65,13 +67,55 @@ def _get_command_name(device_action_name: str) -> str: def _get_runtime_data_from_device_id( - hass: HomeAssistant, device_id: str + hass: HomeAssistant, + device_id: str, ) -> NutRuntimeData | None: + """Find the runtime data for device ID and return None on error.""" device_registry = dr.async_get(hass) if (device := device_registry.async_get(device_id)) is None: return None - entry = hass.config_entries.async_get_entry( - next(entry_id for entry_id in device.config_entries) + return _get_runtime_data_for_device(hass, device) + + +def _get_runtime_data_for_device( + hass: HomeAssistant, device: dr.DeviceEntry +) -> NutRuntimeData | None: + """Find the runtime data for device and return None on error.""" + for config_entry_id in device.config_entries: + entry = hass.config_entries.async_get_entry(config_entry_id) + if ( + entry + and entry.domain == DOMAIN + and entry.state is ConfigEntryState.LOADED + and hasattr(entry, "runtime_data") + ): + return cast(NutConfigEntry, entry).runtime_data + + return None + + +def _get_runtime_data_from_device_id_exception_on_failure( + hass: HomeAssistant, + device_id: str, +) -> NutRuntimeData | None: + """Find the runtime data for device ID and raise exception on error.""" + device_registry = dr.async_get(hass) + if (device := device_registry.async_get(device_id)) is None: + raise InvalidDeviceAutomationConfig( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + + if runtime_data := _get_runtime_data_for_device(hass, device): + return runtime_data + + raise InvalidDeviceAutomationConfig( + translation_domain=DOMAIN, + translation_key="config_invalid", + translation_placeholders={ + "device_id": device_id, + }, ) - assert entry and isinstance(entry.runtime_data, NutRuntimeData) - return entry.runtime_data diff --git a/homeassistant/components/nut/entity.py b/homeassistant/components/nut/entity.py new file mode 100644 index 00000000000..e6536d8aad6 --- /dev/null +++ b/homeassistant/components/nut/entity.py @@ -0,0 +1,67 @@ +"""Base entity for the NUT integration.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import cast + +from homeassistant.const import ( + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + ATTR_SW_VERSION, +) +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import PyNUTData +from .const import DOMAIN + +NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { + "manufacturer": ATTR_MANUFACTURER, + "model": ATTR_MODEL, + "firmware": ATTR_SW_VERSION, + "serial": ATTR_SERIAL_NUMBER, +} + + +class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): + """NUT base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator, + entity_description: EntityDescription, + data: PyNUTData, + unique_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_unique_id = f"{unique_id}_{entity_description.key}" + + self.pynut_data = data + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=self.pynut_data.device_name, + ) + self._attr_device_info.update(_get_nut_device_info(data)) + + +def _get_nut_device_info(data: PyNUTData) -> DeviceInfo: + """Return a DeviceInfo object filled with NUT device info.""" + nut_dev_infos = asdict(data.device_info) + nut_infos = { + info_key: nut_dev_infos[nut_key] + for nut_key, info_key in NUT_DEV_INFO_TO_DEV_INFO.items() + if nut_dev_infos[nut_key] is not None + } + + return cast(DeviceInfo, nut_infos) diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index 91df9d10553..ae87c955164 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -37,17 +37,38 @@ "battery_packs_bad": { "default": "mdi:information-outline" }, + "battery_runtime": { + "default": "mdi:clock-outline" + }, + "battery_runtime_low": { + "default": "mdi:clock-alert-outline" + }, + "battery_runtime_restart": { + "default": "mdi:clock-start" + }, "battery_type": { "default": "mdi:information-outline" }, + "battery_voltage_high": { + "default": "mdi:battery-high" + }, + "battery_voltage_low": { + "default": "mdi:battery-low" + }, "input_bypass_phases": { + "default": "mdi:sine-wave" + }, + "input_current_status": { "default": "mdi:information-outline" }, "input_frequency_status": { "default": "mdi:information-outline" }, + "input_load": { + "default": "mdi:percent-box-outline" + }, "input_phases": { - "default": "mdi:information-outline" + "default": "mdi:sine-wave" }, "input_sensitivity": { "default": "mdi:information-outline" @@ -55,17 +76,26 @@ "input_transfer_reason": { "default": "mdi:information-outline" }, + "input_voltage_status": { + "default": "mdi:information-outline" + }, + "outlet_number_current_status": { + "default": "mdi:information-outline" + }, + "outlet_number_desc": { + "default": "mdi:information-outline" + }, "output_l1_power_percent": { - "default": "mdi:gauge" + "default": "mdi:percent-circle-outline" }, "output_l2_power_percent": { - "default": "mdi:gauge" + "default": "mdi:percent-circle-outline" }, "output_l3_power_percent": { - "default": "mdi:gauge" + "default": "mdi:percent-circle-outline" }, "output_phases": { - "default": "mdi:information-outline" + "default": "mdi:sine-wave" }, "ups_alarm": { "default": "mdi:alarm" @@ -76,20 +106,29 @@ "ups_contacts": { "default": "mdi:information-outline" }, + "ups_delay_reboot": { + "default": "mdi:timelapse" + }, + "ups_delay_shutdown": { + "default": "mdi:timelapse" + }, + "ups_delay_start": { + "default": "mdi:timelapse" + }, "ups_display_language": { "default": "mdi:information-outline" }, "ups_efficiency": { - "default": "mdi:gauge" + "default": "mdi:percent-outline" }, "ups_id": { "default": "mdi:information-outline" }, "ups_load": { - "default": "mdi:gauge" + "default": "mdi:percent-box-outline" }, "ups_load_high": { - "default": "mdi:gauge" + "default": "mdi:percent-box-outline" }, "ups_shutdown": { "default": "mdi:information-outline" @@ -112,15 +151,32 @@ "ups_test_date": { "default": "mdi:calendar" }, + "ups_test_interval": { + "default": "mdi:timelapse" + }, "ups_test_result": { "default": "mdi:information-outline" }, + "ups_timer_reboot": { + "default": "mdi:timer-refresh-outline" + }, + "ups_timer_shutdown": { + "default": "mdi:timer-stop-outline" + }, + "ups_timer_start": { + "default": "mdi:timer-play-outline" + }, "ups_type": { "default": "mdi:information-outline" }, "ups_watchdog_status": { "default": "mdi:information-outline" } + }, + "switch": { + "outlet_number_load_poweronoff": { + "default": "mdi:power" + } } } } diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 2f574ec4842..ce8c10f8f41 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -1,10 +1,9 @@ -"""Provides a sensor to track various status aspects of a UPS.""" +"""Provides a sensor to track various status aspects of a NUT device.""" from __future__ import annotations -from dataclasses import asdict import logging -from typing import Final, cast +from typing import Final from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,12 +12,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SERIAL_NUMBER, - ATTR_SW_VERSION, PERCENTAGE, - STATE_UNKNOWN, EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, @@ -29,80 +23,806 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) -from . import NutConfigEntry, PyNUTData -from .const import DOMAIN, KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES +from . import NutConfigEntry +from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES +from .entity import NUTBaseEntity -NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { - "manufacturer": ATTR_MANUFACTURER, - "model": ATTR_MODEL, - "firmware": ATTR_SW_VERSION, - "serial": ATTR_SERIAL_NUMBER, +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +AMBIENT_PRESENT = "ambient.present" +AMBIENT_SENSORS = { + "ambient.humidity", + "ambient.humidity.status", + "ambient.temperature", + "ambient.temperature.status", } - -AMBIENT_THRESHOLD_STATUS_OPTIONS = [ +BATTERY_CHARGER_STATUS_OPTIONS = [ + "charging", + "discharging", + "floating", + "resting", + "unknown", + "disabled", + "off", +] +FREQUENCY_STATUS_OPTIONS = [ + "good", + "out-of-range", +] +THRESHOLD_STATUS_OPTIONS = [ "good", "warning-low", "critical-low", "warning-high", "critical-high", ] +UPS_BEEPER_STATUS_OPTIONS = [ + "enabled", + "disabled", + "muted", +] _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { - "ups.status.display": SensorEntityDescription( - key="ups.status.display", - translation_key="ups_status_display", + "ambient.humidity": SensorEntityDescription( + key="ambient.humidity", + translation_key="ambient_humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), - "ups.status": SensorEntityDescription( - key="ups.status", - translation_key="ups_status", + "ambient.humidity.status": SensorEntityDescription( + key="ambient.humidity.status", + translation_key="ambient_humidity_status", + device_class=SensorDeviceClass.ENUM, + options=THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, ), - "ups.alarm": SensorEntityDescription( - key="ups.alarm", - translation_key="ups_alarm", + "ambient.temperature": SensorEntityDescription( + key="ambient.temperature", + translation_key="ambient_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), - "ups.temperature": SensorEntityDescription( - key="ups.temperature", - translation_key="ups_temperature", + "ambient.temperature.status": SensorEntityDescription( + key="ambient.temperature.status", + translation_key="ambient_temperature_status", + device_class=SensorDeviceClass.ENUM, + options=THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "battery.alarm.threshold": SensorEntityDescription( + key="battery.alarm.threshold", + translation_key="battery_alarm_threshold", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.capacity": SensorEntityDescription( + key="battery.capacity", + translation_key="battery_capacity", + native_unit_of_measurement="Ah", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charge": SensorEntityDescription( + key="battery.charge", + translation_key="battery_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + "battery.charge.low": SensorEntityDescription( + key="battery.charge.low", + translation_key="battery_charge_low", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charge.restart": SensorEntityDescription( + key="battery.charge.restart", + translation_key="battery_charge_restart", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charge.warning": SensorEntityDescription( + key="battery.charge.warning", + translation_key="battery_charge_warning", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charger.status": SensorEntityDescription( + key="battery.charger.status", + translation_key="battery_charger_status", + device_class=SensorDeviceClass.ENUM, + options=BATTERY_CHARGER_STATUS_OPTIONS, + ), + "battery.current": SensorEntityDescription( + key="battery.current", + translation_key="battery_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.current.total": SensorEntityDescription( + key="battery.current.total", + translation_key="battery_current_total", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.date": SensorEntityDescription( + key="battery.date", + translation_key="battery_date", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.mfr.date": SensorEntityDescription( + key="battery.mfr.date", + translation_key="battery_mfr_date", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.packs": SensorEntityDescription( + key="battery.packs", + translation_key="battery_packs", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.packs.bad": SensorEntityDescription( + key="battery.packs.bad", + translation_key="battery_packs_bad", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.runtime": SensorEntityDescription( + key="battery.runtime", + translation_key="battery_runtime", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.runtime.low": SensorEntityDescription( + key="battery.runtime.low", + translation_key="battery_runtime_low", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.runtime.restart": SensorEntityDescription( + key="battery.runtime.restart", + translation_key="battery_runtime_restart", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.temperature": SensorEntityDescription( + key="battery.temperature", + translation_key="battery_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.load": SensorEntityDescription( - key="ups.load", - translation_key="ups_load", + "battery.type": SensorEntityDescription( + key="battery.type", + translation_key="battery_type", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage": SensorEntityDescription( + key="battery.voltage", + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage.high": SensorEntityDescription( + key="battery.voltage.high", + translation_key="battery_voltage_high", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage.low": SensorEntityDescription( + key="battery.voltage.low", + translation_key="battery_voltage_low", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage.nominal": SensorEntityDescription( + key="battery.voltage.nominal", + translation_key="battery_voltage_nominal", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.current": SensorEntityDescription( + key="input.bypass.current", + translation_key="input_bypass_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.frequency": SensorEntityDescription( + key="input.bypass.frequency", + translation_key="input_bypass_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.current": SensorEntityDescription( + key="input.bypass.L1.current", + translation_key="input_bypass_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1-N.voltage": SensorEntityDescription( + key="input.bypass.L1-N.voltage", + translation_key="input_bypass_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.realpower": SensorEntityDescription( + key="input.bypass.L1.realpower", + translation_key="input_bypass_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.current": SensorEntityDescription( + key="input.bypass.L2.current", + translation_key="input_bypass_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2-N.voltage": SensorEntityDescription( + key="input.bypass.L2-N.voltage", + translation_key="input_bypass_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.realpower": SensorEntityDescription( + key="input.bypass.L2.realpower", + translation_key="input_bypass_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.current": SensorEntityDescription( + key="input.bypass.L3.current", + translation_key="input_bypass_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3-N.voltage": SensorEntityDescription( + key="input.bypass.L3-N.voltage", + translation_key="input_bypass_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.realpower": SensorEntityDescription( + key="input.bypass.L3.realpower", + translation_key="input_bypass_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.phases": SensorEntityDescription( + key="input.bypass.phases", + translation_key="input_bypass_phases", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.realpower": SensorEntityDescription( + key="input.bypass.realpower", + translation_key="input_bypass_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.voltage": SensorEntityDescription( + key="input.bypass.voltage", + translation_key="input_bypass_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.current": SensorEntityDescription( + key="input.current", + translation_key="input_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "input.current.status": SensorEntityDescription( + key="input.current.status", + translation_key="input_current_status", + device_class=SensorDeviceClass.ENUM, + options=THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.frequency": SensorEntityDescription( + key="input.frequency", + translation_key="input_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.frequency.nominal": SensorEntityDescription( + key="input.frequency.nominal", + translation_key="input_frequency_nominal", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.frequency.status": SensorEntityDescription( + key="input.frequency.status", + translation_key="input_frequency_status", + device_class=SensorDeviceClass.ENUM, + options=FREQUENCY_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1.current": SensorEntityDescription( + key="input.L1.current", + translation_key="input_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1.frequency": SensorEntityDescription( + key="input.L1.frequency", + translation_key="input_l1_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1-N.voltage": SensorEntityDescription( + key="input.L1-N.voltage", + translation_key="input_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1.realpower": SensorEntityDescription( + key="input.L1.realpower", + translation_key="input_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.current": SensorEntityDescription( + key="input.L2.current", + translation_key="input_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.frequency": SensorEntityDescription( + key="input.L2.frequency", + translation_key="input_l2_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2-N.voltage": SensorEntityDescription( + key="input.L2-N.voltage", + translation_key="input_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.realpower": SensorEntityDescription( + key="input.L2.realpower", + translation_key="input_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.current": SensorEntityDescription( + key="input.L3.current", + translation_key="input_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.frequency": SensorEntityDescription( + key="input.L3.frequency", + translation_key="input_l3_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3-N.voltage": SensorEntityDescription( + key="input.L3-N.voltage", + translation_key="input_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.realpower": SensorEntityDescription( + key="input.L3.realpower", + translation_key="input_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.load": SensorEntityDescription( + key="input.load", + translation_key="input_load", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - "ups.load.high": SensorEntityDescription( - key="ups.load.high", - translation_key="ups_load_high", + "input.phases": SensorEntityDescription( + key="input.phases", + translation_key="input_phases", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.power": SensorEntityDescription( + key="input.power", + translation_key="input_power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.realpower": SensorEntityDescription( + key="input.realpower", + translation_key="input_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.sensitivity": SensorEntityDescription( + key="input.sensitivity", + translation_key="input_sensitivity", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.transfer.high": SensorEntityDescription( + key="input.transfer.high", + translation_key="input_transfer_high", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.transfer.low": SensorEntityDescription( + key="input.transfer.low", + translation_key="input_transfer_low", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.transfer.reason": SensorEntityDescription( + key="input.transfer.reason", + translation_key="input_transfer_reason", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.voltage": SensorEntityDescription( + key="input.voltage", + translation_key="input_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "input.voltage.nominal": SensorEntityDescription( + key="input.voltage.nominal", + translation_key="input_voltage_nominal", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.voltage.status": SensorEntityDescription( + key="input.voltage.status", + translation_key="input_voltage_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "outlet.voltage": SensorEntityDescription( + key="outlet.voltage", + translation_key="outlet_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "output.current": SensorEntityDescription( + key="output.current", + translation_key="output_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.current.nominal": SensorEntityDescription( + key="output.current.nominal", + translation_key="output_current_nominal", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.frequency": SensorEntityDescription( + key="output.frequency", + translation_key="output_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.frequency.nominal": SensorEntityDescription( + key="output.frequency.nominal", + translation_key="output_frequency_nominal", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L1.current": SensorEntityDescription( + key="output.L1.current", + translation_key="output_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L1-N.voltage": SensorEntityDescription( + key="output.L1-N.voltage", + translation_key="output_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L1.power.percent": SensorEntityDescription( + key="output.L1.power.percent", + translation_key="output_l1_power_percent", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.id": SensorEntityDescription( - key="ups.id", - translation_key="ups_id", + "output.L1.realpower": SensorEntityDescription( + key="output.L1.realpower", + translation_key="output_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.delay.start": SensorEntityDescription( - key="ups.delay.start", - translation_key="ups_delay_start", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, + "output.L2.current": SensorEntityDescription( + key="output.L2.current", + translation_key="output_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2-N.voltage": SensorEntityDescription( + key="output.L2-N.voltage", + translation_key="output_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.power.percent": SensorEntityDescription( + key="output.L2.power.percent", + translation_key="output_l2_power_percent", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.realpower": SensorEntityDescription( + key="output.L2.realpower", + translation_key="output_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.current": SensorEntityDescription( + key="output.L3.current", + translation_key="output_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3-N.voltage": SensorEntityDescription( + key="output.L3-N.voltage", + translation_key="output_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.power.percent": SensorEntityDescription( + key="output.L3.power.percent", + translation_key="output_l3_power_percent", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.realpower": SensorEntityDescription( + key="output.L3.realpower", + translation_key="output_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.phases": SensorEntityDescription( + key="output.phases", + translation_key="output_phases", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.power": SensorEntityDescription( + key="output.power", + translation_key="output_power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.power.nominal": SensorEntityDescription( + key="output.power.nominal", + translation_key="output_power_nominal", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.realpower": SensorEntityDescription( + key="output.realpower", + translation_key="output_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.realpower.nominal": SensorEntityDescription( + key="output.realpower.nominal", + translation_key="output_realpower_nominal", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.voltage": SensorEntityDescription( + key="output.voltage", + translation_key="output_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "output.voltage.nominal": SensorEntityDescription( + key="output.voltage.nominal", + translation_key="output_voltage_nominal", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.alarm": SensorEntityDescription( + key="ups.alarm", + translation_key="ups_alarm", + ), + "ups.beeper.status": SensorEntityDescription( + key="ups.beeper.status", + translation_key="ups_beeper_status", + device_class=SensorDeviceClass.ENUM, + options=UPS_BEEPER_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.contacts": SensorEntityDescription( + key="ups.contacts", + translation_key="ups_contacts", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -122,62 +842,20 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.timer.start": SensorEntityDescription( - key="ups.timer.start", - translation_key="ups_timer_start", + "ups.delay.start": SensorEntityDescription( + key="ups.delay.start", + translation_key="ups_delay_start", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.timer.reboot": SensorEntityDescription( - key="ups.timer.reboot", - translation_key="ups_timer_reboot", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.timer.shutdown": SensorEntityDescription( - key="ups.timer.shutdown", - translation_key="ups_timer_shutdown", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.test.interval": SensorEntityDescription( - key="ups.test.interval", - translation_key="ups_test_interval", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.test.result": SensorEntityDescription( - key="ups.test.result", - translation_key="ups_test_result", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.test.date": SensorEntityDescription( - key="ups.test.date", - translation_key="ups_test_date", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), "ups.display.language": SensorEntityDescription( key="ups.display.language", translation_key="ups_display_language", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.contacts": SensorEntityDescription( - key="ups.contacts", - translation_key="ups_contacts", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), "ups.efficiency": SensorEntityDescription( key="ups.efficiency", translation_key="ups_efficiency", @@ -186,6 +864,25 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "ups.id": SensorEntityDescription( + key="ups.id", + translation_key="ups_id", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.load": SensorEntityDescription( + key="ups.load", + translation_key="ups_load", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "ups.load.high": SensorEntityDescription( + key="ups.load.high", + translation_key="ups_load_high", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "ups.power": SensorEntityDescription( key="ups.power", translation_key="ups_power", @@ -220,21 +917,9 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.beeper.status": SensorEntityDescription( - key="ups.beeper.status", - translation_key="ups_beeper_status", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.type": SensorEntityDescription( - key="ups.type", - translation_key="ups_type", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.watchdog.status": SensorEntityDescription( - key="ups.watchdog.status", - translation_key="ups_watchdog_status", + "ups.shutdown": SensorEntityDescription( + key="ups.shutdown", + translation_key="ups_shutdown", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -256,739 +941,89 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.shutdown": SensorEntityDescription( - key="ups.shutdown", - translation_key="ups_shutdown", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, + "ups.status": SensorEntityDescription( + key="ups.status", + translation_key="ups_status", ), - "battery.charge": SensorEntityDescription( - key="battery.charge", - translation_key="battery_charge", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.BATTERY, - state_class=SensorStateClass.MEASUREMENT, + "ups.status.display": SensorEntityDescription( + key="ups.status.display", + translation_key="ups_status_display", ), - "battery.charge.low": SensorEntityDescription( - key="battery.charge.low", - translation_key="battery_charge_low", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.charge.restart": SensorEntityDescription( - key="battery.charge.restart", - translation_key="battery_charge_restart", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.charge.warning": SensorEntityDescription( - key="battery.charge.warning", - translation_key="battery_charge_warning", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.charger.status": SensorEntityDescription( - key="battery.charger.status", - translation_key="battery_charger_status", - ), - "battery.voltage": SensorEntityDescription( - key="battery.voltage", - translation_key="battery_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.voltage.nominal": SensorEntityDescription( - key="battery.voltage.nominal", - translation_key="battery_voltage_nominal", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.voltage.low": SensorEntityDescription( - key="battery.voltage.low", - translation_key="battery_voltage_low", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.voltage.high": SensorEntityDescription( - key="battery.voltage.high", - translation_key="battery_voltage_high", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.capacity": SensorEntityDescription( - key="battery.capacity", - translation_key="battery_capacity", - native_unit_of_measurement="Ah", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.current": SensorEntityDescription( - key="battery.current", - translation_key="battery_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.current.total": SensorEntityDescription( - key="battery.current.total", - translation_key="battery_current_total", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.temperature": SensorEntityDescription( - key="battery.temperature", - translation_key="battery_temperature", + "ups.temperature": SensorEntityDescription( + key="ups.temperature", + translation_key="ups_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.runtime": SensorEntityDescription( - key="battery.runtime", - translation_key="battery_runtime", + "ups.test.date": SensorEntityDescription( + key="ups.test.date", + translation_key="ups_test_date", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.test.interval": SensorEntityDescription( + key="ups.test.interval", + translation_key="ups_test_interval", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.runtime.low": SensorEntityDescription( - key="battery.runtime.low", - translation_key="battery_runtime_low", + "ups.test.result": SensorEntityDescription( + key="ups.test.result", + translation_key="ups_test_result", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.timer.reboot": SensorEntityDescription( + key="ups.timer.reboot", + translation_key="ups_timer_reboot", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.runtime.restart": SensorEntityDescription( - key="battery.runtime.restart", - translation_key="battery_runtime_restart", + "ups.timer.shutdown": SensorEntityDescription( + key="ups.timer.shutdown", + translation_key="ups_timer_shutdown", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.alarm.threshold": SensorEntityDescription( - key="battery.alarm.threshold", - translation_key="battery_alarm_threshold", + "ups.timer.start": SensorEntityDescription( + key="ups.timer.start", + translation_key="ups_timer_start", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.date": SensorEntityDescription( - key="battery.date", - translation_key="battery_date", + "ups.type": SensorEntityDescription( + key="ups.type", + translation_key="ups_type", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.mfr.date": SensorEntityDescription( - key="battery.mfr.date", - translation_key="battery_mfr_date", + "ups.watchdog.status": SensorEntityDescription( + key="ups.watchdog.status", + translation_key="ups_watchdog_status", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.packs": SensorEntityDescription( - key="battery.packs", - translation_key="battery_packs", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.packs.bad": SensorEntityDescription( - key="battery.packs.bad", - translation_key="battery_packs_bad", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.type": SensorEntityDescription( - key="battery.type", - translation_key="battery_type", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.sensitivity": SensorEntityDescription( - key="input.sensitivity", - translation_key="input_sensitivity", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.transfer.low": SensorEntityDescription( - key="input.transfer.low", - translation_key="input_transfer_low", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.transfer.high": SensorEntityDescription( - key="input.transfer.high", - translation_key="input_transfer_high", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.transfer.reason": SensorEntityDescription( - key="input.transfer.reason", - translation_key="input_transfer_reason", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.voltage": SensorEntityDescription( - key="input.voltage", - translation_key="input_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "input.voltage.nominal": SensorEntityDescription( - key="input.voltage.nominal", - translation_key="input_voltage_nominal", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L1-N.voltage": SensorEntityDescription( - key="input.L1-N.voltage", - translation_key="input_l1_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2-N.voltage": SensorEntityDescription( - key="input.L2-N.voltage", - translation_key="input_l2_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3-N.voltage": SensorEntityDescription( - key="input.L3-N.voltage", - translation_key="input_l3_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.frequency": SensorEntityDescription( - key="input.frequency", - translation_key="input_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.frequency.nominal": SensorEntityDescription( - key="input.frequency.nominal", - translation_key="input_frequency_nominal", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.frequency.status": SensorEntityDescription( - key="input.frequency.status", - translation_key="input_frequency_status", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L1.frequency": SensorEntityDescription( - key="input.L1.frequency", - translation_key="input_l1_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2.frequency": SensorEntityDescription( - key="input.L2.frequency", - translation_key="input_l2_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3.frequency": SensorEntityDescription( - key="input.L3.frequency", - translation_key="input_l3_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.current": SensorEntityDescription( - key="input.bypass.current", - translation_key="input_bypass_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L1.current": SensorEntityDescription( - key="input.bypass.L1.current", - translation_key="input_bypass_l1_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L2.current": SensorEntityDescription( - key="input.bypass.L2.current", - translation_key="input_bypass_l2_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L3.current": SensorEntityDescription( - key="input.bypass.L3.current", - translation_key="input_bypass_l3_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.frequency": SensorEntityDescription( - key="input.bypass.frequency", - translation_key="input_bypass_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.phases": SensorEntityDescription( - key="input.bypass.phases", - translation_key="input_bypass_phases", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.realpower": SensorEntityDescription( - key="input.bypass.realpower", - translation_key="input_bypass_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L1.realpower": SensorEntityDescription( - key="input.bypass.L1.realpower", - translation_key="input_bypass_l1_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L2.realpower": SensorEntityDescription( - key="input.bypass.L2.realpower", - translation_key="input_bypass_l2_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L3.realpower": SensorEntityDescription( - key="input.bypass.L3.realpower", - translation_key="input_bypass_l3_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.voltage": SensorEntityDescription( - key="input.bypass.voltage", - translation_key="input_bypass_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L1-N.voltage": SensorEntityDescription( - key="input.bypass.L1-N.voltage", - translation_key="input_bypass_l1_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L2-N.voltage": SensorEntityDescription( - key="input.bypass.L2-N.voltage", - translation_key="input_bypass_l2_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L3-N.voltage": SensorEntityDescription( - key="input.bypass.L3-N.voltage", - translation_key="input_bypass_l3_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.current": SensorEntityDescription( - key="input.current", - translation_key="input_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - "input.L1.current": SensorEntityDescription( - key="input.L1.current", - translation_key="input_l1_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2.current": SensorEntityDescription( - key="input.L2.current", - translation_key="input_l2_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3.current": SensorEntityDescription( - key="input.L3.current", - translation_key="input_l3_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.phases": SensorEntityDescription( - key="input.phases", - translation_key="input_phases", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.realpower": SensorEntityDescription( - key="input.realpower", - translation_key="input_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L1.realpower": SensorEntityDescription( - key="input.L1.realpower", - translation_key="input_l1_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2.realpower": SensorEntityDescription( - key="input.L2.realpower", - translation_key="input_l2_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3.realpower": SensorEntityDescription( - key="input.L3.realpower", - translation_key="input_l3_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.power.nominal": SensorEntityDescription( - key="output.power.nominal", - translation_key="output_power_nominal", - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - device_class=SensorDeviceClass.APPARENT_POWER, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1.power.percent": SensorEntityDescription( - key="output.L1.power.percent", - translation_key="output_l1_power_percent", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2.power.percent": SensorEntityDescription( - key="output.L2.power.percent", - translation_key="output_l2_power_percent", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3.power.percent": SensorEntityDescription( - key="output.L3.power.percent", - translation_key="output_l3_power_percent", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.current": SensorEntityDescription( - key="output.current", - translation_key="output_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.current.nominal": SensorEntityDescription( - key="output.current.nominal", - translation_key="output_current_nominal", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1.current": SensorEntityDescription( - key="output.L1.current", - translation_key="output_l1_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2.current": SensorEntityDescription( - key="output.L2.current", - translation_key="output_l2_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3.current": SensorEntityDescription( - key="output.L3.current", - translation_key="output_l3_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.voltage": SensorEntityDescription( - key="output.voltage", - translation_key="output_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "output.voltage.nominal": SensorEntityDescription( - key="output.voltage.nominal", - translation_key="output_voltage_nominal", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1-N.voltage": SensorEntityDescription( - key="output.L1-N.voltage", - translation_key="output_l1_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2-N.voltage": SensorEntityDescription( - key="output.L2-N.voltage", - translation_key="output_l2_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3-N.voltage": SensorEntityDescription( - key="output.L3-N.voltage", - translation_key="output_l3_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.frequency": SensorEntityDescription( - key="output.frequency", - translation_key="output_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.frequency.nominal": SensorEntityDescription( - key="output.frequency.nominal", - translation_key="output_frequency_nominal", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.phases": SensorEntityDescription( - key="output.phases", - translation_key="output_phases", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.power": SensorEntityDescription( - key="output.power", - translation_key="output_power", - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - device_class=SensorDeviceClass.APPARENT_POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.realpower": SensorEntityDescription( - key="output.realpower", - translation_key="output_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.realpower.nominal": SensorEntityDescription( - key="output.realpower.nominal", - translation_key="output_realpower_nominal", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1.realpower": SensorEntityDescription( - key="output.L1.realpower", - translation_key="output_l1_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2.realpower": SensorEntityDescription( - key="output.L2.realpower", - translation_key="output_l2_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3.realpower": SensorEntityDescription( - key="output.L3.realpower", - translation_key="output_l3_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ambient.humidity": SensorEntityDescription( - key="ambient.humidity", - translation_key="ambient_humidity", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "ambient.humidity.status": SensorEntityDescription( - key="ambient.humidity.status", - translation_key="ambient_humidity_status", - device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "ambient.temperature": SensorEntityDescription( - key="ambient.temperature", - translation_key="ambient_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "ambient.temperature.status": SensorEntityDescription( - key="ambient.temperature.status", - translation_key="ambient_temperature_status", - device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "watts": SensorEntityDescription( - key="watts", - translation_key="watts", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), } -def _get_nut_device_info(data: PyNUTData) -> DeviceInfo: - """Return a DeviceInfo object filled with NUT device info.""" - nut_dev_infos = asdict(data.device_info) - nut_infos = { - info_key: nut_dev_infos[nut_key] - for nut_key, info_key in NUT_DEV_INFO_TO_DEV_INFO.items() - if nut_dev_infos[nut_key] is not None - } - - return cast(DeviceInfo, nut_infos) - - async def async_setup_entry( hass: HomeAssistant, config_entry: NutConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NUT sensors.""" + valid_sensor_types: dict[str, SensorEntityDescription] pynut_data = config_entry.runtime_data coordinator = pynut_data.coordinator @@ -996,16 +1031,75 @@ async def async_setup_entry( unique_id = pynut_data.unique_id status = coordinator.data - resources = [sensor_id for sensor_id in SENSOR_TYPES if sensor_id in status] + # Dynamically add outlet sensors to valid sensors dictionary + if (num_outlets := status.get("outlet.count")) is not None: + additional_sensor_types: dict[str, SensorEntityDescription] = {} + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str: str = str(outlet_num) + outlet_name: str = ( + status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str + ) + additional_sensor_types |= { + f"outlet.{outlet_num_str}.current": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.current", + translation_key="outlet_number_current", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + f"outlet.{outlet_num_str}.current_status": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.current_status", + translation_key="outlet_number_current_status", + translation_placeholders={"outlet_name": outlet_name}, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + f"outlet.{outlet_num_str}.desc": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.desc", + translation_key="outlet_number_desc", + translation_placeholders={"outlet_name": outlet_name}, + ), + f"outlet.{outlet_num_str}.power": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.power", + translation_key="outlet_number_power", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + f"outlet.{outlet_num_str}.realpower": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.realpower", + translation_key="outlet_number_realpower", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + } + + valid_sensor_types = {**SENSOR_TYPES, **additional_sensor_types} + else: + valid_sensor_types = SENSOR_TYPES + + # If device reports ambient sensors are not present, then remove + has_ambient_sensors: bool = status.get(AMBIENT_PRESENT) != "no" + resources = [ + sensor_id + for sensor_id in valid_sensor_types + if sensor_id in status + and (has_ambient_sensors or sensor_id not in AMBIENT_SENSORS) + ] + # Display status is a special case that falls back to the status value # of the UPS instead. - if KEY_STATUS in resources: + if KEY_STATUS in status: resources.append(KEY_STATUS_DISPLAY) async_add_entities( NUTSensor( coordinator, - SENSOR_TYPES[sensor_type], + valid_sensor_types[sensor_type], data, unique_id, ) @@ -1013,42 +1107,21 @@ async def async_setup_entry( ) -class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], SensorEntity): +class NUTSensor(NUTBaseEntity, SensorEntity): """Representation of a sensor entity for NUT status values.""" - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, str]], - sensor_description: SensorEntityDescription, - data: PyNUTData, - unique_id: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.entity_description = sensor_description - - device_name = data.name.title() - self._attr_unique_id = f"{unique_id}_{sensor_description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - name=device_name, - ) - self._attr_device_info.update(_get_nut_device_info(data)) - @property def native_value(self) -> str | None: - """Return entity state from ups.""" + """Return entity state from NUT device.""" status = self.coordinator.data if self.entity_description.key == KEY_STATUS_DISPLAY: return _format_display_state(status) return status.get(self.entity_description.key) -def _format_display_state(status: dict[str, str]) -> str: +def _format_display_state(status: dict[str, str]) -> str | None: """Return UPS display state.""" try: - return " ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split()) + return ", ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split()) except KeyError: - return STATE_UNKNOWN + return None diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index b9485a320fb..a9a3b470cca 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -10,13 +10,19 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your NUT server." + "host": "The IP address or hostname of your NUT server.", + "port": "The network port of your NUT server. The NUT server's default port is '3493'.", + "username": "The username to sign in to your NUT server. The username is optional.", + "password": "The password to sign in to your NUT server. The password is optional." } }, "ups": { - "title": "Choose the UPS to Monitor", + "title": "Choose the NUT server UPS to monitor", "data": { - "alias": "Alias" + "alias": "NUT server UPS name" + }, + "data_description": { + "alias": "The UPS name configured on the NUT server." } }, "reauth_confirm": { @@ -24,27 +30,48 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::nut::config::step::user::data_description::username%]", + "password": "[%key:component::nut::config::step::user::data_description::password%]" + } + }, + "reconfigure": { + "description": "[%key:component::nut::config::step::user::description%]", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "[%key:component::nut::config::step::user::data_description::host%]", + "port": "[%key:component::nut::config::step::user::data_description::port%]", + "username": "[%key:component::nut::config::step::user::data_description::username%]", + "password": "[%key:component::nut::config::step::user::data_description::password%]" + } + }, + "reconfigure_ups": { + "title": "[%key:component::nut::config::step::ups::title%]", + "data": { + "alias": "[%key:component::nut::config::step::ups::data::alias%]" + }, + "data_description": { + "alias": "[%key:component::nut::config::step::ups::data_description::alias%]" } } }, "error": { "cannot_connect": "Connection error: {error}", - "unknown": "[%key:common::config_flow::error::unknown%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "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%]", "no_ups_found": "There are no UPS devices available on the NUT server.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Scan Interval (seconds)" - } - } + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device's manufacturer, model and serial number identifier does not match the previous identifier." } }, "device_automation": { @@ -78,24 +105,62 @@ } }, "entity": { + "button": { + "outlet_number_load_cycle": { "name": "Power cycle outlet {outlet_name}" } + }, "sensor": { "ambient_humidity": { "name": "Ambient humidity" }, - "ambient_humidity_status": { "name": "Ambient humidity status" }, + "ambient_humidity_status": { + "name": "Ambient humidity status", + "state": { + "good": "Good", + "warning-low": "Warning low", + "critical-low": "Critical low", + "warning-high": "Warning high", + "critical-high": "Critical high" + } + }, "ambient_temperature": { "name": "Ambient temperature" }, - "ambient_temperature_status": { "name": "Ambient temperature status" }, + "ambient_temperature_status": { + "name": "Ambient temperature status", + "state": { + "good": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::good%]", + "warning-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-low%]", + "critical-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-low%]", + "warning-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-high%]", + "critical-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-high%]" + } + }, "battery_alarm_threshold": { "name": "Battery alarm threshold" }, "battery_capacity": { "name": "Battery capacity" }, "battery_charge": { "name": "Battery charge" }, "battery_charge_low": { "name": "Low battery setpoint" }, "battery_charge_restart": { "name": "Minimum battery to start" }, "battery_charge_warning": { "name": "Warning battery setpoint" }, - "battery_charger_status": { "name": "Charging status" }, + "battery_charger_status": { + "name": "Charging status", + "state": { + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "floating": "Floating", + "resting": "Resting", + "unknown": "Unknown", + "disabled": "[%key:common::state::disabled%]", + "off": "[%key:common::state::off%]" + } + }, "battery_current": { "name": "Battery current" }, "battery_current_total": { "name": "Total battery current" }, "battery_date": { "name": "Battery date" }, "battery_mfr_date": { "name": "Battery manuf. date" }, - "battery_packs": { "name": "Number of batteries" }, - "battery_packs_bad": { "name": "Number of bad batteries" }, + "battery_packs": { + "name": "Number of batteries", + "unit_of_measurement": "packs" + }, + "battery_packs_bad": { + "name": "Number of bad batteries", + "unit_of_measurement": "packs" + }, "battery_runtime": { "name": "Battery runtime" }, "battery_runtime_low": { "name": "Low battery runtime" }, "battery_runtime_restart": { "name": "Minimum battery runtime to start" }, @@ -106,74 +171,111 @@ "battery_voltage_low": { "name": "Low battery voltage" }, "battery_voltage_nominal": { "name": "Nominal battery voltage" }, "input_bypass_current": { "name": "Input bypass current" }, - "input_bypass_l1_current": { "name": "Input bypass L1 current" }, - "input_bypass_l2_current": { "name": "Input bypass L2 current" }, - "input_bypass_l3_current": { "name": "Input bypass L3 current" }, - "input_bypass_voltage": { "name": "Input bypass voltage" }, - "input_bypass_l1_n_voltage": { "name": "Input bypass L1-N voltage" }, - "input_bypass_l2_n_voltage": { "name": "Input bypass L2-N voltage" }, - "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_l1_current": { "name": "Input bypass L1 current" }, + "input_bypass_l1_n_voltage": { "name": "Input bypass L1-N voltage" }, + "input_bypass_l1_realpower": { "name": "Input bypass L1 real power" }, + "input_bypass_l2_current": { "name": "Input bypass L2 current" }, + "input_bypass_l2_n_voltage": { "name": "Input bypass L2-N voltage" }, + "input_bypass_l2_realpower": { "name": "Input bypass L2 real power" }, + "input_bypass_l3_current": { "name": "Input bypass L3 current" }, + "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, + "input_bypass_l3_realpower": { "name": "Input bypass L3 real power" }, + "input_bypass_phases": { + "name": "Input bypass phases", + "unit_of_measurement": "phase" + }, "input_bypass_realpower": { "name": "Input bypass real power" }, - "input_bypass_l1_realpower": { - "name": "Input bypass L1 real power" - }, - "input_bypass_l2_realpower": { - "name": "Input bypass L2 real power" - }, - "input_bypass_l3_realpower": { - "name": "Input bypass L3 real power" - }, + "input_bypass_voltage": { "name": "Input bypass voltage" }, "input_current": { "name": "Input current" }, - "input_l1_current": { "name": "Input L1 current" }, - "input_l2_current": { "name": "Input L2 current" }, - "input_l3_current": { "name": "Input L3 current" }, + "input_current_status": { + "name": "Input current status", + "state": { + "good": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::good%]", + "warning-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-low%]", + "critical-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-low%]", + "warning-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-high%]", + "critical-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-high%]" + } + }, "input_frequency": { "name": "Input frequency" }, "input_frequency_nominal": { "name": "Input nominal frequency" }, - "input_frequency_status": { "name": "Input frequency status" }, + "input_frequency_status": { + "name": "Input frequency status", + "state": { + "good": "Good", + "out-of-range": "Out of range" + } + }, + "input_l1_current": { "name": "Input L1 current" }, "input_l1_frequency": { "name": "Input L1 line frequency" }, - "input_l2_frequency": { "name": "Input L2 line frequency" }, - "input_l3_frequency": { "name": "Input L3 line frequency" }, - "input_phases": { "name": "Input phases" }, - "input_realpower": { "name": "Input real power" }, + "input_l1_n_voltage": { "name": "Input L1 voltage" }, "input_l1_realpower": { "name": "Input L1 real power" }, + "input_l2_current": { "name": "Input L2 current" }, + "input_l2_frequency": { "name": "Input L2 line frequency" }, + "input_l2_n_voltage": { "name": "Input L2 voltage" }, "input_l2_realpower": { "name": "Input L2 real power" }, + "input_l3_current": { "name": "Input L3 current" }, + "input_l3_frequency": { "name": "Input L3 line frequency" }, + "input_l3_n_voltage": { "name": "Input L3 voltage" }, "input_l3_realpower": { "name": "Input L3 real power" }, + "input_load": { "name": "Input load" }, + "input_phases": { + "name": "Input phases", + "unit_of_measurement": "phase" + }, + "input_power": { "name": "Input power" }, + "input_realpower": { "name": "Input real power" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, "input_transfer_reason": { "name": "Voltage transfer reason" }, "input_voltage": { "name": "Input voltage" }, "input_voltage_nominal": { "name": "Nominal input voltage" }, - "input_l1_n_voltage": { "name": "Input L1 voltage" }, - "input_l2_n_voltage": { "name": "Input L2 voltage" }, - "input_l3_n_voltage": { "name": "Input L3 voltage" }, + "input_voltage_status": { "name": "Input voltage status" }, + "outlet_number_current": { "name": "Outlet {outlet_name} current" }, + "outlet_number_current_status": { + "name": "Outlet {outlet_name} current status" + }, + "outlet_number_desc": { "name": "Outlet {outlet_name} description" }, + "outlet_number_power": { "name": "Outlet {outlet_name} power" }, + "outlet_number_realpower": { "name": "Outlet {outlet_name} real power" }, + "outlet_voltage": { "name": "Outlet voltage" }, "output_current": { "name": "Output current" }, "output_current_nominal": { "name": "Nominal output current" }, - "output_l1_current": { "name": "Output L1 current" }, - "output_l2_current": { "name": "Output L2 current" }, - "output_l3_current": { "name": "Output L3 current" }, "output_frequency": { "name": "Output frequency" }, "output_frequency_nominal": { "name": "Nominal output frequency" }, - "output_phases": { "name": "Output phases" }, - "output_power": { "name": "Output apparent power" }, - "output_l2_power_percent": { "name": "Output L2 power usage" }, + "output_l1_current": { "name": "Output L1 current" }, + "output_l1_n_voltage": { "name": "Output L1-N voltage" }, "output_l1_power_percent": { "name": "Output L1 power usage" }, + "output_l1_realpower": { "name": "Output L1 real power" }, + "output_l2_current": { "name": "Output L2 current" }, + "output_l2_n_voltage": { "name": "Output L2-N voltage" }, + "output_l2_power_percent": { "name": "Output L2 power usage" }, + "output_l2_realpower": { "name": "Output L2 real power" }, + "output_l3_current": { "name": "Output L3 current" }, + "output_l3_n_voltage": { "name": "Output L3-N voltage" }, "output_l3_power_percent": { "name": "Output L3 power usage" }, + "output_l3_realpower": { "name": "Output L3 real power" }, + "output_phases": { + "name": "Output phases", + "unit_of_measurement": "phase" + }, + "output_power": { "name": "Output apparent power" }, "output_power_nominal": { "name": "Nominal output power" }, "output_realpower": { "name": "Output real power" }, "output_realpower_nominal": { "name": "Nominal output 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" }, - "output_l2_n_voltage": { "name": "Output L2-N voltage" }, - "output_l3_n_voltage": { "name": "Output L3-N voltage" }, "ups_alarm": { "name": "Alarms" }, - "ups_beeper_status": { "name": "Beeper status" }, + "ups_beeper_status": { + "name": "Beeper status", + "state": { + "enabled": "[%key:common::state::enabled%]", + "disabled": "[%key:common::state::disabled%]", + "muted": "Muted" + } + }, "ups_contacts": { "name": "External contacts" }, "ups_delay_reboot": { "name": "UPS reboot delay" }, "ups_delay_shutdown": { "name": "UPS shutdown delay" }, @@ -203,8 +305,27 @@ "ups_timer_shutdown": { "name": "Load shutdown timer" }, "ups_timer_start": { "name": "Load start timer" }, "ups_type": { "name": "UPS type" }, - "ups_watchdog_status": { "name": "Watchdog status" }, - "watts": { "name": "Watts" } + "ups_watchdog_status": { "name": "Watchdog status" } + }, + "switch": { + "outlet_number_load_poweronoff": { "name": "Power outlet {outlet_name}" } + } + }, + "exceptions": { + "config_invalid": { + "message": "Invalid configuration entries for NUT device with ID {device_id}" + }, + "data_fetch_error": { + "message": "Error fetching UPS state: {err}" + }, + "device_authentication": { + "message": "Device authentication error: {err}" + }, + "device_not_found": { + "message": "Unable to find a NUT device with ID {device_id}" + }, + "nut_command_error": { + "message": "Error running command {command_name}, {err}" } } } diff --git a/homeassistant/components/nut/switch.py b/homeassistant/components/nut/switch.py new file mode 100644 index 00000000000..924a596cc8e --- /dev/null +++ b/homeassistant/components/nut/switch.py @@ -0,0 +1,90 @@ +"""Provides a switch for switchable NUT outlets.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NutConfigEntry +from .entity import NUTBaseEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NutConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the NUT switches.""" + pynut_data = config_entry.runtime_data + coordinator = pynut_data.coordinator + status = coordinator.data + + # Dynamically add outlet switch types + if (num_outlets := status.get("outlet.count")) is None: + return + + data = pynut_data.data + unique_id = pynut_data.unique_id + user_available_commands = pynut_data.user_available_commands + switch_descriptions = [ + SwitchEntityDescription( + key=f"outlet.{outlet_num!s}.load.poweronoff", + translation_key="outlet_number_load_poweronoff", + translation_placeholders={ + "outlet_name": status.get(f"outlet.{outlet_num!s}.name") + or str(outlet_num) + }, + device_class=SwitchDeviceClass.OUTLET, + entity_registry_enabled_default=True, + ) + for outlet_num in range(1, int(num_outlets) + 1) + if ( + status.get(f"outlet.{outlet_num!s}.switchable") == "yes" + and f"outlet.{outlet_num!s}.load.on" in user_available_commands + and f"outlet.{outlet_num!s}.load.off" in user_available_commands + ) + ] + + async_add_entities( + NUTSwitch(coordinator, description, data, unique_id) + for description in switch_descriptions + ) + + +class NUTSwitch(NUTBaseEntity, SwitchEntity): + """Representation of a switch entity for NUT status values.""" + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + status = self.coordinator.data + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + if (state := status.get(f"{outlet}.{outlet_num_str}.status")) is None: + return None + return bool(state == "on") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + command_name = f"{outlet}.{outlet_num_str}.load.on" + await self.pynut_data.async_run_command(command_name) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + command_name = f"{outlet}.{outlet_num_str}.load.off" + await self.pynut_data.async_run_command(command_name) diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 4cfb3b85e0f..348d9ade7a3 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -2,9 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime -from types import MappingProxyType from typing import Any from homeassistant.components.sensor import ( @@ -115,6 +115,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, unit_convert=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), NWSSensorEntityDescription( key="barometricPressure", @@ -179,7 +180,7 @@ class NWSSensor(CoordinatorEntity[TimestampDataUpdateCoordinator[None]], SensorE def __init__( self, hass: HomeAssistant, - entry_data: MappingProxyType[str, Any], + entry_data: Mapping[str, Any], nws_data: NWSData, description: NWSSensorEntityDescription, station: str, diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index c90c67edcb7..c44869939ff 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping from functools import partial -from types import MappingProxyType from typing import Any, Required, TypedDict, cast import voluptuous as vol @@ -126,7 +126,7 @@ class ExtraForecast(TypedDict, total=False): short_description: str | None -def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> str: +def _calculate_unique_id(entry_data: Mapping[str, Any], mode: str) -> str: """Calculate unique ID.""" latitude = entry_data[CONF_LATITUDE] longitude = entry_data[CONF_LONGITUDE] @@ -148,7 +148,7 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]]) def __init__( self, - entry_data: MappingProxyType[str, Any], + entry_data: Mapping[str, Any], nws_data: NWSData, ) -> None: """Initialise the platform with a data instance and station name.""" diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 59fd04357eb..48d81b81f0c 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -181,11 +181,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = aiohttp.ClientSession(connector=connector) @callback - def _async_close_websession(event: Event) -> None: + def _async_close_websession(event: Event | None = None) -> None: """Close websession.""" session.detach() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_websession) + entry.async_on_unload(_async_close_websession) + entry.async_on_unload( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_close_websession) + ) client = OctoprintClient( host=entry.data[CONF_HOST], diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 010b45e5a1c..e20eea0a61f 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -85,7 +85,8 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): raise err from None except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" if errors: diff --git a/homeassistant/components/ogemray/__init__.py b/homeassistant/components/ogemray/__init__.py new file mode 100644 index 00000000000..94e19234a6b --- /dev/null +++ b/homeassistant/components/ogemray/__init__.py @@ -0,0 +1 @@ +"""Ogemray virtual integration.""" diff --git a/homeassistant/components/ogemray/manifest.json b/homeassistant/components/ogemray/manifest.json new file mode 100644 index 00000000000..6a8eb315c7a --- /dev/null +++ b/homeassistant/components/ogemray/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "ogemray", + "name": "Ogemray", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py index e3e252cbf8b..c304bfdf72d 100644 --- a/homeassistant/components/ohme/__init__.py +++ b/homeassistant/components/ohme/__init__.py @@ -6,6 +6,7 @@ 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.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS @@ -31,7 +32,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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]) + client = OhmeApiClient( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) try: await client.async_login() diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py index 6e942215c0f..41782ea4a2d 100644 --- a/homeassistant/components/ohme/button.py +++ b/homeassistant/components/ohme/button.py @@ -2,8 +2,9 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from typing import Any from ohme import ApiException, ChargerStatus, OhmeApiClient @@ -23,7 +24,7 @@ PARALLEL_UPDATES = 1 class OhmeButtonDescription(OhmeEntityDescription, ButtonEntityDescription): """Class describing Ohme button entities.""" - press_fn: Callable[[OhmeApiClient], Awaitable[None]] + press_fn: Callable[[OhmeApiClient], Coroutine[Any, Any, bool]] BUTTON_DESCRIPTIONS = [ diff --git a/homeassistant/components/ohme/config_flow.py b/homeassistant/components/ohme/config_flow.py index 748ea558983..1037c3a7c8b 100644 --- a/homeassistant/components/ohme/config_flow.py +++ b/homeassistant/components/ohme/config_flow.py @@ -99,6 +99,29 @@ class OhmeConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-configuration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input: + errors = await self._validate_account( + reconfigure_entry.data[CONF_EMAIL], + user_input[CONF_PASSWORD], + ) + 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=REAUTH_SCHEMA, + description_placeholders={"email": reconfigure_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] = {} diff --git a/homeassistant/components/ohme/diagnostics.py b/homeassistant/components/ohme/diagnostics.py new file mode 100644 index 00000000000..a955b3b76e2 --- /dev/null +++ b/homeassistant/components/ohme/diagnostics.py @@ -0,0 +1,24 @@ +"""Provides diagnostics for Ohme.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import OhmeConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: OhmeConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Ohme.""" + coordinators = config_entry.runtime_data + client = coordinators.charge_session_coordinator.client + + return { + "device_info": client.device_info, + "vehicles": client.vehicles, + "ct_connected": client.ct_connected, + "cap_available": client.cap_available, + } diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 9771b0bf5c2..8613f2542c4 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -16,6 +16,9 @@ "select": { "charge_mode": { "default": "mdi:play-box" + }, + "vehicle": { + "default": "mdi:car" } }, "sensor": { @@ -51,6 +54,9 @@ "state": { "off": "mdi:sleep-off" } + }, + "price_cap": { + "default": "mdi:car-speed-limiter" } }, "time": { @@ -62,6 +68,9 @@ "services": { "list_charge_slots": { "service": "mdi:clock-start" + }, + "set_price_cap": { + "service": "mdi:car-speed-limiter" } } } diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index fb11fa0dd06..786c615d68a 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/ohme/", "integration_type": "device", "iot_class": "cloud_polling", - "quality_scale": "silver", - "requirements": ["ohme==1.3.2"] + "quality_scale": "platinum", + "requirements": ["ohme==1.5.1"] } diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py index 0c71bab009f..f412c658085 100644 --- a/homeassistant/components/ohme/number.py +++ b/homeassistant/components/ohme/number.py @@ -1,7 +1,8 @@ """Platform for number.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from typing import Any from ohme import ApiException, OhmeApiClient @@ -22,7 +23,7 @@ PARALLEL_UPDATES = 1 class OhmeNumberDescription(OhmeEntityDescription, NumberEntityDescription): """Class describing Ohme number entities.""" - set_fn: Callable[[OhmeApiClient, float], Awaitable[None]] + set_fn: Callable[[OhmeApiClient, float], Coroutine[Any, Any, bool]] value_fn: Callable[[OhmeApiClient], float] @@ -31,7 +32,7 @@ NUMBER_DESCRIPTION = [ 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), + set_fn=lambda client, value: client.async_set_target(target_percent=int(value)), native_min_value=0, native_max_value=100, native_step=1, @@ -42,7 +43,7 @@ NUMBER_DESCRIPTION = [ translation_key="preconditioning_duration", value_fn=lambda client: client.preconditioning, set_fn=lambda client, value: client.async_set_target( - pre_condition_length=value + pre_condition_length=int(value) ), native_min_value=0, native_max_value=60, diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml index 497d5ad32e5..2f7aece5bb6 100644 --- a/homeassistant/components/ohme/quality_scale.yaml +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -39,7 +39,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery: status: exempt comment: | @@ -48,27 +48,33 @@ rules: 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-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo - dynamic-devices: todo - entity-category: todo + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Not supported by the API. Accounts and devices have a one-to-one relationship. + entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | This integration currently has no repairs. - stale-devices: todo + stale-devices: + status: exempt + comment: | + Not supported by the API. Accounts and devices have a one-to-one relationship. # Platinum - async-dependency: todo - inject-websession: todo - strict-typing: todo + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py index 17cc7c67e9a..d8d9c52c3b6 100644 --- a/homeassistant/components/ohme/select.py +++ b/homeassistant/components/ohme/select.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, Final @@ -24,11 +24,13 @@ PARALLEL_UPDATES = 1 class OhmeSelectDescription(OhmeEntityDescription, SelectEntityDescription): """Class to describe an Ohme select entity.""" - select_fn: Callable[[OhmeApiClient, Any], Awaitable[None]] + select_fn: Callable[[OhmeApiClient, Any], Coroutine[Any, Any, bool | None]] + options: list[str] | None = None + options_fn: Callable[[OhmeApiClient], list[str]] | None = None current_option_fn: Callable[[OhmeApiClient], str | None] -SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( +MODE_SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( key="charge_mode", translation_key="charge_mode", select_fn=lambda client, mode: client.async_set_mode(mode), @@ -37,6 +39,14 @@ SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( available_fn=lambda client: client.mode is not None, ) +VEHICLE_SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( + key="vehicle", + translation_key="vehicle", + select_fn=lambda client, selection: client.async_set_vehicle(selection), + options_fn=lambda client: client.vehicles, + current_option_fn=lambda client: client.current_vehicle or None, +) + async def async_setup_entry( hass: HomeAssistant, @@ -44,9 +54,15 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ohme selects.""" - coordinator = config_entry.runtime_data.charge_session_coordinator + charge_sessions_coordinator = config_entry.runtime_data.charge_session_coordinator + device_info_coordinator = config_entry.runtime_data.device_info_coordinator - async_add_entities([OhmeSelect(coordinator, SELECT_DESCRIPTION)]) + async_add_entities( + [ + OhmeSelect(charge_sessions_coordinator, MODE_SELECT_DESCRIPTION), + OhmeSelect(device_info_coordinator, VEHICLE_SELECT_DESCRIPTION), + ] + ) class OhmeSelect(OhmeEntity, SelectEntity): @@ -64,6 +80,14 @@ class OhmeSelect(OhmeEntity, SelectEntity): ) from e await self.coordinator.async_request_refresh() + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + if self.entity_description.options_fn: + return self.entity_description.options_fn(self.coordinator.client) + assert self.entity_description.options + return self.entity_description.options + @property def current_option(self) -> str | None: """Return the current selected option.""" diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index d0425040b53..7047e33749f 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0 class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription): """Class describing Ohme sensor entities.""" - value_fn: Callable[[OhmeApiClient], str | int | float] + value_fn: Callable[[OhmeApiClient], str | int | float | None] SENSOR_CHARGE_SESSION = [ @@ -99,6 +99,7 @@ SENSOR_ADVANCED_SETTINGS = [ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, value_fn=lambda client: client.power.ct_amps, is_supported_fn=lambda client: client.ct_connected, + entity_registry_enabled_default=False, ), ] @@ -129,6 +130,6 @@ class OhmeSensor(OhmeEntity, SensorEntity): entity_description: OhmeSensorDescription @property - def native_value(self) -> str | int | float: + def native_value(self) -> str | int | float | None: """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 index 7d06b909d88..8ed29aa373d 100644 --- a/homeassistant/components/ohme/services.py +++ b/homeassistant/components/ohme/services.py @@ -5,7 +5,7 @@ from typing import Final from ohme import OhmeApiClient import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -16,10 +16,13 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import selector from .const import DOMAIN +from .coordinator import OhmeConfigEntry + +ATTR_CONFIG_ENTRY: Final = "config_entry" +ATTR_PRICE_CAP: Final = "price_cap" SERVICE_LIST_CHARGE_SLOTS = "list_charge_slots" -ATTR_CONFIG_ENTRY: Final = "config_entry" -SERVICE_SCHEMA: Final = vol.Schema( +SERVICE_LIST_CHARGE_SLOTS_SCHEMA: Final = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( { @@ -29,11 +32,23 @@ SERVICE_SCHEMA: Final = vol.Schema( } ) +SERVICE_SET_PRICE_CAP = "set_price_cap" +SERVICE_SET_PRICE_CAP_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(ATTR_PRICE_CAP): vol.Coerce(float), + } +) + 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) + entry: OhmeConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id) if not entry: raise ServiceValidationError( @@ -64,12 +79,28 @@ def async_setup_services(hass: HomeAssistant) -> None: """List of charge slots.""" client = __get_client(service_call) - return {"slots": client.slots} + return {"slots": [slot.to_dict() for slot in client.slots]} + + async def set_price_cap( + service_call: ServiceCall, + ) -> None: + """List of charge slots.""" + client = __get_client(service_call) + price_cap = service_call.data[ATTR_PRICE_CAP] + await client.async_change_price_cap(cap=price_cap) hass.services.async_register( DOMAIN, SERVICE_LIST_CHARGE_SLOTS, list_charge_slots, - schema=SERVICE_SCHEMA, + schema=SERVICE_LIST_CHARGE_SLOTS_SCHEMA, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_PRICE_CAP, + set_price_cap, + schema=SERVICE_SET_PRICE_CAP_SCHEMA, + supports_response=SupportsResponse.NONE, + ) diff --git a/homeassistant/components/ohme/services.yaml b/homeassistant/components/ohme/services.yaml index c5c8ee18138..a45bc131511 100644 --- a/homeassistant/components/ohme/services.yaml +++ b/homeassistant/components/ohme/services.yaml @@ -5,3 +5,16 @@ list_charge_slots: selector: config_entry: integration: ohme +set_price_cap: + fields: + config_entry: + required: true + selector: + config_entry: + integration: ohme + price_cap: + required: true + selector: + number: + min: 0 + mode: box diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 4c845daa8f0..bcd9cfd17fe 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -21,6 +21,16 @@ "data_description": { "password": "Enter the password for your Ohme account" } + }, + "reconfigure": { + "description": "Update your password for {email}", + "title": "Reconfigure Ohme Account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Enter the password for your Ohme account" + } } }, "error": { @@ -29,7 +39,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "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%]" } }, "services": { @@ -42,6 +53,20 @@ "description": "The Ohme config entry for which to return charge slots." } } + }, + "set_price_cap": { + "name": "Set price cap", + "description": "Prevents charging when the electricity price exceeds a defined threshold.", + "fields": { + "config_entry": { + "name": "Ohme account", + "description": "The Ohme config entry for which to return charge slots." + }, + "price_cap": { + "name": "Price cap", + "description": "Threshold in 1/100ths of your local currency." + } + } } }, "entity": { @@ -64,16 +89,19 @@ "state": { "smart_charge": "Smart charge", "max_charge": "Max charge", - "paused": "Paused" + "paused": "[%key:common::state::paused%]" } + }, + "vehicle": { + "name": "Vehicle" } }, "sensor": { "status": { "name": "Status", "state": { - "unplugged": "Unplugged", - "plugged_in": "Plugged in", + "unplugged": "[%key:component::binary_sensor::entity_component::plug::state::off%]", + "plugged_in": "[%key:component::binary_sensor::entity_component::plug::state::on%]", "charging": "[%key:common::state::charging%]", "paused": "[%key:common::state::paused%]", "pending_approval": "Pending approval", @@ -99,6 +127,9 @@ }, "sleep_when_inactive": { "name": "Sleep when inactive" + }, + "price_cap": { + "name": "Price cap" } }, "time": { @@ -109,7 +140,7 @@ }, "exceptions": { "auth_failed": { - "message": "Unable to login to Ohme" + "message": "Unable to log in to Ohme" }, "device_info_failed": { "message": "Unable to get Ohme device information" diff --git a/homeassistant/components/ohme/switch.py b/homeassistant/components/ohme/switch.py index c4465ec7e97..47e3bf8a99d 100644 --- a/homeassistant/components/ohme/switch.py +++ b/homeassistant/components/ohme/switch.py @@ -1,9 +1,10 @@ """Platform for switch.""" +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from ohme import ApiException +from ohme import ApiException, OhmeApiClient from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -19,28 +20,37 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class OhmeSwitchDescription(OhmeEntityDescription, SwitchEntityDescription): - """Class describing Ohme switch entities.""" +class OhmeConfigSwitchDescription(OhmeEntityDescription, SwitchEntityDescription): + """Class describing Ohme configuration switch entities.""" configuration_key: str -SWITCH_DEVICE_INFO = [ - OhmeSwitchDescription( +@dataclass(frozen=True, kw_only=True) +class OhmeSwitchDescription(OhmeEntityDescription, SwitchEntityDescription): + """Class describing basic Ohme switch entities.""" + + is_on_fn: Callable[[OhmeApiClient], bool] + off_fn: Callable[[OhmeApiClient], Awaitable] + on_fn: Callable[[OhmeApiClient], Awaitable] + + +SWITCH_CONFIG = [ + OhmeConfigSwitchDescription( key="lock_buttons", translation_key="lock_buttons", entity_category=EntityCategory.CONFIG, is_supported_fn=lambda client: client.is_capable("buttonsLockable"), configuration_key="buttonsLocked", ), - OhmeSwitchDescription( + OhmeConfigSwitchDescription( key="require_approval", translation_key="require_approval", entity_category=EntityCategory.CONFIG, is_supported_fn=lambda client: client.is_capable("pluginsRequireApprovalMode"), configuration_key="pluginsRequireApproval", ), - OhmeSwitchDescription( + OhmeConfigSwitchDescription( key="sleep_when_inactive", translation_key="sleep_when_inactive", entity_category=EntityCategory.CONFIG, @@ -49,6 +59,17 @@ SWITCH_DEVICE_INFO = [ ), ] +SWITCH_DESCRIPTION = [ + OhmeSwitchDescription( + key="price_cap", + translation_key="price_cap", + is_supported_fn=lambda client: client.cap_available, + is_on_fn=lambda client: client.cap_enabled, + on_fn=lambda client: client.async_change_price_cap(True), + off_fn=lambda client: client.async_change_price_cap(False), + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -56,15 +77,17 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" - coordinators = config_entry.runtime_data - coordinator_map = [ - (SWITCH_DEVICE_INFO, coordinators.device_info_coordinator), - ] + coordinator = config_entry.runtime_data.device_info_coordinator + + async_add_entities( + OhmeConfigSwitch(coordinator, description) + for description in SWITCH_CONFIG + if description.is_supported_fn(coordinator.client) + ) async_add_entities( OhmeSwitch(coordinator, description) - for entities, coordinator in coordinator_map - for description in entities + for description in SWITCH_DESCRIPTION if description.is_supported_fn(coordinator.client) ) @@ -74,6 +97,27 @@ class OhmeSwitch(OhmeEntity, SwitchEntity): entity_description: OhmeSwitchDescription + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self.entity_description.is_on_fn(self.coordinator.client) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.entity_description.off_fn(self.coordinator.client) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.entity_description.on_fn(self.coordinator.client) + await self.coordinator.async_request_refresh() + + +class OhmeConfigSwitch(OhmeEntity, SwitchEntity): + """Configuration switch for Ohme.""" + + entity_description: OhmeConfigSwitchDescription + @property def is_on(self) -> bool: """Return the entity value to represent the entity state.""" diff --git a/homeassistant/components/ohme/time.py b/homeassistant/components/ohme/time.py index 264b2afd41a..a0b1edb594a 100644 --- a/homeassistant/components/ohme/time.py +++ b/homeassistant/components/ohme/time.py @@ -1,8 +1,9 @@ """Platform for time.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import time +from typing import Any from ohme import ApiException, OhmeApiClient @@ -22,7 +23,7 @@ PARALLEL_UPDATES = 1 class OhmeTimeDescription(OhmeEntityDescription, TimeEntityDescription): """Class describing Ohme time entities.""" - set_fn: Callable[[OhmeApiClient, time], Awaitable[None]] + set_fn: Callable[[OhmeApiClient, time], Coroutine[Any, Any, bool]] value_fn: Callable[[OhmeApiClient], time] diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 1024a824c25..d7f874c261c 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -3,9 +3,9 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import logging import sys -from types import MappingProxyType from typing import Any import httpx @@ -215,13 +215,11 @@ class OllamaOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) return self.async_create_entry( title=_get_title(self.model), data=user_input ) - options = self.config_entry.options or MappingProxyType({}) + options: Mapping[str, Any] = self.config_entry.options or {} schema = ollama_config_option_schema(self.hass, options) return self.async_show_form( step_id="init", @@ -230,22 +228,16 @@ class OllamaOptionsFlow(OptionsFlow): def ollama_config_option_schema( - hass: HomeAssistant, options: MappingProxyType[str, Any] + hass: HomeAssistant, options: Mapping[str, Any] ) -> dict: """Ollama options schema.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) + ] return { vol.Optional( @@ -259,8 +251,7 @@ def ollama_config_option_schema( vol.Optional( CONF_LLM_HASS_API, description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - default="none", - ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Optional( CONF_NUM_CTX, description={"suggested_value": options.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 90e81544f66..ab9e05b5fbe 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -15,7 +15,7 @@ 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 -from homeassistant.helpers import chat_session, intent, llm +from homeassistant.helpers import intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -206,18 +206,6 @@ class OllamaConversationEntity( """Return a list of supported languages.""" return MATCH_ALL - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) - async def _async_handle_message( self, user_input: conversation.ConversationInput, @@ -292,7 +280,9 @@ class OllamaConversationEntity( ) intent_response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=chat_log.conversation_id + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, ) def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: diff --git a/homeassistant/components/omnilogic/strings.json b/homeassistant/components/omnilogic/strings.json index 5b193b7f5ba..6f207337789 100644 --- a/homeassistant/components/omnilogic/strings.json +++ b/homeassistant/components/omnilogic/strings.json @@ -34,7 +34,7 @@ "fields": { "speed": { "name": "Speed", - "description": "Speed for the VSP between min and max speed." + "description": "Speed for the pump between min and max speed." } } } diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index c11bd79c377..097cddd6603 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -21,6 +21,7 @@ from .const import ( STEP_USER, STEPS, ) +from .views import BaseOnboardingView, NoAuthBaseOnboardingView # noqa: F401 STORAGE_KEY = DOMAIN STORAGE_VERSION = 4 diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index a4cf814eb2a..e57857896e0 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -2,7 +2,7 @@ "domain": "onboarding", "name": "Home Assistant Onboarding", "codeowners": ["@home-assistant/core"], - "dependencies": ["auth", "http", "person"], + "dependencies": ["auth", "http"], "documentation": "https://www.home-assistant.io/integrations/onboarding", "integration_type": "system", "quality_scale": "internal" diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index a590588c009..a42577b9f34 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,10 +3,9 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -from functools import wraps from http import HTTPStatus -from typing import TYPE_CHECKING, Any, Concatenate, cast +import logging +from typing import TYPE_CHECKING, Any, Protocol, cast from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized @@ -16,22 +15,14 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import person from homeassistant.components.auth import indieauth -from homeassistant.components.backup import ( - BackupManager, - Folder, - IncorrectPasswordError, - http as backup_http, -) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import area_registry as ar -from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager +from homeassistant.helpers import area_registry as ar, integration_platform from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations -from homeassistant.setup import async_setup_component +from homeassistant.setup import async_setup_component, async_wait_component if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -46,33 +37,76 @@ from .const import ( STEPS, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup( hass: HomeAssistant, data: OnboardingStoreData, store: OnboardingStorage ) -> None: """Set up the onboarding view.""" - hass.http.register_view(OnboardingView(data, store)) + await async_process_onboarding_platforms(hass) + hass.http.register_view(OnboardingStatusView(data, store)) hass.http.register_view(InstallationTypeOnboardingView(data)) hass.http.register_view(UserOnboardingView(data, store)) hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) hass.http.register_view(AnalyticsOnboardingView(data, store)) - hass.http.register_view(BackupInfoView(data)) - hass.http.register_view(RestoreBackupView(data)) - hass.http.register_view(UploadBackupView(data)) + hass.http.register_view(WaitIntegrationOnboardingView(data)) -class OnboardingView(HomeAssistantView): - """Return the onboarding status.""" +class OnboardingPlatformProtocol(Protocol): + """Define the format of onboarding platforms.""" + + async def async_setup_views( + self, hass: HomeAssistant, data: OnboardingStoreData + ) -> None: + """Set up onboarding views.""" + + +async def async_process_onboarding_platforms(hass: HomeAssistant) -> None: + """Start processing onboarding platforms.""" + await integration_platform.async_process_integration_platforms( + hass, DOMAIN, _register_onboarding_platform, wait_for_platforms=False + ) + + +async def _register_onboarding_platform( + hass: HomeAssistant, integration_domain: str, platform: OnboardingPlatformProtocol +) -> None: + """Register a onboarding platform.""" + if not hasattr(platform, "async_setup_views"): + _LOGGER.debug( + "'%s.onboarding' is not a valid onboarding platform", + integration_domain, + ) + return + await platform.async_setup_views(hass, hass.data[DOMAIN].steps) + + +class BaseOnboardingView(HomeAssistantView): + """Base class for onboarding views.""" + + def __init__(self, data: OnboardingStoreData) -> None: + """Initialize the onboarding view.""" + self._data = data + + +class NoAuthBaseOnboardingView(BaseOnboardingView): + """Base class for unauthenticated onboarding views.""" requires_auth = False + + +class OnboardingStatusView(NoAuthBaseOnboardingView): + """Return the onboarding status.""" + url = "/api/onboarding" name = "api:onboarding" def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None: """Initialize the onboarding view.""" + super().__init__(data) self._store = store - self._data = data async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" @@ -81,17 +115,12 @@ class OnboardingView(HomeAssistantView): ) -class InstallationTypeOnboardingView(HomeAssistantView): +class InstallationTypeOnboardingView(NoAuthBaseOnboardingView): """Return the installation type during onboarding.""" - requires_auth = False url = "/api/onboarding/installation_type" name = "api:onboarding:installation_type" - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the onboarding installation type view.""" - self._data = data - async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" if self._data["done"]: @@ -102,15 +131,15 @@ class InstallationTypeOnboardingView(HomeAssistantView): return self.json({"installation_type": info["installation_type"]}) -class _BaseOnboardingView(HomeAssistantView): - """Base class for onboarding.""" +class _BaseOnboardingStepView(BaseOnboardingView): + """Base class for an onboarding step.""" step: str def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None: """Initialize the onboarding view.""" + super().__init__(data) self._store = store - self._data = data self._lock = asyncio.Lock() @callback @@ -130,7 +159,7 @@ class _BaseOnboardingView(HomeAssistantView): listener() -class UserOnboardingView(_BaseOnboardingView): +class UserOnboardingView(_BaseOnboardingStepView): """View to handle create user onboarding step.""" url = "/api/onboarding/users" @@ -168,7 +197,7 @@ class UserOnboardingView(_BaseOnboardingView): {"username": data["username"]} ) await hass.auth.async_link_user(user, credentials) - if "person" in hass.config.components: + if await async_wait_component(hass, "person"): await person.async_create_person(hass, data["name"], user_id=user.id) # Create default areas using the users supplied language. @@ -196,7 +225,7 @@ class UserOnboardingView(_BaseOnboardingView): return self.json({"auth_code": auth_code}) -class CoreConfigOnboardingView(_BaseOnboardingView): +class CoreConfigOnboardingView(_BaseOnboardingStepView): """View to finish core config onboarding step.""" url = "/api/onboarding/core_config" @@ -242,7 +271,7 @@ class CoreConfigOnboardingView(_BaseOnboardingView): return self.json({}) -class IntegrationOnboardingView(_BaseOnboardingView): +class IntegrationOnboardingView(_BaseOnboardingStepView): """View to finish integration onboarding step.""" url = "/api/onboarding/integration" @@ -289,7 +318,31 @@ class IntegrationOnboardingView(_BaseOnboardingView): return self.json({"auth_code": auth_code}) -class AnalyticsOnboardingView(_BaseOnboardingView): +class WaitIntegrationOnboardingView(NoAuthBaseOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/integration/wait" + name = "api:onboarding:integration:wait" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("domain"): str, + } + ) + ) + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + """Handle wait for integration command.""" + hass = request.app[KEY_HASS] + domain = data["domain"] + return self.json( + { + "integration_loaded": await async_wait_component(hass, domain), + } + ) + + +class AnalyticsOnboardingView(_BaseOnboardingStepView): """View to finish analytics onboarding step.""" url = "/api/onboarding/analytics" @@ -311,124 +364,6 @@ class AnalyticsOnboardingView(_BaseOnboardingView): return self.json({}) -class BackupOnboardingView(HomeAssistantView): - """Backup onboarding view.""" - - requires_auth = False - - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the view.""" - self._data = data - - -def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( - func: Callable[ - Concatenate[_ViewT, BackupManager, web.Request, _P], - Coroutine[Any, Any, web.Response], - ], -) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: - """Home Assistant API decorator to check onboarding and inject manager.""" - - @wraps(func) - async def with_backup( - self: _ViewT, - request: web.Request, - *args: _P.args, - **kwargs: _P.kwargs, - ) -> web.Response: - """Check admin and call function.""" - if self._data["done"]: - raise HTTPUnauthorized - - try: - manager = await async_get_backup_manager(request.app[KEY_HASS]) - except HomeAssistantError: - return self.json( - {"code": "backup_disabled"}, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - return await func(self, manager, request, *args, **kwargs) - - return with_backup - - -class BackupInfoView(BackupOnboardingView): - """Get backup info view.""" - - url = "/api/onboarding/backup/info" - name = "api:onboarding:backup:info" - - @with_backup_manager - async def get(self, manager: BackupManager, request: web.Request) -> web.Response: - """Return backup info.""" - backups, _ = await manager.async_get_backups() - return self.json( - { - "backups": list(backups.values()), - "state": manager.state, - "last_non_idle_event": manager.last_non_idle_event, - } - ) - - -class RestoreBackupView(BackupOnboardingView): - """Restore backup view.""" - - url = "/api/onboarding/backup/restore" - name = "api:onboarding:backup:restore" - - @RequestDataValidator( - vol.Schema( - { - 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)], - } - ) - ) - @with_backup_manager - async def post( - self, manager: BackupManager, request: web.Request, data: dict[str, Any] - ) -> web.Response: - """Restore a backup.""" - try: - await manager.async_restore_backup( - data["backup_id"], - agent_id=data["agent_id"], - password=data.get("password"), - restore_addons=data.get("restore_addons"), - restore_database=data["restore_database"], - restore_folders=data.get("restore_folders"), - restore_homeassistant=True, - ) - except IncorrectPasswordError: - return self.json( - {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST - ) - except HomeAssistantError as err: - return self.json( - {"code": "restore_failed", "message": str(err)}, - status_code=HTTPStatus.BAD_REQUEST, - ) - return web.Response(status=HTTPStatus.OK) - - -class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): - """Upload backup view.""" - - url = "/api/onboarding/backup/upload" - name = "api:onboarding:backup:upload" - - @with_backup_manager - async def post(self, manager: BackupManager, request: web.Request) -> web.Response: - """Upload a backup file.""" - return await self._post(request) - - @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/homeassistant/components/oncue/__init__.py b/homeassistant/components/oncue/__init__.py index 19d134a398f..53c54290bf9 100644 --- a/homeassistant/components/oncue/__init__.py +++ b/homeassistant/components/oncue/__init__.py @@ -2,60 +2,40 @@ from __future__ import annotations -from datetime import timedelta -import logging - -from aiooncue import LoginFailedException, Oncue, OncueDevice - -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers import issue_registry as ir -from .const import CONNECTION_EXCEPTIONS, DOMAIN # noqa: F401 -from .types import OncueConfigEntry - -PLATFORMS: list[str] = [Platform.BINARY_SENSOR, Platform.SENSOR] - -_LOGGER = logging.getLogger(__name__) +DOMAIN = "oncue" -async def async_setup_entry(hass: HomeAssistant, entry: OncueConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: """Set up Oncue from a config entry.""" - data = entry.data - websession = async_get_clientsession(hass) - client = Oncue(data[CONF_USERNAME], data[CONF_PASSWORD], websession) - try: - await client.async_login() - except CONNECTION_EXCEPTIONS as ex: - raise ConfigEntryNotReady from ex - except LoginFailedException as ex: - raise ConfigEntryAuthFailed from ex - - async def _async_update() -> dict[str, OncueDevice]: - """Fetch data from Oncue.""" - try: - return await client.async_fetch_all() - except LoginFailedException as ex: - raise ConfigEntryAuthFailed from ex - - coordinator = DataUpdateCoordinator[dict[str, OncueDevice]]( + ir.async_create_issue( hass, - _LOGGER, - config_entry=entry, - name=f"Oncue {entry.data[CONF_USERNAME]}", - update_interval=timedelta(minutes=10), - update_method=_async_update, - always_update=False, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/oncue", + "rehlko": "/config/integrations/integration/rehlko", + }, ) - 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: OncueConfigEntry) -> bool: +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 True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/oncue/binary_sensor.py b/homeassistant/components/oncue/binary_sensor.py deleted file mode 100644 index 8dc9ba1be6f..00000000000 --- a/homeassistant/components/oncue/binary_sensor.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Support for Oncue binary sensors.""" - -from __future__ import annotations - -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 AddConfigEntryEntitiesCallback - -from .entity import OncueEntity -from .types import OncueConfigEntry - -SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( - key="NetworkConnectionEstablished", - entity_category=EntityCategory.DIAGNOSTIC, - device_class=BinarySensorDeviceClass.CONNECTIVITY, - ), -) - -SENSOR_MAP = {description.key: description for description in SENSOR_TYPES} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: OncueConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up binary sensors.""" - coordinator = config_entry.runtime_data - devices = coordinator.data - async_add_entities( - OncueBinarySensorEntity(coordinator, device_id, device, sensor, SENSOR_MAP[key]) - for device_id, device in devices.items() - for key, sensor in device.sensors.items() - if key in SENSOR_MAP - ) - - -class OncueBinarySensorEntity(OncueEntity, BinarySensorEntity): - """Representation of an Oncue binary sensor.""" - - @property - def is_on(self) -> bool: - """Return the binary sensor state.""" - return self._oncue_value == "true" diff --git a/homeassistant/components/oncue/config_flow.py b/homeassistant/components/oncue/config_flow.py index 872fe84350b..cf5b3262f0d 100644 --- a/homeassistant/components/oncue/config_flow.py +++ b/homeassistant/components/oncue/config_flow.py @@ -1,101 +1,11 @@ -"""Config flow for Oncue integration.""" +"""The Oncue integration.""" -from __future__ import annotations +from homeassistant.config_entries import ConfigFlow -from collections.abc import Mapping -import logging -from typing import Any - -from aiooncue import LoginFailedException, Oncue -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import CONNECTION_EXCEPTIONS, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from . import DOMAIN class OncueConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Oncue.""" VERSION = 1 - - 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: - if not (errors := await self._async_validate_or_error(user_input)): - normalized_username = user_input[CONF_USERNAME].lower() - await self.async_set_unique_id(normalized_username) - self._abort_if_unique_id_configured( - updates={ - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - } - ) - return self.async_create_entry( - title=normalized_username, data=user_input - ) - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors, - ) - - async def _async_validate_or_error(self, config: dict[str, Any]) -> dict[str, str]: - """Validate the user input.""" - errors: dict[str, str] = {} - try: - await Oncue( - config[CONF_USERNAME], - config[CONF_PASSWORD], - async_get_clientsession(self.hass), - ).async_login() - except CONNECTION_EXCEPTIONS: - errors["base"] = "cannot_connect" - except LoginFailedException: - errors[CONF_PASSWORD] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - return errors - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Handle reauth.""" - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle reauth input.""" - errors: dict[str, str] = {} - reauth_entry = self._get_reauth_entry() - existing_data = reauth_entry.data - description_placeholders: dict[str, str] = { - CONF_USERNAME: existing_data[CONF_USERNAME] - } - if user_input is not None: - new_config = {**existing_data, CONF_PASSWORD: user_input[CONF_PASSWORD]} - if not (errors := await self._async_validate_or_error(new_config)): - return self.async_update_reload_and_abort(reauth_entry, data=new_config) - - return self.async_show_form( - description_placeholders=description_placeholders, - step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), - errors=errors, - ) diff --git a/homeassistant/components/oncue/const.py b/homeassistant/components/oncue/const.py deleted file mode 100644 index bc14133b0d3..00000000000 --- a/homeassistant/components/oncue/const.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Constants for the Oncue integration.""" - -import aiohttp -from aiooncue import ServiceFailedException - -DOMAIN = "oncue" - -CONNECTION_EXCEPTIONS = ( - TimeoutError, - aiohttp.ClientError, - ServiceFailedException, -) - -CONNECTION_ESTABLISHED_KEY: str = "NetworkConnectionEstablished" - -VALUE_UNAVAILABLE: str = "--" diff --git a/homeassistant/components/oncue/entity.py b/homeassistant/components/oncue/entity.py deleted file mode 100644 index 55bd86d8912..00000000000 --- a/homeassistant/components/oncue/entity.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Support for Oncue sensors.""" - -from __future__ import annotations - -from aiooncue import OncueDevice, OncueSensor - -from homeassistant.const import ATTR_CONNECTIONS -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) - -from .const import CONNECTION_ESTABLISHED_KEY, DOMAIN, VALUE_UNAVAILABLE - - -class OncueEntity( - CoordinatorEntity[DataUpdateCoordinator[dict[str, OncueDevice]]], Entity -): - """Representation of an Oncue entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, OncueDevice]], - device_id: str, - device: OncueDevice, - sensor: OncueSensor, - description: EntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._device_id = device_id - self._attr_unique_id = f"{device_id}_{description.key}" - self._attr_name = sensor.display_name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_id)}, - name=device.name, - hw_version=device.hardware_version, - sw_version=device.sensors["FirmwareVersion"].display_value, - model=device.sensors["GensetModelNumberSelect"].display_value, - manufacturer="Kohler", - ) - try: - mac_address_hex = hex(int(device.sensors["MacAddress"].value))[2:] - except ValueError: # MacAddress may be invalid if the gateway is offline - return - self._attr_device_info[ATTR_CONNECTIONS] = { - (dr.CONNECTION_NETWORK_MAC, mac_address_hex) - } - - @property - def _oncue_value(self) -> str: - """Return the sensor value.""" - device: OncueDevice = self.coordinator.data[self._device_id] - sensor: OncueSensor = device.sensors[self.entity_description.key] - return sensor.value - - @property - def available(self) -> bool: - """Return if entity is available.""" - # The binary sensor that tracks the connection should not go unavailable. - if self.entity_description.key != CONNECTION_ESTABLISHED_KEY: - # If Kohler returns -- the entity is unavailable. - if self._oncue_value == VALUE_UNAVAILABLE: - return False - # If the cloud is reporting that the generator is not connected - # this also indicates the data is not available. - # The battery voltage sensor reports 0.0 rather than - # -- hence the purpose of this check. - device: OncueDevice = self.coordinator.data[self._device_id] - conn_established: OncueSensor = device.sensors[CONNECTION_ESTABLISHED_KEY] - if ( - conn_established is not None - and conn_established.value == VALUE_UNAVAILABLE - ): - return False - return super().available diff --git a/homeassistant/components/oncue/manifest.json b/homeassistant/components/oncue/manifest.json index 33d56f23669..b3744c1bb65 100644 --- a/homeassistant/components/oncue/manifest.json +++ b/homeassistant/components/oncue/manifest.json @@ -1,16 +1,10 @@ { "domain": "oncue", "name": "Oncue by Kohler", - "codeowners": ["@bdraco", "@peterager"], - "config_flow": true, - "dhcp": [ - { - "hostname": "kohlergen*", - "macaddress": "00146F*" - } - ], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/oncue", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["aiooncue"], - "requirements": ["aiooncue==0.3.9"] + "quality_scale": "legacy", + "requirements": [] } diff --git a/homeassistant/components/oncue/sensor.py b/homeassistant/components/oncue/sensor.py deleted file mode 100644 index 669c34157d4..00000000000 --- a/homeassistant/components/oncue/sensor.py +++ /dev/null @@ -1,217 +0,0 @@ -"""Support for Oncue sensors.""" - -from __future__ import annotations - -from aiooncue import OncueDevice, OncueSensor - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - PERCENTAGE, - EntityCategory, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfFrequency, - UnitOfPower, - UnitOfPressure, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .entity import OncueEntity -from .types import OncueConfigEntry - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="LatestFirmware", - icon="mdi:update", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineSpeed", - icon="mdi:speedometer", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineTargetSpeed", - icon="mdi:speedometer", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineOilPressure", - native_unit_of_measurement=UnitOfPressure.PSI, - device_class=SensorDeviceClass.PRESSURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineCoolantTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="BatteryVoltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="LubeOilTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GensetControllerTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineCompartmentTemperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorTrueTotalPower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorTruePercentOfRatedPower", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorVoltageAverageLineToLine", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorFrequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription(key="GensetState", icon="mdi:home-lightning-bolt"), - SensorEntityDescription( - key="GensetControllerTotalOperationTime", - icon="mdi:hours-24", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineTotalRunTime", - icon="mdi:hours-24", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="EngineTotalRunTimeLoaded", - icon="mdi:hours-24", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription(key="AtsContactorPosition", icon="mdi:electric-switch"), - SensorEntityDescription( - key="IPAddress", - icon="mdi:ip-network", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="ConnectedServerIPAddress", - icon="mdi:server-network", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="Source1VoltageAverageLineToLine", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="Source2VoltageAverageLineToLine", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GensetTotalEnergy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - SensorEntityDescription( - key="EngineTotalNumberOfStarts", - icon="mdi:engine", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="GeneratorCurrentAverage", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), -) - -SENSOR_MAP = {description.key: description for description in SENSOR_TYPES} - -UNIT_MAPPINGS = { - "C": UnitOfTemperature.CELSIUS, - "F": UnitOfTemperature.FAHRENHEIT, -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: OncueConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up sensors.""" - coordinator = config_entry.runtime_data - devices = coordinator.data - async_add_entities( - OncueSensorEntity(coordinator, device_id, device, sensor, SENSOR_MAP[key]) - for device_id, device in devices.items() - for key, sensor in device.sensors.items() - if key in SENSOR_MAP - ) - - -class OncueSensorEntity(OncueEntity, SensorEntity): - """Representation of an Oncue sensor.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, OncueDevice]], - device_id: str, - device: OncueDevice, - sensor: OncueSensor, - description: SensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, device_id, device, sensor, description) - if not description.native_unit_of_measurement and sensor.unit is not None: - self._attr_native_unit_of_measurement = UNIT_MAPPINGS.get( - sensor.unit, sensor.unit - ) - - @property - def native_value(self) -> str: - """Return the sensors state.""" - return self._oncue_value diff --git a/homeassistant/components/oncue/strings.json b/homeassistant/components/oncue/strings.json index ce7561962a2..6581555ff9e 100644 --- a/homeassistant/components/oncue/strings.json +++ b/homeassistant/components/oncue/strings.json @@ -1,27 +1,8 @@ { - "config": { - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - }, - "reauth_confirm": { - "description": "Re-authenticate Oncue account {username}", - "data": { - "password": "[%key:common::config_flow::data::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%]" + "issues": { + "integration_removed": { + "title": "The Oncue integration has been removed", + "description": "The Oncue integration has been removed from Home Assistant.\n\nThe Oncue service has been discontinued and [Rehlko]({rehlko}) is the integration to keep using it.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Oncue integration entries]({entries})." } } } diff --git a/homeassistant/components/oncue/types.py b/homeassistant/components/oncue/types.py deleted file mode 100644 index 89dd7095d59..00000000000 --- a/homeassistant/components/oncue/types.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Support for Oncue types.""" - -from __future__ import annotations - -from aiooncue import OncueDevice - -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -type OncueConfigEntry = ConfigEntry[DataUpdateCoordinator[dict[str, OncueDevice]]] diff --git a/homeassistant/components/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py index ddcd7ab8831..93aadb5b6ea 100644 --- a/homeassistant/components/ondilo_ico/__init__.py +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -1,27 +1,37 @@ """The Ondilo ICO integration.""" +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.typing import ConfigType from .api import OndiloClient -from .config_flow import OndiloIcoOAuth2FlowHandler -from .const import DOMAIN +from .const import DOMAIN, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET from .coordinator import OndiloIcoPoolsCoordinator -from .oauth_impl import OndiloOauth2Implementation +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.SENSOR] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Ondilo ICO integration.""" + # Import the default client credential. + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, name="Ondilo ICO"), + ) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ondilo ICO from a config entry.""" - - OndiloIcoOAuth2FlowHandler.async_register_implementation( - hass, - OndiloOauth2Implementation(hass), - ) - implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry diff --git a/homeassistant/components/ondilo_ico/api.py b/homeassistant/components/ondilo_ico/api.py index f6ab0baa576..696acf1b2d6 100644 --- a/homeassistant/components/ondilo_ico/api.py +++ b/homeassistant/components/ondilo_ico/api.py @@ -1,15 +1,12 @@ """API for Ondilo ICO bound to Home Assistant OAuth.""" from asyncio import run_coroutine_threadsafe -import logging from ondilo import Ondilo from homeassistant import config_entries, core from homeassistant.helpers import config_entry_oauth2_flow -_LOGGER = logging.getLogger(__name__) - class OndiloClient(Ondilo): """Provide Ondilo ICO authentication tied to an OAuth2 based config entry.""" diff --git a/homeassistant/components/ondilo_ico/application_credentials.py b/homeassistant/components/ondilo_ico/application_credentials.py new file mode 100644 index 00000000000..5481a88bc1b --- /dev/null +++ b/homeassistant/components/ondilo_ico/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for Ondilo ICO.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/ondilo_ico/config_flow.py b/homeassistant/components/ondilo_ico/config_flow.py index fe0b89e7258..6839d2089bf 100644 --- a/homeassistant/components/ondilo_ico/config_flow.py +++ b/homeassistant/components/ondilo_ico/config_flow.py @@ -3,11 +3,14 @@ import logging from typing import Any +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.config_entries import ConfigFlowResult from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import DOMAIN -from .oauth_impl import OndiloOauth2Implementation +from .const import DOMAIN, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET class OndiloIcoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): @@ -18,14 +21,13 @@ class OndiloIcoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle a flow initialized by the user.""" - await self.async_set_unique_id(DOMAIN) - - self.async_register_implementation( + """Handle a flow start.""" + # Import the default client credential. + await async_import_client_credential( self.hass, - OndiloOauth2Implementation(self.hass), + DOMAIN, + ClientCredential(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, name="Ondilo ICO"), ) - return await super().async_step_user(user_input) @property diff --git a/homeassistant/components/ondilo_ico/const.py b/homeassistant/components/ondilo_ico/const.py index 3c947776857..8dec6072556 100644 --- a/homeassistant/components/ondilo_ico/const.py +++ b/homeassistant/components/ondilo_ico/const.py @@ -4,5 +4,5 @@ DOMAIN = "ondilo_ico" OAUTH2_AUTHORIZE = "https://interop.ondilo.com/oauth2/authorize" OAUTH2_TOKEN = "https://interop.ondilo.com/oauth2/token" -OAUTH2_CLIENTID = "customer_api" -OAUTH2_CLIENTSECRET = "" +OAUTH2_CLIENT_ID = "customer_api" +OAUTH2_CLIENT_SECRET = "" diff --git a/homeassistant/components/ondilo_ico/manifest.json b/homeassistant/components/ondilo_ico/manifest.json index 84862a89fbb..3553797b9cd 100644 --- a/homeassistant/components/ondilo_ico/manifest.json +++ b/homeassistant/components/ondilo_ico/manifest.json @@ -3,7 +3,7 @@ "name": "Ondilo ICO", "codeowners": ["@JeromeHXP"], "config_flow": true, - "dependencies": ["auth"], + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/ondilo_ico", "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/homeassistant/components/ondilo_ico/oauth_impl.py b/homeassistant/components/ondilo_ico/oauth_impl.py deleted file mode 100644 index e1c6e6fdb90..00000000000 --- a/homeassistant/components/ondilo_ico/oauth_impl.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Local implementation of OAuth2 specific to Ondilo to hard code client id and secret and return a proper name.""" - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import LocalOAuth2Implementation - -from .const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_CLIENTID, - OAUTH2_CLIENTSECRET, - OAUTH2_TOKEN, -) - - -class OndiloOauth2Implementation(LocalOAuth2Implementation): - """Local implementation of OAuth2 specific to Ondilo to hard code client id and secret and return a proper name.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Just init default class with default values.""" - super().__init__( - hass, - DOMAIN, - OAUTH2_CLIENTID, - OAUTH2_CLIENTSECRET, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, - ) - - @property - def name(self) -> str: - """Name of the implementation.""" - return "Ondilo" diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index ddc4a94853f..da5ccae11a5 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + EntityCategory, UnitOfElectricPotential, UnitOfTemperature, ) @@ -56,12 +57,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="rssi", translation_key="rssi", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 454c782af92..f5d841683d5 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -19,12 +19,14 @@ from onedrive_personal_sdk.models.items import Item, ItemUpdate from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) from homeassistant.helpers.instance_id import async_get as async_get_instance_id +from homeassistant.helpers.typing import ConfigType from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN from .coordinator import ( @@ -32,23 +34,23 @@ from .coordinator import ( OneDriveRuntimeData, OneDriveUpdateCoordinator, ) +from .services import async_register_services +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.SENSOR] - _LOGGER = logging.getLogger(__name__) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the OneDrive integration.""" + async_register_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Set up OneDrive from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) - session = OAuth2Session(hass, entry, implementation) - - async def get_access_token() -> str: - await session.async_ensure_token_valid() - return cast(str, session.token[CONF_ACCESS_TOKEN]) - - client = OneDriveClient(get_access_token, async_get_clientsession(hass)) + client, get_access_token = await _get_onedrive_client(hass, entry) # get approot, will be created automatically if it does not exist approot = await _handle_item_operation(client.get_approot, "approot") @@ -104,11 +106,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: - await hass.config_entries.async_reload(entry.entry_id) - - entry.async_on_unload(entry.add_update_listener(update_listener)) - def async_notify_backup_listeners() -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): listener() @@ -164,20 +161,47 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) - _LOGGER.debug( "Migrating OneDrive config entry from version %s.%s", version, minor_version ) - + client, _ = await _get_onedrive_client(hass, entry) instance_id = await async_get_instance_id(hass) + try: + approot = await client.get_approot() + folder = await client.get_drive_item( + f"{approot.id}:/backups_{instance_id[:8]}:" + ) + except OneDriveException: + _LOGGER.exception("Migration to version 1.2 failed") + return False + hass.config_entries.async_update_entry( entry, data={ **entry.data, - CONF_FOLDER_ID: "id", # will be updated during setup_entry + CONF_FOLDER_ID: folder.id, CONF_FOLDER_NAME: f"backups_{instance_id[:8]}", }, + minor_version=2, ) _LOGGER.debug("Migration to version 1.2 successful") return True +async def _get_onedrive_client( + hass: HomeAssistant, entry: OneDriveConfigEntry +) -> tuple[OneDriveClient, Callable[[], Awaitable[str]]]: + """Get OneDrive client.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + + async def get_access_token() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) + + return ( + OneDriveClient(get_access_token, async_get_clientsession(hass)), + get_access_token, + ) + + async def _handle_item_operation( func: Callable[[], Awaitable[Item]], folder: str ) -> Item: diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 9c7371bee4b..dfb592c8d45 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -138,7 +138,7 @@ class OneDriveBackupAgent(BackupAgent): """Download a backup file.""" backups = await self._list_cached_backups() if backup_id not in backups: - raise BackupNotFound("Backup not found") + raise BackupNotFound(f"Backup {backup_id} not found") stream = await self._client.download_drive_item( backups[backup_id].backup_file_id, timeout=TIMEOUT @@ -174,11 +174,15 @@ class OneDriveBackupAgent(BackupAgent): description = dumps(backup.as_dict()) _LOGGER.debug("Creating metadata: %s", description) metadata_filename = filename.rsplit(".", 1)[0] + ".metadata.json" - metadata_file = await self._client.upload_file( - self._folder_id, - metadata_filename, - description, - ) + try: + metadata_file = await self._client.upload_file( + self._folder_id, + metadata_filename, + description, + ) + except OneDriveException: + await self._client.delete_drive_item(backup_file.id) + raise # add metadata to the metadata file metadata_description = { @@ -186,10 +190,15 @@ class OneDriveBackupAgent(BackupAgent): "backup_id": backup.backup_id, "backup_file_id": backup_file.id, } - await self._client.update_drive_item( - path_or_id=metadata_file.id, - data=ItemUpdate(description=dumps(metadata_description)), - ) + try: + await self._client.update_drive_item( + path_or_id=metadata_file.id, + data=ItemUpdate(description=dumps(metadata_description)), + ) + except OneDriveException: + await self._client.delete_drive_item(backup_file.id) + await self._client.delete_drive_item(metadata_file.id) + raise self._cache_expiration = time() @handle_backup_errors @@ -201,7 +210,7 @@ class OneDriveBackupAgent(BackupAgent): """Delete a backup file.""" backups = await self._list_cached_backups() if backup_id not in backups: - return + raise BackupNotFound(f"Backup {backup_id} not found") backup = backups[backup_id] @@ -221,12 +230,12 @@ class OneDriveBackupAgent(BackupAgent): ] @handle_backup_errors - async def async_get_backup( - self, backup_id: str, **kwargs: Any - ) -> AgentBackup | None: + async def async_get_backup(self, backup_id: str, **kwargs: Any) -> AgentBackup: """Return a backup.""" backups = await self._list_cached_backups() - return backups[backup_id].backup if backup_id in backups else None + if backup_id not in backups: + raise BackupNotFound(f"Backup {backup_id} not found") + return backups[backup_id].backup async def _list_cached_backups(self) -> dict[str, OneDriveBackup]: """List backups with a cache.""" @@ -235,8 +244,12 @@ class OneDriveBackupAgent(BackupAgent): items = await self._client.list_drive_items(self._folder_id) - async def download_backup_metadata(item_id: str) -> AgentBackup: - metadata_stream = await self._client.download_drive_item(item_id) + async def download_backup_metadata(item_id: str) -> AgentBackup | None: + try: + metadata_stream = await self._client.download_drive_item(item_id) + except OneDriveException as err: + _LOGGER.warning("Error downloading metadata for %s: %s", item_id, err) + return None metadata_json = loads(await metadata_stream.read()) return AgentBackup.from_dict(metadata_json) @@ -246,6 +259,8 @@ class OneDriveBackupAgent(BackupAgent): metadata_description_json := unescape(item.description) ): backup = await download_backup_metadata(item.id) + if backup is None: + continue metadata_description = loads(metadata_description_json) backups[backup.backup_id] = OneDriveBackup( backup=backup, diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index 7b2dbaab87a..3eb7d762712 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -88,8 +88,8 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): ), translation_key=key, translation_placeholders={ - "total": str(drive.quota.total), - "used": str(drive.quota.used), + "total": f"{drive.quota.total / (1024**3):.2f}", + "used": f"{drive.quota.used / (1024**3):.2f}", }, ) return drive diff --git a/homeassistant/components/onedrive/diagnostics.py b/homeassistant/components/onedrive/diagnostics.py new file mode 100644 index 00000000000..0e1ed94e155 --- /dev/null +++ b/homeassistant/components/onedrive/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for OneDrive.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import OneDriveConfigEntry + +TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: OneDriveConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + coordinator = entry.runtime_data.coordinator + + data = { + "drive": asdict(coordinator.data), + "config": { + **entry.data, + **entry.options, + }, + } + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/onedrive/icons.json b/homeassistant/components/onedrive/icons.json index b693f69934e..2ac4921439c 100644 --- a/homeassistant/components/onedrive/icons.json +++ b/homeassistant/components/onedrive/icons.json @@ -20,5 +20,10 @@ } } } + }, + "services": { + "upload": { + "service": "mdi:cloud-upload" + } } } diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 31a1f2ccb06..c20a99c727e 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "platinum", - "requirements": ["onedrive-personal-sdk==0.0.12"] + "requirements": ["onedrive-personal-sdk==0.0.14"] } diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index dd9e7f26102..1632c2670e0 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -1,18 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: Integration does not register custom actions. + 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: | - This integration does not have any custom actions. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -41,10 +36,7 @@ rules: # Gold devices: done - diagnostics: - status: exempt - comment: | - There is no data to diagnose. + diagnostics: done discovery-update-info: status: exempt comment: | diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py new file mode 100644 index 00000000000..1f1afe1507c --- /dev/null +++ b/homeassistant/components/onedrive/services.py @@ -0,0 +1,131 @@ +"""OneDrive services.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from pathlib import Path +from typing import cast + +from onedrive_personal_sdk.exceptions import OneDriveException +import voluptuous as vol + +from homeassistant.const import CONF_FILENAME +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .coordinator import OneDriveConfigEntry + +CONF_CONFIG_ENTRY_ID = "config_entry_id" +CONF_DESTINATION_FOLDER = "destination_folder" + +UPLOAD_SERVICE = "upload" +UPLOAD_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_DESTINATION_FOLDER): cv.string, + } +) +CONTENT_SIZE_LIMIT = 250 * 1024 * 1024 + + +def _read_file_contents( + hass: HomeAssistant, filenames: list[str] +) -> list[tuple[str, bytes]]: + """Return the mime types and file contents for each file.""" + results = [] + for filename in filenames: + if not hass.config.is_allowed_path(filename): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_access_to_path", + translation_placeholders={"filename": filename}, + ) + filename_path = Path(filename) + if not filename_path.exists(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="filename_does_not_exist", + translation_placeholders={"filename": filename}, + ) + if filename_path.stat().st_size > CONTENT_SIZE_LIMIT: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_too_large", + translation_placeholders={ + "filename": filename, + "size": str(filename_path.stat().st_size), + "limit": str(CONTENT_SIZE_LIMIT), + }, + ) + results.append((filename_path.name, filename_path.read_bytes())) + return results + + +def async_register_services(hass: HomeAssistant) -> None: + """Register OneDrive services.""" + + async def async_handle_upload(call: ServiceCall) -> ServiceResponse: + """Generate content from text and optionally images.""" + config_entry: OneDriveConfigEntry | None = hass.config_entries.async_get_entry( + call.data[CONF_CONFIG_ENTRY_ID] + ) + if not config_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + client = config_entry.runtime_data.client + upload_tasks = [] + file_results = await hass.async_add_executor_job( + _read_file_contents, hass, call.data[CONF_FILENAME] + ) + + # make sure the destination folder exists + try: + folder_id = (await client.get_approot()).id + for folder in ( + cast(str, call.data[CONF_DESTINATION_FOLDER]).strip("/").split("/") + ): + folder_id = (await client.create_folder(folder_id, folder)).id + except OneDriveException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="create_folder_error", + translation_placeholders={"message": str(err)}, + ) from err + + upload_tasks = [ + client.upload_file(folder_id, file_name, content) + for file_name, content in file_results + ] + try: + upload_results = await asyncio.gather(*upload_tasks) + except OneDriveException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_error", + translation_placeholders={"message": str(err)}, + ) from err + + if call.return_response: + return {"files": [asdict(item_result) for item_result in upload_results]} + return None + + if not hass.services.has_service(DOMAIN, UPLOAD_SERVICE): + hass.services.async_register( + DOMAIN, + UPLOAD_SERVICE, + async_handle_upload, + schema=UPLOAD_SERVICE_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) diff --git a/homeassistant/components/onedrive/services.yaml b/homeassistant/components/onedrive/services.yaml new file mode 100644 index 00000000000..0cf0faf6b60 --- /dev/null +++ b/homeassistant/components/onedrive/services.yaml @@ -0,0 +1,15 @@ +upload: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: onedrive + filename: + required: false + selector: + object: + destination_folder: + required: true + selector: + text: diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 37e19eb68ca..b8fa7f8189d 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -90,6 +90,24 @@ }, "update_failed": { "message": "Failed to update drive state" + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + }, + "no_access_to_path": { + "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + }, + "filename_does_not_exist": { + "message": "`{filename}` does not exist" + }, + "file_too_large": { + "message": "`{filename}` is too large ({size} > {limit})" + }, + "upload_error": { + "message": "Failed to upload content: {message}" + }, + "create_folder_error": { + "message": "Failed to create folder: {message}" } }, "entity": { @@ -106,12 +124,34 @@ "drive_state": { "name": "Drive state", "state": { - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "nearing": "Nearing limit", "critical": "Critical", "exceeded": "Exceeded" } } } + }, + "services": { + "upload": { + "name": "Upload file", + "description": "Uploads files to OneDrive.", + "fields": { + "config_entry_id": { + "name": "Config entry ID", + "description": "The config entry representing the OneDrive you want to upload to." + }, + "filename": { + "name": "Filename", + "description": "Path to the file to upload.", + "example": "/config/www/image.jpg" + }, + "destination_folder": { + "name": "Destination folder", + "description": "Folder inside the Home Assistant app folder (Apps/Home Assistant) you want to upload the file to. Will be created if it does not exist.", + "example": "photos/snapshots" + } + } + } } } diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 8a5623772f7..2099d9aabb5 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -234,12 +234,7 @@ class OnewireOptionsFlowHandler(OptionsFlow): INPUT_ENTRY_DEVICE_SELECTION, default=self._get_current_configured_sensors(), description="Multiselect with list of devices to choose from", - ): cv.multi_select( - { - friendly_name: False - for friendly_name in self.configurable_devices - } - ), + ): cv.multi_select(dict.fromkeys(self.configurable_devices, False)), } ), errors=errors, diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index d65d7a90950..dc894a4242e 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib from datetime import datetime, timedelta import logging import os @@ -58,7 +59,7 @@ class OneWireHub: owproxy: protocol._Proxy devices: list[OWDeviceDescription] - _version: str + _version: str | None = None def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None: """Initialize.""" @@ -74,7 +75,9 @@ class OneWireHub: port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) self.owproxy = protocol.proxy(host, port) - self._version = self.owproxy.read(protocol.PTH_VERSION).decode() + with contextlib.suppress(protocol.OwnetError): + # Version is not available on all servers + self._version = self.owproxy.read(protocol.PTH_VERSION).decode() self.devices = _discover_devices(self.owproxy) async def initialize(self) -> None: diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 5e1c7d35bd6..7039dc09858 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -7,7 +7,6 @@ import dataclasses from datetime import timedelta import logging import os -from types import MappingProxyType from typing import Any from pyownet import protocol @@ -415,7 +414,7 @@ async def async_setup_entry( def get_entities( onewire_hub: OneWireHub, devices: list[OWDeviceDescription], - options: MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> list[OneWireSensorEntity]: """Get a list of entities.""" entities: list[OneWireSensorEntity] = [] diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 46f41503d97..5e7719673b1 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -140,14 +140,14 @@ "device_selection": "[%key:component::onewire::options::error::device_not_selected%]" }, "description": "Select what configuration steps to process", - "title": "OneWire Device Options" + "title": "1-Wire device options" }, "configure_device": { "data": { - "precision": "Sensor Precision" + "precision": "Sensor precision" }, "description": "Select sensor precision for {sensor_id}", - "title": "OneWire Sensor Precision" + "title": "1-Wire sensor precision" } } } diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 5d941be959a..85ff0de3251 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import section from homeassistant.helpers.selector import ( @@ -30,8 +30,6 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( - CONF_RECEIVER_MAX_VOLUME, - CONF_SOURCES, DOMAIN, OPTION_INPUT_SOURCES, OPTION_LISTENING_MODES, @@ -329,61 +327,6 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle reconfiguration of the receiver.""" return await self.async_step_manual() - async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """Import the yaml config.""" - _LOGGER.debug("Import flow user input: %s", user_input) - - host: str = user_input[CONF_HOST] - name: str | None = user_input.get(CONF_NAME) - user_max_volume: int = user_input[OPTION_MAX_VOLUME] - user_volume_resolution: int = user_input[CONF_RECEIVER_MAX_VOLUME] - user_sources: dict[InputSource, str] = user_input[CONF_SOURCES] - - info: ReceiverInfo | None = user_input.get("info") - if info is None: - try: - info = await async_interview(host) - except Exception: - _LOGGER.exception("Import flow interview error for host %s", host) - return self.async_abort(reason="cannot_connect") - - if info is None: - _LOGGER.error("Import flow interview error for host %s", host) - return self.async_abort(reason="cannot_connect") - - unique_id = info.identifier - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - - name = name or info.model_name - - volume_resolution = VOLUME_RESOLUTION_ALLOWED[-1] - for volume_resolution_allowed in VOLUME_RESOLUTION_ALLOWED: - if user_volume_resolution <= volume_resolution_allowed: - volume_resolution = volume_resolution_allowed - break - - max_volume = min( - 100, user_max_volume * user_volume_resolution / volume_resolution - ) - - sources_store: dict[str, str] = {} - for source, source_name in user_sources.items(): - sources_store[source.value] = source_name - - return self.async_create_entry( - title=name, - data={ - CONF_HOST: host, - }, - options={ - OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: max_volume, - OPTION_INPUT_SOURCES: sources_store, - OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, - }, - ) - @staticmethod @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py index fcb1a8a0a9e..851d80c5100 100644 --- a/homeassistant/components/onkyo/const.py +++ b/homeassistant/components/onkyo/const.py @@ -11,9 +11,6 @@ DOMAIN = "onkyo" DEVICE_INTERVIEW_TIMEOUT = 5 DEVICE_DISCOVERY_TIMEOUT = 5 -CONF_SOURCES = "sources" -CONF_RECEIVER_MAX_VOLUME = "receiver_max_volume" - type VolumeResolution = Literal[50, 80, 100, 200] OPTION_VOLUME_RESOLUTION = "volume_resolution" OPTION_VOLUME_RESOLUTION_DEFAULT: VolumeResolution = 50 diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 8f9587bc426..aed7c51af80 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -8,32 +8,18 @@ from functools import cache import logging from typing import Any, Literal -import voluptuous as vol - from homeassistant.components.media_player import ( - PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, ) -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.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OnkyoConfigEntry from .const import ( - CONF_RECEIVER_MAX_VOLUME, - CONF_SOURCES, DOMAIN, OPTION_MAX_VOLUME, OPTION_VOLUME_RESOLUTION, @@ -43,46 +29,11 @@ from .const import ( ListeningMode, VolumeResolution, ) -from .receiver import Receiver, async_discover +from .receiver import Receiver from .services import DATA_MP_ENTITIES _LOGGER = logging.getLogger(__name__) -CONF_MAX_VOLUME_DEFAULT = 100 -CONF_RECEIVER_MAX_VOLUME_DEFAULT = 80 -CONF_SOURCES_DEFAULT = { - "tv": "TV", - "bd": "Bluray", - "game": "Game", - "aux1": "Aux1", - "video1": "Video 1", - "video2": "Video 2", - "video3": "Video 3", - "video4": "Video 4", - "video5": "Video 5", - "video6": "Video 6", - "video7": "Video 7", - "fm": "Radio", -} - -ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" - -PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(OPTION_MAX_VOLUME, default=CONF_MAX_VOLUME_DEFAULT): vol.All( - vol.Coerce(int), vol.Range(min=1, max=100) - ), - vol.Optional( - CONF_RECEIVER_MAX_VOLUME, default=CONF_RECEIVER_MAX_VOLUME_DEFAULT - ): cv.positive_int, - vol.Optional(CONF_SOURCES, default=CONF_SOURCES_DEFAULT): { - cv.string: cv.string - }, - } -) - SUPPORTED_FEATURES_BASE = ( MediaPlayerEntityFeature.TURN_ON @@ -194,122 +145,6 @@ def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode] return {value: key for key, value in _listening_mode_lib_mappings(zone).items()} -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import config from yaml.""" - host = config.get(CONF_HOST) - - source_mapping: dict[str, InputSource] = {} - for zone in ZONES: - for source, source_lib in _input_source_lib_mappings(zone).items(): - if isinstance(source_lib, str): - source_mapping.setdefault(source_lib, source) - else: - for source_lib_single in source_lib: - source_mapping.setdefault(source_lib_single, source) - - sources: dict[InputSource, str] = {} - for source_lib_single, source_name in config[CONF_SOURCES].items(): - user_source = source_mapping.get(source_lib_single.lower()) - if user_source is not None: - sources[user_source] = source_name - - config[CONF_SOURCES] = sources - - results = [] - if host is not None: - _LOGGER.debug("Importing yaml single: %s", host) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - results.append((host, result)) - else: - for info in await async_discover(): - host = info.host - - # Migrate legacy entities. - registry = er.async_get(hass) - old_unique_id = f"{info.model_name}_{info.identifier}" - new_unique_id = f"{info.identifier}_main" - entity_id = registry.async_get_entity_id( - "media_player", DOMAIN, old_unique_id - ) - if entity_id is not None: - _LOGGER.debug( - "Migrating unique_id from [%s] to [%s] for entity %s", - old_unique_id, - new_unique_id, - entity_id, - ) - registry.async_update_entity(entity_id, new_unique_id=new_unique_id) - - _LOGGER.debug("Importing yaml discover: %s", info.host) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config | {CONF_HOST: info.host} | {"info": info}, - ) - results.append((host, result)) - - _LOGGER.debug("Importing yaml results: %s", results) - if not results: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_import_issue_no_discover", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_no_discover", - translation_placeholders={"url": ISSUE_URL_PLACEHOLDER}, - ) - - all_successful = True - for host, result in results: - if ( - result.get("type") == FlowResultType.CREATE_ENTRY - or result.get("reason") == "already_configured" - ): - continue - if error := result.get("reason"): - all_successful = False - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{host}_{error}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{error}", - translation_placeholders={ - "host": host, - "url": ISSUE_URL_PLACEHOLDER, - }, - ) - - if all_successful: - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - breaks_in_ha_version="2025.5.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "onkyo", - }, - ) - - async def async_setup_entry( hass: HomeAssistant, entry: OnkyoConfigEntry, @@ -588,7 +423,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_volume_level = min(1, volume_level) elif command in ["muting", "audio-muting"]: self._attr_is_volume_muted = bool(value == "on") - elif command in ["selector", "input-selector"]: + elif command in ["selector", "input-selector"] and value != "N/A": self._parse_source(value) self._query_av_info_delayed() elif command == "hdmi-output-selector": diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index d8131dd1149..3e5520c79f7 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -83,16 +83,6 @@ "empty_listening_mode_list": "Listening mode list cannot be empty" } }, - "issues": { - "deprecated_yaml_import_issue_no_discover": { - "title": "The Onkyo YAML configuration import failed", - "description": "Configuring Onkyo using YAML is being removed but no receivers were discovered when importing your YAML configuration.\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." - }, - "deprecated_yaml_import_issue_cannot_connect": { - "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_sound_mode": { "message": "Cannot select sound mode \"{invalid_sound_mode}\" for entity: {entity_id}." diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 02e7e28ea18..09a4aba52bf 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -19,8 +19,9 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from .const import ( CONF_ENABLE_WEBHOOKS, @@ -99,6 +100,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if device.capabilities.imaging: device.platforms += [Platform.SWITCH] + _async_migrate_camera_entities_unique_ids(hass, entry, device) + await hass.config_entries.async_forward_entry_setups(entry, device.platforms) entry.async_on_unload( @@ -155,3 +158,58 @@ async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> Non } hass.config_entries.async_update_entry(entry, options=options) + + +@callback +def _async_migrate_camera_entities_unique_ids( + hass: HomeAssistant, config_entry: ConfigEntry, device: ONVIFDevice +) -> None: + """Migrate unique ids of camera entities from profile index to profile token.""" + entity_reg = er.async_get(hass) + entities: list[er.RegistryEntry] = er.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ) + + mac_or_serial = device.info.mac or device.info.serial_number + old_uid_start = f"{mac_or_serial}_" + new_uid_start = f"{mac_or_serial}#" + + for entity in entities: + if entity.domain != Platform.CAMERA: + continue + + if ( + not entity.unique_id.startswith(old_uid_start) + and entity.unique_id != mac_or_serial + ): + continue + + index = 0 + if entity.unique_id.startswith(old_uid_start): + try: + index = int(entity.unique_id[len(old_uid_start) :]) + except ValueError: + LOGGER.error( + "Failed to migrate unique id for '%s' as the ONVIF profile index could not be parsed from unique id '%s'", + entity.entity_id, + entity.unique_id, + ) + continue + try: + token = device.profiles[index].token + except IndexError: + LOGGER.error( + "Failed to migrate unique id for '%s' as the ONVIF profile index '%d' parsed from unique id '%s' could not be found", + entity.entity_id, + index, + entity.unique_id, + ) + continue + new_uid = f"{new_uid_start}{token}" + LOGGER.debug( + "Migrating unique id for '%s' from '%s' to '%s'", + entity.entity_id, + entity.unique_id, + new_uid, + ) + entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_uid) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index da99e170ff6..fc17e912fcc 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -117,10 +117,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): self._attr_entity_registry_enabled_default = ( device.max_resolution == profile.video.resolution.width ) - if profile.index: - self._attr_unique_id = f"{self.mac_or_serial}_{profile.index}" - else: - self._attr_unique_id = self.mac_or_serial + self._attr_unique_id = f"{self.mac_or_serial}#{profile.token}" self._attr_name = f"{device.name} {profile.name}" @property diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index b7b34f7be9f..d1b93304ccc 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -174,11 +174,20 @@ class EventManager: UNHANDLED_TOPICS.add(topic) continue - event = await parser(unique_id, msg) + try: + event = await parser(unique_id, msg) + error = None + except (AttributeError, KeyError) as e: + event = None + error = e if not event: LOGGER.warning( - "%s: Unable to parse event from %s: %s", self.name, unique_id, msg + "%s: Unable to parse event from %s: %s: %s", + self.name, + unique_id, + error, + msg, ) return diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 6eb1d001796..e5a731c73f6 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -49,24 +49,22 @@ def local_datetime_or_none(value: str) -> datetime.datetime | None: @PARSERS.register("tns1:VideoSource/MotionAlarm") +@PARSERS.register("tns1:Device/Trigger/tnshik:AlarmIn") async def async_parse_motion_alarm(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/MotionAlarm """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Motion Alarm", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Motion Alarm", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:VideoSource/ImageTooBlurry/AnalyticsService") @@ -77,20 +75,17 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBlurry/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Blurry", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Image Too Blurry", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:VideoSource/ImageTooDark/AnalyticsService") @@ -101,20 +96,17 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooDark/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Dark", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Image Too Dark", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:VideoSource/ImageTooBright/AnalyticsService") @@ -125,20 +117,17 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBright/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Bright", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Image Too Bright", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:VideoSource/GlobalSceneChange/AnalyticsService") @@ -149,19 +138,16 @@ async def async_parse_scene_change(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/GlobalSceneChange/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Global Scene Change", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Global Scene Change", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound") @@ -170,29 +156,26 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: Topic: tns1:AudioAnalytics/Audio/DetectedSound """ - try: - audio_source = "" - audio_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "AudioSourceConfigurationToken": - audio_source = source.Value - if source.Name == "AudioAnalyticsConfigurationToken": - audio_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + audio_source = "" + audio_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "AudioSourceConfigurationToken": + audio_source = source.Value + if source.Name == "AudioAnalyticsConfigurationToken": + audio_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}", - "Detected Sound", - "binary_sensor", - "sound", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}", + "Detected Sound", + "binary_sensor", + "sound", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside") @@ -201,30 +184,26 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/FieldDetector/ObjectsInside """ - 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 + 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 - evt = Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Field Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None - return evt + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Field Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion") @@ -233,29 +212,26 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/CellMotionDetector/Motion """ - 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 + 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 - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Cell Motion Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Cell Motion Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion") @@ -264,29 +240,26 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MotionRegionDetector/Motion """ - 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 + 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 - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Motion Region Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value in ["1", "true"], - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Motion Region Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value in ["1", "true"], + ) @PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper") @@ -295,30 +268,27 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/TamperDetector/Tamper """ - 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 + 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 - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Tamper Detection", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Tamper Detection", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect") @@ -327,23 +297,20 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/DogCatDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Pet Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Pet Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect") @@ -352,23 +319,20 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/VehicleDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Vehicle Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Vehicle Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) _TAPO_EVENT_TEMPLATES: dict[str, Event] = { @@ -420,32 +384,28 @@ async def async_parse_tplink_detector(uid: str, msg) -> Event | None: 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 + 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 + 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 dataclasses.replace( + event_template, + uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + value=item.Value == "true", + ) return None @@ -456,23 +416,20 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/PeopleDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Person Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Person Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect") @@ -481,23 +438,20 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/FaceDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Face Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Face Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor") @@ -506,23 +460,42 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/Visitor """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Visitor Detection", - "binary_sensor", - "occupancy", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Visitor Detection", + "binary_sensor", + "occupancy", + None, + payload.Data.SimpleItem[0].Value == "true", + ) + + +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Package") +async def async_parse_package_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/Package + """ + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) + + return Event( + f"{uid}_{topic}_{video_source}", + "Package Detection", + "binary_sensor", + "occupancy", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:Device/Trigger/DigitalInput") @@ -531,19 +504,16 @@ async def async_parse_digital_input(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/DigitalInput """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Digital Input", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Digital Input", + "binary_sensor", + None, + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:Device/Trigger/Relay") @@ -552,19 +522,16 @@ async def async_parse_relay(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/Relay """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Relay Triggered", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "active", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Relay Triggered", + "binary_sensor", + None, + None, + payload.Data.SimpleItem[0].Value == "active", + ) @PARSERS.register("tns1:Device/HardwareFailure/StorageFailure") @@ -573,20 +540,17 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None: Topic: tns1:Device/HardwareFailure/StorageFailure """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Storage Failure", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Storage Failure", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:Monitoring/ProcessorUsage") @@ -595,23 +559,20 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/ProcessorUsage """ - try: - topic, payload = extract_message(msg) - usage = float(payload.Data.SimpleItem[0].Value) - if usage <= 1: - usage *= 100 + topic, payload = extract_message(msg) + usage = float(payload.Data.SimpleItem[0].Value) + if usage <= 1: + usage *= 100 - return Event( - f"{uid}_{topic}", - "Processor Usage", - "sensor", - None, - "percent", - int(usage), - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}", + "Processor Usage", + "sensor", + None, + "percent", + int(usage), + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot") @@ -620,20 +581,17 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReboot """ - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Reboot", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Reboot", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:Monitoring/OperatingTime/LastReset") @@ -642,21 +600,18 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReset """ - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Reset", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Reset", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + entity_enabled=False, + ) @PARSERS.register("tns1:Monitoring/Backup/Last") @@ -665,22 +620,18 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/Backup/Last """ - - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Backup", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Backup", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + entity_enabled=False, + ) @PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization") @@ -689,21 +640,18 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Clock Synchronization", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Clock Synchronization", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + entity_enabled=False, + ) @PARSERS.register("tns1:RecordingConfig/JobState") @@ -713,20 +661,17 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: Topic: tns1:RecordingConfig/JobState """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Recording Job State", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "Active", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Recording Job State", + "binary_sensor", + None, + None, + payload.Data.SimpleItem[0].Value == "Active", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:RuleEngine/LineDetector/Crossed") @@ -735,30 +680,27 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/LineDetector/Crossed """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = source.Value + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Line Detector Crossed", - "sensor", - None, - None, - payload.Data.SimpleItem[0].Value, - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Line Detector Crossed", + "sensor", + None, + None, + payload.Data.SimpleItem[0].Value, + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:RuleEngine/CountAggregation/Counter") @@ -767,30 +709,27 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/CountAggregation/Counter """ - 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 + 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 - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Count Aggregation Counter", - "sensor", - None, - None, - payload.Data.SimpleItem[0].Value, - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Count Aggregation Counter", + "sensor", + None, + None, + payload.Data.SimpleItem[0].Value, + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:UserAlarm/IVA/HumanShapeDetect") @@ -799,21 +738,18 @@ async def async_parse_human_shape_detect(uid: str, msg) -> Event | None: Topic: tns1:UserAlarm/IVA/HumanShapeDetect """ - try: - topic, payload = extract_message(msg) - video_source = "" - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - break + topic, payload = extract_message(msg) + video_source = "" + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + break - return Event( - f"{uid}_{topic}_{video_source}", - "Human Shape Detect", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Human Shape Detect", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 0afb5e59e8e..7988c50b1ac 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -62,12 +62,12 @@ "step": { "onvif_devices": { "data": { - "extra_arguments": "Extra FFMPEG arguments", + "extra_arguments": "Extra FFmpeg arguments", "rtsp_transport": "RTSP transport mechanism", "use_wallclock_as_timestamps": "Use wall clock as timestamps", - "enable_webhooks": "Enable Webhooks" + "enable_webhooks": "Enable webhooks" }, - "title": "ONVIF Device Options" + "title": "ONVIF device options" } } }, diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 0fbda9b7f4a..71effe83884 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -2,7 +2,21 @@ from __future__ import annotations +import base64 +from mimetypes import guess_file_type +from pathlib import Path + import openai +from openai.types.images_response import ImagesResponse +from openai.types.responses import ( + EasyInputMessageParam, + Response, + ResponseInputFileParam, + ResponseInputImageParam, + ResponseInputMessageContentListParam, + ResponseInputParam, + ResponseInputTextParam, +) import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -22,15 +36,41 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, LOGGER +from .const import ( + CONF_CHAT_MODEL, + CONF_FILENAMES, + CONF_MAX_TOKENS, + CONF_PROMPT, + CONF_REASONING_EFFORT, + CONF_TEMPERATURE, + CONF_TOP_P, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, +) SERVICE_GENERATE_IMAGE = "generate_image" +SERVICE_GENERATE_CONTENT = "generate_content" + PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient] +def encode_file(file_path: str) -> tuple[str, str]: + """Return base64 version of file contents.""" + mime_type, _ = guess_file_type(file_path) + if mime_type is None: + mime_type = "application/octet-stream" + with open(file_path, "rb") as image_file: + return (mime_type, base64.b64encode(image_file.read()).decode("utf-8")) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up OpenAI Conversation.""" @@ -49,9 +89,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client: openai.AsyncClient = entry.runtime_data try: - response = await client.images.generate( + response: ImagesResponse = await client.images.generate( model="dall-e-3", - prompt=call.data["prompt"], + prompt=call.data[CONF_PROMPT], size=call.data["size"], quality=call.data["quality"], style=call.data["style"], @@ -61,8 +101,121 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: except openai.OpenAIError as err: raise HomeAssistantError(f"Error generating image: {err}") from err + if not response.data or not response.data[0].url: + raise HomeAssistantError("No image returned") + return response.data[0].model_dump(exclude={"b64_json"}) + async def send_prompt(call: ServiceCall) -> ServiceResponse: + """Send a prompt to ChatGPT and return the response.""" + entry_id = call.data["config_entry"] + entry = hass.config_entries.async_get_entry(entry_id) + + if entry is None or entry.domain != DOMAIN: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={"config_entry": entry_id}, + ) + + model: str = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + client: openai.AsyncClient = entry.runtime_data + + content: ResponseInputMessageContentListParam = [ + ResponseInputTextParam(type="input_text", text=call.data[CONF_PROMPT]) + ] + + def append_files_to_content() -> None: + for filename in call.data[CONF_FILENAMES]: + if not hass.config.is_allowed_path(filename): + raise HomeAssistantError( + f"Cannot read `{filename}`, no access to path; " + "`allowlist_external_dirs` may need to be adjusted in " + "`configuration.yaml`" + ) + if not Path(filename).exists(): + raise HomeAssistantError(f"`{filename}` does not exist") + mime_type, base64_file = encode_file(filename) + if "image/" in mime_type: + content.append( + ResponseInputImageParam( + type="input_image", + image_url=f"data:{mime_type};base64,{base64_file}", + detail="auto", + ) + ) + elif "application/pdf" in mime_type: + content.append( + ResponseInputFileParam( + type="input_file", + filename=filename, + file_data=f"data:{mime_type};base64,{base64_file}", + ) + ) + else: + raise HomeAssistantError( + "Only images and PDF are supported by the OpenAI API," + f"`{filename}` is not an image file or PDF" + ) + + if CONF_FILENAMES in call.data: + await hass.async_add_executor_job(append_files_to_content) + + messages: ResponseInputParam = [ + EasyInputMessageParam(type="message", role="user", content=content) + ] + + try: + model_args = { + "model": model, + "input": messages, + "max_output_tokens": entry.options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": entry.options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + "user": call.context.user_id, + "store": False, + } + + if model.startswith("o"): + model_args["reasoning"] = { + "effort": entry.options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) + } + + response: Response = await client.responses.create(**model_args) + + except openai.OpenAIError as err: + raise HomeAssistantError(f"Error generating content: {err}") from err + except FileNotFoundError as err: + raise HomeAssistantError(f"Error generating content: {err}") from err + + return {"text": response.output_text} + + hass.services.async_register( + DOMAIN, + SERVICE_GENERATE_CONTENT, + send_prompt, + schema=vol.Schema( + { + vol.Required("config_entry"): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(CONF_PROMPT): cv.string, + vol.Optional(CONF_FILENAMES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( DOMAIN, SERVICE_GENERATE_IMAGE, @@ -74,7 +227,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "integration": DOMAIN, } ), - vol.Required("prompt"): cv.string, + vol.Required(CONF_PROMPT): cv.string, vol.Optional("size", default="1024x1024"): vol.In( ("1024x1024", "1024x1792", "1792x1024") ), @@ -84,6 +237,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ), supports_response=SupportsResponse.ONLY, ) + return True diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index c631884ea0b..6d3f461981c 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -2,22 +2,32 @@ from __future__ import annotations +from collections.abc import Mapping +import json import logging from types import MappingProxyType from typing import Any import openai import voluptuous as vol +from voluptuous_openapi import convert +from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_API_KEY, + CONF_LLM_HASS_API, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import llm +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, @@ -37,13 +47,24 @@ from .const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + RECOMMENDED_WEB_SEARCH, + RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, + RECOMMENDED_WEB_SEARCH_USER_LOCATION, UNSUPPORTED_MODELS, + WEB_SEARCH_MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -66,7 +87,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - client = openai.AsyncOpenAI(api_key=data[CONF_API_KEY]) + client = openai.AsyncOpenAI( + api_key=data[CONF_API_KEY], http_client=get_async_client(hass) + ) await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) @@ -132,12 +155,21 @@ class OpenAIOptionsFlow(OptionsFlow): if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) - + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: errors[CONF_CHAT_MODEL] = "model_not_supported" - else: + + if user_input.get(CONF_WEB_SEARCH): + if ( + user_input.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + not in WEB_SEARCH_MODELS + ): + errors[CONF_WEB_SEARCH] = "web_search_not_supported" + elif user_input.get(CONF_WEB_SEARCH_USER_LOCATION): + user_input.update(await self.get_location_data()) + + if not errors: return self.async_create_entry(title="", data=user_input) else: # Re-render the options again, now with the recommended options shown/hidden @@ -145,8 +177,10 @@ class OpenAIOptionsFlow(OptionsFlow): options = { CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + CONF_PROMPT: user_input.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ), + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), } schema = openai_config_option_schema(self.hass, options) @@ -156,26 +190,76 @@ class OpenAIOptionsFlow(OptionsFlow): errors=errors, ) + async def get_location_data(self) -> dict[str, str]: + """Get approximate location data of the user.""" + location_data: dict[str, str] = {} + zone_home = self.hass.states.get(ENTITY_ID_HOME) + if zone_home is not None: + client = openai.AsyncOpenAI( + api_key=self.config_entry.data[CONF_API_KEY], + http_client=get_async_client(self.hass), + ) + location_schema = vol.Schema( + { + vol.Optional( + CONF_WEB_SEARCH_CITY, + description="Free text input for the city, e.g. `San Francisco`", + ): str, + vol.Optional( + CONF_WEB_SEARCH_REGION, + description="Free text input for the region, e.g. `California`", + ): str, + } + ) + response = await client.responses.create( + model=RECOMMENDED_CHAT_MODEL, + input=[ + { + "role": "system", + "content": "Where are the following coordinates located: " + f"({zone_home.attributes[ATTR_LATITUDE]}," + f" {zone_home.attributes[ATTR_LONGITUDE]})?", + } + ], + text={ + "format": { + "type": "json_schema", + "name": "approximate_location", + "description": "Approximate location data of the user " + "for refined web search results", + "schema": convert(location_schema), + "strict": False, + } + }, + store=False, + ) + location_data = location_schema(json.loads(response.output_text) or {}) + + if self.hass.config.country: + location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country + location_data[CONF_WEB_SEARCH_TIMEZONE] = self.hass.config.time_zone + + _LOGGER.debug("Location data: %s", location_data) + + return location_data + def openai_config_option_schema( hass: HomeAssistant, - options: dict[str, Any] | MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> VolDictType: """Return a schema for OpenAI completion options.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) - + ] + if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance( + suggested_llm_apis, str + ): + suggested_llm_apis = [suggested_llm_apis] schema: VolDictType = { vol.Optional( CONF_PROMPT, @@ -187,9 +271,8 @@ def openai_config_option_schema( ): TemplateSelector(), vol.Optional( CONF_LLM_HASS_API, - description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - default="none", - ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, @@ -227,10 +310,35 @@ def openai_config_option_schema( ): SelectSelector( SelectSelectorConfig( options=["low", "medium", "high"], - translation_key="reasoning_effort", + translation_key=CONF_REASONING_EFFORT, mode=SelectSelectorMode.DROPDOWN, ) ), + vol.Optional( + CONF_WEB_SEARCH, + description={"suggested_value": options.get(CONF_WEB_SEARCH)}, + default=RECOMMENDED_WEB_SEARCH, + ): bool, + vol.Optional( + CONF_WEB_SEARCH_CONTEXT_SIZE, + description={ + "suggested_value": options.get(CONF_WEB_SEARCH_CONTEXT_SIZE) + }, + default=RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key=CONF_WEB_SEARCH_CONTEXT_SIZE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional( + CONF_WEB_SEARCH_USER_LOCATION, + description={ + "suggested_value": options.get(CONF_WEB_SEARCH_USER_LOCATION) + }, + default=RECOMMENDED_WEB_SEARCH_USER_LOCATION, + ): bool, } ) return schema diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 793e021e332..f022b4840eb 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -3,22 +3,34 @@ import logging DOMAIN = "openai_conversation" -LOGGER = logging.getLogger(__package__) +LOGGER: logging.Logger = logging.getLogger(__package__) -CONF_RECOMMENDED = "recommended" -CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" +CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 150 -CONF_TOP_P = "top_p" -RECOMMENDED_TOP_P = 1.0 -CONF_TEMPERATURE = "temperature" -RECOMMENDED_TEMPERATURE = 1.0 +CONF_PROMPT = "prompt" +CONF_PROMPT = "prompt" CONF_REASONING_EFFORT = "reasoning_effort" +CONF_RECOMMENDED = "recommended" +CONF_TEMPERATURE = "temperature" +CONF_TOP_P = "top_p" +CONF_WEB_SEARCH = "web_search" +CONF_WEB_SEARCH_USER_LOCATION = "user_location" +CONF_WEB_SEARCH_CONTEXT_SIZE = "search_context_size" +CONF_WEB_SEARCH_CITY = "city" +CONF_WEB_SEARCH_REGION = "region" +CONF_WEB_SEARCH_COUNTRY = "country" +CONF_WEB_SEARCH_TIMEZONE = "timezone" +RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" +RECOMMENDED_MAX_TOKENS = 150 RECOMMENDED_REASONING_EFFORT = "low" +RECOMMENDED_TEMPERATURE = 1.0 +RECOMMENDED_TOP_P = 1.0 +RECOMMENDED_WEB_SEARCH = False +RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium" +RECOMMENDED_WEB_SEARCH_USER_LOCATION = False -UNSUPPORTED_MODELS = [ +UNSUPPORTED_MODELS: list[str] = [ "o1-mini", "o1-mini-2024-09-12", "o1-preview", @@ -29,3 +41,12 @@ UNSUPPORTED_MODELS = [ "gpt-4o-mini-realtime-preview", "gpt-4o-mini-realtime-preview-2024-12-17", ] + +WEB_SEARCH_MODELS: list[str] = [ + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4o", + "gpt-4o-search-preview", + "gpt-4o-mini", + "gpt-4o-mini-search-preview", +] diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index cc09ec77c0e..d55ffc2df0c 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -6,17 +6,31 @@ from typing import Any, Literal, cast import openai from openai._streaming import AsyncStream -from openai._types import NOT_GIVEN -from openai.types.chat import ( - ChatCompletionAssistantMessageParam, - ChatCompletionChunk, - ChatCompletionMessageParam, - ChatCompletionMessageToolCallParam, - ChatCompletionToolMessageParam, - ChatCompletionToolParam, +from openai.types.responses import ( + EasyInputMessageParam, + FunctionToolParam, + ResponseCompletedEvent, + ResponseErrorEvent, + ResponseFailedEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseFunctionToolCallParam, + ResponseIncompleteEvent, + ResponseInputParam, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputMessageParam, + ResponseReasoningItem, + ResponseReasoningItemParam, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ToolParam, + WebSearchToolParam, ) -from openai.types.chat.chat_completion_message_tool_call_param import Function -from openai.types.shared_params import FunctionDefinition +from openai.types.responses.response_input_param import FunctionCallOutput +from openai.types.responses.web_search_tool_param import UserLocation from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation @@ -24,7 +38,7 @@ 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 -from homeassistant.helpers import chat_session, device_registry as dr, intent, llm +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry @@ -35,6 +49,13 @@ from .const import ( CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, @@ -42,6 +63,7 @@ from .const import ( RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, ) # Max number of back and forth with the LLM to generate a response @@ -60,122 +82,146 @@ async def async_setup_entry( def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> ChatCompletionToolParam: +) -> FunctionToolParam: """Format tool specification.""" - tool_spec = FunctionDefinition( + return FunctionToolParam( + type="function", name=tool.name, parameters=convert(tool.parameters, custom_serializer=custom_serializer), + description=tool.description, + strict=False, ) - if tool.description: - tool_spec["description"] = tool.description - return ChatCompletionToolParam(type="function", function=tool_spec) def _convert_content_to_param( content: conversation.Content, -) -> ChatCompletionMessageParam: +) -> ResponseInputParam: """Convert any native chat message for this agent to the native format.""" - if content.role == "tool_result": - assert type(content) is conversation.ToolResultContent - return ChatCompletionToolMessageParam( - role="tool", - tool_call_id=content.tool_call_id, - content=json.dumps(content.tool_result), - ) - if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] - role = content.role + messages: ResponseInputParam = [] + if isinstance(content, conversation.ToolResultContent): + return [ + FunctionCallOutput( + type="function_call_output", + call_id=content.tool_call_id, + output=json.dumps(content.tool_result), + ) + ] + + if content.content: + role: Literal["user", "assistant", "system", "developer"] = content.role if role == "system": role = "developer" - return cast( - ChatCompletionMessageParam, - {"role": content.role, "content": content.content}, # type: ignore[union-attr] + messages.append( + EasyInputMessageParam(type="message", role=role, content=content.content) ) - # Handle the Assistant content including tool calls. - assert type(content) is conversation.AssistantContent - return ChatCompletionAssistantMessageParam( - role="assistant", - content=content.content, - tool_calls=[ - ChatCompletionMessageToolCallParam( - id=tool_call.id, - function=Function( - arguments=json.dumps(tool_call.tool_args), - name=tool_call.tool_name, - ), - type="function", + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + messages.extend( + ResponseFunctionToolCallParam( + type="function_call", + name=tool_call.tool_name, + arguments=json.dumps(tool_call.tool_args), + call_id=tool_call.id, ) for tool_call in content.tool_calls - ], - ) + ) + return messages async def _transform_stream( - result: AsyncStream[ChatCompletionChunk], + chat_log: conversation.ChatLog, + result: AsyncStream[ResponseStreamEvent], + messages: ResponseInputParam, ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform an OpenAI delta stream into HA format.""" - current_tool_call: dict | None = None + async for event in result: + LOGGER.debug("Received event: %s", event) - async for chunk in result: - LOGGER.debug("Received chunk: %s", chunk) - choice = chunk.choices[0] - - if choice.finish_reason: - if current_tool_call: - yield { - "tool_calls": [ - llm.ToolInput( - id=current_tool_call["id"], - tool_name=current_tool_call["tool_name"], - tool_args=json.loads(current_tool_call["tool_args"]), - ) - ] - } - - break - - delta = chunk.choices[0].delta - - # We can yield delta messages not continuing or starting tool calls - if current_tool_call is None and not delta.tool_calls: - yield { # type: ignore[misc] - key: value - for key in ("role", "content") - if (value := getattr(delta, key)) is not None - } - continue - - # When doing tool calls, we should always have a tool call - # object or we have gotten stopped above with a finish_reason set. - if ( - not delta.tool_calls - or not (delta_tool_call := delta.tool_calls[0]) - or not delta_tool_call.function - ): - raise ValueError("Expected delta with tool call") - - if current_tool_call and delta_tool_call.index == current_tool_call["index"]: - current_tool_call["tool_args"] += delta_tool_call.function.arguments or "" - continue - - # We got tool call with new index, so we need to yield the previous - if current_tool_call: + if isinstance(event, ResponseOutputItemAddedEvent): + if isinstance(event.item, ResponseOutputMessage): + yield {"role": event.item.role} + elif isinstance(event.item, ResponseFunctionToolCall): + # OpenAI has tool calls as individual events + # while HA puts tool calls inside the assistant message. + # We turn them into individual assistant content for HA + # to ensure that tools are called as soon as possible. + yield {"role": "assistant"} + current_tool_call = event.item + elif isinstance(event, ResponseOutputItemDoneEvent): + item = event.item.model_dump() + item.pop("status", None) + if isinstance(event.item, ResponseReasoningItem): + messages.append(cast(ResponseReasoningItemParam, item)) + elif isinstance(event.item, ResponseOutputMessage): + messages.append(cast(ResponseOutputMessageParam, item)) + elif isinstance(event.item, ResponseFunctionToolCall): + messages.append(cast(ResponseFunctionToolCallParam, item)) + elif isinstance(event, ResponseTextDeltaEvent): + yield {"content": event.delta} + elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): + current_tool_call.arguments += event.delta + elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): + current_tool_call.status = "completed" yield { "tool_calls": [ llm.ToolInput( - id=current_tool_call["id"], - tool_name=current_tool_call["tool_name"], - tool_args=json.loads(current_tool_call["tool_args"]), + id=current_tool_call.call_id, + tool_name=current_tool_call.name, + tool_args=json.loads(current_tool_call.arguments), ) ] } + elif isinstance(event, ResponseCompletedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + elif isinstance(event, ResponseIncompleteEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) - current_tool_call = { - "index": delta_tool_call.index, - "id": delta_tool_call.id, - "tool_name": delta_tool_call.function.name, - "tool_args": delta_tool_call.function.arguments or "", - } + if ( + event.response.incomplete_details + and event.response.incomplete_details.reason + ): + reason: str = event.response.incomplete_details.reason + else: + reason = "unknown reason" + + if reason == "max_output_tokens": + reason = "max output tokens reached" + elif reason == "content_filter": + reason = "content filter triggered" + + raise HomeAssistantError(f"OpenAI response incomplete: {reason}") + elif isinstance(event, ResponseFailedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + reason = "unknown reason" + if event.response.error is not None: + reason = event.response.error.message + raise HomeAssistantError(f"OpenAI response failed: {reason}") + elif isinstance(event, ResponseErrorEvent): + raise HomeAssistantError(f"OpenAI response error: {event.message}") class OpenAIConversationEntity( @@ -223,24 +269,12 @@ class OpenAIConversationEntity( conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) - async def _async_handle_message( self, user_input: conversation.ConversationInput, chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: - """Call the API.""" + """Process the user input and call the API.""" options = self.entry.options try: @@ -253,15 +287,56 @@ class OpenAIConversationEntity( except conversation.ConverseError as err: return err.as_conversation_result() - tools: list[ChatCompletionToolParam] | None = None + await self._async_handle_chat_log(chat_log) + + intent_response = intent.IntentResponse(language=user_input.language) + assert type(chat_log.content[-1]) is conversation.AssistantContent + intent_response.async_set_speech(chat_log.content[-1].content or "") + return conversation.ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.entry.options + + tools: list[ToolParam] | None = None if chat_log.llm_api: tools = [ _format_tool(tool, chat_log.llm_api.custom_serializer) for tool in chat_log.llm_api.tools ] + if options.get(CONF_WEB_SEARCH): + web_search = WebSearchToolParam( + type="web_search_preview", + search_context_size=options.get( + CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE + ), + ) + if options.get(CONF_WEB_SEARCH_USER_LOCATION): + web_search["user_location"] = UserLocation( + type="approximate", + city=options.get(CONF_WEB_SEARCH_CITY, ""), + region=options.get(CONF_WEB_SEARCH_REGION, ""), + country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), + timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), + ) + if tools is None: + tools = [] + tools.append(web_search) + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - messages = [_convert_content_to_param(content) for content in chat_log.content] + messages = [ + m + for content in chat_log.content + for m in _convert_content_to_param(content) + ] client = self.entry.runtime_data @@ -269,9 +344,8 @@ class OpenAIConversationEntity( for _iteration in range(MAX_TOOL_ITERATIONS): model_args = { "model": model, - "messages": messages, - "tools": tools or NOT_GIVEN, - "max_completion_tokens": options.get( + "input": messages, + "max_output_tokens": options.get( CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS ), "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), @@ -279,14 +353,20 @@ class OpenAIConversationEntity( "user": chat_log.conversation_id, "stream": True, } + if tools: + model_args["tools"] = tools if model.startswith("o"): - model_args["reasoning_effort"] = options.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) + model_args["reasoning"] = { + "effort": options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) + } + else: + model_args["store"] = False try: - result = await client.chat.completions.create(**model_args) + result = await client.responses.create(**model_args) except openai.RateLimitError as err: LOGGER.error("Rate limited by OpenAI: %s", err) raise HomeAssistantError("Rate limited or insufficient funds") from err @@ -294,25 +374,15 @@ class OpenAIConversationEntity( LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err - messages.extend( - [ - _convert_content_to_param(content) - async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(result) - ) - ] - ) + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(chat_log, result, messages) + ): + if not isinstance(content, conversation.AssistantContent): + messages.extend(_convert_content_to_param(content)) if not chat_log.unresponded_tool_results: break - intent_response = intent.IntentResponse(language=user_input.language) - assert type(chat_log.content[-1]) is conversation.AssistantContent - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, conversation_id=chat_log.conversation_id - ) - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/openai_conversation/icons.json b/homeassistant/components/openai_conversation/icons.json index 3abecd640d1..f0ece31c304 100644 --- a/homeassistant/components/openai_conversation/icons.json +++ b/homeassistant/components/openai_conversation/icons.json @@ -2,6 +2,9 @@ "services": { "generate_image": { "service": "mdi:image-sync" + }, + "generate_content": { + "service": "mdi:receipt-text" } } } diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index a7aa7884dc4..84369eb15a2 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.61.0"] + "requirements": ["openai==1.76.2"] } diff --git a/homeassistant/components/openai_conversation/services.yaml b/homeassistant/components/openai_conversation/services.yaml index 3db71cae383..75fa097f25d 100644 --- a/homeassistant/components/openai_conversation/services.yaml +++ b/homeassistant/components/openai_conversation/services.yaml @@ -38,3 +38,23 @@ generate_image: options: - "vivid" - "natural" +generate_content: + fields: + config_entry: + required: true + selector: + config_entry: + integration: openai_conversation + prompt: + required: true + selector: + text: + multiline: true + example: "Hello, how can I help you?" + filenames: + selector: + text: + multiline: true + example: | + - /path/to/file1.txt + - /path/to/file2.txt diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index b8768f8abbe..0a07fa354b2 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -24,34 +24,48 @@ "top_p": "Top P", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", "recommended": "Recommended model settings", - "reasoning_effort": "Reasoning effort" + "reasoning_effort": "Reasoning effort", + "web_search": "Enable web search", + "search_context_size": "Search context size", + "user_location": "Include home location" }, "data_description": { "prompt": "Instruct how the LLM should respond. This can be a template.", - "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)" + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)", + "web_search": "Allow the model to search the web for the latest information before generating a response", + "search_context_size": "High level guidance for the amount of context window space to use for the search", + "user_location": "Refine search results based on geography" } } }, "error": { - "model_not_supported": "This model is not supported, please select a different model" + "model_not_supported": "This model is not supported, please select a different model", + "web_search_not_supported": "Web search is not supported by this model" } }, "selector": { "reasoning_effort": { "options": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } + }, + "search_context_size": { + "options": { + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, "services": { "generate_image": { "name": "Generate image", - "description": "Turn a prompt into an image", + "description": "Turns a prompt into an image", "fields": { "config_entry": { - "name": "Config Entry", + "name": "Config entry", "description": "The config entry to use for this action" }, "prompt": { @@ -72,6 +86,24 @@ "description": "The style of the generated image" } } + }, + "generate_content": { + "name": "Generate content", + "description": "Sends a conversational query to ChatGPT including any attached image or PDF files", + "fields": { + "config_entry": { + "name": "Config entry", + "description": "The config entry to use for this action" + }, + "prompt": { + "name": "Prompt", + "description": "The prompt to send" + }, + "filenames": { + "name": "Files", + "description": "List of files to upload" + } + } } }, "exceptions": { diff --git a/homeassistant/components/opensky/strings.json b/homeassistant/components/opensky/strings.json index 4b4dc908b14..c699783551f 100644 --- a/homeassistant/components/opensky/strings.json +++ b/homeassistant/components/opensky/strings.json @@ -15,7 +15,7 @@ "options": { "step": { "init": { - "description": "You can login to your OpenSky account to increase the update frequency.", + "description": "You can log in to your OpenSky account to increase the update frequency.", "data": { "radius": "[%key:component::opensky::config::step::user::data::radius%]", "altitude": "[%key:component::opensky::config::step::user::data::altitude%]", diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 8c92c70ab49..87da159872d 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -9,8 +9,7 @@ import pyotgw.vars as gw_vars from serial import SerialException import voluptuous as vol -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DATE, ATTR_ID, @@ -21,21 +20,12 @@ from homeassistant.const import ( CONF_ID, CONF_NAME, EVENT_HOMEASSISTANT_STOP, - PRECISION_HALVES, - PRECISION_TENTHS, - PRECISION_WHOLE, Platform, ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_CH_OVRD, @@ -44,9 +34,6 @@ from .const import ( ATTR_LEVEL, ATTR_TRANSP_ARG, ATTR_TRANSP_CMD, - CONF_CLIMATE, - CONF_FLOOR_TEMP, - CONF_PRECISION, CONF_TEMPORARY_OVRD_MODE, CONNECTION_TIMEOUT, DATA_GATEWAYS, @@ -70,29 +57,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -# *_SCHEMA required for deprecated import from configuration.yaml, can be removed in 2025.4.0 -CLIMATE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_PRECISION): vol.In( - [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] - ), - vol.Optional(CONF_FLOOR_TEMP, default=False): cv.boolean, - } -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: cv.schema_with_slug_keys( - { - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_CLIMATE, default={}): CLIMATE_SCHEMA, - vol.Optional(CONF_NAME): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -118,35 +82,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b gateway = OpenThermGatewayHub(hass, config_entry) hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] = gateway - # Migration can be removed in 2025.4.0 - dev_reg = dr.async_get(hass) - if ( - migrate_device := dev_reg.async_get_device( - {(DOMAIN, config_entry.data[CONF_ID])} - ) - ) is not None: - dev_reg.async_update_device( - migrate_device.id, - new_identifiers={ - ( - DOMAIN, - f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}", - ) - }, - ) - - # Migration can be removed in 2025.4.0 - ent_reg = er.async_get(hass) - if ( - entity_id := ent_reg.async_get_entity_id( - CLIMATE_DOMAIN, DOMAIN, config_entry.data[CONF_ID] - ) - ) is not None: - ent_reg.async_update_entity( - entity_id, - new_unique_id=f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity", - ) - config_entry.add_update_listener(options_updated) try: @@ -164,33 +99,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -# Deprecated import from configuration.yaml, can be removed in 2025.4.0 -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the OpenTherm Gateway component.""" - if DOMAIN in config: - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_import_from_configuration_yaml", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - is_persistent=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_import_from_configuration_yaml", - ) - if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config: - conf = config[DOMAIN] - for device_id, device_config in conf.items(): - device_config[CONF_ID] = device_id - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=device_config - ) - ) - return True - - def register_services(hass: HomeAssistant) -> None: """Register services for the component.""" service_reset_schema = vol.Schema( diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index c69151c293a..68463e764f2 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -2,9 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass import logging -from types import MappingProxyType from typing import Any from pyotgw import vars as gw_vars @@ -94,7 +94,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): self, gw_hub: OpenThermGatewayHub, description: OpenThermClimateEntityDescription, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], ) -> None: """Initialize the entity.""" super().__init__(gw_hub, description) diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index bcbf279f3f7..a100dcb730f 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -95,19 +95,6 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN): """Handle manual initiation of the config flow.""" return await self.async_step_init(user_input) - # Deprecated import from configuration.yaml, can be removed in 2025.4.0 - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import an OpenTherm Gateway device as a config entry. - - This flow is triggered by `async_setup` for configured devices. - """ - formatted_config = { - CONF_NAME: import_data.get(CONF_NAME, import_data[CONF_ID]), - CONF_DEVICE: import_data[CONF_DEVICE], - CONF_ID: import_data[CONF_ID], - } - return await self.async_step_init(info=formatted_config) - def _show_form(self, errors: dict[str, str] | None = None) -> ConfigFlowResult: """Show the config flow form with possible errors.""" return self.async_show_form( diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index b49dea4a267..ae1a1eb9276 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -172,8 +172,8 @@ "vcc": "Vcc (5V)", "led_e": "LED E", "led_f": "LED F", - "home": "Home", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", "ds1820": "DS1820", "dhw_block": "Block hot water" } @@ -354,12 +354,6 @@ } } }, - "issues": { - "deprecated_import_from_configuration_yaml": { - "title": "Deprecated configuration", - "description": "Configuration of the OpenTherm Gateway integration through configuration.yaml is deprecated. Your configuration has been migrated to config entries. Please remove any OpenTherm Gateway configuration from your configuration.yaml." - } - }, "options": { "step": { "init": { diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index 9349d2cc116..f3b9aa686d5 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -54,10 +54,10 @@ "name": "Current UV level", "state": { "extreme": "Extreme", - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "very_high": "Very high" + "very_high": "[%key:common::state::very_high%]" } }, "max_uv_index": { diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index fa51b91dc6d..40ddf0ff37e 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant -from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS +from .const import CONFIG_FLOW_VERSION, DEFAULT_OWM_MODE, OWM_MODES, PLATFORMS from .coordinator import WeatherUpdateCoordinator from .repairs import async_create_issue, async_delete_issue from .utils import build_data_and_options @@ -39,7 +39,7 @@ async def async_setup_entry( language = entry.options[CONF_LANGUAGE] mode = entry.options[CONF_MODE] - if mode == OWM_MODE_V25: + if mode not in OWM_MODES: async_create_issue(hass, entry.entry_id) else: async_delete_issue(hass, entry.entry_id) @@ -70,7 +70,7 @@ async def async_migrate_entry( _LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version) if version < 5: - combined_data = {**data, **options, CONF_MODE: OWM_MODE_V25} + combined_data = {**data, **options, CONF_MODE: DEFAULT_OWM_MODE} new_data, new_options = build_data_and_options(combined_data) config_entries.async_update_entry( entry, diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index de317709f5b..0bc804a5b42 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -54,18 +54,11 @@ ATTR_API_DAILY_FORECAST = "daily_forecast" UPDATE_LISTENER = "update_listener" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] -FORECAST_MODE_HOURLY = "hourly" -FORECAST_MODE_DAILY = "daily" -FORECAST_MODE_FREE_DAILY = "freedaily" -FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly" -FORECAST_MODE_ONECALL_DAILY = "onecall_daily" OWM_MODE_FREE_CURRENT = "current" OWM_MODE_FREE_FORECAST = "forecast" OWM_MODE_V30 = "v3.0" -OWM_MODE_V25 = "v2.5" OWM_MODES = [ OWM_MODE_V30, - OWM_MODE_V25, OWM_MODE_FREE_CURRENT, OWM_MODE_FREE_FORECAST, ] diff --git a/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json index 14313a5a77e..2c32882b6ed 100644 --- a/homeassistant/components/openweathermap/manifest.json +++ b/homeassistant/components/openweathermap/manifest.json @@ -1,10 +1,10 @@ { "domain": "openweathermap", "name": "OpenWeatherMap", - "codeowners": ["@fabaff", "@freekode", "@nzapponi"], + "codeowners": ["@fabaff", "@freekode", "@nzapponi", "@wittypluck"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openweathermap", "iot_class": "cloud_polling", "loggers": ["pyopenweathermap"], - "requirements": ["pyopenweathermap==0.2.1"] + "requirements": ["pyopenweathermap==0.2.2"] } diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 0afab69b638..e37ff678708 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -45,12 +45,10 @@ from .const import ( ATTR_API_WIND_BEARING, ATTR_API_WIND_SPEED, ATTRIBUTION, - DEFAULT_NAME, DOMAIN, MANUFACTURER, OWM_MODE_FREE_FORECAST, ) -from .coordinator import WeatherUpdateCoordinator WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -89,7 +87,8 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=ATTR_API_WIND_BEARING, name="Wind bearing", native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key=ATTR_API_HUMIDITY, @@ -161,6 +160,8 @@ async def async_setup_entry( """Set up OpenWeatherMap sensor entities based on a config entry.""" domain_data = config_entry.runtime_data name = domain_data.name + unique_id = config_entry.unique_id + assert unique_id is not None weather_coordinator = domain_data.coordinator if domain_data.mode == OWM_MODE_FREE_FORECAST: @@ -174,7 +175,7 @@ async def async_setup_entry( async_add_entities( OpenWeatherMapSensor( name, - f"{config_entry.unique_id}-{description.key}", + unique_id, description, weather_coordinator, ) @@ -187,6 +188,7 @@ class AbstractOpenWeatherMapSensor(SensorEntity): _attr_should_poll = False _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -199,14 +201,12 @@ class AbstractOpenWeatherMapSensor(SensorEntity): self.entity_description = description self._coordinator = coordinator - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = unique_id - split_unique_id = unique_id.split("-") + self._attr_unique_id = f"{unique_id}-{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, f"{split_unique_id[0]}-{split_unique_id[1]}")}, + identifiers={(DOMAIN, unique_id)}, manufacturer=MANUFACTURER, - name=DEFAULT_NAME, + name=name, ) @property @@ -228,20 +228,7 @@ class AbstractOpenWeatherMapSensor(SensorEntity): class OpenWeatherMapSensor(AbstractOpenWeatherMapSensor): """Implementation of an OpenWeatherMap sensor.""" - def __init__( - self, - name: str, - unique_id: str, - description: SensorEntityDescription, - weather_coordinator: WeatherUpdateCoordinator, - ) -> None: - """Initialize the sensor.""" - super().__init__(name, unique_id, description, weather_coordinator) - self._weather_coordinator = weather_coordinator - @property def native_value(self) -> StateType: """Return the state of the device.""" - return self._weather_coordinator.data[ATTR_API_CURRENT].get( - self.entity_description.key - ) + return self._coordinator.data[ATTR_API_CURRENT].get(self.entity_description.key) diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index a6ad163e1c8..d6cdee77ce9 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -42,7 +42,6 @@ from .const import ( DOMAIN, MANUFACTURER, OWM_MODE_FREE_FORECAST, - OWM_MODE_V25, OWM_MODE_V30, ) from .coordinator import WeatherUpdateCoordinator @@ -80,6 +79,8 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina _attr_attribution = ATTRIBUTION _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA @@ -96,17 +97,16 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina ) -> None: """Initialize the sensor.""" super().__init__(weather_coordinator) - self._attr_name = name self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, unique_id)}, manufacturer=MANUFACTURER, - name=DEFAULT_NAME, + name=name, ) self.mode = mode - if mode in (OWM_MODE_V30, OWM_MODE_V25): + if mode == OWM_MODE_V30: self._attr_supported_features = ( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index aed89ccf46e..d03c30b7db0 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta import logging -from typing import cast +from typing import Any, cast from opower import ( Account, @@ -16,7 +16,11 @@ from opower import ( from opower.exceptions import ApiException, CannotConnect, InvalidAuth from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -26,7 +30,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -109,21 +113,69 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): _LOGGER.error("Error getting accounts: %s", err) raise for account in accounts: - id_prefix = "_".join( + id_prefix = ( ( - self.api.utility.subdomain(), - account.meter_type.name.lower(), - # Some utilities like AEP have "-" in their account id. - # Replace it with "_" to avoid "Invalid statistic_id" - account.utility_account_id.replace("-", "_").lower(), + f"{self.api.utility.subdomain()}_{account.meter_type.name}_" + f"{account.utility_account_id}" ) + # Some utilities like AEP have "-" in their account id. + # Other utilities like ngny-gas have "-" in their subdomain. + # Replace it with "_" to avoid "Invalid statistic_id" + .replace("-", "_") + .lower() ) cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" + compensation_statistic_id = f"{DOMAIN}:{id_prefix}_energy_compensation" consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" + return_statistic_id = f"{DOMAIN}:{id_prefix}_energy_return" _LOGGER.debug( - "Updating Statistics for %s and %s", + "Updating Statistics for %s, %s, %s, and %s", cost_statistic_id, + compensation_statistic_id, consumption_statistic_id, + return_statistic_id, + ) + + name_prefix = ( + f"Opower {self.api.utility.subdomain()} " + f"{account.meter_type.name.lower()} {account.utility_account_id}" + ) + cost_metadata = StatisticMetaData( + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"{name_prefix} cost", + source=DOMAIN, + statistic_id=cost_statistic_id, + unit_of_measurement=None, + ) + compensation_metadata = StatisticMetaData( + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"{name_prefix} compensation", + source=DOMAIN, + statistic_id=compensation_statistic_id, + unit_of_measurement=None, + ) + consumption_unit = ( + UnitOfEnergy.KILO_WATT_HOUR + if account.meter_type == MeterType.ELEC + else UnitOfVolume.CENTUM_CUBIC_FEET + ) + consumption_metadata = StatisticMetaData( + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"{name_prefix} consumption", + source=DOMAIN, + statistic_id=consumption_statistic_id, + unit_of_measurement=consumption_unit, + ) + return_metadata = StatisticMetaData( + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"{name_prefix} return", + source=DOMAIN, + statistic_id=return_statistic_id, + unit_of_measurement=consumption_unit, ) last_stat = await get_instance(self.hass).async_add_executor_job( @@ -135,9 +187,31 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): account, self.api.utility.timezone() ) cost_sum = 0.0 + compensation_sum = 0.0 consumption_sum = 0.0 + return_sum = 0.0 last_stats_time = None else: + migrated = await self._async_maybe_migrate_statistics( + account.utility_account_id, + { + cost_statistic_id: compensation_statistic_id, + consumption_statistic_id: return_statistic_id, + }, + { + cost_statistic_id: cost_metadata, + compensation_statistic_id: compensation_metadata, + consumption_statistic_id: consumption_metadata, + return_statistic_id: return_metadata, + }, + ) + if migrated: + # Skip update to avoid working on old data since the migration is done + # asynchronously. Update the statistics in the next refresh in 12h. + _LOGGER.debug( + "Statistics migration completed. Skipping update for now" + ) + continue cost_reads = await self._async_get_cost_reads( account, self.api.utility.timezone(), @@ -156,7 +230,12 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): self.hass, start, end, - {cost_statistic_id, consumption_statistic_id}, + { + cost_statistic_id, + compensation_statistic_id, + consumption_statistic_id, + return_statistic_id, + }, "hour", None, {"sum"}, @@ -171,53 +250,56 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): # We are in this code path only if get_last_statistics found a stat # so statistics_during_period should also have found at least one. assert stats - cost_sum = cast(float, stats[cost_statistic_id][0]["sum"]) - consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"]) + + def _safe_get_sum(records: list[Any]) -> float: + if records and "sum" in records[0]: + return float(records[0]["sum"]) + return 0.0 + + cost_sum = _safe_get_sum(stats.get(cost_statistic_id, [])) + compensation_sum = _safe_get_sum( + stats.get(compensation_statistic_id, []) + ) + consumption_sum = _safe_get_sum(stats.get(consumption_statistic_id, [])) + return_sum = _safe_get_sum(stats.get(return_statistic_id, [])) last_stats_time = stats[consumption_statistic_id][0]["start"] cost_statistics = [] + compensation_statistics = [] consumption_statistics = [] + return_statistics = [] for cost_read in cost_reads: start = cost_read.start_time if last_stats_time is not None and start.timestamp() <= last_stats_time: continue - cost_sum += cost_read.provided_cost - consumption_sum += cost_read.consumption + + cost_state = max(0, cost_read.provided_cost) + compensation_state = max(0, -cost_read.provided_cost) + consumption_state = max(0, cost_read.consumption) + return_state = max(0, -cost_read.consumption) + + cost_sum += cost_state + compensation_sum += compensation_state + consumption_sum += consumption_state + return_sum += return_state cost_statistics.append( + StatisticData(start=start, state=cost_state, sum=cost_sum) + ) + compensation_statistics.append( StatisticData( - start=start, state=cost_read.provided_cost, sum=cost_sum + start=start, state=compensation_state, sum=compensation_sum ) ) consumption_statistics.append( StatisticData( - start=start, state=cost_read.consumption, sum=consumption_sum + start=start, state=consumption_state, sum=consumption_sum ) ) - - name_prefix = ( - f"Opower {self.api.utility.subdomain()} " - f"{account.meter_type.name.lower()} {account.utility_account_id}" - ) - cost_metadata = StatisticMetaData( - has_mean=False, - has_sum=True, - name=f"{name_prefix} cost", - source=DOMAIN, - statistic_id=cost_statistic_id, - unit_of_measurement=None, - ) - consumption_metadata = StatisticMetaData( - has_mean=False, - has_sum=True, - name=f"{name_prefix} consumption", - source=DOMAIN, - statistic_id=consumption_statistic_id, - unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR - if account.meter_type == MeterType.ELEC - else UnitOfVolume.CENTUM_CUBIC_FEET, - ) + return_statistics.append( + StatisticData(start=start, state=return_state, sum=return_sum) + ) _LOGGER.debug( "Adding %s statistics for %s", @@ -225,6 +307,14 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): cost_statistic_id, ) async_add_external_statistics(self.hass, cost_metadata, cost_statistics) + _LOGGER.debug( + "Adding %s statistics for %s", + len(compensation_statistics), + compensation_statistic_id, + ) + async_add_external_statistics( + self.hass, compensation_metadata, compensation_statistics + ) _LOGGER.debug( "Adding %s statistics for %s", len(consumption_statistics), @@ -233,6 +323,135 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): async_add_external_statistics( self.hass, consumption_metadata, consumption_statistics ) + _LOGGER.debug( + "Adding %s statistics for %s", + len(return_statistics), + return_statistic_id, + ) + async_add_external_statistics(self.hass, return_metadata, return_statistics) + + async def _async_maybe_migrate_statistics( + self, + utility_account_id: str, + migration_map: dict[str, str], + metadata_map: dict[str, StatisticMetaData], + ) -> bool: + """Perform one-time statistics migration based on the provided map. + + Splits negative values from source IDs into target IDs. + + Args: + utility_account_id: The account ID (for issue_id). + migration_map: Map from source statistic ID to target statistic ID + (e.g., {cost_id: compensation_id}). + metadata_map: Map of all statistic IDs (source and target) to their metadata. + + """ + if not migration_map: + return False + + need_migration_source_ids = set() + for source_id, target_id in migration_map.items(): + last_target_stat = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, + self.hass, + 1, + target_id, + True, + set(), + ) + if not last_target_stat: + need_migration_source_ids.add(source_id) + if not need_migration_source_ids: + return False + + _LOGGER.info("Starting one-time migration for: %s", need_migration_source_ids) + + processed_stats: dict[str, list[StatisticData]] = {} + + existing_stats = await get_instance(self.hass).async_add_executor_job( + statistics_during_period, + self.hass, + dt_util.utc_from_timestamp(0), + None, + need_migration_source_ids, + "hour", + None, + {"start", "state", "sum"}, + ) + for source_id, source_stats in existing_stats.items(): + _LOGGER.debug("Found %d statistics for %s", len(source_stats), source_id) + if not source_stats: + continue + target_id = migration_map[source_id] + + updated_source_stats: list[StatisticData] = [] + new_target_stats: list[StatisticData] = [] + updated_source_sum = 0.0 + new_target_sum = 0.0 + need_migration = False + + prev_sum = 0.0 + for stat in source_stats: + start = dt_util.utc_from_timestamp(stat["start"]) + curr_sum = cast(float, stat["sum"]) + state = curr_sum - prev_sum + prev_sum = curr_sum + if state < 0: + need_migration = True + + updated_source_state = max(0, state) + new_target_state = max(0, -state) + + updated_source_sum += updated_source_state + new_target_sum += new_target_state + + updated_source_stats.append( + StatisticData( + start=start, state=updated_source_state, sum=updated_source_sum + ) + ) + new_target_stats.append( + StatisticData( + start=start, state=new_target_state, sum=new_target_sum + ) + ) + + if need_migration: + processed_stats[source_id] = updated_source_stats + processed_stats[target_id] = new_target_stats + else: + need_migration_source_ids.remove(source_id) + + if not need_migration_source_ids: + _LOGGER.debug("No migration needed") + return False + + for stat_id, stats in processed_stats.items(): + _LOGGER.debug("Applying %d migrated stats for %s", len(stats), stat_id) + async_add_external_statistics(self.hass, metadata_map[stat_id], stats) + + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id=f"return_to_grid_migration_{utility_account_id}", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="return_to_grid_migration", + translation_placeholders={ + "utility_account_id": utility_account_id, + "energy_settings": "/config/energy", + "target_ids": "\n".join( + { + str(metadata_map[v]["name"]) + for k, v in migration_map.items() + if k in need_migration_source_ids + } + ), + }, + ) + + return True async def _async_get_cost_reads( self, account: Account, time_zone_str: str, start_time: float | None = None diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 2da4511c0aa..a09405f1ca8 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.9.0"] + "requirements": ["opower==0.12.0"] } diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 362e6cd7596..3af968cf789 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -9,9 +9,9 @@ } }, "mfa": { - "description": "The TOTP secret below is not one of the 6 digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", + "description": "The TOTP secret below is not one of the 6-digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", "data": { - "totp_secret": "TOTP Secret" + "totp_secret": "TOTP secret" } }, "reauth_confirm": { @@ -19,7 +19,7 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "totp_secret": "TOTP Secret" + "totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]" } } }, @@ -31,5 +31,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "issues": { + "return_to_grid_migration": { + "title": "Return to grid statistics for account: {utility_account_id}", + "description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those in separate return statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Return to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue." + } } } diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index 7e10168d941..465f3f15c6b 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -55,12 +55,12 @@ "heater_mode": { "name": "Heater mode", "state": { - "auto": "Auto", + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", "extraenergy": "Extra energy", "ffr": "Fast frequency reserve", "legionella": "Legionella", - "manual": "Manual", - "off": "Off", "powersave": "Power save", "voltage": "Voltage" } @@ -70,7 +70,7 @@ "state": { "advanced": "Advanced", "gridcompany": "Grid company", - "off": "Off", + "off": "[%key:common::state::off%]", "oso": "OSO", "smartcompany": "Smart company" } diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py index 25380810862..42af6c74e45 100644 --- a/homeassistant/components/osramlightify/light.py +++ b/homeassistant/components/osramlightify/light.py @@ -279,7 +279,7 @@ class Luminary(LightEntity): return self._device_attributes @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._available diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 8aa1ed0e4fe..c9bf618ee8f 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -12,6 +12,7 @@ from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, + NotAuthenticatedException, NotSuchTokenException, TooManyRequestsException, ) @@ -92,7 +93,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) scenarios = await client.get_scenarios() else: scenarios = [] - except (BadCredentialsException, NotSuchTokenException) as exception: + except ( + BadCredentialsException, + NotSuchTokenException, + NotAuthenticatedException, + ) as exception: raise ConfigEntryAuthFailed("Invalid authentication") from exception except TooManyRequestsException as exception: raise ConfigEntryNotReady("Too many requests, try again later") from exception diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 09319d59932..5db96e17322 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -49,6 +49,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [ key=OverkizState.CORE_WATER_DETECTION, name="Water", icon="mdi:water", + device_class=BinarySensorDeviceClass.MOISTURE, value_fn=lambda state: state == OverkizCommandParam.DETECTED, ), # AirSensor/AirFlowSensor diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py index 059e64ef55d..4a05a94b635 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py @@ -58,9 +58,12 @@ class AtlanticElectricalHeater(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_ON_OFF)) - ] + if OverkizState.CORE_ON_OFF in self.device.states: + return OVERKIZ_TO_HVAC_MODES[ + cast(str, self.executor.select_state(OverkizState.CORE_ON_OFF)) + ] + + return HVACMode.OFF async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" 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 93c7d03293b..041571f7b5f 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 @@ -13,6 +13,7 @@ from homeassistant.components.climate import ( PRESET_NONE, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature @@ -56,6 +57,12 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.INTERNAL: HVACMode.AUTO, } +OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = { + OverkizCommandParam.STANDBY: HVACAction.IDLE, + OverkizCommandParam.INCREASE: HVACAction.HEATING, + OverkizCommandParam.NONE: HVACAction.OFF, +} + HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} TEMPERATURE_SENSOR_DEVICE_INDEX = 2 @@ -102,6 +109,14 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( OverkizCommand.SET_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] ) + @property + def hvac_action(self) -> HVACAction: + """Return the current running hvac operation ie. heating, idle, off.""" + states = self.device.states + if (state := states[OverkizState.CORE_REGULATION_MODE]) and state.value_as_str: + return OVERKIZ_TO_HVAC_ACTION[state.value_as_str] + return HVACAction.OFF + @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py index 0b5ba3ffcc7..e0cfebc2449 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py @@ -20,11 +20,13 @@ from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity PRESET_DRYING = "drying" +PRESET_PROG = "prog" OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { OverkizCommandParam.EXTERNAL: HVACMode.HEAT, # manu - OverkizCommandParam.INTERNAL: HVACMode.AUTO, # prog - OverkizCommandParam.STANDBY: HVACMode.OFF, + OverkizCommandParam.INTERNAL: HVACMode.AUTO, # prog (schedule, user program) - mapped as preset + OverkizCommandParam.AUTO: HVACMode.AUTO, # auto (intelligent, user behavior) + OverkizCommandParam.STANDBY: HVACMode.OFF, # off } HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} @@ -33,7 +35,6 @@ OVERKIZ_TO_PRESET_MODE: dict[str, str] = { OverkizCommandParam.BOOST: PRESET_BOOST, OverkizCommandParam.DRYING: PRESET_DRYING, } - PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()} TEMPERATURE_SENSOR_DEVICE_INDEX = 7 @@ -43,9 +44,15 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): """Representation of Atlantic Electrical Towel Dryer.""" _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] - _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] + _attr_preset_modes = [PRESET_NONE, PRESET_PROG] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.PRESET_MODE + ) def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator @@ -56,15 +63,15 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): TEMPERATURE_SENSOR_DEVICE_INDEX ) - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - - # Not all AtlanticElectricalTowelDryer models support presets, thus we need to check if the command is available + # Not all AtlanticElectricalTowelDryer models support temporary presets, + # thus we check if the command is available and then extend the presets if self.executor.has_command(OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE): - self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + # Extend preset modes with supported temporary presets, avoiding duplicates + self._attr_preset_modes += [ + mode + for mode in PRESET_MODE_TO_OVERKIZ + if mode not in self._attr_preset_modes + ] @property def hvac_mode(self) -> HVACMode: @@ -120,16 +127,53 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return OVERKIZ_TO_PRESET_MODE[ - cast( - str, - self.executor.select_state(OverkizState.IO_TOWEL_DRYER_TEMPORARY_STATE), - ) - ] + if ( + OverkizState.CORE_OPERATING_MODE in self.device.states + and cast(str, self.executor.select_state(OverkizState.CORE_OPERATING_MODE)) + == OverkizCommandParam.INTERNAL + ): + return PRESET_PROG + + if PRESET_DRYING in self._attr_preset_modes: + return OVERKIZ_TO_PRESET_MODE[ + cast( + str, + self.executor.select_state( + OverkizState.IO_TOWEL_DRYER_TEMPORARY_STATE + ), + ) + ] + + 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_TOWEL_DRYER_TEMPORARY_STATE, - PRESET_MODE_TO_OVERKIZ[preset_mode], - ) + # If the preset mode is set to prog, we need to set the operating mode to internal + if preset_mode == PRESET_PROG: + # If currently in a temporary preset (drying or boost), turn it off before turn on prog + if self.preset_mode in (PRESET_DRYING, PRESET_BOOST): + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE, + OverkizCommandParam.PERMANENT_HEATING, + ) + + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_OPERATING_MODE, + OverkizCommandParam.INTERNAL, + ) + + # If the preset mode is set from prog to none, we need to set the operating mode to external + # This will set the towel dryer to auto (intelligent mode) + elif preset_mode == PRESET_NONE and self.preset_mode == PRESET_PROG: + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_OPERATING_MODE, + OverkizCommandParam.AUTO, + ) + + # Normal behavior of setting a preset mode + # for towel dryers that support temporary presets + elif PRESET_DRYING in self._attr_preset_modes: + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE, + PRESET_MODE_TO_OVERKIZ[preset_mode], + ) diff --git a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py index 5ca17f9b6b1..381ec4d83ba 100644 --- a/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py @@ -77,7 +77,7 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ, HVACMode.OFF] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 @@ -110,9 +110,14 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - await self.executor.async_execute_command( - OverkizCommand.SET_ACTIVE_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] - ) + if hvac_mode is HVACMode.OFF: + await self.executor.async_execute_command( + OverkizCommand.SET_ON_OFF, OverkizCommandParam.OFF + ) + else: + await self.executor.async_execute_command( + OverkizCommand.SET_ACTIVE_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] + ) @property def preset_mode(self) -> str | None: diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index af955e5fb95..520e9460147 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -13,12 +13,12 @@ from pyoverkiz.exceptions import ( BadCredentialsException, CozyTouchBadCredentialsException, MaintenanceException, + NotAuthenticatedException, NotSuchTokenException, TooManyAttemptsBannedException, TooManyRequestsException, UnknownUserException, ) -from pyoverkiz.models import OverkizServer from pyoverkiz.obfuscate import obfuscate_id from pyoverkiz.utils import generate_local_server, is_overkiz_gateway import voluptuous as vol @@ -31,7 +31,6 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -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 @@ -39,15 +38,12 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER -class DeveloperModeDisabled(HomeAssistantError): - """Error to indicate Somfy Developer Mode is disabled.""" - - class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Overkiz (by Somfy).""" VERSION = 1 + _verify_ssl: bool = True _api_type: APIType = APIType.CLOUD _user: str | None = None _server: str = DEFAULT_SERVER @@ -57,27 +53,36 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): """Validate user credentials.""" user_input[CONF_API_TYPE] = self._api_type - client = self._create_cloud_client( - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - server=SUPPORTED_SERVERS[user_input[CONF_HUB]], - ) - await client.login(register_event_listener=False) - - # For Local API, we create and activate a local token if self._api_type == APIType.LOCAL: - user_input[CONF_TOKEN] = await self._create_local_api_token( - cloud_client=client, - host=user_input[CONF_HOST], + user_input[CONF_VERIFY_SSL] = self._verify_ssl + session = async_create_clientsession( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] + ) + client = OverkizClient( + username="", + password="", + token=user_input[CONF_TOKEN], + session=session, + server=generate_local_server(host=user_input[CONF_HOST]), verify_ssl=user_input[CONF_VERIFY_SSL], ) + else: # APIType.CLOUD + session = async_create_clientsession(self.hass) + client = OverkizClient( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + server=SUPPORTED_SERVERS[user_input[CONF_HUB]], + session=session, + ) + + await client.login(register_event_listener=False) # Set main gateway id as unique id if gateways := await client.get_gateways(): for gateway in gateways: if is_overkiz_gateway(gateway.id): - gateway_id = gateway.id - await self.async_set_unique_id(gateway_id, raise_on_progress=False) + await self.async_set_unique_id(gateway.id, raise_on_progress=False) + break return user_input @@ -141,15 +146,13 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._user = user_input[CONF_USERNAME] - - # inherit the server from previous step user_input[CONF_HUB] = self._server try: await self.async_validate_input(user_input) except TooManyRequestsException: errors["base"] = "too_many_requests" - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) 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] in { @@ -211,16 +214,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._host = user_input[CONF_HOST] - self._user = user_input[CONF_USERNAME] - - # inherit the server from previous step + self._verify_ssl = user_input[CONF_VERIFY_SSL] user_input[CONF_HUB] = self._server try: user_input = await self.async_validate_input(user_input) except TooManyRequestsException: errors["base"] = "too_many_requests" - except BadCredentialsException: + except ( + BadCredentialsException, + NotSuchTokenException, + NotAuthenticatedException, + ): errors["base"] = "invalid_auth" except ClientConnectorCertificateError as exception: errors["base"] = "certificate_verify_failed" @@ -232,10 +237,6 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "server_in_maintenance" except TooManyAttemptsBannedException: errors["base"] = "too_many_attempts" - except NotSuchTokenException: - errors["base"] = "no_such_token" - except DeveloperModeDisabled: - errors["base"] = "developer_mode_disabled" except UnknownUserException: # Somfy Protect accounts are not supported since they don't use # the Overkiz API server. Login will return unknown user. @@ -264,9 +265,8 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_HOST, default=self._host): str, - vol.Required(CONF_USERNAME, default=self._user): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_VERIFY_SSL, default=True): bool, + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_VERIFY_SSL, default=self._verify_ssl): bool, } ), description_placeholders=description_placeholders, @@ -320,64 +320,15 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth.""" - # overkiz entries always have unique IDs + # Overkiz entries always have unique IDs self.context["title_placeholders"] = {"gateway_id": cast(str, self.unique_id)} - - self._user = entry_data[CONF_USERNAME] - self._server = entry_data[CONF_HUB] self._api_type = entry_data.get(CONF_API_TYPE, APIType.CLOUD) + self._server = entry_data[CONF_HUB] if self._api_type == APIType.LOCAL: self._host = entry_data[CONF_HOST] + self._verify_ssl = entry_data[CONF_VERIFY_SSL] + else: + self._user = entry_data[CONF_USERNAME] return await self.async_step_user(dict(entry_data)) - - def _create_cloud_client( - self, username: str, password: str, server: OverkizServer - ) -> OverkizClient: - session = async_create_clientsession(self.hass) - return OverkizClient( - username=username, password=password, server=server, session=session - ) - - async def _create_local_api_token( - self, cloud_client: OverkizClient, host: str, verify_ssl: bool - ) -> str: - """Create local API token.""" - # Create session on Somfy cloud server to generate an access token for local API - gateways = await cloud_client.get_gateways() - - gateway_id = "" - for gateway in gateways: - # Overkiz can return multiple gateways, but we only can generate a token - # for the main gateway. - if is_overkiz_gateway(gateway.id): - gateway_id = gateway.id - - developer_mode = await cloud_client.get_setup_option( - f"developerMode-{gateway_id}" - ) - - if developer_mode is None: - raise DeveloperModeDisabled - - token = await cloud_client.generate_local_token(gateway_id) - await cloud_client.activate_local_token( - gateway_id=gateway_id, token=token, label="Home Assistant/local" - ) - - session = async_create_clientsession(self.hass, verify_ssl=verify_ssl) - - # Local API - local_client = OverkizClient( - username="", - password="", - token=token, - session=session, - server=generate_local_server(host=host), - verify_ssl=verify_ssl, - ) - - await local_client.login() - - return token diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 4b79cfc9c06..598bf4b06d0 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -79,7 +79,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): """Fetch Overkiz data via event listener.""" try: events = await self.client.fetch_events() - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) as exception: raise ConfigEntryAuthFailed("Invalid authentication.") from exception except TooManyConcurrentRequestsException as exception: raise UpdateFailed("Too many concurrent requests.") from exception @@ -98,7 +98,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): try: await self.client.login() self.devices = await self._get_devices() - except BadCredentialsException as exception: + except (BadCredentialsException, NotAuthenticatedException) as exception: raise ConfigEntryAuthFailed("Invalid authentication.") from exception except TooManyRequestsException as exception: raise UpdateFailed("Too many requests, try again later.") from exception diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index c13b2fc96ba..d3f49b20f08 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -35,7 +35,6 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): self.executor = OverkizExecutor(device_url, coordinator) self._attr_assumed_state = not self.device.states - self._attr_available = self.device.available self._attr_unique_id = self.device.device_url if self.is_sub_device: @@ -44,6 +43,11 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): self._attr_device_info = self.generate_device_info() + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.device.available and super().available + @property def is_sub_device(self) -> bool: """Return True if device is a sub device.""" diff --git a/homeassistant/components/overkiz/icons.json b/homeassistant/components/overkiz/icons.json new file mode 100644 index 00000000000..3347750063e --- /dev/null +++ b/homeassistant/components/overkiz/icons.json @@ -0,0 +1,46 @@ +{ + "entity": { + "climate": { + "overkiz": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "mdi:thermostat-auto", + "comfort-1": "mdi:thermometer", + "comfort-2": "mdi:thermometer-low", + "drying": "mdi:hair-dryer", + "frost_protection": "mdi:snowflake", + "prog": "mdi:clock-outline", + "external": "mdi:remote" + } + } + } + } + }, + "select": { + "open_closed_pedestrian": { + "default": "mdi:content-save-cog" + }, + "open_closed_partial": { + "default": "mdi:content-save-cog" + }, + "memorized_simple_volume": { + "default": "mdi:volume-medium", + "state": { + "highest": "mdi:volume-high", + "standard": "mdi:volume-medium" + } + }, + "operating_mode": { + "default": "mdi:sun-snowflake", + "state": { + "heating": "mdi:heat-wave", + "cooling": "mdi:snowflake" + } + }, + "active_zones": { + "default": "mdi:shield-lock" + } + } + } +} diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index c25accd87f3..6f1af6d5aca 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.0"], + "requirements": ["pyoverkiz==1.17.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 83c0e7cf7a8..70028f138b7 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -172,6 +172,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ native_max_value=7, set_native_value=_async_set_native_value_boost_mode_duration, entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, ), # DomesticHotWaterProduction - away mode in days (0 - 6) OverkizNumberDescription( @@ -182,6 +184,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ native_min_value=0, native_max_value=6, entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, ), ] diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index e23dafdaab8..d93b71b540f 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -72,7 +72,6 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, name="Position", - icon="mdi:content-save-cog", options=[ OverkizCommandParam.OPEN, OverkizCommandParam.PEDESTRIAN, @@ -84,7 +83,6 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.CORE_OPEN_CLOSED_PARTIAL, name="Position", - icon="mdi:content-save-cog", options=[ OverkizCommandParam.OPEN, OverkizCommandParam.PARTIAL, @@ -96,7 +94,6 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.IO_MEMORIZED_SIMPLE_VOLUME, name="Memorized simple volume", - icon="mdi:volume-high", options=[OverkizCommandParam.STANDARD, OverkizCommandParam.HIGHEST], select_option=_select_option_memorized_simple_volume, entity_category=EntityCategory.CONFIG, @@ -106,20 +103,20 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.OVP_HEATING_TEMPERATURE_INTERFACE_OPERATING_MODE, name="Operating mode", - icon="mdi:sun-snowflake", options=[OverkizCommandParam.HEATING, OverkizCommandParam.COOLING], select_option=lambda option, execute_command: execute_command( OverkizCommand.SET_OPERATING_MODE, option ), entity_category=EntityCategory.CONFIG, + translation_key="operating_mode", ), # StatefulAlarmController OverkizSelectDescription( key=OverkizState.CORE_ACTIVE_ZONES, name="Active zones", - icon="mdi:shield-lock", options=["", "A", "B", "C", "A,B", "B,C", "A,C", "A,B,C"], select_option=_select_option_active_zone, + translation_key="active_zones", ), ] diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 9214398a37b..b0a15b3970e 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( EntityCategory, UnitOfEnergy, UnitOfPower, + UnitOfSpeed, UnitOfTemperature, UnitOfTime, UnitOfVolume, @@ -70,6 +71,15 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ options=["full", "normal", "medium", "low", "verylow"], translation_key="battery", ), + OverkizSensorDescription( + key=OverkizState.CORE_BATTERY_DISCRETE_LEVEL, + name="Battery", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:battery", + device_class=SensorDeviceClass.ENUM, + options=["good", "medium", "low", "critical"], + translation_key="battery", + ), OverkizSensorDescription( key=OverkizState.CORE_RSSI_LEVEL, name="RSSI level", @@ -117,6 +127,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Outlet engine", icon="mdi:fan-chevron-down", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), OverkizSensorDescription( @@ -143,14 +154,23 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ OverkizSensorDescription( key=OverkizState.CORE_FOSSIL_ENERGY_CONSUMPTION, name="Fossil energy consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_GAS_CONSUMPTION, name="Gas consumption", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_THERMAL_ENERGY_CONSUMPTION, name="Thermal energy consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), # LightSensor/LuminanceSensor OverkizSensorDescription( @@ -195,7 +215,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF2, @@ -204,7 +224,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF3, @@ -213,7 +233,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF4, @@ -222,7 +242,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF5, @@ -231,7 +251,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF6, @@ -240,7 +260,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF7, @@ -249,7 +269,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF8, @@ -258,7 +278,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF9, @@ -267,7 +287,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), # HumiditySensor/RelativeHumiditySensor OverkizSensorDescription( @@ -333,6 +353,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Sun energy", native_value=lambda value: round(cast(float, value), 2), icon="mdi:solar-power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), # WindSensor/WindSpeedSensor @@ -341,6 +363,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Wind speed", native_value=lambda value: round(cast(float, value), 2), icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, ), # SmokeSensor/SmokeSensor @@ -389,6 +413,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ native_value=lambda value: OVERKIZ_STATE_TO_TRANSLATION.get( cast(str, value), cast(str, value) ), + device_class=SensorDeviceClass.ENUM, + options=["dead", "low_battery", "maintenance_required", "no_defect"], ), # DomesticHotWaterProduction/WaterHeatingSystem OverkizSensorDescription( diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 0c564a003d6..c8f0fae3622 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -32,17 +32,15 @@ } }, "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\nAfter 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\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or 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%]", + "token": "[%key:common::config_flow::data::api_token%]", "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).", + "token": "Token generated by the app used to control your device.", "verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname." } } @@ -71,14 +69,14 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "Auto", - "comfort-1": "Comfort 1", - "comfort-2": "Comfort 2", + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", + "comfort-1": "Comfort -1°C", + "comfort-2": "Comfort -2°C", "drying": "Drying", "external": "External", "freeze": "Freeze", "frost_protection": "Frost protection", - "manual": "Manual", "night": "Night", "prog": "Prog" } @@ -114,24 +112,32 @@ "highest": "Highest", "standard": "Standard" } + }, + "operating_mode": { + "state": { + "heating": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]", + "cooling": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]" + } } }, "sensor": { "battery": { "state": { "full": "Full", - "low": "Low", - "normal": "Normal", - "medium": "Medium", - "verylow": "Very low" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]", + "medium": "[%key:common::state::medium%]", + "verylow": "[%key:common::state::very_low%]", + "good": "Good", + "critical": "Critical" } }, "discrete_rssi_level": { "state": { "good": "Good", - "low": "Low", - "normal": "Normal", - "verylow": "Very low" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]", + "verylow": "[%key:common::state::very_low%]" } }, "priority_lock_originator": { diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py index 9895ea84c2c..2960cefe10c 100644 --- a/homeassistant/components/overkiz/water_heater/__init__.py +++ b/homeassistant/components/overkiz/water_heater/__init__.py @@ -13,6 +13,9 @@ from ..entity import OverkizEntity from .atlantic_domestic_hot_water_production_mlb_component import ( AtlanticDomesticHotWaterProductionMBLComponent, ) +from .atlantic_domestic_hot_water_production_v2_io_component import ( + AtlanticDomesticHotWaterProductionV2IOComponent, +) from .atlantic_pass_apc_dhw import AtlanticPassAPCDHW from .domestic_hot_water_production import DomesticHotWaterProduction from .hitachi_dhw import HitachiDHW @@ -52,4 +55,5 @@ WIDGET_TO_WATER_HEATER_ENTITY = { CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY = { "modbuslink:AtlanticDomesticHotWaterProductionMBLComponent": AtlanticDomesticHotWaterProductionMBLComponent, + "io:AtlanticDomesticHotWaterProductionV2_CV4E_IOComponent": AtlanticDomesticHotWaterProductionV2IOComponent, } diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py new file mode 100644 index 00000000000..7e7db07f847 --- /dev/null +++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py @@ -0,0 +1,332 @@ +"""Support for AtlanticDomesticHotWaterProductionV2IOComponent.""" + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..entity import OverkizEntity + +DEFAULT_MIN_TEMP: float = 50.0 +DEFAULT_MAX_TEMP: float = 62.0 +MAX_BOOST_MODE_DURATION: int = 7 + +DHWP_AWAY_MODES = [ + OverkizCommandParam.ABSENCE, + OverkizCommandParam.AWAY, + OverkizCommandParam.FROSTPROTECTION, +] + + +class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeaterEntity): + """Representation of AtlanticDomesticHotWaterProductionV2IOComponent (io).""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + | WaterHeaterEntityFeature.AWAY_MODE + | WaterHeaterEntityFeature.ON_OFF + ) + _attr_operation_list = [ + STATE_ECO, + STATE_PERFORMANCE, + STATE_HEAT_PUMP, + STATE_ELECTRIC, + ] + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + + min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE] + if min_temp: + return cast(float, min_temp.value_as_float) + return DEFAULT_MIN_TEMP + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + + max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE] + if max_temp: + return cast(float, max_temp.value_as_float) + return DEFAULT_MAX_TEMP + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + + return cast( + float, + self.executor.select_state( + OverkizState.IO_MIDDLE_WATER_TEMPERATURE, + ), + ) + + @property + def target_temperature(self) -> float: + """Return the temperature corresponding to the PRESET.""" + + return cast( + float, + self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + + temperature = kwargs.get(ATTR_TEMPERATURE) + await self.executor.async_execute_command( + OverkizCommand.SET_TARGET_TEMPERATURE, temperature, refresh_afterwards=False + ) + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + await self.coordinator.async_refresh() + + @property + def is_state_eco(self) -> bool: + """Return true if eco mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.MANUAL_ECO_ACTIVE + ) + + @property + def is_state_performance(self) -> bool: + """Return true if performance mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.AUTO_MODE + ) + + @property + def is_state_heat_pump(self) -> bool: + """Return true if heat pump mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.MANUAL_ECO_INACTIVE + ) + + @property + def is_away_mode_on(self) -> bool: + """Return true if away mode is on.""" + + away_mode_duration = cast( + str, self.executor.select_state(OverkizState.IO_AWAY_MODE_DURATION) + ) + # away_mode_duration can be either a Literal["always"] + if away_mode_duration == OverkizCommandParam.ALWAYS: + return True + + # Or an int of 0 to 7 days. But it still is a string. + if away_mode_duration.isdecimal() and int(away_mode_duration) > 0: + return True + + return False + + @property + def current_operation(self) -> str | None: + """Return current operation.""" + + # The Away Mode leaves the current operation unchanged + if self.is_boost_mode_on: + return STATE_ELECTRIC + + if self.is_state_eco: + return STATE_ECO + + if self.is_state_performance: + return STATE_PERFORMANCE + + if self.is_state_heat_pump: + return STATE_HEAT_PUMP + + return None + + @property + def is_boost_mode_on(self) -> bool: + """Return true if boost mode is on.""" + + return ( + cast( + int, + self.executor.select_state(OverkizState.CORE_BOOST_MODE_DURATION), + ) + > 0 + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + + if operation_mode == STATE_ECO: + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.MANUAL_ECO_ACTIVE, + refresh_afterwards=False, + ) + # ECO changes the target temperature so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + await self.coordinator.async_refresh() + + elif operation_mode == STATE_PERFORMANCE: + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.AUTO_MODE, + refresh_afterwards=False, + ) + + await self.coordinator.async_refresh() + + elif operation_mode == STATE_HEAT_PUMP: + refresh_target_temp = False + if self.is_state_performance: + # Switching from STATE_PERFORMANCE to STATE_HEAT_PUMP + # changes the target temperature and requires a target temperature refresh + refresh_target_temp = True + + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.MANUAL_ECO_INACTIVE, + refresh_afterwards=False, + ) + + if refresh_target_temp: + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, + refresh_afterwards=False, + ) + + await self.coordinator.async_refresh() + + elif operation_mode == STATE_ELECTRIC: + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + if not self.is_boost_mode_on: + await self.async_turn_boost_mode_on(refresh_afterwards=False) + await self.coordinator.async_refresh() + + async def async_turn_away_mode_on(self, refresh_afterwards: bool = True) -> None: + """Turn away mode on.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.ON, + }, + refresh_afterwards=False, + ) + # Toggling the AWAY mode changes away mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_AWAY_MODE_DURATION, + refresh_afterwards=False, + ) + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_away_mode_off(self, refresh_afterwards: bool = True) -> None: + """Turn away mode off.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + # Toggling the AWAY mode changes away mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_AWAY_MODE_DURATION, + refresh_afterwards=False, + ) + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_boost_mode_on(self, refresh_afterwards: bool = True) -> None: + """Turn boost mode on.""" + + refresh_target_temp = False + if self.is_state_performance: + # Switching from STATE_PERFORMANCE to BOOST requires a target temperature refresh + refresh_target_temp = True + + await self.executor.async_execute_command( + OverkizCommand.SET_BOOST_MODE_DURATION, + MAX_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.ON, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + if refresh_target_temp: + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_boost_mode_off(self, refresh_afterwards: bool = True) -> None: + """Turn boost mode off.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + # Toggling the BOOST mode changes boost mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + if refresh_afterwards: + await self.coordinator.async_refresh() diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 14650fd5c25..ce8b9fe9fec 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -90,7 +90,7 @@ "services": { "get_requests": { "name": "Get requests", - "description": "Get media requests from Overseerr.", + "description": "Retrieves a list of media requests from Overseerr.", "fields": { "config_entry_id": { "name": "Overseerr instance", @@ -106,7 +106,7 @@ }, "requested_by": { "name": "Requested by", - "description": "Filter the requests by the user id that requested them." + "description": "Filter the requests by the user ID that requested them." } } } diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 7ccbbb69aa1..80a06478506 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -19,7 +19,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import DOMAIN as OT_DOMAIN +from . import DOMAIN async def async_setup_entry( @@ -38,22 +38,22 @@ async def async_setup_entry( entities = [] for dev_id in dev_ids: - entity = hass.data[OT_DOMAIN]["devices"][dev_id] = OwnTracksEntity(dev_id) + entity = hass.data[DOMAIN]["devices"][dev_id] = OwnTracksEntity(dev_id) entities.append(entity) @callback def _receive_data(dev_id, **data): """Receive set location.""" - entity = hass.data[OT_DOMAIN]["devices"].get(dev_id) + entity = hass.data[DOMAIN]["devices"].get(dev_id) if entity is not None: entity.update_data(data) return - entity = hass.data[OT_DOMAIN]["devices"][dev_id] = OwnTracksEntity(dev_id, data) + entity = hass.data[DOMAIN]["devices"][dev_id] = OwnTracksEntity(dev_id, data) async_add_entities([entity]) - hass.data[OT_DOMAIN]["context"].set_async_see(_receive_data) + hass.data[DOMAIN]["context"].set_async_see(_receive_data) async_add_entities(entities) @@ -121,7 +121,7 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): @property def device_info(self) -> DeviceInfo: """Return the device info.""" - device_info = DeviceInfo(identifiers={(OT_DOMAIN, self._dev_id)}) + device_info = DeviceInfo(identifiers={(DOMAIN, self._dev_id)}) if "host_name" in self._data: device_info["name"] = self._data["host_name"] return device_info diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index 501ee777fe9..59a2ba1ffe9 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -52,8 +52,8 @@ "fan_mode": { "state": { "silent": "Silent", - "auto": "Auto", - "high": "High" + "auto": "[%key:common::state::auto%]", + "high": "[%key:common::state::high%]" } } } @@ -74,7 +74,7 @@ "status": { "name": "Status", "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "off_timer": "Timer-regulated switch off", "test_fire": "Ignition test", "heatup": "Pellet feed", @@ -83,7 +83,7 @@ "burning": "Operating", "burning_mod": "Operating - Modulating", "unknown": "Unknown", - "cool_fluid": "Stand-by", + "cool_fluid": "[%key:common::state::standby%]", "fire_stop": "Switch off", "clean_fire": "Burn pot cleaning", "cooling": "Cooling in progress", diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py index 0b2f5b7055f..064b2930971 100644 --- a/homeassistant/components/pandora/media_player.py +++ b/homeassistant/components/pandora/media_player.py @@ -94,18 +94,22 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._attr_media_duration = 0 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", encoding="utf-8") + async def _start_pianobar(self) -> bool: + pianobar = pexpect.spawn("pianobar", encoding="utf-8") + pianobar.delaybeforesend = None + # mypy thinks delayafterread must be a float but that is not what pexpect says + # https://github.com/pexpect/pexpect/blob/4.9/pexpect/expect.py#L170 + pianobar.delayafterread = None # type: ignore[assignment] + pianobar.delayafterclose = 0 + pianobar.delayafterterminate = 0 _LOGGER.debug("Started pianobar subprocess") - mode = self._pianobar.expect( - ["Receiving new playlist", "Select station:", "Email:"] + mode = await pianobar.expect( + ["Receiving new playlist", "Select station:", "Email:"], + async_=True, ) if mode == 1: # station list was presented. dismiss it. - self._pianobar.sendcontrol("m") + pianobar.sendcontrol("m") elif mode == 2: _LOGGER.warning( "The pianobar client is not configured to log in. " @@ -113,16 +117,20 @@ class PandoraMediaPlayer(MediaPlayerEntity): "https://www.home-assistant.io/integrations/pandora/" ) # pass through the email/password prompts to quit cleanly - self._pianobar.sendcontrol("m") - self._pianobar.sendcontrol("m") - self._pianobar.terminate() - self._pianobar = None - return - self._update_stations() - self.update_playing_status() + pianobar.sendcontrol("m") + pianobar.sendcontrol("m") + pianobar.terminate() + return False + self._pianobar = pianobar + return True - self._attr_state = MediaPlayerState.IDLE - self.schedule_update_ha_state() + async def async_turn_on(self) -> None: + """Turn the media player on.""" + if self.state == MediaPlayerState.OFF and await self._start_pianobar(): + await self._update_stations() + await self.update_playing_status() + self._attr_state = MediaPlayerState.IDLE + self.schedule_update_ha_state() def turn_off(self) -> None: """Turn the media player off.""" @@ -142,30 +150,24 @@ class PandoraMediaPlayer(MediaPlayerEntity): self._attr_state = MediaPlayerState.OFF self.schedule_update_ha_state() - def media_play(self) -> None: + async def async_media_play(self) -> None: """Send play command.""" - self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) + await self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) self._attr_state = MediaPlayerState.PLAYING self.schedule_update_ha_state() - def media_pause(self) -> None: + async def async_media_pause(self) -> None: """Send pause command.""" - self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) + await self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) self._attr_state = MediaPlayerState.PAUSED self.schedule_update_ha_state() - def media_next_track(self) -> None: + async def async_media_next_track(self) -> None: """Go to next track.""" - self._send_pianobar_command(SERVICE_MEDIA_NEXT_TRACK) + await self._send_pianobar_command(SERVICE_MEDIA_NEXT_TRACK) self.schedule_update_ha_state() - @property - def media_title(self) -> str | None: - """Title of current playing media.""" - self.update_playing_status() - return self._attr_media_title - - def select_source(self, source: str) -> None: + async def async_select_source(self, source: str) -> None: """Choose a different Pandora station and play it.""" if self.source_list is None: return @@ -176,45 +178,46 @@ class PandoraMediaPlayer(MediaPlayerEntity): return _LOGGER.debug("Setting station %s, %d", source, station_index) assert self._pianobar is not None - self._send_station_list_command() + await self._send_station_list_command() self._pianobar.sendline(f"{station_index}") - self._pianobar.expect("\r\n") + await self._pianobar.expect("\r\n", async_=True) self._attr_state = MediaPlayerState.PLAYING - def _send_station_list_command(self) -> None: + async 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) + await self._pianobar.expect("Select station:", async_=True, timeout=1) except pexpect.exceptions.TIMEOUT: # try again. Buffer was contaminated. - self._clear_buffer() + await self._clear_buffer() self._pianobar.send("s") - self._pianobar.expect("Select station:") + await self._pianobar.expect("Select station:", async_=True) - def update_playing_status(self) -> None: + async def update_playing_status(self) -> None: """Query pianobar for info about current media_title, station.""" - response = self._query_for_playing_status() + response = await self._query_for_playing_status() if not response: return self._update_current_station(response) self._update_current_song(response) self._update_song_position() - def _query_for_playing_status(self) -> str | None: + async def _query_for_playing_status(self) -> str | None: """Query system for info about current track.""" assert self._pianobar is not None - self._clear_buffer() + await self._clear_buffer() self._pianobar.send("i") try: - match_idx = self._pianobar.expect( + match_idx = await self._pianobar.expect( [ r"(\d\d):(\d\d)/(\d\d):(\d\d)", "No song playing", "Select station", "Receiving new playlist", - ] + ], + async_=True, ) except pexpect.exceptions.EOF: _LOGGER.warning("Pianobar process already exited") @@ -229,11 +232,11 @@ class PandoraMediaPlayer(MediaPlayerEntity): _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 - self.update_playing_status() + await self.update_playing_status() return None if match_idx == 3: _LOGGER.debug("Received new playlist list") - self.update_playing_status() + await self.update_playing_status() return None return self._pianobar.before @@ -292,7 +295,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): repr(self._pianobar.after), ) - def _send_pianobar_command(self, service_cmd: str) -> None: + async 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) @@ -300,13 +303,13 @@ class PandoraMediaPlayer(MediaPlayerEntity): if command is None: _LOGGER.warning("Command %s not supported yet", service_cmd) return - self._clear_buffer() + await self._clear_buffer() self._pianobar.sendline(command) - def _update_stations(self) -> None: + async def _update_stations(self) -> None: """List defined Pandora stations.""" assert self._pianobar is not None - self._send_station_list_command() + await self._send_station_list_command() station_lines = self._pianobar.before or "" _LOGGER.debug("Getting stations: %s", station_lines) self._attr_source_list = [] @@ -320,7 +323,7 @@ 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) -> None: + async def _clear_buffer(self) -> None: """Clear buffer from pexpect. This is necessary because there are a bunch of 00:00 in the buffer @@ -328,7 +331,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): """ assert self._pianobar is not None try: - while not self._pianobar.expect(".+", timeout=0.1): + while not await self._pianobar.expect(".+", async_=True, timeout=0.1): pass except pexpect.exceptions.TIMEOUT: pass diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 416f1a2c062..d13add0c2dd 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -108,8 +108,8 @@ "name": "State", "state": { "charging": "[%key:common::state::charging%]", - "error": "Error", - "fault": "Fault", + "error": "[%key:common::state::error%]", + "fault": "[%key:common::state::fault%]", "invalid": "Invalid", "no_ev_connected": "No EV connected", "suspended": "Suspended" diff --git a/homeassistant/components/peco/strings.json b/homeassistant/components/peco/strings.json index cdf5bb497db..c4683056dd7 100644 --- a/homeassistant/components/peco/strings.json +++ b/homeassistant/components/peco/strings.json @@ -4,7 +4,7 @@ "user": { "data": { "county": "County", - "phone_number": "Phone Number" + "phone_number": "Phone number" }, "data_description": { "county": "County used for outage number retrieval", diff --git a/homeassistant/components/pegel_online/entity.py b/homeassistant/components/pegel_online/entity.py index 4e157a5f63b..d69b0e13667 100644 --- a/homeassistant/components/pegel_online/entity.py +++ b/homeassistant/components/pegel_online/entity.py @@ -13,18 +13,13 @@ class PegelOnlineEntity(CoordinatorEntity[PegelOnlineDataUpdateCoordinator]): """Representation of a PEGELONLINE entity.""" _attr_has_entity_name = True - _attr_available = True def __init__(self, coordinator: PegelOnlineDataUpdateCoordinator) -> None: """Initialize a PEGELONLINE entity.""" super().__init__(coordinator) self.station = coordinator.station self._attr_extra_state_attributes = {} - - @property - def device_info(self) -> DeviceInfo: - """Return the device information of the entity.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.station.uuid)}, name=f"{self.station.name} {self.station.water_name}", manufacturer=self.station.agency, diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index fd90683a9b2..ee2e6750911 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from aiopegelonline.models import CurrentMeasurement +from aiopegelonline.models import CurrentMeasurement, StationMeasurements from homeassistant.components.sensor import ( SensorDeviceClass, @@ -24,67 +25,68 @@ from .entity import PegelOnlineEntity class PegelOnlineSensorEntityDescription(SensorEntityDescription): """PEGELONLINE sensor entity description.""" - measurement_key: str + measurement_fn: Callable[[StationMeasurements], CurrentMeasurement | None] SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( PegelOnlineSensorEntityDescription( key="air_temperature", translation_key="air_temperature", - measurement_key="air_temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.air_temperature, ), PegelOnlineSensorEntityDescription( key="clearance_height", translation_key="clearance_height", - measurement_key="clearance_height", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, + measurement_fn=lambda data: data.clearance_height, ), PegelOnlineSensorEntityDescription( key="oxygen_level", translation_key="oxygen_level", - measurement_key="oxygen_level", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.oxygen_level, ), PegelOnlineSensorEntityDescription( key="ph_value", - measurement_key="ph_value", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PH, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.ph_value, ), PegelOnlineSensorEntityDescription( key="water_speed", translation_key="water_speed", - measurement_key="water_speed", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.SPEED, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.water_speed, ), PegelOnlineSensorEntityDescription( key="water_flow", translation_key="water_flow", - measurement_key="water_flow", state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.water_flow, ), PegelOnlineSensorEntityDescription( key="water_level", translation_key="water_level", - measurement_key="water_level", state_class=SensorStateClass.MEASUREMENT, + measurement_fn=lambda data: data.water_level, ), PegelOnlineSensorEntityDescription( key="water_temperature", translation_key="water_temperature", - measurement_key="water_temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, + measurement_fn=lambda data: data.water_temperature, ), ) @@ -101,7 +103,7 @@ async def async_setup_entry( [ PegelOnlineSensor(coordinator, description) for description in SENSORS - if getattr(coordinator.data, description.measurement_key) is not None + if description.measurement_fn(coordinator.data) is not None ] ) @@ -135,7 +137,9 @@ class PegelOnlineSensor(PegelOnlineEntity, SensorEntity): @property def measurement(self) -> CurrentMeasurement: """Return the measurement data of the entity.""" - return getattr(self.coordinator.data, self.entity_description.measurement_key) + measurement = self.entity_description.measurement_fn(self.coordinator.data) + assert measurement is not None # we ensure existence in async_setup_entry + return measurement @property def native_value(self) -> float: diff --git a/homeassistant/components/pegel_online/strings.json b/homeassistant/components/pegel_online/strings.json index b8d18e63a4f..7d0702754af 100644 --- a/homeassistant/components/pegel_online/strings.json +++ b/homeassistant/components/pegel_online/strings.json @@ -2,10 +2,10 @@ "config": { "step": { "user": { - "description": "Select the area, where you want to search for water measuring stations", + "description": "Select the area in which you want to search for water measuring stations", "data": { "location": "[%key:common::config_flow::data::location%]", - "radius": "Search radius (in km)" + "radius": "Search radius" } }, "select_station": { diff --git a/homeassistant/components/person/manifest.json b/homeassistant/components/person/manifest.json index 7f370be6fbe..0c1792e9277 100644 --- a/homeassistant/components/person/manifest.json +++ b/homeassistant/components/person/manifest.json @@ -1,7 +1,6 @@ { "domain": "person", "name": "Person", - "after_dependencies": ["device_tracker"], "codeowners": [], "dependencies": ["image_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/person", diff --git a/homeassistant/components/pglab/__init__.py b/homeassistant/components/pglab/__init__.py index 7307ac2f801..a490f476f83 100644 --- a/homeassistant/components/pglab/__init__.py +++ b/homeassistant/components/pglab/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from pypglab.mqtt import ( Client as PyPGLabMqttClient, Sub_State as PyPGLabSubState, - Subcribe_CallBack as PyPGLabSubscribeCallBack, + Subscribe_CallBack as PyPGLabSubscribeCallBack, ) from homeassistant.components import mqtt @@ -23,12 +23,14 @@ from homeassistant.helpers import config_validation as cv from .const import DOMAIN, LOGGER from .discovery import PGLabDiscovery -type PGLABConfigEntry = ConfigEntry[PGLabDiscovery] +type PGLabConfigEntry = ConfigEntry[PGLabDiscovery] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: PGLabConfigEntry +) -> bool: """Set up PG LAB Electronics integration from a config entry.""" async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None: @@ -67,19 +69,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> boo pglab_mqtt = PyPGLabMqttClient(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe) # Setup PGLab device discovery. - entry.runtime_data = PGLabDiscovery() + config_entry.runtime_data = PGLabDiscovery() # Start to discovery PG Lab devices. - await entry.runtime_data.start(hass, pglab_mqtt, entry) + await config_entry.runtime_data.start(hass, pglab_mqtt, config_entry) return True -async def async_unload_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: PGLabConfigEntry +) -> bool: """Unload a config entry.""" # Stop PGLab device discovery. - pglab_discovery = entry.runtime_data - await pglab_discovery.stop(hass, entry) + pglab_discovery = config_entry.runtime_data + await pglab_discovery.stop(hass, config_entry) return True diff --git a/homeassistant/components/pglab/coordinator.py b/homeassistant/components/pglab/coordinator.py new file mode 100644 index 00000000000..b703f368eb1 --- /dev/null +++ b/homeassistant/components/pglab/coordinator.py @@ -0,0 +1,78 @@ +"""Coordinator for PG LAB Electronics.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE +from pypglab.device import Device as PyPGLabDevice +from pypglab.sensor import StatusSensor as PyPGLabSensors + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.dt import utcnow + +from .const import DOMAIN, LOGGER + +if TYPE_CHECKING: + from . import PGLabConfigEntry + + +class PGLabSensorsCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to update Sensor Entities when receiving new data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: PGLabConfigEntry, + pglab_device: PyPGLabDevice, + ) -> None: + """Initialize.""" + + # get a reference of PG Lab device internal sensors state + self._sensors: PyPGLabSensors = pglab_device.status_sensor + + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + ) + + @callback + def _new_sensors_data(self, payload: str) -> None: + """Handle new sensor data.""" + + # notify all listeners that new sensor values are available + self.async_set_updated_data(self._sensors.state) + + async def subscribe_topics(self) -> None: + """Subscribe the sensors state to be notifty from MQTT update messages.""" + + # subscribe to the pypglab sensors to receive updates from the mqtt broker + # when a new sensor values are available + await self._sensors.subscribe_topics() + + # set the callback to be called when a new sensor values are available + self._sensors.set_on_state_callback(self._new_sensors_data) + + def get_sensor_value(self, sensor_key: str) -> float | datetime | None: + """Return the value of a sensor.""" + + if self.data: + value = self.data[sensor_key] + + if (sensor_key == SENSOR_REBOOT_TIME) and value: + # convert the reboot time to a datetime object + return utcnow() - timedelta(seconds=value) + + if (sensor_key == SENSOR_TEMPERATURE) and value: + # convert the temperature value to a float + return float(value) + + if (sensor_key == SENSOR_VOLTAGE) and value: + # convert the voltage value to a float + return float(value) + + return None diff --git a/homeassistant/components/pglab/cover.py b/homeassistant/components/pglab/cover.py new file mode 100644 index 00000000000..8385fd95ffa --- /dev/null +++ b/homeassistant/components/pglab/cover.py @@ -0,0 +1,107 @@ +"""PG LAB Electronics Cover.""" + +from __future__ import annotations + +from typing import Any + +from pypglab.device import Device as PyPGLabDevice +from pypglab.shutter import Shutter as PyPGLabShutter + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .discovery import PGLabDiscovery +from .entity import PGLabEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switches for device.""" + + @callback + def async_discover( + pglab_device: PyPGLabDevice, pglab_shutter: PyPGLabShutter + ) -> None: + """Discover and add a PG LAB Cover.""" + pglab_discovery = config_entry.runtime_data + pglab_cover = PGLabCover(pglab_discovery, pglab_device, pglab_shutter) + async_add_entities([pglab_cover]) + + # Register the callback to create the cover entity when discovered. + pglab_discovery = config_entry.runtime_data + await pglab_discovery.register_platform(hass, Platform.COVER, async_discover) + + +class PGLabCover(PGLabEntity, CoverEntity): + """A PGLab Cover.""" + + _attr_translation_key = "shutter" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_shutter: PyPGLabShutter, + ) -> None: + """Initialize the Cover class.""" + + super().__init__( + pglab_discovery, + pglab_device, + pglab_shutter, + ) + + self._attr_unique_id = f"{pglab_device.id}_shutter{pglab_shutter.id}" + self._attr_translation_placeholders = {"shutter_id": pglab_shutter.id} + + self._shutter = pglab_shutter + + self._attr_device_class = CoverDeviceClass.SHUTTER + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._shutter.open() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + await self._shutter.close() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._shutter.stop() + + @property + def is_closed(self) -> bool | None: + """Return if cover is closed.""" + if not self._shutter.state: + return None + return self._shutter.state == PyPGLabShutter.STATE_CLOSED + + @property + def is_closing(self) -> bool | None: + """Return if the cover is closing.""" + if not self._shutter.state: + return None + return self._shutter.state == PyPGLabShutter.STATE_CLOSING + + @property + def is_opening(self) -> bool | None: + """Return if the cover is opening.""" + if not self._shutter.state: + return None + return self._shutter.state == PyPGLabShutter.STATE_OPENING diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index af6bedc9bf4..c83ea4466fa 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -25,20 +25,24 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity from .const import DISCOVERY_TOPIC, DOMAIN, LOGGER +from .coordinator import PGLabSensorsCoordinator if TYPE_CHECKING: - from . import PGLABConfigEntry + from . import PGLabConfigEntry # Supported platforms. PLATFORMS = [ + Platform.COVER, + Platform.SENSOR, Platform.SWITCH, ] # Used to create a new component entity. CREATE_NEW_ENTITY = { + Platform.COVER: "pglab_create_new_entity_cover", + Platform.SENSOR: "pglab_create_new_entity_sensor", Platform.SWITCH: "pglab_create_new_entity_switch", } @@ -66,7 +70,12 @@ def get_device_id_from_discovery_topic(topic: str) -> str | None: class DiscoverDeviceInfo: """Keeps information of the PGLab discovered device.""" - def __init__(self, pglab_device: PyPGLabDevice) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: PGLabConfigEntry, + pglab_device: PyPGLabDevice, + ) -> None: """Initialize the device discovery info.""" # Hash string represents the devices actual configuration, @@ -74,14 +83,15 @@ class DiscoverDeviceInfo: # When the hash string changes the devices entities must be rebuilt. self._hash = pglab_device.hash self._entities: list[tuple[str, str]] = [] + self.coordinator = PGLabSensorsCoordinator(hass, config_entry, pglab_device) - def add_entity(self, entity: Entity) -> None: + def add_entity(self, platform_domain: str, entity_unique_id: str | None) -> None: """Add an entity.""" # PGLabEntity always have unique IDs if TYPE_CHECKING: - assert entity.unique_id is not None - self._entities.append((entity.platform.domain, entity.unique_id)) + assert entity_unique_id is not None + self._entities.append((platform_domain, entity_unique_id)) @property def hash(self) -> int: @@ -94,6 +104,17 @@ class DiscoverDeviceInfo: return self._entities +async def create_discover_device_info( + hass: HomeAssistant, config_entry: PGLabConfigEntry, pglab_device: PyPGLabDevice +) -> DiscoverDeviceInfo: + """Create a new DiscoverDeviceInfo instance.""" + discovery_info = DiscoverDeviceInfo(hass, config_entry, pglab_device) + + # Subscribe to sensor state changes. + await discovery_info.coordinator.subscribe_topics() + return discovery_info + + @dataclass class PGLabDiscovery: """Discovery a PGLab device with the following MQTT topic format pglab/discovery/[device]/config.""" @@ -166,7 +187,10 @@ class PGLabDiscovery: del self._discovered[device_id] async def start( - self, hass: HomeAssistant, mqtt: PyPGLabMqttClient, entry: PGLABConfigEntry + self, + hass: HomeAssistant, + mqtt: PyPGLabMqttClient, + config_entry: PGLabConfigEntry, ) -> None: """Start discovering a PGLab devices.""" @@ -192,11 +216,11 @@ class PGLabDiscovery: # Create a new device. device_registry = dr.async_get(hass) device_registry.async_get_or_create( - config_entry_id=entry.entry_id, + config_entry_id=config_entry.entry_id, configuration_url=f"http://{pglab_device.ip}/", connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, identifiers={(DOMAIN, pglab_device.id)}, - manufacturer=pglab_device.manufactor, + manufacturer=pglab_device.manufacturer, model=pglab_device.type, name=pglab_device.name, sw_version=pglab_device.firmware_version, @@ -223,9 +247,18 @@ class PGLabDiscovery: self.__clean_discovered_device(hass, pglab_device.id) # Add a new device. - discovery_info = DiscoverDeviceInfo(pglab_device) + discovery_info = await create_discover_device_info( + hass, config_entry, pglab_device + ) self._discovered[pglab_device.id] = discovery_info + # Create all new cover entities. + for s in pglab_device.shutters: + # the HA entity is not yet created, send a message to create it + async_dispatcher_send( + hass, CREATE_NEW_ENTITY[Platform.COVER], pglab_device, s + ) + # Create all new relay entities. for r in pglab_device.relays: # The HA entity is not yet created, send a message to create it. @@ -233,6 +266,14 @@ class PGLabDiscovery: hass, CREATE_NEW_ENTITY[Platform.SWITCH], pglab_device, r ) + # Create all new sensor entities. + async_dispatcher_send( + hass, + CREATE_NEW_ENTITY[Platform.SENSOR], + pglab_device, + discovery_info.coordinator, + ) + topics = { "discovery_topic": { "topic": f"{self._discovery_topic}/#", @@ -241,7 +282,7 @@ class PGLabDiscovery: } # Forward setup all HA supported platforms. - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) self._mqtt_client = mqtt self._substate = async_prepare_subscribe_topics(hass, self._substate, topics) @@ -256,9 +297,9 @@ class PGLabDiscovery: ) self._disconnect_platform.append(disconnect_callback) - async def stop(self, hass: HomeAssistant, entry: PGLABConfigEntry) -> None: + async def stop(self, hass: HomeAssistant, config_entry: PGLabConfigEntry) -> None: """Stop to discovery PG LAB devices.""" - await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) # Disconnect all registered platforms. for disconnect_callback in self._disconnect_platform: @@ -266,7 +307,9 @@ class PGLabDiscovery: async_unsubscribe_topics(hass, self._substate) - async def add_entity(self, entity: Entity, device_id: str): + async def add_entity( + self, platform_domain: str, entity_unique_id: str | None, device_id: str + ): """Save a new PG LAB device entity.""" # Be sure that the device is been discovered. @@ -274,4 +317,4 @@ class PGLabDiscovery: raise PGLabDiscoveryError("Unknown device, device_id not discovered") discovery_info = self._discovered[device_id] - discovery_info.add_entity(entity) + discovery_info.add_entity(platform_domain, entity_unique_id) diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index 1b8975a3bbe..c0a02f4f835 100644 --- a/homeassistant/components/pglab/entity.py +++ b/homeassistant/components/pglab/entity.py @@ -8,59 +8,86 @@ from pypglab.entity import Entity as PyPGLabEntity from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import PGLabSensorsCoordinator from .discovery import PGLabDiscovery -class PGLabEntity(Entity): - """Representation of a PGLab entity in Home Assistant.""" +class PGLabBaseEntity(Entity): + """Base class of a PGLab entity in Home Assistant.""" _attr_has_entity_name = True def __init__( self, - discovery: PGLabDiscovery, - device: PyPGLabDevice, - entity: PyPGLabEntity, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, ) -> None: """Initialize the class.""" - self._id = entity.id - self._device_id = device.id - self._entity = entity - self._discovery = discovery + self._device_id = pglab_device.id + self._discovery = pglab_discovery # Information about the device that is partially visible in the UI. self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.id)}, - name=device.name, - sw_version=device.firmware_version, - hw_version=device.hardware_version, - model=device.type, - manufacturer=device.manufactor, - configuration_url=f"http://{device.ip}/", - connections={(CONNECTION_NETWORK_MAC, device.mac)}, + identifiers={(DOMAIN, pglab_device.id)}, + name=pglab_device.name, + sw_version=pglab_device.firmware_version, + hw_version=pglab_device.hardware_version, + model=pglab_device.type, + manufacturer=pglab_device.manufacturer, + configuration_url=f"http://{pglab_device.ip}/", + connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, ) async def async_added_to_hass(self) -> None: """Update the device discovery info.""" - self._entity.set_on_state_callback(self.state_updated) - await self._entity.subscribe_topics() - - await super().async_added_to_hass() - # Inform PGLab discovery instance that a new entity is available. # This is important to know in case the device needs to be reconfigured # and the entity can be potentially destroyed. - await self._discovery.add_entity(self, self._device_id) + await self._discovery.add_entity( + self.platform.domain, + self.unique_id, + self._device_id, + ) + + # propagate the async_added_to_hass to the super class + await super().async_added_to_hass() + + +class PGLabEntity(PGLabBaseEntity): + """Representation of a PGLab entity in Home Assistant.""" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_entity: PyPGLabEntity, + ) -> None: + """Initialize the class.""" + + super().__init__(pglab_discovery, pglab_device) + + self._id = pglab_entity.id + self._entity: PyPGLabEntity = pglab_entity + + async def async_added_to_hass(self) -> None: + """Subscribe pypglab entity to be updated from mqtt when pypglab entity internal state change.""" + + # set the callback to be called when pypglab entity state is changed + self._entity.set_on_state_callback(self.state_updated) + + # subscribe to the pypglab entity to receive updates from the mqtt broker + await self._entity.subscribe_topics() + await super().async_added_to_hass() async def async_will_remove_from_hass(self) -> None: """Unsubscribe when removed.""" await super().async_will_remove_from_hass() - await self._entity.unsubscribe_topics() self._entity.set_on_state_callback(None) @@ -68,3 +95,18 @@ class PGLabEntity(Entity): def state_updated(self, payload: str) -> None: """Handle state updates.""" self.async_write_ha_state() + + +class PGLabSensorEntity(PGLabBaseEntity, CoordinatorEntity[PGLabSensorsCoordinator]): + """Representation of a PGLab sensor entity in Home Assistant.""" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_coordinator: PGLabSensorsCoordinator, + ) -> None: + """Initialize the class.""" + + PGLabBaseEntity.__init__(self, pglab_discovery, pglab_device) + CoordinatorEntity.__init__(self, pglab_coordinator) diff --git a/homeassistant/components/pglab/manifest.json b/homeassistant/components/pglab/manifest.json index 7f7d596be77..c8dca6c6229 100644 --- a/homeassistant/components/pglab/manifest.json +++ b/homeassistant/components/pglab/manifest.json @@ -9,6 +9,6 @@ "loggers": ["pglab"], "mqtt": ["pglab/discovery/#"], "quality_scale": "bronze", - "requirements": ["pypglab==0.0.3"], + "requirements": ["pypglab==0.0.5"], "single_config_entry": true } diff --git a/homeassistant/components/pglab/sensor.py b/homeassistant/components/pglab/sensor.py new file mode 100644 index 00000000000..ce19ec3a21a --- /dev/null +++ b/homeassistant/components/pglab/sensor.py @@ -0,0 +1,109 @@ +"""Sensor for PG LAB Electronics.""" + +from __future__ import annotations + +from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE +from pypglab.device import Device as PyPGLabDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import Platform, UnitOfElectricPotential, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PGLabConfigEntry +from .coordinator import PGLabSensorsCoordinator +from .discovery import PGLabDiscovery +from .entity import PGLabSensorEntity + +PARALLEL_UPDATES = 0 + +SENSOR_INFO: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_VOLTAGE, + translation_key="mpu_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_REBOOT_TIME, + translation_key="runtime", + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:progress-clock", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PGLabConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor for device.""" + + @callback + def async_discover( + pglab_device: PyPGLabDevice, + pglab_coordinator: PGLabSensorsCoordinator, + ) -> None: + """Discover and add a PG LAB Sensor.""" + pglab_discovery = config_entry.runtime_data + + sensors: list[PGLabSensor] = [ + PGLabSensor( + description, + pglab_discovery, + pglab_device, + pglab_coordinator, + ) + for description in SENSOR_INFO + ] + + async_add_entities(sensors) + + # Register the callback to create the sensor entity when discovered. + pglab_discovery = config_entry.runtime_data + await pglab_discovery.register_platform(hass, Platform.SENSOR, async_discover) + + +class PGLabSensor(PGLabSensorEntity, SensorEntity): + """A PGLab sensor.""" + + def __init__( + self, + description: SensorEntityDescription, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_coordinator: PGLabSensorsCoordinator, + ) -> None: + """Initialize the Sensor class.""" + + super().__init__(pglab_discovery, pglab_device, pglab_coordinator) + + self._attr_unique_id = f"{pglab_device.id}_{description.key}" + self.entity_description = description + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + + self._attr_native_value = self.coordinator.get_sensor_value( + self.entity_description.key + ) + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return PG LAB sensor availability.""" + return super().available and self.native_value is not None diff --git a/homeassistant/components/pglab/strings.json b/homeassistant/components/pglab/strings.json index 8f9021cdcca..c6f80d12f09 100644 --- a/homeassistant/components/pglab/strings.json +++ b/homeassistant/components/pglab/strings.json @@ -15,10 +15,26 @@ } }, "entity": { + "cover": { + "shutter": { + "name": "Shutter {shutter_id}" + } + }, "switch": { "relay": { "name": "Relay {relay_id}" } + }, + "sensor": { + "temperature": { + "name": "Temperature" + }, + "runtime": { + "name": "Run time" + }, + "mpu_voltage": { + "name": "MPU voltage" + } } } } diff --git a/homeassistant/components/pglab/switch.py b/homeassistant/components/pglab/switch.py index 554b5cf80ca..76b177e84c4 100644 --- a/homeassistant/components/pglab/switch.py +++ b/homeassistant/components/pglab/switch.py @@ -12,7 +12,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PGLABConfigEntry +from . import PGLabConfigEntry from .discovery import PGLabDiscovery from .entity import PGLabEntity @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: PGLABConfigEntry, + config_entry: PGLabConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for device.""" @@ -52,9 +52,9 @@ class PGLabSwitch(PGLabEntity, SwitchEntity): """Initialize the Switch class.""" super().__init__( - discovery=pglab_discovery, - device=pglab_device, - entity=pglab_relay, + pglab_discovery, + pglab_device, + pglab_relay, ) self._attr_unique_id = f"{pglab_device.id}_relay{pglab_relay.id}" diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index bf15292335e..87e3323a30c 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, cast from haphilipsjs import PhilipsTV from haphilipsjs.typing import AmbilightCurrentConfiguration @@ -328,7 +328,7 @@ class PhilipsTVLightEntity(PhilipsJsEntity, LightEntity): """Turn the bulb on.""" brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color) - attr_effect = kwargs.get(ATTR_EFFECT, self.effect) + attr_effect = cast(str, kwargs.get(ATTR_EFFECT, self.effect)) if not self._tv.on: raise HomeAssistantError("TV is not available") diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index 09f28da39a4..251964c15d0 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/picnic", "iot_class": "cloud_polling", "loggers": ["python_picnic_api2"], - "requirements": ["python-picnic-api2==1.2.2"] + "requirements": ["python-picnic-api2==1.2.4"] } diff --git a/homeassistant/components/picotts/tts.py b/homeassistant/components/picotts/tts.py index 44d33145b3d..54caf1a2b26 100644 --- a/homeassistant/components/picotts/tts.py +++ b/homeassistant/components/picotts/tts.py @@ -56,10 +56,15 @@ class PicoProvider(Provider): with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpf: fname = tmpf.name - cmd = ["pico2wave", "--wave", fname, "-l", language, "--", message] - subprocess.call(cmd) + cmd = ["pico2wave", "--wave", fname, "-l", language] + result = subprocess.run(cmd, text=True, input=message, check=False) data = None try: + if result.returncode != 0: + _LOGGER.error( + "Error running pico2wave, return code: %s", result.returncode + ) + return (None, None) with open(fname, "rb") as voice: data = voice.read() except OSError: diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index ef9f74b4207..c301a1b277d 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -2,16 +2,16 @@ "entity": { "sensor": { "round_trip_time_avg": { - "name": "Round Trip Time Average" + "name": "Round-trip time average" }, "round_trip_time_max": { - "name": "Round Trip Time Maximum" + "name": "Round-trip time maximum" }, "round_trip_time_mdev": { - "name": "Round Trip Time Mean Deviation" + "name": "Round-trip time mean deviation" }, "round_trip_time_min": { - "name": "Round Trip Time Minimum" + "name": "Round-trip time minimum" } } }, diff --git a/homeassistant/components/pitsos/__init__.py b/homeassistant/components/pitsos/__init__.py new file mode 100644 index 00000000000..e49539d8ed2 --- /dev/null +++ b/homeassistant/components/pitsos/__init__.py @@ -0,0 +1 @@ +"""Pitsos virtual integration.""" diff --git a/homeassistant/components/pitsos/manifest.json b/homeassistant/components/pitsos/manifest.json new file mode 100644 index 00000000000..55f5ac7b2fc --- /dev/null +++ b/homeassistant/components/pitsos/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "pitsos", + "name": "Pitsos", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index 9adfb4a14fe..6a05b209f2c 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -32,6 +32,8 @@ from .const import ( PLACEHOLDER_WEBHOOK_URL, ) +AUTH_TOKEN_URL = "https://intercom.help/plaato/en/articles/5004720-auth_token" + class PlaatoConfigFlow(ConfigFlow, domain=DOMAIN): """Handles a Plaato config flow.""" @@ -153,7 +155,10 @@ class PlaatoConfigFlow(ConfigFlow, domain=DOMAIN): step_id="api_method", data_schema=data_schema, errors=errors, - description_placeholders={PLACEHOLDER_DEVICE_TYPE: device_type.name}, + description_placeholders={ + PLACEHOLDER_DEVICE_TYPE: device_type.name, + "auth_token_url": AUTH_TOKEN_URL, + }, ) async def _get_webhook_id(self): diff --git a/homeassistant/components/plaato/strings.json b/homeassistant/components/plaato/strings.json index 23568258118..66c5d18e0e7 100644 --- a/homeassistant/components/plaato/strings.json +++ b/homeassistant/components/plaato/strings.json @@ -11,10 +11,10 @@ }, "api_method": { "title": "Select API method", - "description": "To be able to query the API an `auth_token` is required which can be obtained by following [these](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) instructions\n\n Selected device: **{device_type}** \n\nIf you rather use the built in webhook method (Airlock only) please check the box below and leave Auth Token blank", + "description": "To be able to query the API an 'auth token' is required which can be obtained by following [these instructions]({auth_token_url})\n\nSelected device: **{device_type}** \n\nIf you prefer to use the built-in webhook method (Airlock only) please check the box below and leave 'Auth token' blank", "data": { "use_webhook": "Use webhook", - "token": "Paste Auth Token here" + "token": "Auth token" } }, "webhook": { diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 3c9f35b20a4..48459a81860 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -14,7 +14,6 @@ from plexauth import PlexAuth import requests.exceptions import voluptuous as vol -from homeassistant.components import http from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ( @@ -36,7 +35,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow, http from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index 4f5ca3f2bc4..0c8eae86f73 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -11,7 +11,7 @@ } }, "manual_setup": { - "title": "Manual Plex Configuration", + "title": "Manual Plex configuration", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -29,8 +29,8 @@ } }, "error": { - "faulty_credentials": "Authorization failed, verify Token", - "host_or_token": "Must provide at least one of Host or Token", + "faulty_credentials": "Authorization failed, verify token", + "host_or_token": "Must provide at least one of host or token", "no_servers": "No servers linked to Plex account", "not_found": "Plex server not found", "ssl_error": "SSL certificate issue" @@ -47,12 +47,12 @@ "options": { "step": { "plex_mp_settings": { - "description": "Options for Plex Media Players", + "description": "Options for Plex media players", "data": { "use_episode_art": "Use episode art", "ignore_new_shared_users": "Ignore new managed/shared users", "monitored_users": "Monitored users", - "ignore_plex_web_clients": "Ignore Plex Web clients" + "ignore_plex_web_clients": "Ignore Plex web clients" } } } @@ -62,6 +62,11 @@ "scan_clients": { "name": "Scan clients" } + }, + "update": { + "server_update": { + "name": "[%key:component::update::title%]" + } } }, "services": { diff --git a/homeassistant/components/plex/update.py b/homeassistant/components/plex/update.py index 9b7645cd078..bc1c6abf2ed 100644 --- a/homeassistant/components/plex/update.py +++ b/homeassistant/components/plex/update.py @@ -4,16 +4,16 @@ import logging from typing import Any from plexapi.exceptions import PlexApiException -import plexapi.server import requests.exceptions from homeassistant.components.update import UpdateEntity, UpdateEntityFeature 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 AddConfigEntryEntitiesCallback -from .const import CONF_SERVER_IDENTIFIER +from .const import CONF_SERVER_IDENTIFIER, DOMAIN from .helpers import get_plex_server _LOGGER = logging.getLogger(__name__) @@ -27,9 +27,8 @@ async def async_setup_entry( """Set up Plex update entities from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] server = get_plex_server(hass, server_id) - plex_server = server.plex_server - can_update = await hass.async_add_executor_job(plex_server.canInstallUpdate) - async_add_entities([PlexUpdate(plex_server, can_update)], update_before_add=True) + can_update = await hass.async_add_executor_job(server.plex_server.canInstallUpdate) + async_add_entities([PlexUpdate(server, can_update)], update_before_add=True) class PlexUpdate(UpdateEntity): @@ -37,22 +36,21 @@ class PlexUpdate(UpdateEntity): _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES _release_notes: str | None = None + _attr_translation_key: str = "server_update" + _attr_has_entity_name = True - def __init__( - self, plex_server: plexapi.server.PlexServer, can_update: bool - ) -> None: + def __init__(self, plex_server, can_update: bool) -> None: """Initialize the Update entity.""" - self.plex_server = plex_server - self._attr_name = f"Plex Media Server ({plex_server.friendlyName})" - self._attr_unique_id = plex_server.machineIdentifier + self._server = plex_server + self._attr_unique_id = plex_server.machine_identifier if can_update: self._attr_supported_features |= UpdateEntityFeature.INSTALL def update(self) -> None: """Update sync attributes.""" - self._attr_installed_version = self.plex_server.version + self._attr_installed_version = self._server.version try: - if (release := self.plex_server.checkForUpdate()) is None: + if (release := self._server.plex_server.checkForUpdate()) is None: self._attr_latest_version = self.installed_version return except (requests.exceptions.RequestException, PlexApiException): @@ -73,6 +71,18 @@ class PlexUpdate(UpdateEntity): def install(self, version: str | None, backup: bool, **kwargs: Any) -> None: """Install an update.""" try: - self.plex_server.installUpdate() + self._server.plex_server.installUpdate() except (requests.exceptions.RequestException, PlexApiException) as exc: raise HomeAssistantError(str(exc)) from exc + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + return DeviceInfo( + identifiers={(DOMAIN, self._server.machine_identifier)}, + manufacturer="Plex", + model="Plex Media Server", + name=self._server.friendly_name, + sw_version=self._server.version, + configuration_url=f"{self._server.url_in_use}/web", + ) diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index b346f26492c..4ed100b538d 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -99,12 +99,10 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData translation_key="unsupported_firmware", ) from err - self._async_add_remove_devices(data, self.config_entry) + self._async_add_remove_devices(data) return data - def _async_add_remove_devices( - self, data: dict[str, GwEntityData], entry: ConfigEntry - ) -> None: + def _async_add_remove_devices(self, data: dict[str, GwEntityData]) -> None: """Add new Plugwise devices, remove non-existing devices.""" # Check for new or removed devices self.new_devices = set(data) - self._current_devices @@ -112,11 +110,9 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData self._current_devices = set(data) if removed_devices: - self._async_remove_devices(data, entry) + self._async_remove_devices(data) - def _async_remove_devices( - self, data: dict[str, GwEntityData], entry: ConfigEntry - ) -> None: + def _async_remove_devices(self, data: dict[str, GwEntityData]) -> None: """Clean registries when removed devices found.""" device_reg = dr.async_get(self.hass) device_list = dr.async_entries_for_config_entry( @@ -136,7 +132,8 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData and identifier[1] not in data ): device_reg.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id + device_entry.id, + remove_config_entry_id=self.config_entry.entry_id, ) LOGGER.debug( "Removed %s device %s %s from device_registry", diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 87878980f2d..264afd79ed2 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.7.2"], + "requirements": ["plugwise==1.7.4"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index d16b38df992..fdbe8c39015 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -19,11 +19,11 @@ "host": "[%key:common::config_flow::data::ip%]", "password": "Smile ID", "port": "[%key:common::config_flow::data::port%]", - "username": "Smile Username" + "username": "Smile username" }, "data_description": { "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.", + "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." } @@ -85,7 +85,7 @@ "preset_mode": { "state": { "asleep": "Night", - "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "away": "[%key:common::state::not_home%]", "home": "[%key:common::state::home%]", "no_frost": "Anti-frost", "vacation": "Vacation" @@ -113,7 +113,7 @@ "name": "DHW mode", "state": { "off": "[%key:common::state::off%]", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "boost": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::boost%]", "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]" } @@ -122,7 +122,7 @@ "name": "Gateway mode", "state": { "away": "Pause", - "full": "Normal", + "full": "[%key:common::state::normal%]", "vacation": "Vacation" } }, @@ -139,7 +139,7 @@ "select_schedule": { "name": "Thermostat schedule", "state": { - "off": "Off" + "off": "[%key:common::state::off%]" } } }, @@ -184,7 +184,7 @@ "name": "Electricity consumed peak interval" }, "electricity_consumed_off_peak_interval": { - "name": "Electricity consumed off peak interval" + "name": "Electricity consumed off-peak interval" }, "electricity_produced_interval": { "name": "Electricity produced interval" @@ -193,19 +193,19 @@ "name": "Electricity produced peak interval" }, "electricity_produced_off_peak_interval": { - "name": "Electricity produced off peak interval" + "name": "Electricity produced off-peak interval" }, "electricity_consumed_point": { "name": "Electricity consumed point" }, "electricity_consumed_off_peak_point": { - "name": "Electricity consumed off peak point" + "name": "Electricity consumed off-peak point" }, "electricity_consumed_peak_point": { "name": "Electricity consumed peak point" }, "electricity_consumed_off_peak_cumulative": { - "name": "Electricity consumed off peak cumulative" + "name": "Electricity consumed off-peak cumulative" }, "electricity_consumed_peak_cumulative": { "name": "Electricity consumed peak cumulative" @@ -214,13 +214,13 @@ "name": "Electricity produced point" }, "electricity_produced_off_peak_point": { - "name": "Electricity produced off peak point" + "name": "Electricity produced off-peak point" }, "electricity_produced_peak_point": { "name": "Electricity produced peak point" }, "electricity_produced_off_peak_cumulative": { - "name": "Electricity produced off peak cumulative" + "name": "Electricity produced off-peak cumulative" }, "electricity_produced_peak_cumulative": { "name": "Electricity produced peak cumulative" diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index e446606f191..5c782bb3304 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -1,107 +1,28 @@ """Support for Minut Point.""" -import asyncio -from dataclasses import dataclass from http import HTTPStatus import logging from aiohttp import ClientError, ClientResponseError, web from pypoint import PointSession -import voluptuous as vol from homeassistant.components import webhook -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_WEBHOOK_ID, - Platform, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID, Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import ( - aiohttp_client, - config_entry_oauth2_flow, - config_validation as cv, -) +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from . import api -from .const import ( - CONF_WEBHOOK_URL, - DOMAIN, - EVENT_RECEIVED, - POINT_DISCOVERY_NEW, - SCAN_INTERVAL, - SIGNAL_UPDATE_ENTITY, - SIGNAL_WEBHOOK, -) +from .const import CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, SIGNAL_WEBHOOK +from .coordinator import PointDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -type PointConfigEntry = ConfigEntry[PointData] - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Minut Point component.""" - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Point", - }, - ) - - if not hass.config_entries.async_entries(DOMAIN): - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - conf[CONF_CLIENT_ID], - conf[CONF_CLIENT_SECRET], - ), - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - - return True +type PointConfigEntry = ConfigEntry[PointDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: @@ -131,9 +52,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo point_session = PointSession(auth) - client = MinutPointClient(hass, entry, point_session) - hass.async_create_task(client.update()) - entry.runtime_data = PointData(client) + coordinator = PointDataUpdateCoordinator(hass, point_session) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await async_setup_webhook(hass, entry, point_session) await hass.config_entries.async_forward_entry_setups( @@ -176,7 +99,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bo if unload_ok := await hass.config_entries.async_unload_platforms( entry, [*PLATFORMS, Platform.ALARM_CONTROL_PANEL] ): - session: PointSession = entry.runtime_data.client + session = entry.runtime_data.point if CONF_WEBHOOK_ID in entry.data: webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) await session.remove_webhook() @@ -197,87 +120,3 @@ async def handle_webhook( data["webhook_id"] = webhook_id async_dispatcher_send(hass, SIGNAL_WEBHOOK, data, data.get("hook_id")) hass.bus.async_fire(EVENT_RECEIVED, data) - - -class MinutPointClient: - """Get the latest data and update the states.""" - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: PointSession - ) -> None: - """Initialize the Minut data object.""" - self._known_devices: set[str] = set() - self._known_homes: set[str] = set() - self._hass = hass - self._config_entry = config_entry - self._is_available = True - self._client = session - - async_track_time_interval(self._hass, self.update, SCAN_INTERVAL) - - async def update(self, *args): - """Periodically poll the cloud for current state.""" - await self._sync() - - async def _sync(self): - """Update local list of devices.""" - if not await self._client.update(): - self._is_available = False - _LOGGER.warning("Device is unavailable") - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) - return - - self._is_available = True - for home_id in self._client.homes: - if home_id not in self._known_homes: - async_dispatcher_send( - self._hass, - POINT_DISCOVERY_NEW.format(Platform.ALARM_CONTROL_PANEL), - home_id, - ) - self._known_homes.add(home_id) - for device in self._client.devices: - if device.device_id not in self._known_devices: - for platform in PLATFORMS: - async_dispatcher_send( - self._hass, - POINT_DISCOVERY_NEW.format(platform), - device.device_id, - ) - self._known_devices.add(device.device_id) - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) - - def device(self, device_id): - """Return device representation.""" - return self._client.device(device_id) - - def is_available(self, device_id): - """Return device availability.""" - if not self._is_available: - return False - return device_id in self._client.device_ids - - async def remove_webhook(self): - """Remove the session webhook.""" - return await self._client.remove_webhook() - - @property - def homes(self): - """Return known homes.""" - return self._client.homes - - async def async_alarm_disarm(self, home_id): - """Send alarm disarm command.""" - return await self._client.alarm_disarm(home_id) - - async def async_alarm_arm(self, home_id): - """Send alarm arm command.""" - return await self._client.alarm_arm(home_id) - - -@dataclass -class PointData: - """Point Data.""" - - client: MinutPointClient - entry_lock: asyncio.Lock = asyncio.Lock() diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 0f501d2ee09..2df26283624 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -2,23 +2,22 @@ from __future__ import annotations -from collections.abc import Callable import logging +from pypoint import PointSession + from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, AlarmControlPanelEntity, AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MinutPointClient -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK +from . import PointConfigEntry +from .const import DOMAIN, SIGNAL_WEBHOOK _LOGGER = logging.getLogger(__name__) @@ -32,21 +31,20 @@ EVENT_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's alarm_control_panel based on a config entry.""" + coordinator = config_entry.runtime_data - async def async_discover_home(home_id): + def async_discover_home(home_id: str) -> None: """Discover and add a discovered home.""" - client = config_entry.runtime_data.client - async_add_entities([MinutPointAlarmControl(client, home_id)], True) + async_add_entities([MinutPointAlarmControl(coordinator.point, home_id)]) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(ALARM_CONTROL_PANEL_DOMAIN, POINT_DOMAIN), - async_discover_home, - ) + coordinator.new_home_callback = async_discover_home + + for home_id in coordinator.point.homes: + async_discover_home(home_id) class MinutPointAlarmControl(AlarmControlPanelEntity): @@ -55,17 +53,16 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY _attr_code_arm_required = False - def __init__(self, point_client: MinutPointClient, home_id: str) -> None: + def __init__(self, point: PointSession, home_id: str) -> None: """Initialize the entity.""" - self._client = point_client + self._client = point self._home_id = home_id - self._async_unsub_hook_dispatcher_connect: Callable[[], None] | None = None - self._home = point_client.homes[self._home_id] + self._home = point.homes[self._home_id] self._attr_name = self._home["name"] self._attr_unique_id = f"point.{home_id}" self._attr_device_info = DeviceInfo( - identifiers={(POINT_DOMAIN, home_id)}, + identifiers={(DOMAIN, home_id)}, manufacturer="Minut", name=self._attr_name, ) @@ -73,16 +70,10 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" await super().async_added_to_hass() - self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_WEBHOOK, self._webhook_event + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event) ) - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - await super().async_will_remove_from_hass() - if self._async_unsub_hook_dispatcher_connect: - self._async_unsub_hook_dispatcher_connect() - @callback def _webhook_event(self, data, webhook): """Process new event from the webhook.""" @@ -107,12 +98,12 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - status = await self._client.async_alarm_disarm(self._home_id) + status = await self._client.alarm_disarm(self._home_id) if status: self._home["alarm_status"] = "off" async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - status = await self._client.async_alarm_arm(self._home_id) + status = await self._client.alarm_arm(self._home_id) if status: self._home["alarm_status"] = "on" diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index c9338cb63f2..17fe40b9654 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -3,26 +3,27 @@ from __future__ import annotations import logging +from typing import Any from pypoint import EVENTS from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) -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 AddConfigEntryEntitiesCallback -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK +from . import PointConfigEntry +from .const import SIGNAL_WEBHOOK +from .coordinator import PointDataUpdateCoordinator from .entity import MinutPointEntity _LOGGER = logging.getLogger(__name__) -DEVICES = { +DEVICES: dict[str, Any] = { "alarm": {"icon": "mdi:alarm-bell"}, "battery": {"device_class": BinarySensorDeviceClass.BATTERY}, "button_press": {"icon": "mdi:gesture-tap-button"}, @@ -42,69 +43,60 @@ DEVICES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's binary sensors based on a config entry.""" - async def async_discover_sensor(device_id): + coordinator = config_entry.runtime_data + + def async_discover_sensor(device_id: str) -> None: """Discover and add a discovered sensor.""" - client = config_entry.runtime_data.client async_add_entities( - ( - MinutPointBinarySensor(client, device_id, device_name) - for device_name in DEVICES - if device_name in EVENTS - ), - True, + MinutPointBinarySensor(coordinator, device_id, device_name) + for device_name in DEVICES + if device_name in EVENTS ) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(BINARY_SENSOR_DOMAIN, POINT_DOMAIN), - async_discover_sensor, + coordinator.new_device_callbacks.append(async_discover_sensor) + + async_add_entities( + MinutPointBinarySensor(coordinator, device_id, device_name) + for device_name in DEVICES + if device_name in EVENTS + for device_id in coordinator.point.device_ids ) class MinutPointBinarySensor(MinutPointEntity, BinarySensorEntity): """The platform class required by Home Assistant.""" - def __init__(self, point_client, device_id, device_name): + def __init__( + self, coordinator: PointDataUpdateCoordinator, device_id: str, key: str + ) -> None: """Initialize the binary sensor.""" - super().__init__( - point_client, - device_id, - DEVICES[device_name].get("device_class", device_name), - ) - self._device_name = device_name - self._async_unsub_hook_dispatcher_connect = None - self._events = EVENTS[device_name] - self._attr_unique_id = f"point.{device_id}-{device_name}" - self._attr_icon = DEVICES[self._device_name].get("icon") + self._attr_device_class = DEVICES[key].get("device_class", key) + super().__init__(coordinator, device_id) + self._device_name = key + self._events = EVENTS[key] + self._attr_unique_id = f"point.{device_id}-{key}" + self._attr_icon = DEVICES[key].get("icon") async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" await super().async_added_to_hass() - self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_WEBHOOK, self._webhook_event + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event) ) - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - await super().async_will_remove_from_hass() - if self._async_unsub_hook_dispatcher_connect: - self._async_unsub_hook_dispatcher_connect() - - async def _update_callback(self): + def _handle_coordinator_update(self) -> None: """Update the value of the sensor.""" - if not self.is_updated: - return if self.device_class == BinarySensorDeviceClass.CONNECTIVITY: # connectivity is the other way around. self._attr_is_on = self._events[0] not in self.device.ongoing_events else: self._attr_is_on = self._events[0] in self.device.ongoing_events - self.async_write_ha_state() + super()._handle_coordinator_update() @callback def _webhook_event(self, data, webhook): diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index a0a51c7b9e6..426177a1849 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -11,6 +11,8 @@ from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHan from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow to handle Minut Point OAuth2 authentication.""" @@ -22,10 +24,6 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Return logger.""" return logging.getLogger(__name__) - async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult: - """Handle import from YAML.""" - return await self.async_step_user() - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -56,7 +54,7 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): if reauth_entry.unique_id is not None: self._abort_if_unique_id_mismatch(reason="wrong_account") - logging.debug("user_id: %s", user_id) + _LOGGER.debug("user_id: %s", user_id) return self.async_update_reload_and_abort( reauth_entry, data_updates=data, unique_id=user_id ) diff --git a/homeassistant/components/point/coordinator.py b/homeassistant/components/point/coordinator.py new file mode 100644 index 00000000000..93bd74955ea --- /dev/null +++ b/homeassistant/components/point/coordinator.py @@ -0,0 +1,71 @@ +"""Define a data update coordinator for Point.""" + +from collections.abc import Callable +from datetime import datetime +import logging +from typing import Any + +from pypoint import PointSession + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import parse_datetime + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Class to manage fetching Point data from the API.""" + + def __init__(self, hass: HomeAssistant, point: PointSession) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.point = point + self.device_updates: dict[str, datetime] = {} + self._known_devices: set[str] = set() + self._known_homes: set[str] = set() + self.new_home_callback: Callable[[str], None] | None = None + self.new_device_callbacks: list[Callable[[str], None]] = [] + self.data: dict[str, dict[str, Any]] = {} + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + if not await self.point.update(): + raise UpdateFailed("Failed to fetch data from Point") + + if new_homes := set(self.point.homes) - self._known_homes: + _LOGGER.debug("Found new homes: %s", new_homes) + for home_id in new_homes: + if self.new_home_callback: + self.new_home_callback(home_id) + self._known_homes.update(new_homes) + + device_ids = {device.device_id for device in self.point.devices} + if new_devices := device_ids - self._known_devices: + _LOGGER.debug("Found new devices: %s", new_devices) + for device_id in new_devices: + for callback in self.new_device_callbacks: + callback(device_id) + self._known_devices.update(new_devices) + + for device in self.point.devices: + last_updated = parse_datetime(device.last_update) + if ( + not last_updated + or device.device_id not in self.device_updates + or self.device_updates[device.device_id] < last_updated + ): + self.device_updates[device.device_id] = ( + last_updated or datetime.fromtimestamp(0) + ) + self.data[device.device_id] = { + k: await device.sensor(k) + for k in ("temperature", "humidity", "sound_pressure") + } + return self.data diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index 5c52e81e6f7..39af7867e97 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -2,31 +2,27 @@ import logging +from pypoint import Device, PointSession + from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity -from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.dt import as_local -from .const import DOMAIN, SIGNAL_UPDATE_ENTITY +from .const import DOMAIN +from .coordinator import PointDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class MinutPointEntity(Entity): +class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]): """Base Entity used by the sensors.""" - _attr_should_poll = False - - def __init__(self, point_client, device_id, device_class) -> None: + def __init__(self, coordinator: PointDataUpdateCoordinator, device_id: str) -> None: """Initialize the entity.""" - self._async_unsub_dispatcher_connect = None - self._client = point_client - self._id = device_id + super().__init__(coordinator) + self.device_id = device_id self._name = self.device.name - self._attr_device_class = device_class - self._updated = utc_from_timestamp(0) - self._attr_unique_id = f"point.{device_id}-{device_class}" device = self.device.device self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, @@ -37,59 +33,32 @@ class MinutPointEntity(Entity): sw_version=device["firmware"]["installed"], via_device=(DOMAIN, device["home"]), ) - if device_class: - self._attr_name = f"{self._name} {device_class.capitalize()}" - - def __str__(self) -> str: - """Return string representation of device.""" - return f"MinutPoint {self.name}" - - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - _LOGGER.debug("Created device %s", self) - self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback - ) - await self._update_callback() - - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher listener when removed.""" - if self._async_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() + if self.device_class: + self._attr_name = f"{self._name} {self.device_class.capitalize()}" async def _update_callback(self): """Update the value of the sensor.""" + @property + def client(self) -> PointSession: + """Return the client object.""" + return self.coordinator.point + @property def available(self) -> bool: """Return true if device is not offline.""" - return self._client.is_available(self.device_id) + return super().available and self.device_id in self.client.device_ids @property - def device(self): + def device(self) -> Device: """Return the representation of the device.""" - return self._client.device(self.device_id) - - @property - def device_id(self): - """Return the id of the device.""" - return self._id + return self.client.device(self.device_id) @property def extra_state_attributes(self): """Return status of device.""" attrs = self.device.device_status - attrs["last_heard_from"] = as_local(self.last_update).strftime( - "%Y-%m-%d %H:%M:%S" - ) + attrs["last_heard_from"] = as_local( + self.coordinator.device_updates[self.device_id] + ).strftime("%Y-%m-%d %H:%M:%S") return attrs - - @property - def is_updated(self): - """Return true if sensor have been updated.""" - return self.last_update > self._updated - - @property - def last_update(self): - """Return the last_update time for the device.""" - return parse_datetime(self.device.last_update) diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index c959d09d606..246536d86ab 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -5,19 +5,17 @@ from __future__ import annotations import logging from homeassistant.components.sensor import ( - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfSoundPressure, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.dt import parse_datetime +from homeassistant.helpers.typing import StateType -from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW +from . import PointConfigEntry +from .coordinator import PointDataUpdateCoordinator from .entity import MinutPointEntity _LOGGER = logging.getLogger(__name__) @@ -37,7 +35,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key="sound", + key="sound_pressure", suggested_display_precision=1, device_class=SensorDeviceClass.SOUND_PRESSURE, native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, @@ -47,26 +45,26 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PointConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's sensors based on a config entry.""" - async def async_discover_sensor(device_id): + coordinator = config_entry.runtime_data + + def async_discover_sensor(device_id: str) -> None: """Discover and add a discovered sensor.""" - client = config_entry.runtime_data.client async_add_entities( - [ - MinutPointSensor(client, device_id, description) - for description in SENSOR_TYPES - ], - True, + MinutPointSensor(coordinator, device_id, description) + for description in SENSOR_TYPES ) - async_dispatcher_connect( - hass, - POINT_DISCOVERY_NEW.format(SENSOR_DOMAIN, POINT_DOMAIN), - async_discover_sensor, + coordinator.new_device_callbacks.append(async_discover_sensor) + + async_add_entities( + MinutPointSensor(coordinator, device_id, description) + for device_id in coordinator.data + for description in SENSOR_TYPES ) @@ -74,16 +72,17 @@ class MinutPointSensor(MinutPointEntity, SensorEntity): """The platform class required by Home Assistant.""" def __init__( - self, point_client, device_id, description: SensorEntityDescription + self, + coordinator: PointDataUpdateCoordinator, + device_id: str, + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(point_client, device_id, description.device_class) self.entity_description = description + super().__init__(coordinator, device_id) + self._attr_unique_id = f"point.{device_id}-{description.key}" - async def _update_callback(self): - """Update the value of the sensor.""" - _LOGGER.debug("Update sensor value for %s", self) - if self.is_updated: - self._attr_native_value = await self.device.sensor(self.device_class) - self._updated = parse_datetime(self.device.last_update) - self.async_write_ha_state() + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.coordinator.data[self.device_id].get(self.entity_description.key) diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index b4988133727..f242d2c67e6 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -297,7 +297,6 @@ class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity): _attr_translation_key = "backup_reserve" _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = PERCENTAGE - _attr_device_class = SensorDeviceClass.BATTERY @property def unique_id(self) -> str: diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 445affbcd57..f1e1839b735 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.23.4"] + "requirements": ["bluetooth-data-tools==1.28.1"] } diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json index c35775a4843..845a5d92bae 100644 --- a/homeassistant/components/private_ble_device/strings.json +++ b/homeassistant/components/private_ble_device/strings.json @@ -14,7 +14,7 @@ "irk_not_valid": "The key does not look like a valid IRK." }, "abort": { - "bluetooth_not_available": "At least one Bluetooth adapter or remote bluetooth proxy must be configured to track Private BLE Devices." + "bluetooth_not_available": "At least one Bluetooth adapter or remote Bluetooth proxy must be configured to track Private BLE Devices." } }, "entity": { diff --git a/homeassistant/components/profilo/__init__.py b/homeassistant/components/profilo/__init__.py new file mode 100644 index 00000000000..5f727b1bc8b --- /dev/null +++ b/homeassistant/components/profilo/__init__.py @@ -0,0 +1 @@ +"""Profilo virtual integration.""" diff --git a/homeassistant/components/profilo/manifest.json b/homeassistant/components/profilo/manifest.json new file mode 100644 index 00000000000..c5671d5be3f --- /dev/null +++ b/homeassistant/components/profilo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "profilo", + "name": "Profilo", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 2e5ea221dca..8818eff2d81 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -1,5 +1,6 @@ """Config flow for ProgettiHWSW Automation integration.""" +import logging from typing import TYPE_CHECKING, Any from ProgettiHWSW.ProgettiHWSWAPI import ProgettiHWSWAPI @@ -11,6 +12,8 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( {vol.Required("host"): str, vol.Required("port", default=80): int} ) @@ -86,7 +89,8 @@ class ProgettiHWSWConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: user_input.update(info) diff --git a/homeassistant/components/prosegur/strings.json b/homeassistant/components/prosegur/strings.json index 9b9ac45fc85..e5176e96090 100644 --- a/homeassistant/components/prosegur/strings.json +++ b/homeassistant/components/prosegur/strings.json @@ -5,7 +5,7 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" } }, "choose_contract": { diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 0db6ea28652..11fa530f47b 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -6,7 +6,6 @@ from datetime import timedelta from typing import Any from proxmoxer import AuthenticationError, ProxmoxAPI -from proxmoxer.core import ResourceException import requests.exceptions from requests.exceptions import ConnectTimeout, SSLError import voluptuous as vol @@ -25,6 +24,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .common import ProxmoxClient, call_api_container_vm, parse_api_container_vm from .const import ( _LOGGER, CONF_CONTAINERS, @@ -219,80 +219,3 @@ def create_coordinator_container_vm( update_method=async_update_data, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - - -def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: - """Get the container or vm api data and return it formatted in a dictionary. - - It is implemented in this way to allow for more data to be added for sensors - in the future. - """ - - return {"status": status["status"], "name": status["name"]} - - -def call_api_container_vm( - proxmox: ProxmoxAPI, - node_name: str, - vm_id: int, - machine_type: int, -) -> dict[str, Any] | None: - """Make proper api calls.""" - status = None - - try: - if machine_type == TYPE_VM: - status = proxmox.nodes(node_name).qemu(vm_id).status.current.get() - elif machine_type == TYPE_CONTAINER: - status = proxmox.nodes(node_name).lxc(vm_id).status.current.get() - except (ResourceException, requests.exceptions.ConnectionError): - return None - - return status - - -class ProxmoxClient: - """A wrapper for the proxmoxer ProxmoxAPI client.""" - - _proxmox: ProxmoxAPI - - def __init__( - self, - host: str, - port: int, - user: str, - realm: str, - password: str, - verify_ssl: bool, - ) -> None: - """Initialize the ProxmoxClient.""" - - self._host = host - self._port = port - self._user = user - self._realm = realm - self._password = password - self._verify_ssl = verify_ssl - - def build_client(self) -> None: - """Construct the ProxmoxAPI client. - - Allows inserting the realm within the `user` value. - """ - - if "@" in self._user: - user_id = self._user - else: - user_id = f"{self._user}@{self._realm}" - - self._proxmox = ProxmoxAPI( - self._host, - port=self._port, - user=user_id, - password=self._password, - verify_ssl=self._verify_ssl, - ) - - def get_api_client(self) -> ProxmoxAPI: - """Return the ProxmoxAPI client.""" - return self._proxmox diff --git a/homeassistant/components/proxmoxve/common.py b/homeassistant/components/proxmoxve/common.py new file mode 100644 index 00000000000..4173377377c --- /dev/null +++ b/homeassistant/components/proxmoxve/common.py @@ -0,0 +1,88 @@ +"""Commons for Proxmox VE integration.""" + +from __future__ import annotations + +from typing import Any + +from proxmoxer import ProxmoxAPI +from proxmoxer.core import ResourceException +import requests.exceptions + +from .const import TYPE_CONTAINER, TYPE_VM + + +class ProxmoxClient: + """A wrapper for the proxmoxer ProxmoxAPI client.""" + + _proxmox: ProxmoxAPI + + def __init__( + self, + host: str, + port: int, + user: str, + realm: str, + password: str, + verify_ssl: bool, + ) -> None: + """Initialize the ProxmoxClient.""" + + self._host = host + self._port = port + self._user = user + self._realm = realm + self._password = password + self._verify_ssl = verify_ssl + + def build_client(self) -> None: + """Construct the ProxmoxAPI client. + + Allows inserting the realm within the `user` value. + """ + + if "@" in self._user: + user_id = self._user + else: + user_id = f"{self._user}@{self._realm}" + + self._proxmox = ProxmoxAPI( + self._host, + port=self._port, + user=user_id, + password=self._password, + verify_ssl=self._verify_ssl, + ) + + def get_api_client(self) -> ProxmoxAPI: + """Return the ProxmoxAPI client.""" + return self._proxmox + + +def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: + """Get the container or vm api data and return it formatted in a dictionary. + + It is implemented in this way to allow for more data to be added for sensors + in the future. + """ + + return {"status": status["status"], "name": status["name"]} + + +def call_api_container_vm( + proxmox: ProxmoxAPI, + node_name: str, + vm_id: int, + machine_type: int, +) -> dict[str, Any] | None: + """Make proper api calls.""" + status = None + + try: + if machine_type == TYPE_VM: + status = proxmox.nodes(node_name).qemu(vm_id).status.current.get() + elif machine_type == TYPE_CONTAINER: + status = proxmox.nodes(node_name).lxc(vm_id).status.current.get() + except (ResourceException, requests.exceptions.ConnectionError): + return None + + return status diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py index f6e909f13d1..47fa9454deb 100644 --- a/homeassistant/components/proxy/camera.py +++ b/homeassistant/components/proxy/camera.py @@ -104,6 +104,15 @@ def _resize_image(image, opts): new_width = opts.max_width (old_width, old_height) = img.size old_size = len(image) + + # If no max_width specified, only apply quality changes if requested + if new_width is None: + if opts.quality is None: + return image + imgbuf = io.BytesIO() + img.save(imgbuf, "JPEG", optimize=True, quality=quality) + return imgbuf.getvalue() + if old_width <= new_width: if opts.quality is None: _LOGGER.debug("Image is smaller-than/equal-to requested width") diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 6925b9e2133..02074a18b61 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.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index 7c6f0bbf2dd..6c698cf3dc2 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -36,8 +36,8 @@ "printing": "Printing", "paused": "[%key:common::state::paused%]", "finished": "Finished", - "stopped": "Stopped", - "error": "Error", + "stopped": "[%key:common::state::stopped%]", + "error": "[%key:common::state::error%]", "attention": "Attention", "ready": "Ready" } @@ -85,7 +85,7 @@ "name": "Z-Height" }, "nozzle_diameter": { - "name": "Nozzle Diameter" + "name": "Nozzle diameter" } }, "button": { diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 2ccf086071a..ddde4620871 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -1,11 +1,14 @@ """Support for PlayStation 4 consoles.""" +from __future__ import annotations + +from dataclasses import dataclass import logging import os +from typing import TYPE_CHECKING -from pyps4_2ndscreen.ddp import async_create_ddp_endpoint +from pyps4_2ndscreen.ddp import DDPProtocol, async_create_ddp_endpoint from pyps4_2ndscreen.media_art import COUNTRIES -import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.components.media_player import ( @@ -14,15 +17,8 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_COMMAND, - ATTR_ENTITY_ID, - ATTR_LOCKED, - CONF_REGION, - CONF_TOKEN, - Platform, -) -from homeassistant.core import HomeAssistant, ServiceCall, split_entity_id +from homeassistant.const import ATTR_LOCKED, CONF_REGION, CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -32,48 +28,37 @@ from homeassistant.util import location as location_util from homeassistant.util.json import JsonObjectType, load_json_object from .config_flow import PlayStation4FlowHandler # noqa: F401 -from .const import ( - ATTR_MEDIA_IMAGE_URL, - COMMANDS, - COUNTRYCODE_NAMES, - DOMAIN, - GAMES_FILE, - PS4_DATA, -) +from .const import ATTR_MEDIA_IMAGE_URL, COUNTRYCODE_NAMES, DOMAIN, GAMES_FILE, PS4_DATA +from .services import register_services + +if TYPE_CHECKING: + from .media_player import PS4Device _LOGGER = logging.getLogger(__name__) -SERVICE_COMMAND = "send_command" - -PS4_COMMAND_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_COMMAND): vol.In(list(COMMANDS)), - } -) PLATFORMS = [Platform.MEDIA_PLAYER] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +@dataclass class PS4Data: """Init Data Class.""" - def __init__(self): - """Init Class.""" - self.devices = [] - self.protocol = None + devices: list[PS4Device] + protocol: DDPProtocol async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the PS4 Component.""" - hass.data[PS4_DATA] = PS4Data() - transport, protocol = await async_create_ddp_endpoint() - hass.data[PS4_DATA].protocol = protocol + hass.data[PS4_DATA] = PS4Data( + devices=[], + protocol=protocol, + ) _LOGGER.debug("PS4 DDP endpoint created: %s, %s", transport, protocol) - service_handle(hass) + register_services(hass) return True @@ -216,19 +201,3 @@ def _reformat_data(hass: HomeAssistant, games: dict, unique_id: str) -> dict: if data_reformatted: save_games(hass, games, unique_id) return games - - -def service_handle(hass: HomeAssistant): - """Handle for services.""" - - async def async_service_command(call: ServiceCall) -> None: - """Service for sending commands.""" - entity_ids = call.data[ATTR_ENTITY_ID] - command = call.data[ATTR_COMMAND] - for device in hass.data[PS4_DATA].devices: - if device.entity_id in entity_ids: - await device.async_send_command(command) - - hass.services.async_register( - DOMAIN, SERVICE_COMMAND, async_service_command, schema=PS4_COMMAND_SCHEMA - ) diff --git a/homeassistant/components/ps4/const.py b/homeassistant/components/ps4/const.py index bd1144c4d98..f552388fe1d 100644 --- a/homeassistant/components/ps4/const.py +++ b/homeassistant/components/ps4/const.py @@ -1,5 +1,14 @@ """Constants for PlayStation 4.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from . import PS4Data + ATTR_MEDIA_IMAGE_URL = "media_image_url" CONFIG_ENTRY_VERSION = 3 DEFAULT_NAME = "PlayStation 4" @@ -7,7 +16,7 @@ DEFAULT_REGION = "United States" DEFAULT_ALIAS = "Home-Assistant" DOMAIN = "ps4" GAMES_FILE = ".ps4-games.{}.json" -PS4_DATA = "ps4_data" +PS4_DATA: HassKey[PS4Data] = HassKey(DOMAIN) COMMANDS = ("up", "down", "right", "left", "enter", "back", "option", "ps", "ps_hold") diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 4de7cbeb463..aaec7cdf105 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -34,7 +34,7 @@ from . import format_unique_id, load_games, save_games from .const import ( ATTR_MEDIA_IMAGE_URL, DEFAULT_ALIAS, - DOMAIN as PS4_DOMAIN, + DOMAIN, PS4_DATA, REGIONS as deprecated_regions, ) @@ -366,7 +366,7 @@ class PS4Device(MediaPlayerEntity): _sw_version = _sw_version[1:4] sw_version = f"{_sw_version[0]}.{_sw_version[1:]}" self._attr_device_info = DeviceInfo( - identifiers={(PS4_DOMAIN, status["host-id"])}, + identifiers={(DOMAIN, status["host-id"])}, manufacturer="Sony Interactive Entertainment Inc.", model="PlayStation 4", name=status["host-name"], diff --git a/homeassistant/components/ps4/services.py b/homeassistant/components/ps4/services.py new file mode 100644 index 00000000000..7da3cb0ae93 --- /dev/null +++ b/homeassistant/components/ps4/services.py @@ -0,0 +1,37 @@ +"""Support for PlayStation 4 consoles.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ATTR_COMMAND, ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from .const import COMMANDS, DOMAIN, PS4_DATA + +SERVICE_COMMAND = "send_command" + +PS4_COMMAND_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_COMMAND): vol.In(list(COMMANDS)), + } +) + + +async def async_service_command(call: ServiceCall) -> None: + """Service for sending commands.""" + entity_ids = call.data[ATTR_ENTITY_ID] + command = call.data[ATTR_COMMAND] + for device in call.hass.data[PS4_DATA].devices: + if device.entity_id in entity_ids: + await device.async_send_command(command) + + +def register_services(hass: HomeAssistant) -> None: + """Handle for services.""" + + hass.services.async_register( + DOMAIN, SERVICE_COMMAND, async_service_command, schema=PS4_COMMAND_SCHEMA + ) diff --git a/homeassistant/components/pterodactyl/__init__.py b/homeassistant/components/pterodactyl/__init__.py new file mode 100644 index 00000000000..c0e23b271d1 --- /dev/null +++ b/homeassistant/components/pterodactyl/__init__.py @@ -0,0 +1,27 @@ +"""The Pterodactyl integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator + +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: PterodactylConfigEntry) -> bool: + """Set up Pterodactyl from a config entry.""" + coordinator = PterodactylCoordinator(hass, 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: PterodactylConfigEntry +) -> bool: + """Unload a Pterodactyl config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py new file mode 100644 index 00000000000..2aac359a5c6 --- /dev/null +++ b/homeassistant/components/pterodactyl/api.py @@ -0,0 +1,158 @@ +"""API module of the Pterodactyl integration.""" + +from dataclasses import dataclass +from enum import StrEnum +import logging + +from pydactyl import PterodactylClient +from pydactyl.exceptions import BadRequestError, PterodactylApiError +from requests.exceptions import ConnectionError, HTTPError + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +class PterodactylAuthorizationError(Exception): + """Raised when access to server is unauthorized.""" + + +class PterodactylConnectionError(Exception): + """Raised when no data can be fechted from the server.""" + + +@dataclass +class PterodactylData: + """Data for the Pterodactyl server.""" + + name: str + uuid: str + identifier: str + state: str + cpu_utilization: float + cpu_limit: int + disk_usage: int + disk_limit: int + memory_usage: int + memory_limit: int + network_inbound: int + network_outbound: int + uptime: int + + +class PterodactylCommand(StrEnum): + """Command enum for the Pterodactyl server.""" + + START_SERVER = "start" + STOP_SERVER = "stop" + RESTART_SERVER = "restart" + FORCE_STOP_SERVER = "kill" + + +class PterodactylAPI: + """Wrapper for Pterodactyl's API.""" + + pterodactyl: PterodactylClient | None + identifiers: list[str] + + def __init__(self, hass: HomeAssistant, host: str, api_key: str) -> None: + """Initialize the Pterodactyl API.""" + self.hass = hass + self.host = host + self.api_key = api_key + self.pterodactyl = None + self.identifiers = [] + + def get_game_servers(self) -> list[str]: + """Get all game servers.""" + paginated_response = self.pterodactyl.client.servers.list_servers() # type: ignore[union-attr] + + return paginated_response.collect() + + async def async_init(self): + """Initialize the Pterodactyl API.""" + self.pterodactyl = PterodactylClient(self.host, self.api_key) + + try: + game_servers = await self.hass.async_add_executor_job(self.get_game_servers) + except ( + BadRequestError, + PterodactylApiError, + ConnectionError, + StopIteration, + ) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + + raise PterodactylConnectionError(error) from error + else: + for game_server in game_servers: + self.identifiers.append(game_server["attributes"]["identifier"]) + + _LOGGER.debug("Identifiers of Pterodactyl servers: %s", self.identifiers) + + def get_server_data(self, identifier: str) -> tuple[dict, dict]: + """Get all data from the Pterodactyl server.""" + server = self.pterodactyl.client.servers.get_server(identifier) # type: ignore[union-attr] + utilization = self.pterodactyl.client.servers.get_server_utilization( # type: ignore[union-attr] + identifier + ) + + return server, utilization + + async def async_get_data(self) -> dict[str, PterodactylData]: + """Update the data from all Pterodactyl servers.""" + data = {} + + for identifier in self.identifiers: + try: + server, utilization = await self.hass.async_add_executor_job( + self.get_server_data, identifier + ) + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + + raise PterodactylConnectionError(error) from error + else: + data[identifier] = PterodactylData( + name=server["name"], + uuid=server["uuid"], + identifier=identifier, + state=utilization["current_state"], + cpu_utilization=utilization["resources"]["cpu_absolute"], + cpu_limit=server["limits"]["cpu"], + memory_usage=utilization["resources"]["memory_bytes"], + memory_limit=server["limits"]["memory"], + disk_usage=utilization["resources"]["disk_bytes"], + disk_limit=server["limits"]["disk"], + network_inbound=utilization["resources"]["network_rx_bytes"], + network_outbound=utilization["resources"]["network_tx_bytes"], + uptime=utilization["resources"]["uptime"], + ) + + _LOGGER.debug("%s", data[identifier]) + + return data + + async def async_send_command( + self, identifier: str, command: PterodactylCommand + ) -> None: + """Send a command to the Pterodactyl server.""" + try: + await self.hass.async_add_executor_job( + self.pterodactyl.client.servers.send_power_action, # type: ignore[union-attr] + identifier, + command, + ) + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + + raise PterodactylConnectionError(error) from error diff --git a/homeassistant/components/pterodactyl/binary_sensor.py b/homeassistant/components/pterodactyl/binary_sensor.py new file mode 100644 index 00000000000..e3615c47499 --- /dev/null +++ b/homeassistant/components/pterodactyl/binary_sensor.py @@ -0,0 +1,64 @@ +"""Binary sensor platform of the Pterodactyl integration.""" + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator +from .entity import PterodactylEntity + +KEY_STATUS = "status" + + +BINARY_SENSOR_DESCRIPTIONS = [ + BinarySensorEntityDescription( + key=KEY_STATUS, + translation_key=KEY_STATUS, + device_class=BinarySensorDeviceClass.RUNNING, + ), +] + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl binary sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + PterodactylBinarySensorEntity( + coordinator, identifier, description, config_entry + ) + for identifier in coordinator.api.identifiers + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class PterodactylBinarySensorEntity(PterodactylEntity, BinarySensorEntity): + """Representation of a Pterodactyl binary sensor base entity.""" + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: BinarySensorEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize binary sensor base entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" + + @property + def is_on(self) -> bool: + """Return binary sensor state.""" + return self.game_server_data.state == "running" diff --git a/homeassistant/components/pterodactyl/button.py b/homeassistant/components/pterodactyl/button.py new file mode 100644 index 00000000000..44d3a6d0a82 --- /dev/null +++ b/homeassistant/components/pterodactyl/button.py @@ -0,0 +1,106 @@ +"""Button platform for the Pterodactyl integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .api import ( + PterodactylAuthorizationError, + PterodactylCommand, + PterodactylConnectionError, +) +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator +from .entity import PterodactylEntity + +KEY_START_SERVER = "start_server" +KEY_STOP_SERVER = "stop_server" +KEY_RESTART_SERVER = "restart_server" +KEY_FORCE_STOP_SERVER = "force_stop_server" + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PterodactylButtonEntityDescription(ButtonEntityDescription): + """Class describing Pterodactyl button entities.""" + + command: PterodactylCommand + + +BUTTON_DESCRIPTIONS = [ + PterodactylButtonEntityDescription( + key=KEY_START_SERVER, + translation_key=KEY_START_SERVER, + command=PterodactylCommand.START_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_STOP_SERVER, + translation_key=KEY_STOP_SERVER, + command=PterodactylCommand.STOP_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_RESTART_SERVER, + translation_key=KEY_RESTART_SERVER, + command=PterodactylCommand.RESTART_SERVER, + ), + PterodactylButtonEntityDescription( + key=KEY_FORCE_STOP_SERVER, + translation_key=KEY_FORCE_STOP_SERVER, + command=PterodactylCommand.FORCE_STOP_SERVER, + entity_registry_enabled_default=False, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl button platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + PterodactylButtonEntity(coordinator, identifier, description, config_entry) + for identifier in coordinator.api.identifiers + for description in BUTTON_DESCRIPTIONS + ) + + +class PterodactylButtonEntity(PterodactylEntity, ButtonEntity): + """Representation of a Pterodactyl button entity.""" + + entity_description: PterodactylButtonEntityDescription + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: PterodactylButtonEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.coordinator.api.async_send_command( + self.identifier, self.entity_description.command + ) + except PterodactylConnectionError as err: + raise HomeAssistantError( + f"Failed to send action '{self.entity_description.key}': Connection error" + ) from err + except PterodactylAuthorizationError as err: + raise HomeAssistantError( + f"Failed to send action '{self.entity_description.key}': Unauthorized" + ) from err diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py new file mode 100644 index 00000000000..db03c89f95e --- /dev/null +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -0,0 +1,110 @@ +"""Config flow for the Pterodactyl integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_URL + +from .api import ( + PterodactylAPI, + PterodactylAuthorizationError, + PterodactylConnectionError, +) +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_URL = "http://localhost:8080" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): str, + vol.Required(CONF_API_KEY): str, + } +) + +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } +) + + +class PterodactylConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Pterodactyl.""" + + VERSION = 1 + + async def async_validate_connection(self, url: str, api_key: str) -> dict[str, str]: + """Validate the connection to the Pterodactyl server.""" + errors: dict[str, str] = {} + api = PterodactylAPI(self.hass, url, api_key) + + try: + await api.async_init() + except PterodactylAuthorizationError: + errors["base"] = "invalid_auth" + except PterodactylConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception occurred during config flow") + errors["base"] = "unknown" + + return errors + + 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: + url = URL(user_input[CONF_URL]).human_repr() + api_key = user_input[CONF_API_KEY] + + self._async_abort_entries_match({CONF_URL: url}) + errors = await self.async_validate_connection(url, api_key) + + if not errors: + return self.async_create_entry(title=url, data=user_input) + + 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 re-authentication on an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that re-authentication is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + url = reauth_entry.data[CONF_URL] + api_key = user_input[CONF_API_KEY] + + errors = await self.async_validate_connection(url, api_key) + + 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=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/pterodactyl/const.py b/homeassistant/components/pterodactyl/const.py new file mode 100644 index 00000000000..8cf4d0c3963 --- /dev/null +++ b/homeassistant/components/pterodactyl/const.py @@ -0,0 +1,3 @@ +"""Constants for the Pterodactyl integration.""" + +DOMAIN = "pterodactyl" diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py new file mode 100644 index 00000000000..6d644e96e4c --- /dev/null +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -0,0 +1,71 @@ +"""Data update coordinator of the Pterodactyl integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import ( + PterodactylAPI, + PterodactylAuthorizationError, + PterodactylConnectionError, + PterodactylData, +) + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + +type PterodactylConfigEntry = ConfigEntry[PterodactylCoordinator] + + +class PterodactylCoordinator(DataUpdateCoordinator[dict[str, PterodactylData]]): + """Pterodactyl data update coordinator.""" + + config_entry: PterodactylConfigEntry + api: PterodactylAPI + + def __init__( + self, + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize coordinator instance.""" + + super().__init__( + hass=hass, + name=config_entry.data[CONF_URL], + config_entry=config_entry, + logger=_LOGGER, + update_interval=SCAN_INTERVAL, + ) + + async def _async_setup(self) -> None: + """Set up the Pterodactyl data coordinator.""" + self.api = PterodactylAPI( + hass=self.hass, + host=self.config_entry.data[CONF_URL], + api_key=self.config_entry.data[CONF_API_KEY], + ) + + try: + await self.api.async_init() + except PterodactylConnectionError as error: + raise UpdateFailed(error) from error + except PterodactylAuthorizationError as error: + raise ConfigEntryAuthFailed(error) from error + + async def _async_update_data(self) -> dict[str, PterodactylData]: + """Get updated data from the Pterodactyl server.""" + try: + return await self.api.async_get_data() + except PterodactylConnectionError as error: + raise UpdateFailed(error) from error + except PterodactylAuthorizationError as error: + raise ConfigEntryAuthFailed(error) from error diff --git a/homeassistant/components/pterodactyl/entity.py b/homeassistant/components/pterodactyl/entity.py new file mode 100644 index 00000000000..49fd65af476 --- /dev/null +++ b/homeassistant/components/pterodactyl/entity.py @@ -0,0 +1,47 @@ +"""Base entity for the Pterodactyl integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .api import PterodactylData +from .const import DOMAIN +from .coordinator import PterodactylCoordinator + +MANUFACTURER = "Pterodactyl" + + +class PterodactylEntity(CoordinatorEntity[PterodactylCoordinator]): + """Representation of a Pterodactyl base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + config_entry: ConfigEntry, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + + self.identifier = identifier + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer=MANUFACTURER, + name=self.game_server_data.name, + model=self.game_server_data.name, + model_id=self.game_server_data.uuid, + configuration_url=f"{config_entry.data[CONF_URL]}/server/{identifier}", + ) + + @property + def available(self) -> bool: + """Return binary sensor availability.""" + return super().available and self.identifier in self.coordinator.data + + @property + def game_server_data(self) -> PterodactylData: + """Return game server data.""" + return self.coordinator.data[self.identifier] diff --git a/homeassistant/components/pterodactyl/icons.json b/homeassistant/components/pterodactyl/icons.json new file mode 100644 index 00000000000..265a8dcadda --- /dev/null +++ b/homeassistant/components/pterodactyl/icons.json @@ -0,0 +1,47 @@ +{ + "entity": { + "button": { + "start_server": { + "default": "mdi:play" + }, + "stop_server": { + "default": "mdi:stop" + }, + "restart_server": { + "default": "mdi:refresh" + }, + "force_stop_server": { + "default": "mdi:flash-alert" + } + }, + "sensor": { + "cpu_utilization": { + "default": "mdi:cpu-64-bit" + }, + "cpu_limit": { + "default": "mdi:cpu-64-bit" + }, + "memory_usage": { + "default": "mdi:memory" + }, + "memory_limit": { + "default": "mdi:memory" + }, + "disk_usage": { + "default": "mdi:harddisk" + }, + "disk_limit": { + "default": "mdi:harddisk" + }, + "network_inbound": { + "default": "mdi:download" + }, + "network_outbound": { + "default": "mdi:upload" + }, + "uptime": { + "default": "mdi:timer" + } + } + } +} diff --git a/homeassistant/components/pterodactyl/manifest.json b/homeassistant/components/pterodactyl/manifest.json new file mode 100644 index 00000000000..8ffa21dd186 --- /dev/null +++ b/homeassistant/components/pterodactyl/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "pterodactyl", + "name": "Pterodactyl", + "codeowners": ["@elmurato"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/pterodactyl", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["py-dactyl==2.0.4"] +} diff --git a/homeassistant/components/pterodactyl/quality_scale.yaml b/homeassistant/components/pterodactyl/quality_scale.yaml new file mode 100644 index 00000000000..80ebb3fc7e3 --- /dev/null +++ b/homeassistant/components/pterodactyl/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration doesn't provide any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + 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: done + entity-event-setup: + status: done + comment: Handled by coordinator. + entity-unique-id: + status: done + comment: Using confid entry ID as the dependency pydactyl doesn't provide a unique information. + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: done + comment: | + Raising ConfigEntryNotReady, if the initialization isn't successful. + unique-config-entry: + status: done + comment: | + As there is no unique information available from the dependency pydactyl, + the server host 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: todo + entity-unavailable: + status: done + comment: Handled by coordinator. + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by coordinator. + parallel-updates: done + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + 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: 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: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair use-cases for this integration. + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: Integration isn't making any HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/pterodactyl/sensor.py b/homeassistant/components/pterodactyl/sensor.py new file mode 100644 index 00000000000..646b429cd08 --- /dev/null +++ b/homeassistant/components/pterodactyl/sensor.py @@ -0,0 +1,183 @@ +"""Sensor platform of the Pterodactyl integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator, PterodactylData +from .entity import PterodactylEntity + +KEY_CPU_UTILIZATION = "cpu_utilization" +KEY_CPU_LIMIT = "cpu_limit" +KEY_MEMORY_USAGE = "memory_usage" +KEY_MEMORY_LIMIT = "memory_limit" +KEY_DISK_USAGE = "disk_usage" +KEY_DISK_LIMIT = "disk_limit" +KEY_NETWORK_INBOUND = "network_inbound" +KEY_NETWORK_OUTBOUND = "network_outbound" +KEY_UPTIME = "uptime" + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class PterodactylSensorEntityDescription(SensorEntityDescription): + """Class describing Pterodactyl sensor entities.""" + + value_fn: Callable[[PterodactylData], StateType | datetime] + + +SENSOR_DESCRIPTIONS = [ + PterodactylSensorEntityDescription( + key=KEY_CPU_UTILIZATION, + translation_key=KEY_CPU_UTILIZATION, + value_fn=lambda data: data.cpu_utilization, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + ), + PterodactylSensorEntityDescription( + key=KEY_CPU_LIMIT, + translation_key=KEY_CPU_LIMIT, + value_fn=lambda data: data.cpu_limit, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_MEMORY_USAGE, + translation_key=KEY_MEMORY_USAGE, + value_fn=lambda data: data.memory_usage, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + ), + PterodactylSensorEntityDescription( + key=KEY_MEMORY_LIMIT, + translation_key=KEY_MEMORY_LIMIT, + value_fn=lambda data: data.memory_limit, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_DISK_USAGE, + translation_key=KEY_DISK_USAGE, + value_fn=lambda data: data.disk_usage, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + ), + PterodactylSensorEntityDescription( + key=KEY_DISK_LIMIT, + translation_key=KEY_DISK_LIMIT, + value_fn=lambda data: data.disk_limit, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_NETWORK_INBOUND, + translation_key=KEY_NETWORK_INBOUND, + value_fn=lambda data: data.network_inbound, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_NETWORK_OUTBOUND, + translation_key=KEY_NETWORK_OUTBOUND, + value_fn=lambda data: data.network_outbound, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + PterodactylSensorEntityDescription( + key=KEY_UPTIME, + translation_key=KEY_UPTIME, + value_fn=( + lambda data: dt_util.utcnow() - timedelta(milliseconds=data.uptime) + if data.uptime > 0 + else None + ), + device_class=SensorDeviceClass.TIMESTAMP, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + PterodactylSensorEntity(coordinator, identifier, description, config_entry) + for identifier in coordinator.api.identifiers + for description in SENSOR_DESCRIPTIONS + ) + + +class PterodactylSensorEntity(PterodactylEntity, SensorEntity): + """Representation of a Pterodactyl sensor base entity.""" + + entity_description: PterodactylSensorEntityDescription + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: PterodactylSensorEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize sensor base entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" + + @property + def native_value(self) -> StateType | datetime: + """Return native value of sensor.""" + return self.entity_description.value_fn(self.game_server_data) diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json new file mode 100644 index 00000000000..3d01700f189 --- /dev/null +++ b/homeassistant/components/pterodactyl/strings.json @@ -0,0 +1,85 @@ +{ + "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 your Pterodactyl server, including the protocol (http:// or https://) and optionally the port number.", + "api_key": "The account API key for accessing your Pterodactyl server." + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please update your account API key.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::pterodactyl::config::step::user::data_description::api_key%]" + } + } + }, + "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_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + }, + "button": { + "start_server": { + "name": "Start server" + }, + "stop_server": { + "name": "Stop server" + }, + "restart_server": { + "name": "Restart server" + }, + "force_stop_server": { + "name": "Force stop server" + } + }, + "sensor": { + "cpu_utilization": { + "name": "CPU utilization" + }, + "cpu_limit": { + "name": "CPU limit" + }, + "memory_usage": { + "name": "Memory usage" + }, + "memory_limit": { + "name": "Memory limit" + }, + "disk_usage": { + "name": "Disk usage" + }, + "disk_limit": { + "name": "Disk limit" + }, + "network_inbound": { + "name": "Network inbound" + }, + "network_outbound": { + "name": "Network outbound" + }, + "uptime": { + "name": "Uptime" + } + } + } +} diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 2d4022946b2..78986b34351 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -2,37 +2,34 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator - -PLATFORMS = [Platform.SENSOR] +from .const import PLATFORMS +from .coordinator import PurpleAirConfigEntry, PurpleAirDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up PurpleAir from a config entry.""" - coordinator = PurpleAirDataUpdateCoordinator(hass, entry) +async def async_setup_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool: + """Set up PurpleAir config entry.""" + coordinator = PurpleAirDataUpdateCoordinator( + hass, + entry, + ) + entry.runtime_data = coordinator + await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_handle_entry_update)) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -async def async_handle_entry_update(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle an options update.""" +async def async_reload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None: + """Reload config entry.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> 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 +async def async_unload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool: + """Unload config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/purpleair/const.py b/homeassistant/components/purpleair/const.py index 5f1ec84d469..fcb928bd4f3 100644 --- a/homeassistant/components/purpleair/const.py +++ b/homeassistant/components/purpleair/const.py @@ -1,10 +1,13 @@ """Constants for the PurpleAir integration.""" import logging +from typing import Final -DOMAIN = "purpleair" +from homeassistant.const import Platform -LOGGER = logging.getLogger(__package__) +LOGGER: Final = logging.getLogger(__package__) +PLATFORMS: Final = [Platform.SENSOR] -CONF_READ_KEY = "read_key" -CONF_SENSOR_INDICES = "sensor_indices" +DOMAIN: Final[str] = "purpleair" + +CONF_SENSOR_INDICES: Final[str] = "sensor_indices" diff --git a/homeassistant/components/purpleair/coordinator.py b/homeassistant/components/purpleair/coordinator.py index f1511733cfa..4ed0c0340c6 100644 --- a/homeassistant/components/purpleair/coordinator.py +++ b/homeassistant/components/purpleair/coordinator.py @@ -46,12 +46,15 @@ SENSOR_FIELDS_TO_RETRIEVE = [ UPDATE_INTERVAL = timedelta(minutes=2) +type PurpleAirConfigEntry = ConfigEntry[PurpleAirDataUpdateCoordinator] + + class PurpleAirDataUpdateCoordinator(DataUpdateCoordinator[GetSensorsResponse]): """Define a PurpleAir-specific coordinator.""" - config_entry: ConfigEntry + config_entry: PurpleAirConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None: """Initialize.""" self._api = API( entry.data[CONF_API_KEY], diff --git a/homeassistant/components/purpleair/diagnostics.py b/homeassistant/components/purpleair/diagnostics.py index f7c44b7e9b2..71b83e277d3 100644 --- a/homeassistant/components/purpleair/diagnostics.py +++ b/homeassistant/components/purpleair/diagnostics.py @@ -5,7 +5,6 @@ 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_API_KEY, CONF_LATITUDE, @@ -14,8 +13,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator +from .coordinator import PurpleAirConfigEntry CONF_TITLE = "title" @@ -30,14 +28,13 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: PurpleAirConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: PurpleAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return async_redact_data( { "entry": entry.as_dict(), - "data": coordinator.data.model_dump(), + "data": entry.runtime_data.data.model_dump(), }, TO_REDACT, ) diff --git a/homeassistant/components/purpleair/entity.py b/homeassistant/components/purpleair/entity.py index 4f7be1874ed..410fdd9b942 100644 --- a/homeassistant/components/purpleair/entity.py +++ b/homeassistant/components/purpleair/entity.py @@ -7,13 +7,12 @@ from typing import Any from aiopurpleair.models.sensors import SensorModel -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator +from .coordinator import PurpleAirConfigEntry, PurpleAirDataUpdateCoordinator class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): @@ -23,12 +22,11 @@ class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): def __init__( self, - coordinator: PurpleAirDataUpdateCoordinator, - entry: ConfigEntry, + entry: PurpleAirConfigEntry, sensor_index: int, ) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__(entry.runtime_data) self._sensor_index = sensor_index diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index bed1d878557..a85a23b6144 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, @@ -27,8 +26,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_SENSOR_INDICES, DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator +from .const import CONF_SENSOR_INDICES +from .coordinator import PurpleAirConfigEntry from .entity import PurpleAirEntity CONCENTRATION_PARTICLES_PER_100_MILLILITERS = f"particles/100{UnitOfVolume.MILLILITERS}" @@ -165,13 +164,12 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PurpleAirConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PurpleAir sensors based on a config entry.""" - coordinator: PurpleAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - PurpleAirSensorEntity(coordinator, entry, sensor_index, description) + PurpleAirSensorEntity(entry, sensor_index, description) for sensor_index in entry.options[CONF_SENSOR_INDICES] for description in SENSOR_DESCRIPTIONS ) @@ -184,13 +182,12 @@ class PurpleAirSensorEntity(PurpleAirEntity, SensorEntity): def __init__( self, - coordinator: PurpleAirDataUpdateCoordinator, - entry: ConfigEntry, + entry: PurpleAirConfigEntry, sensor_index: int, description: PurpleAirSensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(coordinator, entry, sensor_index) + super().__init__(entry, sensor_index) self._attr_unique_id = f"{self._sensor_index}-{description.key}" self.entity_description = description diff --git a/homeassistant/components/pushover/manifest.json b/homeassistant/components/pushover/manifest.json index d086321c088..e13a254c423 100644 --- a/homeassistant/components/pushover/manifest.json +++ b/homeassistant/components/pushover/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pushover", "iot_class": "cloud_push", "loggers": ["pushover_complete"], - "requirements": ["pushover_complete==1.1.1"] + "requirements": ["pushover_complete==1.2.0"] } diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index 9dbdad53bcb..dee5f9cda6e 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.2.0"] + "requirements": ["pvo==2.2.1"] } diff --git a/homeassistant/components/pvoutput/strings.json b/homeassistant/components/pvoutput/strings.json index 06d98971053..651bb55a2b4 100644 --- a/homeassistant/components/pvoutput/strings.json +++ b/homeassistant/components/pvoutput/strings.json @@ -27,19 +27,19 @@ "entity": { "sensor": { "energy_consumption": { - "name": "Energy consumed" + "name": "Energy consumption" }, "energy_generation": { - "name": "Energy generated" + "name": "Energy generation" }, "efficiency": { "name": "Efficiency" }, "power_consumption": { - "name": "Power consumed" + "name": "Power consumption" }, "power_generation": { - "name": "Power generated" + "name": "Power generation" } } } diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index cf8e922d70e..ca7bbb0c1dc 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -2,14 +2,18 @@ from __future__ import annotations +import logging + from aiohttp import CookieJar from pyloadapi import PyLoadAPI +from yarl import URL from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, Platform, @@ -19,17 +23,14 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import PyLoadConfigEntry, PyLoadCoordinator +_LOGGER = logging.getLogger(__name__) + PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: """Set up pyLoad from a config entry.""" - url = ( - f"{'https' if entry.data[CONF_SSL] else 'http'}://" - f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/" - ) - session = async_create_clientsession( hass, verify_ssl=entry.data[CONF_VERIFY_SSL], @@ -37,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo ) pyloadapi = PyLoadAPI( session, - api_url=url, + api_url=URL(entry.data[CONF_URL]), username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], ) @@ -55,3 +56,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: + """Migrate config entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", entry.version, entry.minor_version + ) + + if entry.version == 1 and entry.minor_version == 0: + url = URL.build( + scheme="https" if entry.data[CONF_SSL] else "http", + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + ).human_repr() + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_URL: url}, minor_version=1, version=1 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + entry.version, + entry.minor_version, + ) + return True diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index bc3bbc6cb34..50d354d345d 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -9,19 +9,17 @@ from typing import Any from aiohttp import CookieJar from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PASSWORD, - CONF_PORT, - CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( TextSelector, @@ -29,15 +27,18 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) -from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Required(CONF_SSL, default=False): cv.boolean, + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), vol.Required(CONF_VERIFY_SSL, default=True): bool, vol.Required(CONF_USERNAME): TextSelector( TextSelectorConfig( @@ -80,14 +81,9 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non user_input[CONF_VERIFY_SSL], cookie_jar=CookieJar(unsafe=True), ) - - url = ( - f"{'https' if user_input[CONF_SSL] else 'http'}://" - f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/" - ) pyload = PyLoadAPI( session, - api_url=url, + api_url=URL(user_input[CONF_URL]), username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], ) @@ -99,6 +95,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for pyLoad.""" VERSION = 1 + MINOR_VERSION = 1 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -106,9 +103,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match( - {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} - ) + url = URL(user_input[CONF_URL]).human_repr() + self._async_abort_entries_match({CONF_URL: url}) try: await validate_input(self.hass, user_input) except (CannotConnect, ParserError): @@ -120,7 +116,14 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: title = DEFAULT_NAME - return self.async_create_entry(title=title, data=user_input) + + return self.async_create_entry( + title=title, + data={ + **user_input, + CONF_URL: url, + }, + ) return self.async_show_form( step_id="user", @@ -144,9 +147,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() if user_input is not None: - new_input = reauth_entry.data | user_input try: - await validate_input(self.hass, new_input) + await validate_input(self.hass, {**reauth_entry.data, **user_input}) except (CannotConnect, ParserError): errors["base"] = "cannot_connect" except InvalidAuth: @@ -155,7 +157,9 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_update_reload_and_abort(reauth_entry, data=new_input) + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) return self.async_show_form( step_id="reauth_confirm", @@ -191,15 +195,18 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): else: return self.async_update_reload_and_abort( reconfig_entry, - data=user_input, + data={ + **user_input, + CONF_URL: URL(user_input[CONF_URL]).human_repr(), + }, reload_even_if_entry_is_unchanged=False, ) - + suggested_values = user_input if user_input else reconfig_entry.data return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_USER_DATA_SCHEMA, - user_input or reconfig_entry.data, + suggested_values, ), description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, errors=errors, diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index c57dfa7720d..7bb2b870520 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -31,6 +31,7 @@ class PyLoadData: download: bool reconnect: bool captcha: bool | None = None + proxy: bool | None = None free_space: int diff --git a/homeassistant/components/pyload/diagnostics.py b/homeassistant/components/pyload/diagnostics.py index 105a9a953e2..98fab38da1d 100644 --- a/homeassistant/components/pyload/diagnostics.py +++ b/homeassistant/components/pyload/diagnostics.py @@ -5,13 +5,15 @@ from __future__ import annotations from dataclasses import asdict from typing import Any -from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from yarl import URL + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from .coordinator import PyLoadConfigEntry, PyLoadData -TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_HOST} +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_URL} async def async_get_config_entry_diagnostics( @@ -21,6 +23,9 @@ async def async_get_config_entry_diagnostics( pyload_data: PyLoadData = config_entry.runtime_data.data return { - "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), + "config_entry_data": { + **async_redact_data(dict(config_entry.data), TO_REDACT), + CONF_URL: URL(config_entry.data[CONF_URL]).with_host(REDACTED).human_repr(), + }, "pyload_data": asdict(pyload_data), } diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index 134865b9d93..feaa23af7de 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["pyloadapi"], + "quality_scale": "platinum", "requirements": ["PyLoadAPI==1.4.2"] } diff --git a/homeassistant/components/pyload/quality_scale.yaml b/homeassistant/components/pyload/quality_scale.yaml new file mode 100644 index 00000000000..a9ce552961b --- /dev/null +++ b/homeassistant/components/pyload/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration registers no 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 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: done + 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: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: The integration is a web service, there are no devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The integration is a web service, there are no devices. + 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: The integration has no repairs. + stale-devices: + status: exempt + comment: The integration is a web service, there are no devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index ed15a438c28..9414f7f7bb8 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -3,38 +3,30 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "port": "[%key:common::config_flow::data::port%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The hostname or IP address of the device running your pyLoad instance.", + "url": "Specify the full URL of your pyLoad web interface, including the protocol (HTTP or HTTPS), hostname or IP address, port (pyLoad uses 8000 by default), and any path prefix if applicable.\nExample: `https://example.com:8000/path`", "username": "The username used to access the pyLoad instance.", "password": "The password associated with the pyLoad account.", - "port": "pyLoad uses port 8000 by default.", - "ssl": "If enabled, the connection to the pyLoad instance will use HTTPS.", "verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection." } }, "reconfigure": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "port": "[%key:common::config_flow::data::port%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "[%key:component::pyload::config::step::user::data_description::host%]", - "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]", + "url": "[%key:component::pyload::config::step::user::data_description::url%]", "username": "[%key:component::pyload::config::step::user::data_description::username%]", "password": "[%key:component::pyload::config::step::user::data_description::password%]", - "port": "[%key:component::pyload::config::step::user::data_description::port%]", - "ssl": "[%key:component::pyload::config::step::user::data_description::ssl%]" + "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]" } }, "reauth_confirm": { diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index bd9897aa6ba..2f813e35557 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], - "requirements": ["qbittorrent-api==2024.2.59"] + "requirements": ["qbittorrent-api==2024.9.67"] } diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 23ec485fcd4..d565d2f7b5f 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -218,7 +218,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( key=SENSOR_TYPE_PAUSED_TORRENTS, translation_key="paused_torrents", value_fn=lambda coordinator: count_torrents_in_states( - coordinator, ["pausedDL", "pausedUP"] + coordinator, ["stoppedDL", "stoppedUP"] ), ), ) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index ee613eb96c2..ef2f45bbc28 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -53,9 +53,9 @@ "connection_status": { "name": "Connection status", "state": { - "connected": "Connected", + "connected": "[%key:common::state::connected%]", "firewalled": "Firewalled", - "disconnected": "Disconnected" + "disconnected": "[%key:common::state::disconnected%]" } }, "active_torrents": { diff --git a/homeassistant/components/qbus/climate.py b/homeassistant/components/qbus/climate.py new file mode 100644 index 00000000000..57d97c046b7 --- /dev/null +++ b/homeassistant/components/qbus/climate.py @@ -0,0 +1,172 @@ +"""Support for Qbus thermostat.""" + +import logging +from typing import Any + +from qbusmqttapi.const import KEY_PROPERTIES_REGIME, KEY_PROPERTIES_SET_TEMPERATURE +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttThermoState, StateType + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.mqtt import ReceiveMessage, client as mqtt +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, add_new_outputs + +PARALLEL_UPDATES = 0 + +STATE_REQUEST_DELAY = 2 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up climate entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _check_outputs() -> None: + add_new_outputs( + coordinator, + added_outputs, + lambda output: output.type == "thermo", + QbusClimate, + async_add_entities, + ) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusClimate(QbusEntity, ClimateEntity): + """Representation of a Qbus climate entity.""" + + _attr_hvac_modes = [HVACMode.HEAT] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize climate entity.""" + + super().__init__(mqtt_output) + + self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVACMode.HEAT + + set_temp: dict[str, Any] = mqtt_output.properties.get( + KEY_PROPERTIES_SET_TEMPERATURE, {} + ) + current_regime: dict[str, Any] = mqtt_output.properties.get( + KEY_PROPERTIES_REGIME, {} + ) + + self._attr_min_temp: float = set_temp.get("min", 0) + self._attr_max_temp: float = set_temp.get("max", 35) + self._attr_target_temperature_step: float = set_temp.get("step", 0.5) + self._attr_preset_modes: list[str] = current_regime.get("enumValues", []) + self._attr_preset_mode: str = ( + self._attr_preset_modes[0] if len(self._attr_preset_modes) > 0 else "" + ) + + self._request_state_debouncer: Debouncer | None = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self._request_state_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=STATE_REQUEST_DELAY, + immediate=False, + function=self._async_request_state, + ) + await super().async_added_to_hass() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + + if preset_mode not in self._attr_preset_modes: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_preset", + translation_placeholders={ + "preset": preset_mode, + "options": ", ".join(self._attr_preset_modes), + }, + ) + + state = QbusMqttThermoState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_regime(preset_mode) + + await self._async_publish_output_state(state) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + + if temperature is not None and isinstance(temperature, float): + state = QbusMqttThermoState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_set_temperature(temperature) + + await self._async_publish_output_state(state) + + async def _state_received(self, msg: ReceiveMessage) -> None: + state = self._message_factory.parse_output_state( + QbusMqttThermoState, msg.payload + ) + + if state is None: + return + + if preset_mode := state.read_regime(): + self._attr_preset_mode = preset_mode + + if current_temperature := state.read_current_temperature(): + self._attr_current_temperature = current_temperature + + if target_temperature := state.read_set_temperature(): + self._attr_target_temperature = target_temperature + + self._set_hvac_action() + + # When the state type is "event", the payload only contains the changed + # property. Request the state to get the full payload. However, changing + # temperature step by step could cause a flood of state requests, so we're + # holding off a few seconds before requesting the full state. + if state.type == StateType.EVENT: + assert self._request_state_debouncer is not None + await self._request_state_debouncer.async_call() + + self.async_schedule_update_ha_state() + + def _set_hvac_action(self) -> None: + if self.target_temperature is None or self.current_temperature is None: + self._attr_hvac_action = HVACAction.IDLE + return + + self._attr_hvac_action = ( + HVACAction.HEATING + if self.target_temperature > self.current_temperature + else HVACAction.IDLE + ) + + async def _async_request_state(self) -> None: + request = self._message_factory.create_state_request([self._mqtt_output.id]) + await mqtt.async_publish(self.hass, request.topic, request.payload) diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index b9e42f13766..767a41f48cc 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -6,6 +6,7 @@ from homeassistant.const import Platform DOMAIN: Final = "qbus" PLATFORMS: list[Platform] = [ + Platform.CLIMATE, Platform.LIGHT, Platform.SWITCH, ] diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 5ec76f5e807..3d2c763b8e3 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -51,7 +51,7 @@ class QbusLight(QbusEntity, LightEntity): super().__init__(mqtt_output) - self._set_state() + self._set_state(0) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -74,7 +74,6 @@ class QbusLight(QbusEntity, LightEntity): state.write_percentage(percentage) await self._async_publish_output_state(state) - self._set_state(percentage=percentage, on=on) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" @@ -82,7 +81,6 @@ class QbusLight(QbusEntity, LightEntity): state.write_on_off(on=False) await self._async_publish_output_state(state) - self._set_state(on=False) async def _state_received(self, msg: ReceiveMessage) -> None: output = self._message_factory.parse_output_state( @@ -91,20 +89,9 @@ class QbusLight(QbusEntity, LightEntity): if output is not None: percentage = round(output.read_percentage()) - self._set_state(percentage=percentage) + self._set_state(percentage) self.async_schedule_update_ha_state() - def _set_state( - self, *, percentage: int | None = None, on: bool | None = None - ) -> None: - if percentage is None: - # When turning on without brightness, we don't know the desired - # brightness. It will be set during _state_received(). - if on is True: - self._attr_is_on = True - else: - self._attr_is_on = False - self._attr_brightness = 0 - else: - self._attr_is_on = percentage > 0 - self._attr_brightness = value_to_brightness((1, 100), percentage) + def _set_state(self, percentage: int = 0) -> None: + self._attr_is_on = percentage > 0 + self._attr_brightness = value_to_brightness((1, 100), percentage) diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json index e6df18c393c..f308c5b3519 100644 --- a/homeassistant/components/qbus/strings.json +++ b/homeassistant/components/qbus/strings.json @@ -15,5 +15,10 @@ "error": { "no_controller": "No controllers were found" } + }, + "exceptions": { + "invalid_preset": { + "message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}." + } } } diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py index 002ad43e904..e1feccf4450 100644 --- a/homeassistant/components/qbus/switch.py +++ b/homeassistant/components/qbus/switch.py @@ -57,7 +57,6 @@ class QbusSwitch(QbusEntity, SwitchEntity): 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.""" @@ -65,7 +64,6 @@ class QbusSwitch(QbusEntity, SwitchEntity): 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( diff --git a/homeassistant/components/qnap/config_flow.py b/homeassistant/components/qnap/config_flow.py index 75f41a27f69..504883b55e9 100644 --- a/homeassistant/components/qnap/config_flow.py +++ b/homeassistant/components/qnap/config_flow.py @@ -70,8 +70,8 @@ class QnapConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except TypeError: errors["base"] = "invalid_auth" - except Exception as error: # noqa: BLE001 - _LOGGER.error(error) + except Exception: + _LOGGER.exception("Unexpected error") errors["base"] = "unknown" else: unique_id = stats["system"]["serial_number"] diff --git a/homeassistant/components/qnap/coordinator.py b/homeassistant/components/qnap/coordinator.py index 297f6569d2b..a6d654ddbbd 100644 --- a/homeassistant/components/qnap/coordinator.py +++ b/homeassistant/components/qnap/coordinator.py @@ -2,11 +2,13 @@ from __future__ import annotations +from contextlib import contextmanager, nullcontext from datetime import timedelta import logging from typing import Any from qnapstats import QNAPStats +import urllib3 from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,6 +30,17 @@ UPDATE_INTERVAL = timedelta(minutes=1) _LOGGER = logging.getLogger(__name__) +@contextmanager +def suppress_insecure_request_warning(): + """Context manager to suppress InsecureRequestWarning. + + Was added in here to solve the following issue, not being solved upstream. + https://github.com/colinodell/python-qnapstats/issues/96 + """ + with urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning): + yield + + class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Custom coordinator for the qnap integration.""" @@ -42,24 +55,31 @@ class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): ) protocol = "https" if config_entry.data[CONF_SSL] else "http" + self._verify_ssl = config_entry.data.get(CONF_VERIFY_SSL) + self._api = QNAPStats( f"{protocol}://{config_entry.data.get(CONF_HOST)}", config_entry.data.get(CONF_PORT), config_entry.data.get(CONF_USERNAME), config_entry.data.get(CONF_PASSWORD), - verify_ssl=config_entry.data.get(CONF_VERIFY_SSL), + verify_ssl=self._verify_ssl, timeout=config_entry.data.get(CONF_TIMEOUT), ) def _sync_update(self) -> dict[str, dict[str, Any]]: """Get the latest data from the Qnap API.""" - return { - "system_stats": self._api.get_system_stats(), - "system_health": self._api.get_system_health(), - "smart_drive_health": self._api.get_smart_disk_health(), - "volumes": self._api.get_volumes(), - "bandwidth": self._api.get_bandwidth(), - } + with ( + suppress_insecure_request_warning() + if not self._verify_ssl + else nullcontext() + ): + return { + "system_stats": self._api.get_system_stats(), + "system_health": self._api.get_system_health(), + "smart_drive_health": self._api.get_smart_disk_health(), + "volumes": self._api.get_volumes(), + "bandwidth": self._api.get_bandwidth(), + } async def _async_update_data(self) -> dict[str, dict[str, Any]]: """Get the latest data from the Qnap API.""" diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index cd3ee8eca42..e29e95abc62 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.1.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==11.2.1", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/qwikswitch/binary_sensor.py b/homeassistant/components/qwikswitch/binary_sensor.py index 195433ebc17..bbe8d309e50 100644 --- a/homeassistant/components/qwikswitch/binary_sensor.py +++ b/homeassistant/components/qwikswitch/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH +from . import DOMAIN from .entity import QSEntity _LOGGER = logging.getLogger(__name__) @@ -27,9 +27,9 @@ async def async_setup_platform( if discovery_info is None: return - qsusb = hass.data[QWIKSWITCH] + qsusb = hass.data[DOMAIN] _LOGGER.debug("Setup qwikswitch.binary_sensor %s, %s", qsusb, discovery_info) - devs = [QSBinarySensor(sensor) for sensor in discovery_info[QWIKSWITCH]] + devs = [QSBinarySensor(sensor) for sensor in discovery_info[DOMAIN]] add_entities(devs) diff --git a/homeassistant/components/qwikswitch/light.py b/homeassistant/components/qwikswitch/light.py index 073f7bb873a..0f91faeedc8 100644 --- a/homeassistant/components/qwikswitch/light.py +++ b/homeassistant/components/qwikswitch/light.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH +from . import DOMAIN from .entity import QSToggleEntity @@ -21,8 +21,8 @@ async def async_setup_platform( if discovery_info is None: return - qsusb = hass.data[QWIKSWITCH] - devs = [QSLight(qsid, qsusb) for qsid in discovery_info[QWIKSWITCH]] + qsusb = hass.data[DOMAIN] + devs = [QSLight(qsid, qsusb) for qsid in discovery_info[DOMAIN]] add_entities(devs) diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py index 64b95fb17f6..e87fae83464 100644 --- a/homeassistant/components/qwikswitch/sensor.py +++ b/homeassistant/components/qwikswitch/sensor.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH +from . import DOMAIN from .entity import QSEntity _LOGGER = logging.getLogger(__name__) @@ -28,9 +28,9 @@ async def async_setup_platform( if discovery_info is None: return - qsusb = hass.data[QWIKSWITCH] + qsusb = hass.data[DOMAIN] _LOGGER.debug("Setup qwikswitch.sensor %s, %s", qsusb, discovery_info) - devs = [QSSensor(sensor) for sensor in discovery_info[QWIKSWITCH]] + devs = [QSSensor(sensor) for sensor in discovery_info[DOMAIN]] add_entities(devs) diff --git a/homeassistant/components/qwikswitch/switch.py b/homeassistant/components/qwikswitch/switch.py index ec47b4d99f2..6131d9e595c 100644 --- a/homeassistant/components/qwikswitch/switch.py +++ b/homeassistant/components/qwikswitch/switch.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH +from . import DOMAIN from .entity import QSToggleEntity @@ -21,8 +21,8 @@ async def async_setup_platform( if discovery_info is None: return - qsusb = hass.data[QWIKSWITCH] - devs = [QSSwitch(qsid, qsusb) for qsid in discovery_info[QWIKSWITCH]] + qsusb = hass.data[DOMAIN] + devs = [QSSwitch(qsid, qsusb) for qsid in discovery_info[DOMAIN]] add_entities(devs) diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py index f4487a73b58..43959e1e42c 100644 --- a/homeassistant/components/rabbitair/config_flow.py +++ b/homeassistant/components/rabbitair/config_flow.py @@ -74,8 +74,8 @@ class RabbitAirConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_host" except TimeoutConnect: errors["base"] = "timeout_connect" - except Exception as err: # noqa: BLE001 - _LOGGER.debug("Unexpected exception: %s", err) + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: user_input[CONF_MAC] = info["mac"] diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index d6cdd2701b6..ab0886096cc 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -7,13 +7,12 @@ from rachiopy import Rachio from requests.exceptions import ConnectTimeout from homeassistant.components import cloud -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, DOMAIN -from .device import RachioPerson +from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS +from .device import RachioConfigEntry, RachioPerson from .webhooks import ( async_get_or_create_registered_webhook_id_and_url, async_register_webhook, @@ -25,21 +24,20 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SWITCH] -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RachioConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): async_unregister_webhook(hass, entry) - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: RachioConfigEntry) -> None: """Remove a rachio config entry.""" if CONF_CLOUDHOOK_URL in entry.data: await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RachioConfigEntry) -> bool: """Set up the Rachio config entry.""" config = entry.data @@ -97,7 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await base.schedule_coordinator.async_config_entry_first_refresh() # Enable platform - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = person + entry.runtime_data = person async_register_webhook(hass, entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 3bf0f716c6d..dbe41de2c4c 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -1,28 +1,29 @@ """Integration with the Rachio Iro sprinkler system controller.""" -from abc import abstractmethod +from collections.abc import Callable +from dataclasses import dataclass import logging from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN as DOMAIN_RACHIO, - KEY_BATTERY_STATUS, + KEY_BATTERY, + KEY_DETECT_FLOW, KEY_DEVICE_ID, - KEY_LOW, + KEY_FLOW, + KEY_ONLINE, + KEY_RAIN_SENSOR, KEY_RAIN_SENSOR_TRIPPED, - KEY_REPLACE, - KEY_REPORTED_STATE, - KEY_STATE, KEY_STATUS, KEY_SUBTYPE, SIGNAL_RACHIO_CONTROLLER_UPDATE, @@ -30,7 +31,7 @@ from .const import ( STATUS_ONLINE, ) from .coordinator import RachioUpdateCoordinator -from .device import RachioPerson +from .device import RachioConfigEntry, RachioIro from .entity import RachioDevice, RachioHoseTimerEntity from .webhooks import ( SUBTYPE_COLD_REBOOT, @@ -43,9 +44,70 @@ from .webhooks import ( _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class RachioControllerBinarySensorDescription(BinarySensorEntityDescription): + """Describe a Rachio controller binary sensor.""" + + update_received: Callable[[str], bool | None] + is_on: Callable[[RachioIro], bool] + signal_string: str + + +CONTROLLER_BINARY_SENSOR_TYPES: tuple[RachioControllerBinarySensorDescription, ...] = ( + RachioControllerBinarySensorDescription( + key=KEY_ONLINE, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + signal_string=SIGNAL_RACHIO_CONTROLLER_UPDATE, + is_on=lambda controller: controller.init_data[KEY_STATUS] == STATUS_ONLINE, + update_received={ + SUBTYPE_ONLINE: True, + SUBTYPE_COLD_REBOOT: True, + SUBTYPE_OFFLINE: False, + }.get, + ), + RachioControllerBinarySensorDescription( + key=KEY_RAIN_SENSOR, + translation_key="rain", + device_class=BinarySensorDeviceClass.MOISTURE, + signal_string=SIGNAL_RACHIO_RAIN_SENSOR_UPDATE, + is_on=lambda controller: controller.init_data[KEY_RAIN_SENSOR_TRIPPED], + update_received={ + SUBTYPE_RAIN_SENSOR_DETECTION_ON: True, + SUBTYPE_RAIN_SENSOR_DETECTION_OFF: False, + }.get, + ), +) + + +@dataclass(frozen=True, kw_only=True) +class RachioHoseTimerBinarySensorDescription(BinarySensorEntityDescription): + """Describe a Rachio hose timer binary sensor.""" + + value_fn: Callable[[RachioHoseTimerEntity], bool] + exists_fn: Callable[[dict[str, Any]], bool] = lambda _: True + + +HOSE_TIMER_BINARY_SENSOR_TYPES: tuple[RachioHoseTimerBinarySensorDescription, ...] = ( + RachioHoseTimerBinarySensorDescription( + key=KEY_BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.BATTERY, + value_fn=lambda device: device.battery, + ), + RachioHoseTimerBinarySensorDescription( + key=KEY_FLOW, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="flow", + value_fn=lambda device: device.no_flow_detected, + exists_fn=lambda valve: valve[KEY_DETECT_FLOW], + ), +) + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RachioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio binary sensors.""" @@ -53,25 +115,42 @@ async def async_setup_entry( async_add_entities(entities) -def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]: +def _create_entities( + hass: HomeAssistant, config_entry: RachioConfigEntry +) -> list[Entity]: entities: list[Entity] = [] - person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] - for controller in person.controllers: - entities.append(RachioControllerOnlineBinarySensor(controller)) - entities.append(RachioRainSensor(controller)) + person = config_entry.runtime_data entities.extend( - RachioHoseTimerBattery(valve, base_station.status_coordinator) + RachioControllerBinarySensor(controller, description) + for controller in person.controllers + for description in CONTROLLER_BINARY_SENSOR_TYPES + ) + entities.extend( + RachioHoseTimerBinarySensor(valve, base_station.status_coordinator, description) for base_station in person.base_stations for valve in base_station.status_coordinator.data.values() + for description in HOSE_TIMER_BINARY_SENSOR_TYPES + if description.exists_fn(valve) ) return entities class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity): - """Represent a binary sensor that reflects a Rachio state.""" + """Represent a binary sensor that reflects a Rachio controller state.""" + entity_description: RachioControllerBinarySensorDescription _attr_has_entity_name = True + def __init__( + self, + controller: RachioIro, + description: RachioControllerBinarySensorDescription, + ) -> None: + """Initialize a controller binary sensor.""" + super().__init__(controller) + self.entity_description = description + self._attr_unique_id = f"{controller.controller_id}-{description.key}" + @callback def _async_handle_any_update(self, *args, **kwargs) -> None: """Determine whether an update event applies to this device.""" @@ -82,97 +161,49 @@ class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity): # For this device self._async_handle_update(args, kwargs) - @abstractmethod - def _async_handle_update(self, *args, **kwargs) -> None: - """Handle an update to the state of this sensor.""" - - -class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): - """Represent a binary sensor that reflects if the controller is online.""" - - _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - - @property - def unique_id(self) -> str: - """Return a unique id for this entity.""" - return f"{self._controller.controller_id}-online" - @callback def _async_handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" - if args[0][0][KEY_SUBTYPE] in (SUBTYPE_ONLINE, SUBTYPE_COLD_REBOOT): - self._attr_is_on = True - elif args[0][0][KEY_SUBTYPE] == SUBTYPE_OFFLINE: - self._attr_is_on = False + if ( + updated_state := self.entity_description.update_received( + args[0][0][KEY_SUBTYPE] + ) + ) is not None: + self._attr_is_on = updated_state self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._attr_is_on = self._controller.init_data[KEY_STATUS] == STATUS_ONLINE + self._attr_is_on = self.entity_description.is_on(self._controller) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_RACHIO_CONTROLLER_UPDATE, + self.entity_description.signal_string, self._async_handle_any_update, ) ) -class RachioRainSensor(RachioControllerBinarySensor): - """Represent a binary sensor that reflects the status of the rain sensor.""" +class RachioHoseTimerBinarySensor(RachioHoseTimerEntity, BinarySensorEntity): + """Represents a binary sensor for a smart hose timer.""" - _attr_device_class = BinarySensorDeviceClass.MOISTURE - _attr_translation_key = "rain" - - @property - def unique_id(self) -> str: - """Return a unique id for this entity.""" - return f"{self._controller.controller_id}-rain_sensor" - - @callback - def _async_handle_update(self, *args, **kwargs) -> None: - """Handle an update to the state of this sensor.""" - if args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_SENSOR_DETECTION_ON: - self._attr_is_on = True - elif args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_SENSOR_DETECTION_OFF: - self._attr_is_on = False - - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Subscribe to updates.""" - self._attr_is_on = self._controller.init_data[KEY_RAIN_SENSOR_TRIPPED] - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_RACHIO_RAIN_SENSOR_UPDATE, - self._async_handle_any_update, - ) - ) - - -class RachioHoseTimerBattery(RachioHoseTimerEntity, BinarySensorEntity): - """Represents a battery sensor for a smart hose timer.""" - - _attr_device_class = BinarySensorDeviceClass.BATTERY + entity_description: RachioHoseTimerBinarySensorDescription def __init__( - self, data: dict[str, Any], coordinator: RachioUpdateCoordinator + self, + data: dict[str, Any], + coordinator: RachioUpdateCoordinator, + description: RachioHoseTimerBinarySensorDescription, ) -> None: - """Initialize a smart hose timer battery sensor.""" + """Initialize a smart hose timer binary sensor.""" super().__init__(data, coordinator) - self._attr_unique_id = f"{self.id}-battery" + self.entity_description = description + self._attr_unique_id = f"{self.id}-{description.key}" + self._update_attr() @callback def _update_attr(self) -> None: """Handle updated coordinator data.""" - data = self.coordinator.data[self.id] - - self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] - self._attr_is_on = self._static_attrs[KEY_BATTERY_STATUS] in [ - KEY_LOW, - KEY_REPLACE, - ] + self._attr_is_on = self.entity_description.value_fn(self) diff --git a/homeassistant/components/rachio/calendar.py b/homeassistant/components/rachio/calendar.py index 91ad29fac9f..18b1b6a4d8f 100644 --- a/homeassistant/components/rachio/calendar.py +++ b/homeassistant/components/rachio/calendar.py @@ -9,7 +9,6 @@ from homeassistant.components.calendar import ( CalendarEntityFeature, CalendarEvent, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -17,7 +16,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( - DOMAIN as DOMAIN_RACHIO, KEY_ADDRESS, KEY_DURATION_SECONDS, KEY_ID, @@ -33,18 +31,18 @@ from .const import ( KEY_VALVE_NAME, ) from .coordinator import RachioScheduleUpdateCoordinator -from .device import RachioPerson +from .device import RachioConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RachioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for Rachio smart hose timer calendar.""" - person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] + person = config_entry.runtime_data async_add_entities( RachioCalendarEntity(base_station.schedule_coordinator, base_station) for base_station in person.base_stations diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index ad670fc3608..08a09f309f6 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -25,10 +25,12 @@ KEY_ID = "id" KEY_NAME = "name" KEY_MODEL = "model" KEY_ON = "on" +KEY_ONLINE = "online" KEY_DURATION = "totalDuration" KEY_DURATION_MINUTES = "duration" KEY_RAIN_DELAY = "rainDelayExpirationDate" KEY_RAIN_DELAY_END = "endTime" +KEY_RAIN_SENSOR = "rain_sensor" KEY_RAIN_SENSOR_TRIPPED = "rainSensorTripped" KEY_STATUS = "status" KEY_SUBTYPE = "subType" @@ -57,6 +59,8 @@ KEY_STATE = "state" KEY_CONNECTED = "connected" KEY_CURRENT_STATUS = "lastWateringAction" KEY_DETECT_FLOW = "detectFlow" +KEY_BATTERY = "battery" +KEY_FLOW = "flow" KEY_BATTERY_STATUS = "batteryStatus" KEY_LOW = "LOW" KEY_REPLACE = "REPLACE" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 179e5f5ec0d..a5dd3dba054 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -57,11 +57,13 @@ RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) +type RachioConfigEntry = ConfigEntry[RachioPerson] + class RachioPerson: """Represent a Rachio user.""" - def __init__(self, rachio: Rachio, config_entry: ConfigEntry) -> None: + def __init__(self, rachio: Rachio, config_entry: RachioConfigEntry) -> None: """Create an object from the provided API instance.""" # Use API token to get user ID self.rachio = rachio diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py index 056abe9145b..10657a1f0e9 100644 --- a/homeassistant/components/rachio/entity.py +++ b/homeassistant/components/rachio/entity.py @@ -12,9 +12,14 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DEFAULT_NAME, DOMAIN, + KEY_BATTERY_STATUS, KEY_CONNECTED, + KEY_CURRENT_STATUS, + KEY_FLOW_DETECTED, KEY_ID, + KEY_LOW, KEY_NAME, + KEY_REPLACE, KEY_REPORTED_STATE, KEY_STATE, ) @@ -70,17 +75,29 @@ class RachioHoseTimerEntity(CoordinatorEntity[RachioUpdateCoordinator]): manufacturer=DEFAULT_NAME, configuration_url="https://app.rach.io", ) - self._update_attr() + + @property + def reported_state(self) -> dict[str, Any]: + """Return the reported state.""" + return self.coordinator.data[self.id][KEY_STATE][KEY_REPORTED_STATE] @property def available(self) -> bool: """Return if the entity is available.""" - return ( - super().available - and self.coordinator.data[self.id][KEY_STATE][KEY_REPORTED_STATE][ - KEY_CONNECTED - ] - ) + return super().available and self.reported_state[KEY_CONNECTED] + + @property + def battery(self) -> bool: + """Return the battery status.""" + return self.reported_state[KEY_BATTERY_STATUS] in [KEY_LOW, KEY_REPLACE] + + @property + def no_flow_detected(self) -> bool: + """Return true if valve is on and flow is not detected.""" + if status := self.reported_state.get(KEY_CURRENT_STATUS): + # Since this is a problem indicator we need the opposite of the API state + return not status.get(KEY_FLOW_DETECTED, True) + return False @abstractmethod def _update_attr(self) -> None: diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json index 308403d805d..ea3c8911463 100644 --- a/homeassistant/components/rachio/strings.json +++ b/homeassistant/components/rachio/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Connect to your Rachio device", - "description": "You will need the API Key from https://app.rach.io/. Go to Settings, then select 'GET API KEY'.", + "description": "You will need the API key from https://app.rach.io/. Go to Settings, then select 'GET API KEY'.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } @@ -31,6 +31,9 @@ "binary_sensor": { "rain": { "name": "Rain" + }, + "flow": { + "name": "Flow" } }, "calendar": { @@ -70,7 +73,7 @@ }, "start_watering": { "name": "Start watering", - "description": "Start a single zone, a schedule or any number of smart hose timers.", + "description": "Starts a single zone, a schedule or any number of smart hose timers.", "fields": { "duration": { "name": "Duration", @@ -80,7 +83,7 @@ }, "pause_watering": { "name": "Pause watering", - "description": "Pause any currently running zones or schedules.", + "description": "Pauses any currently running zones or schedules.", "fields": { "devices": { "name": "Devices", @@ -94,7 +97,7 @@ }, "resume_watering": { "name": "Resume watering", - "description": "Resume any paused zone runs or schedules.", + "description": "Resumes any paused zone runs or schedules.", "fields": { "devices": { "name": "[%key:component::rachio::services::pause_watering::fields::devices::name%]", @@ -104,7 +107,7 @@ }, "stop_watering": { "name": "Stop watering", - "description": "Stop any currently running zones or schedules.", + "description": "Stops any currently running zones or schedules.", "fields": { "devices": { "name": "[%key:component::rachio::services::pause_watering::fields::devices::name%]", diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 25cdeac62f7..bfd75ad7e8b 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -9,7 +9,6 @@ from typing import Any import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError @@ -23,7 +22,7 @@ from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_ti from .const import ( CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS, - DOMAIN as DOMAIN_RACHIO, + DOMAIN, KEY_CURRENT_STATUS, KEY_CUSTOM_CROP, KEY_CUSTOM_SHADE, @@ -37,9 +36,7 @@ from .const import ( KEY_ON, KEY_RAIN_DELAY, KEY_RAIN_DELAY_END, - KEY_REPORTED_STATE, KEY_SCHEDULE_ID, - KEY_STATE, KEY_SUBTYPE, KEY_SUMMARY, KEY_TYPE, @@ -59,7 +56,7 @@ from .const import ( SLOPE_SLIGHT, SLOPE_STEEP, ) -from .device import RachioPerson +from .device import RachioConfigEntry from .entity import RachioDevice, RachioHoseTimerEntity from .webhooks import ( SUBTYPE_RAIN_DELAY_OFF, @@ -101,7 +98,7 @@ START_MULTIPLE_ZONES_SCHEMA = vol.Schema( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RachioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio switches.""" @@ -119,7 +116,7 @@ async def async_setup_entry( def start_multiple(service: ServiceCall) -> None: """Service to start multiple zones in sequence.""" zones_list = [] - person = hass.data[DOMAIN_RACHIO][config_entry.entry_id] + person = config_entry.runtime_data entity_id = service.data[ATTR_ENTITY_ID] duration = iter(service.data[ATTR_DURATION]) default_time = service.data[ATTR_DURATION][0] @@ -160,7 +157,7 @@ async def async_setup_entry( return hass.services.async_register( - DOMAIN_RACHIO, + DOMAIN, SERVICE_START_MULTIPLE_ZONES, start_multiple, schema=START_MULTIPLE_ZONES_SCHEMA, @@ -175,9 +172,11 @@ async def async_setup_entry( ) -def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]: +def _create_entities( + hass: HomeAssistant, config_entry: RachioConfigEntry +) -> list[Entity]: entities: list[Entity] = [] - person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] + person = config_entry.runtime_data # Fetch the schedule once at startup # in order to avoid every zone doing it for controller in person.controllers: @@ -548,6 +547,7 @@ class RachioValve(RachioHoseTimerEntity, SwitchEntity): self._person = person self._base = base self._attr_unique_id = f"{self.id}-valve" + self._update_attr() def turn_on(self, **kwargs: Any) -> None: """Turn on this valve.""" @@ -575,7 +575,5 @@ class RachioValve(RachioHoseTimerEntity, SwitchEntity): @callback def _update_attr(self) -> None: """Handle updated coordinator data.""" - data = self.coordinator.data[self.id] - - self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] + self._static_attrs = self.reported_state self._attr_is_on = KEY_CURRENT_STATUS in self._static_attrs diff --git a/homeassistant/components/rachio/webhooks.py b/homeassistant/components/rachio/webhooks.py index 06cd0941dcc..a88df37cb7d 100644 --- a/homeassistant/components/rachio/webhooks.py +++ b/homeassistant/components/rachio/webhooks.py @@ -5,7 +5,6 @@ from __future__ import annotations from aiohttp import web from homeassistant.components import cloud, webhook -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID, URL_API from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -21,7 +20,7 @@ from .const import ( SIGNAL_RACHIO_SCHEDULE_UPDATE, SIGNAL_RACHIO_ZONE_UPDATE, ) -from .device import RachioPerson +from .device import RachioConfigEntry # Device webhook values TYPE_CONTROLLER_STATUS = "DEVICE_STATUS" @@ -83,7 +82,7 @@ SIGNAL_MAP = { @callback -def async_register_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: +def async_register_webhook(hass: HomeAssistant, entry: RachioConfigEntry) -> None: """Register a webhook.""" webhook_id: str = entry.data[CONF_WEBHOOK_ID] @@ -91,7 +90,7 @@ def async_register_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: hass: HomeAssistant, webhook_id: str, request: web.Request ) -> web.Response: """Handle webhook calls from the server.""" - person: RachioPerson = hass.data[DOMAIN][entry.entry_id] + person = entry.runtime_data data = await request.json() try: @@ -114,14 +113,14 @@ def async_register_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: @callback -def async_unregister_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: +def async_unregister_webhook(hass: HomeAssistant, entry: RachioConfigEntry) -> None: """Unregister a webhook.""" webhook_id: str = entry.data[CONF_WEBHOOK_ID] webhook.async_unregister(hass, webhook_id) async def async_get_or_create_registered_webhook_id_and_url( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: RachioConfigEntry ) -> str: """Generate webhook url.""" config = entry.data.copy() diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 58427b0e5ba..6f4cbf4f02c 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -59,7 +59,7 @@ async def async_setup_entry( coordinator, SensorEntityDescription( key="zigbee:Price", - translation_key="meter_price", + translation_key="energy_price", native_unit_of_measurement=f"{coordinator.data['zigbee:PriceCurrency']}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index 7b5054bfb0f..08e237d5af0 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -5,7 +5,7 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "cloud_id": "Cloud ID", - "install_code": "Installation Code" + "install_code": "Installation code" }, "data_description": { "host": "The hostname or IP address of your Rainforest gateway." @@ -24,16 +24,16 @@ "entity": { "sensor": { "power_demand": { - "name": "Meter power demand" + "name": "Power demand" }, "total_energy_delivered": { - "name": "Total meter energy delivered" + "name": "Total energy delivered" }, "total_energy_received": { - "name": "Total meter energy received" + "name": "Total energy received" }, - "meter_price": { - "name": "Meter price" + "energy_price": { + "name": "Energy price" } } } diff --git a/homeassistant/components/rainforest_raven/sensor.py b/homeassistant/components/rainforest_raven/sensor.py index 3d358322b70..658689c7e6c 100644 --- a/homeassistant/components/rainforest_raven/sensor.py +++ b/homeassistant/components/rainforest_raven/sensor.py @@ -101,7 +101,7 @@ async def async_setup_entry( coordinator, RAVEnSensorEntityDescription( message_key="PriceCluster", - translation_key="meter_price", + translation_key="energy_price", key="price", native_unit_of_measurement=f"{meter_data['PriceCluster']['currency'].value}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/rainforest_raven/strings.json b/homeassistant/components/rainforest_raven/strings.json index fb667d64d3f..bc2653aea87 100644 --- a/homeassistant/components/rainforest_raven/strings.json +++ b/homeassistant/components/rainforest_raven/strings.json @@ -12,7 +12,7 @@ "step": { "meters": { "data": { - "mac": "Meter MAC Addresses" + "mac": "Meter MAC addresses" } }, "user": { @@ -24,27 +24,27 @@ }, "entity": { "sensor": { - "meter_price": { - "name": "Meter price", + "energy_price": { + "name": "Energy price", "state_attributes": { "rate_label": { "name": "Rate" }, "tier": { "name": "Tier" } } }, "power_demand": { - "name": "Meter power demand" + "name": "Power demand" }, "signal_strength": { - "name": "Meter signal strength", + "name": "Signal strength", "state_attributes": { "channel": { "name": "Channel" } } }, "total_energy_delivered": { - "name": "Total meter energy delivered" + "name": "Total energy delivered" }, "total_energy_received": { - "name": "Total meter energy received" + "name": "Total energy received" } } } diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index e5c5543e39f..d57f2dc8eec 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -84,8 +84,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%]", @@ -96,6 +98,7 @@ "distance": "[%key:component::sensor::entity_component::distance::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", "frequency": "[%key:component::sensor::entity_component::frequency::name%]", "gas": "[%key:component::sensor::entity_component::gas::name%]", @@ -117,6 +120,7 @@ "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]", "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", @@ -132,6 +136,7 @@ "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", "water": "[%key:component::sensor::entity_component::water::name%]", "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" } } diff --git a/homeassistant/components/raspyrfm/switch.py b/homeassistant/components/raspyrfm/switch.py index b9506c3688c..a609ddb27d3 100644 --- a/homeassistant/components/raspyrfm/switch.py +++ b/homeassistant/components/raspyrfm/switch.py @@ -118,7 +118,7 @@ class RaspyRFMSwitch(SwitchEntity): return self._name @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return True when the current state cannot be queried.""" return True diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 7cb71e70f65..c0bffbe9615 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -170,12 +170,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: exclude_event_types=exclude_event_types, ) get_instance.cache_clear() + entity_registry.async_setup(hass) instance.async_initialize() instance.async_register() instance.start() async_register_services(hass, instance) websocket_api.async_setup(hass) - entity_registry.async_setup(hass) await _async_setup_integration_platform(hass, instance) diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index 1373f466bc2..cf3addd4f20 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -175,7 +175,7 @@ def _validate_db_schema_precision( # Mark the session as read_only to ensure that the test data is not committed # to the database and we always rollback when the scope is exited with session_scope(session=instance.get_session(), read_only=True) as session: - db_object = table_object(**{column: PRECISE_NUMBER for column in columns}) + db_object = table_object(**dict.fromkeys(columns, PRECISE_NUMBER)) table = table_object.__tablename__ try: session.add(db_object) @@ -184,7 +184,7 @@ def _validate_db_schema_precision( _check_columns( schema_errors=schema_errors, stored={column: getattr(db_object, column) for column in columns}, - expected={column: PRECISE_NUMBER for column in columns}, + expected=dict.fromkeys(columns, PRECISE_NUMBER), columns=columns, table_name=table, supports="double precision", diff --git a/homeassistant/components/recorder/backup.py b/homeassistant/components/recorder/backup.py index d47cbe92bd4..eeebe328007 100644 --- a/homeassistant/components/recorder/backup.py +++ b/homeassistant/components/recorder/backup.py @@ -2,7 +2,7 @@ from logging import getLogger -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError from .util import async_migration_in_progress, get_instance @@ -14,6 +14,8 @@ async def async_pre_backup(hass: HomeAssistant) -> None: """Perform operations before a backup starts.""" _LOGGER.info("Backup start notification, locking database for writes") instance = get_instance(hass) + if hass.state is not CoreState.running: + raise HomeAssistantError("Home Assistant is not running") if async_migration_in_progress(hass): raise HomeAssistantError("Database migration in progress") await instance.lock_database() diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index b7ee984558c..4797eecda0f 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -30,6 +30,12 @@ CONF_DB_INTEGRITY_CHECK = "db_integrity_check" MAX_QUEUE_BACKLOG_MIN_VALUE = 65000 MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG = 256 * 1024**2 +# As soon as we have more than 999 ids, split the query as the +# MySQL optimizer handles it poorly and will no longer +# do an index only scan with a group-by +# https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 +MAX_IDS_FOR_INDEXED_GROUP_BY = 999 + # The maximum number of rows (events) we purge in one delete statement DEFAULT_MAX_BIND_VARS = 4000 @@ -48,6 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36 EVENT_TYPE_IDS_SCHEMA_VERSION = 37 STATES_META_SCHEMA_VERSION = 38 LAST_REPORTED_SCHEMA_VERSION = 43 +CIRCULAR_MEAN_SCHEMA_VERSION = 49 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43 diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index eaf72b74cdc..34fa6a62d44 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -79,7 +79,13 @@ from .db_schema import ( StatisticsShortTerm, ) from .executor import DBInterruptibleThreadPoolExecutor -from .models import DatabaseEngine, StatisticData, StatisticMetaData, UnsupportedDialect +from .models import ( + DatabaseEngine, + StatisticData, + StatisticMeanType, + StatisticMetaData, + UnsupportedDialect, +) from .pool import POOL_SIZE, MutexPool, RecorderPool from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager @@ -123,8 +129,6 @@ from .util import ( _LOGGER = logging.getLogger(__name__) -DEFAULT_URL = "sqlite:///{hass_config_path}" - # Controls how often we clean up # States and Events objects EXPIRE_AFTER_COMMITS = 120 @@ -613,6 +617,17 @@ class Recorder(threading.Thread): table: type[Statistics | StatisticsShortTerm], ) -> None: """Schedule import of statistics.""" + if "mean_type" not in metadata: + # Backwards compatibility for old metadata format + # Can be removed after 2026.4 + metadata["mean_type"] = ( # type: ignore[unreachable] + StatisticMeanType.ARITHMETIC + if metadata.get("has_mean") + else StatisticMeanType.NONE + ) + # Remove deprecated has_mean as it's not needed anymore in core + metadata.pop("has_mean", None) + self.queue_task(ImportStatisticsTask(metadata, stats, table)) @callback @@ -1292,11 +1307,17 @@ class Recorder(threading.Thread): async def async_block_till_done(self) -> None: """Async version of block_till_done.""" + if future := self.async_get_commit_future(): + await future + + @callback + def async_get_commit_future(self) -> asyncio.Future[None] | None: + """Return a future that will wait for the next commit or None if nothing pending.""" if self._queue.empty() and not self._event_session_has_pending_writes: - return - event = asyncio.Event() - self.queue_task(SynchronizeTask(event)) - await event.wait() + return None + future: asyncio.Future[None] = self.hass.loop.create_future() + self.queue_task(SynchronizeTask(future)) + return future def block_till_done(self) -> None: """Block till all events processed. diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index d1a2405406e..6566cadf64c 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -58,6 +58,7 @@ from .const import ALL_DOMAIN_EXCLUDE_ATTRS, SupportedDialect from .models import ( StatisticData, StatisticDataTimestamp, + StatisticMeanType, StatisticMetaData, bytes_to_ulid_or_none, bytes_to_uuid_hex_or_none, @@ -77,7 +78,7 @@ class LegacyBase(DeclarativeBase): """Base class for tables, used for schema migration.""" -SCHEMA_VERSION = 48 +SCHEMA_VERSION = 50 _LOGGER = logging.getLogger(__name__) @@ -203,11 +204,11 @@ UINT_32_TYPE = BigInteger().with_variant( "mariadb", ) JSON_VARIANT_CAST = Text().with_variant( - postgresql.JSON(none_as_null=True), # type: ignore[no-untyped-call] + postgresql.JSON(none_as_null=True), "postgresql", ) JSONB_VARIANT_CAST = Text().with_variant( - postgresql.JSONB(none_as_null=True), # type: ignore[no-untyped-call] + postgresql.JSONB(none_as_null=True), "postgresql", ) DATETIME_TYPE = ( @@ -719,6 +720,7 @@ class StatisticsBase: start: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) start_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True) mean: Mapped[float | None] = mapped_column(DOUBLE_TYPE) + mean_weight: Mapped[float | None] = mapped_column(DOUBLE_TYPE) min: Mapped[float | None] = mapped_column(DOUBLE_TYPE) max: Mapped[float | None] = mapped_column(DOUBLE_TYPE) last_reset: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) @@ -740,6 +742,7 @@ class StatisticsBase: start=None, start_ts=stats["start"].timestamp(), mean=stats.get("mean"), + mean_weight=stats.get("mean_weight"), min=stats.get("min"), max=stats.get("max"), last_reset=None, @@ -763,6 +766,7 @@ class StatisticsBase: start=None, start_ts=stats["start_ts"], mean=stats.get("mean"), + mean_weight=stats.get("mean_weight"), min=stats.get("min"), max=stats.get("max"), last_reset=None, @@ -848,6 +852,9 @@ class _StatisticsMeta: has_mean: Mapped[bool | None] = mapped_column(Boolean) has_sum: Mapped[bool | None] = mapped_column(Boolean) name: Mapped[str | None] = mapped_column(String(255)) + mean_type: Mapped[StatisticMeanType] = mapped_column( + SmallInteger, nullable=False, default=StatisticMeanType.NONE.value + ) # See StatisticMeanType @staticmethod def from_meta(meta: StatisticMetaData) -> StatisticsMeta: diff --git a/homeassistant/components/recorder/entity_registry.py b/homeassistant/components/recorder/entity_registry.py index 07f8f2f88de..30a3a1b8239 100644 --- a/homeassistant/components/recorder/entity_registry.py +++ b/homeassistant/components/recorder/entity_registry.py @@ -4,8 +4,9 @@ import logging from typing import TYPE_CHECKING from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.start import async_at_start +from homeassistant.helpers.event import async_has_entity_registry_updated_listeners from .core import Recorder from .util import filter_unique_constraint_integrity_error, get_instance, session_scope @@ -40,16 +41,17 @@ def async_setup(hass: HomeAssistant) -> None: """Handle entity_id changed filter.""" return event_data["action"] == "update" and "old_entity_id" in event_data - @callback - def _setup_entity_registry_event_handler(hass: HomeAssistant) -> None: - """Subscribe to event registry events.""" - hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, - _async_entity_id_changed, - event_filter=entity_registry_changed_filter, + if async_has_entity_registry_updated_listeners(hass): + raise HomeAssistantError( + "The recorder entity registry listener must be installed" + " before async_track_entity_registry_updated_event is called" ) - async_at_start(hass, _setup_entity_registry_event_handler) + hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + _async_entity_id_changed, + event_filter=entity_registry_changed_filter, + ) def update_states_metadata( diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 8958913bce6..566e30713f0 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -6,11 +6,12 @@ from collections.abc import Callable, Iterable, Iterator from datetime import datetime from itertools import groupby from operator import itemgetter -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from sqlalchemy import ( CompoundSelect, Select, + StatementLambdaElement, Subquery, and_, func, @@ -26,8 +27,9 @@ from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_ from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all -from ..const import LAST_REPORTED_SCHEMA_VERSION +from ..const import LAST_REPORTED_SCHEMA_VERSION, MAX_IDS_FOR_INDEXED_GROUP_BY from ..db_schema import ( SHARED_ATTR_OR_LEGACY_ATTRIBUTES, StateAttributes, @@ -149,6 +151,7 @@ def _significant_states_stmt( no_attributes: bool, include_start_time_state: bool, run_start_ts: float | None, + slow_dependent_subquery: bool, ) -> Select | CompoundSelect: """Query the database for significant state changes.""" include_last_changed = not significant_changes_only @@ -187,6 +190,7 @@ def _significant_states_stmt( metadata_ids, no_attributes, include_last_changed, + slow_dependent_subquery, ).subquery(), no_attributes, include_last_changed, @@ -257,7 +261,68 @@ def get_significant_states_with_session( 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( + rows: list[Row] = [] + if TYPE_CHECKING: + assert instance.database_engine is not None + slow_dependent_subquery = instance.database_engine.optimizer.slow_dependent_subquery + if include_start_time_state and slow_dependent_subquery: + # https://github.com/home-assistant/core/issues/137178 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + iter_metadata_ids = chunked_or_all(metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY) + else: + iter_metadata_ids = (metadata_ids,) + for metadata_ids_chunk in iter_metadata_ids: + stmt = _generate_significant_states_with_session_stmt( + start_time_ts, + end_time_ts, + single_metadata_id, + metadata_ids_chunk, + metadata_ids_in_significant_domains, + significant_changes_only, + no_attributes, + include_start_time_state, + oldest_ts, + slow_dependent_subquery, + ) + row_chunk = cast( + list[Row], + execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), + ) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return _sorted_states_to_dict( + rows, + start_time_ts if include_start_time_state else None, + entity_ids, + entity_id_to_metadata_id, + minimal_response, + compressed_state_format, + no_attributes=no_attributes, + ) + + +def _generate_significant_states_with_session_stmt( + start_time_ts: float, + end_time_ts: float | None, + single_metadata_id: int | None, + metadata_ids: list[int], + metadata_ids_in_significant_domains: list[int], + significant_changes_only: bool, + no_attributes: bool, + include_start_time_state: bool, + oldest_ts: float | None, + slow_dependent_subquery: bool, +) -> StatementLambdaElement: + return lambda_stmt( lambda: _significant_states_stmt( start_time_ts, end_time_ts, @@ -268,6 +333,7 @@ def get_significant_states_with_session( no_attributes, include_start_time_state, oldest_ts, + slow_dependent_subquery, ), track_on=[ bool(single_metadata_id), @@ -276,17 +342,9 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, include_start_time_state, + slow_dependent_subquery, ], ) - return _sorted_states_to_dict( - execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), - start_time_ts if include_start_time_state else None, - entity_ids, - entity_id_to_metadata_id, - minimal_response, - compressed_state_format, - no_attributes=no_attributes, - ) def get_full_significant_states_with_session( @@ -554,13 +612,14 @@ def get_last_state_changes( ) -def _get_start_time_state_for_entities_stmt( +def _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time: float, metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, ) -> Select: """Baked query to get states for specific entities.""" + # Engine has a fast dependent subquery optimizer # 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 @@ -570,7 +629,9 @@ def _get_start_time_state_for_entities_stmt( # before a specific point in time for all entities. stmt = ( _stmt_and_join_attributes_for_start_state( - no_attributes, include_last_changed, False + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, ) .select_from(StatesMeta) .join( @@ -600,6 +661,55 @@ def _get_start_time_state_for_entities_stmt( ) +def _get_start_time_state_for_entities_stmt_group_by( + epoch_time: float, + metadata_ids: list[int], + no_attributes: bool, + include_last_changed: bool, +) -> Select: + """Baked query to get states for specific entities.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + 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 < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + .group_by(States.metadata_id) + .subquery() + ) + stmt = ( + _stmt_and_join_attributes_for_start_state( + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, + ) + .join( + most_recent_states_for_entities_by_date, + 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, + ), + ) + .filter( + (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + ) + if no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + + def _get_oldest_possible_ts( hass: HomeAssistant, utc_point_in_time: datetime ) -> float | None: @@ -620,6 +730,7 @@ def _get_start_time_state_stmt( metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, + slow_dependent_subquery: bool, ) -> Select: """Return the states at a specific point in time.""" if single_metadata_id: @@ -634,7 +745,15 @@ 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( + if slow_dependent_subquery: + return _get_start_time_state_for_entities_stmt_group_by( + epoch_time, + metadata_ids, + no_attributes, + include_last_changed, + ) + + return _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time, metadata_ids, no_attributes, diff --git a/homeassistant/components/recorder/icons.json b/homeassistant/components/recorder/icons.json index 9e41637184a..87634bedcc8 100644 --- a/homeassistant/components/recorder/icons.json +++ b/homeassistant/components/recorder/icons.json @@ -11,6 +11,9 @@ }, "enable": { "service": "mdi:database" + }, + "get_statistics": { + "service": "mdi:chart-bar" } } } diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 40513c8ea24..01b5d089bf3 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.38", - "fnv-hash-fast==1.2.6", + "SQLAlchemy==2.0.40", + "fnv-hash-fast==1.5.0", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 3aa12f2b1f9..58af15c2aa7 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, replace as dataclass_replace from datetime import timedelta import logging from time import time -from typing import TYPE_CHECKING, Any, cast, final +from typing import TYPE_CHECKING, Any, TypedDict, cast, final from uuid import UUID import sqlalchemy @@ -81,7 +81,7 @@ from .db_schema import ( StatisticsRuns, StatisticsShortTerm, ) -from .models import process_timestamp +from .models import StatisticMeanType, process_timestamp from .models.time import datetime_to_timestamp_or_none from .queries import ( batch_cleanup_entity_ids, @@ -144,24 +144,32 @@ class _ColumnTypesForDialect: big_int_type: str timestamp_type: str context_bin_type: str + small_int_type: str + double_type: str _MYSQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER(20)", timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type=f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})", + small_int_type="SMALLINT", + double_type=DOUBLE_PRECISION_TYPE_SQL, ) _POSTGRESQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER", timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type="BYTEA", + small_int_type="SMALLINT", + double_type=DOUBLE_PRECISION_TYPE_SQL, ) _SQLITE_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER", timestamp_type="FLOAT", context_bin_type="BLOB", + small_int_type="INTEGER", + double_type="FLOAT", ) _COLUMN_TYPES_FOR_DIALECT: dict[SupportedDialect | None, _ColumnTypesForDialect] = { @@ -712,6 +720,11 @@ def _modify_columns( raise +class _FKAlterDict(TypedDict): + old_fk: ForeignKeyConstraint + columns: list[str] + + def _update_states_table_with_foreign_key_options( session_maker: Callable[[], Session], engine: Engine ) -> None: @@ -729,7 +742,7 @@ def _update_states_table_with_foreign_key_options( inspector = sqlalchemy.inspect(engine) tmp_states_table = Table(TABLE_STATES, MetaData()) - alters = [ + alters: list[_FKAlterDict] = [ { "old_fk": ForeignKeyConstraint( (), (), name=foreign_key["name"], table=tmp_states_table @@ -755,14 +768,14 @@ def _update_states_table_with_foreign_key_options( with session_scope(session=session_maker()) as session: try: connection = session.connection() - connection.execute(DropConstraint(alter["old_fk"])) # type: ignore[no-untyped-call] + connection.execute(DropConstraint(alter["old_fk"])) for fkc in states_key_constraints: if fkc.column_keys == alter["columns"]: # AddConstraint mutates the constraint passed to it, we need to # undo that to avoid changing the behavior of the table schema. # https://github.com/sqlalchemy/sqlalchemy/blob/96f1172812f858fead45cdc7874abac76f45b339/lib/sqlalchemy/sql/ddl.py#L746-L748 create_rule = fkc._create_rule # noqa: SLF001 - add_constraint = AddConstraint(fkc) # type: ignore[no-untyped-call] + add_constraint = AddConstraint(fkc) fkc._create_rule = create_rule # noqa: SLF001 connection.execute(add_constraint) except (InternalError, OperationalError): @@ -800,7 +813,7 @@ def _drop_foreign_key_constraints( with session_scope(session=session_maker()) as session: try: connection = session.connection() - connection.execute(DropConstraint(drop)) # type: ignore[no-untyped-call] + connection.execute(DropConstraint(drop)) except (InternalError, OperationalError): _LOGGER.exception( "Could not drop foreign constraints in %s table on %s", @@ -845,7 +858,7 @@ def _restore_foreign_key_constraints( # undo that to avoid changing the behavior of the table schema. # https://github.com/sqlalchemy/sqlalchemy/blob/96f1172812f858fead45cdc7874abac76f45b339/lib/sqlalchemy/sql/ddl.py#L746-L748 create_rule = constraint._create_rule # noqa: SLF001 - add_constraint = AddConstraint(constraint) # type: ignore[no-untyped-call] + add_constraint = AddConstraint(constraint) constraint._create_rule = create_rule # noqa: SLF001 try: _add_constraint(session_maker, add_constraint, table, column) @@ -1988,6 +2001,42 @@ class _SchemaVersion48Migrator(_SchemaVersionMigrator, target_version=48): _migrate_columns_to_timestamp(self.instance, self.session_maker, self.engine) +class _SchemaVersion49Migrator(_SchemaVersionMigrator, target_version=49): + def _apply_update(self) -> None: + """Version specific update method.""" + _add_columns( + self.session_maker, + "statistics_meta", + [ + f"mean_type {self.column_types.small_int_type} NOT NULL DEFAULT {StatisticMeanType.NONE.value}" + ], + ) + + for table in ("statistics", "statistics_short_term"): + _add_columns( + self.session_maker, + table, + [f"mean_weight {self.column_types.double_type}"], + ) + + with session_scope(session=self.session_maker()) as session: + connection = session.connection() + connection.execute( + text( + "UPDATE statistics_meta SET mean_type=:mean_type WHERE has_mean=true" + ), + {"mean_type": StatisticMeanType.ARITHMETIC.value}, + ) + + +class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50): + def _apply_update(self) -> None: + """Version specific update method.""" + with session_scope(session=self.session_maker()) as session: + connection = session.connection() + connection.execute(text("UPDATE statistics_meta SET has_mean=NULL")) + + def _migrate_statistics_columns_to_timestamp_removing_duplicates( hass: HomeAssistant, instance: Recorder, diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py index ea7a6c86854..8f76982a900 100644 --- a/homeassistant/components/recorder/models/__init__.py +++ b/homeassistant/components/recorder/models/__init__.py @@ -17,6 +17,7 @@ from .statistics import ( RollingWindowStatisticPeriod, StatisticData, StatisticDataTimestamp, + StatisticMeanType, StatisticMetaData, StatisticPeriod, StatisticResult, @@ -37,6 +38,7 @@ __all__ = [ "RollingWindowStatisticPeriod", "StatisticData", "StatisticDataTimestamp", + "StatisticMeanType", "StatisticMetaData", "StatisticPeriod", "StatisticResult", diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py index b86fd299793..2a4924edab3 100644 --- a/homeassistant/components/recorder/models/database.py +++ b/homeassistant/components/recorder/models/database.py @@ -37,3 +37,13 @@ class DatabaseOptimizer: # https://wiki.postgresql.org/wiki/Loose_indexscan # https://github.com/home-assistant/core/issues/126084 slow_range_in_select: bool + + # MySQL 8.x+ can end up with a file-sort on a dependent subquery + # which makes the query painfully slow. + # https://github.com/home-assistant/core/issues/137178 + # The solution is to use multiple indexed group-by queries instead + # of the subquery as long as the group by does not exceed + # 999 elements since as soon as we hit 1000 elements MySQL + # will no longer use the group_index_range optimization. + # https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 + slow_dependent_subquery: bool diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 919ee078a99..28459cfef07 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -104,7 +104,7 @@ class LazyState(State): return self._last_updated_ts @cached_property - def last_changed_timestamp(self) -> float: # type: ignore[override] + def last_changed_timestamp(self) -> float: """Last changed timestamp.""" ts = self._last_changed_ts or self._last_updated_ts if TYPE_CHECKING: @@ -112,7 +112,7 @@ class LazyState(State): return ts @cached_property - def last_reported_timestamp(self) -> float: # type: ignore[override] + def last_reported_timestamp(self) -> float: """Last reported timestamp.""" ts = self._last_reported_ts or self._last_updated_ts if TYPE_CHECKING: diff --git a/homeassistant/components/recorder/models/statistics.py b/homeassistant/components/recorder/models/statistics.py index ad4d82067c4..08da12d6b17 100644 --- a/homeassistant/components/recorder/models/statistics.py +++ b/homeassistant/components/recorder/models/statistics.py @@ -3,7 +3,8 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import Literal, TypedDict +from enum import IntEnum +from typing import Literal, NotRequired, TypedDict class StatisticResult(TypedDict): @@ -36,6 +37,7 @@ class StatisticMixIn(TypedDict, total=False): min: float max: float mean: float + mean_weight: float class StatisticData(StatisticDataBase, StatisticMixIn, total=False): @@ -50,10 +52,20 @@ class StatisticDataTimestamp(StatisticDataTimestampBase, StatisticMixIn, total=F last_reset_ts: float | None +class StatisticMeanType(IntEnum): + """Statistic mean type.""" + + NONE = 0 + ARITHMETIC = 1 + CIRCULAR = 2 + + class StatisticMetaData(TypedDict): """Statistic meta data class.""" - has_mean: bool + # has_mean is deprecated, use mean_type instead. has_mean will be removed in 2026.4 + has_mean: NotRequired[bool] + mean_type: StatisticMeanType has_sum: bool name: str | None source: str diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index cc74d7a2376..ba454c59bf3 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -8,7 +8,13 @@ from typing import cast import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.service import ( @@ -16,15 +22,18 @@ from homeassistant.helpers.service import ( async_register_admin_service, ) from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonArrayType, JsonObjectType from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN from .core import Recorder +from .statistics import statistics_during_period from .tasks import PurgeEntitiesTask, PurgeTask SERVICE_PURGE = "purge" SERVICE_PURGE_ENTITIES = "purge_entities" SERVICE_ENABLE = "enable" SERVICE_DISABLE = "disable" +SERVICE_GET_STATISTICS = "get_statistics" SERVICE_PURGE_SCHEMA = vol.Schema( { @@ -63,6 +72,20 @@ SERVICE_PURGE_ENTITIES_SCHEMA = vol.All( SERVICE_ENABLE_SCHEMA = vol.Schema({}) SERVICE_DISABLE_SCHEMA = vol.Schema({}) +SERVICE_GET_STATISTICS_SCHEMA = vol.Schema( + { + vol.Required("start_time"): cv.datetime, + vol.Optional("end_time"): cv.datetime, + vol.Required("statistic_ids"): vol.All(cv.ensure_list, [cv.string]), + vol.Required("period"): vol.In(["5minute", "hour", "day", "week", "month"]), + vol.Required("types"): vol.All( + cv.ensure_list, + [vol.In(["change", "last_reset", "max", "mean", "min", "state", "sum"])], + ), + vol.Optional("units"): vol.Schema({cv.string: cv.string}), + } +) + @callback def _async_register_purge_service(hass: HomeAssistant, instance: Recorder) -> None: @@ -135,6 +158,79 @@ def _async_register_disable_service(hass: HomeAssistant, instance: Recorder) -> ) +@callback +def _async_register_get_statistics_service( + hass: HomeAssistant, instance: Recorder +) -> None: + async def async_handle_get_statistics_service( + service: ServiceCall, + ) -> ServiceResponse: + """Handle calls to the get_statistics service.""" + start_time = dt_util.as_utc(service.data["start_time"]) + end_time = ( + dt_util.as_utc(service.data["end_time"]) + if "end_time" in service.data + else None + ) + + statistic_ids = service.data["statistic_ids"] + types = service.data["types"] + period = service.data["period"] + units = service.data.get("units") + + result = await instance.async_add_executor_job( + statistics_during_period, + hass, + start_time, + end_time, + statistic_ids, + period, + units, + types, + ) + + formatted_result: JsonObjectType = {} + for statistic_id, statistic_rows in result.items(): + formatted_statistic_rows: JsonArrayType = [] + + for row in statistic_rows: + formatted_row: JsonObjectType = { + "start": dt_util.utc_from_timestamp(row["start"]).isoformat(), + "end": dt_util.utc_from_timestamp(row["end"]).isoformat(), + } + if (last_reset := row.get("last_reset")) is not None: + formatted_row["last_reset"] = dt_util.utc_from_timestamp( + last_reset + ).isoformat() + if (state := row.get("state")) is not None: + formatted_row["state"] = state + if (sum_value := row.get("sum")) is not None: + formatted_row["sum"] = sum_value + if (min_value := row.get("min")) is not None: + formatted_row["min"] = min_value + if (max_value := row.get("max")) is not None: + formatted_row["max"] = max_value + if (mean := row.get("mean")) is not None: + formatted_row["mean"] = mean + if (change := row.get("change")) is not None: + formatted_row["change"] = change + + formatted_statistic_rows.append(formatted_row) + + formatted_result[statistic_id] = formatted_statistic_rows + + return {"statistics": formatted_result} + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_GET_STATISTICS, + async_handle_get_statistics_service, + schema=SERVICE_GET_STATISTICS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + @callback def async_register_services(hass: HomeAssistant, instance: Recorder) -> None: """Register recorder services.""" @@ -142,3 +238,4 @@ def async_register_services(hass: HomeAssistant, instance: Recorder) -> None: _async_register_purge_entities_service(hass, instance) _async_register_enable_service(hass, instance) _async_register_disable_service(hass, instance) + _async_register_get_statistics_service(hass, instance) diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index 7d7b926548c..65aa797d91b 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -48,3 +48,63 @@ purge_entities: disable: enable: + +get_statistics: + fields: + start_time: + required: true + example: "2025-01-01 00:00:00" + selector: + datetime: + + end_time: + required: false + example: "2025-01-02 00:00:00" + selector: + datetime: + + statistic_ids: + required: true + example: + - sensor.energy_consumption + - sensor.temperature + selector: + entity: + multiple: true + + period: + required: true + example: "hour" + selector: + select: + options: + - "5minute" + - "hour" + - "day" + - "week" + - "month" + + types: + required: true + example: + - "mean" + - "sum" + selector: + select: + options: + - "change" + - "last_reset" + - "max" + - "mean" + - "min" + - "state" + - "sum" + multiple: true + + units: + required: false + example: + energy: "kWh" + temperature: "°C" + selector: + object: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c42a0f77c39..bdb5062e88e 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -9,12 +9,23 @@ from datetime import datetime, timedelta from functools import lru_cache, partial from itertools import chain, groupby import logging +import math from operator import itemgetter import re from time import time as time_time -from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, Required, TypedDict, cast -from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text +from sqlalchemy import ( + Label, + Select, + and_, + bindparam, + case, + func, + lambda_stmt, + select, + text, +) from sqlalchemy.engine.row import Row from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm.session import Session @@ -28,6 +39,8 @@ from homeassistant.helpers.recorder import DATA_RECORDER from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all +from homeassistant.util.enum import try_parse_enum from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, @@ -44,6 +57,7 @@ from homeassistant.util.unit_conversion import ( MassConverter, PowerConverter, PressureConverter, + ReactiveEnergyConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -59,6 +73,7 @@ from .const import ( INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, + MAX_IDS_FOR_INDEXED_GROUP_BY, SupportedDialect, ) from .db_schema import ( @@ -72,6 +87,7 @@ from .db_schema import ( from .models import ( StatisticData, StatisticDataTimestamp, + StatisticMeanType, StatisticMetaData, StatisticResult, datetime_to_timestamp_or_none, @@ -111,11 +127,53 @@ QUERY_STATISTICS_SHORT_TERM = ( StatisticsShortTerm.sum, ) + +def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]: + """Return the sqlalchemy function for circular mean and the mean_weight. + + The result must be modulo 360 to normalize the result [0, 360]. + """ + # Postgres doesn't support modulo for double precision and + # the other dbs return the remainder instead of the modulo + # meaning negative values are possible. For these reason + # we need to normalize the result to be in the range [0, 360) + # in Python. + # https://en.wikipedia.org/wiki/Circular_mean + radians = func.radians(table.mean) + weighted_sum_sin = func.sum(func.sin(radians) * table.mean_weight) + weighted_sum_cos = func.sum(func.cos(radians) * table.mean_weight) + weight = func.sqrt( + func.power(weighted_sum_sin, 2) + func.power(weighted_sum_cos, 2) + ) + return ( + func.degrees(func.atan2(weighted_sum_sin, weighted_sum_cos)).label("mean"), + weight.label("mean_weight"), + ) + + QUERY_STATISTICS_SUMMARY_MEAN = ( StatisticsShortTerm.metadata_id, - func.avg(StatisticsShortTerm.mean), func.min(StatisticsShortTerm.min), func.max(StatisticsShortTerm.max), + case( + ( + StatisticsMeta.mean_type == StatisticMeanType.ARITHMETIC, + func.avg(StatisticsShortTerm.mean), + ), + ( + StatisticsMeta.mean_type == StatisticMeanType.CIRCULAR, + query_circular_mean(StatisticsShortTerm)[0], + ), + else_=None, + ), + case( + ( + StatisticsMeta.mean_type == StatisticMeanType.CIRCULAR, + query_circular_mean(StatisticsShortTerm)[1], + ), + else_=None, + ), + StatisticsMeta.mean_type, ) QUERY_STATISTICS_SUMMARY_SUM = ( @@ -134,31 +192,29 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { - **{unit: AreaConverter for unit in AreaConverter.VALID_UNITS}, - **{ - unit: BloodGlucoseConcentrationConverter - for unit in BloodGlucoseConcentrationConverter.VALID_UNITS - }, - **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, - **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, - **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, - **{unit: DurationConverter for unit in DurationConverter.VALID_UNITS}, - **{unit: ElectricCurrentConverter for unit in ElectricCurrentConverter.VALID_UNITS}, - **{ - unit: ElectricPotentialConverter - for unit in ElectricPotentialConverter.VALID_UNITS - }, - **{unit: EnergyConverter for unit in EnergyConverter.VALID_UNITS}, - **{unit: EnergyDistanceConverter for unit in EnergyDistanceConverter.VALID_UNITS}, - **{unit: InformationConverter for unit in InformationConverter.VALID_UNITS}, - **{unit: MassConverter for unit in MassConverter.VALID_UNITS}, - **{unit: PowerConverter for unit in PowerConverter.VALID_UNITS}, - **{unit: PressureConverter for unit in PressureConverter.VALID_UNITS}, - **{unit: SpeedConverter for unit in SpeedConverter.VALID_UNITS}, - **{unit: TemperatureConverter for unit in TemperatureConverter.VALID_UNITS}, - **{unit: UnitlessRatioConverter for unit in UnitlessRatioConverter.VALID_UNITS}, - **{unit: VolumeConverter for unit in VolumeConverter.VALID_UNITS}, - **{unit: VolumeFlowRateConverter for unit in VolumeFlowRateConverter.VALID_UNITS}, + **dict.fromkeys(AreaConverter.VALID_UNITS, AreaConverter), + **dict.fromkeys( + BloodGlucoseConcentrationConverter.VALID_UNITS, + BloodGlucoseConcentrationConverter, + ), + **dict.fromkeys(ConductivityConverter.VALID_UNITS, ConductivityConverter), + **dict.fromkeys(DataRateConverter.VALID_UNITS, DataRateConverter), + **dict.fromkeys(DistanceConverter.VALID_UNITS, DistanceConverter), + **dict.fromkeys(DurationConverter.VALID_UNITS, DurationConverter), + **dict.fromkeys(ElectricCurrentConverter.VALID_UNITS, ElectricCurrentConverter), + **dict.fromkeys(ElectricPotentialConverter.VALID_UNITS, ElectricPotentialConverter), + **dict.fromkeys(EnergyConverter.VALID_UNITS, EnergyConverter), + **dict.fromkeys(EnergyDistanceConverter.VALID_UNITS, EnergyDistanceConverter), + **dict.fromkeys(InformationConverter.VALID_UNITS, InformationConverter), + **dict.fromkeys(MassConverter.VALID_UNITS, MassConverter), + **dict.fromkeys(PowerConverter.VALID_UNITS, PowerConverter), + **dict.fromkeys(PressureConverter.VALID_UNITS, PressureConverter), + **dict.fromkeys(ReactiveEnergyConverter.VALID_UNITS, ReactiveEnergyConverter), + **dict.fromkeys(SpeedConverter.VALID_UNITS, SpeedConverter), + **dict.fromkeys(TemperatureConverter.VALID_UNITS, TemperatureConverter), + **dict.fromkeys(UnitlessRatioConverter.VALID_UNITS, UnitlessRatioConverter), + **dict.fromkeys(VolumeConverter.VALID_UNITS, VolumeConverter), + **dict.fromkeys(VolumeFlowRateConverter.VALID_UNITS, VolumeFlowRateConverter), } @@ -181,6 +237,26 @@ def mean(values: list[float]) -> float | None: return sum(values) / len(values) +DEG_TO_RAD = math.pi / 180 +RAD_TO_DEG = 180 / math.pi + + +def weighted_circular_mean( + values: Iterable[tuple[float, float]], +) -> tuple[float, float]: + """Return the weighted circular mean and the weight of the values.""" + weighted_sin_sum, weighted_cos_sum = 0.0, 0.0 + for x, weight in values: + rad_x = x * DEG_TO_RAD + weighted_sin_sum += math.sin(rad_x) * weight + weighted_cos_sum += math.cos(rad_x) * weight + + return ( + (RAD_TO_DEG * math.atan2(weighted_sin_sum, weighted_cos_sum)) % 360, + math.sqrt(weighted_sin_sum**2 + weighted_cos_sum**2), + ) + + _LOGGER = logging.getLogger(__name__) @@ -227,6 +303,7 @@ class StatisticsRow(BaseStatisticsRow, total=False): min: float | None max: float | None mean: float | None + mean_weight: float | None change: float | None @@ -373,11 +450,19 @@ def _compile_hourly_statistics_summary_mean_stmt( start_time_ts: float, end_time_ts: float ) -> StatementLambdaElement: """Generate the summary mean statement for hourly statistics.""" + # Due the fact that we support different mean type (See StatisticMeanType) + # we need to join here with the StatisticsMeta table to get the mean type + # and then use a case statement to compute the mean based on the mean type. + # As we use the StatisticsMeta.mean_type in the select case statement we need + # to group by it as well. return lambda_stmt( lambda: select(*QUERY_STATISTICS_SUMMARY_MEAN) .filter(StatisticsShortTerm.start_ts >= start_time_ts) .filter(StatisticsShortTerm.start_ts < end_time_ts) - .group_by(StatisticsShortTerm.metadata_id) + .join( + StatisticsMeta, and_(StatisticsShortTerm.metadata_id == StatisticsMeta.id) + ) + .group_by(StatisticsShortTerm.metadata_id, StatisticsMeta.mean_type) .order_by(StatisticsShortTerm.metadata_id) ) @@ -419,10 +504,17 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None: if stats: for stat in stats: - metadata_id, _mean, _min, _max = stat + metadata_id, _min, _max, _mean, _mean_weight, _mean_type = stat + if ( + try_parse_enum(StatisticMeanType, _mean_type) + is StatisticMeanType.CIRCULAR + ): + # Normalize the circular mean to be in the range [0, 360) + _mean = _mean % 360 summary[metadata_id] = { "start_ts": start_time_ts, "mean": _mean, + "mean_weight": _mean_weight, "min": _min, "max": _max, } @@ -828,7 +920,7 @@ def _statistic_by_id_from_metadata( "display_unit_of_measurement": get_display_unit( hass, meta["statistic_id"], meta["unit_of_measurement"] ), - "has_mean": meta["has_mean"], + "mean_type": meta["mean_type"], "has_sum": meta["has_sum"], "name": meta["name"], "source": meta["source"], @@ -847,7 +939,9 @@ def _flatten_list_statistic_ids_metadata_result( { "statistic_id": _id, "display_unit_of_measurement": info["display_unit_of_measurement"], - "has_mean": info["has_mean"], + "has_mean": info["mean_type"] + == StatisticMeanType.ARITHMETIC, # Can be removed with 2026.4 + "mean_type": info["mean_type"], "has_sum": info["has_sum"], "name": info.get("name"), "source": info["source"], @@ -902,7 +996,7 @@ def list_statistic_ids( continue result[key] = { "display_unit_of_measurement": meta["unit_of_measurement"], - "has_mean": meta["has_mean"], + "mean_type": meta["mean_type"], "has_sum": meta["has_sum"], "name": meta["name"], "source": meta["source"], @@ -920,6 +1014,7 @@ def _reduce_statistics( period_start_end: Callable[[float], tuple[float, float]], period: timedelta, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily or monthly statistics.""" result: dict[str, list[StatisticsRow]] = defaultdict(list) @@ -932,7 +1027,7 @@ def _reduce_statistics( _want_sum = "sum" in types for statistic_id, stat_list in stats.items(): max_values: list[float] = [] - mean_values: list[float] = [] + mean_values: list[tuple[float, float]] = [] min_values: list[float] = [] prev_stat: StatisticsRow = stat_list[0] fake_entry: StatisticsRow = {"start": stat_list[-1]["start"] + period_seconds} @@ -947,7 +1042,16 @@ def _reduce_statistics( "end": end, } if _want_mean: - row["mean"] = mean(mean_values) if mean_values else None + row["mean"] = None + row["mean_weight"] = None + if mean_values: + match metadata[statistic_id][1]["mean_type"]: + case StatisticMeanType.ARITHMETIC: + row["mean"] = mean([x[0] for x in mean_values]) + case StatisticMeanType.CIRCULAR: + row["mean"], row["mean_weight"] = ( + weighted_circular_mean(mean_values) + ) mean_values.clear() if _want_min: row["min"] = min(min_values) if min_values else None @@ -964,8 +1068,10 @@ def _reduce_statistics( result[statistic_id].append(row) if _want_max and (_max := statistic.get("max")) is not None: max_values.append(_max) - if _want_mean and (_mean := statistic.get("mean")) is not None: - mean_values.append(_mean) + if _want_mean: + if (_mean := statistic.get("mean")) is not None: + _mean_weight = statistic.get("mean_weight") or 0.0 + mean_values.append((_mean, _mean_weight)) if _want_min and (_min := statistic.get("min")) is not None: min_values.append(_min) prev_stat = statistic @@ -1012,11 +1118,12 @@ def reduce_day_ts_factory() -> tuple[ def _reduce_statistics_per_day( stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily statistics.""" _same_day_ts, _day_start_end_ts = reduce_day_ts_factory() return _reduce_statistics( - stats, _same_day_ts, _day_start_end_ts, timedelta(days=1), types + stats, _same_day_ts, _day_start_end_ts, timedelta(days=1), types, metadata ) @@ -1060,11 +1167,12 @@ def reduce_week_ts_factory() -> tuple[ def _reduce_statistics_per_week( stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to weekly statistics.""" _same_week_ts, _week_start_end_ts = reduce_week_ts_factory() return _reduce_statistics( - stats, _same_week_ts, _week_start_end_ts, timedelta(days=7), types + stats, _same_week_ts, _week_start_end_ts, timedelta(days=7), types, metadata ) @@ -1113,11 +1221,12 @@ def reduce_month_ts_factory() -> tuple[ def _reduce_statistics_per_month( stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to monthly statistics.""" _same_month_ts, _month_start_end_ts = reduce_month_ts_factory() return _reduce_statistics( - stats, _same_month_ts, _month_start_end_ts, timedelta(days=31), types + stats, _same_month_ts, _month_start_end_ts, timedelta(days=31), types, metadata ) @@ -1161,27 +1270,41 @@ def _generate_max_mean_min_statistic_in_sub_period_stmt( return stmt +class _MaxMinMeanStatisticSubPeriod(TypedDict, total=False): + max: float + mean_acc: float + min: float + duration: float + circular_means: Required[list[tuple[float, float]]] + + def _get_max_mean_min_statistic_in_sub_period( session: Session, - result: dict[str, float], + result: _MaxMinMeanStatisticSubPeriod, start_time: datetime | None, end_time: datetime | None, table: type[StatisticsBase], types: set[Literal["max", "mean", "min", "change"]], - metadata_id: int, + metadata: tuple[int, StatisticMetaData], ) -> None: """Return max, mean and min during the period.""" # Calculate max, mean, min + mean_type = metadata[1]["mean_type"] columns = select() if "max" in types: columns = columns.add_columns(func.max(table.max)) if "mean" in types: - columns = columns.add_columns(func.avg(table.mean)) - columns = columns.add_columns(func.count(table.mean)) + match mean_type: + case StatisticMeanType.ARITHMETIC: + columns = columns.add_columns(func.avg(table.mean)) + columns = columns.add_columns(func.count(table.mean)) + case StatisticMeanType.CIRCULAR: + columns = columns.add_columns(*query_circular_mean(table)) if "min" in types: columns = columns.add_columns(func.min(table.min)) + stmt = _generate_max_mean_min_statistic_in_sub_period_stmt( - columns, start_time, end_time, table, metadata_id + columns, start_time, end_time, table, metadata[0] ) stats = cast(Sequence[Row[Any]], execute_stmt_lambda_element(session, stmt)) if not stats: @@ -1189,11 +1312,21 @@ def _get_max_mean_min_statistic_in_sub_period( if "max" in types and (new_max := stats[0].max) is not None: old_max = result.get("max") result["max"] = max(new_max, old_max) if old_max is not None else new_max - if "mean" in types and stats[0].avg is not None: + if "mean" in types: # https://github.com/sqlalchemy/sqlalchemy/issues/9127 - duration = stats[0].count * table.duration.total_seconds() # type: ignore[operator] - result["duration"] = result.get("duration", 0.0) + duration - result["mean_acc"] = result.get("mean_acc", 0.0) + stats[0].avg * duration + match mean_type: + case StatisticMeanType.ARITHMETIC: + duration = stats[0].count * table.duration.total_seconds() # type: ignore[operator] + if stats[0].avg is not None: + result["duration"] = result.get("duration", 0.0) + duration + result["mean_acc"] = ( + result.get("mean_acc", 0.0) + stats[0].avg * duration + ) + case StatisticMeanType.CIRCULAR: + if (new_circular_mean := stats[0].mean) is not None and ( + weight := stats[0].mean_weight + ) is not None: + result["circular_means"].append((new_circular_mean, weight)) if "min" in types and (new_min := stats[0].min) is not None: old_min = result.get("min") result["min"] = min(new_min, old_min) if old_min is not None else new_min @@ -1208,15 +1341,15 @@ def _get_max_mean_min_statistic( tail_start_time: datetime | None, tail_end_time: datetime | None, tail_only: bool, - metadata_id: int, + metadata: tuple[int, StatisticMetaData], types: set[Literal["max", "mean", "min", "change"]], ) -> dict[str, float | None]: """Return max, mean and min during the period. - The mean is a time weighted average, combining hourly and 5-minute statistics if + The mean is time weighted, combining hourly and 5-minute statistics if necessary. """ - max_mean_min: dict[str, float] = {} + max_mean_min = _MaxMinMeanStatisticSubPeriod(circular_means=[]) result: dict[str, float | None] = {} if tail_start_time is not None: @@ -1228,7 +1361,7 @@ def _get_max_mean_min_statistic( tail_end_time, StatisticsShortTerm, types, - metadata_id, + metadata, ) if not tail_only: @@ -1239,7 +1372,7 @@ def _get_max_mean_min_statistic( main_end_time, Statistics, types, - metadata_id, + metadata, ) if head_start_time is not None: @@ -1250,16 +1383,23 @@ def _get_max_mean_min_statistic( head_end_time, StatisticsShortTerm, types, - metadata_id, + metadata, ) if "max" in types: result["max"] = max_mean_min.get("max") if "mean" in types: - if "mean_acc" not in max_mean_min: - result["mean"] = None - else: - result["mean"] = max_mean_min["mean_acc"] / max_mean_min["duration"] + mean_value = None + match metadata[1]["mean_type"]: + case StatisticMeanType.CIRCULAR: + if circular_means := max_mean_min["circular_means"]: + mean_value = weighted_circular_mean(circular_means)[0] + case StatisticMeanType.ARITHMETIC: + if (mean_value := max_mean_min.get("mean_acc")) is not None and ( + duration := max_mean_min.get("duration") + ) is not None: + mean_value = mean_value / duration + result["mean"] = mean_value if "min" in types: result["min"] = max_mean_min.get("min") return result @@ -1560,7 +1700,7 @@ def statistic_during_period( tail_start_time, tail_end_time, tail_only, - metadata_id, + metadata, types, ) @@ -1607,12 +1747,12 @@ def statistic_during_period( _type_column_mapping = { - "last_reset": "last_reset_ts", - "max": "max", - "mean": "mean", - "min": "min", - "state": "state", - "sum": "sum", + "last_reset": ("last_reset_ts",), + "max": ("max",), + "mean": ("mean", "mean_weight"), + "min": ("min",), + "state": ("state",), + "sum": ("sum",), } @@ -1624,12 +1764,13 @@ def _generate_select_columns_for_types_stmt( track_on: list[str | None] = [ table.__tablename__, # type: ignore[attr-defined] ] - for key, column in _type_column_mapping.items(): - if key in types: - columns = columns.add_columns(getattr(table, column)) - track_on.append(column) - else: - track_on.append(None) + for key, type_columns in _type_column_mapping.items(): + for column in type_columns: + if key in types: + columns = columns.add_columns(getattr(table, column)) + track_on.append(column) + else: + track_on.append(None) return lambda_stmt(lambda: columns, track_on=track_on) @@ -1643,7 +1784,7 @@ def _extract_metadata_and_discard_impossible_columns( has_sum = False for metadata_id, stats_metadata in metadata.values(): metadata_ids.append(metadata_id) - has_mean |= stats_metadata["has_mean"] + has_mean |= stats_metadata["mean_type"] is not StatisticMeanType.NONE has_sum |= stats_metadata["has_sum"] if not has_mean: types.discard("mean") @@ -1669,6 +1810,7 @@ def _augment_result_with_change( drop_sum = "sum" not in _types prev_sums = {} if tmp := _statistics_at_time( + get_instance(hass), session, {metadata[statistic_id][0] for statistic_id in result}, table, @@ -1798,19 +1940,25 @@ def _statistics_during_period_with_session( ) if period == "day": - result = _reduce_statistics_per_day(result, types) + result = _reduce_statistics_per_day(result, types, metadata) if period == "week": - result = _reduce_statistics_per_week(result, types) + result = _reduce_statistics_per_week(result, types, metadata) if period == "month": - result = _reduce_statistics_per_month(result, types) + result = _reduce_statistics_per_month(result, types, metadata) if "change" in _types: _augment_result_with_change( hass, session, start_time, units, _types, table, metadata, result ) + # filter out mean_weight as it is only needed to reduce statistics + # and not needed in the result + for stats_rows in result.values(): + for row in stats_rows: + row.pop("mean_weight", None) + # Return statistics combined with metadata return result @@ -2027,7 +2175,39 @@ def get_latest_short_term_statistics_with_session( ) -def _generate_statistics_at_time_stmt( +def _generate_statistics_at_time_stmt_group_by( + table: type[StatisticsBase], + metadata_ids: set[int], + start_time_ts: float, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> StatementLambdaElement: + """Create the statement for finding the statistics for a given time.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + return _generate_select_columns_for_types_stmt(table, types) + ( + 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"), + ) + .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, + ), + ) + ) + + +def _generate_statistics_at_time_stmt_dependent_sub_query( table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, @@ -2041,8 +2221,7 @@ def _generate_statistics_at_time_stmt( # 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 += ( + return _generate_select_columns_for_types_stmt(table, types) + ( lambda q: q.select_from(StatisticsMeta) .join( table, @@ -2064,10 +2243,10 @@ def _generate_statistics_at_time_stmt( ) .where(table.metadata_id.in_(metadata_ids)) ) - return stmt def _statistics_at_time( + instance: Recorder, session: Session, metadata_ids: set[int], table: type[StatisticsBase], @@ -2076,8 +2255,41 @@ def _statistics_at_time( ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) - return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) + if TYPE_CHECKING: + assert instance.database_engine is not None + if not instance.database_engine.optimizer.slow_dependent_subquery: + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + table=table, + metadata_ids=metadata_ids, + start_time_ts=start_time_ts, + types=types, + ) + return cast(list[Row], execute_stmt_lambda_element(session, stmt)) + rows: list[Row] = [] + # https://github.com/home-assistant/core/issues/132865 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + for metadata_ids_chunk in chunked_or_all( + metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY + ): + stmt = _generate_statistics_at_time_stmt_group_by( + table=table, + metadata_ids=metadata_ids_chunk, + start_time_ts=start_time_ts, + types=types, + ) + row_chunk = cast(list[Row], execute_stmt_lambda_element(session, stmt)) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return rows def _build_sum_converted_stats( @@ -2194,7 +2406,12 @@ def _sorted_statistics_to_dict( field_map["last_reset"] = field_map.pop("last_reset_ts") sum_idx = field_map["sum"] if "sum" in types else None sum_only = len(types) == 1 and sum_idx is not None - row_mapping = tuple((key, field_map[key]) for key in types if key in field_map) + row_mapping = tuple( + (column, field_map[column]) + for key in types + for column in ({key, *_type_column_mapping.get(key, ())}) + if column in field_map + ) # Append all statistic entries, and optionally do unit conversion table_duration_seconds = table.duration.total_seconds() for meta_id, db_rows in stats_by_meta_id.items(): diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 43c2ecdc14f..eb7e0c8b63d 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -43,15 +43,15 @@ "fields": { "entity_id": { "name": "Entities to remove", - "description": "List of entities for which the data is to be removed from the recorder database." + "description": "List of entities for which the data is to be removed from the Recorder database." }, "domains": { "name": "Domains to remove", - "description": "List of domains for which the data needs to be removed from the recorder database." + "description": "List of domains for which the data needs to be removed from the Recorder database." }, "entity_globs": { "name": "Entity globs to remove", - "description": "List of glob patterns used to select the entities for which the data is to be removed from the recorder database." + "description": "List of glob patterns used to select the entities for which the data is to be removed from the Recorder database." }, "keep_days": { "name": "[%key:component::recorder::services::purge::fields::keep_days::name%]", @@ -66,6 +66,36 @@ "enable": { "name": "[%key:common::action::enable%]", "description": "Starts the recording of events and state changes." + }, + "get_statistics": { + "name": "Get statistics", + "description": "Retrieves statistics data for entities within a specific time period.", + "fields": { + "end_time": { + "name": "End time", + "description": "The end time for the statistics query. If omitted, returns all statistics from start time onward." + }, + "period": { + "name": "Period", + "description": "The time period to group statistics by." + }, + "start_time": { + "name": "Start time", + "description": "The start time for the statistics query." + }, + "statistic_ids": { + "name": "Statistic IDs", + "description": "The entity IDs or statistic IDs to return statistics for." + }, + "types": { + "name": "Types", + "description": "The types of statistics values to return." + }, + "units": { + "name": "Units", + "description": "Optional unit conversion mapping." + } + } } } } diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index 77fc34518db..634e9565c12 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -4,16 +4,18 @@ from __future__ import annotations import logging import threading -from typing import TYPE_CHECKING, Final, Literal +from typing import TYPE_CHECKING, Any, Final, Literal from lru import LRU from sqlalchemy import lambda_stmt, select +from sqlalchemy.orm import InstrumentedAttribute from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import true from sqlalchemy.sql.lambdas import StatementLambdaElement +from ..const import CIRCULAR_MEAN_SCHEMA_VERSION from ..db_schema import StatisticsMeta -from ..models import StatisticMetaData +from ..models import StatisticMeanType, StatisticMetaData from ..util import execute_stmt_lambda_element if TYPE_CHECKING: @@ -28,7 +30,6 @@ QUERY_STATISTIC_META = ( StatisticsMeta.statistic_id, StatisticsMeta.source, StatisticsMeta.unit_of_measurement, - StatisticsMeta.has_mean, StatisticsMeta.has_sum, StatisticsMeta.name, ) @@ -37,24 +38,38 @@ INDEX_ID: Final = 0 INDEX_STATISTIC_ID: Final = 1 INDEX_SOURCE: Final = 2 INDEX_UNIT_OF_MEASUREMENT: Final = 3 -INDEX_HAS_MEAN: Final = 4 -INDEX_HAS_SUM: Final = 5 -INDEX_NAME: Final = 6 +INDEX_HAS_SUM: Final = 4 +INDEX_NAME: Final = 5 +INDEX_MEAN_TYPE: Final = 6 def _generate_get_metadata_stmt( statistic_ids: set[str] | None = None, statistic_type: Literal["mean", "sum"] | None = None, statistic_source: str | None = None, + schema_version: int = 0, ) -> StatementLambdaElement: - """Generate a statement to fetch metadata.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) + """Generate a statement to fetch metadata with the passed filters. + + Depending on the schema version, either mean_type (added in version 49) or has_mean column is used. + """ + columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META) + if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: + columns.append(StatisticsMeta.mean_type) + else: + columns.append(StatisticsMeta.has_mean) + stmt = lambda_stmt(lambda: select(*columns)) if statistic_ids: stmt += lambda q: q.where(StatisticsMeta.statistic_id.in_(statistic_ids)) if statistic_source is not None: stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) if statistic_type == "mean": - stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) + if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: + stmt += lambda q: q.where( + StatisticsMeta.mean_type != StatisticMeanType.NONE + ) + else: + stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) elif statistic_type == "sum": stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) return stmt @@ -100,14 +115,34 @@ class StatisticsMetaManager: for row in execute_stmt_lambda_element( session, _generate_get_metadata_stmt( - statistic_ids, statistic_type, statistic_source + statistic_ids, + statistic_type, + statistic_source, + self.recorder.schema_version, ), orm_rows=False, ): statistic_id = row[INDEX_STATISTIC_ID] row_id = row[INDEX_ID] + if self.recorder.schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: + try: + mean_type = StatisticMeanType(row[INDEX_MEAN_TYPE]) + except ValueError: + _LOGGER.warning( + "Invalid mean type found for statistic_id: %s, mean_type: %s. Skipping", + statistic_id, + row[INDEX_MEAN_TYPE], + ) + continue + else: + mean_type = ( + StatisticMeanType.ARITHMETIC + if row[INDEX_MEAN_TYPE] + else StatisticMeanType.NONE + ) meta = { - "has_mean": row[INDEX_HAS_MEAN], + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": row[INDEX_HAS_SUM], "name": row[INDEX_NAME], "source": row[INDEX_SOURCE], @@ -157,9 +192,18 @@ class StatisticsMetaManager: This call is not thread-safe and must be called from the recorder thread. """ + if "mean_type" not in new_metadata: + # To maintain backward compatibility after adding 'mean_type' in schema version 49, + # we must still check for its presence. Even though type hints suggest it should always exist, + # custom integrations might omit it, so we need to guard against that. + new_metadata["mean_type"] = ( # type: ignore[unreachable] + StatisticMeanType.ARITHMETIC + if new_metadata["has_mean"] + else StatisticMeanType.NONE + ) metadata_id, old_metadata = old_metadata_dict[statistic_id] if not ( - old_metadata["has_mean"] != new_metadata["has_mean"] + old_metadata["mean_type"] != new_metadata["mean_type"] or old_metadata["has_sum"] != new_metadata["has_sum"] or old_metadata["name"] != new_metadata["name"] or old_metadata["unit_of_measurement"] @@ -170,7 +214,7 @@ class StatisticsMetaManager: self._assert_in_recorder_thread() session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( { - StatisticsMeta.has_mean: new_metadata["has_mean"], + StatisticsMeta.mean_type: new_metadata["mean_type"], StatisticsMeta.has_sum: new_metadata["has_sum"], StatisticsMeta.name: new_metadata["name"], StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 4eb9547ee9d..f5ad7f2a3d9 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -317,13 +317,18 @@ class SynchronizeTask(RecorderTask): """Ensure all pending data has been committed.""" # commit_before is the default - event: asyncio.Event + future: asyncio.Future def run(self, instance: Recorder) -> None: """Handle the task.""" # Does not use a tracked task to avoid # blocking shutdown if the recorder is broken - instance.hass.loop.call_soon_threadsafe(self.event.set) + instance.hass.loop.call_soon_threadsafe(self._set_result_if_not_done) + + def _set_result_if_not_done(self) -> None: + """Set the result if not done.""" + if not self.future.done(): + self.future.set_result(None) @dataclass(slots=True) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a686c7c6498..b7b1a8e17a3 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -464,6 +464,7 @@ def setup_connection_for_dialect( """Execute statements needed for dialect connection.""" version: AwesomeVersion | None = None slow_range_in_select = False + slow_dependent_subquery = False if dialect_name == SupportedDialect.SQLITE: if first_connection: old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] @@ -505,9 +506,8 @@ def setup_connection_for_dialect( result = query_on_connection(dbapi_connection, "SELECT VERSION()") version_string = result[0][0] version = _extract_version_from_server_response(version_string) - is_maria_db = "mariadb" in version_string.lower() - if is_maria_db: + if "mariadb" in version_string.lower(): if not version or version < MIN_VERSION_MARIA_DB: _raise_if_version_unsupported( version or version_string, "MariaDB", MIN_VERSION_MARIA_DB @@ -523,19 +523,21 @@ def setup_connection_for_dialect( instance.hass, version, ) - + slow_range_in_select = bool( + not version + or version < MARIADB_WITH_FIXED_IN_QUERIES_105 + or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 + or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 + or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 + ) elif not version or version < MIN_VERSION_MYSQL: _raise_if_version_unsupported( version or version_string, "MySQL", MIN_VERSION_MYSQL ) - - slow_range_in_select = bool( - not version - or version < MARIADB_WITH_FIXED_IN_QUERIES_105 - or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 - or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 - or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 - ) + else: + # MySQL + # https://github.com/home-assistant/core/issues/137178 + slow_dependent_subquery = True # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") @@ -565,7 +567,10 @@ def setup_connection_for_dialect( return DatabaseEngine( dialect=SupportedDialect(dialect_name), version=version, - optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), + optimizer=DatabaseOptimizer( + slow_range_in_select=slow_range_in_select, + slow_dependent_subquery=slow_dependent_subquery, + ), max_bind_vars=DEFAULT_MAX_BIND_VARS, ) @@ -645,7 +650,7 @@ def _wrap_retryable_database_job_func_or_meth[**_P]( # Failed with retryable error return False - _LOGGER.warning("Error executing %s: %s", description, err) + _LOGGER.error("Error executing %s: %s", description, err) # Failed with permanent error return True diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index d23ecab3dac..76a75a5849e 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -30,6 +30,7 @@ from homeassistant.util.unit_conversion import ( MassConverter, PowerConverter, PressureConverter, + ReactiveEnergyConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -37,7 +38,7 @@ from homeassistant.util.unit_conversion import ( VolumeFlowRateConverter, ) -from .models import StatisticPeriod +from .models import StatisticMeanType, StatisticPeriod from .statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, async_add_external_statistics, @@ -73,6 +74,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), + vol.Optional("reactive_energy"): vol.In(ReactiveEnergyConverter.VALID_UNITS), vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS), vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS), @@ -532,6 +534,10 @@ def ws_import_statistics( ) -> None: """Import statistics.""" metadata = msg["metadata"] + # The WS command will be changed in a follow up PR + metadata["mean_type"] = ( + StatisticMeanType.ARITHMETIC if metadata["has_mean"] else StatisticMeanType.NONE + ) stats = msg["stats"] if valid_entity_id(metadata["statistic_id"]): diff --git a/homeassistant/components/rehlko/__init__.py b/homeassistant/components/rehlko/__init__.py new file mode 100644 index 00000000000..bda2704a206 --- /dev/null +++ b/homeassistant/components/rehlko/__init__.py @@ -0,0 +1,100 @@ +"""The Rehlko integration.""" + +from __future__ import annotations + +import logging + +from aiokem import AioKem, AuthenticationError + +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 ( + CONF_REFRESH_TOKEN, + CONNECTION_EXCEPTIONS, + DEVICE_DATA_DEVICES, + DEVICE_DATA_DISPLAY_NAME, + DEVICE_DATA_ID, + DOMAIN, +) +from .coordinator import RehlkoConfigEntry, RehlkoRuntimeData, RehlkoUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: RehlkoConfigEntry) -> bool: + """Set up Rehlko from a config entry.""" + websession = async_get_clientsession(hass) + rehlko = AioKem(session=websession) + # If requests take more than 20 seconds; timeout and let the setup retry. + rehlko.set_timeout(20) + + async def async_refresh_token_update(refresh_token: str) -> None: + """Handle refresh token update.""" + _LOGGER.debug("Saving refresh token") + # Update the config entry with the new refresh token + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_REFRESH_TOKEN: refresh_token}, + ) + + rehlko.set_refresh_token_callback(async_refresh_token_update) + + try: + await rehlko.authenticate( + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + entry.data.get(CONF_REFRESH_TOKEN), + ) + homes = await rehlko.get_homes() + except AuthenticationError as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={CONF_EMAIL: entry.data[CONF_EMAIL]}, + ) from ex + except CONNECTION_EXCEPTIONS as ex: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from ex + coordinators: dict[int, RehlkoUpdateCoordinator] = {} + + entry.runtime_data = RehlkoRuntimeData( + coordinators=coordinators, + rehlko=rehlko, + homes=homes, + ) + + for home_data in homes: + for device_data in home_data[DEVICE_DATA_DEVICES]: + device_id = device_data[DEVICE_DATA_ID] + coordinator = RehlkoUpdateCoordinator( + hass=hass, + logger=_LOGGER, + config_entry=entry, + home_data=home_data, + device_id=device_id, + device_data=device_data, + rehlko=rehlko, + name=f"{DOMAIN} {device_data[DEVICE_DATA_DISPLAY_NAME]}", + ) + # Intentionally done in series to avoid overloading + # the Rehlko API with requests + await coordinator.async_config_entry_first_refresh() + coordinators[device_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Retrys enabled after successful connection to prevent blocking startup + rehlko.set_retry_policy(retry_count=3, retry_delays=[5, 10, 20]) + # Rehlko service can be slow to respond, increase timeout for polls. + rehlko.set_timeout(100) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: RehlkoConfigEntry) -> bool: + """Unload a config entry.""" + await entry.runtime_data.rehlko.close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rehlko/config_flow.py b/homeassistant/components/rehlko/config_flow.py new file mode 100644 index 00000000000..16f97bb385a --- /dev/null +++ b/homeassistant/components/rehlko/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for Rehlko integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from aiokem import AioKem, AuthenticationError +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 CONNECTION_EXCEPTIONS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class RehlkoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rehlko.""" + + VERSION = 1 + + 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: + errors, token_subject = await self._async_validate_or_error(user_input) + if not errors: + await self.async_set_unique_id(token_subject) + self._abort_if_unique_id_configured() + email: str = user_input[CONF_EMAIL] + normalized_email = email.lower() + return self.async_create_entry(title=normalized_email, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def _async_validate_or_error( + self, config: dict[str, Any] + ) -> tuple[dict[str, str], str | None]: + """Validate the user input.""" + errors: dict[str, str] = {} + token_subject = None + rehlko = AioKem(session=async_get_clientsession(self.hass)) + try: + await rehlko.authenticate(config[CONF_EMAIL], config[CONF_PASSWORD]) + except CONNECTION_EXCEPTIONS: + errors["base"] = "cannot_connect" + except AuthenticationError: + errors[CONF_PASSWORD] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + token_subject = rehlko.get_token_subject() + return errors, token_subject + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth input.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + existing_data = reauth_entry.data + description_placeholders: dict[str, str] = { + CONF_EMAIL: existing_data[CONF_EMAIL] + } + if user_input is not None: + errors, _ = await self._async_validate_or_error( + {**existing_data, **user_input} + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + + return self.async_show_form( + description_placeholders=description_placeholders, + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors, + ) diff --git a/homeassistant/components/rehlko/const.py b/homeassistant/components/rehlko/const.py new file mode 100644 index 00000000000..f63c0872d46 --- /dev/null +++ b/homeassistant/components/rehlko/const.py @@ -0,0 +1,25 @@ +"""Constants for the Rehlko integration.""" + +from aiokem import CommunicationError + +DOMAIN = "rehlko" + +CONF_REFRESH_TOKEN = "refresh_token" + +DEVICE_DATA_DEVICES = "devices" +DEVICE_DATA_PRODUCT = "product" +DEVICE_DATA_FIRMWARE_VERSION = "firmwareVersion" +DEVICE_DATA_MODEL_NAME = "modelDisplayName" +DEVICE_DATA_ID = "id" +DEVICE_DATA_DISPLAY_NAME = "displayName" +DEVICE_DATA_MAC_ADDRESS = "macAddress" +DEVICE_DATA_IS_CONNECTED = "isConnected" + +KOHLER = "Kohler" + +GENERATOR_DATA_DEVICE = "device" + +CONNECTION_EXCEPTIONS = ( + TimeoutError, + CommunicationError, +) diff --git a/homeassistant/components/rehlko/coordinator.py b/homeassistant/components/rehlko/coordinator.py new file mode 100644 index 00000000000..f5a268dff74 --- /dev/null +++ b/homeassistant/components/rehlko/coordinator.py @@ -0,0 +1,78 @@ +"""The Rehlko coordinator.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from aiokem import AioKem, CommunicationError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type RehlkoConfigEntry = ConfigEntry[RehlkoRuntimeData] + +SCAN_INTERVAL_MINUTES = timedelta(minutes=10) + + +@dataclass +class RehlkoRuntimeData: + """Dataclass to hold runtime data for the Rehlko integration.""" + + coordinators: dict[int, RehlkoUpdateCoordinator] + rehlko: AioKem + homes: list[dict[str, Any]] + + +class RehlkoUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching Rehlko data API.""" + + config_entry: RehlkoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + config_entry: RehlkoConfigEntry, + rehlko: AioKem, + home_data: dict[str, Any], + device_data: dict[str, Any], + device_id: int, + name: str, + ) -> None: + """Initialize.""" + self.rehlko = rehlko + self.device_data = device_data + self.device_id = device_id + self.home_data = home_data + super().__init__( + hass=hass, + logger=logger, + config_entry=config_entry, + name=name, + update_interval=SCAN_INTERVAL_MINUTES, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + try: + result = await self.rehlko.get_generator_data(self.device_id) + except CommunicationError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error + return result + + @property + def entry_unique_id(self) -> str: + """Get the unique ID for the entry.""" + assert self.config_entry.unique_id + return self.config_entry.unique_id diff --git a/homeassistant/components/rehlko/entity.py b/homeassistant/components/rehlko/entity.py new file mode 100644 index 00000000000..94d384e1949 --- /dev/null +++ b/homeassistant/components/rehlko/entity.py @@ -0,0 +1,81 @@ +"""Base class for Rehlko entities.""" + +from __future__ import annotations + +from typing import Any + +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 + +from .const import ( + DEVICE_DATA_DISPLAY_NAME, + DEVICE_DATA_FIRMWARE_VERSION, + DEVICE_DATA_IS_CONNECTED, + DEVICE_DATA_MAC_ADDRESS, + DEVICE_DATA_MODEL_NAME, + DEVICE_DATA_PRODUCT, + DOMAIN, + GENERATOR_DATA_DEVICE, + KOHLER, +) +from .coordinator import RehlkoUpdateCoordinator + + +def _get_device_connections(mac_address: str) -> set[tuple[str, str]]: + """Get device connections.""" + try: + mac_address_hex = mac_address.replace(":", "") + except ValueError: # MacAddress may be invalid if the gateway is offline + return set() + return {(dr.CONNECTION_NETWORK_MAC, mac_address_hex)} + + +class RehlkoEntity(CoordinatorEntity[RehlkoUpdateCoordinator]): + """Representation of a Rehlko entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: RehlkoUpdateCoordinator, + device_id: int, + device_data: dict, + description: EntityDescription, + use_device_key: bool = False, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._device_id = device_id + self._attr_unique_id = ( + f"{coordinator.entry_unique_id}_{device_id}_{description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.entry_unique_id}_{device_id}")}, + name=device_data[DEVICE_DATA_DISPLAY_NAME], + hw_version=device_data[DEVICE_DATA_PRODUCT], + sw_version=device_data[DEVICE_DATA_FIRMWARE_VERSION], + model=device_data[DEVICE_DATA_MODEL_NAME], + manufacturer=KOHLER, + connections=_get_device_connections(device_data[DEVICE_DATA_MAC_ADDRESS]), + ) + self._use_device_key = use_device_key + + @property + def _device_data(self) -> dict[str, Any]: + """Return the device data.""" + return self.coordinator.data[GENERATOR_DATA_DEVICE] + + @property + def _rehlko_value(self) -> str: + """Return the sensor value.""" + if self._use_device_key: + return self._device_data[self.entity_description.key] + return self.coordinator.data[self.entity_description.key] + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._device_data[DEVICE_DATA_IS_CONNECTED] diff --git a/homeassistant/components/rehlko/icons.json b/homeassistant/components/rehlko/icons.json new file mode 100644 index 00000000000..309fc2ffd27 --- /dev/null +++ b/homeassistant/components/rehlko/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "engine_speed": { + "default": "mdi:speedometer" + }, + "engine_state": { + "default": "mdi:engine" + }, + "device_ip_address": { + "default": "mdi:ip-network" + }, + "server_ip_address": { + "default": "mdi:server-network" + }, + "generator_status": { + "default": "mdi:home-lightning-bolt" + }, + "power_source": { + "default": "mdi:transmission-tower" + } + } + } +} diff --git a/homeassistant/components/rehlko/manifest.json b/homeassistant/components/rehlko/manifest.json new file mode 100644 index 00000000000..6b2f6190883 --- /dev/null +++ b/homeassistant/components/rehlko/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "rehlko", + "name": "Rehlko", + "codeowners": ["@bdraco", "@peterager"], + "config_flow": true, + "dhcp": [ + { + "hostname": "kohlergen*", + "macaddress": "00146F*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/rehlko", + "iot_class": "cloud_polling", + "loggers": ["aiokem"], + "quality_scale": "silver", + "requirements": ["aiokem==0.5.10"] +} diff --git a/homeassistant/components/rehlko/quality_scale.yaml b/homeassistant/components/rehlko/quality_scale.yaml new file mode 100644 index 00000000000..646fac448cc --- /dev/null +++ b/homeassistant/components/rehlko/quality_scale.yaml @@ -0,0 +1,78 @@ +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: 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: Network information not useful as it is a cloud integration. + 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: | + Device type integration. + entity-category: done + 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: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/rehlko/sensor.py b/homeassistant/components/rehlko/sensor.py new file mode 100644 index 00000000000..9186f0e0c9f --- /dev/null +++ b/homeassistant/components/rehlko/sensor.py @@ -0,0 +1,216 @@ +"""Support for Rehlko sensors.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + EntityCategory, + UnitOfElectricPotential, + UnitOfFrequency, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DEVICE_DATA_DEVICES, DEVICE_DATA_ID +from .coordinator import RehlkoConfigEntry +from .entity import RehlkoEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RehlkoSensorEntityDescription(SensorEntityDescription): + """Class describing Rehlko sensor entities.""" + + use_device_key: bool = False + + +SENSORS: tuple[RehlkoSensorEntityDescription, ...] = ( + RehlkoSensorEntityDescription( + key="engineSpeedRpm", + translation_key="engine_speed", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + ), + RehlkoSensorEntityDescription( + key="engineOilPressurePsi", + translation_key="engine_oil_pressure", + native_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="engineCoolantTempF", + translation_key="engine_coolant_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="batteryVoltageV", + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="lubeOilTempF", + translation_key="lube_oil_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RehlkoSensorEntityDescription( + key="controllerTempF", + translation_key="controller_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="engineCompartmentTempF", + translation_key="engine_compartment_temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RehlkoSensorEntityDescription( + key="engineFrequencyHz", + translation_key="engine_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="totalOperationHours", + translation_key="total_operation", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="totalRuntimeHours", + translation_key="total_runtime", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + use_device_key=True, + ), + RehlkoSensorEntityDescription( + key="runtimeSinceLastMaintenanceHours", + translation_key="runtime_since_last_maintenance", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="deviceIpAddress", + translation_key="device_ip_address", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + use_device_key=True, + ), + RehlkoSensorEntityDescription( + key="serverIpAddress", + translation_key="server_ip_address", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + RehlkoSensorEntityDescription( + key="utilityVoltageV", + translation_key="utility_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="generatorVoltageAvgV", + translation_key="generator_voltage_avg", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="generatorLoadW", + translation_key="generator_load", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + RehlkoSensorEntityDescription( + key="generatorLoadPercent", + translation_key="generator_load_percent", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RehlkoSensorEntityDescription( + key="status", + translation_key="generator_status", + use_device_key=True, + ), + RehlkoSensorEntityDescription( + key="engineState", + translation_key="engine_state", + ), + RehlkoSensorEntityDescription( + key="powerSource", + translation_key="power_source", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RehlkoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensors.""" + + homes = config_entry.runtime_data.homes + coordinators = config_entry.runtime_data.coordinators + async_add_entities( + RehlkoSensorEntity( + coordinators[device_data[DEVICE_DATA_ID]], + device_data[DEVICE_DATA_ID], + device_data, + sensor_description, + sensor_description.use_device_key, + ) + for home_data in homes + for device_data in home_data[DEVICE_DATA_DEVICES] + for sensor_description in SENSORS + ) + + +class RehlkoSensorEntity(RehlkoEntity, SensorEntity): + """Representation of a Rehlko sensor.""" + + @property + def native_value(self) -> StateType: + """Return the sensor state.""" + return self._rehlko_value diff --git a/homeassistant/components/rehlko/strings.json b/homeassistant/components/rehlko/strings.json new file mode 100644 index 00000000000..6b842173558 --- /dev/null +++ b/homeassistant/components/rehlko/strings.json @@ -0,0 +1,108 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email used to log in to the Rehlko application.", + "password": "The password used to log in to the Rehlko application." + } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::rehlko::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": { + "sensor": { + "engine_speed": { + "name": "Engine speed" + }, + "engine_oil_pressure": { + "name": "Engine oil pressure" + }, + "engine_coolant_temperature": { + "name": "Engine coolant temperature" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "lube_oil_temperature": { + "name": "Lube oil temperature" + }, + "controller_temperature": { + "name": "Controller temperature" + }, + "engine_compartment_temperature": { + "name": "Engine compartment temperature" + }, + "engine_frequency": { + "name": "Engine frequency" + }, + "total_operation": { + "name": "Total operation" + }, + "total_runtime": { + "name": "Total runtime" + }, + "runtime_since_last_maintenance": { + "name": "Runtime since last maintenance" + }, + "device_ip_address": { + "name": "Device IP address" + }, + "server_ip_address": { + "name": "Server IP address" + }, + "utility_voltage": { + "name": "Utility voltage" + }, + "generator_voltage_average": { + "name": "Average generator voltage" + }, + "generator_load": { + "name": "Generator load" + }, + "generator_load_percent": { + "name": "Generator load percentage" + }, + "engine_state": { + "name": "Engine state" + }, + "power_source": { + "name": "Power source" + }, + "generator_status": { + "name": "Generator status" + } + } + }, + "exceptions": { + "update_failed": { + "message": "Updating data failed after retries." + }, + "invalid_auth": { + "message": "Authentication failed for email {email}." + }, + "cannot_connect": { + "message": "Can not connect to Rehlko servers." + } + } +} diff --git a/homeassistant/components/remote_calendar/__init__.py b/homeassistant/components/remote_calendar/__init__.py new file mode 100644 index 00000000000..910eeae8268 --- /dev/null +++ b/homeassistant/components/remote_calendar/__init__.py @@ -0,0 +1,33 @@ +"""The Remote Calendar integration.""" + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RemoteCalendarConfigEntry, RemoteCalendarDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: RemoteCalendarConfigEntry +) -> bool: + """Set up Remote Calendar from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + coordinator = RemoteCalendarDataUpdateCoordinator(hass, 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: RemoteCalendarConfigEntry +) -> bool: + """Handle unload of an entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py new file mode 100644 index 00000000000..2f60918f010 --- /dev/null +++ b/homeassistant/components/remote_calendar/calendar.py @@ -0,0 +1,113 @@ +"""Calendar platform for a Remote Calendar.""" + +from datetime import datetime +import logging + +from ical.event import Event + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from . import RemoteCalendarConfigEntry +from .const import CONF_CALENDAR_NAME +from .coordinator import RemoteCalendarDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RemoteCalendarConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the remote calendar platform.""" + coordinator = entry.runtime_data + entity = RemoteCalendarEntity(coordinator, entry) + async_add_entities([entity], True) + + +class RemoteCalendarEntity( + CoordinatorEntity[RemoteCalendarDataUpdateCoordinator], CalendarEntity +): + """A calendar entity backed by a remote iCalendar url.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: RemoteCalendarDataUpdateCoordinator, + entry: RemoteCalendarConfigEntry, + ) -> None: + """Initialize RemoteCalendarEntity.""" + super().__init__(coordinator) + self._attr_name = entry.data[CONF_CALENDAR_NAME] + self._attr_unique_id = entry.entry_id + self._event: CalendarEvent | None = None + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return self._event + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + + def events_in_range() -> list[CalendarEvent]: + """Return all events in the given time range.""" + events = self.coordinator.data.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, + ) + return [_get_calendar_event(event) for event in events] + + return await self.hass.async_add_executor_job(events_in_range) + + async def async_update(self) -> None: + """Refresh the timeline. + + This is called when the coordinator updates. Creating the timeline may + require walking through the entire calendar and handling recurring + events, so it is done as a separate task without blocking the event loop. + """ + await super().async_update() + + def next_timeline_event() -> CalendarEvent | None: + """Return the next active event.""" + now = dt_util.now() + events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None + + self._event = await self.hass.async_add_executor_job(next_timeline_event) + + +def _get_calendar_event(event: Event) -> CalendarEvent: + """Return a CalendarEvent from an API event.""" + + return CalendarEvent( + summary=event.summary, + start=( + dt_util.as_local(event.start) + if isinstance(event.start, datetime) + else event.start + ), + end=( + dt_util.as_local(event.end) + if isinstance(event.end, datetime) + else event.end + ), + description=event.description, + uid=event.uid, + rrule=event.rrule.as_rrule_str() if event.rrule else None, + recurrence_id=event.recurrence_id, + location=event.location, + ) diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py new file mode 100644 index 00000000000..558a3d668ae --- /dev/null +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow for Remote Calendar integration.""" + +from http import HTTPStatus +import logging +from typing import Any + +from httpx import HTTPError, InvalidURL +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_URL +from homeassistant.helpers.httpx_client import get_async_client + +from .const import CONF_CALENDAR_NAME, DOMAIN +from .ics import InvalidIcsException, parse_calendar + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CALENDAR_NAME): str, + vol.Required(CONF_URL): str, + } +) + + +class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Remote Calendar.""" + + VERSION = 1 + + 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=STEP_USER_DATA_SCHEMA + ) + errors: dict = {} + _LOGGER.debug("User input: %s", user_input) + self._async_abort_entries_match( + {CONF_CALENDAR_NAME: user_input[CONF_CALENDAR_NAME]} + ) + if user_input[CONF_URL].startswith("webcal://"): + user_input[CONF_URL] = user_input[CONF_URL].replace( + "webcal://", "https://", 1 + ) + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + client = get_async_client(self.hass) + try: + res = await client.get(user_input[CONF_URL], follow_redirects=True) + if res.status_code == HTTPStatus.FORBIDDEN: + errors["base"] = "forbidden" + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + res.raise_for_status() + except (HTTPError, InvalidURL) as err: + errors["base"] = "cannot_connect" + _LOGGER.debug("An error occurred: %s", err) + else: + try: + await parse_calendar(self.hass, res.text) + except InvalidIcsException: + errors["base"] = "invalid_ics_file" + else: + return self.async_create_entry( + title=user_input[CONF_CALENDAR_NAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/remote_calendar/const.py b/homeassistant/components/remote_calendar/const.py new file mode 100644 index 00000000000..060d7633111 --- /dev/null +++ b/homeassistant/components/remote_calendar/const.py @@ -0,0 +1,4 @@ +"""Constants for the Remote Calendar integration.""" + +DOMAIN = "remote_calendar" +CONF_CALENDAR_NAME = "calendar_name" diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py new file mode 100644 index 00000000000..1eead7682d3 --- /dev/null +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -0,0 +1,65 @@ +"""Data UpdateCoordinator for the Remote Calendar integration.""" + +from datetime import timedelta +import logging + +from httpx import HTTPError, InvalidURL +from ical.calendar import Calendar + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN +from .ics import InvalidIcsException, parse_calendar + +type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(days=1) + + +class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): + """Class to manage fetching calendar data.""" + + config_entry: RemoteCalendarConfigEntry + ics: str + + def __init__( + self, + hass: HomeAssistant, + config_entry: RemoteCalendarConfigEntry, + ) -> None: + """Initialize data updater.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + always_update=True, + ) + self._client = get_async_client(hass) + self._url = config_entry.data[CONF_URL] + + async def _async_update_data(self) -> Calendar: + """Update data from the url.""" + try: + res = await self._client.get(self._url, follow_redirects=True) + res.raise_for_status() + except (HTTPError, InvalidURL) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unable_to_fetch", + translation_placeholders={"err": str(err)}, + ) from err + try: + self.ics = res.text + return await parse_calendar(self.hass, res.text) + except InvalidIcsException as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unable_to_parse", + translation_placeholders={"err": str(err)}, + ) from err diff --git a/homeassistant/components/remote_calendar/diagnostics.py b/homeassistant/components/remote_calendar/diagnostics.py new file mode 100644 index 00000000000..5ebfb3d3812 --- /dev/null +++ b/homeassistant/components/remote_calendar/diagnostics.py @@ -0,0 +1,25 @@ +"""Provides diagnostics for the remote calendar.""" + +import datetime +from typing import Any + +from ical.diagnostics import redact_ics + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import RemoteCalendarConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: RemoteCalendarConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + payload: dict[str, Any] = { + "now": dt_util.now().isoformat(), + "timezone": str(dt_util.get_default_time_zone()), + "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), + } + payload["ics"] = "\n".join(redact_ics(coordinator.ics)) + return payload diff --git a/homeassistant/components/remote_calendar/ics.py b/homeassistant/components/remote_calendar/ics.py new file mode 100644 index 00000000000..d0920d7ae32 --- /dev/null +++ b/homeassistant/components/remote_calendar/ics.py @@ -0,0 +1,44 @@ +"""Module for parsing ICS content. + +This module exists to fix known issues where calendar providers return calendars +that do not follow rfcc5545. This module will attempt to fix the calendar and return +a valid calendar object. +""" + +import logging + +from ical.calendar import Calendar +from ical.calendar_stream import IcsCalendarStream +from ical.compat import enable_compat_mode +from ical.exceptions import CalendarParseError + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +class InvalidIcsException(Exception): + """Exception to indicate that the ICS content is invalid.""" + + +def _compat_calendar_from_ics(ics: str) -> Calendar: + """Parse the ICS content and return a Calendar object. + + This function is called in a separate thread to avoid blocking the event + loop while loading packages or parsing the ICS content for large calendars. + + It uses the `enable_compat_mode` context manager to fix known issues with + calendar providers that return invalid calendars. + """ + with enable_compat_mode(ics) as compat_ics: + return IcsCalendarStream.calendar_from_ics(compat_ics) + + +async def parse_calendar(hass: HomeAssistant, ics: str) -> Calendar: + """Parse the ICS content and return a Calendar object.""" + try: + return await hass.async_add_executor_job(_compat_calendar_from_ics, ics) + except CalendarParseError as err: + _LOGGER.error("Error parsing calendar information: %s", err.message) + _LOGGER.debug("Additional calendar error detail: %s", str(err.detailed_error)) + raise InvalidIcsException(err.message) from err diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json new file mode 100644 index 00000000000..9cf39b7ce45 --- /dev/null +++ b/homeassistant/components/remote_calendar/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "remote_calendar", + "name": "Remote Calendar", + "codeowners": ["@Thomas55555"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/remote_calendar", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["ical"], + "quality_scale": "silver", + "requirements": ["ical==9.2.4"] +} diff --git a/homeassistant/components/remote_calendar/quality_scale.yaml b/homeassistant/components/remote_calendar/quality_scale.yaml new file mode 100644 index 00000000000..964b63d7116 --- /dev/null +++ b/homeassistant/components/remote_calendar/quality_scale.yaml @@ -0,0 +1,98 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: + status: exempt + comment: | + No unique identifier. + 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: | + There are no actions. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: No actions available. + brands: done + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: | + There are no actions. + reauthentication-flow: + status: exempt + comment: | + There is no authentication required. + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: no configuration options + + # Gold + devices: + status: exempt + comment: No devices. One URL is always assigned to one calendar. + diagnostics: done + discovery-update-info: + status: todo + comment: No discovery protocol available. + discovery: + status: exempt + comment: No discovery protocol available. + 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: No devices. One URL is always assigned to one calendar. + entity-category: done + entity-device-class: + status: exempt + comment: No devices classes for calendars. + entity-disabled-by-default: + status: exempt + comment: Only one entity per entry. + entity-translations: + status: exempt + comment: Entity name is defined by the user, so no translation possible. + exception-translations: done + icon-translations: + status: exempt + comment: Only the default icon is used. + reconfiguration-flow: + status: exempt + comment: no configuration possible + repair-issues: todo + stale-devices: + status: exempt + comment: No devices. One URL is always assigned to one calendar. + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json new file mode 100644 index 00000000000..ef7f20d4699 --- /dev/null +++ b/homeassistant/components/remote_calendar/strings.json @@ -0,0 +1,34 @@ +{ + "title": "Remote Calendar", + "config": { + "step": { + "user": { + "description": "Please choose a name for the calendar to be imported", + "data": { + "calendar_name": "Calendar name", + "url": "Calendar URL" + }, + "data_description": { + "calendar_name": "The name of the calendar shown in the UI.", + "url": "The URL of the remote calendar." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "forbidden": "The server understood the request but refuses to authorize it.", + "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details." + } + }, + "exceptions": { + "unable_to_fetch": { + "message": "Unable to fetch calendar data: {err}" + }, + "unable_to_parse": { + "message": "Unable to parse calendar data: {err}" + } + } +} diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index 42e8517c1e8..1d970bb3541 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from gpiozero import DigitalInputDevice import requests import voluptuous as vol @@ -48,10 +49,10 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Raspberry PI GPIO devices.""" - address = config["host"] + address = config[CONF_HOST] invert_logic = config[CONF_INVERT_LOGIC] pull_mode = config[CONF_PULL_MODE] - ports = config["ports"] + ports = config[CONF_PORTS] bouncetime = config[CONF_BOUNCETIME] / 1000 devices = [] @@ -71,9 +72,11 @@ class RemoteRPiGPIOBinarySensor(BinarySensorEntity): _attr_should_poll = False - def __init__(self, name, sensor, invert_logic): + def __init__( + self, name: str | None, sensor: DigitalInputDevice, invert_logic: bool + ) -> None: """Initialize the RPi binary sensor.""" - self._name = name + self._attr_name = name self._invert_logic = invert_logic self._state = False self._sensor = sensor @@ -90,20 +93,10 @@ class RemoteRPiGPIOBinarySensor(BinarySensorEntity): self._sensor.when_activated = read_gpio @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the entity.""" return self._state != self._invert_logic - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return - def update(self) -> None: """Update the GPIO state.""" try: diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index 91b389c5a1e..25f95045e4b 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any +from gpiozero import LED import voluptuous as vol from homeassistant.components.switch import ( @@ -57,37 +58,23 @@ def setup_platform( class RemoteRPiGPIOSwitch(SwitchEntity): """Representation of a Remote Raspberry Pi GPIO.""" + _attr_assumed_state = True _attr_should_poll = False - def __init__(self, name, led): + def __init__(self, name: str | None, led: LED) -> None: """Initialize the pin.""" - self._name = name or DEVICE_DEFAULT_NAME - self._state = False + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_is_on = False self._switch = led - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def assumed_state(self): - """If unable to access real state of the entity.""" - return True - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" write_output(self._switch, 1) - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" write_output(self._switch, 0) - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 0aebd3bd835..5e4f08e9d5c 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from renault_api.kamereon.enums import ChargeState, PlugState @@ -22,6 +23,16 @@ from .entity import RenaultDataEntity, RenaultDataEntityDescription # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 +_PLUG_FROM_CHARGE_STATUS: set[ChargeState] = { + ChargeState.CHARGE_IN_PROGRESS, + ChargeState.WAITING_FOR_CURRENT_CHARGE, + ChargeState.CHARGE_ENDED, + ChargeState.V2G_CHARGING_NORMAL, + ChargeState.V2G_CHARGING_WAITING, + ChargeState.V2G_DISCHARGING, + ChargeState.WAITING_FOR_A_PLANNED_CHARGE, +} + @dataclass(frozen=True, kw_only=True) class RenaultBinarySensorEntityDescription( @@ -30,8 +41,9 @@ class RenaultBinarySensorEntityDescription( ): """Class describing Renault binary sensor entities.""" - on_key: str - on_value: StateType | list[StateType] + on_key: str | None = None + on_value: StateType | None = None + value_lambda: Callable[[RenaultBinarySensor], bool | None] | None = None async def async_setup_entry( @@ -59,25 +71,40 @@ class RenaultBinarySensor( @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" + + if self.entity_description.value_lambda is not None: + return self.entity_description.value_lambda(self) + if self.entity_description.on_key is None: + raise NotImplementedError("Either value_lambda or on_key must be set") if (data := self._get_data_attr(self.entity_description.on_key)) is None: return None - if isinstance(self.entity_description.on_value, list): - return data in self.entity_description.on_value return data == self.entity_description.on_value +def _plugged_in_value_lambda(self: RenaultBinarySensor) -> bool | None: + """Return true if the vehicle is plugged in.""" + + data = self.coordinator.data + plug_status = data.get_plug_status() if data else None + + if plug_status is not None: + return plug_status == PlugState.PLUGGED + + charging_status = data.get_charging_status() if data else None + if charging_status is not None and charging_status in _PLUG_FROM_CHARGE_STATUS: + return True + + return None + + BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( [ RenaultBinarySensorEntityDescription( key="plugged_in", coordinator="battery", device_class=BinarySensorDeviceClass.PLUG, - on_key="plugStatus", - on_value=[ - PlugState.PLUGGED.value, - PlugState.PLUGGED_WAITING_FOR_CHARGE.value, - ], + value_lambda=_plugged_in_value_lambda, ), RenaultBinarySensorEntityDescription( key="charging", diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 70544a5637f..d46f0ff4a80 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any import aiohttp @@ -10,12 +11,18 @@ from renault_api.const import AVAILABLE_LOCALES from renault_api.gigya.exceptions import GigyaException import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN from .renault_hub import RenaultHub +_LOGGER = logging.getLogger(__name__) + USER_SCHEMA = vol.Schema( { vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()), @@ -43,6 +50,7 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): Ask the user for API keys. """ errors: dict[str, str] = {} + suggested_values: Mapping[str, Any] | None = None if user_input: locale = user_input[CONF_LOCALE] self.renault_config.update(user_input) @@ -54,15 +62,22 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): ) except (aiohttp.ClientConnectionError, GigyaException): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: if login_success: return await self.async_step_kamereon() errors["base"] = "invalid_credentials" + suggested_values = user_input + elif self.source == SOURCE_RECONFIGURE: + suggested_values = self._get_reconfigure_entry().data + return self.async_show_form( step_id="user", - data_schema=USER_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + USER_SCHEMA, suggested_values + ), errors=errors, ) @@ -72,6 +87,14 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): """Select Kamereon account.""" if user_input: await self.async_set_unique_id(user_input[CONF_KAMEREON_ACCOUNT_ID]) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + self.renault_config.update(user_input) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self.renault_config, + ) + self._abort_if_unique_id_configured() self.renault_config.update(user_input) @@ -124,3 +147,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]}, ) + + 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/renault/const.py b/homeassistant/components/renault/const.py index 201a07c6783..1dffededf38 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -7,7 +7,12 @@ DOMAIN = "renault" CONF_LOCALE = "locale" CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" -DEFAULT_SCAN_INTERVAL = 420 # 7 minutes +# normal number of allowed calls per hour to the API +# for a single car and the 7 coordinator, it is a scan every 7mn +MAX_CALLS_PER_HOURS = 60 + +# If throttled time to pause the updates, in seconds +COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes PLATFORMS = [ Platform.BINARY_SENSOR, diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index a90331730bc..c768c436133 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -12,6 +12,7 @@ from renault_api.kamereon.exceptions import ( AccessDeniedException, KamereonResponseException, NotSupportedException, + QuotaLimitException, ) from renault_api.kamereon.models import KamereonVehicleDataAttributes @@ -20,6 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub T = TypeVar("T", bound=KamereonVehicleDataAttributes) @@ -37,6 +39,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, logger: logging.Logger, *, name: str, @@ -54,10 +57,24 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): ) self.access_denied = False self.not_supported = False + self.assumed_state = False + self._has_already_worked = False + self._hub = hub async def _async_update_data(self) -> T: """Fetch the latest data from the source.""" + + if self._hub.is_throttled(): + if not self._has_already_worked: + raise UpdateFailed("Renault hub currently throttled: init skipped") + # we have been throttled and decided to cooldown + # so do not count this update as an error + # coordinator. last_update_success should still be ok + self.logger.debug("Renault hub currently throttled: scan skipped") + self.assumed_state = True + return self.data + try: async with _PARALLEL_SEMAPHORE: data = await self.update_method() @@ -70,6 +87,16 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self.access_denied = True raise UpdateFailed(f"This endpoint is denied: {err}") from err + except QuotaLimitException as err: + # The data we got is not bad per see, initiate cooldown for all coordinators + self._hub.set_throttled() + if self._has_already_worked: + self.assumed_state = True + self.logger.warning("Renault API throttled") + return self.data + + raise UpdateFailed(f"Renault API throttled: {err}") from err + except NotSupportedException as err: # Disable because the vehicle does not support this Renault endpoint. self.update_interval = None @@ -81,6 +108,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): raise UpdateFailed(f"Error communicating with API: {err}") from err self._has_already_worked = True + self.assumed_state = False return data async def async_config_entry_first_refresh(self) -> None: diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index 7beb91e9603..81d81a18b7f 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -60,3 +60,8 @@ class RenaultDataEntity( def _get_data_attr(self, key: str) -> StateType: """Return the attribute value from the coordinator data.""" return cast(StateType, getattr(self.coordinator.data, key)) + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return self.coordinator.assumed_state diff --git a/homeassistant/components/renault/icons.json b/homeassistant/components/renault/icons.json index 8b9c4885eaa..aa9175052fb 100644 --- a/homeassistant/components/renault/icons.json +++ b/homeassistant/components/renault/icons.json @@ -35,7 +35,7 @@ }, "sensor": { "charge_state": { - "default": "mdi:mdi:flash-off", + "default": "mdi:flash-off", "state": { "charge_in_progress": "mdi:flash" } diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 1a599afe4e4..2861c52c24a 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.2.9"] + "requirements": ["renault-api==0.3.1"] } diff --git a/homeassistant/components/renault/quality_scale.yaml b/homeassistant/components/renault/quality_scale.yaml index f2d70622192..84a7e352cbc 100644 --- a/homeassistant/components/renault/quality_scale.yaml +++ b/homeassistant/components/renault/quality_scale.yaml @@ -40,21 +40,21 @@ rules: discovery: status: exempt comment: Discovery not possible - docs-data-update: todo + docs-data-update: done docs-examples: todo - docs-known-limitations: todo + docs-known-limitations: done docs-supported-devices: todo docs-supported-functions: todo - docs-troubleshooting: todo + docs-troubleshooting: done 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 + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: done stale-devices: done diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index b37390526cf..1f883435dee 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -27,8 +27,14 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession if TYPE_CHECKING: from . import RenaultConfigEntry -from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL -from .renault_vehicle import RenaultVehicleProxy +from time import time + +from .const import ( + CONF_KAMEREON_ACCOUNT_ID, + COOLING_UPDATES_SECONDS, + MAX_CALLS_PER_HOURS, +) +from .renault_vehicle import COORDINATORS, RenaultVehicleProxy LOGGER = logging.getLogger(__name__) @@ -45,6 +51,24 @@ class RenaultHub: self._account: RenaultAccount | None = None self._vehicles: dict[str, RenaultVehicleProxy] = {} + self._got_throttled_at_time: float | None = None + + def set_throttled(self) -> None: + """We got throttled, we need to adjust the rate limit.""" + if self._got_throttled_at_time is None: + self._got_throttled_at_time = time() + + def is_throttled(self) -> bool: + """Check if we are throttled.""" + if self._got_throttled_at_time is None: + return False + + if time() - self._got_throttled_at_time > COOLING_UPDATES_SECONDS: + self._got_throttled_at_time = None + return False + + return True + async def attempt_login(self, username: str, password: str) -> bool: """Attempt login to Renault servers.""" try: @@ -58,7 +82,6 @@ class RenaultHub: async def async_initialise(self, config_entry: RenaultConfigEntry) -> None: """Set up proxy.""" account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] - scan_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) self._account = await self._client.get_api_account(account_id) vehicles = await self._account.get_vehicles() @@ -70,6 +93,12 @@ class RenaultHub: raise ConfigEntryNotReady( "Failed to retrieve vehicle details from Renault servers" ) + + num_call_per_scan = len(COORDINATORS) * len(vehicles.vehicleLinks) + scan_interval = timedelta( + seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS + ) + device_registry = dr.async_get(self._hass) await asyncio.gather( *( @@ -84,6 +113,21 @@ class RenaultHub: ) ) + # all vehicles have been initiated with the right number of active coordinators + num_call_per_scan = 0 + for vehicle_link in vehicles.vehicleLinks: + vehicle = self._vehicles[str(vehicle_link.vin)] + num_call_per_scan += len(vehicle.coordinators) + + new_scan_interval = timedelta( + seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS + ) + if new_scan_interval != scan_interval: + # we need to change the vehicles with the right scan interval + for vehicle_link in vehicles.vehicleLinks: + vehicle = self._vehicles[str(vehicle_link.vin)] + vehicle.update_scan_interval(new_scan_interval) + async def async_initialise_vehicle( self, vehicle_link: KamereonVehiclesLink, @@ -99,6 +143,7 @@ class RenaultHub: vehicle = RenaultVehicleProxy( hass=self._hass, config_entry=config_entry, + hub=self, vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), details=vehicle_link.vehicleDetails, scan_interval=scan_interval, diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 1cce0e4459f..89059e890f4 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -11,7 +11,7 @@ import logging from typing import TYPE_CHECKING, Any, Concatenate, cast from renault_api.exceptions import RenaultException -from renault_api.kamereon import models +from renault_api.kamereon import models, schemas from renault_api.renault_vehicle import RenaultVehicle from homeassistant.core import HomeAssistant @@ -20,6 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub from .const import DOMAIN from .coordinator import RenaultDataUpdateCoordinator @@ -42,7 +43,11 @@ def with_error_wrapping[**_P, _R]( try: return await func(self, *args, **kwargs) except RenaultException as err: - raise HomeAssistantError(err) from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"error": str(err)}, + ) from err return wrapper @@ -68,6 +73,7 @@ class RenaultVehicleProxy: self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, vehicle: RenaultVehicle, details: models.KamereonVehicleDetails, scan_interval: timedelta, @@ -87,6 +93,14 @@ class RenaultVehicleProxy: self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} self.hvac_target_temperature = 21 self._scan_interval = scan_interval + self._hub = hub + + def update_scan_interval(self, scan_interval: timedelta) -> None: + """Set the scan interval for the vehicle.""" + if scan_interval != self._scan_interval: + self._scan_interval = scan_interval + for coordinator in self.coordinators.values(): + coordinator.update_interval = scan_interval @property def details(self) -> models.KamereonVehicleDetails: @@ -104,6 +118,7 @@ class RenaultVehicleProxy: coord.key: RenaultDataUpdateCoordinator( self.hass, self.config_entry, + self._hub, LOGGER, name=f"{self.details.vin} {coord.key}", update_method=coord.update_method(self._vehicle), @@ -186,7 +201,18 @@ class RenaultVehicleProxy: @with_error_wrapping async def get_charging_settings(self) -> models.KamereonVehicleChargingSettingsData: """Get vehicle charging settings.""" - return await self._vehicle.get_charging_settings() + full_endpoint = await self._vehicle.get_full_endpoint("charging-settings") + response = await self._vehicle.http_get(full_endpoint) + response_data = cast( + models.KamereonVehicleDataResponse, + schemas.KamereonVehicleDataResponseSchema.load(response.raw_data), + ) + return cast( + models.KamereonVehicleChargingSettingsData, + response_data.get_attributes( + schemas.KamereonVehicleChargingSettingsDataSchema + ), + ) @with_error_wrapping async def set_charge_schedules( diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index df65d16b0b8..dfad97ae4ea 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from datetime import datetime import logging from typing import TYPE_CHECKING, Any @@ -105,91 +104,96 @@ SERVICES = [ ] -def setup_services(hass: HomeAssistant) -> None: - """Register the Renault services.""" +async def ac_cancel(service_call: ServiceCall) -> None: + """Cancel A/C.""" + proxy = get_vehicle_proxy(service_call) - async def ac_cancel(service_call: ServiceCall) -> None: - """Cancel A/C.""" - proxy = get_vehicle_proxy(service_call.data) + LOGGER.debug("A/C cancel attempt") + result = await proxy.set_ac_stop() + LOGGER.debug("A/C cancel result: %s", result) - LOGGER.debug("A/C cancel attempt") - result = await proxy.set_ac_stop() - LOGGER.debug("A/C cancel result: %s", result) - async def ac_start(service_call: ServiceCall) -> None: - """Start A/C.""" - temperature: float = service_call.data[ATTR_TEMPERATURE] - when: datetime | None = service_call.data.get(ATTR_WHEN) - proxy = get_vehicle_proxy(service_call.data) +async def ac_start(service_call: ServiceCall) -> None: + """Start A/C.""" + temperature: float = service_call.data[ATTR_TEMPERATURE] + when: datetime | None = service_call.data.get(ATTR_WHEN) + proxy = get_vehicle_proxy(service_call) - LOGGER.debug("A/C start attempt: %s / %s", temperature, when) - result = await proxy.set_ac_start(temperature, when) - LOGGER.debug("A/C start result: %s", result.raw_data) + LOGGER.debug("A/C start attempt: %s / %s", temperature, when) + result = await proxy.set_ac_start(temperature, when) + LOGGER.debug("A/C start result: %s", result.raw_data) - async def charge_set_schedules(service_call: ServiceCall) -> None: - """Set charge schedules.""" - schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] - proxy = get_vehicle_proxy(service_call.data) - charge_schedules = await proxy.get_charging_settings() - for schedule in schedules: - charge_schedules.update(schedule) - if TYPE_CHECKING: - assert charge_schedules.schedules is not None - LOGGER.debug("Charge set schedules attempt: %s", schedules) - result = await proxy.set_charge_schedules(charge_schedules.schedules) +async def charge_set_schedules(service_call: ServiceCall) -> None: + """Set charge schedules.""" + schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + proxy = get_vehicle_proxy(service_call) + charge_schedules = await proxy.get_charging_settings() + for schedule in schedules: + charge_schedules.update(schedule) - LOGGER.debug("Charge set schedules result: %s", result) - LOGGER.debug( - "It may take some time before these changes are reflected in your vehicle" - ) + if TYPE_CHECKING: + assert charge_schedules.schedules is not None + LOGGER.debug("Charge set schedules attempt: %s", schedules) + result = await proxy.set_charge_schedules(charge_schedules.schedules) - async def ac_set_schedules(service_call: ServiceCall) -> None: - """Set A/C schedules.""" - schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] - proxy = get_vehicle_proxy(service_call.data) - hvac_schedules = await proxy.get_hvac_settings() + LOGGER.debug("Charge set schedules result: %s", result) + LOGGER.debug( + "It may take some time before these changes are reflected in your vehicle" + ) - for schedule in schedules: - hvac_schedules.update(schedule) - if TYPE_CHECKING: - assert hvac_schedules.schedules is not None - LOGGER.debug("HVAC set schedules attempt: %s", schedules) - result = await proxy.set_hvac_schedules(hvac_schedules.schedules) +async def ac_set_schedules(service_call: ServiceCall) -> None: + """Set A/C schedules.""" + schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + proxy = get_vehicle_proxy(service_call) + hvac_schedules = await proxy.get_hvac_settings() - LOGGER.debug("HVAC set schedules result: %s", result) - LOGGER.debug( - "It may take some time before these changes are reflected in your vehicle" - ) + for schedule in schedules: + hvac_schedules.update(schedule) - def get_vehicle_proxy(service_call_data: Mapping) -> RenaultVehicleProxy: - """Get vehicle from service_call data.""" - device_registry = dr.async_get(hass) - device_id = service_call_data[ATTR_VEHICLE] - device_entry = device_registry.async_get(device_id) - if device_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_device_id", - translation_placeholders={"device_id": device_id}, - ) + if TYPE_CHECKING: + assert hvac_schedules.schedules is not None + LOGGER.debug("HVAC set schedules attempt: %s", schedules) + result = await proxy.set_hvac_schedules(hvac_schedules.schedules) - loaded_entries: list[RenaultConfigEntry] = [ - entry - for entry in hass.config_entries.async_loaded_entries(DOMAIN) - if entry.entry_id in device_entry.config_entries - ] - for entry in loaded_entries: - for vin, vehicle in entry.runtime_data.vehicles.items(): - if (DOMAIN, vin) in device_entry.identifiers: - return vehicle + LOGGER.debug("HVAC set schedules result: %s", result) + LOGGER.debug( + "It may take some time before these changes are reflected in your vehicle" + ) + + +def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy: + """Get vehicle from service_call data.""" + device_registry = dr.async_get(service_call.hass) + device_id = service_call.data[ATTR_VEHICLE] + device_entry = device_registry.async_get(device_id) + if device_entry is None: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="no_config_entry_for_device", - translation_placeholders={"device_id": device_entry.name or device_id}, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, ) + loaded_entries: list[RenaultConfigEntry] = [ + entry + for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries + ] + for entry in loaded_entries: + for vin, vehicle in entry.runtime_data.vehicles.items(): + if (DOMAIN, vin) in device_entry.identifiers: + return vehicle + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_config_entry_for_device", + translation_placeholders={"device_id": device_entry.name or device_id}, + ) + + +def setup_services(hass: HomeAssistant) -> None: + """Register the Renault services.""" + hass.services.async_register( DOMAIN, SERVICE_AC_CANCEL, diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 8649a5c7b47..dabe2f77bac 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "kamereon_no_account": "Unable to find Kamereon 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%]", + "unique_id_mismatch": "The selected Kamereon account ID does not match the previous account ID" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -118,7 +120,7 @@ "charge_ended": "Charge ended", "waiting_for_current_charge": "Waiting for current charge", "energy_flap_opened": "Energy flap opened", - "charge_in_progress": "Charging", + "charge_in_progress": "[%key:common::state::charging%]", "charge_error": "Not charging or plugged in", "unavailable": "Unavailable" } @@ -155,7 +157,6 @@ "state": { "unplugged": "Unplugged", "plugged": "Plugged in", - "plugged_waiting_for_charge": "Plugged in, waiting for charge", "plug_error": "Plug error", "plug_unknown": "Plug unknown" } @@ -232,6 +233,9 @@ }, "no_config_entry_for_device": { "message": "No loaded config entry was found for device with ID {device_id}" + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Renault servers: {error}" } } } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 71ca5428740..48b5dc1a3d6 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -23,12 +23,12 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services @@ -67,9 +67,7 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: """Set up Reolink from a config entry.""" - host = ReolinkHost( - hass, config_entry.data, config_entry.options, config_entry.entry_id - ) + host = ReolinkHost(hass, config_entry.data, config_entry.options, config_entry) try: await host.async_init() @@ -100,6 +98,7 @@ async def async_setup_entry( or host.api.use_https != config_entry.data[CONF_USE_HTTPS] or host.api.supported(None, "privacy_mode") != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) + or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT) ): if host.api.port != config_entry.data[CONF_PORT]: _LOGGER.warning( @@ -108,10 +107,21 @@ async def async_setup_entry( config_entry.data[CONF_PORT], host.api.port, ) + if ( + config_entry.data.get(CONF_BC_PORT, host.api.baichuan.port) + != host.api.baichuan.port + ): + _LOGGER.warning( + "Baichuan port of Reolink %s, changed from %s to %s", + host.api.nvr_name, + config_entry.data.get(CONF_BC_PORT), + host.api.baichuan.port, + ) data = { **config_entry.data, CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, + CONF_BC_PORT: host.api.baichuan.port, CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) @@ -354,39 +364,90 @@ def migrate_entity_ids( devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) ch_device_ids = {} for device in devices: - (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host) + for dev_id in device.identifiers: + (device_uid, ch, is_chime) = get_device_uid_and_ch(dev_id, host) + if not device_uid: + continue - if host.api.supported(None, "UID") and device_uid[0] != host.unique_id: - if ch is None: - new_device_id = f"{host.unique_id}" - else: - new_device_id = f"{host.unique_id}_{device_uid[1]}" - new_identifiers = {(DOMAIN, new_device_id)} - device_reg.async_update_device(device.id, new_identifiers=new_identifiers) - - if ch is None or is_chime: - continue # Do not consider the NVR itself or chimes - - ch_device_ids[device.id] = ch - if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(ch): - if host.api.supported(None, "UID"): - new_device_id = f"{host.unique_id}_{host.api.camera_uid(ch)}" - else: - new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}" - new_identifiers = {(DOMAIN, new_device_id)} - existing_device = device_reg.async_get_device(identifiers=new_identifiers) - if existing_device is None: + if host.api.supported(None, "UID") and device_uid[0] != host.unique_id: + if ch is None: + new_device_id = f"{host.unique_id}" + else: + new_device_id = f"{host.unique_id}_{device_uid[1]}" + _LOGGER.debug( + "Updating Reolink device UID from %s to %s", + device_uid, + new_device_id, + ) + new_identifiers = {(DOMAIN, new_device_id)} device_reg.async_update_device( device.id, new_identifiers=new_identifiers ) - else: - _LOGGER.warning( - "Reolink device with uid %s already exists, " - "removing device with uid %s", - new_device_id, - device_uid, + + if ch is None or is_chime: + continue # Do not consider the NVR itself or chimes + + # Check for wrongfully combined host with NVR entities in one device + # Can be removed in HA 2025.12 + if (DOMAIN, host.unique_id) in device.identifiers: + new_identifiers = device.identifiers.copy() + for old_id in device.identifiers: + if old_id[0] == DOMAIN and old_id[1] != host.unique_id: + new_identifiers.remove(old_id) + _LOGGER.debug( + "Updating Reolink device identifiers from %s to %s", + device.identifiers, + new_identifiers, ) - device_reg.async_remove_device(device.id) + device_reg.async_update_device( + device.id, new_identifiers=new_identifiers + ) + break + + # Check for wrongfully added MAC of the NVR/Hub to the camera + # Can be removed in HA 2025.12 + host_connnection = (CONNECTION_NETWORK_MAC, host.api.mac_address) + if host_connnection in device.connections: + new_connections = device.connections.copy() + new_connections.remove(host_connnection) + _LOGGER.debug( + "Updating Reolink device connections from %s to %s", + device.connections, + new_connections, + ) + device_reg.async_update_device( + device.id, new_connections=new_connections + ) + + ch_device_ids[device.id] = ch + if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid( + ch + ): + if host.api.supported(None, "UID"): + new_device_id = f"{host.unique_id}_{host.api.camera_uid(ch)}" + else: + new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}" + _LOGGER.debug( + "Updating Reolink device UID from %s to %s", + device_uid, + new_device_id, + ) + new_identifiers = {(DOMAIN, new_device_id)} + existing_device = device_reg.async_get_device( + identifiers=new_identifiers + ) + if existing_device is None: + device_reg.async_update_device( + device.id, new_identifiers=new_identifiers + ) + else: + _LOGGER.warning( + "Reolink device with uid %s already exists, " + "removing device with uid %s", + new_device_id, + device_uid, + ) + device_reg.async_remove_device(device.id) entity_reg = er.async_get(hass) entities = er.async_entries_for_config_entry(entity_reg, config_entry_id) @@ -405,13 +466,31 @@ def migrate_entity_ids( host.unique_id ): new_id = f"{host.unique_id}_{entity.unique_id.split('_', 1)[1]}" + _LOGGER.debug( + "Updating Reolink entity unique_id from %s to %s", + entity.unique_id, + new_id, + ) entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) if entity.device_id in ch_device_ids: ch = ch_device_ids[entity.device_id] id_parts = entity.unique_id.split("_", 2) + if len(id_parts) < 3: + _LOGGER.warning( + "Reolink channel %s entity has unexpected unique_id format %s, with device id %s", + ch, + entity.unique_id, + entity.device_id, + ) + continue if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch): new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}" + _LOGGER.debug( + "Updating Reolink entity unique_id from %s to %s", + entity.unique_id, + new_id, + ) existing_entity = entity_reg.async_get_entity_id( entity.domain, entity.platform, new_id ) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 4e90bfc9eef..2d08e42a6c8 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -25,7 +25,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkEntityDescription, +) from .util import ReolinkConfigEntry, ReolinkData PARALLEL_UPDATES = 0 @@ -41,6 +45,18 @@ class ReolinkBinarySensorEntityDescription( value: Callable[[Host, int], bool] +@dataclass(frozen=True, kw_only=True) +class ReolinkSmartAIBinarySensorEntityDescription( + BinarySensorEntityDescription, + ReolinkEntityDescription, +): + """A class that describes Smart AI binary sensor entities.""" + + smart_type: str + value: Callable[[Host, int, int], bool] + supported: Callable[[Host, int, int], bool] = lambda api, ch, loc: True + + BINARY_PUSH_SENSORS = ( ReolinkBinarySensorEntityDescription( key="motion", @@ -99,6 +115,7 @@ BINARY_PUSH_SENSORS = ( translation_key="visitor", value=lambda api, ch: api.visitor_detected(ch), supported=lambda api, ch: api.is_doorbell(ch), + always_available=True, ), ReolinkBinarySensorEntityDescription( key="cry", @@ -121,6 +138,142 @@ BINARY_SENSORS = ( ), ) +BINARY_SMART_AI_SENSORS = ( + ReolinkSmartAIBinarySensorEntityDescription( + key="crossline_person", + smart_type="crossline", + cmd_id=33, + translation_key="crossline_person", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "crossline", loc, "people") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_crossline") + and "people" in api.baichuan.smart_ai_type_list(ch, "crossline", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="crossline_vehicle", + smart_type="crossline", + cmd_id=33, + translation_key="crossline_vehicle", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "crossline", loc, "vehicle") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_crossline") + and "vehicle" in api.baichuan.smart_ai_type_list(ch, "crossline", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="crossline_dog_cat", + smart_type="crossline", + cmd_id=33, + translation_key="crossline_dog_cat", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "crossline", loc, "dog_cat") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_crossline") + and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "crossline", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="intrusion_person", + smart_type="intrusion", + cmd_id=33, + translation_key="intrusion_person", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "intrusion", loc, "people") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_intrusion") + and "people" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="intrusion_vehicle", + smart_type="intrusion", + cmd_id=33, + translation_key="intrusion_vehicle", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "intrusion", loc, "vehicle") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_intrusion") + and "vehicle" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="intrusion_dog_cat", + smart_type="intrusion", + cmd_id=33, + translation_key="intrusion_dog_cat", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "intrusion", loc, "dog_cat") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_intrusion") + and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="linger_person", + smart_type="loitering", + cmd_id=33, + translation_key="linger_person", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "loitering", loc, "people") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_linger") + and "people" in api.baichuan.smart_ai_type_list(ch, "loitering", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="linger_vehicle", + smart_type="loitering", + cmd_id=33, + translation_key="linger_vehicle", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "loitering", loc, "vehicle") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_linger") + and "vehicle" in api.baichuan.smart_ai_type_list(ch, "loitering", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="linger_dog_cat", + smart_type="loitering", + cmd_id=33, + translation_key="linger_dog_cat", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "loitering", loc, "dog_cat") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_linger") + and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "loitering", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="forgotten_item", + smart_type="legacy", + cmd_id=33, + translation_key="forgotten_item", + value=lambda api, ch, loc: (api.baichuan.smart_ai_state(ch, "legacy", loc)), + supported=lambda api, ch, loc: api.supported(ch, "ai_forgotten_item"), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="taken_item", + smart_type="loss", + cmd_id=33, + translation_key="taken_item", + value=lambda api, ch, loc: (api.baichuan.smart_ai_state(ch, "loss", loc)), + supported=lambda api, ch, loc: api.supported(ch, "ai_taken_item"), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -129,18 +282,29 @@ async def async_setup_entry( ) -> None: """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = config_entry.runtime_data + api = reolink_data.host.api - entities: list[ReolinkBinarySensorEntity] = [] - for channel in reolink_data.host.api.channels: + entities: list[ReolinkBinarySensorEntity | ReolinkSmartAIBinarySensorEntity] = [] + for channel in api.channels: entities.extend( ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description) for entity_description in BINARY_PUSH_SENSORS - if entity_description.supported(reolink_data.host.api, channel) + if entity_description.supported(api, channel) ) entities.extend( ReolinkBinarySensorEntity(reolink_data, channel, entity_description) for entity_description in BINARY_SENSORS - if entity_description.supported(reolink_data.host.api, channel) + if entity_description.supported(api, channel) + ) + entities.extend( + ReolinkSmartAIBinarySensorEntity( + reolink_data, channel, location, entity_description + ) + for entity_description in BINARY_SMART_AI_SENSORS + for location in api.baichuan.smart_location_list( + channel, entity_description.smart_type + ) + if entity_description.supported(api, channel, location) ) async_add_entities(entities) @@ -198,3 +362,40 @@ class ReolinkPushBinarySensorEntity(ReolinkBinarySensorEntity): async def _async_handle_event(self, event: str) -> None: """Handle incoming event for motion detection.""" self.async_write_ha_state() + + +class ReolinkSmartAIBinarySensorEntity( + ReolinkChannelCoordinatorEntity, BinarySensorEntity +): + """Binary-sensor class for Reolink IP camera Smart AI sensors.""" + + entity_description: ReolinkSmartAIBinarySensorEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + location: int, + entity_description: ReolinkSmartAIBinarySensorEntityDescription, + ) -> None: + """Initialize Reolink binary sensor.""" + self.entity_description = entity_description + super().__init__(reolink_data, channel) + unique_index = self._host.api.baichuan.smart_ai_index( + channel, entity_description.smart_type, location + ) + self._attr_unique_id = f"{self._attr_unique_id}_{unique_index}" + + self._location = location + self._attr_translation_placeholders = { + "zone_name": self._host.api.baichuan.smart_ai_name( + channel, entity_description.smart_type, location + ) + } + + @property + def is_on(self) -> bool: + """State of the sensor.""" + return self.entity_description.value( + self._host.api, self._channel, self._location + ) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 7943cadef21..12ccd455be3 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -8,6 +8,7 @@ import logging from typing import Any from reolink_aio.api import ALLOWED_SPECIAL_CHARS +from reolink_aio.baichuan import DEFAULT_BC_PORT from reolink_aio.exceptions import ( ApiError, CredentialsInvalidError, @@ -37,7 +38,7 @@ 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_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -287,6 +288,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if not errors: user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https + user_input[CONF_BC_PORT] = host.api.baichuan.port user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( None, "privacy_mode" ) @@ -326,8 +328,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if errors: data_schema = data_schema.extend( { - vol.Optional(CONF_PORT): cv.positive_int, + vol.Optional(CONF_PORT): cv.port, vol.Required(CONF_USE_HTTPS, default=False): bool, + vol.Required(CONF_BC_PORT, default=DEFAULT_BC_PORT): cv.port, } ) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 7bd93337c46..026d1219881 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,4 +3,5 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" +CONF_BC_PORT = "baichuan_port" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 693f2ba59a4..1d0e5d919e7 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -25,6 +25,14 @@ async def async_get_config_entry_diagnostics( IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) + chimes: dict[int, dict[str, Any]] = {} + for chime in api.chime_list: + chimes[chime.dev_id] = {} + chimes[chime.dev_id]["channel"] = chime.channel + chimes[chime.dev_id]["name"] = chime.name + chimes[chime.dev_id]["online"] = chime.online + chimes[chime.dev_id]["event_types"] = chime.chime_event_types + return { "model": api.model, "hardware version": api.hardware_version, @@ -41,9 +49,11 @@ async def async_get_config_entry_diagnostics( "channels": api.channels, "stream channels": api.stream_channels, "IPC cams": IPC_cam, + "Chimes": chimes, "capabilities": api.capabilities, "cmd list": host.update_cmd, "firmware ch list": host.firmware_ch_list, "api versions": api.checked_api_versions, "abilities": api.abilities, + "BC_abilities": api.baichuan.abilities, } diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 55ce4ce891e..f2a0b20994a 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -178,8 +178,13 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): else: self._dev_id = f"{self._host.unique_id}_ch{dev_ch}" + connections = set() + if mac := self._host.api.baichuan.mac_address(dev_ch): + connections.add((CONNECTION_NETWORK_MAC, mac)) + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, + connections=connections, via_device=(DOMAIN, self._host.unique_id), name=self._host.api.camera_name(dev_ch), model=self._host.api.camera_model(dev_ch), @@ -187,13 +192,20 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): hw_version=self._host.api.camera_hardware_version(dev_ch), sw_version=self._host.api.camera_sw_version(dev_ch), serial_number=self._host.api.camera_uid(dev_ch), - configuration_url=self._conf_url, + configuration_url=f"{self._conf_url}/?ch={dev_ch}", ) @property def available(self) -> bool: """Return True if entity is available.""" - return super().available and self._host.api.camera_online(self._channel) + if self.entity_description.always_available: + return True + + return ( + super().available + and self._host.api.camera_online(self._channel) + and not self._host.api.baichuan.privacy_mode(self._channel) + ) def register_callback(self, callback_id: str, cmd_id: int) -> None: """Register callback for TCP push events.""" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 2f646ba9090..c3a8d340501 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -12,6 +12,7 @@ from typing import Any, Literal import aiohttp from aiohttp.web import Request from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host +from reolink_aio.baichuan import DEFAULT_BC_PORT from reolink_aio.enums import SubType from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError @@ -33,14 +34,14 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.storage import Store from homeassistant.util.ssl import SSLCipherList -from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkSetupException, ReolinkWebhookException, UserNotAdmin, ) -from .util import get_store +from .util import ReolinkConfigEntry, get_store DEFAULT_TIMEOUT = 30 FIRST_TCP_PUSH_TIMEOUT = 10 @@ -66,11 +67,11 @@ class ReolinkHost: hass: HomeAssistant, config: Mapping[str, Any], options: Mapping[str, Any], - config_entry_id: str | None = None, + config_entry: ReolinkConfigEntry | None = None, ) -> None: """Initialize Reolink Host. Could be either NVR, or Camera.""" self._hass: HomeAssistant = hass - self._config_entry_id = config_entry_id + self._config_entry = config_entry self._config = config self._unique_id: str = "" @@ -91,6 +92,7 @@ class ReolinkHost: protocol=options[CONF_PROTOCOL], timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=get_aiohttp_session, + bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), ) self.last_wake: float = 0 @@ -149,15 +151,33 @@ class ReolinkHost: async def async_init(self) -> None: """Connect to Reolink host.""" if not self._api.valid_password(): + if ( + len(self._config[CONF_PASSWORD]) >= 32 + and self._config_entry is not None + ): + ir.async_create_issue( + self._hass, + DOMAIN, + f"password_too_long_{self._config_entry.entry_id}", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="password_too_long", + translation_placeholders={"name": self._config_entry.title}, + ) + raise PasswordIncompatible( - "Reolink password contains incompatible special character, " - "please change the password to only contain characters: " - f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}" + "Reolink password contains incompatible special character or " + "is too long, please change the password to only contain characters: " + f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS} " + "and not be longer than 31 characters" ) store: Store[str] | None = None - if self._config_entry_id is not None: - store = get_store(self._hass, self._config_entry_id) + if self._config_entry is not None: + ir.async_delete_issue( + self._hass, DOMAIN, f"password_too_long_{self._config_entry.entry_id}" + ) + store = get_store(self._hass, self._config_entry.entry_id) if self._config.get(CONF_SUPPORTS_PRIVACY_MODE) and ( data := await store.async_load() ): @@ -445,10 +465,11 @@ class ReolinkHost: wake = True self.last_wake = time() + for channel in self._api.channels: + if self._api.baichuan.privacy_mode(channel): + await self._api.baichuan.get_privacy_mode(channel) if self._api.baichuan.privacy_mode(): - await self._api.baichuan.get_privacy_mode() - if self._api.baichuan.privacy_mode(): - return # API is shutdown, no need to check states + return # API is shutdown, no need to check states await self._api.get_states(cmd_list=self.update_cmd, wake=wake) @@ -560,7 +581,12 @@ class ReolinkHost: ) return - await self._api.subscribe(self._webhook_url) + try: + await self._api.subscribe(self._webhook_url) + except NotSupportedError as err: + self._onvif_push_supported = False + _LOGGER.debug(err) + return _LOGGER.debug( "Host %s: subscribed successfully to webhook %s", @@ -581,7 +607,11 @@ class ReolinkHost: return # API is shutdown, no need to subscribe try: - if self._onvif_push_supported and not self._api.baichuan.events_active: + if ( + self._onvif_push_supported + and not self._api.baichuan.events_active + and self._cancel_tcp_push_check is None + ): await self._renew(SubType.push) if self._onvif_long_poll_supported and self._long_poll_task is not None: diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 26198a11594..7df82dfc512 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -54,6 +54,72 @@ "state": { "on": "mdi:sleep" } + }, + "crossline_person": { + "default": "mdi:fence", + "state": { + "on": "mdi:fence-electric" + } + }, + "crossline_vehicle": { + "default": "mdi:fence", + "state": { + "on": "mdi:fence-electric" + } + }, + "crossline_dog_cat": { + "default": "mdi:fence", + "state": { + "on": "mdi:fence-electric" + } + }, + "intrusion_person": { + "default": "mdi:location-enter", + "state": { + "on": "mdi:alert-circle-outline" + } + }, + "intrusion_vehicle": { + "default": "mdi:location-enter", + "state": { + "on": "mdi:alert-circle-outline" + } + }, + "intrusion_dog_cat": { + "default": "mdi:location-enter", + "state": { + "on": "mdi:alert-circle-outline" + } + }, + "linger_person": { + "default": "mdi:account-switch", + "state": { + "on": "mdi:account-alert" + } + }, + "linger_vehicle": { + "default": "mdi:account-switch", + "state": { + "on": "mdi:account-alert" + } + }, + "linger_dog_cat": { + "default": "mdi:account-switch", + "state": { + "on": "mdi:account-alert" + } + }, + "forgotten_item": { + "default": "mdi:package-variant-closed-plus", + "state": { + "on": "mdi:package-variant-closed-check" + } + }, + "taken_item": { + "default": "mdi:package-variant-closed-minus", + "state": { + "on": "mdi:package-variant-closed-check" + } } }, "button": { @@ -151,6 +217,21 @@ "ai_animal_sensitivity": { "default": "mdi:paw" }, + "crossline_sensitivity": { + "default": "mdi:fence" + }, + "intrusion_sensitivity": { + "default": "mdi:location-enter" + }, + "linger_sensitivity": { + "default": "mdi:account-switch" + }, + "forgotten_item_sensitivity": { + "default": "mdi:package-variant-closed-plus" + }, + "taken_item_sensitivity": { + "default": "mdi:package-variant-closed-minus" + }, "ai_face_delay": { "default": "mdi:face-recognition" }, @@ -169,6 +250,18 @@ "ai_animal_delay": { "default": "mdi:paw" }, + "intrusion_delay": { + "default": "mdi:location-enter" + }, + "linger_delay": { + "default": "mdi:account-switch" + }, + "forgotten_item_delay": { + "default": "mdi:package-variant-closed-plus" + }, + "taken_item_delay": { + "default": "mdi:package-variant-closed-minus" + }, "auto_quick_reply_time": { "default": "mdi:message-reply-text-outline" }, @@ -284,6 +377,12 @@ }, "sub_bit_rate": { "default": "mdi:play-speed" + }, + "scene_mode": { + "default": "mdi:view-list" + }, + "packing_time": { + "default": "mdi:record-rec" } }, "sensor": { @@ -299,6 +398,9 @@ "battery_state": { "default": "mdi:battery-charging" }, + "day_night_state": { + "default": "mdi:theme-light-dark" + }, "wifi_signal": { "default": "mdi:wifi" }, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 37e448aa820..a6f0b59426a 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.12.0"] + "requirements": ["reolink-aio==0.13.3"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 39514d58cb7..092f0d4ddca 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -70,7 +70,7 @@ class ReolinkVODMediaSource(MediaSource): host = get_host(self.hass, config_entry_id) def get_vod_type() -> VodRequestType: - if filename.endswith((".mp4", ".vref")): + if filename.endswith((".mp4", ".vref")) or host.api.is_hub: if host.api.is_nvr: return VodRequestType.DOWNLOAD return VodRequestType.PLAYBACK diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 48382df4cbc..2a6fb740ee0 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -9,6 +9,7 @@ from typing import Any from reolink_aio.api import Chime, Host from homeassistant.components.number import ( + NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, @@ -44,6 +45,19 @@ class ReolinkNumberEntityDescription( value: Callable[[Host, int], float | None] +@dataclass(frozen=True, kw_only=True) +class ReolinkSmartAINumberEntityDescription( + NumberEntityDescription, + ReolinkChannelEntityDescription, +): + """A class that describes smart AI number entities.""" + + smart_type: str + method: Callable[[Host, int, int, float], Any] + mode: NumberMode = NumberMode.AUTO + value: Callable[[Host, int, int], float | None] + + @dataclass(frozen=True, kw_only=True) class ReolinkHostNumberEntityDescription( NumberEntityDescription, @@ -125,6 +139,7 @@ NUMBER_ENTITIES = ( cmd_key="GetPtzGuard", translation_key="guard_return_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=10, @@ -248,6 +263,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_face_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -264,6 +280,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_person_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -280,6 +297,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_vehicle_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -296,6 +314,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_package_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -312,6 +331,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_pet_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -330,6 +350,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_animal_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -346,6 +367,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAutoReply", translation_key="auto_quick_reply_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -385,6 +407,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiCfg", translation_key="auto_track_disappear_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -400,6 +423,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiCfg", translation_key="auto_track_stop_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -493,6 +517,168 @@ NUMBER_ENTITIES = ( ), ) +SMART_AI_NUMBER_ENTITIES = ( + ReolinkSmartAINumberEntityDescription( + key="crossline_sensitivity", + smart_type="crossline", + cmd_id=527, + translation_key="crossline_sensitivity", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_crossline"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "crossline", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "crossline", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="intrusion_sensitivity", + smart_type="intrusion", + cmd_id=529, + translation_key="intrusion_sensitivity", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_intrusion"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "intrusion", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "intrusion", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="linger_sensitivity", + smart_type="loitering", + cmd_id=531, + translation_key="linger_sensitivity", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_linger"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "loitering", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loitering", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="forgotten_item_sensitivity", + smart_type="legacy", + cmd_id=549, + translation_key="forgotten_item_sensitivity", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_forgotten_item"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "legacy", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "legacy", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="taken_item_sensitivity", + smart_type="loss", + cmd_id=551, + translation_key="taken_item_sensitivity", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_taken_item"), + value=lambda api, ch, loc: api.baichuan.smart_ai_sensitivity(ch, "loss", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loss", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="intrusion_delay", + smart_type="intrusion", + cmd_id=529, + translation_key="intrusion_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=10, + supported=lambda api, ch: api.supported(ch, "ai_intrusion"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "intrusion", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "intrusion", loc, delay=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="linger_delay", + smart_type="loitering", + cmd_id=531, + translation_key="linger_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=10, + supported=lambda api, ch: api.supported(ch, "ai_linger"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "loitering", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loitering", loc, delay=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="forgotten_item_delay", + smart_type="legacy", + cmd_id=549, + translation_key="forgotten_item_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=30, + supported=lambda api, ch: api.supported(ch, "ai_forgotten_item"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "legacy", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "legacy", loc, delay=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="taken_item_delay", + smart_type="loss", + cmd_id=551, + translation_key="taken_item_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=30, + supported=lambda api, ch: api.supported(ch, "ai_taken_item"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "loss", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loss", loc, delay=int(value) + ), + ), +) + HOST_NUMBER_ENTITIES = ( ReolinkHostNumberEntityDescription( key="alarm_volume", @@ -542,22 +728,32 @@ async def async_setup_entry( ) -> None: """Set up a Reolink number entities.""" reolink_data: ReolinkData = config_entry.runtime_data + api = reolink_data.host.api entities: list[NumberEntity] = [ ReolinkNumberEntity(reolink_data, channel, entity_description) for entity_description in NUMBER_ENTITIES - for channel in reolink_data.host.api.channels - if entity_description.supported(reolink_data.host.api, channel) + for channel in api.channels + if entity_description.supported(api, channel) ] + entities.extend( + ReolinkSmartAINumberEntity(reolink_data, channel, location, entity_description) + for entity_description in SMART_AI_NUMBER_ENTITIES + for channel in api.channels + for location in api.baichuan.smart_location_list( + channel, entity_description.smart_type + ) + if entity_description.supported(api, channel) + ) entities.extend( ReolinkHostNumberEntity(reolink_data, entity_description) for entity_description in HOST_NUMBER_ENTITIES - if entity_description.supported(reolink_data.host.api) + if entity_description.supported(api) ) entities.extend( ReolinkChimeNumberEntity(reolink_data, chime, entity_description) for entity_description in CHIME_NUMBER_ENTITIES - for chime in reolink_data.host.api.chime_list + for chime in api.chime_list ) async_add_entities(entities) @@ -599,6 +795,51 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): self.async_write_ha_state() +class ReolinkSmartAINumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): + """Base smart AI number entity class for Reolink IP cameras.""" + + entity_description: ReolinkSmartAINumberEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + location: int, + entity_description: ReolinkSmartAINumberEntityDescription, + ) -> None: + """Initialize Reolink number entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, channel) + + unique_index = self._host.api.baichuan.smart_ai_index( + channel, entity_description.smart_type, location + ) + self._attr_unique_id = f"{self._attr_unique_id}_{unique_index}" + + self._location = location + self._attr_mode = entity_description.mode + self._attr_translation_placeholders = { + "zone_name": self._host.api.baichuan.smart_ai_name( + channel, entity_description.smart_type, location + ) + } + + @property + def native_value(self) -> float | None: + """State of the number entity.""" + return self.entity_description.value( + self._host.api, self._channel, self._location + ) + + @raise_translated_error + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await self.entity_description.method( + self._host.api, self._channel, self._location, value + ) + self.async_write_ha_state() + + class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity): """Base number entity class for Reolink Host.""" diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index c0b20da0238..2ee2b790687 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -30,6 +30,8 @@ from .entity import ( ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, ) from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error @@ -49,6 +51,18 @@ class ReolinkSelectEntityDescription( value: Callable[[Host, int], str] | None = None +@dataclass(frozen=True, kw_only=True) +class ReolinkHostSelectEntityDescription( + SelectEntityDescription, + ReolinkHostEntityDescription, +): + """A class that describes host select entities.""" + + get_options: Callable[[Host], list[str]] + method: Callable[[Host, str], Any] + value: Callable[[Host], str] + + @dataclass(frozen=True, kw_only=True) class ReolinkChimeSelectEntityDescription( SelectEntityDescription, @@ -238,6 +252,30 @@ SELECT_ENTITIES = ( ), ) +HOST_SELECT_ENTITIES = ( + ReolinkHostSelectEntityDescription( + key="scene_mode", + cmd_key="GetScene", + translation_key="scene_mode", + entity_category=EntityCategory.CONFIG, + get_options=lambda api: api.baichuan.scene_names, + supported=lambda api: api.supported(None, "scenes"), + value=lambda api: api.baichuan.active_scene, + method=lambda api, name: api.baichuan.set_scene(scene_name=name), + ), + ReolinkHostSelectEntityDescription( + key="packing_time", + cmd_key="GetRec", + translation_key="packing_time", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + get_options=lambda api: api.recording_packing_time_list, + supported=lambda api: api.supported(None, "pak_time"), + value=lambda api: api.recording_packing_time, + method=lambda api, value: api.set_recording_packing_time(value), + ), +) + CHIME_SELECT_ENTITIES = ( ReolinkChimeSelectEntityDescription( key="motion_tone", @@ -300,12 +338,19 @@ async def async_setup_entry( """Set up a Reolink select entities.""" reolink_data: ReolinkData = config_entry.runtime_data - entities: list[ReolinkSelectEntity | ReolinkChimeSelectEntity] = [ + entities: list[ + ReolinkSelectEntity | ReolinkHostSelectEntity | ReolinkChimeSelectEntity + ] = [ ReolinkSelectEntity(reolink_data, channel, entity_description) for entity_description in SELECT_ENTITIES for channel in reolink_data.host.api.channels if entity_description.supported(reolink_data.host.api, channel) ] + entities.extend( + ReolinkHostSelectEntity(reolink_data, entity_description) + for entity_description in HOST_SELECT_ENTITIES + if entity_description.supported(reolink_data.host.api) + ) entities.extend( ReolinkChimeSelectEntity(reolink_data, chime, entity_description) for entity_description in CHIME_SELECT_ENTITIES @@ -360,6 +405,33 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): self.async_write_ha_state() +class ReolinkHostSelectEntity(ReolinkHostCoordinatorEntity, SelectEntity): + """Base select entity class for Reolink Host.""" + + entity_description: ReolinkHostSelectEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkHostSelectEntityDescription, + ) -> None: + """Initialize Reolink select entity.""" + self.entity_description = entity_description + super().__init__(reolink_data) + self._attr_options = entity_description.get_options(self._host.api) + + @property + def current_option(self) -> str | None: + """Return the current option.""" + return self.entity_description.value(self._host.api) + + @raise_translated_error + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.method(self._host.api, option) + self.async_write_ha_state() + + class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity): """Base select entity class for Reolink IP cameras.""" diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index ecad555b481..85de03dd1a3 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -107,6 +107,17 @@ SENSORS = ( value=lambda api, ch: BatteryEnum(api.battery_status(ch)).name, supported=lambda api, ch: api.supported(ch, "battery"), ), + ReolinkSensorEntityDescription( + key="day_night_state", + cmd_id=33, + cmd_key="296", + translation_key="day_night_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=["day", "night", "led_day"], + value=lambda api, ch: api.baichuan.day_night_state(ch), + supported=lambda api, ch: api.supported(ch, "day_night_state"), + ), ) HOST_SENSORS = ( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 335ed92d32e..94d2ee3cf27 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -8,15 +8,17 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "use_https": "Enable HTTPS", + "baichuan_port": "Basic service port", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, "data_description": { "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.", - "port": "The port to connect to the Reolink device. For HTTP normally: '80', for HTTPS normally '443'.", - "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", - "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." + "port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.", + "use_https": "Use an HTTPS (SSL) connection to the Reolink device.", + "baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.", + "username": "Username to log in to the Reolink device itself. Not the Reolink cloud account.", + "password": "Password to log in to the Reolink device itself. Not the Reolink cloud account." } }, "privacy": { @@ -28,10 +30,10 @@ "api_error": "API error occurred", "cannot_connect": "Failed to connect, check the IP address of the camera", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "not_admin": "User needs to be admin, user \"{username}\" has authorisation level \"{userlevel}\"", - "password_incompatible": "Password contains incompatible special character, only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}", + "not_admin": "User needs to be admin, user \"{username}\" has authorization level \"{userlevel}\"", + "password_incompatible": "Password contains incompatible special character or is too long, maximum 31 characters and only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}. The streaming protocols necessitate these additional password restrictions.", "unknown": "[%key:common::config_flow::error::unknown%]", - "update_needed": "Failed to login because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed", + "update_needed": "Failed to log in because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed", "webhook_exception": "Home Assistant URL is not available, go to Settings > System > Network > Home Assistant URL and correct the URLs, see {more_info}" }, "abort": { @@ -64,7 +66,7 @@ "message": "Invalid input parameter: {err}" }, "api_error": { - "message": "The device responded with a error: {err}" + "message": "The device responded with an error: {err}" }, "invalid_content_type": { "message": "Received a different content type than expected: {err}" @@ -100,7 +102,13 @@ "message": "Error trying to update Reolink firmware: {err}" }, "config_entry_not_ready": { - "message": "Error while trying to setup {host}: {err}" + "message": "Error while trying to set up {host}: {err}" + }, + "update_already_running": { + "message": "Reolink firmware update already running, wait on completion before starting another" + }, + "firmware_rate_limit": { + "message": "Reolink firmware update server reached hourly rate limit: updating can be tried again in 1 hour" } }, "issues": { @@ -122,11 +130,15 @@ }, "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 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})." + "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running an 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", "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are deprecated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned." + }, + "password_too_long": { + "title": "Reolink password too long", + "description": "The password for \"{name}\" is more than 31 characters long, this is no longer compatible with the Reolink API. Please change the password using the Reolink app/client to a password with is shorter than 32 characters. After changing the password, fill in the new password in the Reolink Re-authentication flow to continue using this integration. The latest version of the Reolink app/client also has a password limit of 31 characters." } }, "services": { @@ -335,6 +347,83 @@ "off": "Awake", "on": "Sleeping" } + }, + "crossline_person": { + "name": "Crossline {zone_name} person", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "crossline_vehicle": { + "name": "Crossline {zone_name} vehicle", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "crossline_dog_cat": { + "name": "Crossline {zone_name} animal", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "intrusion_person": { + "name": "Intrusion {zone_name} person", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "intrusion_vehicle": { + "name": "Intrusion {zone_name} vehicle", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "intrusion_dog_cat": { + "name": "Intrusion {zone_name} animal", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "linger_person": { + "name": "Linger {zone_name} person", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "linger_vehicle": { + "name": "Linger {zone_name} vehicle", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "linger_dog_cat": { + "name": "Linger {zone_name} animal", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "forgotten_item": { + "name": "Item forgotten {zone_name}", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "taken_item": { + "name": "Item taken {zone_name}", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } } }, "button": { @@ -479,6 +568,21 @@ "ai_animal_sensitivity": { "name": "AI animal sensitivity" }, + "crossline_sensitivity": { + "name": "AI crossline {zone_name} sensitivity" + }, + "intrusion_sensitivity": { + "name": "AI intrusion {zone_name} sensitivity" + }, + "linger_sensitivity": { + "name": "AI linger {zone_name} sensitivity" + }, + "forgotten_item_sensitivity": { + "name": "AI item forgotten {zone_name} sensitivity" + }, + "taken_item_sensitivity": { + "name": "AI item taken {zone_name} sensitivity" + }, "ai_face_delay": { "name": "AI face delay" }, @@ -497,6 +601,18 @@ "ai_animal_delay": { "name": "AI animal delay" }, + "intrusion_delay": { + "name": "AI intrusion {zone_name} delay" + }, + "linger_delay": { + "name": "AI linger {zone_name} delay" + }, + "forgotten_item_delay": { + "name": "AI item forgotten {zone_name} delay" + }, + "taken_item_delay": { + "name": "AI item taken {zone_name} delay" + }, "auto_quick_reply_time": { "name": "Auto quick reply time" }, @@ -536,7 +652,7 @@ "name": "Floodlight mode", "state": { "off": "[%key:common::state::off%]", - "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]", + "auto": "[%key:common::state::auto%]", "onatnight": "On at night", "schedule": "Schedule", "adaptive": "Adaptive", @@ -546,7 +662,7 @@ "day_night_mode": { "name": "Day night mode", "state": { - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "color": "Color", "blackwhite": "Black & white" } @@ -575,7 +691,7 @@ "name": "Doorbell LED", "state": { "stayoff": "Stay off", - "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]", + "auto": "[%key:common::state::auto%]", "alwaysonatnight": "Auto & always on at night", "always": "Always on", "alwayson": "Always on" @@ -586,7 +702,7 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]" + "auto": "[%key:common::state::auto%]" } }, "binning_mode": { @@ -594,7 +710,7 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]" + "auto": "[%key:common::state::auto%]" } }, "hub_alarm_ringtone": { @@ -720,6 +836,18 @@ }, "sub_bit_rate": { "name": "Fluent bit rate" + }, + "scene_mode": { + "name": "Scene mode", + "state": { + "off": "[%key:common::state::off%]", + "disarm": "Disarmed", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]" + } + }, + "packing_time": { + "name": "Recording packing time" } }, "sensor": { @@ -746,6 +874,14 @@ "chargecomplete": "Charge complete" } }, + "day_night_state": { + "name": "Day night state", + "state": { + "day": "Color", + "night": "Black & white", + "led_day": "Color with floodlight" + } + }, "hdd_storage": { "name": "HDD {hdd_index} storage" }, @@ -760,7 +896,7 @@ }, "switch": { "ir_lights": { - "name": "Infra red lights in night mode" + "name": "Infrared lights in night mode" }, "record_audio": { "name": "Record audio" diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 0f106c0f2cc..af87a75eece 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -162,6 +162,7 @@ SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="manual_record", cmd_key="GetManualRec", + cmd_id=588, translation_key="manual_record", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "manual_record"), diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 0744d66fb5b..a7c883003b7 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -31,7 +31,7 @@ from .entity import ( ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) -from .util import ReolinkConfigEntry, ReolinkData +from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error PARALLEL_UPDATES = 0 RESUME_AFTER_INSTALL = 15 @@ -184,6 +184,7 @@ class ReolinkUpdateBaseEntity( f"## Release notes\n\n{new_firmware.release_notes}" ) + @raise_translated_error async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: @@ -196,6 +197,8 @@ class ReolinkUpdateBaseEntity( try: await self._host.api.update_firmware(self._channel) except ReolinkError as err: + if err.translation_key: + raise raise HomeAssistantError( translation_domain=DOMAIN, translation_key="firmware_install_error", diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index a5556b66a33..a80e9f8962c 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -27,6 +27,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.storage import Store +from homeassistant.helpers.translation import async_get_exception_message from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -75,14 +76,23 @@ def get_store(hass: HomeAssistant, config_entry_id: str) -> Store[str]: def get_device_uid_and_ch( - device: dr.DeviceEntry, host: ReolinkHost + device: dr.DeviceEntry | tuple[str, str], host: ReolinkHost ) -> tuple[list[str], int | None, bool]: """Get the channel and the split device_uid from a reolink DeviceEntry.""" - device_uid = [ - dev_id[1].split("_") for dev_id in device.identifiers if dev_id[0] == DOMAIN - ][0] - + device_uid = [] is_chime = False + + if isinstance(device, dr.DeviceEntry): + dev_ids = device.identifiers + else: + dev_ids = {device} + + for dev_id in dev_ids: + if dev_id[0] == DOMAIN: + device_uid = dev_id[1].split("_") + if device_uid[0] == host.unique_id: + break + if len(device_uid) < 2: # NVR itself ch = None @@ -97,6 +107,30 @@ def get_device_uid_and_ch( return (device_uid, ch, is_chime) +def check_translation_key(err: ReolinkError) -> str | None: + """Check if the translation key from the upstream library is present.""" + if not err.translation_key: + return None + if async_get_exception_message(DOMAIN, err.translation_key) == err.translation_key: + # translation key not found in strings.json + return None + return err.translation_key + + +_EXCEPTION_TO_TRANSLATION_KEY = { + ApiError: "api_error", + InvalidContentTypeError: "invalid_content_type", + CredentialsInvalidError: "invalid_credentials", + LoginError: "login_error", + NoDataError: "no_data", + UnexpectedDataError: "unexpected_data", + NotSupportedError: "not_supported", + SubscriptionError: "subscription_error", + ReolinkConnectionError: "connection_error", + ReolinkTimeoutError: "timeout", +} + + # Decorators def raise_translated_error[**P, R]( func: Callable[P, Awaitable[R]], @@ -110,73 +144,14 @@ def raise_translated_error[**P, R]( 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_key=check_translation_key(err) or "invalid_parameter", translation_placeholders={"err": str(err)}, ) from err except ReolinkError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="unexpected", + translation_key=check_translation_key(err) + or _EXCEPTION_TO_TRANSLATION_KEY.get(type(err), "unexpected"), translation_placeholders={"err": str(err)}, ) from err diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py index 1a4585bc997..44265244b18 100644 --- a/homeassistant/components/reolink/views.py +++ b/homeassistant/components/reolink/views.py @@ -83,7 +83,16 @@ class PlaybackProxyView(HomeAssistantView): _LOGGER.warning("Reolink playback proxy error: %s", str(err)) return web.Response(body=str(err), status=HTTPStatus.BAD_REQUEST) + headers = dict(request.headers) + headers.pop("Host", None) + headers.pop("Referer", None) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Requested Playback Proxy Method %s, Headers: %s", + request.method, + headers, + ) _LOGGER.debug( "Opening VOD stream from %s: %s", host.api.camera_name(ch), @@ -93,6 +102,7 @@ class PlaybackProxyView(HomeAssistantView): try: reolink_response = await self.session.get( reolink_url, + headers=headers, timeout=ClientTimeout( connect=15, sock_connect=15, sock_read=5, total=None ), @@ -118,18 +128,25 @@ class PlaybackProxyView(HomeAssistantView): ]: err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}" _LOGGER.error(err_str) + if reolink_response.content_type == "text/html": + text = await reolink_response.text() + _LOGGER.debug(text) return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) - response = web.StreamResponse( - status=200, - reason="OK", - headers={ - "Content-Type": "video/mp4", - }, + response_headers = dict(reolink_response.headers) + _LOGGER.debug( + "Response Playback Proxy Status %s:%s, Headers: %s", + reolink_response.status, + reolink_response.reason, + response_headers, ) + response_headers["Content-Type"] = "video/mp4" - if reolink_response.content_length is not None: - response.content_length = reolink_response.content_length + response = web.StreamResponse( + status=reolink_response.status, + reason=reolink_response.reason, + headers=response_headers, + ) await response.prepare(request) @@ -141,7 +158,8 @@ class PlaybackProxyView(HomeAssistantView): "Timeout while reading Reolink playback from %s, writing EOF", host.api.nvr_name, ) + finally: + reolink_response.release() - reolink_response.release() await response.write_eof() return response diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index 4875a8f6cfa..4117b0ee35b 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -14,7 +14,6 @@ from homeassistant.components import websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.decorators import require_admin from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import Unauthorized from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, @@ -114,7 +113,7 @@ class RepairsFlowIndexView(FlowManagerIndexView): url = "/api/repairs/issues/fix" name = "api:repairs:issues:fix" - @require_admin(error=Unauthorized(permission=POLICY_EDIT)) + @require_admin(permission=POLICY_EDIT) @RequestDataValidator( vol.Schema( { @@ -149,12 +148,12 @@ class RepairsFlowResourceView(FlowManagerResourceView): url = "/api/repairs/issues/fix/{flow_id}" name = "api:repairs:issues:fix:resource" - @require_admin(error=Unauthorized(permission=POLICY_EDIT)) + @require_admin(permission=POLICY_EDIT) async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) - @require_admin(error=Unauthorized(permission=POLICY_EDIT)) + @require_admin(permission=POLICY_EDIT) async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index fa5bd388009..2e73f1b1b82 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -32,6 +32,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -132,7 +133,7 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): config[CONF_FORCE_UPDATE], ) self._previous_data = None - self._value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + self._value_template: ValueTemplate | None = config.get(CONF_VALUE_TEMPLATE) @property def available(self) -> bool: @@ -156,11 +157,14 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): ) return - raw_value = response + variables = self._template_variables_with_value(response) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return if response is not None and self._value_template is not None: - response = self._value_template.async_render_with_possible_json_value( - response, False + response = self._value_template.async_render_as_value_template( + self.entity_id, variables, False ) try: @@ -173,5 +177,5 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): "yes": True, }.get(str(response).lower(), False) - self._process_manual_data(raw_value) + self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index 62ed2d5c5b2..bddad18586e 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -31,6 +31,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_ENTITY_BASE_SCHEMA, TEMPLATE_SENSOR_BASE_SCHEMA, + ValueTemplate, ) from homeassistant.util.ssl import SSLCipherList @@ -76,7 +77,9 @@ SENSOR_SCHEMA = { **TEMPLATE_SENSOR_BASE_SCHEMA.schema, vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_AVAILABILITY): cv.template, } @@ -84,7 +87,9 @@ SENSOR_SCHEMA = { BINARY_SENSOR_SCHEMA = { **TEMPLATE_ENTITY_BASE_SCHEMA.schema, vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_AVAILABILITY): cv.template, } diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index b95e6dd72b7..9df10197a1a 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -36,6 +36,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -138,7 +139,7 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): config.get(CONF_RESOURCE_TEMPLATE), config[CONF_FORCE_UPDATE], ) - self._value_template = config.get(CONF_VALUE_TEMPLATE) + self._value_template: ValueTemplate | None = config.get(CONF_VALUE_TEMPLATE) self._json_attrs = config.get(CONF_JSON_ATTRS) self._json_attrs_path = config.get(CONF_JSON_ATTRS_PATH) self._attr_extra_state_attributes = {} @@ -165,16 +166,19 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): ) value = self.rest.data + variables = self._template_variables_with_value(value) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._json_attrs: self._attr_extra_state_attributes = parse_json_attributes( value, self._json_attrs, self._json_attrs_path ) - raw_value = value - if value is not None and self._value_template is not None: - value = self._value_template.async_render_with_possible_json_value( - value, None + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) if value is None or self.device_class not in ( @@ -182,7 +186,7 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): SensorDeviceClass.TIMESTAMP, ): self._attr_native_value = value - self._process_manual_data(raw_value) + self._process_manual_data(variables) self.async_write_ha_state() return @@ -190,5 +194,5 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): value, self.entity_id, self.device_class ) - self._process_manual_data(raw_value) + self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index e4bb1f797d9..4f16503a2ea 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -38,6 +38,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_PICTURE, TEMPLATE_ENTITY_BASE_SCHEMA, ManualTriggerEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -73,7 +74,9 @@ PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( vol.Optional(CONF_PARAMS): {cv.string: cv.template}, vol.Optional(CONF_BODY_OFF, default=DEFAULT_BODY_OFF): cv.template, vol.Optional(CONF_BODY_ON, default=DEFAULT_BODY_ON): cv.template, - vol.Optional(CONF_IS_ON_TEMPLATE): cv.template, + vol.Optional(CONF_IS_ON_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.All( vol.Lower, vol.In(SUPPORT_REST_METHODS) ), @@ -107,7 +110,7 @@ async def async_setup_platform( try: switch = RestSwitch(hass, config, trigger_entity_config) - req = await switch.get_device_state(hass) + req = await switch.get_response(hass) if req.status_code >= HTTPStatus.BAD_REQUEST: _LOGGER.error("Got non-ok response from resource: %s", req.status_code) else: @@ -147,7 +150,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): self._auth = auth self._body_on: template.Template = config[CONF_BODY_ON] self._body_off: template.Template = config[CONF_BODY_OFF] - self._is_on_template: template.Template | None = config.get(CONF_IS_ON_TEMPLATE) + self._is_on_template: ValueTemplate | None = config.get(CONF_IS_ON_TEMPLATE) self._timeout: int = config[CONF_TIMEOUT] self._verify_ssl: bool = config[CONF_VERIFY_SSL] @@ -208,35 +211,41 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): """Get the current state, catching errors.""" req = None try: - req = await self.get_device_state(self.hass) + req = await self.get_response(self.hass) except (TimeoutError, httpx.TimeoutException): _LOGGER.exception("Timed out while fetching data") except httpx.RequestError: _LOGGER.exception("Error while fetching data") if req: - self._process_manual_data(req.text) - self.async_write_ha_state() + self._async_update(req.text) - async def get_device_state(self, hass: HomeAssistant) -> httpx.Response: + async def get_response(self, hass: HomeAssistant) -> httpx.Response: """Get the latest data from REST API and update the state.""" websession = get_async_client(hass, self._verify_ssl) rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - req = await websession.get( + return await websession.get( self._state_resource, auth=self._auth, headers=rendered_headers, params=rendered_params, timeout=self._timeout, ) - text = req.text + + def _async_update(self, text: str) -> None: + """Get the latest data from REST API and update the state.""" + + variables = self._template_variables_with_value(text) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return if self._is_on_template is not None: - text = self._is_on_template.async_render_with_possible_json_value( - text, "None" + text = self._is_on_template.async_render_as_value_template( + self.entity_id, variables, "None" ) text = text.lower() if text == "true": @@ -252,4 +261,5 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): else: self._attr_is_on = None - return req + self._process_manual_data(variables) + self.async_write_ha_state() diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 85195fb1581..d83a242ac71 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -16,8 +16,16 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, + EVENT_LOGGING_CHANGED, +) +from homeassistant.core import ( + CoreState, + Event, + HassJob, + HomeAssistant, + ServiceCall, + callback, ) -from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -41,6 +49,7 @@ from .entity import RflinkCommand from .utils import identify_event_type _LOGGER = logging.getLogger(__name__) +LIB_LOGGER = logging.getLogger("rflink") CONF_IGNORE_DEVICES = "ignore_devices" CONF_RECONNECT_INTERVAL = "reconnect_interval" @@ -277,4 +286,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.async_create_task(connect(), eager_start=False) async_dispatcher_connect(hass, SIGNAL_EVENT, event_callback) + + async def handle_logging_changed(_: Event) -> None: + """Handle logging changed event.""" + if LIB_LOGGER.isEnabledFor(logging.DEBUG): + await RflinkCommand.send_command("rfdebug", "on") + _LOGGER.info("RFDEBUG enabled") + else: + await RflinkCommand.send_command("rfdebug", "off") + _LOGGER.info("RFDEBUG disabled") + + # Listen to EVENT_LOGGING_CHANGED to manage the RFDEBUG + hass.bus.async_listen(EVENT_LOGGING_CHANGED, handle_logging_changed) + return True diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 027c39da70f..97d0b811509 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -236,7 +236,8 @@ SENSOR_TYPES = ( key="winddirection", name="Wind direction", icon="mdi:compass", - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, ), SensorEntityDescription( diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 4b256279445..6669b1367df 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -161,7 +161,8 @@ SENSOR_TYPES = ( RfxtrxSensorEntityDescription( key="Wind direction", translation_key="wind_direction", - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, ), RfxtrxSensorEntityDescription( diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index db4efad5bb4..d3b65dc238a 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -48,7 +48,7 @@ "event_code": "Enter event code to add", "device": "Select device to configure" }, - "title": "Rfxtrx Options" + "title": "RFXtrx options" }, "set_device_options": { "data": { @@ -105,15 +105,15 @@ "sound_15": "Sound 15", "down": "Down", "up": "Up", - "all_off": "All Off", - "all_on": "All On", + "all_off": "All off", + "all_on": "All on", "scene": "Scene", - "off": "Off", - "on": "On", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", "dim": "Dim", "bright": "Bright", - "all_group_off": "All/group Off", - "all_group_on": "All/group On", + "all_group_off": "All/group off", + "all_group_on": "All/group on", "chime": "Chime", "illegal_command": "Illegal command", "set_level": "Set level", @@ -131,40 +131,40 @@ "level_9": "Level 9", "program": "Program", "stop": "Stop", - "0_5_seconds_up": "0.5 Seconds Up", - "0_5_seconds_down": "0.5 Seconds Down", - "2_seconds_up": "2 Seconds Up", - "2_seconds_down": "2 Seconds Down", + "0_5_seconds_up": "0.5 seconds up", + "0_5_seconds_down": "0.5 seconds down", + "2_seconds_up": "2 seconds up", + "2_seconds_down": "2 seconds down", "enable_sun_automation": "Enable sun automation", "disable_sun_automation": "Disable sun automation", - "normal": "Normal", - "normal_delayed": "Normal Delayed", + "normal": "[%key:common::state::normal%]", + "normal_delayed": "Normal delayed", "alarm": "Alarm", - "alarm_delayed": "Alarm Delayed", + "alarm_delayed": "Alarm delayed", "motion": "Motion", - "no_motion": "No Motion", + "no_motion": "No motion", "panic": "Panic", - "end_panic": "End Panic", + "end_panic": "End panic", "ir": "IR", - "arm_away": "Arm Away", - "arm_away_delayed": "Arm Away Delayed", - "arm_home": "Arm Home", - "arm_home_delayed": "Arm Home Delayed", + "arm_away": "Arm away", + "arm_away_delayed": "Arm away delayed", + "arm_home": "Arm home", + "arm_home_delayed": "Arm home delayed", "disarm": "Disarm", - "light_1_off": "Light 1 Off", - "light_1_on": "Light 1 On", - "light_2_off": "Light 2 Off", - "light_2_on": "Light 2 On", - "dark_detected": "Dark Detected", - "light_detected": "Light Detected", + "light_1_off": "Light 1 off", + "light_1_on": "Light 1 on", + "light_2_off": "Light 2 off", + "light_2_on": "Light 2 on", + "dark_detected": "Dark detected", + "light_detected": "Light detected", "battery_low": "Battery low", "pairing_kd101": "Pairing KD101", - "normal_tamper": "Normal Tamper", - "normal_delayed_tamper": "Normal Delayed Tamper", - "alarm_tamper": "Alarm Tamper", - "alarm_delayed_tamper": "Alarm Delayed Tamper", - "motion_tamper": "Motion Tamper", - "no_motion_tamper": "No Motion Tamper" + "normal_tamper": "Normal tamper", + "normal_delayed_tamper": "Normal delayed tamper", + "alarm_tamper": "Alarm tamper", + "alarm_delayed_tamper": "Alarm delayed tamper", + "motion_tamper": "Motion tamper", + "no_motion_tamper": "No motion tamper" } } } diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 2d7e0b17da1..d1a3deafa71 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Sign-in with Ring account", + "title": "Sign in with Ring account", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/risco/const.py b/homeassistant/components/risco/const.py index 078e26c43b5..ef3280fe232 100644 --- a/homeassistant/components/risco/const.py +++ b/homeassistant/components/risco/const.py @@ -30,9 +30,9 @@ RISCO_ARM = "arm" RISCO_PARTIAL_ARM = "partial_arm" RISCO_STATES = [RISCO_ARM, RISCO_PARTIAL_ARM, *RISCO_GROUPS] -DEFAULT_RISCO_GROUPS_TO_HA = { - group: AlarmControlPanelState.ARMED_HOME for group in RISCO_GROUPS -} +DEFAULT_RISCO_GROUPS_TO_HA = dict.fromkeys( + RISCO_GROUPS, AlarmControlPanelState.ARMED_HOME +) DEFAULT_RISCO_STATES_TO_HA = { RISCO_ARM: AlarmControlPanelState.ARMED_AWAY, RISCO_PARTIAL_ARM: AlarmControlPanelState.ARMED_HOME, diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index c3217d9334e..92f4f5a0434 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -156,7 +156,7 @@ class RMVDepartureSensor(SensorEntity): return self._name @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._state is not None diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1c25d527aa8..6697779adf6 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -23,6 +23,8 @@ from roborock.web_api import RoborockApiClient from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .coordinator import ( @@ -44,10 +46,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> entry.async_on_unload(entry.add_update_listener(update_listener)) user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) - api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL]) + api_client = RoborockApiClient( + entry.data[CONF_USERNAME], + entry.data[CONF_BASE_URL], + session=async_get_clientsession(hass), + ) _LOGGER.debug("Getting home data") try: - home_data = await api_client.get_home_data_v2(user_data) + home_data = await api_client.get_home_data_v3(user_data) except RoborockInvalidCredentials as err: raise ConfigEntryAuthFailed( "Invalid credentials", @@ -65,6 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_key="no_user_agreement", ) from err except RoborockException as err: + _LOGGER.debug("Failed to get Roborock home data: %s", err) raise ConfigEntryNotReady( "Failed to get Roborock home data", translation_domain=DOMAIN, @@ -110,6 +117,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_key="no_coordinators", ) valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) + await asyncio.gather( + *(coord.refresh_coordinator_map() for coord in valid_coordinators.v1) + ) async def on_stop(_: Any) -> None: _LOGGER.debug("Shutting down roborock") @@ -130,6 +140,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=entry.entry_id + ) + for device in device_entries: + # Remove any devices that are no longer in the account. + # The API returns all devices, even if they are offline + device_duids = { + identifier[1].replace("_dock", "") for identifier in device.identifiers + } + if any(device_duid in device_map for device_duid in device_duids): + continue + _LOGGER.info( + "Removing device: %s because it is no longer exists in your account", + device.name, + ) + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=entry.entry_id, + ) + + return True + + +async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: + """Migrate old configuration entries to the new format.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + entry.version, + entry.minor_version, + ) + if entry.version > 1: + # Downgrade from future version + return False + + # 1->2: Migrate from unique id as email address to unique id as rruid + if entry.minor_version == 1: + user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) + _LOGGER.debug("Updating unique id to %s", user_data.rruid) + hass.config_entries.async_update_entry( + entry, + unique_id=user_data.rruid, + version=1, + minor_version=2, + ) + return True diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index db557f055dc..a2c34f5c59d 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from roborock.containers import RoborockStateCode from roborock.roborock_typing import DeviceProp from homeassistant.components.binary_sensor import ( @@ -12,19 +13,23 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockBinarySensorDescription(BinarySensorEntityDescription): """A class that describes Roborock binary sensors.""" value_fn: Callable[[DeviceProp], bool | int | None] + # If it is a dock entity + is_dock_entity: bool = False BINARY_SENSOR_DESCRIPTIONS = [ @@ -34,6 +39,7 @@ BINARY_SENSOR_DESCRIPTIONS = [ device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.dry_status, + is_dock_entity=True, ), RoborockBinarySensorDescription( key="water_box_carriage_status", @@ -63,6 +69,13 @@ BINARY_SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.in_cleaning, ), + RoborockBinarySensorDescription( + key=ATTR_BATTERY_CHARGING, + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.state + in (RoborockStateCode.charging, RoborockStateCode.charging_complete), + ), ] @@ -97,6 +110,7 @@ class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity super().__init__( f"{description.key}_{coordinator.duid_slug}", coordinator, + is_dock_entity=description.is_dock_entity, ) self.entity_description = description diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 33e9502aca1..fea38524fe0 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -2,7 +2,10 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass +import itertools +from typing import Any from roborock.roborock_typing import RoborockCommand @@ -12,7 +15,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator -from .entity import RoborockEntityV1 +from .entity import RoborockEntity, RoborockEntityV1 + +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -65,14 +70,34 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock button platform.""" + routines_lists = await asyncio.gather( + *[coordinator.get_routines() for coordinator in config_entry.runtime_data.v1], + ) async_add_entities( - RoborockButtonEntity( - coordinator, - description, + itertools.chain( + ( + RoborockButtonEntity( + coordinator, + description, + ) + for coordinator in config_entry.runtime_data.v1 + for description in CONSUMABLE_BUTTON_DESCRIPTIONS + if isinstance(coordinator, RoborockDataUpdateCoordinator) + ), + ( + RoborockRoutineButtonEntity( + coordinator, + ButtonEntityDescription( + key=str(routine.id), + name=routine.name, + ), + ) + for coordinator, routines in zip( + config_entry.runtime_data.v1, routines_lists, strict=True + ) + for routine in routines + ), ) - for coordinator in config_entry.runtime_data.v1 - for description in CONSUMABLE_BUTTON_DESCRIPTIONS - if isinstance(coordinator, RoborockDataUpdateCoordinator) ) @@ -97,3 +122,28 @@ class RoborockButtonEntity(RoborockEntityV1, ButtonEntity): async def async_press(self) -> None: """Press the button.""" await self.send(self.entity_description.command, self.entity_description.param) + + +class RoborockRoutineButtonEntity(RoborockEntity, ButtonEntity): + """A class to define Roborock routines button entities.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinator, + entity_description: ButtonEntityDescription, + ) -> None: + """Create a button entity.""" + super().__init__( + f"{entity_description.key}_{coordinator.duid_slug}", + coordinator.device_info, + coordinator.api, + ) + self._routine_id = int(entity_description.key) + self._coordinator = coordinator + self.entity_description = entity_description + + async def async_press(self, **kwargs: Any) -> None: + """Press the button.""" + await self._coordinator.execute_routines(self._routine_id) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 1a6b67286bb..62943e0dcc9 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -21,14 +21,17 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) from homeassistant.const import CONF_USERNAME 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 . import RoborockConfigEntry from .const import ( CONF_BASE_URL, CONF_ENTRY_CODE, @@ -45,6 +48,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Roborock.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" @@ -59,11 +63,11 @@ 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(error="already_configured_account") self._username = username _LOGGER.debug("Requesting code for Roborock account") - self._client = RoborockApiClient(username) + self._client = RoborockApiClient( + username, session=async_get_clientsession(self.hass) + ) errors = await self._request_code() if not errors: return await self.async_step_code() @@ -106,7 +110,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): code = user_input[CONF_ENTRY_CODE] _LOGGER.debug("Logging into Roborock account using email provided code") try: - login_data = await self._client.code_login(code) + user_data = await self._client.code_login(code) except RoborockInvalidCode: errors["base"] = "invalid_code" except RoborockException: @@ -116,17 +120,20 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + await self.async_set_unique_id(user_data.rruid) if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") reauth_entry = self._get_reauth_entry() self.hass.config_entries.async_update_entry( reauth_entry, data={ **reauth_entry.data, - CONF_USER_DATA: login_data.as_dict(), + CONF_USER_DATA: user_data.as_dict(), }, ) return self.async_abort(reason="reauth_successful") - return self._create_entry(self._client, self._username, login_data) + self._abort_if_unique_id_configured(error="already_configured_account") + return self._create_entry(self._client, self._username, user_data) return self.async_show_form( step_id="code", @@ -134,13 +141,32 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow started by a dhcp discovery.""" + await self._async_handle_discovery_without_unique_id() + device_registry = dr.async_get(self.hass) + device = device_registry.async_get_device( + connections={ + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(discovery_info.macaddress)) + } + ) + if device is not None and any( + identifier[0] == DOMAIN for identifier in device.identifiers + ): + return self.async_abort(reason="already_configured") + return await self.async_step_user() + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Perform reauth upon an API authentication error.""" self._username = entry_data[CONF_USERNAME] assert self._username - self._client = RoborockApiClient(self._username) + self._client = RoborockApiClient( + self._username, session=async_get_clientsession(self.hass) + ) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -170,7 +196,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RoborockConfigEntry, ) -> RoborockOptionsFlowHandler: """Create the options flow.""" return RoborockOptionsFlowHandler(config_entry) @@ -179,7 +205,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): class RoborockOptionsFlowHandler(OptionsFlow): """Handle an option flow for Roborock.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: RoborockConfigEntry) -> None: """Initialize options flow.""" self.options = deepcopy(dict(config_entry.options)) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index fe9091a3ea7..e56fade7078 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -1,5 +1,7 @@ """Constants for Roborock.""" +from datetime import timedelta + from vacuum_map_parser_base.config.drawable import Drawable from homeassistant.const import Platform @@ -36,7 +38,6 @@ PLATFORMS = [ Platform.BUTTON, Platform.IMAGE, Platform.NUMBER, - Platform.SCENE, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, @@ -44,13 +45,21 @@ PLATFORMS = [ Platform.VACUUM, ] - -IMAGE_CACHE_INTERVAL = 90 +# This can be lowered in the future if we do not receive rate limiting issues. +IMAGE_CACHE_INTERVAL = timedelta(seconds=30) MAP_SLEEP = 3 GET_MAPS_SERVICE_NAME = "get_maps" +MAP_SCALE = 4 MAP_FILE_FORMAT = "PNG" MAP_FILENAME_SUFFIX = ".png" SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position" + + +A01_UPDATE_INTERVAL = timedelta(minutes=1) +V1_CLOUD_IN_CLEANING_INTERVAL = timedelta(seconds=30) +V1_CLOUD_NOT_CLEANING_INTERVAL = timedelta(minutes=1) +V1_LOCAL_IN_CLEANING_INTERVAL = timedelta(seconds=15) +V1_LOCAL_NOT_CLEANING_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index b35f62323e8..dc0677b25d2 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from dataclasses import dataclass from datetime import timedelta +import io import logging from propcache.api import cached_property @@ -25,6 +26,11 @@ from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClient from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 from roborock.web_api import RoborockApiClient +from vacuum_map_parser_base.config.color import ColorsPalette +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.config.size import Size, Sizes +from vacuum_map_parser_base.map_data import MapData +from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS @@ -34,9 +40,22 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import slugify +from homeassistant.util import dt as dt_util, slugify -from .const import DOMAIN +from .const import ( + A01_UPDATE_INTERVAL, + DEFAULT_DRAWABLES, + DOMAIN, + DRAWABLES, + IMAGE_CACHE_INTERVAL, + MAP_FILE_FORMAT, + MAP_SCALE, + MAP_SLEEP, + V1_CLOUD_IN_CLEANING_INTERVAL, + V1_CLOUD_NOT_CLEANING_INTERVAL, + V1_LOCAL_IN_CLEANING_INTERVAL, + V1_LOCAL_NOT_CLEANING_INTERVAL, +) from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo from .roborock_storage import RoborockMapStorage @@ -85,7 +104,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=SCAN_INTERVAL, + # Assume we can use the local api. + update_interval=V1_LOCAL_NOT_CLEANING_INTERVAL, ) self.roborock_device_info = RoborockHassDeviceInfo( device, @@ -109,7 +129,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.current_map: int | None = None if mac := self.roborock_device_info.network_info.mac: - self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)} + self.device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)) + } # Maps from map flag to map name self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} @@ -118,6 +140,58 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ) self._user_data = user_data self._api_client = api_client + self._is_cloud_api = False + drawables = [ + drawable + for drawable, default_value in DEFAULT_DRAWABLES.items() + if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) + ] + self.map_parser = RoborockMapDataParser( + ColorsPalette(), + Sizes( + { + k: v * MAP_SCALE + for k, v in Sizes.SIZES.items() + if k != Size.MOP_PATH_WIDTH + } + ), + drawables, + ImageConfig(scale=MAP_SCALE), + [], + ) + self.last_update_state: str | None = None + + @cached_property + def dock_device_info(self) -> DeviceInfo: + """Gets the device info for the dock. + + This must happen after the coordinator does the first update. + Which will be the case when this is called. + """ + dock_type = self.roborock_device_info.props.status.dock_type + return DeviceInfo( + name=f"{self.roborock_device_info.device.name} Dock", + identifiers={(DOMAIN, f"{self.duid}_dock")}, + manufacturer="Roborock", + model=f"{self.roborock_device_info.product.model} Dock", + model_id=str(dock_type.value) if dock_type is not None else "Unknown", + sw_version=self.roborock_device_info.device.fv, + ) + + def parse_map_data_v1( + self, map_bytes: bytes + ) -> tuple[bytes | None, MapData | None]: + """Parse map_bytes and return MapData and the image.""" + try: + parsed_map = self.map_parser.parse(map_bytes) + except (IndexError, ValueError) as err: + _LOGGER.debug("Exception when parsing map contents: %s", err) + return None, None + if parsed_map.image is None: + return None, None + img_byte_arr = io.BytesIO() + parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) + return img_byte_arr.getvalue(), parsed_map async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -128,17 +202,68 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): try: maps = await self.api.get_multi_maps_list() except RoborockException as err: - raise UpdateFailed("Failed to get map data: {err}") from err + _LOGGER.debug("Failed to get maps: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="map_failure", + translation_placeholders={"error": str(err)}, + ) from err # Rooms names populated later with calls to `set_current_map_rooms` for each map + roborock_maps = maps.map_info if (maps and maps.map_info) else () + stored_images = await asyncio.gather( + *[ + self.map_storage.async_load_map(roborock_map.mapFlag) + for roborock_map in roborock_maps + ] + ) self.maps = { roborock_map.mapFlag: RoborockMapInfo( flag=roborock_map.mapFlag, name=roborock_map.name or f"Map {roborock_map.mapFlag}", rooms={}, + image=image, + last_updated=dt_util.utcnow() - IMAGE_CACHE_INTERVAL, + map_data=None, ) - for roborock_map in (maps.map_info if (maps and maps.map_info) else ()) + for image, roborock_map in zip(stored_images, roborock_maps, strict=False) } + async def update_map(self) -> None: + """Update the currently selected map.""" + # The current map was set in the props update, so these can be done without + # worry of applying them to the wrong map. + if self.current_map is None or self.current_map not in self.maps: + # This exists as a safeguard/ to keep mypy happy. + return + try: + response = await self.cloud_api.get_map_v1() + except RoborockException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) from ex + if not isinstance(response, bytes): + _LOGGER.debug("Failed to parse map contents: %s", response) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) + parsed_image, parsed_map = self.parse_map_data_v1(response) + if parsed_image is None or parsed_map is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) + current_roborock_map_info = self.maps[self.current_map] + if parsed_image != self.maps[self.current_map].image: + await self.map_storage.async_save_map( + self.current_map, + parsed_image, + ) + current_roborock_map_info.image = parsed_image + current_roborock_map_info.last_updated = dt_util.utcnow() + current_roborock_map_info.map_data = parsed_map + async def _verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" if isinstance(self.api, RoborockLocalClientV1): @@ -152,6 +277,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): await self.api.async_disconnect() # We use the cloud api if the local api fails to connect. self.api = self.cloud_api + self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL + self._is_cloud_api = True # Right now this should never be called if the cloud api is the primary api, # but in the future if it is, a new else should be added. @@ -177,9 +304,43 @@ 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. + + # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL + # since the last map update, you can update the map. + new_status = self.roborock_device_info.props.status + if ( + self.current_map is not None + and (current_map := self.maps.get(self.current_map)) + and ( + ( + new_status.in_cleaning + and (dt_util.utcnow() - current_map.last_updated) + > IMAGE_CACHE_INTERVAL + ) + or self.last_update_state != new_status.state_name + ) + ): + try: + await self.update_map() + except HomeAssistantError as err: + _LOGGER.debug("Failed to update map: %s", err) await self.set_current_map_rooms() except RoborockException as ex: - raise UpdateFailed(ex) from ex + _LOGGER.debug("Failed to update data: %s", ex) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) from ex + if self.roborock_device_info.props.status.in_cleaning: + if self._is_cloud_api: + self.update_interval = V1_CLOUD_IN_CLEANING_INTERVAL + else: + self.update_interval = V1_LOCAL_IN_CLEANING_INTERVAL + elif self._is_cloud_api: + self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL + else: + self.update_interval = V1_LOCAL_NOT_CLEANING_INTERVAL + self.last_update_state = self.roborock_device_info.props.status.state_name return self.roborock_device_info.props def _set_current_map(self) -> None: @@ -206,12 +367,12 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): for room in room_mapping or () } - async def get_scenes(self) -> list[HomeDataScene]: - """Get scenes.""" + async def get_routines(self) -> list[HomeDataScene]: + """Get routines.""" try: return await self._api_client.get_scenes(self._user_data, self.duid) except RoborockException as err: - _LOGGER.error("Failed to get scenes %s", err) + _LOGGER.error("Failed to get routines %s", err) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed", @@ -220,12 +381,12 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): }, ) from err - async def execute_scene(self, scene_id: int) -> None: - """Execute scene.""" + async def execute_routines(self, routine_id: int) -> None: + """Execute routines.""" try: - await self._api_client.execute_scene(self._user_data, scene_id) + await self._api_client.execute_scene(self._user_data, routine_id) except RoborockException as err: - _LOGGER.error("Failed to execute scene %s %s", scene_id, err) + _LOGGER.error("Failed to execute routines %s %s", routine_id, err) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed", @@ -244,6 +405,43 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Get the slug of the duid.""" return slugify(self.duid) + async def refresh_coordinator_map(self) -> None: + """Get the starting map information for all maps for this device. + + The following steps must be done synchronously. + Only one map can be loaded at a time per device. + """ + cur_map = self.current_map + # This won't be None at this point as the coordinator will have run first. + if cur_map is None: + # If we don't have a cur map(shouldn't happen) just + # return as we can't do anything. + return + map_flags = sorted(self.maps, key=lambda data: data == cur_map, reverse=True) + for map_flag in map_flags: + if map_flag != cur_map: + # Only change the map and sleep if we have multiple maps. + await self.api.load_multi_map(map_flag) + self.current_map = map_flag + # We cannot get the map until the roborock servers fully process the + # map change. + await asyncio.sleep(MAP_SLEEP) + tasks = [self.set_current_map_rooms()] + # The image is set within async_setup, so if it exists, we have it here. + if self.maps[map_flag].image is None: + # If we don't have a cached map, let's update it here so that it can be + # cached in the future. + tasks.append(self.update_map()) + # If either of these fail, we don't care, and we want to continue. + await asyncio.gather(*tasks, return_exceptions=True) + + if len(self.maps) != 1: + # Set the map back to the map the user previously had selected so that it + # does not change the end user's app. + # Only needs to happen when we changed maps above. + await self.api.load_multi_map(cur_map) + self.current_map = cur_map + class RoborockDataUpdateCoordinatorA01( DataUpdateCoordinator[ @@ -268,7 +466,7 @@ class RoborockDataUpdateCoordinatorA01( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=SCAN_INTERVAL, + update_interval=A01_UPDATE_INTERVAL, ) self.api = api self.device_info = DeviceInfo( diff --git a/homeassistant/components/roborock/entity.py b/homeassistant/components/roborock/entity.py index 4a16ada5967..404f239c93a 100644 --- a/homeassistant/components/roborock/entity.py +++ b/homeassistant/components/roborock/entity.py @@ -8,7 +8,11 @@ from roborock.containers import Consumable, Status from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand -from roborock.version_1_apis.roborock_client_v1 import AttributeCache, RoborockClientV1 +from roborock.version_1_apis.roborock_client_v1 import ( + CLOUD_REQUIRED, + AttributeCache, + RoborockClientV1, +) from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 @@ -53,14 +57,16 @@ class RoborockEntityV1(RoborockEntity): """Get an item from the api cache.""" return self._api.cache[attribute] - async def send( - self, + @classmethod + async def _send_command( + cls, command: RoborockCommand | str, + api: RoborockClientV1, params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: - """Send a command to a vacuum cleaner.""" + """Send a Roborock command with params to a given api.""" try: - response: dict = await self._api.send_command(command, params) + response: dict = await api.send_command(command, params) except RoborockException as err: if isinstance(command, RoborockCommand): command_name = command.name @@ -75,6 +81,14 @@ class RoborockEntityV1(RoborockEntity): ) from err return response + async def send( + self, + command: RoborockCommand | str, + params: dict[str, Any] | list[Any] | int | None = None, + ) -> dict: + """Send a command to a vacuum cleaner.""" + return await self._send_command(command, self._api, params) + @property def api(self) -> RoborockClientV1: """Returns the api.""" @@ -107,12 +121,15 @@ class RoborockCoordinatedEntityV1( listener_request: list[RoborockDataProtocol] | RoborockDataProtocol | None = None, + is_dock_entity: bool = False, ) -> None: """Initialize the coordinated Roborock Device.""" RoborockEntityV1.__init__( self, unique_id=unique_id, - device_info=coordinator.device_info, + device_info=coordinator.device_info + if not is_dock_entity + else coordinator.dock_device_info, api=coordinator.api, ) CoordinatorEntity.__init__(self, coordinator=coordinator) @@ -152,7 +169,10 @@ class RoborockCoordinatedEntityV1( params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Overloads normal send command but refreshes coordinator.""" - res = await super().send(command, params) + if command in CLOUD_REQUIRED: + res = await self._send_command(command, self.coordinator.cloud_api, params) + else: + res = await self._send_command(command, self._api, params) await self.coordinator.async_refresh() return res diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 6d9e87b0556..d1c19331ba4 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -1,35 +1,21 @@ """Support for Roborock image.""" -import asyncio -from collections.abc import Callable from datetime import datetime -import io - -from roborock import RoborockCommand -from vacuum_map_parser_base.config.color import ColorsPalette -from vacuum_map_parser_base.config.image_config import ImageConfig -from vacuum_map_parser_base.config.size import Sizes -from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser +import logging from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import dt as dt_util -from .const import ( - DEFAULT_DRAWABLES, - DOMAIN, - DRAWABLES, - IMAGE_CACHE_INTERVAL, - MAP_FILE_FORMAT, - MAP_SLEEP, -) from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -38,26 +24,6 @@ async def async_setup_entry( ) -> None: """Set up Roborock image platform.""" - drawables = [ - drawable - for drawable, default_value in DEFAULT_DRAWABLES.items() - if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) - ] - parser = RoborockMapDataParser( - ColorsPalette(), Sizes(), drawables, ImageConfig(), [] - ) - - def parse_image(map_bytes: bytes) -> bytes | None: - parsed_map = parser.parse(map_bytes) - if parsed_map.image is None: - return None - img_byte_arr = io.BytesIO() - parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) - return img_byte_arr.getvalue() - - await asyncio.gather( - *(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1) - ) async_add_entities( ( RoborockMap( @@ -66,7 +32,6 @@ async def async_setup_entry( coord, map_info.flag, map_info.name, - parse_image, ) for coord in config_entry.runtime_data.v1 for map_info in coord.maps.values() @@ -88,14 +53,12 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): coordinator: RoborockDataUpdateCoordinator, map_flag: int, map_name: str, - parser: Callable[[bytes], bytes | None], ) -> None: """Initialize a Roborock map.""" RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator) ImageEntity.__init__(self, coordinator.hass) self.config_entry = config_entry self._attr_name = map_name - self.parser = parser self.map_flag = map_flag self.cached_map = b"" self._attr_entity_category = EntityCategory.DIAGNOSTIC @@ -105,89 +68,22 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): """Return if this map is the currently selected map.""" return self.map_flag == self.coordinator.current_map - def is_map_valid(self) -> bool: - """Update the map if it is valid. - - Update this map if it is the currently active map, and the - vacuum is cleaning, or if it has never been set at all. - """ - return self.cached_map == b"" or ( - self.is_selected - and self.image_last_updated is not None - and self.coordinator.roborock_device_info.props.status is not None - and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) - ) - async def async_added_to_hass(self) -> None: """When entity is added to hass load any previously cached maps from disk.""" await super().async_added_to_hass() - content = await self.coordinator.map_storage.async_load_map(self.map_flag) - self.cached_map = content or b"" - self._attr_image_last_updated = dt_util.utcnow() + self._attr_image_last_updated = self.coordinator.maps[ + self.map_flag + ].last_updated self.async_write_ha_state() def _handle_coordinator_update(self) -> None: - # Bump last updated every third time the coordinator runs, so that async_image - # will be called and we will evaluate on the new coordinator data if we should - # update the cache. - if ( - dt_util.utcnow() - self.image_last_updated - ).total_seconds() > IMAGE_CACHE_INTERVAL and self.is_map_valid(): - self._attr_image_last_updated = dt_util.utcnow() + # If the coordinator has updated the map, we can update the image. + self._attr_image_last_updated = self.coordinator.maps[ + self.map_flag + ].last_updated + super()._handle_coordinator_update() async def async_image(self) -> bytes | None: - """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.set_current_map_rooms(), - ), - return_exceptions=True, - ) - if ( - not isinstance(response[0], bytes) - or (content := self.parser(response[0])) is None - ): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="map_failure", - ) - if self.cached_map != content: - self.cached_map = content - await self.coordinator.map_storage.async_save_map( - self.map_flag, - content, - ) - return self.cached_map - - -async def refresh_coordinators( - hass: HomeAssistant, coord: RoborockDataUpdateCoordinator -) -> None: - """Get the starting map information for all maps for this device. - - The following steps must be done synchronously. - Only one map can be loaded at a time per device. - """ - cur_map = coord.current_map - # This won't be None at this point as the coordinator will have run first. - assert cur_map is not None - map_flags = sorted(coord.maps, key=lambda data: data == cur_map, reverse=True) - for map_flag in map_flags: - if map_flag != cur_map: - # Only change the map and sleep if we have multiple maps. - await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag]) - coord.current_map = map_flag - # We cannot get the map until the roborock servers fully process the - # map change. - await asyncio.sleep(MAP_SLEEP) - await coord.set_current_map_rooms() - - if len(coord.maps) != 1: - # Set the map back to the map the user previously had selected so that it - # does not change the end user's app. - # Only needs to happen when we changed maps above. - await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map]) - coord.current_map = cur_map + """Get the cached image.""" + return self.coordinator.maps[self.map_flag].image diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index db2654d4baa..444232b5843 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -3,11 +3,23 @@ "name": "Roborock", "codeowners": ["@Lash-L", "@allenporter"], "config_flow": true, + "dhcp": [ + { + "macaddress": "249E7D*" + }, + { + "macaddress": "B04A39*" + }, + { + "hostname": "roborock-*" + } + ], "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], + "quality_scale": "silver", "requirements": [ - "python-roborock==2.11.1", - "vacuum-map-parser-roborock==0.1.2" + "python-roborock==2.18.2", + "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index 4b8ab43b4a1..ab40f23d574 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -1,10 +1,12 @@ """Roborock Models.""" from dataclasses import dataclass +from datetime import datetime from typing import Any from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.roborock_typing import DeviceProp +from vacuum_map_parser_base.map_data import MapData @dataclass @@ -48,3 +50,13 @@ class RoborockMapInfo: flag: int name: str rooms: dict[int, str] + image: bytes | None + last_updated: datetime + map_data: MapData | None + + @property + def current_room(self) -> str | None: + """Get the currently active room for this map if any.""" + if self.map_data is None or self.map_data.vacuum_room is None: + return None + return self.rooms.get(self.map_data.vacuum_room) diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index a710eeefb90..73ac14fca71 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -22,6 +22,8 @@ from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockNumberDescription(NumberEntityDescription): diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml new file mode 100644 index 00000000000..32ddb145f90 --- /dev/null +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: 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: + status: done + comment: The config flow verifies credentials and the cloud APIs. + 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: done + # Gold + devices: done + diagnostics: done + discovery: done + discovery-update-info: + status: exempt + comment: Devices do not support discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: + status: todo + comment: Documentation does not describe known limitations like rate limiting + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: + status: todo + comment: | + There are good troubleshooting steps, however we should update the "cloud vs local" + and rate limiting documentation with more information. + docs-use-cases: + status: todo + comment: | + The docs describe controlling the vacuum, though does not describe more + interesting potential integrations with the homoe assistant ecosystem. + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: There are no noisy entities. + entity-translations: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: todo + comment: The Cloud vs Local API warning should probably be a repair issue. + stale-devices: done + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/roborock/scene.py b/homeassistant/components/roborock/scene.py deleted file mode 100644 index ff418a2810c..00000000000 --- a/homeassistant/components/roborock/scene.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Support for Roborock scene.""" - -from __future__ import annotations - -import asyncio -from typing import Any - -from homeassistant.components.scene import Scene as SceneEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator -from .entity import RoborockEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: RoborockConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up scene platform.""" - scene_lists = await asyncio.gather( - *[coordinator.get_scenes() for coordinator in config_entry.runtime_data.v1], - ) - async_add_entities( - RoborockSceneEntity( - coordinator, - EntityDescription( - key=str(scene.id), - name=scene.name, - ), - ) - for coordinator, scenes in zip( - config_entry.runtime_data.v1, scene_lists, strict=True - ) - for scene in scenes - ) - - -class RoborockSceneEntity(RoborockEntity, SceneEntity): - """A class to define Roborock scene entities.""" - - entity_description: EntityDescription - - def __init__( - self, - coordinator: RoborockDataUpdateCoordinator, - entity_description: EntityDescription, - ) -> None: - """Create a scene entity.""" - super().__init__( - f"{entity_description.key}_{coordinator.duid_slug}", - coordinator.device_info, - coordinator.api, - ) - self._scene_id = int(entity_description.key) - self._coordinator = coordinator - self.entity_description = entity_description - - async def async_activate(self, **kwargs: Any) -> None: - """Activate the scene.""" - await self._coordinator.execute_scene(self._scene_id) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 6133eed0652..208020dccab 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -4,9 +4,9 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from roborock.containers import Status +from roborock.code_mappings import RoborockDockDustCollectionModeCode from roborock.roborock_message import RoborockDataProtocol -from roborock.roborock_typing import RoborockCommand +from roborock.roborock_typing import DeviceProp, RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -17,6 +17,8 @@ from .const import MAP_SLEEP from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockSelectDescription(SelectEntityDescription): @@ -25,13 +27,15 @@ class RoborockSelectDescription(SelectEntityDescription): # The command that the select entity will send to the api. api_command: RoborockCommand # Gets the current value of the select entity. - value_fn: Callable[[Status], str | None] + value_fn: Callable[[DeviceProp], str | None] # Gets all options of the select entity. - options_lambda: Callable[[Status], list[str] | None] + options_lambda: Callable[[DeviceProp], list[str] | None] # Takes the value from the select entity and converts it for the api. - parameter_lambda: Callable[[str, Status], list[int]] + parameter_lambda: Callable[[str, DeviceProp], list[int]] protocol_listener: RoborockDataProtocol | None = None + # If it is a dock entity + is_dock_entity: bool = False SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ @@ -39,24 +43,38 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ key="water_box_mode", translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, - value_fn=lambda data: data.water_box_mode_name, + value_fn=lambda data: data.status.water_box_mode_name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda data: data.water_box_mode.keys() - if data.water_box_mode is not None + options_lambda=lambda data: data.status.water_box_mode.keys() + if data.status.water_box_mode is not None else None, - parameter_lambda=lambda key, status: [status.get_mop_intensity_code(key)], + parameter_lambda=lambda key, prop: [prop.status.get_mop_intensity_code(key)], protocol_listener=RoborockDataProtocol.WATER_BOX_MODE, ), RoborockSelectDescription( key="mop_mode", translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, - value_fn=lambda data: data.mop_mode_name, + value_fn=lambda data: data.status.mop_mode_name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda data: data.mop_mode.keys() - if data.mop_mode is not None + options_lambda=lambda data: data.status.mop_mode.keys() + if data.status.mop_mode is not None else None, - parameter_lambda=lambda key, status: [status.get_mop_mode_code(key)], + parameter_lambda=lambda key, prop: [prop.status.get_mop_mode_code(key)], + ), + RoborockSelectDescription( + key="dust_collection_mode", + translation_key="dust_collection_mode", + api_command=RoborockCommand.SET_DUST_COLLECTION_MODE, + value_fn=lambda data: data.dust_collection_mode_name, + entity_category=EntityCategory.CONFIG, + options_lambda=lambda data: RoborockDockDustCollectionModeCode.keys() + if data.dust_collection_mode_name is not None + else None, + parameter_lambda=lambda key, _: [ + RoborockDockDustCollectionModeCode.as_dict().get(key) + ], + is_dock_entity=True, ), ] @@ -74,7 +92,7 @@ async def async_setup_entry( for description in SELECT_DESCRIPTIONS if ( options := description.options_lambda( - coordinator.roborock_device_info.props.status + coordinator.roborock_device_info.props ) ) is not None @@ -104,6 +122,7 @@ class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): f"{entity_description.key}_{coordinator.duid_slug}", coordinator, entity_description.protocol_listener, + is_dock_entity=entity_description.is_dock_entity, ) self._attr_options = options @@ -111,27 +130,28 @@ class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """Set the option.""" await self.send( self.entity_description.api_command, - self.entity_description.parameter_lambda(option, self._device_status), + self.entity_description.parameter_lambda(option, self.coordinator.data), ) @property def current_option(self) -> str | None: - """Get the current status of the select entity from device_status.""" - return self.entity_description.value_fn(self._device_status) + """Get the current status of the select entity from device props.""" + return self.entity_description.value_fn(self.coordinator.data) class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """A class to let you set the selected map on Roborock vacuum.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "selected_map" async def async_select_option(self, option: str) -> None: """Set the option.""" for map_id, map_ in self.coordinator.maps.items(): if map_.name == option: - await self.send( + await self._send_command( RoborockCommand.LOAD_MULTI_MAP, + self.api, [map_id], ) # Update the current map id manually so that nothing gets broken @@ -140,6 +160,7 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): # We need to wait after updating the map # so that other commands will be executed correctly. await asyncio.sleep(MAP_SLEEP) + await self.coordinator.async_refresh() break @property diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index f95dc5fa98f..a007d6fa457 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -36,7 +36,13 @@ from .coordinator import ( RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, ) -from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 +from .entity import ( + RoborockCoordinatedEntityA01, + RoborockCoordinatedEntityV1, + RoborockEntity, +) + +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -47,6 +53,9 @@ class RoborockSensorDescription(SensorEntityDescription): protocol_listener: RoborockDataProtocol | None = None + # If it is a dock entity + is_dock_entity: bool = False + @dataclass(frozen=True, kw_only=True) class RoborockSensorDescriptionA01(SensorEntityDescription): @@ -197,6 +206,7 @@ SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=RoborockDockErrorCode.keys(), + is_dock_entity=True, ), RoborockSensorDescription( key="mop_clean_remaining", @@ -205,6 +215,7 @@ SENSOR_DESCRIPTIONS = [ value_fn=lambda data: data.status.rdt, translation_key="mop_drying_remaining_time", entity_category=EntityCategory.DIAGNOSTIC, + is_dock_entity=True, ), ] @@ -299,7 +310,7 @@ async def async_setup_entry( ) -> None: """Set up the Roborock vacuum sensors.""" coordinators = config_entry.runtime_data - async_add_entities( + entities: list[RoborockEntity] = [ RoborockSensorEntity( coordinator, description, @@ -307,8 +318,9 @@ async def async_setup_entry( for coordinator in coordinators.v1 for description in SENSOR_DESCRIPTIONS if description.value_fn(coordinator.roborock_device_info.props) is not None - ) - async_add_entities( + ] + entities.extend(RoborockCurrentRoom(coordinator) for coordinator in coordinators.v1) + entities.extend( RoborockSensorEntityA01( coordinator, description, @@ -317,6 +329,7 @@ async def async_setup_entry( for description in A01_SENSOR_DESCRIPTIONS if description.data_protocol in coordinator.data ) + async_add_entities(entities) class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity): @@ -335,6 +348,7 @@ class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity): f"{description.key}_{coordinator.duid_slug}", coordinator, description.protocol_listener, + is_dock_entity=description.is_dock_entity, ) @property @@ -345,6 +359,48 @@ class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity): ) +class RoborockCurrentRoom(RoborockCoordinatedEntityV1, SensorEntity): + """Representation of a Current Room Sensor.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_translation_key = "current_room" + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinator, + ) -> None: + """Initialize the entity.""" + super().__init__( + f"current_room_{coordinator.duid_slug}", + coordinator, + None, + is_dock_entity=False, + ) + + @property + def options(self) -> list[str]: + """Return the currently valid rooms.""" + if ( + self.coordinator.current_map is not None + and self.coordinator.current_map in self.coordinator.maps + ): + return list( + self.coordinator.maps[self.coordinator.current_map].rooms.values() + ) + return [] + + @property + def native_value(self) -> str | None: + """Return the value reported by the sensor.""" + if ( + self.coordinator.current_map is not None + and self.coordinator.current_map in self.coordinator.maps + ): + return self.coordinator.maps[self.coordinator.current_map].current_room + return None + + class RoborockSensorEntityA01(RoborockCoordinatedEntityA01, SensorEntity): """Representation of a A01 Roborock sensor.""" diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index eb058ea74e3..2d1fcebd9d3 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -5,12 +5,18 @@ "description": "Enter your Roborock email address.", "data": { "username": "[%key:common::config_flow::data::email%]" + }, + "data_description": { + "username": "The email address used to sign in to the Roborock app." } }, "code": { "description": "Type the verification code sent to your email", "data": { "code": "Verification code" + }, + "data_description": { + "code": "The verification code sent to your email." } }, "reauth_confirm": { @@ -29,7 +35,8 @@ }, "abort": { "already_configured_account": "[%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%]", + "wrong_account": "Wrong account: Please authenticate with the right account." } }, "options": { @@ -54,6 +61,25 @@ "vacuum_position": "Vacuum position", "virtual_walls": "Virtual walls", "zones": "Zones" + }, + "data_description": { + "charger": "Show the charger on the map.", + "cleaned_area": "Show the area cleaned on the map.", + "goto_path": "Show the go-to path on the map.", + "ignored_obstacles": "Show ignored obstacles on the map.", + "ignored_obstacles_with_photo": "Show ignored obstacles with photos on the map.", + "mop_path": "Show the mop path on the map.", + "no_carpet_zones": "Show the no carpet zones on the map.", + "no_go_zones": "Show the no-go zones on the map.", + "no_mopping_zones": "Show the no-mop zones on the map.", + "obstacles": "Show obstacles on the map.", + "obstacles_with_photo": "Show obstacles with photos on the map.", + "path": "Show the path on the map.", + "predicted_path": "Show the predicted path on the map.", + "room_names": "Show room names on the map.", + "vacuum_position": "Show the vacuum position on the map.", + "virtual_walls": "Show virtual walls on the map.", + "zones": "Show zones on the map." } } } @@ -130,10 +156,10 @@ "ready": "Ready", "charging": "[%key:common::state::charging%]", "mop_washing": "Washing mop", - "self_clean_cleaning": "Self clean cleaning", - "self_clean_deep_cleaning": "Self clean deep cleaning", - "self_clean_rinsing": "Self clean rinsing", - "self_clean_dehydrating": "Self clean drying", + "self_clean_cleaning": "Self-clean cleaning", + "self_clean_deep_cleaning": "Self-clean deep cleaning", + "self_clean_rinsing": "Self-clean rinsing", + "self_clean_dehydrating": "Self-clean drying", "drying": "Drying", "ventilating": "Ventilating", "reserving": "Reserving", @@ -156,6 +182,9 @@ "countdown": { "name": "Countdown" }, + "current_room": { + "name": "Current room" + }, "dock_error": { "name": "Dock error", "state": { @@ -203,7 +232,7 @@ "charging_problem": "Charging problem", "paused": "[%key:common::state::paused%]", "spot_cleaning": "Spot cleaning", - "error": "Error", + "error": "[%key:common::state::error%]", "shutting_down": "Shutting down", "updating": "Updating", "docking": "Docking", @@ -310,7 +339,7 @@ "zeo_state": { "name": "State", "state": { - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "weighing": "Weighing", "soaking": "Soaking", "washing": "Washing", @@ -339,12 +368,12 @@ "name": "Mop intensity", "state": { "off": "[%key:common::state::off%]", - "low": "Low", + "low": "[%key:common::state::low%]", "mild": "Mild", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "moderate": "Moderate", "max": "Max", - "high": "High", + "high": "[%key:common::state::high%]", "intense": "Intense", "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]", "custom_water_flow": "Custom water flow", @@ -353,6 +382,15 @@ }, "selected_map": { "name": "Selected map" + }, + "dust_collection_mode": { + "name": "Empty mode", + "state": { + "smart": "Smart", + "light": "Light", + "balanced": "[%key:component::roborock::entity::vacuum::roborock::state_attributes::fan_speed::state::balanced%]", + "max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]" + } } }, "switch": { @@ -388,14 +426,14 @@ "state_attributes": { "fan_speed": { "state": { - "auto": "Auto", + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "balanced": "Balanced", "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]", "gentle": "Gentle", - "off": "[%key:common::state::off%]", "max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]", "max_plus": "Max plus", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "quiet": "Quiet", "silent": "Silent", "standard": "[%key:component::roborock::entity::select::mop_mode::state::standard%]", @@ -420,6 +458,12 @@ "map_failure": { "message": "Something went wrong creating the map" }, + "position_not_found": { + "message": "Robot position not found" + }, + "update_data_fail": { + "message": "Failed to update data" + }, "no_coordinators": { "message": "No devices were able to successfully setup" }, diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 0171d59abfd..44feccdebac 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -24,6 +24,8 @@ from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockSwitchDescription(SwitchEntityDescription): @@ -35,6 +37,8 @@ class RoborockSwitchDescription(SwitchEntityDescription): update_value: Callable[[AttributeCache, bool], Coroutine[Any, Any, None]] # Attribute from cache attribute: str + # If it is a dock entity + is_dock_entity: bool = False SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ @@ -47,6 +51,7 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ key="child_lock", translation_key="child_lock", entity_category=EntityCategory.CONFIG, + is_dock_entity=True, ), RoborockSwitchDescription( cache_key=CacheableAttribute.flow_led_status, @@ -57,6 +62,7 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ key="status_indicator", translation_key="status_indicator", entity_category=EntityCategory.CONFIG, + is_dock_entity=True, ), RoborockSwitchDescription( cache_key=CacheableAttribute.dnd_timer, @@ -147,7 +153,13 @@ class RoborockSwitch(RoborockEntityV1, SwitchEntity): ) -> None: """Initialize the entity.""" self.entity_description = entity_description - super().__init__(unique_id, coordinator.device_info, coordinator.api) + super().__init__( + unique_id, + coordinator.device_info + if not entity_description.is_dock_entity + else coordinator.dock_device_info, + coordinator.api, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 6aa70e300e5..83d341fa2dd 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -24,6 +24,8 @@ from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockTimeDescription(TimeEntityDescription): diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 59abc888673..058fffbdb1c 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -1,11 +1,14 @@ """Support for Roborock vacuum class.""" -from dataclasses import asdict from typing import Any from roborock.code_mappings import RoborockStateCode from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand +from vacuum_map_parser_base.config.color import ColorsPalette +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.config.size import Sizes +from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser import voluptuous as vol from homeassistant.components.vacuum import ( @@ -26,7 +29,6 @@ from .const import ( ) from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 -from .image import ColorsPalette, ImageConfig, RoborockMapDataParser, Sizes STATE_CODE_TO_STATE = { RoborockStateCode.starting: VacuumActivity.IDLE, # "Starting" @@ -54,6 +56,8 @@ STATE_CODE_TO_STATE = { RoborockStateCode.device_offline: VacuumActivity.ERROR, # "Device offline" } +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -201,7 +205,14 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): """Get map information such as map id and room ids.""" return { "maps": [ - asdict(vacuum_map) for vacuum_map in self.coordinator.maps.values() + { + "flag": vacuum_map.flag, + "name": vacuum_map.name, + # JsonValueType does not accept a int as a key - was not a + # issue with previous asdict() implementation. + "rooms": vacuum_map.rooms, # type: ignore[dict-item] + } + for vacuum_map in self.coordinator.maps.values() ] } @@ -210,13 +221,18 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): map_data = await self.coordinator.cloud_api.get_map_v1() if not isinstance(map_data, bytes): - raise HomeAssistantError("Failed to retrieve map data.") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) 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") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="position_not_found" + ) return { "x": robot_position.x, diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 04348bc3bfb..62f1f8b1736 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -47,7 +47,7 @@ "name": "Supports AirPlay" }, "supports_ethernet": { - "name": "Supports ethernet" + "name": "Supports Ethernet" }, "supports_find_remote": { "name": "Supports find remote" diff --git a/homeassistant/components/romy/strings.json b/homeassistant/components/romy/strings.json index 78721da17ba..aa7bfe26ea0 100644 --- a/homeassistant/components/romy/strings.json +++ b/homeassistant/components/romy/strings.json @@ -21,7 +21,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "password": "(8 characters, see QR Code under the dustbin)." + "password": "(8 characters, see QR code under the dustbin)." } }, "zeroconf_confirm": { @@ -36,12 +36,12 @@ "fan_speed": { "state": { "default": "Default", - "normal": "Normal", - "silent": "Silent", + "auto": "[%key:common::state::auto%]", + "normal": "[%key:common::state::normal%]", + "high": "[%key:common::state::high%]", "intensive": "Intensive", - "super_silent": "Super silent", - "high": "High", - "auto": "Auto" + "silent": "Silent", + "super_silent": "Super silent" } } } diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 0460e2cfc6e..4a87601a24f 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -329,6 +329,11 @@ class RoonDevice(MediaPlayerEntity): """Album artist of current playing media (Music track only).""" return self.media_artist + @property + def media_content_type(self) -> str: + """Return the media type.""" + return MediaType.MUSIC + @property def supports_standby(self): """Return power state of source controls.""" diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json index 978c916e3ee..8c21b856b80 100644 --- a/homeassistant/components/route53/manifest.json +++ b/homeassistant/components/route53/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], "quality_scale": "legacy", - "requirements": ["boto3==1.34.131"] + "requirements": ["boto3==1.37.1"] } diff --git a/homeassistant/components/rova/strings.json b/homeassistant/components/rova/strings.json index 3b89fc789ee..21f4146bf78 100644 --- a/homeassistant/components/rova/strings.json +++ b/homeassistant/components/rova/strings.json @@ -4,7 +4,7 @@ "user": { "title": "Provide your address details", "data": { - "zip_code": "Your zip code", + "zip_code": "Your ZIP code", "house_number": "Your house number", "house_number_suffix": "A suffix for your house number" } diff --git a/homeassistant/components/rtsp_to_webrtc/__init__.py b/homeassistant/components/rtsp_to_webrtc/__init__.py deleted file mode 100644 index 0fc257c463f..00000000000 --- a/homeassistant/components/rtsp_to_webrtc/__init__.py +++ /dev/null @@ -1,123 +0,0 @@ -"""RTSPtoWebRTC integration with an external RTSPToWebRTC Server. - -WebRTC uses a direct communication from the client (e.g. a web browser) to a -camera device. Home Assistant acts as the signal path for initial set up, -passing through the client offer and returning a camera answer, then the client -and camera communicate directly. - -However, not all cameras natively support WebRTC. This integration is a shim -for camera devices that support RTSP streams only, relying on an external -server RTSPToWebRTC that is a proxy. Home Assistant does not participate in -the offer/answer SDP protocol, other than as a signal path pass through. - -Other integrations may use this integration with these steps: -- Check if this integration is loaded -- Call is_supported_stream_source for compatibility -- Call async_offer_for_stream_source to get back an answer for a client offer -""" - -from __future__ import annotations - -import asyncio -import logging - -from rtsp_to_webrtc.client import get_adaptive_client -from rtsp_to_webrtc.exceptions import ClientError, ResponseError -from rtsp_to_webrtc.interface import WebRTCClientInterface -from webrtc_models import RTCIceServer - -from homeassistant.components import camera -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "rtsp_to_webrtc" -DATA_SERVER_URL = "server_url" -DATA_UNSUB = "unsub" -TIMEOUT = 10 -CONF_STUN_SERVER = "stun_server" - -_DEPRECATED = "deprecated" - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up RTSPtoWebRTC from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - ir.async_create_issue( - hass, - DOMAIN, - _DEPRECATED, - breaks_in_ha_version="2025.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key=_DEPRECATED, - translation_placeholders={ - "go2rtc": "[go2rtc](https://www.home-assistant.io/integrations/go2rtc/)", - }, - ) - - client: WebRTCClientInterface - try: - async with asyncio.timeout(TIMEOUT): - client = await get_adaptive_client( - async_get_clientsession(hass), entry.data[DATA_SERVER_URL] - ) - except ResponseError as err: - raise ConfigEntryNotReady from err - except (TimeoutError, ClientError) as err: - raise ConfigEntryNotReady from err - - hass.data[DOMAIN][CONF_STUN_SERVER] = entry.options.get(CONF_STUN_SERVER) - if server := entry.options.get(CONF_STUN_SERVER): - - @callback - def get_servers() -> list[RTCIceServer]: - return [RTCIceServer(urls=[server])] - - entry.async_on_unload(camera.async_register_ice_servers(hass, get_servers)) - - async def async_offer_for_stream_source( - stream_source: str, - offer_sdp: str, - stream_id: str, - ) -> str: - """Handle the signal path for a WebRTC stream. - - This signal path is used to route the offer created by the client to the - proxy server that translates a stream to WebRTC. The communication for - the stream itself happens directly between the client and proxy. - """ - try: - async with asyncio.timeout(TIMEOUT): - return await client.offer_stream_id(stream_id, offer_sdp, stream_source) - except TimeoutError as err: - raise HomeAssistantError("Timeout talking to RTSPtoWebRTC server") from err - except ClientError as err: - raise HomeAssistantError(str(err)) from err - - entry.async_on_unload( - camera.async_register_rtsp_to_web_rtc_provider( - hass, DOMAIN, async_offer_for_stream_source - ) - ) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - if DOMAIN in hass.data: - del hass.data[DOMAIN] - ir.async_delete_issue(hass, DOMAIN, _DEPRECATED) - return True - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload config entry when options change.""" - if hass.data[DOMAIN][CONF_STUN_SERVER] != entry.options.get(CONF_STUN_SERVER): - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/rtsp_to_webrtc/config_flow.py b/homeassistant/components/rtsp_to_webrtc/config_flow.py deleted file mode 100644 index 22502659757..00000000000 --- a/homeassistant/components/rtsp_to_webrtc/config_flow.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Config flow for RTSPtoWebRTC.""" - -from __future__ import annotations - -import logging -from typing import Any -from urllib.parse import urlparse - -import rtsp_to_webrtc -import voluptuous as vol - -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) -from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.service_info.hassio import HassioServiceInfo - -from . import CONF_STUN_SERVER, DATA_SERVER_URL, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -DATA_SCHEMA = vol.Schema({vol.Required(DATA_SERVER_URL): str}) - - -class RTSPToWebRTCConfigFlow(ConfigFlow, domain=DOMAIN): - """RTSPtoWebRTC config flow.""" - - _hassio_discovery: dict[str, Any] - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure the RTSPtoWebRTC server url.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - if user_input is None: - return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) - - url = user_input[DATA_SERVER_URL] - result = urlparse(url) - if not all([result.scheme, result.netloc]): - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={DATA_SERVER_URL: "invalid_url"}, - ) - - if error_code := await self._test_connection(url): - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={"base": error_code}, - ) - - await self.async_set_unique_id(DOMAIN) - return self.async_create_entry( - title=url, - data={DATA_SERVER_URL: url}, - ) - - async def _test_connection(self, url: str) -> str | None: - """Test the connection and return any relevant errors.""" - client = rtsp_to_webrtc.client.Client(async_get_clientsession(self.hass), url) - try: - await client.heartbeat() - except rtsp_to_webrtc.exceptions.ResponseError as err: - _LOGGER.error("RTSPtoWebRTC server failure: %s", str(err)) - return "server_failure" - except rtsp_to_webrtc.exceptions.ClientError as err: - _LOGGER.error("RTSPtoWebRTC communication failure: %s", str(err)) - return "server_unreachable" - return None - - async def async_step_hassio( - self, discovery_info: HassioServiceInfo - ) -> ConfigFlowResult: - """Prepare configuration for the RTSPtoWebRTC server add-on discovery.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - self._hassio_discovery = discovery_info.config - return await self.async_step_hassio_confirm() - - async def async_step_hassio_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Confirm Add-on discovery.""" - errors = None - if user_input is not None: - # Validate server connection once user has confirmed - host = self._hassio_discovery[CONF_HOST] - port = self._hassio_discovery[CONF_PORT] - url = f"http://{host}:{port}" - if error_code := await self._test_connection(url): - return self.async_abort(reason=error_code) - - if user_input is None or errors: - # Show initial confirmation or errors from server validation - return self.async_show_form( - step_id="hassio_confirm", - description_placeholders={"addon": self._hassio_discovery["addon"]}, - errors=errors, - ) - - return self.async_create_entry( - title=self._hassio_discovery["addon"], - data={DATA_SERVER_URL: url}, - ) - - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: - """Create an options flow.""" - return OptionsFlowHandler() - - -class OptionsFlowHandler(OptionsFlow): - """RTSPtoWeb Options flow.""" - - 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(title="", data=user_input) - - return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_STUN_SERVER, - description={ - "suggested_value": self.config_entry.options.get( - CONF_STUN_SERVER - ), - }, - ): str, - } - ), - ) diff --git a/homeassistant/components/rtsp_to_webrtc/manifest.json b/homeassistant/components/rtsp_to_webrtc/manifest.json deleted file mode 100644 index 27b9703d50e..00000000000 --- a/homeassistant/components/rtsp_to_webrtc/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "domain": "rtsp_to_webrtc", - "name": "RTSPtoWebRTC", - "codeowners": ["@allenporter"], - "config_flow": true, - "dependencies": ["camera"], - "documentation": "https://www.home-assistant.io/integrations/rtsp_to_webrtc", - "iot_class": "local_push", - "loggers": ["rtsp_to_webrtc"], - "requirements": ["rtsp-to-webrtc==0.5.1"] -} diff --git a/homeassistant/components/rtsp_to_webrtc/strings.json b/homeassistant/components/rtsp_to_webrtc/strings.json deleted file mode 100644 index c8dcbb7f462..00000000000 --- a/homeassistant/components/rtsp_to_webrtc/strings.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Configure RTSPtoWebRTC", - "description": "The RTSPtoWebRTC integration requires a server to translate RTSP streams into WebRTC. Enter the URL to the RTSPtoWebRTC server.", - "data": { - "server_url": "RTSPtoWebRTC server URL e.g. https://example.com" - } - }, - "hassio_confirm": { - "title": "RTSPtoWebRTC via Home Assistant add-on", - "description": "Do you want to configure Home Assistant to connect to the RTSPtoWebRTC server provided by the add-on: {addon}?" - } - }, - "error": { - "invalid_url": "Must be a valid RTSPtoWebRTC server URL e.g. https://example.com", - "server_failure": "RTSPtoWebRTC server returned an error. Check logs for more information.", - "server_unreachable": "Unable to communicate with RTSPtoWebRTC server. Check logs for more information." - }, - "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "server_failure": "[%key:component::rtsp_to_webrtc::config::error::server_failure%]", - "server_unreachable": "[%key:component::rtsp_to_webrtc::config::error::server_unreachable%]" - } - }, - "issues": { - "deprecated": { - "title": "The RTSPtoWebRTC integration is deprecated", - "description": "The RTSPtoWebRTC integration is deprecated and will be removed. Please use the {go2rtc} integration instead, which is enabled by default and provides a better experience. You only need to remove the RTSPtoWebRTC config entry." - } - }, - "options": { - "step": { - "init": { - "data": { - "stun_server": "Stun server address (host:port)" - } - } - } - } -} diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index f91406e8a4b..e16e589e648 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.4.0"], + "requirements": ["aiorussound==4.5.2"], "zeroconf": ["_rio._tcp.local."] } diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 1f68781a3a2..4241f39778c 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -2,148 +2,18 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine import logging -from typing import Any - -import voluptuous as vol 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, issue_registry as ir -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady -from .const import ( - ATTR_API_KEY, - ATTR_SPEED, - DEFAULT_SPEED_LIMIT, - DOMAIN, - SERVICE_PAUSE, - SERVICE_RESUME, - SERVICE_SET_SPEED, -) from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator from .helpers import get_client PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -SERVICES = ( - SERVICE_PAUSE, - SERVICE_RESUME, - SERVICE_SET_SPEED, -) - -SERVICE_BASE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_API_KEY): cv.string, - } -) - -SERVICE_SPEED_SCHEMA = SERVICE_BASE_SCHEMA.extend( - { - vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.string, - } -) - -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - - -@callback -def async_get_entry_for_service_call( - hass: HomeAssistant, call: ServiceCall -) -> SabnzbdConfigEntry: - """Get the entry ID related to a service call (by device ID).""" - call_data_api_key = call.data[ATTR_API_KEY] - - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.data[ATTR_API_KEY] == call_data_api_key: - return entry - - raise ValueError(f"No api for API key: {call_data_api_key}") - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the SabNzbd Component.""" - - @callback - def extract_api( - func: Callable[ - [ServiceCall, SabnzbdUpdateCoordinator], Coroutine[Any, Any, None] - ], - ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: - """Define a decorator to get the correct api for a service call.""" - - async def wrapper(call: ServiceCall) -> None: - """Wrap the service function.""" - config_entry = async_get_entry_for_service_call(hass, call) - coordinator = config_entry.runtime_data - - try: - await func(call, coordinator) - except Exception as err: - raise HomeAssistantError( - f"Error while executing {func.__name__}: {err}" - ) from err - - return wrapper - - @extract_api - async def async_pause_queue( - call: ServiceCall, coordinator: SabnzbdUpdateCoordinator - ) -> None: - ir.async_create_issue( - hass, - DOMAIN, - "pause_action_deprecated", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - breaks_in_ha_version="2025.6", - translation_key="pause_action_deprecated", - ) - await coordinator.sab_api.pause_queue() - - @extract_api - async def async_resume_queue( - call: ServiceCall, coordinator: SabnzbdUpdateCoordinator - ) -> None: - ir.async_create_issue( - hass, - DOMAIN, - "resume_action_deprecated", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - breaks_in_ha_version="2025.6", - translation_key="resume_action_deprecated", - ) - await coordinator.sab_api.resume_queue() - - @extract_api - async def async_set_queue_speed( - call: ServiceCall, coordinator: SabnzbdUpdateCoordinator - ) -> None: - ir.async_create_issue( - hass, - DOMAIN, - "set_speed_action_deprecated", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - breaks_in_ha_version="2025.6", - translation_key="set_speed_action_deprecated", - ) - speed = call.data.get(ATTR_SPEED) - await coordinator.sab_api.set_speed_limit(speed) - - for service, method, schema in ( - (SERVICE_PAUSE, async_pause_queue, SERVICE_BASE_SCHEMA), - (SERVICE_RESUME, async_resume_queue, SERVICE_BASE_SCHEMA), - (SERVICE_SET_SPEED, async_set_queue_speed, SERVICE_SPEED_SCHEMA), - ): - 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.""" diff --git a/homeassistant/components/sabnzbd/const.py b/homeassistant/components/sabnzbd/const.py index f05b3f19e98..66c71089b72 100644 --- a/homeassistant/components/sabnzbd/const.py +++ b/homeassistant/components/sabnzbd/const.py @@ -1,12 +1,3 @@ """Constants for the Sabnzbd component.""" DOMAIN = "sabnzbd" - -ATTR_SPEED = "speed" -ATTR_API_KEY = "api_key" - -DEFAULT_SPEED_LIMIT = "100" - -SERVICE_PAUSE = "pause" -SERVICE_RESUME = "resume" -SERVICE_SET_SPEED = "set_speed" diff --git a/homeassistant/components/sabnzbd/icons.json b/homeassistant/components/sabnzbd/icons.json index b0a72040b4b..b06a1e316a1 100644 --- a/homeassistant/components/sabnzbd/icons.json +++ b/homeassistant/components/sabnzbd/icons.json @@ -13,16 +13,5 @@ "default": "mdi:speedometer" } } - }, - "services": { - "pause": { - "service": "mdi:pause" - }, - "resume": { - "service": "mdi:play" - }, - "set_speed": { - "service": "mdi:speedometer" - } } } diff --git a/homeassistant/components/sabnzbd/quality_scale.yaml b/homeassistant/components/sabnzbd/quality_scale.yaml index a1d6fc076b2..7e2a8fe9e26 100644 --- a/homeassistant/components/sabnzbd/quality_scale.yaml +++ b/homeassistant/components/sabnzbd/quality_scale.yaml @@ -1,6 +1,9 @@ rules: # Bronze - action-setup: done + action-setup: + status: exempt + comment: | + The integration does not provide any actions. appropriate-polling: done brands: done common-modules: done @@ -10,7 +13,7 @@ rules: docs-actions: status: exempt comment: | - The integration has deprecated the actions, thus the documentation has been removed. + The integration does not provide any actions. docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -26,10 +29,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: | - Raise ServiceValidationError in async_get_entry_for_service_call. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt diff --git a/homeassistant/components/sabnzbd/services.yaml b/homeassistant/components/sabnzbd/services.yaml deleted file mode 100644 index f1eea1c9469..00000000000 --- a/homeassistant/components/sabnzbd/services.yaml +++ /dev/null @@ -1,23 +0,0 @@ -pause: - fields: - api_key: - required: true - selector: - text: -resume: - fields: - api_key: - required: true - selector: - text: -set_speed: - fields: - api_key: - required: true - selector: - text: - speed: - example: 100 - default: 100 - selector: - text: diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 0ac8b93c57f..601f1153b82 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -32,7 +32,7 @@ "name": "[%key:common::action::pause%]" }, "resume": { - "name": "[%key:component::sabnzbd::services::resume::name%]" + "name": "Resume" } }, "number": { @@ -76,56 +76,6 @@ } } }, - "services": { - "pause": { - "name": "[%key:common::action::pause%]", - "description": "Pauses downloads.", - "fields": { - "api_key": { - "name": "SABnzbd API key", - "description": "The SABnzbd API key to pause downloads." - } - } - }, - "resume": { - "name": "Resume", - "description": "Resumes downloads.", - "fields": { - "api_key": { - "name": "[%key:component::sabnzbd::services::pause::fields::api_key::name%]", - "description": "The SABnzbd API key to resume downloads." - } - } - }, - "set_speed": { - "name": "Set speed", - "description": "Sets the download speed limit.", - "fields": { - "api_key": { - "name": "[%key:component::sabnzbd::services::pause::fields::api_key::name%]", - "description": "The SABnzbd API key to set speed limit." - }, - "speed": { - "name": "Speed", - "description": "Speed limit. If specified as a number with no units, will be interpreted as a percent. If units are provided (e.g., 500K) will be interpreted absolutely." - } - } - } - }, - "issues": { - "pause_action_deprecated": { - "title": "SABnzbd pause action deprecated", - "description": "The 'Pause' action is deprecated and will be removed in a future version. Please use the 'Pause' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." - }, - "resume_action_deprecated": { - "title": "SABnzbd resume action deprecated", - "description": "The 'Resume' action is deprecated and will be removed in a future version. Please use the 'Resume' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." - }, - "set_speed_action_deprecated": { - "title": "SABnzbd set_speed action deprecated", - "description": "The 'Set speed' action is deprecated and will be removed in a future version. Please use the 'Speedlimit' number entity instead. To remove this issue, please adjust automations accordingly and restart Home Assistant." - } - }, "exceptions": { "service_call_exception": { "message": "Unable to send command to SABnzbd due to a connection error, try again later" diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index e416cd35765..f7af5efc899 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -10,7 +10,6 @@ from urllib.parse import urlparse import getmac from homeassistant.components import ssdp -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -22,25 +21,19 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer -from .bridge import ( - SamsungTVBridge, - async_get_device_info, - mac_from_device_info, - model_requires_encryption, -) +from .bridge import SamsungTVBridge, mac_from_device_info, model_requires_encryption from .const import ( CONF_SESSION_ID, CONF_SSDP_MAIN_TV_AGENT_LOCATION, CONF_SSDP_RENDERING_CONTROL_LOCATION, + DOMAIN, ENTRY_RELOAD_COOLDOWN, - LEGACY_PORT, LOGGER, METHOD_ENCRYPTED_WEBSOCKET, - METHOD_LEGACY, UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, ) @@ -51,7 +44,7 @@ PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] @callback def _async_get_device_bridge( - hass: HomeAssistant, data: dict[str, Any] + hass: HomeAssistant, data: Mapping[str, Any] ) -> SamsungTVBridge: """Get device bridge.""" return SamsungTVBridge.get_bridge( @@ -66,7 +59,7 @@ def _async_get_device_bridge( class DebouncedEntryReloader: """Reload only after the timer expires.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: SamsungTVConfigEntry) -> None: """Init the debounced entry reloader.""" self.hass = hass self.entry = entry @@ -79,7 +72,9 @@ class DebouncedEntryReloader: function=self._async_reload_entry, ) - async def async_call(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + async def async_call( + self, hass: HomeAssistant, entry: SamsungTVConfigEntry + ) -> None: """Start the countdown for a reload.""" if (new_token := entry.data.get(CONF_TOKEN)) != self.token: LOGGER.debug("Skipping reload as its a token update") @@ -99,7 +94,9 @@ class DebouncedEntryReloader: await self.hass.config_entries.async_reload(self.entry.entry_id) -async def _async_update_ssdp_locations(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_ssdp_locations( + hass: HomeAssistant, entry: SamsungTVConfigEntry +) -> None: """Update ssdp locations from discovery cache.""" updates = {} for ssdp_st, key in ( @@ -123,7 +120,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> if entry.data.get(CONF_METHOD) == METHOD_ENCRYPTED_WEBSOCKET: if not entry.data.get(CONF_TOKEN) or not entry.data.get(CONF_SESSION_ID): raise ConfigEntryAuthFailed( - "Token and session id are required in encrypted mode" + translation_domain=DOMAIN, translation_key="encrypted_mode_auth_failed" ) bridge = await _async_create_bridge_with_updated_data(hass, entry) @@ -171,42 +168,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> async def _async_create_bridge_with_updated_data( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SamsungTVConfigEntry ) -> SamsungTVBridge: """Create a bridge object and update any missing data in the config entry.""" - updated_data: dict[str, str | int] = {} + updated_data: dict[str, str] = {} host: str = entry.data[CONF_HOST] - port: int | None = entry.data.get(CONF_PORT) - method: str | None = entry.data.get(CONF_METHOD) - load_info_attempted = False + method: str = entry.data[CONF_METHOD] info: dict[str, Any] | None = None - if not port or not method: - LOGGER.debug("Attempting to get port or method for %s", host) - if method == METHOD_LEGACY: - port = LEGACY_PORT - else: - # When we imported from yaml we didn't setup the method - # because we didn't know it - _result, port, method, info = await async_get_device_info(hass, host) - load_info_attempted = True - if not port or not method: - raise ConfigEntryNotReady( - "Failed to determine connection method, make sure the device is on." - ) - - LOGGER.debug("Updated port to %s and method to %s for %s", port, method, host) - updated_data[CONF_PORT] = port - updated_data[CONF_METHOD] = method - - bridge = _async_get_device_bridge(hass, {**entry.data, **updated_data}) + bridge = _async_get_device_bridge(hass, entry.data) mac: str | None = entry.data.get(CONF_MAC) model: str | None = entry.data.get(CONF_MODEL) + # Incorrect MAC cleanup introduced in #110599, can be removed in 2026.3 mac_is_incorrectly_formatted = mac and dr.format_mac(mac) != mac - if ( - not mac or not model or mac_is_incorrectly_formatted - ) and not load_info_attempted: + if not mac or not model or mac_is_incorrectly_formatted: info = await bridge.async_device_info() if not mac or mac_is_incorrectly_formatted: @@ -258,7 +234,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) - 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: SamsungTVConfigEntry +) -> bool: """Migrate old entry.""" version = config_entry.version minor_version = config_entry.minor_version diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index b4d060372e6..11da83219c7 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -46,6 +46,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_component from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac @@ -53,6 +54,7 @@ from homeassistant.util import dt as dt_util from .const import ( CONF_SESSION_ID, + DOMAIN, ENCRYPTED_WEBSOCKET_PORT, LEGACY_PORT, LOGGER, @@ -150,7 +152,7 @@ class SamsungTVBridge(ABC): ) -> SamsungTVBridge: """Get Bridge instance.""" if method == METHOD_LEGACY or port == LEGACY_PORT: - return SamsungTVLegacyBridge(hass, method, host, port) + return SamsungTVLegacyBridge(hass, method, host, port or LEGACY_PORT) if method == METHOD_ENCRYPTED_WEBSOCKET or port == ENCRYPTED_WEBSOCKET_PORT: return SamsungTVEncryptedBridge(hass, method, host, port, entry_data) return SamsungTVWSBridge(hass, method, host, port, entry_data) @@ -262,14 +264,14 @@ class SamsungTVLegacyBridge(SamsungTVBridge): self, hass: HomeAssistant, method: str, host: str, port: int | None ) -> None: """Initialize Bridge.""" - super().__init__(hass, method, host, LEGACY_PORT) + super().__init__(hass, method, host, port) self.config = { CONF_NAME: VALUE_CONF_NAME, CONF_DESCRIPTION: VALUE_CONF_NAME, CONF_ID: VALUE_CONF_ID, CONF_HOST: host, CONF_METHOD: method, - CONF_PORT: None, + CONF_PORT: port, CONF_TIMEOUT: 1, } self._remote: Remote | None = None @@ -301,7 +303,7 @@ class SamsungTVLegacyBridge(SamsungTVBridge): CONF_ID: VALUE_CONF_ID, CONF_HOST: self.host, CONF_METHOD: self.method, - CONF_PORT: None, + CONF_PORT: self.port, # We need this high timeout because waiting for auth popup # is just an open socket CONF_TIMEOUT: TIMEOUT_REQUEST, @@ -371,9 +373,13 @@ class SamsungTVLegacyBridge(SamsungTVBridge): except (ConnectionClosed, BrokenPipeError): # BrokenPipe can occur when the commands is sent to fast self._remote = None - except (UnhandledResponse, AccessDenied): + except (UnhandledResponse, AccessDenied) as err: # We got a response so it's on. - LOGGER.debug("Failed sending command %s", key, exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_sending_command", + translation_placeholders={"error": repr(err), "host": self.host}, + ) from err except OSError: # Different reasons, e.g. hostname not resolveable pass @@ -510,6 +516,7 @@ class SamsungTVWSBridge( async def async_try_connect(self) -> str: """Try to connect to the Websocket TV.""" + temp_result = None for self.port in WEBSOCKET_PORTS: config = { CONF_NAME: VALUE_CONF_NAME, @@ -521,7 +528,6 @@ class SamsungTVWSBridge( CONF_TIMEOUT: TIMEOUT_REQUEST, } - result = None try: LOGGER.debug("Try config: %s", config) async with SamsungTVWSAsyncRemote( @@ -545,38 +551,43 @@ class SamsungTVWSBridge( config, err, ) - result = RESULT_NOT_SUPPORTED + temp_result = RESULT_NOT_SUPPORTED except WebSocketException as err: LOGGER.debug( "Working but unsupported config: %s, error: %s", config, err ) - result = RESULT_NOT_SUPPORTED + temp_result = RESULT_NOT_SUPPORTED except UnauthorizedError as err: LOGGER.debug("Failing config: %s, %s error: %s", config, type(err), err) return RESULT_AUTH_MISSING except (ConnectionFailure, OSError, AsyncioTimeoutError) as err: LOGGER.debug("Failing config: %s, %s error: %s", config, type(err), err) - else: # noqa: PLW0120 - if result: - return result - return RESULT_CANNOT_CONNECT + return temp_result or RESULT_CANNOT_CONNECT async def async_device_info(self, force: bool = False) -> dict[str, Any] | None: """Try to gather infos of this TV.""" if self._rest_api is None: assert self.port - rest_api = SamsungTVAsyncRest( + self._rest_api = SamsungTVAsyncRest( host=self.host, session=async_get_clientsession(self.hass), port=self.port, timeout=TIMEOUT_WEBSOCKET, ) - with contextlib.suppress(*REST_EXCEPTIONS): - device_info: dict[str, Any] = await rest_api.rest_device_info() + try: + device_info: dict[str, Any] = await self._rest_api.rest_device_info() LOGGER.debug("Device info on %s is: %s", self.host, device_info) self._device_info = device_info + except REST_EXCEPTIONS as err: + LOGGER.debug( + "Failed to load device info from %s:%s: %s", + self.host, + self.port, + str(err), + ) + else: return device_info return None if force else self._device_info diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 3f34520e87a..dbde1ee1ef3 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_MAC, CONF_METHOD, CONF_MODEL, - CONF_NAME, + CONF_PIN, CONF_PORT, CONF_TOKEN, ) @@ -56,13 +56,12 @@ from .const import ( RESULT_INVALID_PIN, RESULT_NOT_SUPPORTED, RESULT_SUCCESS, - RESULT_UNKNOWN_HOST, SUCCESSFUL_RESULTS, UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, ) -DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}) +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) def _strip_uuid(udn: str) -> str: @@ -97,6 +96,7 @@ def _mac_is_same_with_incorrect_formatting( current_unformatted_mac: str, formatted_mac: str ) -> bool: """Check if two macs are the same but formatted incorrectly.""" + # Incorrect MAC cleanup introduced in #110599, can be removed in 2026.3 current_formatted_mac = format_mac(current_unformatted_mac) return ( current_formatted_mac == formatted_mac @@ -110,9 +110,11 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 2 MINOR_VERSION = 2 + _host: str + _bridge: SamsungTVBridge + def __init__(self) -> None: """Initialize flow.""" - self._host: str = "" self._mac: str | None = None self._udn: str | None = None self._upnp_udn: str | None = None @@ -125,20 +127,17 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self._name: str | None = None self._title: str = "" self._id: int | None = None - self._bridge: SamsungTVBridge | None = None self._device_info: dict[str, Any] | None = None self._authenticator: SamsungTVEncryptedWSAsyncAuthenticator | None = None def _base_config_entry(self) -> dict[str, Any]: """Generate the base config entry without the method.""" - assert self._bridge is not None return { CONF_HOST: self._host, CONF_MAC: self._mac, CONF_MANUFACTURER: self._manufacturer or DEFAULT_MANUFACTURER, CONF_METHOD: self._bridge.method, CONF_MODEL: self._model, - CONF_NAME: self._name, CONF_PORT: self._bridge.port, CONF_SSDP_RENDERING_CONTROL_LOCATION: self._ssdp_rendering_control_location, CONF_SSDP_MAIN_TV_AGENT_LOCATION: self._ssdp_main_tv_agent_location, @@ -146,7 +145,6 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): def _get_entry_from_bridge(self) -> ConfigFlowResult: """Get device entry.""" - assert self._bridge data = self._base_config_entry() if self._bridge.token: data[CONF_TOKEN] = self._bridge.token @@ -166,7 +164,6 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self, raise_on_progress: bool = True ) -> None: """Set the unique id from the udn.""" - assert self._host is not None # Set the unique id without raising on progress in case # there are two SSDP flows with for each ST await self.async_set_unique_id(self._udn, raise_on_progress=False) @@ -253,39 +250,44 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self._mac = mac return True - async def _async_set_name_host_from_input(self, user_input: dict[str, Any]) -> None: + async def _async_set_name_host_from_input(self, user_input: dict[str, Any]) -> bool: try: self._host = await self.hass.async_add_executor_job( socket.gethostbyname, user_input[CONF_HOST] ) except socket.gaierror as err: - raise AbortFlow(RESULT_UNKNOWN_HOST) from err - self._name = user_input.get(CONF_NAME, self._host) or "" - self._title = self._name + LOGGER.debug("Failed to get IP for %s: %s", user_input[CONF_HOST], err) + return False + self._title = self._host + return True 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] | None = None if user_input is not None: - await self._async_set_name_host_from_input(user_input) - await self._async_create_bridge() - assert self._bridge - self._async_abort_entries_match({CONF_HOST: self._host}) - if self._bridge.method != METHOD_LEGACY: - # Legacy bridge does not provide device info - await self._async_set_device_unique_id(raise_on_progress=False) - if self._bridge.method == METHOD_ENCRYPTED_WEBSOCKET: - return await self.async_step_encrypted_pairing() - return await self.async_step_pairing({}) + if await self._async_set_name_host_from_input(user_input): + await self._async_create_bridge() + self._async_abort_entries_match({CONF_HOST: self._host}) + if self._bridge.method != METHOD_LEGACY: + # Legacy bridge does not provide device info + await self._async_set_device_unique_id(raise_on_progress=False) + if self._bridge.method == METHOD_ENCRYPTED_WEBSOCKET: + return await self.async_step_encrypted_pairing() + return await self.async_step_pairing({}) + errors = {"base": "invalid_host"} - return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input), + errors=errors, + ) async def async_step_pairing( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a pairing by accepting the message on the TV.""" - assert self._bridge is not None errors: dict[str, str] = {} if user_input is not None: result = await self._bridge.async_try_connect() @@ -307,14 +309,13 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a encrypted pairing.""" - assert self._host is not None await self._async_start_encrypted_pairing(self._host) assert self._authenticator is not None errors: dict[str, str] = {} if user_input is not None: if ( - (pin := user_input.get("pin")) + (pin := user_input.get(CONF_PIN)) and (token := await self._authenticator.try_pin(pin)) and (session_id := await self._authenticator.get_session_id_and_close()) ): @@ -333,7 +334,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): step_id="encrypted_pairing", errors=errors, description_placeholders={"device": self._title}, - data_schema=vol.Schema({vol.Required("pin"): str}), + data_schema=vol.Schema({vol.Required(CONF_PIN): str}), ) @callback @@ -422,7 +423,6 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): @callback def _async_start_discovery_with_mac_address(self) -> None: """Start discovery.""" - assert self._host is not None if (entry := self._async_update_existing_matching_entry()) and entry.unique_id: # If we have the unique id and the mac we abort # as we do not need anything else @@ -520,7 +520,6 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): """Handle user-confirmation of discovered node.""" if user_input is not None: await self._async_create_bridge() - assert self._bridge if self._bridge.method == METHOD_ENCRYPTED_WEBSOCKET: return await self.async_step_encrypted_pairing() return await self.async_step_pairing({}) @@ -533,10 +532,6 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - if entry_data.get(CONF_MODEL) and entry_data.get(CONF_NAME): - self._title = f"{entry_data[CONF_NAME]} ({entry_data[CONF_MODEL]})" - else: - self._title = entry_data.get(CONF_NAME) or entry_data[CONF_HOST] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -569,11 +564,11 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): # On websocket we will get RESULT_CANNOT_CONNECT when auth is missing errors = {"base": RESULT_AUTH_MISSING} - self.context["title_placeholders"] = {"device": self._title} + self.context["title_placeholders"] = {"device": reauth_entry.title} return self.async_show_form( step_id="reauth_confirm", errors=errors, - description_placeholders={"device": self._title}, + description_placeholders={"device": reauth_entry.title}, ) async def _async_start_encrypted_pairing(self, host: str) -> None: @@ -596,7 +591,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: if ( - (pin := user_input.get("pin")) + (pin := user_input.get(CONF_PIN)) and (token := await self._authenticator.try_pin(pin)) and (session_id := await self._authenticator.get_session_id_and_close()) ): @@ -610,10 +605,10 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): errors = {"base": RESULT_INVALID_PIN} - self.context["title_placeholders"] = {"device": self._title} + self.context["title_placeholders"] = {"device": reauth_entry.title} return self.async_show_form( step_id="reauth_confirm_encrypted", errors=errors, - description_placeholders={"device": self._title}, - data_schema=vol.Schema({vol.Required("pin"): str}), + description_placeholders={"device": reauth_entry.title}, + data_schema=vol.Schema({vol.Required(CONF_PIN): str}), ) diff --git a/homeassistant/components/samsungtv/coordinator.py b/homeassistant/components/samsungtv/coordinator.py index 443e62b13fb..ed3c24946ab 100644 --- a/homeassistant/components/samsungtv/coordinator.py +++ b/homeassistant/components/samsungtv/coordinator.py @@ -44,7 +44,7 @@ class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from SamsungTV bridge.""" - if self.bridge.auth_failed or self.hass.is_stopping: + if self.bridge.auth_failed: return old_state = self.is_on if self.bridge.power_off_in_progress: diff --git a/homeassistant/components/samsungtv/device_trigger.py b/homeassistant/components/samsungtv/device_trigger.py index 2b3d9dbe666..749276b61c4 100644 --- a/homeassistant/components/samsungtv/device_trigger.py +++ b/homeassistant/components/samsungtv/device_trigger.py @@ -15,6 +15,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import trigger +from .const import DOMAIN from .helpers import ( async_get_client_by_device_entry, async_get_device_entry_by_device_id, @@ -75,4 +76,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/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 61aa8abce53..1918f6ef28c 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from wakeonlan import send_magic_packet from homeassistant.const import ( @@ -10,7 +12,6 @@ from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_MODEL, - CONF_NAME, ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr @@ -39,9 +40,7 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) # Fallback for legacy models that doesn't have a API to retrieve MAC or SerialNumber self._attr_unique_id = config_entry.unique_id or config_entry.entry_id self._attr_device_info = DeviceInfo( - name=config_entry.data.get(CONF_NAME), manufacturer=config_entry.data.get(CONF_MANUFACTURER), - model=config_entry.data.get(CONF_MODEL), model_id=config_entry.data.get(CONF_MODEL), ) if self.unique_id: @@ -55,7 +54,7 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) @property def available(self) -> bool: """Return the availability of the device.""" - if self._bridge.auth_failed: + if not super().available or self._bridge.auth_failed: return False return ( self.coordinator.is_on @@ -82,12 +81,12 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) # broadcast a packet as well send_magic_packet(self._mac) - async def _async_turn_off(self) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._bridge.async_power_off() await self.coordinator.async_refresh() - async def _async_turn_on(self) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the remote on.""" if self._turn_on_action: LOGGER.debug("Attempting to turn on %s via automation", self.entity_id) @@ -106,5 +105,7 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) self.entity_id, ) raise HomeAssistantError( - f"Entity {self.entity_id} does not support this service." + translation_domain=DOMAIN, + translation_key="service_unsupported", + translation_placeholders={"entity": self.entity_id}, ) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 6a30efd64f8..5bb69e7f121 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==2.1.0", - "async-upnp-client==0.43.0" + "async-upnp-client==0.44.0" ], "ssdp": [ { diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 4e6ecfd3593..fa4f04a97ec 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -29,13 +29,14 @@ from homeassistant.components.media_player import ( MediaType, ) 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.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.async_ import create_eager_task from .bridge import SamsungTVWSBridge -from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, LOGGER +from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, LOGGER from .coordinator import SamsungTVConfigEntry, SamsungTVDataUpdateCoordinator from .entity import SamsungTVEntity @@ -59,6 +60,9 @@ SUPPORT_SAMSUNGTV = ( # Max delay waiting for app_list to return, as some TVs simply ignore the request APP_LIST_DELAY = 3 +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -99,8 +103,6 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET - self._bridge.register_app_list_callback(self._app_list_callback) - self._dmr_device: DmrDevice | None = None self._upnp_server: AiohttpNotifyServer | None = None @@ -127,8 +129,11 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() + + self._bridge.register_app_list_callback(self._app_list_callback) await self._async_extra_update() self.coordinator.async_extra_update = self._async_extra_update + if self.coordinator.is_on: self._attr_state = MediaPlayerState.ON self._update_from_upnp() @@ -296,10 +301,6 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): return await self._bridge.async_send_keys(keys) - async def async_turn_off(self) -> None: - """Turn off media player.""" - await super()._async_turn_off() - async def async_set_volume_level(self, volume: float) -> None: """Set volume level on the media player.""" if (dmr_device := self._dmr_device) is None: @@ -308,7 +309,12 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): try: await dmr_device.async_set_volume_level(volume) except UpnpActionResponseError as err: - LOGGER.warning("Unable to set volume level on %s: %r", self._host, err) + assert self._host + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_set_volume", + translation_placeholders={"error": repr(err), "host": self._host}, + ) from err async def async_volume_up(self) -> None: """Volume up the media player.""" @@ -370,10 +376,6 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): keys=[f"KEY_{digit}" for digit in media_id] + ["KEY_ENTER"] ) - async def async_turn_on(self) -> None: - """Turn the media player on.""" - await super()._async_turn_on() - async def async_select_source(self, source: str) -> None: """Select input source.""" if self._app_list and source in self._app_list: @@ -384,4 +386,8 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): await self._async_send_keys([SOURCES[source]]) return - LOGGER.error("Unsupported source") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="source_unsupported", + translation_placeholders={"entity": self.entity_id, "source": source}, + ) diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index d6fef262d91..ec2e8c45963 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -13,6 +13,9 @@ from .const import LOGGER from .coordinator import SamsungTVConfigEntry from .entity import SamsungTVEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -35,10 +38,6 @@ class SamsungTVRemote(SamsungTVEntity, RemoteEntity): self._attr_is_on = self.coordinator.is_on self.async_write_ha_state() - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - await super()._async_turn_off() - async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send a command to a device. @@ -54,7 +53,3 @@ class SamsungTVRemote(SamsungTVEntity, RemoteEntity): for _ in range(num_repeats): await self._bridge.async_send_keys(command_list) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the remote on.""" - await super()._async_turn_on() diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index c9d08f756d0..6251e65b2f8 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -9,7 +9,8 @@ "name": "[%key:common::config_flow::data::name%]" }, "data_description": { - "host": "The hostname or IP address of your TV." + "host": "The hostname or IP address of your TV.", + "name": "The name of your TV. This will be used to identify the device in Home Assistant." } }, "confirm": { @@ -22,14 +23,27 @@ "description": "After submitting, accept the popup on {device} requesting authorization within 30 seconds or input PIN." }, "encrypted_pairing": { - "description": "Please enter the PIN displayed on {device}." + "description": "Please enter the PIN displayed on {device}.", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The PIN displayed on your TV." + } }, "reauth_confirm_encrypted": { - "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]" + "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::samsungtv::config::step::encrypted_pairing::data_description::pin%]" + } } }, "error": { "auth_missing": "[%key:component::samsungtv::config::abort::auth_missing%]", + "invalid_host": "Host is invalid, please try again.", "invalid_pin": "PIN is invalid, please try again." }, "abort": { @@ -39,7 +53,6 @@ "id_missing": "This Samsung device doesn't have a SerialNumber.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "not_supported": "This Samsung device is currently not supported.", - "unknown": "[%key:common::config_flow::error::unknown%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, @@ -47,5 +60,25 @@ "trigger_type": { "samsungtv.turn_on": "Device is requested to turn on" } + }, + "exceptions": { + "unhandled_trigger_type": { + "message": "Unhandled trigger type {trigger_type}." + }, + "service_unsupported": { + "message": "Entity {entity} does not support this action." + }, + "source_unsupported": { + "message": "Entity {entity} does not support source {source}." + }, + "error_set_volume": { + "message": "Unable to set volume level on {host}: {error}" + }, + "error_sending_command": { + "message": "Unable to send command to {host}: {error}" + }, + "encrypted_mode_auth_failed": { + "message": "Token and session ID are required in encrypted mode." + } } } diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index 8638e4a8a84..bb81c029dbf 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -28,7 +28,7 @@ }, "get_schedule": { "name": "Get schedule", - "description": "Retrieve one or multiple schedules." + "description": "Retrieves the configured time ranges of one or multiple schedules." } } } diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 61cc2a3c63d..893c30dfd41 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2024.11.0"] + "requirements": ["pyschlage==2025.4.0"] } diff --git a/homeassistant/components/schlage/select.py b/homeassistant/components/schlage/select.py index 4648686aaac..cb142f01717 100644 --- a/homeassistant/components/schlage/select.py +++ b/homeassistant/components/schlage/select.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pyschlage.lock import AUTO_LOCK_TIMES + from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -15,16 +17,7 @@ _DESCRIPTIONS = ( key="auto_lock_time", translation_key="auto_lock_time", entity_category=EntityCategory.CONFIG, - # valid values are from Schlage UI and validated by pyschlage - options=[ - "0", - "15", - "30", - "60", - "120", - "240", - "300", - ], + options=[str(n) for n in AUTO_LOCK_TIMES], ), ) diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 56e72c2d2c0..e37f4789580 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -33,9 +33,10 @@ }, "select": { "auto_lock_time": { - "name": "Auto-Lock time", + "name": "Auto-lock time", "state": { - "0": "Disabled", + "0": "[%key:common::state::disabled%]", + "5": "5 seconds", "15": "15 seconds", "30": "30 seconds", "60": "1 minute", diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 68a8cf62fe4..801140157c1 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_SENSOR_BASE_SCHEMA, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType @@ -43,7 +44,9 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_ATTRIBUTE): cv.string, vol.Optional(CONF_INDEX, default=0): cv.positive_int, vol.Required(CONF_SELECT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), } ) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 56b9470b4f7..28e08372d68 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.12.3", "lxml==5.3.0"] + "requirements": ["beautifulsoup4==4.13.3", "lxml==5.3.0"] } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index b8ad9cb8a56..80d53a2c8b1 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -25,13 +25,14 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.template import Template +from homeassistant.helpers.template import _SENTINEL, Template from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, ManualTriggerEntity, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -110,8 +111,8 @@ async def async_setup_entry( name: str = sensor_config[CONF_NAME] value_string: str | None = sensor_config.get(CONF_VALUE_TEMPLATE) - value_template: Template | None = ( - Template(value_string, hass) if value_string is not None else None + value_template: ValueTemplate | None = ( + ValueTemplate(value_string, hass) if value_string is not None else None ) trigger_entity_config: dict[str, str | Template | None] = {CONF_NAME: name} @@ -150,7 +151,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti select: str, attr: str | None, index: int, - value_template: Template | None, + value_template: ValueTemplate | None, yaml: bool, ) -> None: """Initialize a web scrape sensor.""" @@ -161,7 +162,6 @@ 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 @@ -176,7 +176,6 @@ 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] @@ -188,14 +187,12 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti value = tag.text except IndexError: _LOGGER.warning("Index '%s' not found in %s", self._index, self.entity_id) - value = None - self._available = False + return _SENTINEL except KeyError: _LOGGER.warning( "Attribute '%s' not found in %s", self._attr, self.entity_id ) - value = None - self._available = False + return _SENTINEL _LOGGER.debug("Parsed value: %s", value) return value @@ -207,26 +204,32 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti def _async_update_from_rest_data(self) -> None: """Update state from the rest data.""" - value = self._extract_value() - raw_value = value + self._attr_available = True + if (value := self._extract_value()) is _SENTINEL: + self._attr_available = False + return + + variables = self._template_variables_with_value(value) + if not self._render_availability_template(variables): + return if (template := self._value_template) is not None: - value = template.async_render_with_possible_json_value(value, None) + value = template.async_render_as_value_template( + self.entity_id, variables, None + ) if self.device_class not in { SensorDeviceClass.DATE, SensorDeviceClass.TIMESTAMP, }: self._attr_native_value = value - self._attr_available = self._available - self._process_manual_data(raw_value) + self._process_manual_data(variables) 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._process_manual_data(variables) @property def available(self) -> bool: diff --git a/homeassistant/components/screenlogic/util.py b/homeassistant/components/screenlogic/util.py index 781d0fcab24..44fc8966b20 100644 --- a/homeassistant/components/screenlogic/util.py +++ b/homeassistant/components/screenlogic/util.py @@ -6,7 +6,7 @@ from screenlogicpy.const.data import SHARED_VALUES from homeassistant.helpers import entity_registry as er -from .const import DOMAIN as SL_DOMAIN, SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath +from .const import DOMAIN, SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -41,7 +41,7 @@ def cleanup_excluded_entity( entity_registry = er.async_get(coordinator.hass) unique_id = f"{coordinator.config_entry.unique_id}_{generate_unique_id(*data_path)}" if entity_id := entity_registry.async_get_entity_id( - platform_domain, SL_DOMAIN, unique_id + platform_domain, DOMAIN, unique_id ): _LOGGER.debug( "Removing existing entity '%s' per data inclusion rule", entity_id diff --git a/homeassistant/components/script/blueprints/confirmable_notification.yaml b/homeassistant/components/script/blueprints/confirmable_notification.yaml index c5f42494f02..0106a4e16c5 100644 --- a/homeassistant/components/script/blueprints/confirmable_notification.yaml +++ b/homeassistant/components/script/blueprints/confirmable_notification.yaml @@ -71,11 +71,11 @@ sequence: title: !input dismiss_text - alias: "Awaiting response" wait_for_trigger: - - platform: event + - trigger: event event_type: mobile_app_notification_action event_data: action: "{{ action_confirm }}" - - platform: event + - trigger: event event_type: mobile_app_notification_action event_data: action: "{{ action_dismiss }}" diff --git a/homeassistant/components/search/manifest.json b/homeassistant/components/search/manifest.json index cd372139451..42a54fe8b55 100644 --- a/homeassistant/components/search/manifest.json +++ b/homeassistant/components/search/manifest.json @@ -1,7 +1,6 @@ { "domain": "search", "name": "Search", - "after_dependencies": ["scene", "group", "automation", "script"], "codeowners": ["@home-assistant/core"], "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/search", diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 4196106edd2..18f520f9a23 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -127,7 +127,7 @@ class SelectEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) entity_description: SelectEntityDescription - _attr_current_option: str | None + _attr_current_option: str | None = None _attr_options: list[str] _attr_state: None = None diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index a7cee28f9c9..33106f0fd1b 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.5"] + "requirements": ["sense-energy==0.13.8"] } diff --git a/homeassistant/components/sense/strings.json b/homeassistant/components/sense/strings.json index 4579c84f050..c9ff5527940 100644 --- a/homeassistant/components/sense/strings.json +++ b/homeassistant/components/sense/strings.json @@ -10,7 +10,7 @@ } }, "validation": { - "title": "Sense Multi-factor authentication", + "title": "Sense multi-factor authentication", "data": { "code": "Verification code" } diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 0d6c47ce46c..c7116db7954 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -130,9 +130,10 @@ async def async_setup_entry( """Handle additions of devices and sensors.""" entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] nonlocal added_devices - new_devices, remove_devices, added_devices = coordinator.get_devices( + new_devices, remove_devices, new_added_devices = coordinator.get_devices( added_devices ) + added_devices = new_added_devices if LOGGER.isEnabledFor(logging.DEBUG): LOGGER.debug( @@ -168,8 +169,7 @@ async def async_setup_entry( device_data.model, DEVICE_SENSOR_TYPES ) ) - - async_add_entities(entities) + async_add_entities(entities) entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) _add_remove_devices() diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index ed0688d6f2c..d36967dae06 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -46,7 +46,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 2190d121248..a40cb110f66 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -149,7 +149,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( @@ -251,7 +252,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): return features @property - def current_humidity(self) -> int | None: + def current_humidity(self) -> float | None: """Return the current humidity.""" return self.device_data.humidity diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index e19f24295b9..3fa8a6e5dae 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -56,18 +56,31 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): ) -> tuple[set[str], set[str], set[str]]: """Addition and removal of devices.""" data = self.data - motion_sensors = { + current_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 + current_devices: set[str] = set(data.parsed) + LOGGER.debug( + "Current devices: %s, moption sensors: %s", + current_devices, + current_motion_sensors, + ) + new_devices: set[str] = ( + current_motion_sensors | current_devices + ) - added_devices + remove_devices = added_devices - current_devices - current_motion_sensors + new_added_devices = (added_devices - remove_devices) | new_devices - return (new_devices, remove_devices, added_devices) + LOGGER.debug( + "New devices: %s, Removed devices: %s, Added devices: %s", + new_devices, + remove_devices, + new_added_devices, + ) + return (new_devices, remove_devices, new_added_devices) async def _async_update_data(self) -> SensiboData: """Fetch data from Sensibo.""" diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index e6398c5076e..4cadd3f8692 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -14,5 +14,6 @@ }, "iot_class": "cloud_polling", "loggers": ["pysensibo"], - "requirements": ["pysensibo==1.1.0"] + "quality_scale": "platinum", + "requirements": ["pysensibo==1.2.1"] } diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index 9d077b308a0..e71ed6f0235 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -76,7 +76,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/quality_scale.yaml b/homeassistant/components/sensibo/quality_scale.yaml index c21cf100e9d..3d71d0ad3ba 100644 --- a/homeassistant/components/sensibo/quality_scale.yaml +++ b/homeassistant/components/sensibo/quality_scale.yaml @@ -19,9 +19,9 @@ rules: comment: | No integrations services. common-modules: done - docs-high-level-description: todo + docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done docs-actions: done brands: done # Silver @@ -39,9 +39,7 @@ rules: comment: | Tests are very complex and needs a rewrite for future additions integration-owner: done - docs-installation-parameters: - status: todo - comment: configuration_basic + docs-installation-parameters: done docs-configuration-parameters: status: exempt comment: | @@ -71,13 +69,13 @@ rules: 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 + 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 diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 73c0734ef73..5a0546b1aa2 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -115,7 +115,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 4174d4b859b..bab85eb2294 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -101,14 +101,25 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionSensorEntityDescription, ...] = ( value_fn=lambda data: data.temperature, ), ) + + +def _pure_aqi(pm25_pure: PureAQI | None) -> str | None: + """Return the Pure aqi name or None if unknown.""" + if pm25_pure: + aqi_name = pm25_pure.name.lower() + if aqi_name != "unknown": + return aqi_name + return None + + PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( SensiboDeviceSensorEntityDescription( key="pm25", translation_key="pm25_pure", device_class=SensorDeviceClass.ENUM, - value_fn=lambda data: data.pm25_pure.name.lower() if data.pm25_pure else None, + value_fn=lambda data: _pure_aqi(data.pm25_pure), extra_fn=None, - options=[aqi.name.lower() for aqi in PureAQI], + options=[aqi.name.lower() for aqi in PureAQI if aqi.name != "UNKNOWN"], ), SensiboDeviceSensorEntityDescription( key="pure_sensitivity", @@ -119,6 +130,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( FILTER_LAST_RESET_DESCRIPTION, ) + DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( SensiboDeviceSensorEntityDescription( key="timer_time", @@ -253,9 +265,8 @@ async def async_setup_entry( entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] nonlocal added_devices - new_devices, remove_devices, added_devices = coordinator.get_devices( - added_devices - ) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: entities.extend( diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 6aba2be52fc..4dce104d1c7 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -115,7 +115,7 @@ "sensitivity": { "name": "Pure sensitivity", "state": { - "n": "Normal", + "n": "[%key:common::state::normal%]", "s": "Sensitive" } }, @@ -139,11 +139,11 @@ "fanlevel": { "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", "state": { - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "auto": "[%key:common::state::auto%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "medium_low": "Medium low", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "Medium high", "strong": "Strong", "quiet": "Quiet" @@ -175,10 +175,10 @@ "name": "Mode", "state": { "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "heat": "[%key:component::climate::entity_component::_::state::heat%]", "cool": "[%key:component::climate::entity_component::_::state::cool%]", "heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]", - "auto": "[%key:component::climate::entity_component::_::state::auto%]", "dry": "[%key:component::climate::entity_component::_::state::dry%]", "fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]" } @@ -225,11 +225,11 @@ "fanlevel": { "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", "state": { - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "auto": "[%key:common::state::auto%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]" @@ -261,10 +261,10 @@ "name": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::mode::name%]", "state": { "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "heat": "[%key:component::climate::entity_component::_::state::heat%]", "cool": "[%key:component::climate::entity_component::_::state::cool%]", "heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]", - "auto": "[%key:component::climate::entity_component::_::state::auto%]", "dry": "[%key:component::climate::entity_component::_::state::dry%]", "fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]" } @@ -330,7 +330,7 @@ "timer_on_switch": { "name": "Timer", "state_attributes": { - "id": { "name": "Id" }, + "id": { "name": "ID" }, "turn_on": { "name": "Turns on", "state": { @@ -364,12 +364,12 @@ "state": { "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]", "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "low": "[%key:common::state::low%]", "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]" + "high": "[%key:common::state::high%]", + "auto": "[%key:common::state::auto%]" } }, "swing_mode": { @@ -524,7 +524,7 @@ "selector": { "sensitivity": { "options": { - "normal": "[%key:component::sensibo::entity::sensor::sensitivity::state::n%]", + "normal": "[%key:common::state::normal%]", "sensitive": "[%key:component::sensibo::entity::sensor::sensitivity::state::s%]" } }, @@ -536,12 +536,12 @@ }, "hvac_mode": { "options": { + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "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%]" + "dry": "[%key:component::climate::entity_component::_::state::dry%]" } }, "light_mode": { @@ -594,7 +594,7 @@ "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." + "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.\nDisable `{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 8c140074e57..03e7c12ec2b 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -89,7 +89,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 2103bbbf64a..6f868e5f366 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -56,7 +56,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 89f39d4fb8c..e06ee85cd03 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -44,6 +44,7 @@ from .const import ( # noqa: F401 DEVICE_CLASSES_SCHEMA, DOMAIN, NON_NUMERIC_DEVICE_CLASSES, + STATE_CLASS_UNITS, STATE_CLASSES, STATE_CLASSES_SCHEMA, UNIT_CONVERTERS, @@ -675,22 +676,13 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ): # Deduce the precision by finding the decimal point, if any value_s = str(value) - precision = ( - len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 - ) - # Scale the precision when converting to a larger unit # For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh - ratio_log = max( - 0, - log10( - converter.get_unit_ratio( - native_unit_of_measurement, unit_of_measurement - ) - ), + precision = ( + len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 + ) + converter.get_unit_floored_log_ratio( + native_unit_of_measurement, unit_of_measurement ) - precision = precision + floor(ratio_log) - value = f"{converted_numerical_value:z.{precision}f}" else: value = converted_numerical_value @@ -722,6 +714,18 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): report_issue, ) + # Validate unit of measurement used for sensors with a state class + if ( + state_class + and (units := STATE_CLASS_UNITS.get(state_class)) is not None + and native_unit_of_measurement not in units + ): + raise ValueError( + f"Sensor {self.entity_id} ({type(self)}) is using native unit of " + f"measurement '{native_unit_of_measurement}' which is not a valid unit " + f"for the state class ('{state_class}') it is using; expected one of {units};" + ) + return value def _display_precision_or_none(self) -> int | None: diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 8eccb758756..31b33303dd4 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -33,6 +33,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPrecipitationDepth, UnitOfPressure, + UnitOfReactiveEnergy, UnitOfReactivePower, UnitOfSoundPressure, UnitOfSpeed, @@ -58,6 +59,7 @@ from homeassistant.util.unit_conversion import ( MassConverter, PowerConverter, PressureConverter, + ReactiveEnergyConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -186,7 +188,7 @@ class SensorDeviceClass(StrEnum): DURATION = "duration" """Fixed duration. - Unit of measurement: `d`, `h`, `min`, `s`, `ms` + Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs` """ ENERGY = "energy" @@ -225,7 +227,7 @@ class SensorDeviceClass(StrEnum): """Gas. Unit of measurement: - - SI / metric: `m³` + - SI / metric: `L`, `m³` - USCS / imperial: `ft³`, `CCF` """ @@ -349,10 +351,16 @@ class SensorDeviceClass(StrEnum): - `psi` """ + REACTIVE_ENERGY = "reactive_energy" + """Reactive energy. + + Unit of measurement: `varh`, `kvarh` + """ + REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var` + Unit of measurement: `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -491,6 +499,9 @@ class SensorStateClass(StrEnum): MEASUREMENT = "measurement" """The state represents a measurement in present time.""" + MEASUREMENT_ANGLE = "measurement_angle" + """The state represents a angle measurement in present time. Currently only degrees are supported.""" + TOTAL = "total" """The state represents a total amount. @@ -526,6 +537,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.PRECIPITATION: DistanceConverter, SensorDeviceClass.PRECIPITATION_INTENSITY: SpeedConverter, SensorDeviceClass.PRESSURE: PressureConverter, + SensorDeviceClass.REACTIVE_ENERGY: ReactiveEnergyConverter, SensorDeviceClass.SPEED: SpeedConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: UnitlessRatioConverter, @@ -558,6 +570,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MINUTES, UnitOfTime.SECONDS, UnitOfTime.MILLISECONDS, + UnitOfTime.MICROSECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), SensorDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), @@ -567,6 +580,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS, + UnitOfVolume.LITERS, }, SensorDeviceClass.HUMIDITY: {PERCENTAGE}, SensorDeviceClass.ILLUMINANCE: {LIGHT_LUX}, @@ -582,6 +596,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, SensorDeviceClass.POWER: { + UnitOfPower.MILLIWATT, UnitOfPower.WATT, UnitOfPower.KILO_WATT, UnitOfPower.MEGA_WATT, @@ -591,7 +606,8 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), SensorDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), SensorDeviceClass.PRESSURE: set(UnitOfPressure), - SensorDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE}, + SensorDeviceClass.REACTIVE_ENERGY: set(UnitOfReactiveEnergy), + SensorDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower), SensorDeviceClass.SIGNAL_STRENGTH: { SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -666,6 +682,10 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.PRECIPITATION: set(SensorStateClass), SensorDeviceClass.PRECIPITATION_INTENSITY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.PRESSURE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.REACTIVE_ENERGY: { + SensorStateClass.TOTAL, + SensorStateClass.TOTAL_INCREASING, + }, SensorDeviceClass.REACTIVE_POWER: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.SIGNAL_STRENGTH: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.SOUND_PRESSURE: {SensorStateClass.MEASUREMENT}, @@ -691,6 +711,11 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, - SensorDeviceClass.WIND_DIRECTION: set(), + SensorDeviceClass.WIND_DIRECTION: {SensorStateClass.MEASUREMENT_ANGLE}, SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT}, } + + +STATE_CLASS_UNITS: dict[SensorStateClass | str, set[type[StrEnum] | str | None]] = { + SensorStateClass.MEASUREMENT_ANGLE: {DEGREE}, +} diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index f52393f28ff..2b1eb350c3e 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -70,6 +70,7 @@ CONF_IS_PRECIPITATION = "is_precipitation" CONF_IS_PRECIPITATION_INTENSITY = "is_precipitation_intensity" CONF_IS_PRESSURE = "is_pressure" CONF_IS_SPEED = "is_speed" +CONF_IS_REACTIVE_ENERGY = "is_reactive_energy" CONF_IS_REACTIVE_POWER = "is_reactive_power" CONF_IS_SIGNAL_STRENGTH = "is_signal_strength" CONF_IS_SOUND_PRESSURE = "is_sound_pressure" @@ -128,6 +129,7 @@ ENTITY_CONDITIONS = { {CONF_TYPE: CONF_IS_PRECIPITATION_INTENSITY} ], SensorDeviceClass.PRESSURE: [{CONF_TYPE: CONF_IS_PRESSURE}], + SensorDeviceClass.REACTIVE_ENERGY: [{CONF_TYPE: CONF_IS_REACTIVE_ENERGY}], SensorDeviceClass.REACTIVE_POWER: [{CONF_TYPE: CONF_IS_REACTIVE_POWER}], SensorDeviceClass.SIGNAL_STRENGTH: [{CONF_TYPE: CONF_IS_SIGNAL_STRENGTH}], SensorDeviceClass.SOUND_PRESSURE: [{CONF_TYPE: CONF_IS_SOUND_PRESSURE}], @@ -193,6 +195,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_PRECIPITATION, CONF_IS_PRECIPITATION_INTENSITY, CONF_IS_PRESSURE, + CONF_IS_REACTIVE_ENERGY, CONF_IS_REACTIVE_POWER, CONF_IS_SIGNAL_STRENGTH, CONF_IS_SOUND_PRESSURE, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index dee48434294..d44611a49db 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -68,6 +68,7 @@ CONF_POWER_FACTOR = "power_factor" CONF_PRECIPITATION = "precipitation" CONF_PRECIPITATION_INTENSITY = "precipitation_intensity" CONF_PRESSURE = "pressure" +CONF_REACTIVE_ENERGY = "reactive_energy" CONF_REACTIVE_POWER = "reactive_power" CONF_SIGNAL_STRENGTH = "signal_strength" CONF_SOUND_PRESSURE = "sound_pressure" @@ -127,6 +128,7 @@ ENTITY_TRIGGERS = { {CONF_TYPE: CONF_PRECIPITATION_INTENSITY} ], SensorDeviceClass.PRESSURE: [{CONF_TYPE: CONF_PRESSURE}], + SensorDeviceClass.REACTIVE_ENERGY: [{CONF_TYPE: CONF_REACTIVE_ENERGY}], SensorDeviceClass.REACTIVE_POWER: [{CONF_TYPE: CONF_REACTIVE_POWER}], SensorDeviceClass.SIGNAL_STRENGTH: [{CONF_TYPE: CONF_SIGNAL_STRENGTH}], SensorDeviceClass.SOUND_PRESSURE: [{CONF_TYPE: CONF_SOUND_PRESSURE}], @@ -193,6 +195,7 @@ TRIGGER_SCHEMA = vol.All( CONF_PRECIPITATION, CONF_PRECIPITATION_INTENSITY, CONF_PRESSURE, + CONF_REACTIVE_ENERGY, CONF_REACTIVE_POWER, CONF_SIGNAL_STRENGTH, CONF_SOUND_PRESSURE, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 497c1544b3b..cc64290d241 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -114,6 +114,9 @@ "pressure": { "default": "mdi:gauge" }, + "reactive_energy": { + "default": "mdi:lightning-bolt" + }, "reactive_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 675d24b9240..c321caa616d 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Callable, Iterable from contextlib import suppress +from dataclasses import dataclass import datetime import itertools import logging @@ -21,6 +22,7 @@ from homeassistant.components.recorder import ( ) from homeassistant.components.recorder.models import ( StatisticData, + StatisticMeanType, StatisticMetaData, StatisticResult, ) @@ -52,10 +54,22 @@ from .const import ( _LOGGER = logging.getLogger(__name__) + +@dataclass +class _StatisticsConfig: + types: set[str] + mean_type: StatisticMeanType = StatisticMeanType.NONE + + DEFAULT_STATISTICS = { - SensorStateClass.MEASUREMENT: {"mean", "min", "max"}, - SensorStateClass.TOTAL: {"sum"}, - SensorStateClass.TOTAL_INCREASING: {"sum"}, + SensorStateClass.MEASUREMENT: _StatisticsConfig( + {"mean", "min", "max"}, StatisticMeanType.ARITHMETIC + ), + SensorStateClass.MEASUREMENT_ANGLE: _StatisticsConfig( + {"mean"}, StatisticMeanType.CIRCULAR + ), + SensorStateClass.TOTAL: _StatisticsConfig({"sum"}), + SensorStateClass.TOTAL_INCREASING: _StatisticsConfig({"sum"}), } EQUIVALENT_UNITS = { @@ -76,8 +90,15 @@ WARN_NEGATIVE: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_total_increasing_nega # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unsupported_unit") WARN_UNSTABLE_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unstable_unit") +# Keep track of entities for which a warning about statistics mean algorithm change has been logged +WARN_STATISTICS_MEAN_CHANGED: HassKey[set[str]] = HassKey( + f"{DOMAIN}_warn_statistics_mean_change" +) # Link to dev statistics where issues around LTS can be fixed LINK_DEV_STATISTICS = "https://my.home-assistant.io/redirect/developer_statistics" +STATE_CLASS_REMOVED_ISSUE = "state_class_removed" +UNITS_CHANGED_ISSUE = "units_changed" +MEAN_TYPE_CHANGED_ISSUE = "mean_type_changed" def _get_sensor_states(hass: HomeAssistant) -> list[State]: @@ -99,7 +120,7 @@ def _get_sensor_states(hass: HomeAssistant) -> list[State]: ] -def _time_weighted_average( +def _time_weighted_arithmetic_mean( fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime ) -> float: """Calculate a time weighted average. @@ -134,16 +155,44 @@ def _time_weighted_average( duration = end - old_start_time accumulated += old_fstate * duration.total_seconds() - period_seconds = (end - start).total_seconds() - if period_seconds == 0: - # If the only state changed that happened was at the exact moment - # at the end of the period, we can't calculate a meaningful average - # so we return 0.0 since it represents a time duration smaller than - # we can measure. This probably means the precision of statistics - # column schema in the database is incorrect but it is actually possible - # to happen if the state change event fired at the exact microsecond - return 0.0 - return accumulated / period_seconds + return accumulated / (end - start).total_seconds() + + +def _time_weighted_circular_mean( + fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime +) -> tuple[float, float]: + """Calculate a time weighted circular mean. + + The circular mean is calculated by weighting the states by duration in seconds between + state changes. + Note: there's no interpolation of values between state changes. + """ + old_fstate: float | None = None + old_start_time: datetime.datetime | None = None + values: list[tuple[float, float]] = [] + + for fstate, state in fstates: + # The recorder will give us the last known state, which may be well + # before the requested start time for the statistics + start_time = max(state.last_updated, start) + if old_start_time is None: + # Adjust start time, if there was no last known state + start = start_time + else: + duration = (start_time - old_start_time).total_seconds() + assert old_fstate is not None + values.append((old_fstate, duration)) + + old_fstate = fstate + old_start_time = start_time + + if old_fstate is not None: + # Add last value weighted by duration until end of the period + assert old_start_time is not None + duration = (end - old_start_time).total_seconds() + values.append((old_fstate, duration)) + + return statistics.weighted_circular_mean(values) def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: @@ -371,7 +420,7 @@ def reset_detected( return fstate < 0.9 * previous_fstate -def _wanted_statistics(sensor_states: list[State]) -> dict[str, set[str]]: +def _wanted_statistics(sensor_states: list[State]) -> dict[str, _StatisticsConfig]: """Prepare a dict with wanted statistics for entities.""" return { state.entity_id: DEFAULT_STATISTICS[state.attributes[ATTR_STATE_CLASS]] @@ -415,7 +464,9 @@ def compile_statistics( # noqa: C901 wanted_statistics = _wanted_statistics(sensor_states) # Get history between start and end entities_full_history = [ - i.entity_id for i in sensor_states if "sum" in wanted_statistics[i.entity_id] + i.entity_id + for i in sensor_states + if "sum" in wanted_statistics[i.entity_id].types ] history_list: dict[str, list[State]] = {} if entities_full_history: @@ -430,7 +481,7 @@ def compile_statistics( # noqa: C901 entities_significant_history = [ i.entity_id for i in sensor_states - if "sum" not in wanted_statistics[i.entity_id] + if "sum" not in wanted_statistics[i.entity_id].types ] if entities_significant_history: _history_list = history.get_full_significant_states_with_session( @@ -447,7 +498,11 @@ def compile_statistics( # noqa: C901 entity_id = _state.entity_id # If there are no recent state changes, the sensor's state may already be pruned # from the recorder. Get the state from the state machine instead. - if not (entity_history := history_list.get(entity_id, [_state])): + try: + entity_history = history_list[entity_id] + except KeyError: + entity_history = [_state] if _state.last_changed < end else [] + if not entity_history: continue if not (float_states := _entity_history_to_float_and_state(entity_history)): continue @@ -476,7 +531,7 @@ def compile_statistics( # noqa: C901 continue state_class: str = _state.attributes[ATTR_STATE_CLASS] to_process.append((entity_id, statistics_unit, state_class, valid_float_states)) - if "sum" in wanted_statistics[entity_id]: + if "sum" in wanted_statistics[entity_id].types: to_query.add(entity_id) last_stats = statistics.get_latest_short_term_statistics_with_session( @@ -488,6 +543,10 @@ def compile_statistics( # noqa: C901 state_class, valid_float_states, ) in to_process: + mean_type = StatisticMeanType.NONE + if "mean" in wanted_statistics[entity_id].types: + mean_type = wanted_statistics[entity_id].mean_type + # Check metadata if old_metadata := old_metadatas.get(entity_id): if not _equivalent_units( @@ -513,10 +572,34 @@ def compile_statistics( # noqa: C901 ) continue + if ( + mean_type is not StatisticMeanType.NONE + and (old_mean_type := old_metadata[1]["mean_type"]) + is not StatisticMeanType.NONE + and mean_type != old_mean_type + ): + if WARN_STATISTICS_MEAN_CHANGED not in hass.data: + hass.data[WARN_STATISTICS_MEAN_CHANGED] = set() + if entity_id not in hass.data[WARN_STATISTICS_MEAN_CHANGED]: + hass.data[WARN_STATISTICS_MEAN_CHANGED].add(entity_id) + _LOGGER.warning( + ( + "The statistics mean algorithm for %s have changed from %s to %s." + " Generation of long term statistics will be suppressed" + " unless it changes back or go to %s to delete the old" + " statistics" + ), + entity_id, + old_mean_type.name, + mean_type.name, + LINK_DEV_STATISTICS, + ) + continue + # Set meta data meta: StatisticMetaData = { - "has_mean": "mean" in wanted_statistics[entity_id], - "has_sum": "sum" in wanted_statistics[entity_id], + "mean_type": mean_type, + "has_sum": "sum" in wanted_statistics[entity_id].types, "name": None, "source": RECORDER_DOMAIN, "statistic_id": entity_id, @@ -525,19 +608,26 @@ def compile_statistics( # noqa: C901 # Make calculations stat: StatisticData = {"start": start} - if "max" in wanted_statistics[entity_id]: + if "max" in wanted_statistics[entity_id].types: stat["max"] = max( *itertools.islice(zip(*valid_float_states, strict=False), 1) ) - if "min" in wanted_statistics[entity_id]: + if "min" in wanted_statistics[entity_id].types: stat["min"] = min( *itertools.islice(zip(*valid_float_states, strict=False), 1) ) - if "mean" in wanted_statistics[entity_id]: - stat["mean"] = _time_weighted_average(valid_float_states, start, end) + match mean_type: + case StatisticMeanType.ARITHMETIC: + stat["mean"] = _time_weighted_arithmetic_mean( + valid_float_states, start, end + ) + case StatisticMeanType.CIRCULAR: + stat["mean"], stat["mean_weight"] = _time_weighted_circular_mean( + valid_float_states, start, end + ) - if "sum" in wanted_statistics[entity_id]: + if "sum" in wanted_statistics[entity_id].types: last_reset = old_last_reset = None new_state = old_state = None _sum = 0.0 @@ -661,18 +751,25 @@ def list_statistic_ids( attributes = state.attributes state_class = attributes[ATTR_STATE_CLASS] provided_statistics = DEFAULT_STATISTICS[state_class] - if statistic_type is not None and statistic_type not in provided_statistics: + if ( + statistic_type is not None + and statistic_type not in provided_statistics.types + ): continue if ( - (has_sum := "sum" in provided_statistics) + (has_sum := "sum" in provided_statistics.types) and ATTR_LAST_RESET not in attributes and state_class == SensorStateClass.MEASUREMENT ): continue + mean_type = StatisticMeanType.NONE + if "mean" in provided_statistics.types: + mean_type = provided_statistics.mean_type + result[entity_id] = { - "has_mean": "mean" in provided_statistics, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": RECORDER_DOMAIN, @@ -702,7 +799,7 @@ def _update_issues( if numeric and state_class is None: # Sensor no longer has a valid state class report_issue( - "state_class_removed", + STATE_CLASS_REMOVED_ISSUE, entity_id, {"statistic_id": entity_id}, ) @@ -713,7 +810,7 @@ def _update_issues( if numeric and not _equivalent_units({state_unit, metadata_unit}): # The unit has changed, and it's not possible to convert report_issue( - "units_changed", + UNITS_CHANGED_ISSUE, entity_id, { "statistic_id": entity_id, @@ -727,7 +824,7 @@ def _update_issues( valid_units = (unit or "" for unit in converter.VALID_UNITS) valid_units_str = ", ".join(sorted(valid_units)) report_issue( - "units_changed", + UNITS_CHANGED_ISSUE, entity_id, { "statistic_id": entity_id, @@ -737,6 +834,23 @@ def _update_issues( }, ) + if ( + (metadata_mean_type := metadata[1]["mean_type"]) is not None + and state_class + and (state_mean_type := DEFAULT_STATISTICS[state_class].mean_type) + != metadata_mean_type + ): + # The mean type has changed and the old statistics are not valid anymore + report_issue( + MEAN_TYPE_CHANGED_ISSUE, + entity_id, + { + "statistic_id": entity_id, + "metadata_mean_type": metadata_mean_type, + "state_mean_type": state_mean_type, + }, + ) + def update_statistics_issues( hass: HomeAssistant, @@ -759,7 +873,11 @@ def update_statistics_issues( issue.domain != DOMAIN or not (issue_data := issue.data) or issue_data.get("issue_type") - not in ("state_class_removed", "units_changed") + not in ( + STATE_CLASS_REMOVED_ISSUE, + UNITS_CHANGED_ISSUE, + MEAN_TYPE_CHANGED_ISSUE, + ) ): continue issues.add(issue.issue_id) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index ae414a178e9..4ad6597692c 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -38,6 +38,7 @@ "is_precipitation": "Current {entity_name} precipitation", "is_precipitation_intensity": "Current {entity_name} precipitation intensity", "is_pressure": "Current {entity_name} pressure", + "is_reactive_energy": "Current {entity_name} reactive energy", "is_reactive_power": "Current {entity_name} reactive power", "is_signal_strength": "Current {entity_name} signal strength", "is_sound_pressure": "Current {entity_name} sound pressure", @@ -92,6 +93,7 @@ "precipitation": "{entity_name} precipitation changes", "precipitation_intensity": "{entity_name} precipitation intensity changes", "pressure": "{entity_name} pressure changes", + "reactive_energy": "{entity_name} reactive energy changes", "reactive_power": "{entity_name} reactive power changes", "signal_strength": "{entity_name} signal strength changes", "sound_pressure": "{entity_name} sound pressure changes", @@ -256,6 +258,9 @@ "pressure": { "name": "Pressure" }, + "reactive_energy": { + "name": "Reactive energy" + }, "reactive_power": { "name": "Reactive power" }, @@ -278,10 +283,10 @@ "name": "Timestamp" }, "volatile_organic_compounds": { - "name": "VOCs" + "name": "Volatile organic compounds" }, "volatile_organic_compounds_parts": { - "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + "name": "Volatile organic compounds parts" }, "voltage": { "name": "Voltage" @@ -309,6 +314,10 @@ } }, "issues": { + "mean_type_changed": { + "title": "The mean type of {statistic_id} has changed", + "description": "" + }, "state_class_removed": { "title": "{statistic_id} no longer has a state class", "description": "" diff --git a/homeassistant/components/sensorpro/manifest.json b/homeassistant/components/sensorpro/manifest.json index ae3229e24c1..ccf042245ea 100644 --- a/homeassistant/components/sensorpro/manifest.json +++ b/homeassistant/components/sensorpro/manifest.json @@ -18,5 +18,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensorpro", "iot_class": "local_push", - "requirements": ["sensorpro-ble==0.5.3"] + "requirements": ["sensorpro-ble==0.7.0"] } diff --git a/homeassistant/components/sensorpush/manifest.json b/homeassistant/components/sensorpush/manifest.json index 7729a67d7a1..a7758960b2b 100644 --- a/homeassistant/components/sensorpush/manifest.json +++ b/homeassistant/components/sensorpush/manifest.json @@ -17,5 +17,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensorpush", "iot_class": "local_push", - "requirements": ["sensorpush-ble==1.7.1"] + "requirements": ["sensorpush-ble==1.9.0"] } diff --git a/homeassistant/components/sensorpush_cloud/manifest.json b/homeassistant/components/sensorpush_cloud/manifest.json index ad817251fa1..6fd6513ad2d 100644 --- a/homeassistant/components/sensorpush_cloud/manifest.json +++ b/homeassistant/components/sensorpush_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["sensorpush_api", "sensorpush_ha"], "quality_scale": "bronze", - "requirements": ["sensorpush-api==2.1.1", "sensorpush-ha==1.3.2"] + "requirements": ["sensorpush-api==2.1.2", "sensorpush-ha==1.3.2"] } diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index 904d493a863..5b89518c616 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Mapping import re -from types import MappingProxyType from typing import Any import sentry_sdk @@ -120,7 +120,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def process_before_send( hass: HomeAssistant, - options: MappingProxyType[str, Any], + options: Mapping[str, Any], channel: str, huuid: str, system_info: dict[str, bool | str], diff --git a/homeassistant/components/sentry/strings.json b/homeassistant/components/sentry/strings.json index efcdb631f3c..22f7b355e0e 100644 --- a/homeassistant/components/sentry/strings.json +++ b/homeassistant/components/sentry/strings.json @@ -24,7 +24,7 @@ "event_handled": "Send handled events", "event_third_party_packages": "Send events from third-party packages", "logging_event_level": "The log level Sentry will register an event for", - "logging_level": "The log level Sentry will record logs as breadcrums for", + "logging_level": "The log level Sentry will record events as breadcrumbs for", "tracing": "Enable performance tracing", "tracing_sample_rate": "Tracing sample rate; between 0.0 and 1.0 (1.0 = 100%)" } diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json index cfe9196f596..2a5d3c78737 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.14"] + "requirements": ["pyserial-asyncio-fast==0.16"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index cdc3b16f95d..6107a6057d1 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.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index b0f9d6cd2bd..c6fd7942655 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -2,11 +2,8 @@ from __future__ import annotations -from typing import Any - 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 from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -14,15 +11,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SeventeenTrackCoordinator -from .const import ( - ATTR_INFO_TEXT, - ATTR_PACKAGES, - ATTR_STATUS, - ATTR_TIMESTAMP, - ATTR_TRACKING_NUMBER, - ATTRIBUTION, - DOMAIN, -) +from .const import ATTRIBUTION, DOMAIN async def async_setup_entry( @@ -81,22 +70,3 @@ class SeventeenTrackSummarySensor(SeventeenTrackSensor): def native_value(self) -> StateType: """Return the state of the sensor.""" return self.coordinator.data.summary[self._status]["quantity"] - - # This has been deprecated in 2024.8, will be removed in 2025.2 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - packages = self.coordinator.data.summary[self._status]["packages"] - return { - ATTR_PACKAGES: [ - { - ATTR_TRACKING_NUMBER: package.tracking_number, - ATTR_LOCATION: package.location, - ATTR_STATUS: package.status, - ATTR_TIMESTAMP: package.timestamp, - ATTR_INFO_TEXT: package.info_text, - ATTR_FRIENDLY_NAME: package.friendly_name, - } - for package in packages - ] - } diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 8b495da56c3..ca064d137b7 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -174,6 +174,7 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda x: x.alimvoltage, ), SFRBoxSensorEntityDescription[SystemInfo]( @@ -182,6 +183,7 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda x: _get_temperature(x.temperature), ), ) diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 0e07dd96902..9f9009693e5 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", "loggers": ["sharkiq"], - "requirements": ["sharkiq==1.0.2"] + "requirements": ["sharkiq==1.1.0"] } diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index 3c4c98db38f..33826baaf5b 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -3,7 +3,7 @@ "flow_title": "Add Shark IQ account", "step": { "user": { - "description": "Sign into your SharkClean account to control your devices.", + "description": "Sign in to your SharkClean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 5ca58ec7d01..3130acff538 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Final +from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions from aioshelly.const import DEFAULT_COAP_PORT, RPC_GENERATIONS @@ -11,12 +12,19 @@ from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, MacAddressMismatchError, + RpcCallError, ) -from aioshelly.rpc_device import RpcDevice +from aioshelly.rpc_device import RpcDevice, bluetooth_mac_from_primary_mac 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.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( @@ -48,11 +56,13 @@ from .coordinator import ( ShellyRpcCoordinator, ShellyRpcPollingCoordinator, ) +from .repairs import async_manage_ble_scanner_firmware_unsupported_issue from .utils import ( async_create_issue_unsupported_firmware, get_coap_context, get_device_entry_gen, get_http_port, + get_rpc_scripts_event_types, get_ws_context, ) @@ -102,6 +112,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool: """Set up Shelly from a config entry.""" + entry.runtime_data = ShellyEntryData([]) + # The custom component for Shelly devices uses shelly domain as well as core # integration. If the user removes the custom component but doesn't remove the # config entry, core integration will try to configure that config entry with an @@ -153,13 +165,14 @@ async def _async_setup_block_entry( device_entry = None sleep_period = entry.data.get(CONF_SLEEP_PERIOD) - runtime_data = entry.runtime_data = ShellyEntryData(BLOCK_SLEEPING_PLATFORMS) + runtime_data = entry.runtime_data + runtime_data.platforms = BLOCK_SLEEPING_PLATFORMS # Some old firmware have a wrong sleep period hardcoded value. # Following code block will force the right value for affected devices if ( sleep_period == BLOCK_WRONG_SLEEP_PERIOD - and entry.data["model"] in MODELS_WITH_WRONG_SLEEP_PERIOD + and entry.data[CONF_MODEL] in MODELS_WITH_WRONG_SLEEP_PERIOD ): LOGGER.warning( "Updating stored sleep period for %s: from %s to %s", @@ -180,13 +193,25 @@ async def _async_setup_block_entry( if not device.firmware_supported: async_create_issue_unsupported_firmware(hass, entry) await device.shutdown() - raise ConfigEntryNotReady + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="firmware_unsupported", + translation_placeholders={"device": entry.title}, + ) except (DeviceConnectionError, MacAddressMismatchError) as err: await device.shutdown() - raise ConfigEntryNotReady(repr(err)) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err except InvalidAuthError as err: await device.shutdown() - raise ConfigEntryAuthFailed(repr(err)) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"device": entry.title}, + ) from err runtime_data.block = ShellyBlockCoordinator(hass, entry, device) runtime_data.block.async_setup() @@ -252,7 +277,8 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) device_entry = None sleep_period = entry.data.get(CONF_SLEEP_PERIOD) - runtime_data = entry.runtime_data = ShellyEntryData(RPC_SLEEPING_PLATFORMS) + runtime_data = entry.runtime_data + runtime_data.platforms = RPC_SLEEPING_PLATFORMS if sleep_period == 0: # Not a sleeping device, finish setup @@ -263,13 +289,31 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) if not device.firmware_supported: async_create_issue_unsupported_firmware(hass, entry) await device.shutdown() - raise ConfigEntryNotReady - except (DeviceConnectionError, MacAddressMismatchError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="firmware_unsupported", + translation_placeholders={"device": entry.title}, + ) + runtime_data.rpc_zigbee_enabled = device.zigbee_enabled + runtime_data.rpc_supports_scripts = await device.supports_scripts() + if runtime_data.rpc_supports_scripts: + runtime_data.rpc_script_events = await get_rpc_scripts_event_types( + device, ignore_scripts=[BLE_SCRIPT_NAME] + ) + except (DeviceConnectionError, MacAddressMismatchError, RpcCallError) as err: await device.shutdown() - raise ConfigEntryNotReady(repr(err)) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err except InvalidAuthError as err: await device.shutdown() - raise ConfigEntryAuthFailed(repr(err)) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"device": entry.title}, + ) from err runtime_data.rpc = ShellyRpcCoordinator(hass, entry, device) runtime_data.rpc.async_setup() @@ -277,6 +321,10 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) await hass.config_entries.async_forward_entry_setups( entry, runtime_data.platforms ) + async_manage_ble_scanner_firmware_unsupported_issue( + hass, + entry, + ) elif ( sleep_period is None or device_entry is None @@ -339,4 +387,5 @@ async def async_remove_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> N if get_device_entry_gen(entry) in RPC_GENERATIONS and ( mac_address := entry.unique_id ): - async_remove_scanner(hass, mac_address) + source = dr.format_mac(bluetooth_mac_from_primary_mac(mac_address)).upper() + async_remove_scanner(hass, source) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index ed2ac68d264..ed5a00fffb3 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -42,6 +42,8 @@ from .utils import ( is_rpc_momentary_input, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class BlockBinarySensorDescription( @@ -130,6 +132,7 @@ SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = { device_class=BinarySensorDeviceClass.GAS, translation_key="gas", value=lambda value: value in ["mild", "heavy"], + # Deprecated, remove in 2025.10 extra_state_attributes=lambda block: {"detected": block.gas}, ), ("sensor", "smoke"): BlockBinarySensorDescription( diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index d7eb020d671..2b772bd1b78 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -7,15 +7,22 @@ from typing import TYPE_CHECKING from aioshelly.ble import async_start_scanner, create_scanner from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION -from homeassistant.components.bluetooth import async_register_scanner +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + async_register_scanner, +) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback -from homeassistant.helpers.device_registry import format_mac from ..const import BLEScannerMode if TYPE_CHECKING: from ..coordinator import ShellyRpcCoordinator +BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE = { + BLEScannerMode.PASSIVE: BluetoothScanningMode.PASSIVE, + BLEScannerMode.ACTIVE: BluetoothScanningMode.ACTIVE, +} + async def async_connect_scanner( hass: HomeAssistant, @@ -26,8 +33,13 @@ async def async_connect_scanner( """Connect scanner.""" device = coordinator.device entry = coordinator.config_entry - source = format_mac(coordinator.mac).upper() - scanner = create_scanner(source, entry.title) + bluetooth_scanning_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode] + scanner = create_scanner( + coordinator.bluetooth_source, + entry.title, + requested_mode=bluetooth_scanning_mode, + current_mode=bluetooth_scanning_mode, + ) unload_callbacks = [ async_register_scanner( hass, diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 1f3c555a64b..77b4021b03b 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Callable from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any, Final -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY, RPC_GENERATIONS +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.button import ( ButtonDeviceClass, @@ -16,15 +17,22 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -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 AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -from .const import LOGGER, SHELLY_GAS_MODELS +from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .utils import get_device_entry_gen +from .utils import get_device_entry_gen, get_rpc_key_ids + +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -33,7 +41,7 @@ class ShellyButtonDescription[ ](ButtonEntityDescription): """Class to describe a Button entity.""" - press_action: Callable[[_ShellyCoordinatorT], Coroutine[Any, Any, None]] + press_action: str supported: Callable[[_ShellyCoordinatorT], bool] = lambda _: True @@ -44,14 +52,14 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_reboot(), + press_action="trigger_reboot", ), ShellyButtonDescription[ShellyBlockCoordinator]( key="self_test", name="Self test", translation_key="self_test", entity_category=EntityCategory.DIAGNOSTIC, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_self_test(), + press_action="trigger_shelly_gas_self_test", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( @@ -59,7 +67,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Mute", translation_key="mute", entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_mute(), + press_action="trigger_shelly_gas_mute", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( @@ -67,11 +75,22 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Unmute", translation_key="unmute", entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_unmute(), + press_action="trigger_shelly_gas_unmute", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ] +BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [ + ShellyButtonDescription[ShellyRpcCoordinator]( + key="calibrate", + name="Calibrate", + translation_key="calibrate", + entity_category=EntityCategory.CONFIG, + press_action="trigger_blu_trv_calibration", + supported=lambda coordinator: coordinator.device.model == MODEL_BLU_GATEWAY, + ), +] + @callback def async_migrate_unique_ids( @@ -123,14 +142,28 @@ async def async_setup_entry( hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) ) - async_add_entities( + entities: list[ShellyButton | ShellyBluTrvButton] = [] + + entities.extend( ShellyButton(coordinator, button) for button in BUTTONS if button.supported(coordinator) ) + if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER): + if TYPE_CHECKING: + assert isinstance(coordinator, ShellyRpcCoordinator) -class ShellyButton( + entities.extend( + ShellyBluTrvButton(coordinator, button, id_) + for id_ in blutrv_key_ids + for button in BLU_TRV_BUTTONS + ) + + async_add_entities(entities) + + +class ShellyBaseButton( CoordinatorEntity[ShellyRpcCoordinator | ShellyBlockCoordinator], ButtonEntity ): """Defines a Shelly base button.""" @@ -148,14 +181,98 @@ class ShellyButton( ) -> None: """Initialize Shelly button.""" super().__init__(coordinator) + self.entity_description = description + async def async_press(self) -> None: + """Triggers the Shelly button press service.""" + try: + await self._press_method() + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except InvalidAuthError: + await self.coordinator.async_shutdown_device_and_start_reauth() + + async def _press_method(self) -> None: + """Press method.""" + raise NotImplementedError + + +class ShellyButton(ShellyBaseButton): + """Defines a Shelly button.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator, + description: ShellyButtonDescription[ + ShellyRpcCoordinator | ShellyBlockCoordinator + ], + ) -> None: + """Initialize Shelly button.""" + super().__init__(coordinator, description) + self._attr_name = f"{coordinator.device.name} {description.name}" self._attr_unique_id = f"{coordinator.mac}_{description.key}" self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) - async def async_press(self) -> None: - """Triggers the Shelly button press service.""" - await self.entity_description.press_action(self.coordinator) + async def _press_method(self) -> None: + """Press method.""" + method = getattr(self.coordinator.device, self.entity_description.press_action) + + if TYPE_CHECKING: + assert method is not None + + await method() + + +class ShellyBluTrvButton(ShellyBaseButton): + """Represent a Shelly BLU TRV button.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + description: ShellyButtonDescription, + id_: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator, description) + + ble_addr: str = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["addr"] + device_name = ( + coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["name"] + or f"shellyblutrv-{ble_addr.replace(':', '')}" + ) + self._attr_name = f"{device_name} {description.name}" + self._attr_unique_id = f"{ble_addr}_{description.key}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) + self._id = id_ + + async def _press_method(self) -> None: + """Press method.""" + method = getattr(self.coordinator.device, self.entity_description.press_action) + + if TYPE_CHECKING: + assert method is not None + + await method(self._id) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index a3ec9be7cb0..e1c55591da0 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -36,7 +36,6 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( BLU_TRV_TEMPERATURE_SETTINGS, - BLU_TRV_TIMEOUT, DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, @@ -44,7 +43,7 @@ from .const import ( SHTRV_01_TEMPERATURE_SETTINGS, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import ShellyRpcEntity +from .entity import ShellyRpcEntity, rpc_call from .utils import ( async_remove_shelly_entity, get_device_entry_gen, @@ -52,6 +51,8 @@ from .utils import ( is_rpc_thermostat_internal_actuator, ) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -322,8 +323,12 @@ class BlockSleepingClimate( except DeviceConnectionError as err: self.coordinator.last_update_success = False raise HomeAssistantError( - f"Setting state for entity {self.name} failed, state: {kwargs}, error:" - f" {err!r}" + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() @@ -593,17 +598,12 @@ class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): return HVACAction.HEATING + @rpc_call 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}, - }, - timeout=BLU_TRV_TIMEOUT, + await self.coordinator.device.blu_trv_set_target_temperature( + self._id, target_temp ) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 5c5e187a0f4..bde57f6f9bc 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -7,30 +7,22 @@ from typing import Any, Final from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info -from aioshelly.const import ( - BLOCK_GENERATIONS, - DEFAULT_HTTP_PORT, - MODEL_WALL_DISPLAY, - RPC_GENERATIONS, -) +from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, InvalidAuthError, + InvalidHostError, MacAddressMismatchError, ) from aioshelly.rpc_device import RpcDevice 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_MODEL, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, @@ -48,7 +40,7 @@ from .const import ( LOGGER, BLEScannerMode, ) -from .coordinator import async_reconnect_soon +from .coordinator import ShellyConfigEntry, async_reconnect_soon from .utils import ( get_block_device_sleep_period, get_coap_context, @@ -116,7 +108,9 @@ async def validate_input( return { "title": rpc_device.name, CONF_SLEEP_PERIOD: sleep_period, - "model": rpc_device.xmod_info.get("p") or rpc_device.shelly.get("model"), + CONF_MODEL: ( + rpc_device.xmod_info.get("p") or rpc_device.shelly.get(CONF_MODEL) + ), CONF_GEN: gen, } @@ -136,7 +130,7 @@ async def validate_input( return { "title": block_device.name, CONF_SLEEP_PERIOD: sleep_period, - "model": block_device.model, + CONF_MODEL: block_device.model, CONF_GEN: gen, } @@ -164,6 +158,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): self.info = await self._async_get_info(host, port) except DeviceConnectionError: errors["base"] = "cannot_connect" + except InvalidHostError: + errors["base"] = "invalid_host" except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -191,18 +187,18 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if device_info["model"]: + if device_info[CONF_MODEL]: return self.async_create_entry( title=device_info["title"], data={ CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT], CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], - "model": device_info["model"], + CONF_MODEL: device_info[CONF_MODEL], CONF_GEN: device_info[CONF_GEN], }, ) - errors["base"] = "firmware_not_fully_provisioned" + return self.async_abort(reason="firmware_not_fully_provisioned") return self.async_show_form( step_id="user", data_schema=CONFIG_SCHEMA, errors=errors @@ -230,7 +226,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if device_info["model"]: + if device_info[CONF_MODEL]: return self.async_create_entry( title=device_info["title"], data={ @@ -238,11 +234,11 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self.host, CONF_PORT: self.port, CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], - "model": device_info["model"], + CONF_MODEL: device_info[CONF_MODEL], CONF_GEN: device_info[CONF_GEN], }, ) - errors["base"] = "firmware_not_fully_provisioned" + return self.async_abort(reason="firmware_not_fully_provisioned") else: user_input = {} @@ -336,28 +332,26 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle discovery confirm.""" errors: dict[str, str] = {} - if not self.device_info["model"]: - errors["base"] = "firmware_not_fully_provisioned" - model = "Shelly" - else: - model = get_model_name(self.info) - if user_input is not None: - return self.async_create_entry( - title=self.device_info["title"], - data={ - "host": self.host, - CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD], - "model": self.device_info["model"], - CONF_GEN: self.device_info[CONF_GEN], - }, - ) - self._set_confirm_only() + if not self.device_info[CONF_MODEL]: + return self.async_abort(reason="firmware_not_fully_provisioned") + model = get_model_name(self.info) + if user_input is not None: + return self.async_create_entry( + title=self.device_info["title"], + data={ + CONF_HOST: self.host, + CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD], + CONF_MODEL: self.device_info[CONF_MODEL], + CONF_GEN: self.device_info[CONF_GEN], + }, + ) + self._set_confirm_only() return self.async_show_form( step_id="confirm_discovery", description_placeholders={ - "model": model, - "host": self.host, + CONF_MODEL: model, + CONF_HOST: self.host, }, errors=errors, ) @@ -455,19 +449,17 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + def async_get_options_flow(config_entry: ShellyConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @classmethod @callback - def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: + def async_supports_options_flow(cls, config_entry: ShellyConfigEntry) -> bool: """Return options flow support for this handler.""" - return ( - get_device_entry_gen(config_entry) in RPC_GENERATIONS - and not config_entry.data.get(CONF_SLEEP_PERIOD) - and config_entry.data.get("model") != MODEL_WALL_DISPLAY - ) + return get_device_entry_gen( + config_entry + ) in RPC_GENERATIONS and not config_entry.data.get(CONF_SLEEP_PERIOD) class OptionsFlowHandler(OptionsFlow): @@ -477,6 +469,15 @@ class OptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle options flow.""" + if ( + supports_scripts := self.config_entry.runtime_data.rpc_supports_scripts + ) is None: + return self.async_abort(reason="cannot_connect") + if not supports_scripts: + return self.async_abort(reason="no_scripts_support") + if self.config_entry.runtime_data.rpc_zigbee_enabled: + return self.async_abort(reason="zigbee_enabled") + if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index d47f2b0ae80..87fc50a6666 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -25,6 +25,7 @@ from aioshelly.const import ( MODEL_VALVE, MODEL_VINTAGE_V2, MODEL_WALL_DISPLAY, + MODEL_WALL_DISPLAY_X2, ) from homeassistant.components.number import NumberMode @@ -208,7 +209,7 @@ KELVIN_MIN_VALUE_COLOR: Final = 3000 BLOCK_WRONG_SLEEP_PERIOD = 21600 BLOCK_EXPECTED_SLEEP_PERIOD = 43200 -UPTIME_DEVIATION: Final = 5 +UPTIME_DEVIATION: Final = 60 # Time to wait before reloading entry upon device config change ENTRY_RELOAD_COOLDOWN = 60 @@ -226,6 +227,8 @@ class BLEScannerMode(StrEnum): PASSIVE = "passive" +BLE_SCANNER_MIN_FIRMWARE = "1.5.1" + MAX_PUSH_UPDATE_FAILURES = 5 PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" @@ -233,6 +236,8 @@ NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" FIRMWARE_UNSUPPORTED_ISSUE_ID = "firmware_unsupported_{unique}" +BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID = "ble_scanner_firmware_unsupported_{unique}" + GAS_VALVE_OPEN_STATES = ("opening", "opened") OTA_BEGIN = "ota_begin" @@ -245,6 +250,7 @@ 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_WALL_DISPLAY_X2, MODEL_MOTION, MODEL_MOTION_2, MODEL_VALVE, @@ -271,10 +277,11 @@ API_WS_URL = "/api/shelly/ws" COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+") -# value confirmed by Shelly team -BLU_TRV_TIMEOUT = 60 - ROLE_TO_DEVICE_CLASS_MAP = { "current_humidity": SensorDeviceClass.HUMIDITY, "current_temperature": SensorDeviceClass.TEMPERATURE, } + +# We want to check only the first 5 KB of the script if it contains emitEvent() +# so that the integration startup remains fast. +MAX_SCRIPT_SIZE = 5120 diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 7b4da241043..f980ba8f914 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -18,6 +18,7 @@ from aioshelly.exceptions import ( RpcCallError, ) from aioshelly.rpc_device import RpcDevice, RpcUpdateType +from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac from propcache.api import cached_property from homeassistant.components.bluetooth import async_remove_scanner @@ -25,6 +26,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, CONF_HOST, + CONF_MODEL, EVENT_HOMEASSISTANT_STOP, Platform, ) @@ -86,6 +88,9 @@ class ShellyEntryData: rest: ShellyRestCoordinator | None = None rpc: ShellyRpcCoordinator | None = None rpc_poll: ShellyRpcPollingCoordinator | None = None + rpc_script_events: dict[int, list[str]] | None = None + rpc_supports_scripts: bool | None = None + rpc_zigbee_enabled: bool | None = None type ShellyConfigEntry = ConfigEntry[ShellyEntryData] @@ -138,7 +143,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @cached_property def model(self) -> str: """Model of the device.""" - return cast(str, self.config_entry.data["model"]) + return cast(str, self.config_entry.data[CONF_MODEL]) @cached_property def mac(self) -> str: @@ -375,14 +380,23 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): if self.sleep_period: # Sleeping device, no point polling it, just mark it unavailable raise UpdateFailed( - f"Sleeping device did not update within {self.sleep_period} seconds interval" + translation_domain=DOMAIN, + translation_key="update_error_sleeping_device", + translation_placeholders={ + "device": self.name, + "period": str(self.sleep_period), + }, ) LOGGER.debug("Polling Shelly Block Device - %s", self.name) try: await self.device.update() except DeviceConnectionError as err: - raise UpdateFailed(repr(err)) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.name}, + ) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() @@ -467,7 +481,11 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]): return await self.device.update_shelly() except (DeviceConnectionError, MacAddressMismatchError) as err: - raise UpdateFailed(repr(err)) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.name}, + ) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() else: @@ -496,6 +514,15 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self._connect_task: asyncio.Task | None = None entry.async_on_unload(entry.add_update_listener(self._async_update_listener)) + @cached_property + def bluetooth_source(self) -> str: + """Return the Bluetooth source address. + + This is the Bluetooth MAC address of the device that is used + for the Bluetooth scanner. + """ + return format_mac(bluetooth_mac_from_primary_mac(self.mac)).upper() + async def async_device_online(self, source: str) -> None: """Handle device going online.""" if not self.sleep_period: @@ -624,7 +651,12 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if self.sleep_period: # Sleeping device, no point polling it, just mark it unavailable raise UpdateFailed( - f"Sleeping device did not update within {self.sleep_period} seconds interval" + translation_domain=DOMAIN, + translation_key="update_error_sleeping_device", + translation_placeholders={ + "device": self.name, + "period": str(self.sleep_period), + }, ) async with self._connection_lock: @@ -632,7 +664,11 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): return if not await self._async_device_connect_task(): - raise UpdateFailed("Device reconnect error") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error_reconnect_error", + translation_placeholders={"device": self.name}, + ) async def _async_disconnected(self, reconnect: bool) -> None: """Handle device disconnected.""" @@ -682,7 +718,11 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): is updated. """ if not self.sleep_period: - await self._async_connect_ble_scanner() + if ( + self.config_entry.runtime_data.rpc_supports_scripts + and not self.config_entry.runtime_data.rpc_zigbee_enabled + ): + await self._async_connect_ble_scanner() else: await self._async_setup_outbound_websocket() @@ -706,7 +746,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()) + async_remove_scanner(self.hass, self.bluetooth_source) return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting @@ -808,13 +848,21 @@ class ShellyRpcPollingCoordinator(ShellyCoordinatorBase[RpcDevice]): async def _async_update_data(self) -> None: """Fetch data.""" if not self.device.connected: - raise UpdateFailed("Device disconnected") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error_device_disconnected", + translation_placeholders={"device": self.name}, + ) LOGGER.debug("Polling Shelly RPC Device - %s", self.name) try: await self.device.poll() except (DeviceConnectionError, RpcCallError) as err: - raise UpdateFailed(f"Device disconnected: {err!r}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.name}, + ) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index e9eb5acf161..d603636644b 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -21,6 +21,8 @@ from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoo from .entity import ShellyBlockEntity, ShellyRpcEntity from .utils import get_device_entry_gen, get_rpc_key_ids +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index 6e96eb5ed21..740e6aae9b2 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -105,7 +105,9 @@ async def async_validate_trigger_config( return config raise InvalidDeviceAutomationConfig( - f"Invalid ({CONF_TYPE},{CONF_SUBTYPE}): {trigger}" + translation_domain=DOMAIN, + translation_key="invalid_trigger", + translation_placeholders={"trigger": str(trigger)}, ) @@ -137,7 +139,11 @@ async def async_get_triggers( return triggers - raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") + raise InvalidDeviceAutomationConfig( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device": device_id}, + ) async def async_attach_trigger( diff --git a/homeassistant/components/shelly/diagnostics.py b/homeassistant/components/shelly/diagnostics.py index a5fe1f5b6c0..2a9699e0a08 100644 --- a/homeassistant/components/shelly/diagnostics.py +++ b/homeassistant/components/shelly/diagnostics.py @@ -6,9 +6,14 @@ from typing import Any from homeassistant.components.bluetooth import async_scanner_by_source from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import format_mac from .coordinator import ShellyConfigEntry from .utils import get_rpc_ws_url @@ -31,9 +36,9 @@ async def async_get_config_entry_diagnostics( block_coordinator = shelly_entry_data.block assert block_coordinator device_info = { - "name": block_coordinator.name, - "model": block_coordinator.model, - "sw_version": block_coordinator.sw_version, + ATTR_NAME: block_coordinator.name, + ATTR_MODEL: block_coordinator.model, + ATTR_SW_VERSION: block_coordinator.sw_version, } if block_coordinator.device.initialized: device_settings = { @@ -66,28 +71,29 @@ async def async_get_config_entry_diagnostics( rpc_coordinator = shelly_entry_data.rpc assert rpc_coordinator device_info = { - "name": rpc_coordinator.name, - "model": rpc_coordinator.model, - "sw_version": rpc_coordinator.sw_version, + ATTR_NAME: rpc_coordinator.name, + ATTR_MODEL: rpc_coordinator.model, + ATTR_SW_VERSION: rpc_coordinator.sw_version, } if rpc_coordinator.device.initialized: device_settings = { k: v for k, v in rpc_coordinator.device.config.items() if k in ["cloud"] } - ws_config = rpc_coordinator.device.config["ws"] - device_settings["ws_outbound_enabled"] = ws_config["enable"] - if ws_config["enable"]: - device_settings["ws_outbound_server_valid"] = bool( - ws_config["server"] == get_rpc_ws_url(hass) - ) + if not (ws_config := rpc_coordinator.device.config.get("ws", {})): + device_settings["ws_outbound"] = "not supported" + if (ws_outbound_enabled := ws_config.get("enable")) is not None: + device_settings["ws_outbound_enabled"] = ws_outbound_enabled + if ws_outbound_enabled: + device_settings["ws_outbound_server_valid"] = bool( + ws_config["server"] == get_rpc_ws_url(hass) + ) device_status = { k: v for k, v in rpc_coordinator.device.status.items() if k in ["sys", "wifi"] } - source = format_mac(rpc_coordinator.mac).upper() - if scanner := async_scanner_by_source(hass, source): + if scanner := async_scanner_by_source(hass, rpc_coordinator.bluetooth_source): bluetooth = { "scanner": await scanner.async_diagnostics(), } diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 001727c74b3..806f5fea700 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -2,9 +2,10 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Awaitable, Callable, Coroutine, Mapping from dataclasses import dataclass -from typing import Any, cast +from functools import wraps +from typing import Any, Concatenate, cast from aioshelly.block_device import Block from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError @@ -19,7 +20,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_SLEEP_PERIOD, LOGGER +from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .utils import ( async_remove_shelly_entity, @@ -296,7 +297,6 @@ class RpcEntityDescription(EntityDescription): value: Callable[[Any, Any], Any] | None = None available: Callable[[dict], bool] | None = None removal_condition: Callable[[dict, dict, str], bool] | None = None - extra_state_attributes: Callable[[dict, dict], dict | None] | None = None use_polling_coordinator: bool = False supported: Callable = lambda _: False unit: Callable[[dict], str | None] | None = None @@ -313,7 +313,41 @@ class RestEntityDescription(EntityDescription): name: str = "" value: Callable[[dict, Any], Any] | None = None - extra_state_attributes: Callable[[dict], dict | None] | None = None + + +def rpc_call[_T: ShellyRpcEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch rpc_call exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err + except InvalidAuthError: + await self.coordinator.async_shutdown_device_and_start_reauth() + + return cmd_wrapper class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): @@ -347,8 +381,12 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): except DeviceConnectionError as err: self.coordinator.last_update_success = False raise HomeAssistantError( - f"Setting state for entity {self.name} failed, state: {kwargs}, error:" - f" {err!r}" + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() @@ -361,9 +399,9 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Initialize Shelly entity.""" super().__init__(coordinator) self.key = key - self._attr_device_info = { - "connections": {(CONNECTION_NETWORK_MAC, coordinator.mac)} - } + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) @@ -390,6 +428,7 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Handle device update.""" self.async_write_ha_state() + @rpc_call async def call_rpc( self, method: str, params: Any, timeout: float | None = None ) -> Any: @@ -401,23 +440,9 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): params, timeout, ) - try: - if timeout: - return await self.coordinator.device.call_rpc(method, params, timeout) - return await self.coordinator.device.call_rpc(method, params) - except DeviceConnectionError as err: - self.coordinator.last_update_success = False - raise HomeAssistantError( - f"Call RPC for {self.name} connection error, method: {method}, params:" - f" {params}, error: {err!r}" - ) from err - except RpcCallError as err: - raise HomeAssistantError( - f"Call RPC for {self.name} request error, method: {method}, params:" - f" {params}, error: {err!r}" - ) from err - except InvalidAuthError: - await self.coordinator.async_shutdown_device_and_start_reauth() + if timeout: + return await self.coordinator.device.call_rpc(method, params, timeout) + return await self.coordinator.device.call_rpc(method, params) class ShellyBlockAttributeEntity(ShellyBlockEntity, Entity): diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index bfd705f447a..c858e7b591f 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -34,11 +34,12 @@ from .utils import ( get_device_entry_gen, get_rpc_entity_name, get_rpc_key_instances, - get_rpc_script_event_types, is_block_momentary_input, is_rpc_momentary_input, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class ShellyBlockEventDescription(EventEntityDescription): @@ -109,18 +110,15 @@ async def async_setup_entry( script_instances = get_rpc_key_instances( coordinator.device.status, SCRIPT_EVENT.key ) + script_events = config_entry.runtime_data.rpc_script_events for script in script_instances: script_name = get_rpc_entity_name(coordinator.device, script) if script_name == BLE_SCRIPT_NAME: continue - event_types = await get_rpc_script_event_types( - coordinator.device, int(script.split(":")[-1]) - ) - if not event_types: - continue - - entities.append(ShellyRpcScriptEvent(coordinator, script, event_types)) + script_id = int(script.split(":")[-1]) + if script_events and (event_types := script_events[script_id]): + entities.append(ShellyRpcScriptEvent(coordinator, script, event_types)) # If a script is removed, from the device configuration, we need to remove orphaned entities async_remove_orphaned_entities( diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index f93abf6b854..08b269a73c5 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -23,12 +23,18 @@ "gas_concentration": { "default": "mdi:gauge" }, + "gas_detected": { + "default": "mdi:gas-burner" + }, "lamp_life": { "default": "mdi:progress-wrench" }, "operation": { "default": "mdi:cog-transfer" }, + "self_test": { + "default": "mdi:progress-wrench" + }, "tilt": { "default": "mdi:angle-acute" }, diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index ce31533b557..f5cffe37d5a 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -49,6 +49,8 @@ from .utils import ( percentage_to_brightness, ) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index ec08a005995..f60718beca3 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==13.0.0"], + "requirements": ["aioshelly==13.6.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 59716f39c7f..49726f436d0 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceIn from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry -from .const import BLU_TRV_TIMEOUT, CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP +from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER, VIRTUAL_NUMBER_MODE_MAP from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -34,6 +34,7 @@ from .entity import ( ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rpc, + rpc_call, ) from .utils import ( async_remove_orphaned_entities, @@ -41,6 +42,8 @@ from .utils import ( get_virtual_component_ids, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): @@ -59,13 +62,14 @@ class RpcNumberDescription(RpcEntityDescription, NumberEntityDescription): 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 + attribute_value: float | None + _id: int | None def __init__( self, @@ -93,20 +97,17 @@ class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): @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 + @rpc_call async def async_set_native_value(self, value: float) -> None: """Change the value.""" - if TYPE_CHECKING: - assert isinstance(self._id, int) + method = getattr(self.coordinator.device, self.entity_description.method) - await self.call_rpc( - self.entity_description.method, - self.entity_description.method_params_fn(self._id, value), - ) + if TYPE_CHECKING: + assert method is not None + + await method(self._id, value) class RpcBluTrvNumber(RpcNumber): @@ -127,17 +128,6 @@ class RpcBluTrvNumber(RpcNumber): connections={(CONNECTION_BLUETOOTH, ble_addr)} ) - 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), - timeout=BLU_TRV_TIMEOUT, - ) - class RpcBluTrvExtTempNumber(RpcBluTrvNumber): """Represent a RPC BluTrv External Temperature number.""" @@ -187,12 +177,7 @@ RPC_NUMBERS: Final = { 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}, - }, + method="blu_trv_set_external_temperature", entity_class=RpcBluTrvExtTempNumber, ), "number": RpcNumberDescription( @@ -209,8 +194,7 @@ 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}, + method="number_set", ), "valve_position": RpcNumberDescription( key="blutrv", @@ -222,12 +206,7 @@ RPC_NUMBERS: Final = { 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": int(value)}, - }, + method="blu_trv_set_valve_position", removal_condition=lambda config, _status, key: config[key].get("enable", True) is True, entity_class=RpcBluTrvNumber, @@ -324,8 +303,12 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber): except DeviceConnectionError as err: self.coordinator.last_update_success = False raise HomeAssistantError( - f"Setting state for entity {self.name} failed, state: {params}, error:" - f" {err!r}" + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/quality_scale.yaml b/homeassistant/components/shelly/quality_scale.yaml new file mode 100644 index 00000000000..39a032a57f6 --- /dev/null +++ b/homeassistant/components/shelly/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register services. + 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 does not register services. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + 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: + status: exempt + comment: The integration does not register 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: 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: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The integration connects to a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: todo + comment: BLU TRV needs to be removed when un-paired + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py new file mode 100644 index 00000000000..c39f619fc6c --- /dev/null +++ b/homeassistant/components/shelly/repairs.py @@ -0,0 +1,127 @@ +"""Repairs flow for Shelly.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from aioshelly.const import MODEL_OUT_PLUG_S_G3, MODEL_PLUG_S_G3 +from aioshelly.exceptions import DeviceConnectionError, RpcCallError +from aioshelly.rpc_device import RpcDevice +from awesomeversion import AwesomeVersion +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import ( + BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, + BLE_SCANNER_MIN_FIRMWARE, + CONF_BLE_SCANNER_MODE, + DOMAIN, + BLEScannerMode, +) +from .coordinator import ShellyConfigEntry + + +@callback +def async_manage_ble_scanner_firmware_unsupported_issue( + hass: HomeAssistant, + entry: ShellyConfigEntry, +) -> None: + """Manage the BLE scanner firmware unsupported issue.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id) + + if TYPE_CHECKING: + assert entry.runtime_data.rpc is not None + + device = entry.runtime_data.rpc.device + supports_scripts = entry.runtime_data.rpc_supports_scripts + + if supports_scripts and device.model not in (MODEL_PLUG_S_G3, MODEL_OUT_PLUG_S_G3): + firmware = AwesomeVersion(device.shelly["ver"]) + if ( + firmware < BLE_SCANNER_MIN_FIRMWARE + and entry.options.get(CONF_BLE_SCANNER_MODE) == BLEScannerMode.ACTIVE + ): + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="ble_scanner_firmware_unsupported", + translation_placeholders={ + "device_name": device.name, + "ip_address": device.ip_address, + "firmware": firmware, + }, + data={"entry_id": entry.entry_id}, + ) + return + + ir.async_delete_issue(hass, DOMAIN, issue_id) + + +class BleScannerFirmwareUpdateFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, device: RpcDevice) -> None: + """Initialize.""" + self._device = device + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.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 + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + return await self.async_step_update_firmware() + + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=description_placeholders, + ) + + async def async_step_update_firmware( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if not self._device.status["sys"]["available_updates"]: + return self.async_abort(reason="update_not_available") + try: + await self._device.trigger_ota_update() + except (DeviceConnectionError, RpcCallError): + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry(title="", data={}) + + +async def async_create_fix_flow( + hass: HomeAssistant, issue_id: str, data: dict[str, str] | None +) -> RepairsFlow: + """Create flow.""" + if TYPE_CHECKING: + assert isinstance(data, dict) + + entry_id = data["entry_id"] + entry = hass.config_entries.async_get_entry(entry_id) + + if TYPE_CHECKING: + assert entry is not None + + device = entry.runtime_data.rpc.device + return BleScannerFirmwareUpdateFlow(device) diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py index 1fb3dfb3447..aec368f356b 100644 --- a/homeassistant/components/shelly/select.py +++ b/homeassistant/components/shelly/select.py @@ -20,6 +20,7 @@ from .entity import ( RpcEntityDescription, ShellyRpcAttributeEntity, async_setup_entry_rpc, + rpc_call, ) from .utils import ( async_remove_orphaned_entities, @@ -27,6 +28,8 @@ from .utils import ( get_virtual_component_ids, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RpcSelectDescription(RpcEntityDescription, SelectEntityDescription): @@ -75,6 +78,7 @@ class RpcSelect(ShellyRpcAttributeEntity, SelectEntity): """Represent a RPC select entity.""" entity_description: RpcSelectDescription + _id: int def __init__( self, @@ -96,8 +100,9 @@ class RpcSelect(ShellyRpcAttributeEntity, SelectEntity): return self.option_map[self.attribute_value] + @rpc_call async def async_select_option(self, option: str) -> None: """Change the value.""" - await self.call_rpc( - "Enum.Set", {"id": self._id, "value": self.reversed_option_map[option]} + await self.coordinator.device.enum_set( + self._id, self.reversed_option_map[option] ) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 183a1aa06a1..986127b5836 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -39,7 +39,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType -from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP, SHAIR_MAX_WORK_HOURS +from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -58,10 +58,13 @@ from .utils import ( async_remove_orphaned_entities, get_device_entry_gen, get_device_uptime, + get_shelly_air_lamp_life, get_virtual_component_ids, is_rpc_wifi_stations_disabled, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class BlockSensorDescription(BlockEntityDescription, SensorEntityDescription): @@ -355,8 +358,9 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { name="Lamp life", native_unit_of_measurement=PERCENTAGE, translation_key="lamp_life", - value=lambda value: 100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), + value=get_shelly_air_lamp_life, suggested_display_precision=1, + # Deprecated, remove in 2025.10 extra_state_attributes=lambda block: { "Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1) }, @@ -374,9 +378,10 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { key="sensor|sensorOp", name="Operation", device_class=SensorDeviceClass.ENUM, - options=["unknown", "warmup", "normal", "fault"], + options=["warmup", "normal", "fault"], translation_key="operation", - value=lambda value: value, + value=lambda value: None if value == "unknown" else value, + # Deprecated, remove in 2025.10 extra_state_attributes=lambda block: {"self_test": block.selfTest}, ), ("valve", "valve"): BlockSensorDescription( @@ -391,11 +396,33 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { "failure", "opened", "opening", - "unknown", ], + value=lambda value: None if value == "unknown" else value, entity_category=EntityCategory.DIAGNOSTIC, removal_condition=lambda _, block: block.valve == "not_connected", ), + ("sensor", "gas"): BlockSensorDescription( + key="sensor|gas", + name="Gas detected", + translation_key="gas_detected", + device_class=SensorDeviceClass.ENUM, + options=[ + "none", + "mild", + "heavy", + "test", + ], + value=lambda value: None if value == "unknown" else value, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ("sensor", "selfTest"): BlockSensorDescription( + key="sensor|selfTest", + name="Self test", + translation_key="self_test", + device_class=SensorDeviceClass.ENUM, + options=["not_completed", "completed", "running", "pending"], + entity_category=EntityCategory.DIAGNOSTIC, + ), } REST_SENSORS: Final = { diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index eb869b54e4c..28f3a993462 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -17,16 +17,24 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Username for the device's web panel.", + "password": "Password for the device's web panel." } }, "reauth_confirm": { "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::shelly::config::step::credentials::data_description::username%]", + "password": "[%key:component::shelly::config::step::credentials::data_description::password%]" } }, "confirm_discovery": { - "description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device." + "description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password-protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password-protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device." }, "reconfigure": { "description": "Update configuration for {device_name}.\n\nBefore setup, battery-powered devices must be woken up, you can now wake the device up using a button on it.", @@ -42,20 +50,21 @@ }, "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%]", - "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support", "custom_port_not_supported": "Gen1 device does not support custom port.", - "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again." + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "mac_address_mismatch": "The MAC address of the device does not match the one in the configuration, please reboot the device and try again.", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.", + "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support", + "ipv6_not_supported": "IPv6 is not supported.", + "mac_address_mismatch": "[%key:component::shelly::config::error::mac_address_mismatch%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.", - "ipv6_not_supported": "IPv6 is not supported.", - "mac_address_mismatch": "[%key:component::shelly::config::error::mac_address_mismatch%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "device_automation": { @@ -87,8 +96,16 @@ "description": "Bluetooth scanning can be active or passive. With active, the Shelly requests data from nearby devices; with passive, the Shelly receives unsolicited data from nearby devices.", "data": { "ble_scanner_mode": "Bluetooth scanner mode" + }, + "data_description": { + "ble_scanner_mode": "The scanner mode to use for Bluetooth scanning." } } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_scripts_support": "Device does not support scripts and cannot be used as a Bluetooth scanner.", + "zigbee_enabled": "Device with Zigbee enabled cannot be used as a Bluetooth scanner. Please disable it to use the device as a Bluetooth scanner." } }, "selector": { @@ -106,7 +123,6 @@ "state_attributes": { "detected": { "state": { - "unknown": "Unknown", "none": "None", "mild": "Mild", "heavy": "Heavy", @@ -139,20 +155,55 @@ } }, "sensor": { + "gas_detected": { + "state": { + "none": "None", + "mild": "Mild", + "heavy": "Heavy", + "test": "Test" + }, + "state_attributes": { + "options": { + "state": { + "none": "[%key:component::shelly::entity::sensor::gas_detected::state::none%]", + "mild": "[%key:component::shelly::entity::sensor::gas_detected::state::mild%]", + "heavy": "[%key:component::shelly::entity::sensor::gas_detected::state::heavy%]", + "test": "[%key:component::shelly::entity::sensor::gas_detected::state::test%]" + } + } + } + }, "operation": { "state": { - "unknown": "Unknown", "warmup": "Warm-up", - "normal": "Normal", - "fault": "Fault" + "normal": "[%key:common::state::normal%]", + "fault": "[%key:common::state::fault%]" }, "state_attributes": { "self_test": { "state": { - "not_completed": "Not completed", - "completed": "Completed", - "running": "Running", - "pending": "Pending" + "not_completed": "[%key:component::shelly::entity::sensor::self_test::state::not_completed%]", + "completed": "[%key:component::shelly::entity::sensor::self_test::state::completed%]", + "running": "[%key:component::shelly::entity::sensor::self_test::state::running%]", + "pending": "[%key:component::shelly::entity::sensor::self_test::state::pending%]" + } + } + } + }, + "self_test": { + "state": { + "not_completed": "Not completed", + "completed": "Completed", + "running": "Running", + "pending": "Pending" + }, + "state_attributes": { + "options": { + "state": { + "not_completed": "[%key:component::shelly::entity::sensor::self_test::state::not_completed%]", + "completed": "[%key:component::shelly::entity::sensor::self_test::state::completed%]", + "running": "[%key:component::shelly::entity::sensor::self_test::state::running%]", + "pending": "[%key:component::shelly::entity::sensor::self_test::state::pending%]" } } } @@ -161,16 +212,71 @@ "state": { "checking": "Checking", "closed": "[%key:common::state::closed%]", - "closing": "Closing", + "closing": "[%key:common::state::closing%]", "failure": "Failure", "opened": "Opened", - "opening": "Opening", - "unknown": "[%key:component::shelly::entity::sensor::operation::state::unknown%]" + "opening": "[%key:common::state::opening%]" } } } }, + "exceptions": { + "auth_error": { + "message": "Authentication failed for {device}, please update your credentials" + }, + "device_communication_error": { + "message": "Device communication error occurred for {device}" + }, + "device_communication_action_error": { + "message": "Device communication error occurred while calling action for {entity} of {device}" + }, + "device_not_found": { + "message": "{device} not found while configuring device automation triggers" + }, + "firmware_unsupported": { + "message": "{device} is running an unsupported firmware, please update the firmware" + }, + "invalid_trigger": { + "message": "Invalid device automation trigger (type, subtype): {trigger}" + }, + "ota_update_connection_error": { + "message": "Device communication error occurred while triggering OTA update for {device}" + }, + "ota_update_rpc_error": { + "message": "RPC call error occurred while triggering OTA update for {device}" + }, + "rpc_call_action_error": { + "message": "RPC call error occurred while calling action for {entity} of {device}" + }, + "update_error": { + "message": "An error occurred while retrieving data from {device}" + }, + "update_error_device_disconnected": { + "message": "An error occurred while retrieving data from {device} because it is disconnected" + }, + "update_error_reconnect_error": { + "message": "An error occurred while reconnecting to {device}" + }, + "update_error_sleeping_device": { + "message": "Sleeping device did not update within {period} seconds interval" + } + }, "issues": { + "ble_scanner_firmware_unsupported": { + "title": "{device_name} is running unsupported firmware", + "fix_flow": { + "step": { + "confirm": { + "title": "{device_name} is running unsupported firmware", + "description": "Your Shelly device {device_name} with IP address {ip_address} is running firmware {firmware} and acts as BLE scanner with active mode. This firmware version is not supported for BLE scanner active mode.\n\nSelect **Submit** button to start the OTA update to the latest stable firmware version." + } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "update_not_available": "Device does not offer firmware update. Check internet connectivity (gateway, DNS, time) and restart the device." + } + } + }, "device_not_calibrated": { "title": "Shelly device {device_name} is not calibrated", "description": "Shelly device {device_name} with IP address {ip_address} requires calibration. To calibrate the device, it must be rebooted after proper installation on the valve. You can reboot the device in its web panel, go to 'Settings' > 'Device Reboot'." diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 41826706945..507f701795e 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -7,8 +7,9 @@ from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_2, MODEL_25, MODEL_WALL_DISPLAY, RPC_GENERATIONS +from aioshelly.const import RPC_GENERATIONS +from homeassistant.components.climate import DOMAIN as CLIMATE_PLATFORM from homeassistant.components.switch import ( DOMAIN as SWITCH_PLATFORM, SwitchEntity, @@ -20,41 +21,46 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import RestoreEntity -from .const import CONF_SLEEP_PERIOD, MOTION_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, RpcEntityDescription, - ShellyBlockEntity, + ShellyBlockAttributeEntity, ShellyRpcAttributeEntity, - ShellyRpcEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rpc, ) from .utils import ( async_remove_orphaned_entities, - async_remove_shelly_entity, get_device_entry_gen, - get_rpc_key_ids, get_virtual_component_ids, - is_block_channel_type_light, - is_rpc_channel_type_light, - is_rpc_thermostat_internal_actuator, - is_rpc_thermostat_mode, + is_block_exclude_from_relay, + is_rpc_exclude_from_relay, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): """Class to describe a BLOCK switch.""" -MOTION_SWITCH = BlockSwitchDescription( - key="sensor|motionActive", - name="Motion detection", - entity_category=EntityCategory.CONFIG, -) +BLOCK_RELAY_SWITCHES = { + ("relay", "output"): BlockSwitchDescription( + key="relay|output", + removal_condition=is_block_exclude_from_relay, + ) +} + +BLOCK_SLEEPING_MOTION_SWITCH = { + ("sensor", "motionActive"): BlockSwitchDescription( + key="sensor|motionActive", + name="Motion detection", + entity_category=EntityCategory.CONFIG, + ) +} @dataclass(frozen=True, kw_only=True) @@ -67,6 +73,18 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): method_params_fn: Callable[[int | None, bool], dict] +RPC_RELAY_SWITCHES = { + "switch": RpcSwitchDescription( + key="switch", + sub_key="output", + removal_condition=is_rpc_exclude_from_relay, + is_on=lambda status: bool(status["output"]), + method_on="Switch.Set", + method_off="Switch.Set", + method_params_fn=lambda id, value: {"id": id, "on": value}, + ), +} + RPC_SWITCHES = { "boolean": RpcSwitchDescription( key="boolean", @@ -111,46 +129,17 @@ def async_setup_block_entry( coordinator = config_entry.runtime_data.block assert coordinator - # Add Shelly Motion as a switch - if coordinator.model in MOTION_MODELS: - async_setup_entry_attribute_entities( - hass, - config_entry, - async_add_entities, - {("sensor", "motionActive"): MOTION_SWITCH}, - BlockSleepingMotionSwitch, - ) - return + async_setup_entry_attribute_entities( + hass, config_entry, async_add_entities, BLOCK_RELAY_SWITCHES, BlockRelaySwitch + ) - if config_entry.data[CONF_SLEEP_PERIOD]: - return - - # In roller mode the relay blocks exist but do not contain required info - if ( - coordinator.model in [MODEL_2, MODEL_25] - and coordinator.device.settings["mode"] != "relay" - ): - return - - relay_blocks = [] - assert coordinator.device.blocks - for block in coordinator.device.blocks: - if block.type != "relay" or ( - block.channel is not None - and is_block_channel_type_light( - coordinator.device.settings, int(block.channel) - ) - ): - continue - - relay_blocks.append(block) - unique_id = f"{coordinator.mac}-{block.type}_{block.channel}" - async_remove_shelly_entity(hass, "light", unique_id) - - if not relay_blocks: - return - - async_add_entities(BlockRelaySwitch(coordinator, block) for block in relay_blocks) + async_setup_entry_attribute_entities( + hass, + config_entry, + async_add_entities, + BLOCK_SLEEPING_MOTION_SWITCH, + BlockSleepingMotionSwitch, + ) @callback @@ -162,32 +151,10 @@ def async_setup_rpc_entry( """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc assert coordinator - switch_key_ids = get_rpc_key_ids(coordinator.device.status, "switch") - switch_ids = [] - for id_ in switch_key_ids: - if is_rpc_channel_type_light(coordinator.device.config, id_): - continue - - if coordinator.model == MODEL_WALL_DISPLAY: - # There are three configuration scenarios for WallDisplay: - # - relay mode (no thermostat) - # - thermostat mode using the internal relay as an actuator - # - thermostat mode using an external (from another device) relay as - # an actuator - if not is_rpc_thermostat_mode(id_, coordinator.device.status): - # The device is not in thermostat mode, we need to remove a climate - # entity - unique_id = f"{coordinator.mac}-thermostat:{id_}" - async_remove_shelly_entity(hass, "climate", unique_id) - elif is_rpc_thermostat_internal_actuator(coordinator.device.status): - # The internal relay is an actuator, skip this ID so as not to create - # a switch entity - continue - - switch_ids.append(id_) - unique_id = f"{coordinator.mac}-switch:{id_}" - async_remove_shelly_entity(hass, "light", unique_id) + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_RELAY_SWITCHES, RpcRelaySwitch + ) async_setup_entry_rpc( hass, config_entry, async_add_entities, RPC_SWITCHES, RpcSwitch @@ -218,10 +185,16 @@ def async_setup_rpc_entry( "script", ) - if not switch_ids: - return - - async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids) + # if the climate is removed, from the device configuration, we need + # to remove orphaned entities + async_remove_orphaned_entities( + hass, + config_entry.entry_id, + coordinator.mac, + CLIMATE_PLATFORM, + coordinator.device.status, + "thermostat", + ) class BlockSleepingMotionSwitch( @@ -272,13 +245,22 @@ class BlockSleepingMotionSwitch( self.last_state = last_state -class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): +class BlockRelaySwitch(ShellyBlockAttributeEntity, SwitchEntity): """Entity that controls a relay on Block based Shelly devices.""" - def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: + entity_description: BlockSwitchDescription + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + attribute: str, + description: BlockSwitchDescription, + ) -> None: """Initialize relay switch.""" - super().__init__(coordinator, block) + super().__init__(coordinator, block, attribute, description) self.control_result: dict[str, Any] | None = None + self._attr_unique_id: str = f"{coordinator.mac}-{block.description}" @property def is_on(self) -> bool: @@ -305,28 +287,6 @@ class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): super()._update_callback() -class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity): - """Entity that controls a relay on RPC based Shelly devices.""" - - def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: - """Initialize relay switch.""" - super().__init__(coordinator, f"switch:{id_}") - self._id = id_ - - @property - def is_on(self) -> bool: - """If switch is on.""" - return bool(self.status["output"]) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on relay.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off relay.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) - - class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): """Entity that controls a switch on RPC based Shelly devices.""" @@ -351,3 +311,21 @@ class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): self.entity_description.method_off, self.entity_description.method_params_fn(self._id, False), ) + + +class RpcRelaySwitch(RpcSwitch): + """Entity that controls a switch on RPC based Shelly devices.""" + + # False to avoid double naming as True is inerithed from base class + _attr_has_entity_name = False + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, key, attribute, description) + self._attr_unique_id: str = f"{coordinator.mac}-{key}" diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py index f64d1252b7e..a780c464947 100644 --- a/homeassistant/components/shelly/text.py +++ b/homeassistant/components/shelly/text.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Final +from typing import Final from aioshelly.const import RPC_GENERATIONS @@ -20,6 +20,7 @@ from .entity import ( RpcEntityDescription, ShellyRpcAttributeEntity, async_setup_entry_rpc, + rpc_call, ) from .utils import ( async_remove_orphaned_entities, @@ -27,6 +28,8 @@ from .utils import ( get_virtual_component_ids, ) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RpcTextDescription(RpcEntityDescription, TextEntityDescription): @@ -75,15 +78,15 @@ class RpcText(ShellyRpcAttributeEntity, TextEntity): """Represent a RPC text entity.""" entity_description: RpcTextDescription + attribute_value: str | None + _id: int @property def native_value(self) -> str | None: """Return value of sensor.""" - if TYPE_CHECKING: - assert isinstance(self.attribute_value, str | None) - return self.attribute_value + @rpc_call async def async_set_value(self, value: str) -> None: """Change the value.""" - await self.call_rpc("Text.Set", {"id": self._id, "value": value}) + await self.coordinator.device.text_set(self._id, value) diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index b1aa84b2640..2ff2462bd79 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -25,7 +25,14 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import CONF_SLEEP_PERIOD, OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS +from .const import ( + CONF_SLEEP_PERIOD, + DOMAIN, + OTA_BEGIN, + OTA_ERROR, + OTA_PROGRESS, + OTA_SUCCESS, +) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( RestEntityDescription, @@ -40,6 +47,8 @@ from .utils import get_device_entry_gen, get_release_url LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RpcUpdateDescription(RpcEntityDescription, UpdateEntityDescription): @@ -198,7 +207,11 @@ class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity): try: result = await self.coordinator.device.trigger_ota_update(beta=beta) except DeviceConnectionError as err: - raise HomeAssistantError(f"Error starting OTA update: {err!r}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ota_update_connection_error", + translation_placeholders={"device": self.coordinator.name}, + ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() else: @@ -310,9 +323,20 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): try: await self.coordinator.device.trigger_ota_update(beta=beta) except DeviceConnectionError as err: - raise HomeAssistantError(f"OTA update connection error: {err!r}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ota_update_connection_error", + translation_placeholders={"device": self.coordinator.name}, + ) from err except RpcCallError as err: - raise HomeAssistantError(f"OTA update request error: {err!r}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ota_update_rpc_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() else: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 2e81f745819..0c8048d34e4 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -2,11 +2,10 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address, ip_address -from types import MappingProxyType -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from aiohttp.web import Request, WebSocketResponse from aioshelly.block_device import COAP, Block, BlockDevice @@ -28,7 +27,12 @@ from yarl import URL from homeassistant.components import network from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( device_registry as dr, @@ -53,7 +57,9 @@ from .const import ( GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, LOGGER, + MAX_SCRIPT_SIZE, RPC_INPUTS_EVENTS_TYPES, + SHAIR_MAX_WORK_HOURS, SHBTN_INPUTS_EVENTS_TYPES, SHBTN_MODELS, SHELLY_EMIT_EVENT_PATTERN, @@ -175,14 +181,36 @@ def is_block_momentary_input( return button_type in momentary_types +def is_block_exclude_from_relay(settings: dict[str, Any], block: Block) -> bool: + """Return true if block should be excluded from switch platform.""" + + if settings.get("mode") == "roller": + return True + + if TYPE_CHECKING: + assert block.channel is not None + + return is_block_channel_type_light(settings, int(block.channel)) + + def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime: """Return device uptime string, tolerate up to 5 seconds deviation.""" delta_uptime = utcnow() - timedelta(seconds=uptime) if ( not last_uptime - or abs((delta_uptime - last_uptime).total_seconds()) > UPTIME_DEVIATION + or (diff := abs((delta_uptime - last_uptime).total_seconds())) + > UPTIME_DEVIATION ): + if last_uptime: + LOGGER.debug( + "Time deviation %s > %s: uptime=%s, last_uptime=%s, delta_uptime=%s", + diff, + UPTIME_DEVIATION, + uptime, + last_uptime, + delta_uptime, + ) return delta_uptime return last_uptime @@ -310,7 +338,7 @@ def get_info_gen(info: dict[str, Any]) -> int: def get_model_name(info: dict[str, Any]) -> str: """Return the device model name.""" if get_info_gen(info) in RPC_GENERATIONS: - return cast(str, MODEL_NAMES.get(info["model"], info["model"])) + return cast(str, MODEL_NAMES.get(info[CONF_MODEL], info[CONF_MODEL])) return cast(str, MODEL_NAMES.get(info["type"], info["type"])) @@ -502,7 +530,7 @@ def async_create_issue_unsupported_firmware( translation_key="unsupported_firmware", translation_placeholders={ "device_name": entry.title, - "ip_address": entry.data["host"], + "ip_address": entry.data[CONF_HOST], }, ) @@ -517,7 +545,7 @@ def is_rpc_wifi_stations_disabled( return True -def get_http_port(data: MappingProxyType[str, Any]) -> int: +def get_http_port(data: Mapping[str, Any]) -> int: """Get port from config entry data.""" return cast(int, data.get(CONF_PORT, DEFAULT_HTTP_PORT)) @@ -624,6 +652,42 @@ def get_rpc_ws_url(hass: HomeAssistant) -> str | None: async def get_rpc_script_event_types(device: RpcDevice, id: int) -> list[str]: """Return a list of event types for a specific script.""" - code_response = await device.script_getcode(id) + code_response = await device.script_getcode(id, bytes_to_read=MAX_SCRIPT_SIZE) matches = SHELLY_EMIT_EVENT_PATTERN.finditer(code_response["data"]) return sorted([*{str(event_type.group(1)) for event_type in matches}]) + + +def is_rpc_exclude_from_relay( + settings: dict[str, Any], status: dict[str, Any], channel: str +) -> bool: + """Return true if rpc channel should be excludeed from switch platform.""" + ch = int(channel.split(":")[1]) + if is_rpc_thermostat_internal_actuator(status): + return True + + return is_rpc_channel_type_light(settings, ch) + + +def get_shelly_air_lamp_life(lamp_seconds: int) -> float: + """Return Shelly Air lamp life in percentage.""" + lamp_hours = lamp_seconds / 3600 + if lamp_hours >= SHAIR_MAX_WORK_HOURS: + return 0.0 + return 100 * (1 - lamp_hours / SHAIR_MAX_WORK_HOURS) + + +async def get_rpc_scripts_event_types( + device: RpcDevice, ignore_scripts: list[str] +) -> dict[int, list[str]]: + """Return a dict of all scripts and their event types.""" + script_instances = get_rpc_key_instances(device.status, "script") + script_events = {} + for script in script_instances: + script_name = get_rpc_entity_name(device, script) + if script_name in ignore_scripts: + continue + + script_id = int(script.split(":")[-1]) + script_events[script_id] = await get_rpc_script_event_types(device, script_id) + + return script_events diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py index 1829f663b22..b748172ba3d 100644 --- a/homeassistant/components/shelly/valve.py +++ b/homeassistant/components/shelly/valve.py @@ -25,6 +25,8 @@ from .entity import ( ) from .utils import async_remove_shelly_entity, get_device_entry_gen +PARALLEL_UPDATES = 0 + @dataclass(kw_only=True, frozen=True) class BlockValveDescription(BlockEntityDescription, ValveEntityDescription): diff --git a/homeassistant/components/sia/strings.json b/homeassistant/components/sia/strings.json index e5eb4770db5..df2e11b5659 100644 --- a/homeassistant/components/sia/strings.json +++ b/homeassistant/components/sia/strings.json @@ -6,12 +6,12 @@ "port": "[%key:common::config_flow::data::port%]", "protocol": "Protocol", "account": "Account ID", - "encryption_key": "Encryption Key", - "ping_interval": "Ping Interval (min)", + "encryption_key": "Encryption key", + "ping_interval": "Ping interval (min)", "zones": "Number of zones for the account", "additional_account": "Additional accounts" }, - "title": "Create a connection for SIA based alarm systems." + "title": "Create a connection for SIA-based alarm systems." }, "additional_account": { "data": { diff --git a/homeassistant/components/siemens/__init__.py b/homeassistant/components/siemens/__init__.py new file mode 100644 index 00000000000..314b7c63da9 --- /dev/null +++ b/homeassistant/components/siemens/__init__.py @@ -0,0 +1 @@ +"""Siemens virtual integration.""" diff --git a/homeassistant/components/siemens/manifest.json b/homeassistant/components/siemens/manifest.json new file mode 100644 index 00000000000..e53aca0895f --- /dev/null +++ b/homeassistant/components/siemens/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "siemens", + "name": "Siemens", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index e1226fd344d..cee768b6ad0 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.1.0", "simplehound==0.3"] + "requirements": ["Pillow==11.2.1", "simplehound==0.3"] } diff --git a/homeassistant/components/simplefin/strings.json b/homeassistant/components/simplefin/strings.json index 3ac03fe2cc0..b3750a96b1e 100644 --- a/homeassistant/components/simplefin/strings.json +++ b/homeassistant/components/simplefin/strings.json @@ -2,22 +2,22 @@ "config": { "step": { "user": { - "description": "Please enter either a Claim Token or an Access URL.", + "description": "Please enter a SimpleFIN setup token.", "data": { - "api_token": "Claim Token or Access URL" + "api_token": "Setup token" } } }, "error": { "invalid_auth": "Authentication failed: This could be due to revoked access or incorrect credentials", - "claim_error": "The claim token either does not exist or has already been used claimed by someone else. Receiving this could mean that the user\u2019s transaction information has been compromised", - "invalid_claim_token": "The claim token is invalid and could not be decoded", - "payment_required": "You presented a valid access url, however payment is required before you can obtain data", - "url_error": "There was an issue parsing the Account URL" + "claim_error": "The setup token either does not exist or has already been used by someone else. Receiving this could mean that the user\u2019s transaction information has been compromised", + "invalid_claim_token": "The setup token is invalid and could not be decoded", + "payment_required": "You presented a valid access URL, however payment is required before you can obtain data", + "url_error": "There was an issue parsing the access URL" }, "abort": { - "missing_access_url": "Access URL or Claim Token missing", - "already_configured": "This Access URL is already configured." + "missing_access_url": "Access URL or setup token missing", + "already_configured": "This access URL is already configured." } }, "entity": { diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index e1f69ed8113..38a80ddd354 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -34,6 +34,7 @@ SUPPORTED_BATTERY_SENSOR_TYPES = [ DeviceTypes.PANIC_BUTTON, DeviceTypes.REMOTE, DeviceTypes.SIREN, + DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX, DeviceTypes.SMOKE, DeviceTypes.SMOKE_AND_CARBON_MONOXIDE, DeviceTypes.TEMPERATURE, @@ -47,6 +48,7 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, DeviceTypes.MOTION_V2: BinarySensorDeviceClass.MOTION, DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY, + DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX: BinarySensorDeviceClass.SAFETY, DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE, # Although this sensor can technically apply to both smoke and carbon, we use the # SMOKE device class for simplicity: diff --git a/homeassistant/components/sky_hub/manifest.json b/homeassistant/components/sky_hub/manifest.json index 1030da4d0ff..b3c61aad2db 100644 --- a/homeassistant/components/sky_hub/manifest.json +++ b/homeassistant/components/sky_hub/manifest.json @@ -1,7 +1,7 @@ { "domain": "sky_hub", "name": "Sky Hub", - "codeowners": ["@rogerselwyn"], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/sky_hub", "iot_class": "local_polling", "loggers": ["pyskyqhub"], diff --git a/homeassistant/components/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py index 13cddf99332..51cf9c9bf64 100644 --- a/homeassistant/components/sky_remote/config_flow.py +++ b/homeassistant/components/sky_remote/config_flow.py @@ -12,6 +12,8 @@ from homeassistant.helpers import config_validation as cv from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, @@ -21,7 +23,7 @@ DATA_SCHEMA = vol.Schema( async def async_find_box_port(host: str) -> int: """Find port box uses for communication.""" - logging.debug("Attempting to find port to connect to %s on", host) + _LOGGER.debug("Attempting to find port to connect to %s on", host) remote = RemoteControl(host, DEFAULT_PORT) try: await remote.check_connectable() @@ -46,12 +48,12 @@ class SkyRemoteConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - logging.debug("user_input: %s", user_input) + _LOGGER.debug("user_input: %s", user_input) self._async_abort_entries_match(user_input) try: port = await async_find_box_port(user_input[CONF_HOST]) except SkyBoxConnectionError: - logging.exception("while finding port of skybox") + _LOGGER.exception("While finding port of skybox") errors["base"] = "cannot_connect" else: return self.async_create_entry( diff --git a/homeassistant/components/skybell/config_flow.py b/homeassistant/components/skybell/config_flow.py index a32441f4cf8..9893d0dd93a 100644 --- a/homeassistant/components/skybell/config_flow.py +++ b/homeassistant/components/skybell/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from aioskybell import Skybell, exceptions @@ -14,6 +15,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class SkybellFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Skybell.""" @@ -95,6 +98,7 @@ class SkybellFlowHandler(ConfigFlow, domain=DOMAIN): return None, "invalid_auth" except exceptions.SkybellException: return None, "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return None, "unknown" return skybell.user_id, None diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index aa67739016d..899b46ee7e8 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from aiohttp.client_exceptions import ClientError -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py index fcdc2e8b362..551e9832b2b 100644 --- a/homeassistant/components/slack/config_flow.py +++ b/homeassistant/components/slack/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient import voluptuous as vol diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 16dd212301a..4c7f52e581f 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -10,7 +10,7 @@ from urllib.parse import urlparse from aiohttp import BasicAuth from aiohttp.client_exceptions import ClientError -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncWebClient import voluptuous as vol diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index bdafbfb6c77..634202d6da8 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -28,10 +28,10 @@ "select": { "foot_warmer_temp": { "state": { - "off": "Off", - "low": "Low", - "medium": "Medium", - "high": "High" + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } diff --git a/homeassistant/components/slide_local/strings.json b/homeassistant/components/slide_local/strings.json index 67514ff0d50..10efa4bc4f2 100644 --- a/homeassistant/components/slide_local/strings.json +++ b/homeassistant/components/slide_local/strings.json @@ -25,7 +25,7 @@ }, "zeroconf_confirm": { "title": "Confirm setup for Slide", - "description": "Do you want to setup {host}?" + "description": "Do you want to set up {host}?" } }, "abort": { diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 6aae74922e4..0dc8fb83fac 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -10,7 +10,9 @@ import pysma from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONNECTIONS, CONF_HOST, + CONF_MAC, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_SSL, @@ -18,7 +20,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -60,6 +63,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: pysma.exceptions.SmaConnectionException, ) as exc: raise ConfigEntryNotReady from exc + except pysma.exceptions.SmaAuthenticationException as exc: + raise ConfigEntryAuthFailed from exc if TYPE_CHECKING: assert entry.unique_id @@ -75,6 +80,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: serial_number=sma_device_info["serial"], ) + # Add the MAC address to connections, if it comes via DHCP + if CONF_MAC in entry.data: + device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, entry.data[CONF_MAC]) + } + # Define the coordinator async def async_update_data(): """Update the used SMA sensors.""" diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 3f5eb635989..c920b4b0a3a 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -2,31 +2,49 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any import pysma import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) 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.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_GROUP, DOMAIN, GROUPS _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input( + hass: HomeAssistant, + user_input: dict[str, Any], + data: dict[str, Any] | None = None, +) -> dict[str, Any]: """Validate the user input allows us to connect.""" - session = async_get_clientsession(hass, verify_ssl=data[CONF_VERIFY_SSL]) + session = async_get_clientsession(hass, verify_ssl=user_input[CONF_VERIFY_SSL]) - protocol = "https" if data[CONF_SSL] else "http" - url = f"{protocol}://{data[CONF_HOST]}" + protocol = "https" if user_input[CONF_SSL] else "http" + host = data[CONF_HOST] if data is not None else user_input[CONF_HOST] + url = URL.build(scheme=protocol, host=host) - sma = pysma.SMA(session, url, data[CONF_PASSWORD], group=data[CONF_GROUP]) + sma = pysma.SMA( + session, str(url), user_input[CONF_PASSWORD], group=user_input[CONF_GROUP] + ) # new_session raises SmaAuthenticationException on failure await sma.new_session() @@ -51,34 +69,53 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_GROUP: GROUPS[0], CONF_PASSWORD: vol.UNDEFINED, } + self._discovery_data: dict[str, Any] = {} + + async def _handle_user_input( + self, user_input: dict[str, Any], discovery: bool = False + ) -> tuple[dict[str, str], dict[str, str]]: + """Handle the user input.""" + errors: dict[str, str] = {} + device_info: dict[str, str] = {} + + if not discovery: + self._data[CONF_HOST] = user_input[CONF_HOST] + + self._data[CONF_SSL] = user_input[CONF_SSL] + self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL] + self._data[CONF_GROUP] = user_input[CONF_GROUP] + self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD] + + try: + device_info = await validate_input( + self.hass, user_input=user_input, data=self._data + ) + except pysma.exceptions.SmaConnectionException: + errors["base"] = "cannot_connect" + except pysma.exceptions.SmaAuthenticationException: + errors["base"] = "invalid_auth" + except pysma.exceptions.SmaReadException: + errors["base"] = "cannot_retrieve_device_info" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return errors, device_info async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """First step in config flow.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - self._data[CONF_HOST] = user_input[CONF_HOST] - self._data[CONF_SSL] = user_input[CONF_SSL] - self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL] - self._data[CONF_GROUP] = user_input[CONF_GROUP] - self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD] - - try: - device_info = await validate_input(self.hass, user_input) - except pysma.exceptions.SmaConnectionException: - errors["base"] = "cannot_connect" - except pysma.exceptions.SmaAuthenticationException: - errors["base"] = "invalid_auth" - except pysma.exceptions.SmaReadException: - errors["base"] = "cannot_retrieve_device_info" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + errors, device_info = await self._handle_user_input(user_input=user_input) if not errors: - await self.async_set_unique_id(str(device_info["serial"])) + await self.async_set_unique_id( + str(device_info["serial"]), raise_on_progress=False + ) self._abort_if_unique_id_configured(updates=self._data) + return self.async_create_entry( title=self._data[CONF_HOST], data=self._data ) @@ -100,3 +137,86 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth on credential failure.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare reauth.""" + errors: dict[str, str] = {} + if user_input is not None: + reauth_entry = self._get_reauth_entry() + errors, device_info = await self._handle_user_input( + user_input={ + **reauth_entry.data, + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + ) + + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + } + ), + errors=errors, + ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + 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 + self._data[CONF_HOST] = discovery_info.ip + self._data[CONF_MAC] = format_mac(self._discovery_data[CONF_MAC]) + + await self.async_set_unique_id(discovery_info.hostname.replace("SMA", "")) + self._abort_if_unique_id_configured() + + 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: + errors, device_info = await self._handle_user_input( + user_input=user_input, discovery=True + ) + + if not errors: + return self.async_create_entry( + title=self._data[CONF_HOST], data=self._data + ) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, + vol.Optional( + CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] + ): cv.boolean, + vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( + GROUPS + ), + vol.Required(CONF_PASSWORD): cv.string, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 8024aad82d6..bb3f5318280 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,6 +3,13 @@ "name": "SMA Solar", "codeowners": ["@kellerza", "@rklomp", "@erwindouna"], "config_flow": true, + "dhcp": [ + { + "hostname": "sma*", + "macaddress": "0015BB*" + }, + { "registered_devices": true } + ], "documentation": "https://www.home-assistant.io/integrations/sma", "iot_class": "local_polling", "loggers": ["pysma"], diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index 16e5d7408c4..3a7c87acfcc 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -11,6 +12,13 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The SMA integration needs to re-authenticate your connection details", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "data": { "group": "Group", diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json index 2966b5cd753..3037fbc98f6 100644 --- a/homeassistant/components/smappee/strings.json +++ b/homeassistant/components/smappee/strings.json @@ -15,7 +15,7 @@ } }, "zeroconf_confirm": { - "description": "Do you want to add the Smappee device with serialnumber `{serialnumber}` to Home Assistant?", + "description": "Do you want to add the Smappee device with serial number `{serialnumber}` to Home Assistant?", "title": "Discovered Smappee device" }, "pick_implementation": { diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index 1cd7df68e91..ce87b85c322 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -3,7 +3,7 @@ import logging import ssl -from smart_meter_texas import Account, Client, ClientSSLContext +from smart_meter_texas import Account, Client from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -16,6 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.ssl import get_default_context from .const import ( DATA_COORDINATOR, @@ -38,8 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: account = Account(username, password) - client_ssl_context = ClientSSLContext() - ssl_context = await client_ssl_context.get_ssl_context() + ssl_context = get_default_context() smart_meter_texas_data = SmartMeterTexasData(hass, entry, account, ssl_context) try: diff --git a/homeassistant/components/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py index b60855b62c8..18a3716e1b9 100644 --- a/homeassistant/components/smart_meter_texas/config_flow.py +++ b/homeassistant/components/smart_meter_texas/config_flow.py @@ -4,7 +4,7 @@ import logging from typing import Any from aiohttp import ClientError -from smart_meter_texas import Account, Client, ClientSSLContext +from smart_meter_texas import Account, Client from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client +from homeassistant.util.ssl import get_default_context from .const import DOMAIN @@ -31,8 +32,7 @@ async def validate_input(hass: HomeAssistant, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - client_ssl_context = ClientSSLContext() - ssl_context = await client_ssl_context.get_ssl_context() + ssl_context = get_default_context() client_session = aiohttp_client.async_get_clientsession(hass) account = Account(data["username"], data["password"]) client = Client(client_session, account, ssl_context) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 846170552e9..52ce07e06e2 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -2,32 +2,66 @@ from __future__ import annotations +from collections.abc import Callable +import contextlib from dataclasses import dataclass +from http import HTTPStatus import logging -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast -from aiohttp import ClientError +from aiohttp import ClientResponseError from pysmartthings import ( Attribute, Capability, + Category, + ComponentStatus, Device, + DeviceEvent, + Lifecycle, Scene, SmartThings, SmartThingsAuthenticationFailedError, + SmartThingsConnectionError, + SmartThingsSinkError, Status, ) +from pysmartthings.models import HealthStatus from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_HW_VERSION, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SUGGESTED_AREA, + ATTR_SW_VERSION, + ATTR_VIA_DEVICE, + CONF_ACCESS_TOKEN, + CONF_TOKEN, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) +from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, MAIN, OLD_DATA +from .const import ( + BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES, + CONF_INSTALLED_APP_ID, + CONF_LOCATION_ID, + CONF_SUBSCRIPTION_ID, + DOMAIN, + EVENT_BUTTON, + MAIN, + OLD_DATA, + SENSOR_ATTRIBUTES_TO_CAPABILITIES, +) _LOGGER = logging.getLogger(__name__) @@ -38,6 +72,7 @@ class SmartThingsData: devices: dict[str, FullDevice] scenes: dict[str, Scene] + rooms: dict[str, str] client: SmartThings @@ -46,21 +81,30 @@ class FullDevice: """Define an object to hold device data.""" device: Device - status: dict[str, dict[Capability, dict[Attribute, Status]]] + status: dict[str, ComponentStatus] + online: bool type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, + Platform.EVENT, Platform.FAN, Platform.LIGHT, Platform.LOCK, + Platform.MEDIA_PLAYER, + Platform.NUMBER, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, + Platform.VALVE, + Platform.WATER_HEATER, ] @@ -75,7 +119,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) try: await session.async_ensure_token_valid() - except ClientError as err: + except ClientResponseError as err: + if err.status == HTTPStatus.BAD_REQUEST: + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from err raise ConfigEntryNotReady from err client = SmartThings(session=async_get_clientsession(hass)) @@ -89,20 +135,109 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) client.refresh_token_function = _refresh_token + def _handle_max_connections() -> None: + _LOGGER.debug( + "We hit the limit of max connections or we could not remove the old one, so retrying" + ) + hass.config_entries.async_schedule_reload(entry.entry_id) + + client.max_connections_reached_callback = _handle_max_connections + + def _handle_new_subscription_identifier(identifier: str | None) -> None: + """Handle a new subscription identifier.""" + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_SUBSCRIPTION_ID: identifier, + }, + ) + if identifier is not None: + _LOGGER.debug("Updating subscription ID to %s", identifier) + else: + _LOGGER.debug("Removing subscription ID") + + client.new_subscription_id_callback = _handle_new_subscription_identifier + + if (old_identifier := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: + _LOGGER.debug("Trying to delete old subscription %s", old_identifier) + try: + await client.delete_subscription(old_identifier) + except SmartThingsConnectionError as err: + raise ConfigEntryNotReady("Could not delete old subscription") from err + + _LOGGER.debug("Trying to create a new subscription") + try: + subscription = await client.create_subscription( + entry.data[CONF_LOCATION_ID], + entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID], + ) + except SmartThingsSinkError as err: + _LOGGER.exception("Couldn't create a new subscription") + raise ConfigEntryNotReady from err + subscription_id = subscription.subscription_id + _handle_new_subscription_identifier(subscription_id) + + entry.async_create_background_task( + hass, + client.subscribe( + entry.data[CONF_LOCATION_ID], + entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID], + subscription, + ), + "smartthings_socket", + ) + device_status: dict[str, FullDevice] = {} try: + rooms = { + room.room_id: room.name + for room in await client.get_rooms(location_id=entry.data[CONF_LOCATION_ID]) + } devices = await client.get_devices() for device in devices: + if ( + (main_component := device.components.get(MAIN)) is not None + and main_component.manufacturer_category is Category.BLUETOOTH_TRACKER + ): + device_status[device.device_id] = FullDevice( + device=device, + status={}, + online=True, + ) + continue status = process_status(await client.get_device_status(device.device_id)) - device_status[device.device_id] = FullDevice(device=device, status=status) + online = await client.get_device_health(device.device_id) + device_status[device.device_id] = FullDevice( + device=device, status=status, online=online.state == HealthStatus.ONLINE + ) except SmartThingsAuthenticationFailedError as err: raise ConfigEntryAuthFailed from err + device_registry = dr.async_get(hass) + create_devices(device_registry, device_status, entry, rooms) + scenes = { scene.scene_id: scene for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) } + def handle_deleted_device(device_id: str) -> None: + """Handle a deleted device.""" + dev_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)}, + ) + if dev_entry is not None: + device_registry.async_update_device( + dev_entry.id, remove_config_entry_id=entry.entry_id + ) + + entry.async_on_unload( + client.add_device_lifecycle_event_listener( + Lifecycle.DELETE, handle_deleted_device + ) + ) + entry.runtime_data = SmartThingsData( devices={ device_id: device @@ -111,18 +246,55 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) }, client=client, scenes=scenes, + rooms=rooms, ) - entry.async_create_background_task( - hass, - client.subscribe( - entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID] - ), - "smartthings_webhook", + # Events are deprecated and will be removed in 2025.10 + def handle_button_press(event: DeviceEvent) -> None: + """Handle a button press.""" + if ( + event.capability is Capability.BUTTON + and event.attribute is Attribute.BUTTON + ): + hass.bus.async_fire( + EVENT_BUTTON, + { + "component_id": event.component_id, + "device_id": event.device_id, + "location_id": event.location_id, + "value": event.value, + "name": entry.runtime_data.devices[event.device_id].device.label, + "data": event.data, + }, + ) + + entry.async_on_unload( + client.add_unspecified_device_event_listener(handle_button_press) + ) + + async def _handle_shutdown(_: Event) -> None: + """Handle shutdown.""" + await client.delete_subscription(subscription_id) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _handle_shutdown) ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for device_entry in device_entries: + device_id = next( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ) + if device_id in device_status: + continue + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id + ) + return True @@ -130,6 +302,10 @@ async def async_unload_entry( hass: HomeAssistant, entry: SmartThingsConfigEntry ) -> bool: """Unload a config entry.""" + client = entry.runtime_data.client + if (subscription_id := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: + with contextlib.suppress(SmartThingsConnectionError): + await client.delete_subscription(subscription_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -142,28 +318,231 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, version=3, data={OLD_DATA: dict(entry.data)} ) + if entry.minor_version < 2: + + def migrate_entities(entity_entry: RegistryEntry) -> dict[str, Any] | None: + if entity_entry.domain == "binary_sensor": + device_id, attribute = entity_entry.unique_id.split(".") + if ( + capability := BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES.get( + attribute + ) + ) is None: + return None + new_unique_id = ( + f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}" + ) + return { + "new_unique_id": new_unique_id, + } + if entity_entry.domain in {"cover", "climate", "fan", "light", "lock"}: + return {"new_unique_id": f"{entity_entry.unique_id}_{MAIN}"} + if entity_entry.domain == "sensor": + delimiter = "." if " " not in entity_entry.unique_id else " " + if delimiter not in entity_entry.unique_id: + return None + device_id, attribute = entity_entry.unique_id.split( + delimiter, maxsplit=1 + ) + if ( + capability := SENSOR_ATTRIBUTES_TO_CAPABILITIES.get(attribute) + ) is None: + if attribute in { + "energy_meter", + "power_meter", + "deltaEnergy_meter", + "powerEnergy_meter", + "energySaved_meter", + }: + return { + "new_unique_id": f"{device_id}_{MAIN}_{Capability.POWER_CONSUMPTION_REPORT}_{Attribute.POWER_CONSUMPTION}_{attribute}", + } + if attribute in { + "X Coordinate", + "Y Coordinate", + "Z Coordinate", + }: + new_attribute = { + "X Coordinate": "x_coordinate", + "Y Coordinate": "y_coordinate", + "Z Coordinate": "z_coordinate", + }[attribute] + return { + "new_unique_id": f"{device_id}_{MAIN}_{Capability.THREE_AXIS}_{Attribute.THREE_AXIS}_{new_attribute}", + } + if attribute in { + Attribute.MACHINE_STATE, + Attribute.COMPLETION_TIME, + }: + capability = determine_machine_type( + hass, entry.entry_id, device_id + ) + if capability is None: + return None + return { + "new_unique_id": f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}", + } + return None + return { + "new_unique_id": f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}", + } + + if entity_entry.domain == "switch": + return { + "new_unique_id": f"{entity_entry.unique_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + } + + return None + + await async_migrate_entries(hass, entry.entry_id, migrate_entities) + hass.config_entries.async_update_entry( + entry, + minor_version=2, + ) + return True -def process_status( - status: dict[str, dict[Capability, dict[Attribute, Status]]], -) -> dict[str, dict[Capability, dict[Attribute, Status]]]: +def determine_machine_type( + hass: HomeAssistant, + entry_id: str, + device_id: str, +) -> Capability | None: + """Determine the machine type for a device.""" + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_registry, entry_id) + device_entries = [entry for entry in entries if device_id in entry.unique_id] + for entry in device_entries: + if Attribute.DISHWASHER_JOB_STATE in entry.unique_id: + return Capability.DISHWASHER_OPERATING_STATE + if Attribute.WASHER_JOB_STATE in entry.unique_id: + return Capability.WASHER_OPERATING_STATE + if Attribute.DRYER_JOB_STATE in entry.unique_id: + return Capability.DRYER_OPERATING_STATE + if Attribute.OVEN_JOB_STATE in entry.unique_id: + return Capability.OVEN_OPERATING_STATE + return None + + +def create_devices( + device_registry: dr.DeviceRegistry, + devices: dict[str, FullDevice], + entry: SmartThingsConfigEntry, + rooms: dict[str, str], +) -> None: + """Create devices in the device registry.""" + for device in sorted( + devices.values(), key=lambda d: d.device.parent_device_id or "" + ): + kwargs: dict[str, Any] = {} + if device.device.hub is not None: + kwargs = { + ATTR_SW_VERSION: device.device.hub.firmware_version, + ATTR_MODEL: device.device.hub.hardware_type, + } + if device.device.hub.mac_address: + kwargs[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, device.device.hub.mac_address) + } + if device.device.parent_device_id and device.device.parent_device_id in devices: + kwargs[ATTR_VIA_DEVICE] = (DOMAIN, device.device.parent_device_id) + if (ocf := device.device.ocf) is not None: + kwargs.update( + { + ATTR_MANUFACTURER: ocf.manufacturer_name, + ATTR_MODEL: ( + (ocf.model_number.split("|")[0]) if ocf.model_number else None + ), + ATTR_HW_VERSION: ocf.hardware_version, + ATTR_SW_VERSION: ocf.firmware_version, + } + ) + if (viper := device.device.viper) is not None: + kwargs.update( + { + ATTR_MANUFACTURER: viper.manufacturer_name, + ATTR_MODEL: viper.model_name, + ATTR_HW_VERSION: viper.hardware_version, + ATTR_SW_VERSION: viper.software_version, + } + ) + if ( + device_registry.async_get_device({(DOMAIN, device.device.device_id)}) + is None + ): + kwargs.update( + { + ATTR_SUGGESTED_AREA: ( + rooms.get(device.device.room_id) + if device.device.room_id + else None + ) + } + ) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, device.device.device_id)}, + configuration_url="https://account.smartthings.com", + name=device.device.label, + **kwargs, + ) + + +KEEP_CAPABILITY_QUIRK: dict[ + Capability | str, Callable[[dict[Attribute | str, Status]], bool] +] = { + Capability.DRYER_OPERATING_STATE: ( + lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None + ), + Capability.WASHER_OPERATING_STATE: ( + lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None + ), + Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True, +} + + +def process_status(status: dict[str, ComponentStatus]) -> dict[str, ComponentStatus]: """Remove disabled capabilities from status.""" - if (main_component := status.get("main")) is None or ( - disabled_capabilities_capability := main_component.get( + if (main_component := status.get(MAIN)) is None: + return status + if ( + disabled_components_capability := main_component.get( + Capability.CUSTOM_DISABLED_COMPONENTS + ) + ) is not None: + disabled_components = cast( + list[str], + disabled_components_capability[Attribute.DISABLED_COMPONENTS].value, + ) + if disabled_components is not None: + for component in disabled_components: + # Burner components are named burner-06 + # but disabledComponents contain burner-6 + if "burner" in component: + burner_id = int(component.split("-")[-1]) + component = f"burner-0{burner_id}" + if component in status: + del status[component] + for component_status in status.values(): + process_component_status(component_status) + return status + + +def process_component_status(status: ComponentStatus) -> None: + """Remove disabled capabilities from component status.""" + if ( + disabled_capabilities_capability := status.get( Capability.CUSTOM_DISABLED_CAPABILITIES ) - ) is None: - return status - disabled_capabilities = cast( - list[Capability], - disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, - ) - for capability in disabled_capabilities: - # We still need to make sure the climate entity can work without this capability - if ( - capability in main_component - and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL - ): - del main_component[capability] - return status + ) is not None: + disabled_capabilities = cast( + list[Capability | str], + disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, + ) + if disabled_capabilities is not None: + for capability in disabled_capabilities: + if capability in status and ( + capability not in KEEP_CAPABILITY_QUIRK + or not KEEP_CAPABILITY_QUIRK[capability](status[capability]) + ): + del status[capability] diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 99cbd3f9353..ea8db71c481 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -2,22 +2,26 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import Attribute, Capability, Category, SmartThings, Status 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 AddConfigEntryEntitiesCallback from . import FullDevice, SmartThingsConfigEntry -from .const import MAIN +from .const import INVALID_SWITCH_CATEGORIES, MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity @dataclass(frozen=True, kw_only=True) @@ -25,6 +29,13 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): """Describe a SmartThings binary sensor entity.""" is_on_key: str + category_device_class: dict[Category | str, BinarySensorDeviceClass] | None = None + category: set[Category] | None = None + exists_fn: Callable[[str], bool] | None = None + component_translation_key: dict[str, str] | None = None + deprecated_fn: Callable[ + [dict[str, dict[Capability | str, dict[Attribute | str, Status]]]], str | None + ] = lambda _: None CAPABILITY_TO_SENSORS: dict[ @@ -43,6 +54,38 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.CONTACT, device_class=BinarySensorDeviceClass.DOOR, is_on_key="open", + category_device_class={ + Category.GARAGE_DOOR: BinarySensorDeviceClass.GARAGE_DOOR, + Category.DOOR: BinarySensorDeviceClass.DOOR, + Category.WINDOW: BinarySensorDeviceClass.WINDOW, + }, + exists_fn=lambda key: key in {"freezer", "cooler", "cvroom"}, + component_translation_key={ + "freezer": "freezer_door", + "cooler": "cooler_door", + "cvroom": "cool_select_plus_door", + }, + deprecated_fn=( + lambda status: "fridge_door" + if "freezer" in status and "cooler" in status + else None + ), + ) + }, + Capability.CUSTOM_DRYER_WRINKLE_PREVENT: { + Attribute.OPERATING_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.OPERATING_STATE, + translation_key="dryer_wrinkle_prevent_active", + is_on_key="running", + entity_category=EntityCategory.DIAGNOSTIC, + ) + }, + Capability.SAMSUNG_CE_STEAM_CLOSET_KEEP_FRESH_MODE: { + Attribute.OPERATING_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.OPERATING_STATE, + translation_key="keep_fresh_mode_active", + is_on_key="running", + entity_category=EntityCategory.DIAGNOSTIC, ) }, Capability.FILTER_STATUS: { @@ -53,6 +96,13 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="replace", ) }, + Capability.SAMSUNG_CE_KIDS_LOCK: { + Attribute.LOCK_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.LOCK_STATE, + translation_key="child_lock", + is_on_key="locked", + ) + }, Capability.MOTION_SENSOR: { Attribute.MOTION: SmartThingsBinarySensorEntityDescription( key=Attribute.MOTION, @@ -67,6 +117,13 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="present", ) }, + Capability.REMOTE_CONTROL_STATUS: { + Attribute.REMOTE_CONTROL_ENABLED: SmartThingsBinarySensorEntityDescription( + key=Attribute.REMOTE_CONTROL_ENABLED, + translation_key="remote_control", + is_on_key="true", + ) + }, Capability.SOUND_SENSOR: { Attribute.SOUND: SmartThingsBinarySensorEntityDescription( key=Attribute.SOUND, @@ -74,6 +131,14 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="detected", ) }, + Capability.SWITCH: { + Attribute.SWITCH: SmartThingsBinarySensorEntityDescription( + key=Attribute.SWITCH, + device_class=BinarySensorDeviceClass.POWER, + is_on_key="on", + category=INVALID_SWITCH_CATEGORIES, + ) + }, Capability.TAMPER_ALERT: { Attribute.TAMPER: SmartThingsBinarySensorEntityDescription( key=Attribute.TAMPER, @@ -88,6 +153,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="valve", device_class=BinarySensorDeviceClass.OPENING, is_on_key="open", + deprecated_fn=lambda _: "valve", ) }, Capability.WATER_SENSOR: { @@ -97,9 +163,25 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="wet", ) }, + Capability.SAMSUNG_CE_DOOR_STATE: { + Attribute.DOOR_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.DOOR_STATE, + translation_key="door", + device_class=BinarySensorDeviceClass.OPENING, + is_on_key="open", + ) + }, } +def get_main_component_category( + device: FullDevice, +) -> Category | str: + """Get the main component of a device.""" + main = device.device.components[MAIN] + return main.user_category or main.manufacturer_category + + async def async_setup_entry( hass: HomeAssistant, entry: SmartThingsConfigEntry, @@ -107,15 +189,64 @@ async def async_setup_entry( ) -> None: """Add binary sensors for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsBinarySensor( - entry_data.client, device, description, capability, attribute - ) - for device in entry_data.devices.values() - for capability, attribute_map in CAPABILITY_TO_SENSORS.items() - if capability in device.status[MAIN] - for attribute, description in attribute_map.items() - ) + entities = [] + + entity_registry = er.async_get(hass) + + for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks + for capability, attribute_map in CAPABILITY_TO_SENSORS.items(): + for attribute, description in attribute_map.items(): + for component in device.status: + if ( + capability in device.status[component] + and ( + component == MAIN + or ( + description.exists_fn is not None + and description.exists_fn(component) + ) + ) + and ( + not description.category + or get_main_component_category(device) + in description.category + ) + ): + if ( + component == MAIN + and (issue := description.deprecated_fn(device.status)) + is not None + ): + if deprecate_entity( + hass, + entity_registry, + BINARY_SENSOR_DOMAIN, + f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}", + f"deprecated_binary_{issue}", + ): + entities.append( + SmartThingsBinarySensor( + entry_data.client, + device, + description, + capability, + attribute, + component, + ) + ) + continue + entities.append( + SmartThingsBinarySensor( + entry_data.client, + device, + description, + capability, + attribute, + component, + ) + ) + + async_add_entities(entities) class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): @@ -130,13 +261,31 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): entity_description: SmartThingsBinarySensorEntityDescription, capability: Capability, attribute: Attribute, + component: str, ) -> None: """Init the class.""" - super().__init__(client, device, {capability}) + super().__init__(client, device, {capability}, component=component) self._attribute = attribute self.capability = capability self.entity_description = entity_description - self._attr_unique_id = f"{device.device.device_id}.{attribute}" + self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}" + if ( + entity_description.category_device_class + and (category := get_main_component_category(device)) + in entity_description.category_device_class + ): + self._attr_device_class = entity_description.category_device_class[category] + self._attr_name = None + if ( + entity_description.component_translation_key is not None + and ( + translation_key := entity_description.component_translation_key.get( + component + ) + ) + is not None + ): + self._attr_translation_key = translation_key @property def is_on(self) -> bool: diff --git a/homeassistant/components/smartthings/button.py b/homeassistant/components/smartthings/button.py new file mode 100644 index 00000000000..00fbaa0e2c4 --- /dev/null +++ b/homeassistant/components/smartthings/button.py @@ -0,0 +1,78 @@ +"""Support for button entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pysmartthings import Capability, Command, SmartThings + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsButtonDescription(ButtonEntityDescription): + """Class describing SmartThings button entities.""" + + key: Capability + command: Command + + +CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] = { + Capability.OVEN_OPERATING_STATE: SmartThingsButtonDescription( + key=Capability.OVEN_OPERATING_STATE, + translation_key="stop", + command=Command.STOP, + ), + Capability.CUSTOM_WATER_FILTER: SmartThingsButtonDescription( + key=Capability.CUSTOM_WATER_FILTER, + translation_key="reset_water_filter", + command=Command.RESET_WATER_FILTER, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add button entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsButtonEntity( + entry_data.client, device, CAPABILITIES_TO_BUTTONS[capability] + ) + for device in entry_data.devices.values() + for capability in device.status[MAIN] + if capability in CAPABILITIES_TO_BUTTONS + ) + + +class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity): + """Define a SmartThings button.""" + + entity_description: SmartThingsButtonDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsButtonDescription, + ) -> None: + """Initialize the instance.""" + super().__init__(client, device, set()) + self.entity_description = entity_description + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{entity_description.key}_{entity_description.command}" + + async def async_press(self) -> None: + """Press the button.""" + await self.execute_device_command( + self.entity_description.key, + self.entity_description.command, + ) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 2c3b8f3ac03..d063316e233 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullDevice, SmartThingsConfigEntry -from .const import MAIN +from .const import MAIN, UNIT_MAP from .entity import SmartThingsEntity ATTR_OPERATION_STATE = "operation_state" @@ -58,7 +58,7 @@ OPERATING_STATE_TO_ACTION = { } AC_MODE_TO_STATE = { - "auto": HVACMode.HEAT_COOL, + "auto": HVACMode.AUTO, "cool": HVACMode.COOL, "dry": HVACMode.DRY, "coolClean": HVACMode.COOL, @@ -66,10 +66,11 @@ AC_MODE_TO_STATE = { "heat": HVACMode.HEAT, "heatClean": HVACMode.HEAT, "fanOnly": HVACMode.FAN_ONLY, + "fan": HVACMode.FAN_ONLY, "wind": HVACMode.FAN_ONLY, } STATE_TO_AC_MODE = { - HVACMode.HEAT_COOL: "auto", + HVACMode.AUTO: "auto", HVACMode.COOL: "cool", HVACMode.DRY: "dry", HVACMode.HEAT: "heat", @@ -88,9 +89,9 @@ FAN_OSCILLATION_TO_SWING = { } WIND = "wind" +FAN = "fan" WINDFREE = "windFree" -UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} _LOGGER = logging.getLogger(__name__) @@ -161,9 +162,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self.get_attribute_value( - Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE - ): + if self.supports_capability(Capability.THERMOSTAT_FAN_MODE): flags |= ClimateEntityFeature.FAN_MODE return flags @@ -253,6 +252,8 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" + if not self.supports_capability(Capability.THERMOSTAT_OPERATING_STATE): + return None return OPERATING_STATE_TO_ACTION.get( self.get_attribute_value( Capability.THERMOSTAT_OPERATING_STATE, @@ -272,12 +273,16 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" - return [ - state - for mode in self.get_attribute_value( + if ( + supported_thermostat_modes := self.get_attribute_value( Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES ) - if (state := AC_MODE_TO_STATE.get(mode)) is not None + ) is None: + return [] + return [ + state + for mode in supported_thermostat_modes + if (state := MODE_TO_STATE.get(mode)) is not None ] @property @@ -303,7 +308,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): return None @property - def target_temperature_low(self): + def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: return self.get_attribute_value( @@ -314,10 +319,14 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ - Attribute.TEMPERATURE - ].unit - assert unit + # Offline third party thermostats may not have a unit + # Since climate always requires a unit, default to Celsius + if ( + unit := self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + ) is None: + return UnitOfTemperature.CELSIUS return UNIT_MAP[unit] @@ -325,7 +334,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" _attr_name = None - _attr_preset_mode = None def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" @@ -345,7 +353,8 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): ) self._attr_hvac_modes = self._determine_hvac_modes() self._attr_preset_modes = self._determine_preset_modes() - self._attr_swing_modes = self._determine_swing_modes() + if self.supports_capability(Capability.FAN_OSCILLATION_MODE): + self._attr_swing_modes = self._determine_swing_modes() self._attr_supported_features = self._determine_supported_features() def _determine_supported_features(self) -> ClimateEntityFeature: @@ -380,14 +389,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): tasks.append(self.async_turn_on()) mode = STATE_TO_AC_MODE[hvac_mode] - # If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" mode the AirConditioner new mode has to be "wind" - # The conversion make the mode change working - # The conversion is made only for device that wrongly has capability "wind" instead "fan_only" + # If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" or "fan" mode the AirConditioner + # new mode has to be "wind" or "fan" if hvac_mode == HVACMode.FAN_ONLY: - if WIND in self.get_attribute_value( - Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES - ): - mode = WIND + for fan_mode in (WIND, FAN): + if fan_mode in self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ): + mode = fan_mode + break tasks.append( self.execute_device_command( @@ -444,22 +454,27 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): ) @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return device specific state attributes. Include attributes from the Demand Response Load Control (drlc) and Power Consumption capabilities. """ + if not self.supports_capability(Capability.DEMAND_RESPONSE_LOAD_CONTROL): + return None + drlc_status = self.get_attribute_value( Capability.DEMAND_RESPONSE_LOAD_CONTROL, Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS, ) - return { - "drlc_status_duration": drlc_status["duration"], - "drlc_status_level": drlc_status["drlcLevel"], - "drlc_status_start": drlc_status["start"], - "drlc_status_override": drlc_status["override"], - } + res = {} + for key in ("duration", "start", "override", "drlcLevel"): + if key in drlc_status: + dict_key = {"drlcLevel": "drlc_status_level"}.get( + key, f"drlc_status_{key}" + ) + res[dict_key] = drlc_status[key] + return res @property def fan_mode(self) -> str: @@ -531,6 +546,18 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): SWING_OFF, ) + @property + def preset_mode(self) -> str | None: + """Return the preset mode.""" + if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): + mode = self.get_attribute_value( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.AC_OPTIONAL_MODE, + ) + if mode == WINDFREE: + return WINDFREE + return None + def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): @@ -553,11 +580,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): def _determine_hvac_modes(self) -> list[HVACMode]: """Determine the supported HVAC modes.""" modes = [HVACMode.OFF] - modes.extend( - state - for mode in self.get_attribute_value( + if ( + ac_modes := self.get_attribute_value( Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES ) - if (state := AC_MODE_TO_STATE.get(mode)) is not None - ) + ) is not None: + modes.extend( + state + for mode in ac_modes + if (state := AC_MODE_TO_STATE.get(mode)) is not None + if state not in modes + ) return modes diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index bcd2ddc192b..03c8e4bfa66 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -11,7 +11,7 @@ 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 CONF_LOCATION_ID, DOMAIN, OLD_DATA, SCOPES +from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, REQUESTED_SCOPES, SCOPES _LOGGER = logging.getLogger(__name__) @@ -20,6 +20,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle configuration of SmartThings integrations.""" VERSION = 3 + MINOR_VERSION = 2 DOMAIN = DOMAIN @property @@ -30,10 +31,23 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): @property def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" - return {"scope": " ".join(SCOPES)} + return {"scope": " ".join(REQUESTED_SCOPES)} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Check we have the cloud integration set up.""" + if "cloud" not in self.hass.config.components: + return self.async_abort( + reason="cloud_not_enabled", + description_placeholders={"default_config": "default_config"}, + ) + return await super().async_step_user(user_input) async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" + if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES): + return self.async_abort(reason="missing_scopes") client = SmartThings(session=async_get_clientsession(self.hass)) client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) locations = await client.get_locations() diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index c39d225dd09..1925d973ef4 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -1,5 +1,9 @@ """Constants used by the SmartThings component and platforms.""" +from pysmartthings import Attribute, Capability, Category + +from homeassistant.const import UnitOfTemperature + DOMAIN = "smartthings" SCOPES = [ @@ -14,9 +18,13 @@ SCOPES = [ "x:scenes:*", "r:rules:*", "w:rules:*", + "sse", +] + +REQUESTED_SCOPES = [ + *SCOPES, "r:installedapps", "w:installedapps", - "sse", ] CONF_APP_ID = "app_id" @@ -28,3 +36,89 @@ CONF_REFRESH_TOKEN = "refresh_token" MAIN = "main" OLD_DATA = "old_data" + +CONF_SUBSCRIPTION_ID = "subscription_id" +EVENT_BUTTON = "smartthings.button" + +BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = { + Attribute.ACCELERATION: Capability.ACCELERATION_SENSOR, + Attribute.CONTACT: Capability.CONTACT_SENSOR, + Attribute.FILTER_STATUS: Capability.FILTER_STATUS, + Attribute.MOTION: Capability.MOTION_SENSOR, + Attribute.PRESENCE: Capability.PRESENCE_SENSOR, + Attribute.SOUND: Capability.SOUND_SENSOR, + Attribute.TAMPER: Capability.TAMPER_ALERT, + Attribute.VALVE: Capability.VALVE, + Attribute.WATER: Capability.WATER_SENSOR, +} + +SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = { + Attribute.LIGHTING_MODE: Capability.ACTIVITY_LIGHTING_MODE, + Attribute.AIR_CONDITIONER_MODE: Capability.AIR_CONDITIONER_MODE, + Attribute.AIR_QUALITY: Capability.AIR_QUALITY_SENSOR, + Attribute.ALARM: Capability.ALARM, + Attribute.BATTERY: Capability.BATTERY, + Attribute.BMI_MEASUREMENT: Capability.BODY_MASS_INDEX_MEASUREMENT, + Attribute.BODY_WEIGHT_MEASUREMENT: Capability.BODY_WEIGHT_MEASUREMENT, + Attribute.CARBON_DIOXIDE: Capability.CARBON_DIOXIDE_MEASUREMENT, + Attribute.CARBON_MONOXIDE: Capability.CARBON_MONOXIDE_MEASUREMENT, + Attribute.CARBON_MONOXIDE_LEVEL: Capability.CARBON_MONOXIDE_MEASUREMENT, + Attribute.DISHWASHER_JOB_STATE: Capability.DISHWASHER_OPERATING_STATE, + Attribute.DRYER_MODE: Capability.DRYER_MODE, + Attribute.DRYER_JOB_STATE: Capability.DRYER_OPERATING_STATE, + Attribute.DUST_LEVEL: Capability.DUST_SENSOR, + Attribute.FINE_DUST_LEVEL: Capability.DUST_SENSOR, + Attribute.ENERGY: Capability.ENERGY_METER, + Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT, + Attribute.FORMALDEHYDE_LEVEL: Capability.FORMALDEHYDE_MEASUREMENT, + Attribute.GAS_METER: Capability.GAS_METER, + Attribute.GAS_METER_CALORIFIC: Capability.GAS_METER, + Attribute.GAS_METER_TIME: Capability.GAS_METER, + Attribute.GAS_METER_VOLUME: Capability.GAS_METER, + Attribute.ILLUMINANCE: Capability.ILLUMINANCE_MEASUREMENT, + Attribute.INFRARED_LEVEL: Capability.INFRARED_LEVEL, + Attribute.INPUT_SOURCE: Capability.MEDIA_INPUT_SOURCE, + Attribute.PLAYBACK_REPEAT_MODE: Capability.MEDIA_PLAYBACK_REPEAT, + Attribute.PLAYBACK_SHUFFLE: Capability.MEDIA_PLAYBACK_SHUFFLE, + Attribute.PLAYBACK_STATUS: Capability.MEDIA_PLAYBACK, + Attribute.ODOR_LEVEL: Capability.ODOR_SENSOR, + Attribute.OVEN_MODE: Capability.OVEN_MODE, + Attribute.OVEN_JOB_STATE: Capability.OVEN_OPERATING_STATE, + Attribute.OVEN_SETPOINT: Capability.OVEN_SETPOINT, + Attribute.POWER: Capability.POWER_METER, + Attribute.POWER_SOURCE: Capability.POWER_SOURCE, + Attribute.REFRIGERATION_SETPOINT: Capability.REFRIGERATION_SETPOINT, + Attribute.HUMIDITY: Capability.RELATIVE_HUMIDITY_MEASUREMENT, + Attribute.ROBOT_CLEANER_CLEANING_MODE: Capability.ROBOT_CLEANER_CLEANING_MODE, + Attribute.ROBOT_CLEANER_MOVEMENT: Capability.ROBOT_CLEANER_MOVEMENT, + Attribute.ROBOT_CLEANER_TURBO_MODE: Capability.ROBOT_CLEANER_TURBO_MODE, + Attribute.LQI: Capability.SIGNAL_STRENGTH, + Attribute.RSSI: Capability.SIGNAL_STRENGTH, + Attribute.SMOKE: Capability.SMOKE_DETECTOR, + Attribute.TEMPERATURE: Capability.TEMPERATURE_MEASUREMENT, + Attribute.COOLING_SETPOINT: Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.THERMOSTAT_FAN_MODE: Capability.THERMOSTAT_FAN_MODE, + Attribute.HEATING_SETPOINT: Capability.THERMOSTAT_HEATING_SETPOINT, + Attribute.THERMOSTAT_MODE: Capability.THERMOSTAT_MODE, + Attribute.THERMOSTAT_OPERATING_STATE: Capability.THERMOSTAT_OPERATING_STATE, + Attribute.THERMOSTAT_SETPOINT: Capability.THERMOSTAT_SETPOINT, + Attribute.TV_CHANNEL: Capability.TV_CHANNEL, + Attribute.TV_CHANNEL_NAME: Capability.TV_CHANNEL, + Attribute.TVOC_LEVEL: Capability.TVOC_MEASUREMENT, + Attribute.ULTRAVIOLET_INDEX: Capability.ULTRAVIOLET_INDEX, + Attribute.VERY_FINE_DUST_LEVEL: Capability.VERY_FINE_DUST_SENSOR, + Attribute.VOLTAGE: Capability.VOLTAGE_MEASUREMENT, + Attribute.WASHER_MODE: Capability.WASHER_MODE, + Attribute.WASHER_JOB_STATE: Capability.WASHER_OPERATING_STATE, +} + +INVALID_SWITCH_CATEGORIES = { + Category.CLOTHING_CARE_MACHINE, + Category.COOKTOP, + Category.DRYER, + Category.WASHER, + Category.MICROWAVE, + Category.DISHWASHER, +} + +UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index fd4752b4e28..0b68409443d 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -41,7 +41,7 @@ async def async_setup_entry( """Add covers for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsCover(entry_data.client, device, capability) + SmartThingsCover(entry_data.client, device, Capability(capability)) for device in entry_data.devices.values() for capability in device.status[MAIN] if capability in CAPABILITIES @@ -55,7 +55,10 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): _state: CoverState | None = None def __init__( - self, client: SmartThings, device: FullDevice, capability: Capability + self, + client: SmartThings, + device: FullDevice, + capability: Capability, ) -> None: """Initialize the cover class.""" super().__init__( @@ -118,7 +121,12 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): self._attr_current_cover_position = self.get_attribute_value( Capability.SWITCH_LEVEL, Attribute.LEVEL ) + elif self.supports_capability(Capability.WINDOW_SHADE_LEVEL): + self._attr_current_cover_position = self.get_attribute_value( + Capability.WINDOW_SHADE_LEVEL, Attribute.SHADE_LEVEL + ) + # Deprecated, remove in 2025.10 self._attr_extra_state_attributes = {} if self.supports_capability(Capability.BATTERY): self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = ( diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py new file mode 100644 index 00000000000..04517112802 --- /dev/null +++ b/homeassistant/components/smartthings/diagnostics.py @@ -0,0 +1,56 @@ +"""Diagnostics support for SmartThings.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from typing import Any + +from pysmartthings import DeviceEvent + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from . import SmartThingsConfigEntry +from .const import DOMAIN + +EVENT_WAIT_TIME = 5 + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + client = entry.runtime_data.client + return {"devices": await client.get_raw_devices()} + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + client = entry.runtime_data.client + device_id = next( + identifier for identifier in device.identifiers if identifier[0] == DOMAIN + )[1] + + device_status = await client.get_raw_device_status(device_id) + device_info = await client.get_raw_device(device_id) + + events: list[DeviceEvent] = [] + + def register_event(event: DeviceEvent) -> None: + events.append(event) + + listener = client.add_device_event_listener(device_id, register_event) + + await asyncio.sleep(EVENT_WAIT_TIME) + + listener() + + return { + "events": [asdict(event) for event in events], + "status": device_status, + "info": device_info, + } diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index b2e556c6718..b25838ad8c9 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -2,9 +2,18 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any -from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings +from pysmartthings import ( + Attribute, + Capability, + Command, + ComponentStatus, + DeviceEvent, + DeviceHealthEvent, + SmartThings, +) +from pysmartthings.models import HealthStatus from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -20,53 +29,52 @@ class SmartThingsEntity(Entity): _attr_has_entity_name = True def __init__( - self, client: SmartThings, device: FullDevice, capabilities: set[Capability] + self, + client: SmartThings, + device: FullDevice, + capabilities: set[Capability], + *, + component: str = MAIN, ) -> None: """Initialize the instance.""" self.client = client self.capabilities = capabilities - self._internal_state = { - capability: device.status[MAIN][capability] + self.component = component + self._internal_state: ComponentStatus = { + capability: device.status[component][capability] for capability in capabilities - if capability in device.status[MAIN] + if capability in device.status[component] } self.device = device - self._attr_unique_id = device.device.device_id + self._attr_unique_id = f"{device.device.device_id}_{component}" self._attr_device_info = DeviceInfo( - configuration_url="https://account.smartthings.com", identifiers={(DOMAIN, device.device.device_id)}, - name=device.device.label, ) - if (ocf := device.status[MAIN].get(Capability.OCF)) is not None: - self._attr_device_info.update( - { - "manufacturer": cast( - str | None, ocf[Attribute.MANUFACTURER_NAME].value - ), - "model": cast(str | None, ocf[Attribute.MODEL_NUMBER].value), - "hw_version": cast( - str | None, ocf[Attribute.HARDWARE_VERSION].value - ), - "sw_version": cast( - str | None, ocf[Attribute.OCF_FIRMWARE_VERSION].value - ), - } - ) + self._attr_available = device.online async def async_added_to_hass(self) -> None: """Subscribe to updates.""" await super().async_added_to_hass() for capability in self._internal_state: self.async_on_remove( - self.client.add_device_event_listener( + self.client.add_device_capability_event_listener( self.device.device.device_id, - MAIN, + self.component, capability, self._update_handler, ) ) + self.async_on_remove( + self.client.add_device_availability_event_listener( + self.device.device.device_id, self._availability_handler + ) + ) self._update_attr() + def _availability_handler(self, event: DeviceHealthEvent) -> None: + self._attr_available = event.status != HealthStatus.OFFLINE + self.async_write_ha_state() + def _update_handler(self, event: DeviceEvent) -> None: self._internal_state[event.capability][event.attribute].value = event.value self._internal_state[event.capability][event.attribute].data = event.data @@ -74,7 +82,7 @@ class SmartThingsEntity(Entity): def supports_capability(self, capability: Capability) -> bool: """Test if device supports a capability.""" - return capability in self.device.status[MAIN] + return capability in self.device.status[self.component] def get_attribute_value(self, capability: Capability, attribute: Attribute) -> Any: """Get the value of a device attribute.""" @@ -99,5 +107,5 @@ class SmartThingsEntity(Entity): if argument is not None: kwargs["argument"] = argument await self.client.execute_device_command( - self.device.device.device_id, capability, command, MAIN, **kwargs + self.device.device.device_id, capability, command, self.component, **kwargs ) diff --git a/homeassistant/components/smartthings/event.py b/homeassistant/components/smartthings/event.py new file mode 100644 index 00000000000..0439e6391f4 --- /dev/null +++ b/homeassistant/components/smartthings/event.py @@ -0,0 +1,63 @@ +"""Support for events through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import cast + +from pysmartthings import Attribute, Capability, Component, DeviceEvent, SmartThings + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add events for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsButtonEvent( + entry_data.client, device, device.device.components[component] + ) + for device in entry_data.devices.values() + for component, capabilities in device.status.items() + if Capability.BUTTON in capabilities + ) + + +class SmartThingsButtonEvent(SmartThingsEntity, EventEntity): + """Define a SmartThings event.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_translation_key = "button" + + def __init__( + self, + client: SmartThings, + device: FullDevice, + component: Component, + ) -> None: + """Init the class.""" + super().__init__(client, device, {Capability.BUTTON}, component=component.id) + self._attr_name = component.label + self._attr_unique_id = ( + f"{device.device.device_id}_{component.id}_{Capability.BUTTON}" + ) + + @property + def event_types(self) -> list[str]: + """Return the event types.""" + return self.get_attribute_value( + Capability.BUTTON, Attribute.SUPPORTED_BUTTON_VALUES + ) + + def _update_handler(self, event: DeviceEvent) -> None: + if event.attribute is Attribute.BUTTON: + self._trigger_event(cast(str, event.value)) + super()._update_handler(event) diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 8edf01ec613..1c4cb4edc4a 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -116,7 +116,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): @property def is_on(self) -> bool: """Return true if fan is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" @property def percentage(self) -> int | None: @@ -132,6 +132,8 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ + if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): + return None return self.get_attribute_value( Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE ) @@ -142,6 +144,8 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ + if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): + return None return self.get_attribute_value( Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES ) diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json new file mode 100644 index 00000000000..f0c688b2ddc --- /dev/null +++ b/homeassistant/components/smartthings/icons.json @@ -0,0 +1,123 @@ +{ + "entity": { + "binary_sensor": { + "dryer_wrinkle_prevent_active": { + "default": "mdi:tumble-dryer", + "state": { + "on": "mdi:tumble-dryer-alert" + } + }, + "remote_control": { + "default": "mdi:remote-off", + "state": { + "on": "mdi:remote" + } + }, + "child_lock": { + "default": "mdi:lock-open", + "state": { + "on": "mdi:lock" + } + }, + "keep_fresh_mode": { + "default": "mdi:creation" + } + }, + "button": { + "reset_water_filter": { + "default": "mdi:reload" + }, + "stop": { + "default": "mdi:stop" + } + }, + "number": { + "washer_rinse_cycles": { + "default": "mdi:waves-arrow-up" + }, + "freezer_temperature": { + "default": "mdi:snowflake-thermometer" + } + }, + "select": { + "operating_state": { + "state": { + "run": "mdi:play", + "pause": "mdi:pause", + "stop": "mdi:stop" + } + }, + "lamp": { + "default": "mdi:lightbulb", + "state": { + "on": "mdi:lightbulb-on", + "off": "mdi:lightbulb-off" + } + }, + "detergent_amount": { + "default": "mdi:car-coolant-level" + }, + "flexible_detergent_amount": { + "default": "mdi:car-coolant-level" + } + }, + "sensor": { + "cooktop_operating_state": { + "default": "mdi:stove", + "state": { + "ready": "mdi:play-speed", + "run": "mdi:play", + "paused": "mdi:pause", + "finished": "mdi:food-turkey" + } + }, + "manual_level": { + "default": "mdi:radiator", + "state": { + "0": "mdi:radiator-off" + } + }, + "heating_mode": { + "state": { + "off": "mdi:power", + "manual": "mdi:cog", + "boost": "mdi:flash", + "keep_warm": "mdi:fire", + "quick_preheat": "mdi:heat-wave", + "defrost": "mdi:car-defrost-rear", + "melt": "mdi:snowflake-melt", + "simmer": "mdi:fire" + } + } + }, + "switch": { + "bubble_soak": { + "default": "mdi:water-off", + "state": { + "on": "mdi:water" + } + }, + "wrinkle_prevent": { + "default": "mdi:tumble-dryer", + "state": { + "off": "mdi:tumble-dryer-off" + } + }, + "keep_fresh_mode": { + "default": "mdi:creation" + }, + "ice_maker": { + "default": "mdi:delete-variant" + }, + "sanitize": { + "default": "mdi:lotion" + }, + "auto_cycle_link": { + "default": "mdi:link-off", + "state": { + "on": "mdi:link" + } + } + } + } +} diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 54e8ad18a7c..1ad315bcd97 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, cast -from pysmartthings import Attribute, Capability, Command, SmartThings +from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -19,6 +20,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import FullDevice, SmartThingsConfigEntry from .const import MAIN @@ -53,7 +55,7 @@ def convert_scale( return round(value * target_scale / value_scale, round_digits) -class SmartThingsLight(SmartThingsEntity, LightEntity): +class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): """Define a SmartThings Light.""" _attr_name = None @@ -84,18 +86,28 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): color_modes = set() if self.supports_capability(Capability.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) + self._attr_color_mode = ColorMode.COLOR_TEMP if self.supports_capability(Capability.COLOR_CONTROL): color_modes.add(ColorMode.HS) + self._attr_color_mode = ColorMode.HS if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL): color_modes.add(ColorMode.BRIGHTNESS) if not color_modes: color_modes.add(ColorMode.ONOFF) + if len(color_modes) == 1: + self._attr_color_mode = list(color_modes)[0] self._attr_supported_color_modes = color_modes features = LightEntityFeature(0) if self.supports_capability(Capability.SWITCH_LEVEL): features |= LightEntityFeature.TRANSITION self._attr_supported_features = features + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_extra_data()) is not None: + self._attr_color_mode = last_state.as_dict()[ATTR_COLOR_MODE] + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" tasks = [] @@ -135,14 +147,21 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): """Update entity attributes when the device status has changed.""" # Brightness and transition if brightness_supported(self._attr_supported_color_modes): - self._attr_brightness = int( - convert_scale( - self.get_attribute_value(Capability.SWITCH_LEVEL, Attribute.LEVEL), - 100, - 255, - 0, + if ( + brightness := self.get_attribute_value( + Capability.SWITCH_LEVEL, Attribute.LEVEL + ) + ) is None: + self._attr_brightness = None + else: + self._attr_brightness = int( + convert_scale( + brightness, + 100, + 255, + 0, + ) ) - ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: self._attr_color_temp_kelvin = self.get_attribute_value( @@ -150,16 +169,21 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): ) # Color if ColorMode.HS in self._attr_supported_color_modes: - self._attr_hs_color = ( - convert_scale( - self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE), - 100, - 360, - ), - self.get_attribute_value( - Capability.COLOR_CONTROL, Attribute.SATURATION - ), - ) + if ( + hue := self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE) + ) is None: + self._attr_hs_color = None + else: + self._attr_hs_color = ( + convert_scale( + hue, + 100, + 360, + ), + self.get_attribute_value( + Capability.COLOR_CONTROL, Attribute.SATURATION + ), + ) async def async_set_color(self, hs_color): """Set the color of the device.""" @@ -195,19 +219,20 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): argument=[level, duration], ) - @property - def color_mode(self) -> ColorMode: - """Return the color mode of the light.""" - if len(self._attr_supported_color_modes) == 1: - # The light supports only a single color mode - return list(self._attr_supported_color_modes)[0] - - # The light supports hs + color temp, determine which one it is - if self._attr_hs_color and self._attr_hs_color[1]: - return ColorMode.HS - return ColorMode.COLOR_TEMP + def _update_handler(self, event: DeviceEvent) -> None: + """Handle device updates.""" + if event.capability in (Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE): + self._attr_color_mode = { + Capability.COLOR_CONTROL: ColorMode.HS, + Capability.COLOR_TEMPERATURE: ColorMode.COLOR_TEMP, + }[cast(Capability, event.capability)] + super()._update_handler(event) @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if light is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" + if ( + state := self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + ) is None: + return None + return state == "on" diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index b34ab90ca8c..f72405dae20 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,6 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==1.2.0"] + "quality_scale": "bronze", + "requirements": ["pysmartthings==3.2.2"] } diff --git a/homeassistant/components/smartthings/media_player.py b/homeassistant/components/smartthings/media_player.py new file mode 100644 index 00000000000..335e8255ae4 --- /dev/null +++ b/homeassistant/components/smartthings/media_player.py @@ -0,0 +1,359 @@ +"""Support for media players through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import Any + +from pysmartthings import Attribute, Capability, Category, Command, SmartThings + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + RepeatMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + +MEDIA_PLAYER_CAPABILITIES = ( + Capability.AUDIO_MUTE, + Capability.AUDIO_VOLUME, +) + +CONTROLLABLE_SOURCES = ["bluetooth", "wifi"] + +DEVICE_CLASS_MAP: dict[Category | str, MediaPlayerDeviceClass] = { + Category.NETWORK_AUDIO: MediaPlayerDeviceClass.SPEAKER, + Category.SPEAKER: MediaPlayerDeviceClass.SPEAKER, + Category.TELEVISION: MediaPlayerDeviceClass.TV, + Category.RECEIVER: MediaPlayerDeviceClass.RECEIVER, +} + +VALUE_TO_STATE = { + "buffering": MediaPlayerState.BUFFERING, + "paused": MediaPlayerState.PAUSED, + "playing": MediaPlayerState.PLAYING, + "stopped": MediaPlayerState.IDLE, + "fast forwarding": MediaPlayerState.BUFFERING, + "rewinding": MediaPlayerState.BUFFERING, +} + +REPEAT_MODE_TO_HA = { + "all": RepeatMode.ALL, + "one": RepeatMode.ONE, + "off": RepeatMode.OFF, +} + +HA_REPEAT_MODE_TO_SMARTTHINGS = {v: k for k, v in REPEAT_MODE_TO_HA.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add media players for a config entry.""" + entry_data = entry.runtime_data + + async_add_entities( + SmartThingsMediaPlayer(entry_data.client, device) + for device in entry_data.devices.values() + if all( + capability in device.status[MAIN] + for capability in MEDIA_PLAYER_CAPABILITIES + ) + ) + + +class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): + """Define a SmartThings media player.""" + + _attr_name = None + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the media_player class.""" + super().__init__( + client, + device, + { + Capability.AUDIO_MUTE, + Capability.AUDIO_TRACK_DATA, + Capability.AUDIO_VOLUME, + Capability.MEDIA_INPUT_SOURCE, + Capability.MEDIA_PLAYBACK, + Capability.MEDIA_PLAYBACK_REPEAT, + Capability.MEDIA_PLAYBACK_SHUFFLE, + Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, + Capability.SWITCH, + }, + ) + self._attr_supported_features = self._determine_features() + self._attr_device_class = DEVICE_CLASS_MAP.get( + device.device.components[MAIN].user_category + or device.device.components[MAIN].manufacturer_category, + ) + + def _determine_features(self) -> MediaPlayerEntityFeature: + flags = ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + ) + if self.supports_capability(Capability.MEDIA_PLAYBACK): + playback_commands = self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.SUPPORTED_PLAYBACK_COMMANDS + ) + if "play" in playback_commands: + flags |= MediaPlayerEntityFeature.PLAY + if "pause" in playback_commands: + flags |= MediaPlayerEntityFeature.PAUSE + if "stop" in playback_commands: + flags |= MediaPlayerEntityFeature.STOP + if "rewind" in playback_commands: + flags |= MediaPlayerEntityFeature.PREVIOUS_TRACK + if "fastForward" in playback_commands: + flags |= MediaPlayerEntityFeature.NEXT_TRACK + if self.supports_capability(Capability.SWITCH): + flags |= ( + MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF + ) + if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + flags |= MediaPlayerEntityFeature.SELECT_SOURCE + if self.supports_capability(Capability.MEDIA_PLAYBACK_SHUFFLE): + flags |= MediaPlayerEntityFeature.SHUFFLE_SET + if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT): + flags |= MediaPlayerEntityFeature.REPEAT_SET + return flags + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the media player off.""" + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the media player on.""" + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) + + async def async_mute_volume(self, mute: bool) -> None: + """Mute volume.""" + await self.execute_device_command( + Capability.AUDIO_MUTE, + Command.SET_MUTE, + argument="muted" if mute else "unmuted", + ) + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level.""" + await self.execute_device_command( + Capability.AUDIO_VOLUME, + Command.SET_VOLUME, + argument=int(volume * 100), + ) + + async def async_volume_up(self) -> None: + """Increase volume.""" + await self.execute_device_command( + Capability.AUDIO_VOLUME, + Command.VOLUME_UP, + ) + + async def async_volume_down(self) -> None: + """Decrease volume.""" + await self.execute_device_command( + Capability.AUDIO_VOLUME, + Command.VOLUME_DOWN, + ) + + async def async_media_play(self) -> None: + """Play media.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.PLAY, + ) + + async def async_media_pause(self) -> None: + """Pause media.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.PAUSE, + ) + + async def async_media_stop(self) -> None: + """Stop media.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.STOP, + ) + + async def async_media_previous_track(self) -> None: + """Previous track.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.REWIND, + ) + + async def async_media_next_track(self) -> None: + """Next track.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.FAST_FORWARD, + ) + + async def async_select_source(self, source: str) -> None: + """Select source.""" + await self.execute_device_command( + Capability.MEDIA_INPUT_SOURCE, + Command.SET_INPUT_SOURCE, + argument=source, + ) + + async def async_set_shuffle(self, shuffle: bool) -> None: + """Set shuffle mode.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK_SHUFFLE, + Command.SET_PLAYBACK_SHUFFLE, + argument="enabled" if shuffle else "disabled", + ) + + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK_REPEAT, + Command.SET_PLAYBACK_REPEAT_MODE, + argument=HA_REPEAT_MODE_TO_SMARTTHINGS[repeat], + ) + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + if ( + not self.supports_capability(Capability.AUDIO_TRACK_DATA) + or ( + track_data := self.get_attribute_value( + Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA + ) + ) + is None + ): + return None + return track_data.get("title", None) + + @property + def media_artist(self) -> str | None: + """Artist of current playing media.""" + if ( + not self.supports_capability(Capability.AUDIO_TRACK_DATA) + or ( + track_data := self.get_attribute_value( + Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA + ) + ) + is None + ): + return None + return track_data.get("artist") + + @property + def state(self) -> MediaPlayerState | None: + """State of the media player.""" + if self.supports_capability(Capability.SWITCH): + if not self.supports_capability(Capability.MEDIA_PLAYBACK): + if ( + self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + == "on" + ): + return MediaPlayerState.ON + return MediaPlayerState.OFF + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on": + if ( + self.source is not None + and self.source in CONTROLLABLE_SOURCES + and self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS + ) + in VALUE_TO_STATE + ): + return VALUE_TO_STATE[ + self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS + ) + ] + return MediaPlayerState.ON + return MediaPlayerState.OFF + return VALUE_TO_STATE[ + self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS + ) + ] + + @property + def is_volume_muted(self) -> bool: + """Returns if the volume is muted.""" + return ( + self.get_attribute_value(Capability.AUDIO_MUTE, Attribute.MUTE) == "muted" + ) + + @property + def volume_level(self) -> float: + """Volume level.""" + return self.get_attribute_value(Capability.AUDIO_VOLUME, Attribute.VOLUME) / 100 + + @property + def source(self) -> str | None: + """Input source.""" + if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + return self.get_attribute_value( + Capability.MEDIA_INPUT_SOURCE, Attribute.INPUT_SOURCE + ) + if self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE): + return self.get_attribute_value( + Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, Attribute.INPUT_SOURCE + ) + return None + + @property + def source_list(self) -> list[str] | None: + """List of input sources.""" + if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + return self.get_attribute_value( + Capability.MEDIA_INPUT_SOURCE, Attribute.SUPPORTED_INPUT_SOURCES + ) + if self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE): + return self.get_attribute_value( + Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, + Attribute.SUPPORTED_INPUT_SOURCES, + ) + return None + + @property + def shuffle(self) -> bool | None: + """Returns if shuffle mode is set.""" + if self.supports_capability(Capability.MEDIA_PLAYBACK_SHUFFLE): + return ( + self.get_attribute_value( + Capability.MEDIA_PLAYBACK_SHUFFLE, Attribute.PLAYBACK_SHUFFLE + ) + == "enabled" + ) + return None + + @property + def repeat(self) -> RepeatMode | None: + """Returns if repeat mode is set.""" + if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT): + return REPEAT_MODE_TO_HA[ + self.get_attribute_value( + Capability.MEDIA_PLAYBACK_REPEAT, Attribute.PLAYBACK_REPEAT_MODE + ) + ] + return None diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py new file mode 100644 index 00000000000..6ac2f60d7a9 --- /dev/null +++ b/homeassistant/components/smartthings/number.py @@ -0,0 +1,219 @@ +"""Support for number entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN, UNIT_MAP +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add number entities for a config entry.""" + entry_data = entry.runtime_data + entities: list[NumberEntity] = [ + SmartThingsWasherRinseCyclesNumberEntity(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.CUSTOM_WASHER_RINSE_CYCLES in device.status[MAIN] + ] + entities.extend( + SmartThingsHoodNumberEntity(entry_data.client, device) + for device in entry_data.devices.values() + if ( + (hood_component := device.status.get("hood")) is not None + and Capability.SAMSUNG_CE_HOOD_FAN_SPEED in hood_component + and Capability.SAMSUNG_CE_CONNECTION_STATE not in hood_component + ) + ) + entities.extend( + SmartThingsRefrigeratorTemperatureNumberEntity( + entry_data.client, device, component + ) + for device in entry_data.devices.values() + for component in device.status + if component in ("cooler", "freezer") + and Capability.THERMOSTAT_COOLING_SETPOINT in device.status[component] + ) + async_add_entities(entities) + + +class SmartThingsWasherRinseCyclesNumberEntity(SmartThingsEntity, NumberEntity): + """Define a SmartThings number.""" + + _attr_translation_key = "washer_rinse_cycles" + _attr_native_step = 1.0 + _attr_mode = NumberMode.BOX + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the instance.""" + super().__init__(client, device, {Capability.CUSTOM_WASHER_RINSE_CYCLES}) + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_WASHER_RINSE_CYCLES}_{Attribute.WASHER_RINSE_CYCLES}_{Attribute.WASHER_RINSE_CYCLES}" + + @property + def options(self) -> list[int]: + """Return the list of options.""" + values = self.get_attribute_value( + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Attribute.SUPPORTED_WASHER_RINSE_CYCLES, + ) + return [int(value) for value in values] if values else [] + + @property + def native_value(self) -> float | None: + """Return the current value.""" + return int( + self.get_attribute_value( + Capability.CUSTOM_WASHER_RINSE_CYCLES, Attribute.WASHER_RINSE_CYCLES + ) + ) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + return min(self.options) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return max(self.options) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.execute_device_command( + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Command.SET_WASHER_RINSE_CYCLES, + str(int(value)), + ) + + +class SmartThingsHoodNumberEntity(SmartThingsEntity, NumberEntity): + """Define a SmartThings number.""" + + _attr_translation_key = "hood_fan_speed" + _attr_native_step = 1.0 + _attr_mode = NumberMode.SLIDER + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the instance.""" + super().__init__( + client, device, {Capability.SAMSUNG_CE_HOOD_FAN_SPEED}, component="hood" + ) + self._attr_unique_id = f"{device.device.device_id}_hood_{Capability.SAMSUNG_CE_HOOD_FAN_SPEED}_{Attribute.HOOD_FAN_SPEED}_{Attribute.HOOD_FAN_SPEED}" + + @property + def options(self) -> list[int]: + """Return the list of options.""" + min_value = self.get_attribute_value( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + Attribute.SETTABLE_MIN_FAN_SPEED, + ) + max_value = self.get_attribute_value( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + Attribute.SETTABLE_MAX_FAN_SPEED, + ) + return list(range(min_value, max_value + 1)) + + @property + def native_value(self) -> int: + """Return the current value.""" + return int( + self.get_attribute_value( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, Attribute.HOOD_FAN_SPEED + ) + ) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + return min(self.options) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return max(self.options) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + Command.SET_HOOD_FAN_SPEED, + int(value), + ) + + +class SmartThingsRefrigeratorTemperatureNumberEntity(SmartThingsEntity, NumberEntity): + """Define a SmartThings number.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_device_class = NumberDeviceClass.TEMPERATURE + + def __init__(self, client: SmartThings, device: FullDevice, component: str) -> None: + """Initialize the instance.""" + super().__init__( + client, + device, + {Capability.THERMOSTAT_COOLING_SETPOINT}, + component=component, + ) + self._attr_unique_id = f"{device.device.device_id}_{component}_{Capability.THERMOSTAT_COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}" + unit = self._internal_state[Capability.THERMOSTAT_COOLING_SETPOINT][ + Attribute.COOLING_SETPOINT + ].unit + assert unit is not None + self._attr_native_unit_of_measurement = UNIT_MAP[unit] + self._attr_translation_key = { + "cooler": "cooler_temperature", + "freezer": "freezer_temperature", + }[component] + + @property + def range(self) -> dict[str, int]: + """Return the list of options.""" + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.COOLING_SETPOINT_RANGE, + ) + + @property + def native_value(self) -> int: + """Return the current value.""" + return int( + self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) + ) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + return self.range["minimum"] + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return self.range["maximum"] + + @property + def native_step(self) -> float: + """Return the step value.""" + return self.range["step"] + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + int(value), + ) diff --git a/homeassistant/components/smartthings/quality_scale.yaml b/homeassistant/components/smartthings/quality_scale.yaml new file mode 100644 index 00000000000..384ce2ea0b6 --- /dev/null +++ b/homeassistant/components/smartthings/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration works via push. + 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: No options to configure + docs-installation-parameters: + status: exempt + comment: No parameters needed during installation + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration connects via the cloud. + 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: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have any entities that are disabled by default. + entity-translations: done + 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: done + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py new file mode 100644 index 00000000000..39a49da2bbe --- /dev/null +++ b/homeassistant/components/smartthings/select.py @@ -0,0 +1,201 @@ +"""Support for select entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + +LAMP_TO_HA = { + "extraHigh": "extra_high", +} + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsSelectDescription(SelectEntityDescription): + """Class describing SmartThings select entities.""" + + key: Capability + requires_remote_control_status: bool = False + options_attribute: Attribute + status_attribute: Attribute + command: Command + options_map: dict[str, str] | None = None + default_options: list[str] | None = None + extra_components: list[str] | None = None + capability_ignore_list: list[Capability] | None = None + + +CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = { + Capability.DISHWASHER_OPERATING_STATE: SmartThingsSelectDescription( + key=Capability.DISHWASHER_OPERATING_STATE, + name=None, + translation_key="operating_state", + requires_remote_control_status=True, + options_attribute=Attribute.SUPPORTED_MACHINE_STATES, + status_attribute=Attribute.MACHINE_STATE, + command=Command.SET_MACHINE_STATE, + ), + Capability.DRYER_OPERATING_STATE: SmartThingsSelectDescription( + key=Capability.DRYER_OPERATING_STATE, + name=None, + translation_key="operating_state", + requires_remote_control_status=True, + options_attribute=Attribute.SUPPORTED_MACHINE_STATES, + status_attribute=Attribute.MACHINE_STATE, + command=Command.SET_MACHINE_STATE, + default_options=["run", "pause", "stop"], + ), + Capability.WASHER_OPERATING_STATE: SmartThingsSelectDescription( + key=Capability.WASHER_OPERATING_STATE, + name=None, + translation_key="operating_state", + requires_remote_control_status=True, + options_attribute=Attribute.SUPPORTED_MACHINE_STATES, + status_attribute=Attribute.MACHINE_STATE, + command=Command.SET_MACHINE_STATE, + default_options=["run", "pause", "stop"], + ), + Capability.SAMSUNG_CE_AUTO_DISPENSE_DETERGENT: SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_AUTO_DISPENSE_DETERGENT, + translation_key="detergent_amount", + options_attribute=Attribute.SUPPORTED_AMOUNT, + status_attribute=Attribute.AMOUNT, + command=Command.SET_AMOUNT, + entity_category=EntityCategory.CONFIG, + ), + Capability.SAMSUNG_CE_FLEXIBLE_AUTO_DISPENSE_DETERGENT: SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_FLEXIBLE_AUTO_DISPENSE_DETERGENT, + translation_key="flexible_detergent_amount", + options_attribute=Attribute.SUPPORTED_AMOUNT, + status_attribute=Attribute.AMOUNT, + command=Command.SET_AMOUNT, + entity_category=EntityCategory.CONFIG, + ), + Capability.SAMSUNG_CE_LAMP: SmartThingsSelectDescription( + key=Capability.SAMSUNG_CE_LAMP, + translation_key="lamp", + options_attribute=Attribute.SUPPORTED_BRIGHTNESS_LEVEL, + status_attribute=Attribute.BRIGHTNESS_LEVEL, + command=Command.SET_BRIGHTNESS_LEVEL, + options_map=LAMP_TO_HA, + entity_category=EntityCategory.CONFIG, + extra_components=["hood"], + capability_ignore_list=[Capability.SAMSUNG_CE_CONNECTION_STATE], + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add select entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsSelectEntity(entry_data.client, device, description, component) + for capability, description in CAPABILITIES_TO_SELECT.items() + for device in entry_data.devices.values() + for component in device.status + if capability in device.status[component] + and ( + component == MAIN + or ( + description.extra_components is not None + and component in description.extra_components + ) + ) + and ( + description.capability_ignore_list is None + or any( + capability not in device.status[component] + for capability in description.capability_ignore_list + ) + ) + ) + + +class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity): + """Define a SmartThings select.""" + + entity_description: SmartThingsSelectDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsSelectDescription, + component: str, + ) -> None: + """Initialize the instance.""" + capabilities = {entity_description.key} + if entity_description.requires_remote_control_status: + capabilities.add(Capability.REMOTE_CONTROL_STATUS) + super().__init__(client, device, capabilities, component=component) + self.entity_description = entity_description + self._attr_unique_id = f"{device.device.device_id}_{component}_{entity_description.key}_{entity_description.status_attribute}_{entity_description.status_attribute}" + + @property + def options(self) -> list[str]: + """Return the list of options.""" + options: list[str] = ( + self.get_attribute_value( + self.entity_description.key, self.entity_description.options_attribute + ) + or self.entity_description.default_options + or [] + ) + if self.entity_description.options_map: + options = [ + self.entity_description.options_map.get(option, option) + for option in options + ] + return options + + @property + def current_option(self) -> str | None: + """Return the current option.""" + option = self.get_attribute_value( + self.entity_description.key, self.entity_description.status_attribute + ) + if self.entity_description.options_map: + option = self.entity_description.options_map.get(option) + return option + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + if ( + self.entity_description.requires_remote_control_status + and self.get_attribute_value( + Capability.REMOTE_CONTROL_STATUS, Attribute.REMOTE_CONTROL_ENABLED + ) + == "false" + ): + raise ServiceValidationError( + "Can only be updated when remote control is enabled" + ) + if self.entity_description.options_map: + option = next( + ( + key + for key, value in self.entity_description.options_map.items() + if value == option + ), + option, + ) + await self.execute_device_command( + self.entity_description.key, + self.entity_description.command, + option, + ) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index d7aaaaa84c5..6c8c78b4d32 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -5,11 +5,12 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import Any, cast -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import Attribute, Capability, ComponentStatus, SmartThings, Status from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -25,16 +26,19 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfMass, UnitOfPower, + UnitOfPressure, UnitOfTemperature, UnitOfVolume, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import FullDevice, SmartThingsConfigEntry from .const import MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity THERMOSTAT_CAPABILITIES = { Capability.TEMPERATURE_MEASUREMENT, @@ -42,6 +46,17 @@ THERMOSTAT_CAPABILITIES = { Capability.THERMOSTAT_MODE, } +COOKTOP_HEATING_MODES = { + "off": "off", + "manual": "manual", + "boost": "boost", + "keepWarm": "keep_warm", + "quickPreheat": "quick_preheat", + "defrost": "defrost", + "melt": "melt", + "simmer": "simmer", +} + JOB_STATE_MAP = { "airWash": "air_wash", "airwash": "air_wash", @@ -57,6 +72,7 @@ JOB_STATE_MAP = { "freezeProtection": "freeze_protection", "preDrain": "pre_drain", "preWash": "pre_wash", + "prewash": "pre_wash", "wrinklePrevent": "wrinkle_prevent", "unknown": None, } @@ -127,9 +143,14 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Any], str | float | int | datetime | None] = lambda value: value extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None - unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None + options_map: dict[str, str] | None = None + translation_placeholders_fn: Callable[[str], dict[str, str]] | None = None + component_fn: Callable[[str], bool] | None = None + exists_fn: Callable[[Status], bool] | None = None + use_temperature_unit: bool = False + deprecated: Callable[[ComponentStatus], tuple[str, str] | None] | None = None CAPABILITY_TO_SENSORS: dict[ @@ -180,12 +201,26 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.ATMOSPHERIC_PRESSURE_MEASUREMENT: { + Attribute.ATMOSPHERIC_PRESSURE: [ + SmartThingsSensorEntityDescription( + key=Attribute.ATMOSPHERIC_PRESSURE, + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, Capability.AUDIO_VOLUME: { Attribute.VOLUME: [ SmartThingsSensorEntityDescription( key=Attribute.VOLUME, translation_key="audio_volume", native_unit_of_measurement=PERCENTAGE, + deprecated=( + lambda status: ("2025.10.0", "media_player") + if Capability.AUDIO_MUTE in status + else None + ), ) ] }, @@ -222,7 +257,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # Haven't seen at devices yet Capability.CARBON_DIOXIDE_MEASUREMENT: { Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( @@ -255,6 +289,41 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.SAMSUNG_CE_COOKTOP_HEATING_POWER: { + Attribute.MANUAL_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.MANUAL_LEVEL, + translation_key="manual_level", + translation_placeholders_fn=lambda component: { + "burner_id": component.split("-0")[-1] + }, + component_fn=lambda component: component.startswith("burner-0"), + ) + ], + Attribute.HEATING_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.HEATING_MODE, + translation_key="heating_mode", + options_attribute=Attribute.SUPPORTED_HEATING_MODES, + options_map=COOKTOP_HEATING_MODES, + device_class=SensorDeviceClass.ENUM, + translation_placeholders_fn=lambda component: { + "burner_id": component.split("-0")[-1] + }, + component_fn=lambda component: component.startswith("burner-0"), + ) + ], + }, + Capability.CUSTOM_COOKTOP_OPERATING_STATE: { + Attribute.COOKTOP_OPERATING_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.COOKTOP_OPERATING_STATE, + translation_key="cooktop_operating_state", + device_class=SensorDeviceClass.ENUM, + options_attribute=Attribute.SUPPORTED_COOKTOP_OPERATING_STATE, + ) + ] + }, Capability.DISHWASHER_OPERATING_STATE: { Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( @@ -397,7 +466,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # Haven't seen at devices yet Capability.GAS_METER: { Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( @@ -405,7 +473,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="gas_meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ) ], Attribute.GAS_METER_CALORIFIC: [ @@ -427,7 +495,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.GAS_METER_VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ) ], }, @@ -460,25 +528,26 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="media_input_source", device_class=SensorDeviceClass.ENUM, options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, - value_fn=lambda value: value.lower(), + value_fn=lambda value: value.lower() if value else None, + deprecated=lambda _: ("2025.10.0", "media_player"), ) ] }, - # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_REPEAT: { Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_REPEAT_MODE, translation_key="media_playback_repeat", + deprecated=lambda _: ("2025.10.0", "media_player"), ) ] }, - # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_SHUFFLE: { Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_SHUFFLE, translation_key="media_playback_shuffle", + deprecated=lambda _: ("2025.10.0", "media_player"), ) ] }, @@ -497,6 +566,7 @@ CAPABILITY_TO_SENSORS: dict[ ], device_class=SensorDeviceClass.ENUM, value_fn=lambda value: MEDIA_PLAYBACK_STATE_MAP.get(value, value), + deprecated=lambda _: ("2025.10.0", "media_player"), ) ] }, @@ -560,6 +630,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, translation_key="completion_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, ) ], }, @@ -568,6 +640,10 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.OVEN_SETPOINT, translation_key="oven_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + use_temperature_unit=True, + # Set the value to None if it is 0 F (-17 C) + value_fn=lambda value: None if value in {-17, 0, 1} else value, ) ] }, @@ -579,6 +655,11 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, + suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "energy" in value + ), ), SmartThingsSensorEntityDescription( key="power_meter", @@ -587,22 +668,37 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfPower.WATT, value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, + suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "power" in value + ), ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", translation_key="energy_difference", - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, + suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "deltaEnergy" in value + ), ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", translation_key="power_energy", - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, + suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "powerEnergy" in value + ), ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -611,6 +707,11 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, + suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "energySaved" in value + ), ), ] }, @@ -644,6 +745,15 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.RELATIVE_BRIGHTNESS: { + Attribute.BRIGHTNESS_INTENSITY: [ + SmartThingsSensorEntityDescription( + key=Attribute.BRIGHTNESS_INTENSITY, + translation_key="brightness_intensity", + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, Capability.RELATIVE_HUMIDITY_MEASUREMENT: { Attribute.HUMIDITY: [ SmartThingsSensorEntityDescription( @@ -737,6 +847,11 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + deprecated=( + lambda status: ("2025.12.0", "dhw") + if Capability.CUSTOM_OUTING_MODE in status + else None + ), ) ] }, @@ -754,6 +869,11 @@ CAPABILITY_TO_SENSORS: dict[ }, THERMOSTAT_CAPABILITIES, ], + deprecated=( + lambda status: ("2025.12.0", "dhw") + if Capability.CUSTOM_OUTING_MODE in status + else None + ), ) ] }, @@ -815,21 +935,18 @@ CAPABILITY_TO_SENSORS: dict[ Capability.THREE_AXIS: { Attribute.THREE_AXIS: [ SmartThingsSensorEntityDescription( - key="X Coordinate", + key="x_coordinate", translation_key="x_coordinate", - unique_id_separator=" ", value_fn=lambda value: value[0], ), SmartThingsSensorEntityDescription( - key="Y Coordinate", + key="y_coordinate", translation_key="y_coordinate", - unique_id_separator=" ", value_fn=lambda value: value[1], ), SmartThingsSensorEntityDescription( - key="Z Coordinate", + key="z_coordinate", translation_key="z_coordinate", - unique_id_separator=" ", value_fn=lambda value: value[2], ), ] @@ -869,6 +986,16 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.VERY_FINE_DUST_SENSOR: { + Attribute.VERY_FINE_DUST_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.VERY_FINE_DUST_LEVEL, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, Capability.VOLTAGE_MEASUREMENT: { Attribute.VOLTAGE: [ SmartThingsSensorEntityDescription( @@ -932,14 +1059,29 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, + Capability.SAMSUNG_CE_WATER_CONSUMPTION_REPORT: { + Attribute.WATER_CONSUMPTION: [ + SmartThingsSensorEntityDescription( + key=Attribute.WATER_CONSUMPTION, + translation_key="water_consumption", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.LITERS, + value_fn=lambda value: value["cumulativeAmount"] / 1000, + ) + ] + }, } UNITS = { "C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT, + "ccf": UnitOfVolume.CENTUM_CUBIC_FEET, "lux": LIGHT_LUX, "mG": None, + "μg/m^3": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "kPa": UnitOfPressure.KPA, } @@ -950,19 +1092,82 @@ async def async_setup_entry( ) -> None: """Add sensors for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsSensor(entry_data.client, device, description, capability, attribute) - for device in entry_data.devices.values() - for capability, attributes in device.status[MAIN].items() - if capability in CAPABILITY_TO_SENSORS - for attribute in attributes - for description in CAPABILITY_TO_SENSORS[capability].get(attribute, []) - if not description.capability_ignore_list - or not any( - all(capability in device.status[MAIN] for capability in capability_list) - for capability_list in description.capability_ignore_list - ) - ) + entities = [] + + entity_registry = er.async_get(hass) + + for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks + for capability, attributes in CAPABILITY_TO_SENSORS.items(): + for component, capabilities in device.status.items(): + if capability in capabilities: + for attribute, descriptions in attributes.items(): + for description in descriptions: + if ( + ( + not description.capability_ignore_list + or not any( + all( + capability in device.status[MAIN] + for capability in capability_list + ) + for capability_list in description.capability_ignore_list + ) + ) + and ( + not description.exists_fn + or description.exists_fn( + device.status[MAIN][capability][attribute] + ) + ) + and ( + component == MAIN + or ( + description.component_fn is not None + and description.component_fn(component) + ) + ) + ): + if ( + description.deprecated + and ( + deprecation_info := description.deprecated( + device.status[MAIN] + ) + ) + is not None + ): + version, reason = deprecation_info + if deprecate_entity( + hass, + entity_registry, + SENSOR_DOMAIN, + f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{description.key}", + f"deprecated_{reason}", + version, + ): + entities.append( + SmartThingsSensor( + entry_data.client, + device, + description, + MAIN, + capability, + attribute, + ) + ) + continue + entities.append( + SmartThingsSensor( + entry_data.client, + device, + description, + component, + capability, + attribute, + ) + ) + + async_add_entities(entities) class SmartThingsSensor(SmartThingsEntity, SensorEntity): @@ -975,26 +1180,41 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsSensorEntityDescription, + component: str, capability: Capability, attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, {capability}) - self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" + capabilities_to_subscribe = {capability} + if entity_description.use_temperature_unit: + capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT) + super().__init__(client, device, capabilities_to_subscribe, component=component) + self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{attribute}_{entity_description.key}" self._attribute = attribute self.capability = capability self.entity_description = entity_description + if self.entity_description.translation_placeholders_fn: + self._attr_translation_placeholders = ( + self.entity_description.translation_placeholders_fn(component) + ) @property def native_value(self) -> str | float | datetime | int | None: """Return the state of the sensor.""" res = self.get_attribute_value(self.capability, self._attribute) + if options_map := self.entity_description.options_map: + return options_map.get(res) return self.entity_description.value_fn(res) @property def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" - unit = self._internal_state[self.capability][self._attribute].unit + if self.entity_description.use_temperature_unit: + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + else: + unit = self._internal_state[self.capability][self._attribute].unit return ( UNITS.get(unit, unit) if unit @@ -1014,8 +1234,13 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): def options(self) -> list[str] | None: """Return the options for this sensor.""" if self.entity_description.options_attribute: - options = self.get_attribute_value( - self.capability, self.entity_description.options_attribute - ) + if ( + options := self.get_attribute_value( + self.capability, self.entity_description.options_attribute + ) + ) is None: + return [] + if options_map := self.entity_description.options_map: + return [options_map[option] for option in options] return [option.lower() for option in options] return super().options diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index e5ffbe35e8b..2c77f7b9fe0 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -23,7 +23,9 @@ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", - "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location." + "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location.", + "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions.", + "cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml." } }, "entity": { @@ -31,13 +33,135 @@ "acceleration": { "name": "Acceleration" }, + "door": { + "name": "[%key:component::binary_sensor::entity_component::door::name%]" + }, + "dryer_wrinkle_prevent_active": { + "name": "Wrinkle prevent active" + }, + "keep_fresh_mode_active": { + "name": "Keep fresh mode active" + }, "filter_status": { "name": "Filter status" }, + "freezer_door": { + "name": "Freezer door" + }, + "cooler_door": { + "name": "Cooler door" + }, + "cool_select_plus_door": { + "name": "CoolSelect+ door" + }, + "remote_control": { + "name": "Remote control" + }, + "child_lock": { + "name": "Child lock" + }, "valve": { "name": "Valve" } }, + "button": { + "reset_water_filter": { + "name": "Reset water filter" + }, + "stop": { + "name": "[%key:common::action::stop%]" + } + }, + "event": { + "button": { + "state": { + "pushed": "Pushed", + "held": "Held", + "double": "Double", + "pushed_2x": "Pushed 2x", + "pushed_3x": "Pushed 3x", + "pushed_4x": "Pushed 4x", + "pushed_5x": "Pushed 5x", + "pushed_6x": "Pushed 6x", + "down": "Down", + "down_2x": "Down 2x", + "down_3x": "Down 3x", + "down_4x": "Down 4x", + "down_5x": "Down 5x", + "down_6x": "Down 6x", + "down_hold": "Down hold", + "up": "Up", + "up_2x": "Up 2x", + "up_3x": "Up 3x", + "up_4x": "Up 4x", + "up_5x": "Up 5x", + "up_6x": "Up 6x", + "up_hold": "Up hold", + "swipe_up": "Swipe up", + "swipe_down": "Swipe down", + "swipe_left": "Swipe left", + "swipe_right": "Swipe right" + } + } + }, + "number": { + "washer_rinse_cycles": { + "name": "Rinse cycles", + "unit_of_measurement": "cycles" + }, + "hood_fan_speed": { + "name": "Fan speed" + }, + "freezer_temperature": { + "name": "Freezer temperature" + }, + "cooler_temperature": { + "name": "Cooler temperature" + }, + "cool_select_plus_temperature": { + "name": "CoolSelect+ temperature" + } + }, + "select": { + "operating_state": { + "state": { + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "pause": "[%key:common::state::paused%]", + "stop": "[%key:common::state::stopped%]" + } + }, + "lamp": { + "name": "Lamp", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "low": "Low", + "mid": "Mid", + "high": "High", + "extra_high": "Extra high" + } + }, + "detergent_amount": { + "name": "Detergent dispense amount", + "state": { + "none": "[%key:common::state::off%]", + "less": "Less", + "standard": "Standard", + "extra": "Extra", + "custom": "Custom" + } + }, + "flexible_detergent_amount": { + "name": "Flexible compartment dispense amount", + "state": { + "none": "[%key:common::state::off%]", + "less": "[%key:component::smartthings::entity::select::detergent_amount::state::less%]", + "standard": "[%key:component::smartthings::entity::select::detergent_amount::state::standard%]", + "extra": "[%key:component::smartthings::entity::select::detergent_amount::state::extra%]", + "custom": "[%key:component::smartthings::entity::select::detergent_amount::state::custom%]" + } + } + }, "sensor": { "lighting_mode": { "name": "Activity lighting mode" @@ -74,12 +198,37 @@ "tested": "Tested" } }, + "cooktop_operating_state": { + "name": "Operating state", + "state": { + "ready": "[%key:component::smartthings::entity::sensor::oven_machine_state::state::ready%]", + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "paused": "[%key:common::state::paused%]", + "finished": "[%key:component::smartthings::entity::sensor::oven_job_state::state::finished%]" + } + }, + "manual_level": { + "name": "Burner {burner_id} level" + }, + "heating_mode": { + "name": "Burner {burner_id} heating mode", + "state": { + "off": "[%key:common::state::off%]", + "manual": "[%key:common::state::manual%]", + "boost": "Boost", + "keep_warm": "Keep warm", + "quick_preheat": "Quick preheat", + "defrost": "Defrost", + "melt": "Melt", + "simmer": "Simmer" + } + }, "dishwasher_machine_state": { "name": "Machine state", "state": { "pause": "[%key:common::state::paused%]", "run": "Running", - "stop": "Stopped" + "stop": "[%key:common::state::stopped%]" } }, "dishwasher_job_state": { @@ -108,7 +257,7 @@ "state": { "pause": "[%key:common::state::paused%]", "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", - "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } }, "dryer_job_state": { @@ -273,14 +422,17 @@ "refrigeration_setpoint": { "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" }, + "brightness_intensity": { + "name": "Brightness intensity" + }, "robot_cleaner_cleaning_mode": { "name": "Cleaning mode", "state": { - "auto": "Auto", + "stop": "[%key:common::action::stop%]", + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", "part": "Partial", "repeat": "Repeat", - "manual": "Manual", - "stop": "[%key:common::action::stop%]", "map": "Map" } }, @@ -363,7 +515,7 @@ "state": { "pause": "[%key:common::state::paused%]", "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", - "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + "stop": "[%key:common::state::stopped%]" } }, "washer_job_state": { @@ -386,7 +538,100 @@ "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]", "freeze_protection": "Freeze protection" } + }, + "water_consumption": { + "name": "Water consumption" } + }, + "switch": { + "bubble_soak": { + "name": "Bubble Soak" + }, + "wrinkle_prevent": { + "name": "Wrinkle prevent" + }, + "ice_maker": { + "name": "Ice maker" + }, + "sabbath_mode": { + "name": "Sabbath mode" + }, + "auto_cycle_link": { + "name": "Auto cycle link" + }, + "sanitize": { + "name": "Sanitize" + }, + "keep_fresh_mode": { + "name": "Keep fresh mode" + } + }, + "water_heater": { + "water_heater": { + "state": { + "standard": "Standard", + "force": "Forced", + "power": "Power" + } + } + } + }, + "issues": { + "deprecated_binary_valve": { + "title": "Valve binary sensor deprecated", + "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. A valve entity with controls is available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_binary_valve_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_binary_valve::title%]", + "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts and disable the entity to fix this issue." + }, + "deprecated_binary_fridge_door": { + "title": "Refrigerator door binary sensor deprecated", + "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. Separate entities for cooler and freezer door are available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_binary_fridge_door_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_binary_fridge_door::title%]", + "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts and disable the entity to fix this issue." + }, + "deprecated_switch_appliance": { + "title": "Appliance switch deprecated", + "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nPlease update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_switch_appliance_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", + "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the switch to fix this issue." + }, + "deprecated_switch_media_player": { + "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", + "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nPlease update your dashboards and templates accordingly and disable the switch to fix this issue." + }, + "deprecated_switch_media_player_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", + "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the switch to fix this issue." + }, + "deprecated_switch_dhw": { + "title": "Heat pump switch deprecated", + "description": "The switch `{entity_id}` is deprecated and a water heater entity has been added to replace it.\n\nPlease update your dashboards and templates accordingly and disable the switch to fix this issue." + }, + "deprecated_switch_dhw_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_switch_dhw::title%]", + "description": "The switch `{entity_id}` is deprecated and a water heater entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new water heater entity in the above automations or scripts and disable the switch to fix this issue." + }, + "deprecated_media_player": { + "title": "Media player sensors deprecated", + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nPlease update your dashboards and templates to use the new media player entity and disable the sensor to fix this issue." + }, + "deprecated_media_player_scripts": { + "title": "Deprecated sensor detected in some automations or scripts", + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new media player entity and disable the sensor to fix this issue." + }, + "deprecated_dhw": { + "title": "Water heater sensors deprecated", + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a water heater entity.\n\nPlease update your dashboards and templates to use the new water heater entity and disable the sensor to fix this issue." + }, + "deprecated_dhw_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_dhw::title%]", + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a water heater entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new water heater entity and disable the sensor to fix this issue." } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 380005f1b93..61ebc56699b 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -2,17 +2,25 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any -from pysmartthings import Attribute, Capability, Command +from pysmartthings import Attribute, Capability, Command, SmartThings -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SmartThingsConfigEntry -from .const import MAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import INVALID_SWITCH_CATEGORIES, MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity CAPABILITIES = ( Capability.SWITCH_LEVEL, @@ -28,6 +36,83 @@ AC_CAPABILITIES = ( Capability.THERMOSTAT_COOLING_SETPOINT, ) +MEDIA_PLAYER_CAPABILITIES = ( + Capability.AUDIO_MUTE, + Capability.AUDIO_VOLUME, +) + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsSwitchEntityDescription(SwitchEntityDescription): + """Describe a SmartThings switch entity.""" + + status_attribute: Attribute + component_translation_key: dict[str, str] | None = None + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsCommandSwitchEntityDescription(SmartThingsSwitchEntityDescription): + """Describe a SmartThings switch entity.""" + + command: Command + + +SWITCH = SmartThingsSwitchEntityDescription( + key=Capability.SWITCH, + status_attribute=Attribute.SWITCH, + name=None, +) +CAPABILITY_TO_COMMAND_SWITCHES: dict[ + Capability | str, SmartThingsCommandSwitchEntityDescription +] = { + Capability.CUSTOM_DRYER_WRINKLE_PREVENT: SmartThingsCommandSwitchEntityDescription( + key=Capability.CUSTOM_DRYER_WRINKLE_PREVENT, + translation_key="wrinkle_prevent", + status_attribute=Attribute.DRYER_WRINKLE_PREVENT, + command=Command.SET_DRYER_WRINKLE_PREVENT, + entity_category=EntityCategory.CONFIG, + ), + Capability.SAMSUNG_CE_STEAM_CLOSET_AUTO_CYCLE_LINK: SmartThingsCommandSwitchEntityDescription( + key=Capability.SAMSUNG_CE_STEAM_CLOSET_AUTO_CYCLE_LINK, + translation_key="auto_cycle_link", + status_attribute=Attribute.STEAM_CLOSET_AUTO_CYCLE_LINK, + command=Command.SET_STEAM_CLOSET_AUTO_CYCLE_LINK, + entity_category=EntityCategory.CONFIG, + ), +} +CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescription] = { + Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK, + translation_key="bubble_soak", + status_attribute=Attribute.STATUS, + entity_category=EntityCategory.CONFIG, + ), + Capability.SWITCH: SmartThingsSwitchEntityDescription( + key=Capability.SWITCH, + status_attribute=Attribute.SWITCH, + component_translation_key={ + "icemaker": "ice_maker", + }, + ), + Capability.SAMSUNG_CE_SABBATH_MODE: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_SABBATH_MODE, + translation_key="sabbath_mode", + status_attribute=Attribute.STATUS, + ), + Capability.SAMSUNG_CE_STEAM_CLOSET_SANITIZE_MODE: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_STEAM_CLOSET_SANITIZE_MODE, + translation_key="sanitize", + status_attribute=Attribute.STATUS, + entity_category=EntityCategory.CONFIG, + ), + Capability.SAMSUNG_CE_STEAM_CLOSET_KEEP_FRESH_MODE: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_STEAM_CLOSET_KEEP_FRESH_MODE, + translation_key="keep_fresh_mode", + status_attribute=Attribute.STATUS, + entity_category=EntityCategory.CONFIG, + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -36,35 +121,162 @@ async def async_setup_entry( ) -> None: """Add switches for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsSwitch(entry_data.client, device, {Capability.SWITCH}) + entities: list[SmartThingsEntity] = [ + SmartThingsCommandSwitch( + entry_data.client, + device, + description, + Capability(capability), + ) for device in entry_data.devices.values() - if Capability.SWITCH in device.status[MAIN] - and not any(capability in device.status[MAIN] for capability in CAPABILITIES) - and not all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) + for capability, description in CAPABILITY_TO_COMMAND_SWITCHES.items() + if capability in device.status[MAIN] + ] + entities.extend( + SmartThingsSwitch( + entry_data.client, + device, + description, + Capability(capability), + component, + ) + for device in entry_data.devices.values() + for capability, description in CAPABILITY_TO_SWITCHES.items() + for component in device.status + if capability in device.status[component] + and ( + (description.component_translation_key is None and component == MAIN) + or ( + description.component_translation_key is not None + and component in description.component_translation_key + ) + ) ) + entity_registry = er.async_get(hass) + for device in entry_data.devices.values(): + if ( + Capability.SWITCH in device.status[MAIN] + and not any( + capability in device.status[MAIN] for capability in CAPABILITIES + ) + and not all( + capability in device.status[MAIN] for capability in AC_CAPABILITIES + ) + ): + media_player = all( + capability in device.status[MAIN] + for capability in MEDIA_PLAYER_CAPABILITIES + ) + appliance = ( + device.device.components[MAIN].manufacturer_category + in INVALID_SWITCH_CATEGORIES + ) + dhw = Capability.SAMSUNG_CE_EHS_FSV_SETTINGS in device.status[MAIN] + if media_player or appliance or dhw: + if appliance: + issue = "appliance" + version = "2025.10.0" + elif media_player: + issue = "media_player" + version = "2025.10.0" + else: + issue = "dhw" + version = "2025.12.0" + if deprecate_entity( + hass, + entity_registry, + SWITCH_DOMAIN, + f"{device.device.device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + f"deprecated_switch_{issue}", + version, + ): + entities.append( + SmartThingsSwitch( + entry_data.client, + device, + SWITCH, + Capability.SWITCH, + ) + ) + continue + entities.append( + SmartThingsSwitch( + entry_data.client, + device, + SWITCH, + Capability.SWITCH, + ) + ) + async_add_entities(entities) class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" - _attr_name = None + entity_description: SmartThingsSwitchEntityDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsSwitchEntityDescription, + capability: Capability, + component: str = MAIN, + ) -> None: + """Initialize the switch.""" + super().__init__(client, device, {capability}, component=component) + self.entity_description = entity_description + self.switch_capability = capability + self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{entity_description.status_attribute}_{entity_description.status_attribute}" + if ( + translation_keys := entity_description.component_translation_key + ) is not None and ( + translation_key := translation_keys.get(component) + ) is not None: + self._attr_translation_key = translation_key async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.execute_device_command( - Capability.SWITCH, + self.switch_capability, Command.OFF, ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.execute_device_command( - Capability.SWITCH, + self.switch_capability, Command.ON, ) @property def is_on(self) -> bool: - """Return true if light is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" + """Return true if switch is on.""" + return ( + self.get_attribute_value( + self.switch_capability, self.entity_description.status_attribute + ) + == "on" + ) + + +class SmartThingsCommandSwitch(SmartThingsSwitch): + """Define a SmartThings command switch.""" + + entity_description: SmartThingsCommandSwitchEntityDescription + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.execute_device_command( + self.switch_capability, + self.entity_description.command, + "off", + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.execute_device_command( + self.switch_capability, + self.entity_description.command, + "on", + ) diff --git a/homeassistant/components/smartthings/update.py b/homeassistant/components/smartthings/update.py new file mode 100644 index 00000000000..bb226918596 --- /dev/null +++ b/homeassistant/components/smartthings/update.py @@ -0,0 +1,87 @@ +"""Support for update entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import Any + +from awesomeversion import AwesomeVersion +from pysmartthings import Attribute, Capability, Command + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add update entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsUpdateEntity(entry_data.client, device, {Capability.FIRMWARE_UPDATE}) + for device in entry_data.devices.values() + if Capability.FIRMWARE_UPDATE in device.status[MAIN] + ) + + +def is_hex_version(version: str) -> bool: + """Check if the version is a hex version.""" + return len(version) == 8 and all(c in "0123456789abcdefABCDEF" for c in version) + + +class SmartThingsUpdateEntity(SmartThingsEntity, UpdateEntity): + """Define a SmartThings update entity.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + @property + def installed_version(self) -> str | None: + """Return the installed version of the entity.""" + return self.get_attribute_value( + Capability.FIRMWARE_UPDATE, Attribute.CURRENT_VERSION + ) + + @property + def latest_version(self) -> str | None: + """Return the available version of the entity.""" + return self.get_attribute_value( + Capability.FIRMWARE_UPDATE, Attribute.AVAILABLE_VERSION + ) + + @property + def in_progress(self) -> bool: + """Return if the entity is in progress.""" + return ( + self.get_attribute_value(Capability.FIRMWARE_UPDATE, Attribute.STATE) + == "updateInProgress" + ) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the firmware update.""" + await self.execute_device_command( + Capability.FIRMWARE_UPDATE, + Command.UPDATE_FIRMWARE, + ) + + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return if the latest version is newer.""" + if is_hex_version(latest_version): + latest_version = f"0x{latest_version}" + if is_hex_version(installed_version): + installed_version = f"0x{installed_version}" + return AwesomeVersion(latest_version) > AwesomeVersion(installed_version) diff --git a/homeassistant/components/smartthings/util.py b/homeassistant/components/smartthings/util.py new file mode 100644 index 00000000000..7d74e22477f --- /dev/null +++ b/homeassistant/components/smartthings/util.py @@ -0,0 +1,84 @@ +"""Utility functions for SmartThings integration.""" + +from homeassistant.components.automation import automations_with_entity +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, + async_delete_issue, +) + +from .const import DOMAIN + + +def deprecate_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + platform_domain: str, + entity_unique_id: str, + issue_string: str, + version: str = "2025.10.0", +) -> bool: + """Create an issue for deprecated entities.""" + if entity_id := entity_registry.async_get_entity_id( + platform_domain, DOMAIN, entity_unique_id + ): + entity_entry = entity_registry.async_get(entity_id) + if not entity_entry: + return False + if entity_entry.disabled: + entity_registry.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + f"{issue_string}_{entity_id}", + ) + return False + translation_key = issue_string + placeholders = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or "Unknown", + } + if items := get_automations_and_scripts_using_entity(hass, entity_id): + translation_key = f"{translation_key}_scripts" + placeholders.update( + { + "items": "\n".join(items), + } + ) + async_create_issue( + hass, + DOMAIN, + f"{issue_string}_{entity_id}", + breaks_in_ha_version=version, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + return True + return False + + +def get_automations_and_scripts_using_entity( + hass: HomeAssistant, + entity_id: str, +) -> list[str]: + """Get automations and scripts using an entity.""" + automations = automations_with_entity(hass, entity_id) + scripts = scripts_with_entity(hass, entity_id) + if not automations and not scripts: + return [] + + entity_reg = er.async_get(hass) + return [ + f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" + for integration, entities in ( + ("automation", automations), + ("script", scripts), + ) + for entity_id in entities + if (item := entity_reg.async_get(entity_id)) + ] diff --git a/homeassistant/components/smartthings/valve.py b/homeassistant/components/smartthings/valve.py new file mode 100644 index 00000000000..4279d528f8b --- /dev/null +++ b/homeassistant/components/smartthings/valve.py @@ -0,0 +1,71 @@ +"""Support for valves through the SmartThings cloud API.""" + +from __future__ import annotations + +from pysmartthings import Attribute, Capability, Category, Command, SmartThings + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + +DEVICE_CLASS_MAP: dict[Category | str, ValveDeviceClass] = { + Category.WATER_VALVE: ValveDeviceClass.WATER, + Category.GAS_VALVE: ValveDeviceClass.GAS, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add valves for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsValve(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.VALVE in device.status[MAIN] + ) + + +class SmartThingsValve(SmartThingsEntity, ValveEntity): + """Define a SmartThings valve.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_reports_position = False + _attr_name = None + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Init the class.""" + super().__init__(client, device, {Capability.VALVE}) + self._attr_device_class = DEVICE_CLASS_MAP.get( + device.device.components[MAIN].user_category + or device.device.components[MAIN].manufacturer_category + ) + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.execute_device_command( + Capability.VALVE, + Command.OPEN, + ) + + async def async_close_valve(self) -> None: + """Close the valve.""" + await self.execute_device_command( + Capability.VALVE, + Command.CLOSE, + ) + + @property + def is_closed(self) -> bool: + """Return if the valve is closed.""" + return self.get_attribute_value(Capability.VALVE, Attribute.VALVE) == "closed" diff --git a/homeassistant/components/smartthings/water_heater.py b/homeassistant/components/smartthings/water_heater.py new file mode 100644 index 00000000000..addbfed2ec4 --- /dev/null +++ b/homeassistant/components/smartthings/water_heater.py @@ -0,0 +1,230 @@ +"""Support for water heaters through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import Any + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.water_heater import ( + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + STATE_ECO, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.unit_conversion import TemperatureConverter + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN, UNIT_MAP +from .entity import SmartThingsEntity + +OPERATION_MAP_TO_HA: dict[str, str] = { + "eco": STATE_ECO, + "std": "standard", + "force": "force", + "power": "power", +} + +HA_TO_OPERATION_MAP = {v: k for k, v in OPERATION_MAP_TO_HA.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add water heaters for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsWaterHeater(entry_data.client, device) + for device in entry_data.devices.values() + if all( + capability in device.status[MAIN] + for capability in ( + Capability.SWITCH, + Capability.AIR_CONDITIONER_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.SAMSUNG_CE_EHS_THERMOSTAT, + Capability.CUSTOM_OUTING_MODE, + ) + ) + and device.status[MAIN][Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].value + is not None + ) + + +class SmartThingsWaterHeater(SmartThingsEntity, WaterHeaterEntity): + """Define a SmartThings Water Heater.""" + + _attr_name = None + _attr_translation_key = "water_heater" + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Init the class.""" + super().__init__( + client, + device, + { + Capability.SWITCH, + Capability.AIR_CONDITIONER_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.CUSTOM_OUTING_MODE, + }, + ) + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + assert unit is not None + self._attr_temperature_unit = UNIT_MAP[unit] + + @property + def supported_features(self) -> WaterHeaterEntityFeature: + """Return the supported features.""" + features = ( + WaterHeaterEntityFeature.OPERATION_MODE + | WaterHeaterEntityFeature.AWAY_MODE + | WaterHeaterEntityFeature.ON_OFF + ) + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on": + features |= WaterHeaterEntityFeature.TARGET_TEMPERATURE + return features + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + min_temperature = TemperatureConverter.convert( + DEFAULT_MIN_TEMP, UnitOfTemperature.FAHRENHEIT, self._attr_temperature_unit + ) + return min(min_temperature, self.target_temperature_low) + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + max_temperature = TemperatureConverter.convert( + DEFAULT_MAX_TEMP, UnitOfTemperature.FAHRENHEIT, self._attr_temperature_unit + ) + return max(max_temperature, self.target_temperature_high) + + @property + def operation_list(self) -> list[str]: + """Return the list of available operation modes.""" + return [ + STATE_OFF, + *( + OPERATION_MAP_TO_HA[mode] + for mode in self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ) + if mode in OPERATION_MAP_TO_HA + ), + ] + + @property + def current_operation(self) -> str | None: + """Return the current operation mode.""" + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": + return STATE_OFF + return OPERATION_MAP_TO_HA.get( + self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE + ) + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.get_attribute_value( + Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE + ) + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) + + @property + def target_temperature_low(self) -> float: + """Return the minimum temperature.""" + return self.get_attribute_value( + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, Attribute.MINIMUM_SETPOINT + ) + + @property + def target_temperature_high(self) -> float: + """Return the maximum temperature.""" + return self.get_attribute_value( + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, Attribute.MAXIMUM_SETPOINT + ) + + @property + def is_away_mode_on(self) -> bool: + """Return if away mode is on.""" + return ( + self.get_attribute_value( + Capability.CUSTOM_OUTING_MODE, Attribute.OUTING_MODE + ) + == "on" + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + if operation_mode == STATE_OFF: + await self.async_turn_off() + return + if self.current_operation == STATE_OFF: + await self.async_turn_on() + await self.execute_device_command( + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + argument=HA_TO_OPERATION_MAP[operation_mode], + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + await self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + argument=kwargs[ATTR_TEMPERATURE], + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the water heater on.""" + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) + + async def async_turn_away_mode_on(self) -> None: + """Turn away mode on.""" + await self.execute_device_command( + Capability.CUSTOM_OUTING_MODE, + Command.SET_OUTING_MODE, + argument="on", + ) + + async def async_turn_away_mode_off(self) -> None: + """Turn away mode off.""" + await self.execute_device_command( + Capability.CUSTOM_OUTING_MODE, + Command.SET_OUTING_MODE, + argument="off", + ) diff --git a/homeassistant/components/smarttub/strings.json b/homeassistant/components/smarttub/strings.json index 974e5fb7d37..8391aaa4d47 100644 --- a/homeassistant/components/smarttub/strings.json +++ b/homeassistant/components/smarttub/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Login", - "description": "Enter your SmartTub email address and password to login", + "description": "Enter your SmartTub email address and password to log in", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" @@ -49,17 +49,17 @@ }, "snooze_reminder": { "name": "Snooze a reminder", - "description": "Delay a reminder, so that it won't trigger again for a period of time.", + "description": "Temporarily suppresses the maintenance reminder on a hot tub.", "fields": { "days": { "name": "Days", - "description": "The number of days to delay the reminder." + "description": "The number of days to snooze the reminder." } } }, "reset_reminder": { "name": "Reset a reminder", - "description": "Reset a reminder, and set the next time it will be triggered.", + "description": "Resets the maintenance reminder on a hot tub.", "fields": { "days": { "name": "[%key:component::smarttub::services::snooze_reminder::fields::days::name%]", diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index aab8c6ab3c7..1803f501dc7 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -1,34 +1,10 @@ """Support to control a Salda Smarty XP/XV ventilation unit.""" -import ipaddress -import logging +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, Platform -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.helpers.typing import ConfigType - -from .const import DOMAIN from .coordinator import SmartyConfigEntry, SmartyCoordinator -_LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string), - vol.Optional(CONF_NAME, default="Smarty"): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -38,54 +14,6 @@ PLATFORMS = [ ] -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Create a smarty system.""" - if config := hass_config.get(DOMAIN): - hass.async_create_task(_async_import(hass, config)) - return True - - -async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: - """Set up the smarty environment.""" - - 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.5.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": "Smarty", - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Smarty", - }, - ) - - async def async_setup_entry(hass: HomeAssistant, entry: SmartyConfigEntry) -> bool: """Set up the Smarty environment from a config entry.""" diff --git a/homeassistant/components/smarty/config_flow.py b/homeassistant/components/smarty/config_flow.py index 9a55356a990..5abae121cd7 100644 --- a/homeassistant/components/smarty/config_flow.py +++ b/homeassistant/components/smarty/config_flow.py @@ -1,15 +1,18 @@ """Config flow for Smarty integration.""" +import logging from typing import Any from pysmarty2 import Smarty import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class SmartyConfigFlow(ConfigFlow, domain=DOMAIN): """Smarty config flow.""" @@ -20,7 +23,8 @@ class SmartyConfigFlow(ConfigFlow, domain=DOMAIN): try: if smarty.update(): return None - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return "unknown" else: return "cannot_connect" @@ -46,17 +50,3 @@ class SmartyConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) - - async def async_step_import( - self, import_config: dict[str, Any] - ) -> ConfigFlowResult: - """Handle a flow initialized by import.""" - error = await self.hass.async_add_executor_job( - self._test_connection, import_config[CONF_HOST] - ) - if not error: - return self.async_create_entry( - title=import_config[CONF_NAME], - data={CONF_HOST: import_config[CONF_HOST]}, - ) - return self.async_abort(reason=error) diff --git a/homeassistant/components/smarty/entity.py b/homeassistant/components/smarty/entity.py index d26b56d489f..f6533000f45 100644 --- a/homeassistant/components/smarty/entity.py +++ b/homeassistant/components/smarty/entity.py @@ -3,7 +3,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN +from .const import DOMAIN from .coordinator import SmartyCoordinator diff --git a/homeassistant/components/smarty/strings.json b/homeassistant/components/smarty/strings.json index 341a300a26e..d9852ab40d3 100644 --- a/homeassistant/components/smarty/strings.json +++ b/homeassistant/components/smarty/strings.json @@ -20,20 +20,6 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, - "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." - }, - "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." - }, - "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." - } - }, "entity": { "binary_sensor": { "alarm": { diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index fc3af634764..0af692b800c 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": ["pysmhi"], - "requirements": ["pysmhi==1.0.0"] + "requirements": ["pysmhi==1.0.2"] } diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 8f3e675ef6b..b3a6860e5b7 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pysmlight import Api2, Info, Radio +from pysmlight import Api2 from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant @@ -50,9 +50,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def get_radio(info: Info, idx: int) -> Radio: - """Get the radio object from the info.""" - assert info.radios is not None - return info.radios[idx] diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index ce3457ae81b..aaba15e19f2 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -22,6 +22,7 @@ from .const import SCAN_INTERNET_INTERVAL from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 0 SCAN_INTERVAL = SCAN_INTERNET_INTERVAL diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index 5caf43b7cba..f834392ea13 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -23,6 +23,8 @@ from .const import DOMAIN from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index fcfc364d983..39750bdc422 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -51,14 +51,13 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self.client = Api2(self._host, session=async_get_clientsession(self.hass)) try: - info = await self.client.get_info() - self._host = str(info.device_ip) - self._device_name = str(info.hostname) - - if info.model not in Devices: - return self.async_abort(reason="unsupported_device") - if not await self._async_check_auth_required(user_input): + info = await self.client.get_info() + self._device_name = str(info.hostname) + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + return await self._async_complete_entry(user_input) except SmlightConnectionError: errors["base"] = "cannot_connect" @@ -79,7 +78,6 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): try: if not await self._async_check_auth_required(user_input): info = await self.client.get_info() - self._host = str(info.device_ip) self._device_name = str(info.hostname) if info.model not in Devices: @@ -128,13 +126,13 @@ 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) + info = await self.client.get_info() + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + + return await self._async_complete_entry(user_input) except SmlightConnectionError: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 5a118e7de15..8a8dcd74b8f 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -111,7 +111,11 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): raise ConfigEntryAuthFailed from err except SmlightConnectionError as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect_device", + translation_placeholders={"error": str(err)}, + ) from err @abstractmethod async def _internal_update_data(self) -> _DataT: diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3f527d1fcd9..b2a03a737fc 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -11,7 +11,8 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.2.3"], + "quality_scale": "silver", + "requirements": ["pysmlight==0.2.4"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/smlight/quality_scale.yaml b/homeassistant/components/smlight/quality_scale.yaml new file mode 100644 index 00000000000..5c6d7364704 --- /dev/null +++ b/homeassistant/components/smlight/quality_scale.yaml @@ -0,0 +1,85 @@ +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: done + comment: | + Entities subscribe to SSE events from pysmlight library. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: done + comment: Handled implicitly within coordinator + 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 provide an option flow. + docs-installation-parameters: done + 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: done + discovery: done + docs-data-update: done + 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: | + Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: | + Device type integration. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 57a08d177d4..f045d009a00 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -25,6 +25,8 @@ from .const import UPTIME_DEVIATION from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class SmSensorEntityDescription(SensorEntityDescription): @@ -37,7 +39,7 @@ class SmSensorEntityDescription(SensorEntityDescription): class SmInfoEntityDescription(SensorEntityDescription): """Class describing SMLIGHT information entities.""" - value_fn: Callable[[Info], StateType] + value_fn: Callable[[Info, int], StateType] INFO: list[SmInfoEntityDescription] = [ @@ -46,24 +48,25 @@ INFO: list[SmInfoEntityDescription] = [ translation_key="device_mode", device_class=SensorDeviceClass.ENUM, options=["eth", "wifi", "usb"], - value_fn=lambda x: x.coord_mode, + value_fn=lambda x, idx: x.coord_mode, ), SmInfoEntityDescription( key="firmware_channel", translation_key="firmware_channel", device_class=SensorDeviceClass.ENUM, options=["dev", "release"], - value_fn=lambda x: x.fw_channel, - ), - SmInfoEntityDescription( - key="zigbee_type", - translation_key="zigbee_type", - device_class=SensorDeviceClass.ENUM, - options=["coordinator", "router", "thread"], - value_fn=lambda x: x.zb_type, + value_fn=lambda x, idx: x.fw_channel, ), ] +RADIO_INFO = SmInfoEntityDescription( + key="zigbee_type", + translation_key="zigbee_type", + device_class=SensorDeviceClass.ENUM, + options=["coordinator", "router", "thread"], + value_fn=lambda x, idx: x.radios[idx].zb_type, +) + SENSORS: list[SmSensorEntityDescription] = [ SmSensorEntityDescription( @@ -102,6 +105,16 @@ SENSORS: list[SmSensorEntityDescription] = [ ), ] +EXTRA_SENSOR = SmSensorEntityDescription( + key="zigbee_temperature_2", + translation_key="zigbee_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda x: x.zb_temp2, +) + UPTIME: list[SmSensorEntityDescription] = [ SmSensorEntityDescription( key="core_uptime", @@ -127,8 +140,7 @@ async def async_setup_entry( ) -> None: """Set up SMLIGHT sensor based on a config entry.""" coordinator = entry.runtime_data.data - - async_add_entities( + entities: list[SmEntity] = list( chain( (SmInfoSensorEntity(coordinator, description) for description in INFO), (SmSensorEntity(coordinator, description) for description in SENSORS), @@ -136,6 +148,16 @@ async def async_setup_entry( ) ) + entities.extend( + SmInfoSensorEntity(coordinator, RADIO_INFO, idx) + for idx, _ in enumerate(coordinator.data.info.radios) + ) + + if coordinator.data.sensors.zb_temp2 is not None: + entities.append(SmSensorEntity(coordinator, EXTRA_SENSOR)) + + async_add_entities(entities) + class SmSensorEntity(SmEntity, SensorEntity): """Representation of a slzb sensor.""" @@ -172,17 +194,20 @@ class SmInfoSensorEntity(SmEntity, SensorEntity): self, coordinator: SmDataUpdateCoordinator, description: SmInfoEntityDescription, + idx: int = 0, ) -> None: """Initiate slzb sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + self.idx = idx + sensor = f"_{idx}" if idx else "" + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}{sensor}" @property def native_value(self) -> StateType: """Return the sensor value.""" - value = self.entity_description.value_fn(self.coordinator.data.info) + value = self.entity_description.value_fn(self.coordinator.data.info, self.idx) options = self.entity_description.options if isinstance(value, int) and options is not None: diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index ca52f6fea38..4abc6349d1e 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -15,6 +15,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Username for the device's web login.", + "password": "Password for the device's web login." } }, "reauth_confirm": { @@ -23,6 +27,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::smlight::config::step::auth::data_description::username%]", + "password": "[%key:component::smlight::config::step::auth::data_description::password%]" } }, "confirm_discovery": { @@ -137,6 +145,14 @@ } } }, + "exceptions": { + "firmware_update_failed": { + "message": "Firmware update failed for {device_name}." + }, + "cannot_connect_device": { + "message": "An error occurred while connecting to the SMLIGHT device: {error}." + } + }, "issues": { "unsupported_firmware": { "title": "SLZB core firmware update required", diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 09d2714956c..5cd187c009c 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -22,6 +22,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 10d142e6221..d7aed0ecb4d 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -22,11 +22,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import get_radio -from .const import LOGGER +from .const import DOMAIN, LOGGER from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity +PARALLEL_UPDATES = 1 + def zigbee_latest_version(data: SmFwData, idx: int) -> Firmware | None: """Get the latest Zigbee firmware version.""" @@ -56,7 +57,7 @@ CORE_UPDATE_ENTITY = SmUpdateEntityDescription( ZB_UPDATE_ENTITY = SmUpdateEntityDescription( key="zigbee_update", translation_key="zigbee_update", - installed_version=lambda x, idx: get_radio(x, idx).zb_version, + installed_version=lambda x, idx: x.radios[idx].zb_version, latest_version=zigbee_latest_version, ) @@ -75,7 +76,6 @@ async def async_setup_entry( entities = [SmUpdateEntity(coordinator, CORE_UPDATE_ENTITY)] radios = coordinator.data.info.radios - assert radios is not None entities.extend( SmUpdateEntity(coordinator, ZB_UPDATE_ENTITY, idx) @@ -210,7 +210,13 @@ class SmUpdateEntity(SmEntity, UpdateEntity): def _update_failed(self, event: MessageEvent) -> None: self._update_done() self.coordinator.in_progress = False - raise HomeAssistantError(f"Update failed for {self.name}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_update_failed", + translation_placeholders={ + "device_name": str(self.name), + }, + ) async def async_install( self, version: str | None, backup: bool, **kwargs: Any diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index e86b22690a4..b0f484f0cb1 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -11,6 +11,9 @@ import logging import os from pathlib import Path import smtplib +import socket +import ssl +from typing import Any import voluptuous as vol @@ -38,7 +41,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from homeassistant.util.ssl import client_context +from homeassistant.util.ssl import create_client_context from .const import ( ATTR_HTML, @@ -86,6 +89,7 @@ def get_service( ) -> MailNotificationService | None: """Get the mail notification service.""" setup_reload_service(hass, DOMAIN, PLATFORMS) + ssl_context = create_client_context() if config[CONF_VERIFY_SSL] else None mail_service = MailNotificationService( config[CONF_SERVER], config[CONF_PORT], @@ -98,6 +102,7 @@ def get_service( config.get(CONF_SENDER_NAME), config[CONF_DEBUG], config[CONF_VERIFY_SSL], + ssl_context, ) if mail_service.connection_is_valid(): @@ -111,18 +116,19 @@ class MailNotificationService(BaseNotificationService): def __init__( self, - server, - port, - timeout, - sender, - encryption, - username, - password, - recipients, - sender_name, - debug, - verify_ssl, - ): + server: str, + port: int, + timeout: int, + sender: str, + encryption: str, + username: str | None, + password: str | None, + recipients: list[str], + sender_name: str | None, + debug: bool, + verify_ssl: bool, + ssl_context: ssl.SSLContext | None, + ) -> None: """Initialize the SMTP service.""" self._server = server self._port = port @@ -136,34 +142,35 @@ class MailNotificationService(BaseNotificationService): self.debug = debug self._verify_ssl = verify_ssl self.tries = 2 + self._ssl_context = ssl_context - def connect(self): + def connect(self) -> smtplib.SMTP_SSL | smtplib.SMTP: """Connect/authenticate to SMTP Server.""" - ssl_context = client_context() if self._verify_ssl else None + mail: smtplib.SMTP_SSL | smtplib.SMTP if self.encryption == "tls": mail = smtplib.SMTP_SSL( self._server, self._port, timeout=self._timeout, - context=ssl_context, + context=self._ssl_context, ) else: mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout) mail.set_debuglevel(self.debug) mail.ehlo_or_helo_if_needed() if self.encryption == "starttls": - mail.starttls(context=ssl_context) + mail.starttls(context=self._ssl_context) mail.ehlo() if self.username and self.password: mail.login(self.username, self.password) return mail - def connection_is_valid(self): + def connection_is_valid(self) -> bool: """Check for valid config, verify connectivity.""" server = None try: server = self.connect() - except (smtplib.socket.gaierror, ConnectionRefusedError): + except (socket.gaierror, ConnectionRefusedError): _LOGGER.exception( ( "SMTP server not found or refused connection (%s:%s). Please check" @@ -185,7 +192,7 @@ class MailNotificationService(BaseNotificationService): return True - def send_message(self, message="", **kwargs): + def send_message(self, message: str, **kwargs: Any) -> None: """Build and send a message to a user. Will send plain text normally, with pictures as attachments if images config is @@ -193,6 +200,7 @@ class MailNotificationService(BaseNotificationService): """ subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + msg: MIMEMultipart | MIMEText if data := kwargs.get(ATTR_DATA): if ATTR_HTML in data: msg = _build_html_msg( @@ -210,20 +218,24 @@ class MailNotificationService(BaseNotificationService): msg["Subject"] = subject - if not (recipients := kwargs.get(ATTR_TARGET)): + if targets := kwargs.get(ATTR_TARGET): + recipients: list[str] = targets # ensured by NOTIFY_SERVICE_SCHEMA + else: recipients = self.recipients - msg["To"] = recipients if isinstance(recipients, str) else ",".join(recipients) + msg["To"] = ",".join(recipients) + if self._sender_name: msg["From"] = f"{self._sender_name} <{self._sender}>" else: msg["From"] = self._sender + msg["X-Mailer"] = "Home Assistant" msg["Date"] = email.utils.format_datetime(dt_util.now()) msg["Message-Id"] = email.utils.make_msgid() return self._send_email(msg, recipients) - def _send_email(self, msg, recipients): + def _send_email(self, msg: MIMEMultipart | MIMEText, recipients: list[str]) -> None: """Send the message.""" mail = self.connect() for _ in range(self.tries): @@ -243,13 +255,15 @@ class MailNotificationService(BaseNotificationService): mail.quit() -def _build_text_msg(message): +def _build_text_msg(message: str) -> MIMEText: """Build plaintext email.""" _LOGGER.debug("Building plain text email") return MIMEText(message) -def _attach_file(hass, atch_name, content_id=""): +def _attach_file( + hass: HomeAssistant, atch_name: str, content_id: str | None = None +) -> MIMEImage | MIMEApplication | None: """Create a message attachment. If MIMEImage is successful and content_id is passed (HTML), add images in-line. @@ -268,7 +282,7 @@ def _attach_file(hass, atch_name, content_id=""): translation_key="remote_path_not_allowed", translation_placeholders={ "allow_list": allow_list, - "file_path": file_path, + "file_path": str(file_path), "file_name": file_name, "url": url, }, @@ -279,6 +293,7 @@ def _attach_file(hass, atch_name, content_id=""): _LOGGER.warning("Attachment %s not found. Skipping", atch_name) return None + attachment: MIMEImage | MIMEApplication try: attachment = MIMEImage(file_bytes) except TypeError: @@ -302,7 +317,9 @@ def _attach_file(hass, atch_name, content_id=""): return attachment -def _build_multipart_msg(hass, message, images): +def _build_multipart_msg( + hass: HomeAssistant, message: str, images: list[str] +) -> MIMEMultipart: """Build Multipart message with images as attachments.""" _LOGGER.debug("Building multipart email with image attachme_build_html_msgnt(s)") msg = MIMEMultipart() @@ -317,7 +334,9 @@ def _build_multipart_msg(hass, message, images): return msg -def _build_html_msg(hass, text, html, images): +def _build_html_msg( + hass: HomeAssistant, text: str, html: str, images: list[str] +) -> MIMEMultipart: """Build Multipart message with in-line images and rich HTML (UTF-8).""" _LOGGER.debug("Building HTML rich email") msg = MIMEMultipart("related") diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 0baecd68ec4..bd50e2050e0 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -45,6 +45,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -94,7 +95,9 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( vol.Optional(CONF_DEFAULT_VALUE): cv.string, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In(SNMP_VERSIONS), vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_AUTH_KEY): cv.string, @@ -173,7 +176,7 @@ async def async_setup_platform( continue trigger_entity_config[key] = config[key] - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = config.get(CONF_VALUE_TEMPLATE) data = SnmpData(request_args, baseoid, accept_errors, default_value) async_add_entities([SnmpSensor(hass, data, trigger_entity_config, value_template)]) @@ -189,7 +192,7 @@ class SnmpSensor(ManualTriggerSensorEntity): hass: HomeAssistant, data: SnmpData, config: ConfigType, - value_template: Template | None, + value_template: ValueTemplate | None, ) -> None: """Initialize the sensor.""" super().__init__(hass, config) @@ -206,17 +209,16 @@ class SnmpSensor(ManualTriggerSensorEntity): """Get the latest data and updates the states.""" await self.data.async_update() - raw_value = self.data.value - + variables = self._template_variables_with_value(self.data.value) if (value := self.data.value) is None: value = STATE_UNKNOWN elif self._value_template is not None: - value = self._value_template.async_render_with_possible_json_value( - value, STATE_UNKNOWN + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, STATE_UNKNOWN ) self._attr_native_value = value - self._process_manual_data(raw_value) + self._process_manual_data(variables) class SnmpData: diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index aaf0c828830..54834bf58ce 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -17,7 +17,13 @@ from .coordinator import SnooConfigEntry, SnooCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.EVENT, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: diff --git a/homeassistant/components/snoo/binary_sensor.py b/homeassistant/components/snoo/binary_sensor.py new file mode 100644 index 00000000000..3c91db5b86d --- /dev/null +++ b/homeassistant/components/snoo/binary_sensor.py @@ -0,0 +1,70 @@ +"""Support for Snoo Binary Sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from python_snoo.containers import SnooData + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, + EntityCategory, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Snoo Binary Sensor.""" + + value_fn: Callable[[SnooData], bool] + + +BINARY_SENSOR_DESCRIPTIONS: list[SnooBinarySensorEntityDescription] = [ + SnooBinarySensorEntityDescription( + key="left_clip", + translation_key="left_clip", + value_fn=lambda data: data.left_safety_clip, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SnooBinarySensorEntityDescription( + key="right_clip", + translation_key="right_clip", + value_fn=lambda data: data.left_safety_clip, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooBinarySensor(coordinator, description) + for coordinator in coordinators.values() + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class SnooBinarySensor(SnooDescriptionEntity, BinarySensorEntity): + """A Binary sensor using Snoo coordinator.""" + + entity_description: SnooBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/snoo/event.py b/homeassistant/components/snoo/event.py new file mode 100644 index 00000000000..1e50ee46d90 --- /dev/null +++ b/homeassistant/components/snoo/event.py @@ -0,0 +1,63 @@ +"""Support for Snoo Events.""" + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooEvent( + coordinator, + EventEntityDescription( + key="event", + translation_key="event", + event_types=[ + "timer", + "cry", + "command", + "safety_clip", + "long_activity_press", + "activity", + "power", + "status_requested", + "sticky_white_noise_updated", + "config_change", + ], + ), + ) + for coordinator in coordinators.values() + ) + + +class SnooEvent(SnooDescriptionEntity, EventEntity): + """A event using Snoo coordinator.""" + + @callback + def _async_handle_event(self) -> None: + """Handle the demo button event.""" + self._trigger_event( + self.coordinator.data.event.value, + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add Event.""" + await super().async_added_to_hass() + if self.coordinator.data: + # If we were able to get data on startup - set it + # Otherwise, it will update when the coordinator gets data. + self._async_handle_event() + + def _handle_coordinator_update(self) -> None: + self._async_handle_event() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 3dca8cfe7dd..2afec990e4b 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.0"] + "requirements": ["python-snoo==0.6.6"] } diff --git a/homeassistant/components/snoo/select.py b/homeassistant/components/snoo/select.py new file mode 100644 index 00000000000..44624ed1a2d --- /dev/null +++ b/homeassistant/components/snoo/select.py @@ -0,0 +1,78 @@ +"""Support for Snoo Select.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from python_snoo.containers import SnooData, SnooDevice, SnooLevels +from python_snoo.exceptions import SnooCommandException +from python_snoo.snoo import Snoo + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooSelectEntityDescription(SelectEntityDescription): + """Describes a Snoo Select.""" + + value_fn: Callable[[SnooData], str] + set_value_fn: Callable[[Snoo, SnooDevice, str], Awaitable[None]] + + +SELECT_DESCRIPTIONS: list[SnooSelectEntityDescription] = [ + SnooSelectEntityDescription( + key="intensity", + translation_key="intensity", + value_fn=lambda data: data.state_machine.level.name, + set_value_fn=lambda snoo_api, device, state: snoo_api.set_level( + device, SnooLevels[state] + ), + options=[level.name for level in SnooLevels], + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooSelect(coordinator, description) + for coordinator in coordinators.values() + for description in SELECT_DESCRIPTIONS + ) + + +class SnooSelect(SnooDescriptionEntity, SelectEntity): + """A sensor using Snoo coordinator.""" + + entity_description: SnooSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + try: + await self.entity_description.set_value_fn( + self.coordinator.snoo, self.device, option + ) + except SnooCommandException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="select_failed", + translation_placeholders={"name": str(self.name), "option": option}, + ) from err diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index 567fa30fca7..e4a5c634a68 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -21,7 +21,48 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "exceptions": { + "select_failed": { + "message": "Error while updating {name} to {option}" + }, + "switch_on_failed": { + "message": "Turning {name} on failed" + }, + "switch_off_failed": { + "message": "Turning {name} off failed" + } + }, "entity": { + "binary_sensor": { + "left_clip": { + "name": "Left safety clip" + }, + "right_clip": { + "name": "Right safety clip" + } + }, + "event": { + "event": { + "name": "Snoo event", + "state_attributes": { + "event_type": { + "state": { + "timer": "Timer", + "cry": "Cry", + "command": "Command sent", + "safety_clip": "Safety clip changed", + "long_activity_press": "Long activity press", + "activity": "Activity press", + "power": "Power button pressed", + "status_requested": "Status requested", + "sticky_white_noise_updated": "Sleepytime sounds updated", + "config_change": "Config changed", + "restart": "Restart" + } + } + } + } + }, "sensor": { "state": { "name": "State", @@ -31,7 +72,7 @@ "level2": "Level 2", "level3": "Level 3", "level4": "Level 4", - "stop": "Stopped", + "stop": "[%key:common::state::stopped%]", "pretimeout": "Pre-timeout", "timeout": "Timeout" } @@ -39,6 +80,27 @@ "time_left": { "name": "Time left" } + }, + "select": { + "intensity": { + "name": "Intensity", + "state": { + "baseline": "[%key:component::snoo::entity::sensor::state::state::baseline%]", + "level1": "[%key:component::snoo::entity::sensor::state::state::level1%]", + "level2": "[%key:component::snoo::entity::sensor::state::state::level2%]", + "level3": "[%key:component::snoo::entity::sensor::state::state::level3%]", + "level4": "[%key:component::snoo::entity::sensor::state::state::level4%]", + "stop": "[%key:common::state::stopped%]" + } + } + }, + "switch": { + "sticky_white_noise": { + "name": "Sleepytime sounds" + }, + "hold": { + "name": "Level lock" + } } } } diff --git a/homeassistant/components/snoo/switch.py b/homeassistant/components/snoo/switch.py new file mode 100644 index 00000000000..2ed322d5f6b --- /dev/null +++ b/homeassistant/components/snoo/switch.py @@ -0,0 +1,105 @@ +"""Support for Snoo Switches.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from python_snoo.containers import SnooData, SnooDevice +from python_snoo.exceptions import SnooCommandException +from python_snoo.snoo import Snoo + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooSwitchEntityDescription(SwitchEntityDescription): + """Describes a Snoo sensor.""" + + value_fn: Callable[[SnooData], bool] + set_value_fn: Callable[[Snoo, SnooDevice, SnooData, bool], Awaitable[None]] + + +BINARY_SENSOR_DESCRIPTIONS: list[SnooSwitchEntityDescription] = [ + SnooSwitchEntityDescription( + key="sticky_white_noise", + translation_key="sticky_white_noise", + value_fn=lambda data: data.state_machine.sticky_white_noise == "on", + set_value_fn=lambda snoo_api, device, _, state: snoo_api.set_sticky_white_noise( + device, state + ), + ), + SnooSwitchEntityDescription( + key="hold", + translation_key="hold", + value_fn=lambda data: data.state_machine.hold == "on", + set_value_fn=lambda snoo_api, device, data, state: snoo_api.set_level( + device, data.state_machine.level, state + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooSwitch(coordinator, description) + for coordinator in coordinators.values() + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class SnooSwitch(SnooDescriptionEntity, SwitchEntity): + """A switch using Snoo coordinator.""" + + entity_description: SnooSwitchEntityDescription + + @property + def is_on(self) -> bool | None: + """Return True if entity is on.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + try: + await self.entity_description.set_value_fn( + self.coordinator.snoo, + self.coordinator.device, + self.coordinator.data, + True, + ) + except SnooCommandException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_on_failed", + translation_placeholders={"name": str(self.name), "status": "on"}, + ) from err + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + try: + await self.entity_description.set_value_fn( + self.coordinator.snoo, + self.coordinator.device, + self.coordinator.data, + False, + ) + except SnooCommandException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_off_failed", + translation_placeholders={"name": str(self.name), "status": "off"}, + ) from err diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json index 2b626987546..105a9282a6d 100644 --- a/homeassistant/components/solaredge/strings.json +++ b/homeassistant/components/solaredge/strings.json @@ -5,7 +5,7 @@ "title": "Define the API parameters for this installation", "data": { "name": "The name of this installation", - "site_id": "The SolarEdge site-id", + "site_id": "The SolarEdge site ID", "api_key": "[%key:common::config_flow::data::api_key%]" } } @@ -14,7 +14,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "site_not_active": "The site is not active", - "could_not_connect": "Could not connect to the solaredge API" + "could_not_connect": "Could not connect to the SolarEdge API" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" @@ -65,7 +65,7 @@ "name": "Grid power" }, "storage_power": { - "name": "Stored power" + "name": "Storage power" }, "purchased_energy": { "name": "Imported energy" diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 6292b1332d7..48ebeece1ba 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -75,7 +75,7 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): await self.solarlog.test_extended_data_available() if logged_in or await self.solarlog.test_extended_data_available(): device_list = await self.solarlog.update_device_list() - self.solarlog.set_enabled_devices({key: True for key in device_list}) + self.solarlog.set_enabled_devices(dict.fromkeys(device_list, True)) async def _async_update_data(self) -> SolarlogData: """Update the data from the SolarLog device.""" diff --git a/homeassistant/components/soma/manifest.json b/homeassistant/components/soma/manifest.json index 5884e5f53c4..ed0c5ff6240 100644 --- a/homeassistant/components/soma/manifest.json +++ b/homeassistant/components/soma/manifest.json @@ -1,7 +1,7 @@ { "domain": "soma", "name": "Soma Connect", - "codeowners": ["@ratsept", "@sebfortier2288"], + "codeowners": ["@ratsept"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/soma", "iot_class": "local_polling", diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index d530fa21e39..24580971ae2 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -7,6 +7,7 @@ from collections import OrderedDict from dataclasses import dataclass, field import datetime from functools import partial +from ipaddress import AddressValueError, IPv4Address import logging import socket from typing import Any, cast @@ -208,6 +209,14 @@ class SonosDiscoveryManager: async def async_subscribe_to_zone_updates(self, ip_address: str) -> None: """Test subscriptions and create SonosSpeakers based on results.""" + try: + _ = IPv4Address(ip_address) + except AddressValueError: + _LOGGER.debug( + "Sonos integration only supports IPv4 addresses, invalid ip_address received: %s", + ip_address, + ) + return soco = SoCo(ip_address) # Cache now to avoid household ID lookup during first ZoneGroupState processing await self.hass.async_add_executor_job( diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index 057cdb8ec08..b5e2c684281 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -31,6 +31,8 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler[Awaitable[bool]], domain=DO hostname = discovery_info.hostname if hostname is None or not hostname.lower().startswith("sonos"): return self.async_abort(reason="not_sonos_device") + if discovery_info.ip_address.version != 4: + return self.async_abort(reason="not_ipv4_address") if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER): host = discovery_info.host mdns_name = discovery_info.name diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 8fb704cbfbc..cda40729dbc 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -32,6 +32,7 @@ SONOS_TRACKS = "tracks" SONOS_COMPOSER = "composers" SONOS_RADIO = "radio" SONOS_OTHER_ITEM = "other items" +SONOS_AUDIO_BOOK = "audio book" SONOS_STATE_PLAYING = "PLAYING" SONOS_STATE_TRANSITIONING = "TRANSITIONING" @@ -67,6 +68,7 @@ SONOS_TO_MEDIA_CLASSES = { "object.item": MediaClass.TRACK, "object.item.audioItem.musicTrack": MediaClass.TRACK, "object.item.audioItem.audioBroadcast": MediaClass.GENRE, + "object.item.audioItem.audioBook": MediaClass.TRACK, } SONOS_TO_MEDIA_TYPES = { @@ -84,6 +86,7 @@ SONOS_TO_MEDIA_TYPES = { "object.container.playlistContainer.sameArtist": MediaType.ARTIST, "object.container.playlistContainer": MediaType.PLAYLIST, "object.item.audioItem.musicTrack": MediaType.TRACK, + "object.item.audioItem.audioBook": MediaType.TRACK, } MEDIA_TYPES_TO_SONOS: dict[MediaType | str, str] = { @@ -113,6 +116,7 @@ SONOS_TYPES_MAPPING = { "object.item": SONOS_OTHER_ITEM, "object.item.audioItem.musicTrack": SONOS_TRACKS, "object.item.audioItem.audioBroadcast": SONOS_RADIO, + "object.item.audioItem.audioBook": SONOS_AUDIO_BOOK, } LIBRARY_TITLES_MAPPING = { diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 5050555a7cb..f8b3dbbe492 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -105,7 +105,10 @@ class SonosFavorites(SonosHouseholdCoordinator): @soco_error() def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: """Update cache of known favorites and return if cache has changed.""" - new_favorites = soco.music_library.get_sonos_favorites() + new_favorites = soco.music_library.get_sonos_favorites(full_album_art_uri=True) + new_playlists = soco.music_library.get_music_library_information( + "sonos_playlists", full_album_art_uri=True + ) # Polled update_id values do not match event_id values # Each speaker can return a different polled update_id @@ -131,6 +134,16 @@ class SonosFavorites(SonosHouseholdCoordinator): except SoCoException as ex: # Skip unknown types _LOGGER.error("Unhandled favorite '%s': %s", fav.title, ex) + for playlist in new_playlists: + playlist_reference = DidlFavorite( + title=playlist.title, + parent_id=playlist.parent_id, + item_id=playlist.item_id, + resources=playlist.resources, + desc=playlist.desc, + ) + playlist_reference.reference = playlist + self._favorites.append(playlist_reference) _LOGGER.debug( "Cached %s favorites for household %s using %s", diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 995d6cea08c..16b425dae50 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -165,6 +165,8 @@ async def async_browse_media( favorites_folder_payload, speaker.favorites, media_content_id, + media, + get_browse_image_url, ) payload = { @@ -443,7 +445,10 @@ def favorites_payload(favorites: SonosFavorites) -> BrowseMedia: def favorites_folder_payload( - favorites: SonosFavorites, media_content_id: str + favorites: SonosFavorites, + media_content_id: str, + media: SonosMedia, + get_browse_image_url: GetBrowseImageUrlType, ) -> BrowseMedia: """Create response payload to describe all items of a type of favorite. @@ -463,7 +468,14 @@ def favorites_folder_payload( media_content_type="favorite_item_id", can_play=True, can_expand=False, - thumbnail=getattr(favorite, "album_art_uri", None), + thumbnail=get_thumbnail_url_full( + media=media, + is_internal=True, + media_content_type="favorite_item_id", + media_content_id=favorite.item_id, + get_browse_image_url=get_browse_image_url, + item=favorite, + ), ) ) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 0c66484202f..573c28d700a 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -52,7 +52,7 @@ from homeassistant.helpers.event import async_call_later from . import UnjoinData, media_browser from .const import ( DATA_SONOS, - DOMAIN as SONOS_DOMAIN, + DOMAIN, MEDIA_TYPES_TO_SONOS, MODELS_LINEIN_AND_TV, MODELS_LINEIN_ONLY, @@ -119,7 +119,7 @@ async def async_setup_entry( _LOGGER.debug("Creating media_player on %s", speaker.zone_name) async_add_entities([SonosMediaPlayerEntity(speaker)]) - @service.verify_domain_control(hass, SONOS_DOMAIN) + @service.verify_domain_control(hass, DOMAIN) async def async_service_handle(service_call: ServiceCall) -> None: """Handle dispatched services.""" assert platform is not None @@ -151,11 +151,11 @@ async def async_setup_entry( ) hass.services.async_register( - SONOS_DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema + DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema ) hass.services.async_register( - SONOS_DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema + DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema ) platform.async_register_entity_service( @@ -448,7 +448,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if len(fav) != 1: raise ServiceValidationError( - translation_domain=SONOS_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_favorite", translation_placeholders={ "name": name, @@ -462,11 +462,20 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Play a favorite.""" uri = favorite.reference.get_uri() soco = self.coordinator.soco - if soco.music_source_from_uri(uri) in [ - MUSIC_SRC_RADIO, - MUSIC_SRC_LINE_IN, - ]: - soco.play_uri(uri, title=favorite.title, timeout=LONG_SERVICE_TIMEOUT) + if ( + soco.music_source_from_uri(uri) + in [ + MUSIC_SRC_RADIO, + MUSIC_SRC_LINE_IN, + ] + or favorite.reference.item_class == "object.item.audioItem.audioBook" + ): + soco.play_uri( + uri, + title=favorite.title, + meta=favorite.resource_meta_data, + timeout=LONG_SERVICE_TIMEOUT, + ) else: soco.clear_queue() soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT) @@ -568,7 +577,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) else: raise HomeAssistantError( - translation_domain=SONOS_DOMAIN, + translation_domain=DOMAIN, translation_key="announce_media_error", translation_placeholders={ "media_id": media_id, @@ -675,7 +684,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): playlist = next((p for p in playlists if p.title == media_id), None) if not playlist: raise ServiceValidationError( - translation_domain=SONOS_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_sonos_playlist", translation_placeholders={ "name": media_id, @@ -688,7 +697,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): item = media_browser.get_media(self.media.library, media_id, media_type) if not item: raise ServiceValidationError( - translation_domain=SONOS_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_media", translation_placeholders={ "media_id": media_id, @@ -697,7 +706,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): self._play_media_queue(soco, item, enqueue) else: raise ServiceValidationError( - translation_domain=SONOS_DOMAIN, + translation_domain=DOMAIN, translation_key="invalid_content_type", translation_placeholders={ "media_type": media_type, diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 07d2e2db4e0..433bb3cc36a 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -8,7 +8,8 @@ "abort": { "not_sonos_device": "Discovered device is not a Sonos device", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "not_ipv4_address": "No IPv4 address in SSDP discovery information" } }, "issues": { diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index ce4774a4138..052dbd990b2 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -20,7 +20,7 @@ from homeassistant.helpers.event import async_track_time_change from .const import ( DATA_SONOS, - DOMAIN as SONOS_DOMAIN, + DOMAIN, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM, SONOS_CREATE_SWITCHES, @@ -276,7 +276,7 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): new_device = device_registry.async_get_or_create( config_entry_id=cast(str, entity.config_entry_id), - identifiers={(SONOS_DOMAIN, self.soco.uid)}, + identifiers={(DOMAIN, self.soco.uid)}, connections={(dr.CONNECTION_NETWORK_MAC, self.speaker.mac_address)}, ) if ( diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py index f024c4ef4f7..c4d993cc22a 100644 --- a/homeassistant/components/sony_projector/switch.py +++ b/homeassistant/components/sony_projector/switch.py @@ -64,7 +64,7 @@ class SonyProjector(SwitchEntity): self._attributes = {} @property - def available(self): + def available(self) -> bool: """Return if projector is available.""" return self._available diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index d99fa7793df..3478887d64c 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -41,7 +41,8 @@ class SpotifyFlowHandler( try: current_user = await spotify.get_current_user() - except Exception: # noqa: BLE001 + except Exception: + self.logger.exception("Error while connecting to Spotify") return self.async_abort(reason="connection_error") name = current_user.display_name diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index 90e573a1706..66d837c503f 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -13,7 +13,7 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "The Spotify integration is not configured. Please follow the documentation.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication.", + "reauth_account_mismatch": "The Spotify account authenticated with does not match the account that needed re-authentication.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 1b9e8502209..e3e6c699d03 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType @@ -55,7 +56,9 @@ QUERY_SCHEMA = vol.Schema( vol.Required(CONF_NAME): cv.template, vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select), vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DB_URL): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index c18b1b9f05f..e6a45390120 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -1,9 +1,10 @@ { "domain": "sql", "name": "SQL", + "after_dependencies": ["recorder"], "codeowners": ["@gjohansson-ST", "@dougiteixeira"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.38", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.40", "sqlparse==0.5.0"] } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index a7b488dd521..b86a33db7ab 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -45,6 +45,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -79,7 +80,7 @@ async def async_setup_platform( name: Template = conf[CONF_NAME] query_str: str = conf[CONF_QUERY] - value_template: Template | None = conf.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = conf.get(CONF_VALUE_TEMPLATE) column_name: str = conf[CONF_COLUMN_NAME] unique_id: str | None = conf.get(CONF_UNIQUE_ID) db_url: str = resolve_db_url(hass, conf.get(CONF_DB_URL)) @@ -116,10 +117,10 @@ async def async_setup_entry( template: str | None = entry.options.get(CONF_VALUE_TEMPLATE) column_name: str = entry.options[CONF_COLUMN_NAME] - value_template: Template | None = None + value_template: ValueTemplate | None = None if template is not None: try: - value_template = Template(template, hass) + value_template = ValueTemplate(template, hass) value_template.ensure_valid() except TemplateError: value_template = None @@ -179,7 +180,7 @@ async def async_setup_sensor( trigger_entity_config: ConfigType, query_str: str, column_name: str, - value_template: Template | None, + value_template: ValueTemplate | None, unique_id: str | None, db_url: str, yaml: bool, @@ -316,7 +317,7 @@ class SQLSensor(ManualTriggerSensorEntity): sessmaker: scoped_session, query: str, column: str, - value_template: Template | None, + value_template: ValueTemplate | None, yaml: bool, use_database_executor: bool, ) -> None: @@ -359,14 +360,14 @@ class SQLSensor(ManualTriggerSensorEntity): async def async_update(self) -> None: """Retrieve sensor data from the query using the right executor.""" if self._use_database_executor: - data = await get_instance(self.hass).async_add_executor_job(self._update) + await get_instance(self.hass).async_add_executor_job(self._update) else: - data = await self.hass.async_add_executor_job(self._update) - self._process_manual_data(data) + await self.hass.async_add_executor_job(self._update) - def _update(self) -> Any: + def _update(self) -> None: """Retrieve sensor data from the query.""" data = None + extra_state_attributes = {} self._attr_extra_state_attributes = {} sess: scoped_session = self.sessionmaker() try: @@ -379,7 +380,7 @@ class SQLSensor(ManualTriggerSensorEntity): ) sess.rollback() sess.close() - return None + return for res in result.mappings(): _LOGGER.debug("Query %s result in %s", self._query, res.items()) @@ -391,15 +392,19 @@ class SQLSensor(ManualTriggerSensorEntity): value = value.isoformat() elif isinstance(value, (bytes, bytearray)): value = f"0x{value.hex()}" + extra_state_attributes[key] = value self._attr_extra_state_attributes[key] = value if data is not None and isinstance(data, (bytes, bytearray)): data = f"0x{data.hex()}" if data is not None and self._template is not None: - self._attr_native_value = ( - self._template.async_render_with_possible_json_value(data, None) - ) + variables = self._template_variables_with_value(data) + if self._render_availability_template(variables): + self._attr_native_value = self._template.async_render_as_value_template( + self.entity_id, variables, None + ) + self._process_manual_data(variables) else: self._attr_native_value = data @@ -407,4 +412,3 @@ class SQLSensor(ManualTriggerSensorEntity): _LOGGER.warning("%s returned no results", self._query) sess.close() - return data diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index ac861e72b72..486fb5946b4 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -106,6 +106,7 @@ "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]", "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index fd641d3389d..d29e7287340 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -53,8 +53,10 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR, + Platform.UPDATE, ] diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py index daae8703597..1045e526ee3 100644 --- a/homeassistant/components/squeezebox/binary_sensor.py +++ b/homeassistant/components/squeezebox/binary_sensor.py @@ -17,6 +17,9 @@ from . import SqueezeboxConfigEntry from .const import STATUS_SENSOR_NEEDSRESTART, STATUS_SENSOR_RESCAN from .entity import LMSStatusEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + SENSORS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key=STATUS_SENSOR_RESCAN, diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 6bc1d2380cf..6e1ec8b37c4 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -19,37 +19,37 @@ from homeassistant.components.media_player import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_internal_request -from .const import UNPLAYABLE_TYPES +from .const import DOMAIN, UNPLAYABLE_TYPES LIBRARY = [ - "Favorites", - "Artists", - "Albums", - "Tracks", - "Playlists", - "Genres", - "New Music", - "Album Artists", - "Apps", - "Radios", + "favorites", + "artists", + "albums", + "tracks", + "playlists", + "genres", + "new music", + "album artists", + "apps", + "radios", ] MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { - "Favorites": "favorites", - "Artists": "artists", - "Albums": "albums", - "Tracks": "titles", - "Playlists": "playlists", - "Genres": "genres", - "New Music": "new music", - "Album Artists": "album artists", + "favorites": "favorites", + "artists": "artists", + "albums": "albums", + "tracks": "titles", + "playlists": "playlists", + "genres": "genres", + "new music": "new music", + "album artists": "album artists", MediaType.ALBUM: "album", MediaType.ARTIST: "artist", MediaType.TRACK: "title", MediaType.PLAYLIST: "playlist", MediaType.GENRE: "genre", - "Apps": "apps", - "Radios": "radios", + MediaType.APPS: "apps", + "radios": "radios", } SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { @@ -58,25 +58,23 @@ SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { MediaType.TRACK: "track_id", MediaType.PLAYLIST: "playlist_id", MediaType.GENRE: "genre_id", - "Favorites": "item_id", + "favorites": "item_id", MediaType.APPS: "item_id", } -CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = { - "Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, - "Apps": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, - "Radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, - "App": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, - "Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, - "Albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, - "Tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, - "Playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST}, - "Genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE}, - "New Music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, - "Album Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, +CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | str]] = { + "favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + "radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, + "albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, + "tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + "playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST}, + "genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE}, + "new music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, + "album artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK}, MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM}, - MediaType.TRACK: {"item": MediaClass.TRACK, "children": None}, + MediaType.TRACK: {"item": MediaClass.TRACK, "children": ""}, MediaType.GENRE: {"item": MediaClass.GENRE, "children": MediaClass.ARTIST}, MediaType.PLAYLIST: {"item": MediaClass.PLAYLIST, "children": MediaClass.TRACK}, MediaType.APP: {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, @@ -91,17 +89,15 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[ MediaType.PLAYLIST: MediaType.PLAYLIST, MediaType.ARTIST: MediaType.ALBUM, MediaType.GENRE: MediaType.ARTIST, - "Artists": MediaType.ARTIST, - "Albums": MediaType.ALBUM, - "Tracks": MediaType.TRACK, - "Playlists": MediaType.PLAYLIST, - "Genres": MediaType.GENRE, - "Favorites": None, # can only be determined after inspecting the item - "Apps": MediaClass.APP, - "Radios": MediaClass.APP, - "App": None, # can only be determined after inspecting the item - "New Music": MediaType.ALBUM, - "Album Artists": MediaType.ARTIST, + "artists": MediaType.ARTIST, + "albums": MediaType.ALBUM, + "tracks": MediaType.TRACK, + "playlists": MediaType.PLAYLIST, + "genres": MediaType.GENRE, + "favorites": None, # can only be determined after inspecting the item + "radios": MediaClass.APP, + "new music": MediaType.ALBUM, + "album artists": MediaType.ARTIST, MediaType.APPS: MediaType.APP, MediaType.APP: MediaType.TRACK, } @@ -115,7 +111,7 @@ class BrowseData: str | MediaType, str | MediaType | None, ] = field(default_factory=dict) - content_type_media_class: dict[str | MediaType, dict[str, MediaClass | None]] = ( + content_type_media_class: dict[str | MediaType, dict[str, MediaClass | str]] = ( field(default_factory=dict) ) squeezebox_id_by_type: dict[str | MediaType, str] = field(default_factory=dict) @@ -130,16 +126,6 @@ class BrowseData: self.media_type_to_squeezebox.update(MEDIA_TYPE_TO_SQUEEZEBOX) -@dataclass -class BrowseItemResponse: - """Class for response data for browse item functions.""" - - child_item_type: str | MediaType - child_media_class: dict[str, MediaClass | None] - can_expand: bool - can_play: bool - - def _add_new_command_to_browse_data( browse_data: BrowseData, cmd: str | MediaType, type: str ) -> None: @@ -154,13 +140,14 @@ def _add_new_command_to_browse_data( def _build_response_apps_radios_category( - browse_data: BrowseData, - cmd: str | MediaType, -) -> BrowseItemResponse: + browse_data: BrowseData, cmd: str | MediaType, item: dict[str, Any] +) -> BrowseMedia: """Build item for App or radio category.""" - return BrowseItemResponse( - child_item_type=cmd, - child_media_class=browse_data.content_type_media_class[cmd], + return BrowseMedia( + media_content_id=item["id"], + title=item["title"], + media_content_type=cmd, + media_class=browse_data.content_type_media_class[cmd]["item"], can_expand=True, can_play=False, ) @@ -168,17 +155,74 @@ def _build_response_apps_radios_category( def _build_response_known_app( browse_data: BrowseData, search_type: str, item: dict[str, Any] -) -> BrowseItemResponse: +) -> BrowseMedia: """Build item for app or radio.""" - return BrowseItemResponse( - child_item_type=search_type, - child_media_class=browse_data.content_type_media_class[search_type], + return BrowseMedia( + media_content_id=item["id"], + title=item["title"], + media_content_type=search_type, + media_class=browse_data.content_type_media_class[search_type]["item"], can_play=bool(item["isaudio"] and item.get("url")), can_expand=item["hasitems"], ) +def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: + """Build item for favorites.""" + if "album_id" in item: + return BrowseMedia( + media_content_id=str(item["album_id"]), + title=item["title"], + media_content_type=MediaType.ALBUM, + media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM]["item"], + can_expand=True, + can_play=True, + ) + if item.get("hasitems") and not item.get("isaudio"): + return BrowseMedia( + media_content_id=item["id"], + title=item["title"], + media_content_type="favorites", + media_class=CONTENT_TYPE_MEDIA_CLASS["favorites"]["item"], + can_expand=True, + can_play=False, + ) + return BrowseMedia( + media_content_id=item["id"], + title=item["title"], + media_content_type="favorites", + media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]["item"], + can_expand=bool(item.get("hasitems")), + can_play=bool(item["isaudio"] and item.get("url")), + ) + + +def _get_item_thumbnail( + item: dict[str, Any], + player: Player, + entity: MediaPlayerEntity, + item_type: str | MediaType | None, + search_type: str, + internal_request: bool, +) -> str | None: + """Construct path to thumbnail image.""" + item_thumbnail: str | None = None + if artwork_track_id := item.get("artwork_track_id"): + if internal_request: + item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id) + elif item_type is not None: + item_thumbnail = entity.get_browse_image_url( + item_type, item["id"], artwork_track_id + ) + + elif search_type in ["apps", "radios"]: + item_thumbnail = player.generate_image_url(item["icon"]) + if item_thumbnail is None: + item_thumbnail = item.get("image_url") # will not be proxied by HA + return item_thumbnail + + async def build_item_response( entity: MediaPlayerEntity, player: Player, @@ -214,36 +258,13 @@ async def build_item_response( item_type = browse_data.content_type_to_child_type[search_type] children = [] - list_playable = [] for item in result["items"]: - item_id = str(item.get("id", "")) - item_thumbnail: str | None = None + # Force the item id to a string in case it's numeric from some lms + item["id"] = str(item.get("id", "")) + if search_type == "favorites": + child_media = _build_response_favorites(item) - if item_type: - child_item_type: MediaType | str = item_type - child_media_class = CONTENT_TYPE_MEDIA_CLASS[item_type] - can_expand = child_media_class["children"] is not None - can_play = True - - if search_type == "Favorites": - if "album_id" in item: - item_id = str(item["album_id"]) - child_item_type = MediaType.ALBUM - child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM] - can_expand = True - can_play = True - elif item["hasitems"] and not item["isaudio"]: - child_item_type = "Favorites" - child_media_class = CONTENT_TYPE_MEDIA_CLASS["Favorites"] - can_expand = True - can_play = False - else: - child_item_type = "Favorites" - child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK] - can_expand = item["hasitems"] - can_play = item["isaudio"] and item.get("url") - - if search_type in ["Apps", "Radios"]: + elif search_type in ["apps", "radios"]: # item["cmd"] contains the name of the command to use with the cli for the app # add the command to the dictionaries if item["title"] == "Search" or item.get("type") in UNPLAYABLE_TYPES: @@ -253,19 +274,12 @@ async def build_item_response( if app_cmd not in browse_data.known_apps_radios: browse_data.known_apps_radios.add(app_cmd) + _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") - _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") - - browse_item_response = _build_response_apps_radios_category( - browse_data, app_cmd + child_media = _build_response_apps_radios_category( + browse_data=browse_data, cmd=app_cmd, item=item ) - # Temporary variables until remainder of browse calls are restructured - child_item_type = browse_item_response.child_item_type - child_media_class = browse_item_response.child_media_class - can_expand = browse_item_response.can_expand - can_play = browse_item_response.can_play - elif search_type in browse_data.known_apps_radios: if ( item.get("title") in ["Search", None] @@ -274,46 +288,41 @@ async def build_item_response( # Skip searches in apps as they'd need UI continue - browse_item_response = _build_response_known_app( - browse_data, search_type, item - ) + child_media = _build_response_known_app(browse_data, search_type, item) - # Temporary variables until remainder of browse calls are restructured - child_item_type = browse_item_response.child_item_type - child_media_class = browse_item_response.child_media_class - can_expand = browse_item_response.can_expand - can_play = browse_item_response.can_play - - if artwork_track_id := item.get("artwork_track_id"): - if internal_request: - item_thumbnail = player.generate_image_url_from_track_id( - artwork_track_id - ) - elif item_type is not None: - item_thumbnail = entity.get_browse_image_url( - item_type, item_id, artwork_track_id - ) - elif search_type in ["Apps", "Radios"]: - item_thumbnail = player.generate_image_url(item["icon"]) - else: - item_thumbnail = item.get("image_url") # will not be proxied by HA - - assert child_media_class["item"] is not None - children.append( - BrowseMedia( + elif item_type: + child_media = BrowseMedia( + media_content_id=item["id"], title=item["title"], - media_class=child_media_class["item"], - media_content_id=item_id, - media_content_type=child_item_type, - can_play=can_play, - can_expand=can_expand, - thumbnail=item_thumbnail, + media_content_type=item_type, + media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"], + can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"] + is not None, + can_play=True, ) + + assert child_media.media_class is not None + + child_media.thumbnail = _get_item_thumbnail( + item=item, + player=player, + entity=entity, + item_type=item_type, + search_type=search_type, + internal_request=internal_request, ) - list_playable.append(can_play) + + children.append(child_media) if children is None: - raise BrowseError(f"Media not found: {search_type} / {search_id}") + raise BrowseError( + translation_domain=DOMAIN, + translation_key="browse_media_not_found", + translation_placeholders={ + "type": str(search_type), + "id": str(search_id), + }, + ) assert media_class["item"] is not None if not search_id: @@ -325,7 +334,7 @@ async def build_item_response( children_media_class=media_class["children"], media_content_id=search_id, media_content_type=search_type, - can_play=any(list_playable), + can_play=any(child.can_play for child in children), children=children, can_expand=True, ) @@ -358,11 +367,11 @@ async def library_payload( assert media_class["children"] is not None library_info["children"].append( BrowseMedia( - title=item, + title=item.title(), media_class=media_class["children"], media_content_id=item, media_content_type=item, - can_play=item not in ["Favorites", "Apps", "Radios"], + can_play=item not in ["favorites", "apps", "radios"], can_expand=True, ) ) @@ -396,7 +405,13 @@ async def generate_playlist( media_id = payload["search_id"] if media_type not in browse_media.squeezebox_id_by_type: - raise BrowseError(f"Media type not supported: {media_type}") + raise BrowseError( + translation_domain=DOMAIN, + translation_key="browse_media_type_not_supported", + translation_placeholders={ + "media_type": str(media_type), + }, + ) browse_id = (browse_media.squeezebox_id_by_type[media_type], media_id) if media_type.startswith("app-"): @@ -410,4 +425,11 @@ async def generate_playlist( if result and "items" in result: items: list = result["items"] return items - raise BrowseError(f"Media not found: {media_type} / {media_id}") + raise BrowseError( + translation_domain=DOMAIN, + translation_key="browse_media_not_found", + translation_placeholders={ + "type": str(media_type), + "id": str(media_id), + }, + ) diff --git a/homeassistant/components/squeezebox/button.py b/homeassistant/components/squeezebox/button.py new file mode 100644 index 00000000000..88018e4f9a9 --- /dev/null +++ b/homeassistant/components/squeezebox/button.py @@ -0,0 +1,163 @@ +"""Platform for button integration for squeezebox.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SqueezeboxConfigEntry +from .const import SIGNAL_PLAYER_DISCOVERED +from .coordinator import SqueezeBoxPlayerUpdateCoordinator +from .entity import SqueezeboxEntity + +_LOGGER = logging.getLogger(__name__) + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +HARDWARE_MODELS_WITH_SCREEN = [ + "Squeezebox Boom", + "Squeezebox Radio", + "Transporter", + "Squeezebox Touch", + "Squeezebox", + "SliMP3", + "Squeezebox 1", + "Squeezebox 2", + "Squeezebox 3", +] + +HARDWARE_MODELS_WITH_TONE = [ + *HARDWARE_MODELS_WITH_SCREEN, + "Squeezebox Receiver", +] + + +@dataclass(frozen=True, kw_only=True) +class SqueezeboxButtonEntityDescription(ButtonEntityDescription): + """Squeezebox Button description.""" + + press_action: str + + +BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = tuple( + SqueezeboxButtonEntityDescription( + key=f"preset_{i}", + translation_key="preset", + translation_placeholders={"index": str(i)}, + press_action=f"preset_{i}.single", + ) + for i in range(1, 7) +) + +SCREEN_BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = ( + SqueezeboxButtonEntityDescription( + key="brightness_up", + translation_key="brightness_up", + press_action="brightness_up", + ), + SqueezeboxButtonEntityDescription( + key="brightness_down", + translation_key="brightness_down", + press_action="brightness_down", + ), +) + +TONE_BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = ( + SqueezeboxButtonEntityDescription( + key="bass_up", + translation_key="bass_up", + press_action="bass_up", + ), + SqueezeboxButtonEntityDescription( + key="bass_down", + translation_key="bass_down", + press_action="bass_down", + ), + SqueezeboxButtonEntityDescription( + key="treble_up", + translation_key="treble_up", + press_action="treble_up", + ), + SqueezeboxButtonEntityDescription( + key="treble_down", + translation_key="treble_down", + press_action="treble_down", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SqueezeboxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Squeezebox button platform from a server config entry.""" + + # Add button entities when player discovered + async def _player_discovered( + player_coordinator: SqueezeBoxPlayerUpdateCoordinator, + ) -> None: + _LOGGER.debug( + "Setting up button entity for player %s, model %s", + player_coordinator.player.name, + player_coordinator.player.model, + ) + + entities: list[SqueezeboxButtonEntity] = [] + + entities.extend( + SqueezeboxButtonEntity(player_coordinator, description) + for description in BUTTON_ENTITIES + ) + + entities.extend( + SqueezeboxButtonEntity(player_coordinator, description) + for description in TONE_BUTTON_ENTITIES + if player_coordinator.player.model in HARDWARE_MODELS_WITH_TONE + ) + + entities.extend( + SqueezeboxButtonEntity(player_coordinator, description) + for description in SCREEN_BUTTON_ENTITIES + if player_coordinator.player.model in HARDWARE_MODELS_WITH_SCREEN + ) + + async_add_entities(entities) + + entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered) + ) + + +class SqueezeboxButtonEntity(SqueezeboxEntity, ButtonEntity): + """Representation of Buttons for Squeezebox entities.""" + + entity_description: SqueezeboxButtonEntityDescription + + def __init__( + self, + coordinator: SqueezeBoxPlayerUpdateCoordinator, + entity_description: SqueezeboxButtonEntityDescription, + ) -> None: + """Initialize the SqueezeBox Button.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{format_mac(self._player.player_id)}_{entity_description.key}" + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.available and super().available + + async def async_press(self) -> None: + """Execute the button action.""" + await self._player.async_query("button", self.entity_description.press_action) diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 2853ad14217..31dd5b003b7 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -151,7 +151,8 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): if server.http_status == HTTPStatus.UNAUTHORIZED: return "invalid_auth" return "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unknown exception while validating connection") return "unknown" if "uuid" in status: diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 5ce95d25632..3f355951acf 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -13,8 +13,6 @@ SERVER_MODEL = "Lyrion Music Server" STATUS_API_TIMEOUT = 10 STATUS_SENSOR_LASTSCAN = "lastscan" STATUS_SENSOR_NEEDSRESTART = "needsrestart" -STATUS_SENSOR_NEWVERSION = "newversion" -STATUS_SENSOR_NEWPLUGINS = "newplugins" STATUS_SENSOR_RESCAN = "rescan" STATUS_SENSOR_INFO_TOTAL_ALBUMS = "info total albums" STATUS_SENSOR_INFO_TOTAL_ARTISTS = "info total artists" @@ -27,6 +25,8 @@ STATUS_QUERY_LIBRARYNAME = "libraryname" STATUS_QUERY_MAC = "mac" STATUS_QUERY_UUID = "uuid" STATUS_QUERY_VERSION = "version" +STATUS_UPDATE_NEWVERSION = "newversion" +STATUS_UPDATE_NEWPLUGINS = "newplugins" SQUEEZEBOX_SOURCE_STRINGS = ( "source:", "wavin:", @@ -44,3 +44,5 @@ DEFAULT_VOLUME_STEP = 5 ATTR_ANNOUNCE_VOLUME = "announce_volume" ATTR_ANNOUNCE_TIMEOUT = "announce_timeout" UNPLAYABLE_TYPES = ("text", "actions") +UPDATE_PLUGINS_RELEASE_SUMMARY = "update_plugins_release_summary" +UPDATE_RELEASE_SUMMARY = "update_release_summary" diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index 955e2896947..9c7d00eae58 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -6,7 +6,6 @@ from asyncio import timeout from collections.abc import Callable from datetime import timedelta import logging -import re from typing import TYPE_CHECKING, Any from pysqueezebox import Player, Server @@ -14,19 +13,16 @@ from pysqueezebox import Player, Server from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util if TYPE_CHECKING: from . import SqueezeboxConfigEntry from .const import ( + DOMAIN, PLAYER_UPDATE_INTERVAL, SENSOR_UPDATE_INTERVAL, SIGNAL_PLAYER_REDISCOVERED, STATUS_API_TIMEOUT, - STATUS_SENSOR_LASTSCAN, - STATUS_SENSOR_NEEDSRESTART, - STATUS_SENSOR_RESCAN, ) _LOGGER = logging.getLogger(__name__) @@ -50,7 +46,16 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): always_update=False, ) self.lms = lms - self.newversion_regex = re.compile("<.*$") + self.can_server_restart = False + + async def _async_setup(self) -> None: + """Query LMS capabilities.""" + result = await self.lms.async_query("can", "restartserver", "?") + if result and "_can" in result and result["_can"] == 1: + _LOGGER.debug("Can restart %s", self.lms.name) + self.can_server_restart = True + else: + _LOGGER.warning("Can't query server capabilities %s", self.lms.name) async def _async_update_data(self) -> dict: """Fetch data from LMS status call. @@ -58,32 +63,15 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): Then we process only a subset to make then nice for HA """ async with timeout(STATUS_API_TIMEOUT): - data = await self.lms.async_status() + data: dict | None = await self.lms.async_prepared_status() if not data: - raise UpdateFailed("No data from status poll") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="coordinator_no_data", + ) _LOGGER.debug("Raw serverstatus %s=%s", self.lms.name, data) - return self._prepare_status_data(data) - - def _prepare_status_data(self, data: dict) -> dict: - """Sensors that need the data changing for HA presentation.""" - - # Binary sensors - # rescan bool are we rescanning alter poll not present if false - data[STATUS_SENSOR_RESCAN] = STATUS_SENSOR_RESCAN in data - # needsrestart bool pending lms plugin updates not present if false - data[STATUS_SENSOR_NEEDSRESTART] = STATUS_SENSOR_NEEDSRESTART in data - - # Sensors that need special handling - # 'lastscan': '1718431678', epoc -> ISO 8601 not always present - data[STATUS_SENSOR_LASTSCAN] = ( - dt_util.utc_from_timestamp(int(data[STATUS_SENSOR_LASTSCAN])) - if STATUS_SENSOR_LASTSCAN in data - else None - ) - - _LOGGER.debug("Processed serverstatus %s=%s", self.lms.name, data) return data diff --git a/homeassistant/components/squeezebox/entity.py b/homeassistant/components/squeezebox/entity.py index 027ca68edc6..2c443c24ffd 100644 --- a/homeassistant/components/squeezebox/entity.py +++ b/homeassistant/components/squeezebox/entity.py @@ -1,11 +1,37 @@ """Base class for Squeezebox Sensor entities.""" -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, STATUS_QUERY_UUID -from .coordinator import LMSStatusDataUpdateCoordinator +from .coordinator import ( + LMSStatusDataUpdateCoordinator, + SqueezeBoxPlayerUpdateCoordinator, +) + + +class SqueezeboxEntity(CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator]): + """Base entity class for Squeezebox entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None: + """Initialize the SqueezeBox entity.""" + super().__init__(coordinator) + self._player = coordinator.player + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, format_mac(self._player.player_id))}, + name=self._player.name, + connections={(CONNECTION_NETWORK_MAC, format_mac(self._player.player_id))}, + via_device=(DOMAIN, coordinator.server_uuid), + model=self._player.model, + manufacturer=self._player.creator, + ) class LMSStatusEntity(CoordinatorEntity[LMSStatusDataUpdateCoordinator]): diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 0cd539b4584..873bedd13fb 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -6,7 +6,7 @@ from collections.abc import Callable from datetime import datetime import json import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from pysqueezebox import Server, async_discover import voluptuous as vol @@ -35,15 +35,10 @@ from homeassistant.helpers import ( entity_platform, entity_registry as er, ) -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceInfo, - format_mac, -) +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.start import async_at_start -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from .browse_media import ( @@ -68,6 +63,7 @@ from .const import ( SQUEEZEBOX_SOURCE_STRINGS, ) from .coordinator import SqueezeBoxPlayerUpdateCoordinator +from .entity import SqueezeboxEntity if TYPE_CHECKING: from . import SqueezeboxConfigEntry @@ -79,6 +75,7 @@ ATTR_QUERY_RESULT = "query_result" _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 ATTR_PARAMETERS = "parameters" ATTR_OTHER_PLAYER = "other_player" @@ -181,9 +178,7 @@ def get_announce_timeout(extra: dict) -> int | None: return announce_timeout -class SqueezeBoxMediaPlayerEntity( - CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator], MediaPlayerEntity -): +class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): """Representation of the media player features of a SqueezeBox device. Wraps a pysqueezebox.Player() object. @@ -217,30 +212,10 @@ class SqueezeBoxMediaPlayerEntity( def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None: """Initialize the SqueezeBox device.""" super().__init__(coordinator) - player = coordinator.player - self._player = player self._query_result: bool | dict = {} self._remove_dispatcher: Callable | None = None self._previous_media_position = 0 - self._attr_unique_id = format_mac(player.player_id) - _manufacturer = None - if player.model == "SqueezeLite" or "SqueezePlay" in player.model: - _manufacturer = "Ralph Irving" - elif ( - "Squeezebox" in player.model - or "Transporter" in player.model - or "Slim" in player.model - ): - _manufacturer = "Logitech" - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - name=player.name, - connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)}, - via_device=(DOMAIN, coordinator.server_uuid), - model=player.model, - manufacturer=_manufacturer, - ) + self._attr_unique_id = format_mac(self._player.player_id) self._browse_data = BrowseData() @callback @@ -355,22 +330,22 @@ class SqueezeBoxMediaPlayerEntity( @property def media_title(self) -> str | None: """Title of current playing media.""" - return str(self._player.title) + return cast(str | None, self._player.title) @property def media_channel(self) -> str | None: """Channel (e.g. webradio name) of current playing media.""" - return str(self._player.remote_title) + return cast(str | None, self._player.remote_title) @property def media_artist(self) -> str | None: """Artist of current playing media.""" - return str(self._player.artist) + return cast(str | None, self._player.artist) @property def media_album_name(self) -> str | None: """Album of current playing media.""" - return str(self._player.album) + return cast(str | None, self._player.album) @property def repeat(self) -> RepeatMode: @@ -472,6 +447,9 @@ class SqueezeBoxMediaPlayerEntity( """Send the play_media command to the media player.""" index = None + if media_type: + media_type = media_type.lower() + enqueue: MediaPlayerEnqueue | None = kwargs.get(ATTR_MEDIA_ENQUEUE) if enqueue == MediaPlayerEnqueue.ADD: @@ -493,7 +471,11 @@ class SqueezeBoxMediaPlayerEntity( if announce: if media_type not in MediaType.MUSIC: raise ServiceValidationError( - "Announcements must have media type of 'music'. Playlists are not supported" + translation_domain=DOMAIN, + translation_key="invalid_announce_media_type", + translation_placeholders={ + "media_type": str(media_type), + }, ) extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) @@ -502,7 +484,11 @@ class SqueezeBoxMediaPlayerEntity( announce_volume = get_announce_volume(extra) except ValueError: raise ServiceValidationError( - f"{ATTR_ANNOUNCE_VOLUME} must be a number greater than 0 and less than or equal to 1" + translation_domain=DOMAIN, + translation_key="invalid_announce_volume", + translation_placeholders={ + "announce_volume": ATTR_ANNOUNCE_VOLUME, + }, ) from None else: self._player.set_announce_volume(announce_volume) @@ -511,7 +497,11 @@ class SqueezeBoxMediaPlayerEntity( announce_timeout = get_announce_timeout(extra) except ValueError: raise ServiceValidationError( - f"{ATTR_ANNOUNCE_TIMEOUT} must be a whole number greater than 0" + translation_domain=DOMAIN, + translation_key="invalid_announce_timeout", + translation_placeholders={ + "announce_timeout": ATTR_ANNOUNCE_TIMEOUT, + }, ) from None else: self._player.set_announce_timeout(announce_timeout) @@ -617,13 +607,21 @@ class SqueezeBoxMediaPlayerEntity( other_player = ent_reg.async_get(other_player_entity_id) if other_player is None: raise ServiceValidationError( - f"Could not find player with entity_id {other_player_entity_id}" + translation_domain=DOMAIN, + translation_key="join_cannot_find_other_player", + translation_placeholders={ + "other_player_entity_id": str(other_player_entity_id) + }, ) if other_player_id := other_player.unique_id: await self._player.async_sync(other_player_id) else: raise ServiceValidationError( - f"Could not join unknown player {other_player_entity_id}" + translation_domain=DOMAIN, + translation_key="join_cannot_join_unknown_player", + translation_placeholders={ + "other_player_entity_id": str(other_player_entity_id) + }, ) async def async_unjoin_player(self) -> None: @@ -643,6 +641,9 @@ class SqueezeBoxMediaPlayerEntity( media_content_id, ) + if media_content_type: + media_content_type = media_content_type.lower() + if media_content_type in [None, "library"]: return await library_payload(self.hass, self._player, self._browse_data) diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index c0a7a37d539..11c169910dc 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -29,6 +29,9 @@ from .const import ( ) from .entity import LMSStatusEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_ALBUMS, @@ -43,6 +46,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, ), SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_GENRES, diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index ed569989b56..6a4e30119a0 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -17,7 +17,14 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "https": "Connect over https (requires reverse proxy)" + "https": "Connect over HTTPS (requires reverse proxy)" + }, + "data_description": { + "host": "[%key:component::squeezebox::config::step::user::data_description::host%]", + "port": "The web interface port on the LMS. The default is 9000.", + "username": "The username from LMS Advanced Security (if defined).", + "password": "The password from LMS Advanced Security (if defined).", + "https": "Connect to the LMS over HTTPS (requires reverse proxy)." } } }, @@ -29,7 +36,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_server_found": "No LMS server found." + "no_server_found": "No LMS found." } }, "services": { @@ -63,6 +70,29 @@ } }, "entity": { + "button": { + "preset": { + "name": "Preset {index}" + }, + "brightness_up": { + "name": "Brightness up" + }, + "brightness_down": { + "name": "Brightness down" + }, + "bass_up": { + "name": "Bass up" + }, + "bass_down": { + "name": "Bass down" + }, + "treble_up": { + "name": "Treble up" + }, + "treble_down": { + "name": "Treble down" + } + }, "binary_sensor": { "rescan": { "name": "Library rescan" @@ -102,6 +132,14 @@ "name": "Player count off service", "unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]" } + }, + "update": { + "newversion": { + "name": "Lyrion Music Server" + }, + "newplugins": { + "name": "Updated plugins" + } } }, "options": { @@ -118,5 +156,34 @@ } } } + }, + "exceptions": { + "invalid_announce_media_type": { + "message": "Only type 'music' can be played as announcement (received type {media_type})." + }, + "invalid_announce_volume": { + "message": "{announce_volume} must be a number greater than 0 and less than or equal to 1." + }, + "invalid_announce_timeout": { + "message": "{announce_timeout} must be a number greater than 0." + }, + "join_cannot_find_other_player": { + "message": "Could not find player with entity_id {other_player_entity_id}." + }, + "join_cannot_join_unknown_player": { + "message": "Could not join unknown player {other_player_entity_id}." + }, + "coordinator_no_data": { + "message": "No data from status poll." + }, + "browse_media_not_found": { + "message": "Media not found: {type} / {id}." + }, + "browse_media_type_not_supported": { + "message": "Media type not supported: {media_type}." + }, + "update_restart_failed": { + "message": "Error trying to update LMS Plugins: Restart failed." + } } } diff --git a/homeassistant/components/squeezebox/update.py b/homeassistant/components/squeezebox/update.py new file mode 100644 index 00000000000..62579424d25 --- /dev/null +++ b/homeassistant/components/squeezebox/update.py @@ -0,0 +1,175 @@ +"""Platform for update integration for squeezebox.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +import logging +from typing import Any + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_call_later + +from . import SqueezeboxConfigEntry +from .const import ( + DOMAIN, + SERVER_MODEL, + STATUS_QUERY_VERSION, + STATUS_UPDATE_NEWPLUGINS, + STATUS_UPDATE_NEWVERSION, + UPDATE_PLUGINS_RELEASE_SUMMARY, + UPDATE_RELEASE_SUMMARY, +) +from .entity import LMSStatusEntity + +newserver = UpdateEntityDescription( + key=STATUS_UPDATE_NEWVERSION, +) + +newplugins = UpdateEntityDescription( + key=STATUS_UPDATE_NEWPLUGINS, +) + +POLL_AFTER_INSTALL = 120 + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SqueezeboxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Platform setup using common elements.""" + + async_add_entities( + [ + ServerStatusUpdateLMS(entry.runtime_data.coordinator, newserver), + ServerStatusUpdatePlugins(entry.runtime_data.coordinator, newplugins), + ] + ) + + +class ServerStatusUpdate(LMSStatusEntity, UpdateEntity): + """LMS Status update sensors via cooridnatior.""" + + @property + def latest_version(self) -> str: + """LMS Status directly from coordinator data.""" + return str(self.coordinator.data[self.entity_description.key]) + + +class ServerStatusUpdateLMS(ServerStatusUpdate): + """LMS Status update sensor from LMS via cooridnatior.""" + + title: str = SERVER_MODEL + + @property + def installed_version(self) -> str: + """LMS Status directly from coordinator data.""" + return str(self.coordinator.data[STATUS_QUERY_VERSION]) + + @property + def release_url(self) -> str: + """LMS Update info page.""" + return str(self.coordinator.lms.generate_image_url("updateinfo.html")) + + @property + def release_summary(self) -> None | str: + """If install is supported give some info.""" + return ( + str(self.coordinator.data[UPDATE_RELEASE_SUMMARY]) + if self.coordinator.data[UPDATE_RELEASE_SUMMARY] + else None + ) + + +class ServerStatusUpdatePlugins(ServerStatusUpdate): + """LMS Plugings update sensor from LMS via cooridnatior.""" + + auto_update = True + title: str = SERVER_MODEL + " Plugins" + installed_version = "Current" + restart_triggered = False + _cancel_update: Callable | None = None + + @property + def supported_features(self) -> UpdateEntityFeature: + """Support install if we can.""" + return ( + (UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS) + if self.coordinator.can_server_restart + else UpdateEntityFeature(0) + ) + + @property + def release_summary(self) -> None | str: + """If install is supported give some info.""" + rs = self.coordinator.data[UPDATE_PLUGINS_RELEASE_SUMMARY] + return ( + (rs or "") + + "The Plugins will be updated on the next restart triggred by selecting the Install button. Allow enough time for the service to restart. It will become briefly unavailable." + if self.coordinator.can_server_restart + else rs + ) + + @property + def release_url(self) -> str: + """LMS Plugins info page.""" + return str( + self.coordinator.lms.generate_image_url( + "/settings/index.html?activePage=SETUP_PLUGINS" + ) + ) + + @property + def in_progress(self) -> bool: + """Are we restarting.""" + if self.latest_version == self.installed_version and self.restart_triggered: + _LOGGER.debug("plugin progress reset %s", self.coordinator.lms.name) + if callable(self._cancel_update): + self._cancel_update() + self.restart_triggered = False + return self.restart_triggered + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install all plugin updates.""" + _LOGGER.debug( + "server restart for plugin install on %s", self.coordinator.lms.name + ) + self.restart_triggered = True + self.async_write_ha_state() + + result = await self.coordinator.lms.async_query("restartserver") + _LOGGER.debug("restart server result %s", result) + if not result: + self._cancel_update = async_call_later( + self.hass, POLL_AFTER_INSTALL, self._async_update_catchall + ) + else: + self.restart_triggered = False + self.async_write_ha_state() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_restart_failed", + ) + + async def _async_update_catchall(self, now: datetime | None = None) -> None: + """Request update. clear restart catchall.""" + if self.restart_triggered: + _LOGGER.debug("server restart catchall for %s", self.coordinator.lms.name) + self.restart_triggered = False + self.async_write_ha_state() + await self.async_update() diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index eca4f465435..dfe2ea32888 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -3,10 +3,10 @@ "step": { "user": { "data": { - "id": "Account Id", + "id": "Account ID", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "is_tou": "Is Time of Use Plan" + "is_tou": "Is Time-of-Use Price Plan" } } }, @@ -18,7 +18,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "unknown": "Unexpected error" + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "entity": { diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index c5fb349ddbb..28ea59c0adc 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -2,59 +2,18 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable, Coroutine, Mapping -from datetime import timedelta -from enum import Enum +from collections.abc import Callable, Coroutine from functools import partial -from ipaddress import IPv4Address, IPv6Address -import logging -import socket -from time import time -from typing import TYPE_CHECKING, Any -from urllib.parse import urljoin -import xml.etree.ElementTree as ET +from typing import Any -from async_upnp_client.aiohttp import AiohttpSessionRequester -from async_upnp_client.const import ( - AddressTupleVXType, - DeviceIcon, - DeviceInfo, - DeviceOrServiceType, - SsdpSource, -) -from async_upnp_client.description_cache import DescriptionCache -from async_upnp_client.server import UpnpServer, UpnpServerDevice, UpnpServerService -from async_upnp_client.ssdp import ( - SSDP_PORT, - determine_source_target, - fix_ipv6_address_scope_id, - is_ipv4_address, -) -from async_upnp_client.ssdp_listener import SsdpDevice, SsdpDeviceTracker, SsdpListener -from async_upnp_client.utils import CaseInsensitiveDict - -from homeassistant import config_entries -from homeassistant.components import network -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STARTED, - EVENT_HOMEASSISTANT_STOP, - MATCH_ALL, - __version__ as current_version, -) -from homeassistant.core import Event, HassJob, HomeAssistant, callback as core_callback -from homeassistant.helpers import config_validation as cv, discovery_flow -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import HassJob, HomeAssistant +from homeassistant.helpers import 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.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, @@ -73,20 +32,19 @@ from homeassistant.helpers.service_info.ssdp import ( 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 -from homeassistant.util.async_ import create_eager_task from homeassistant.util.logging import catch_log_exception -DOMAIN = "ssdp" -SSDP_SCANNER = "scanner" -UPNP_SERVER = "server" -UPNP_SERVER_MIN_PORT = 40000 -UPNP_SERVER_MAX_PORT = 40100 -SCAN_INTERVAL = timedelta(minutes=10) - -IPV4_BROADCAST = IPv4Address("255.255.255.255") +from . import websocket_api +from .const import DOMAIN, SSDP_SCANNER, UPNP_SERVER +from .scanner import ( + IntegrationMatchers, + Scanner, + SsdpChange, + SsdpHassJobCallback, # noqa: F401 +) +from .server import Server # Attributes for accessing info from SSDP response ATTR_SSDP_LOCATION = "ssdp_location" @@ -177,17 +135,6 @@ _DEPRECATED_ATTR_UPNP_PRESENTATION_URL = DeprecatedConstant( # 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, -] - -_LOGGER = logging.getLogger(__name__) - - CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) _DEPRECATED_SsdpServiceInfo = DeprecatedConstant( @@ -197,20 +144,6 @@ _DEPRECATED_SsdpServiceInfo = DeprecatedConstant( ) -SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") -type SsdpHassJobCallback = HassJob[ - [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None -] - -SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { - SsdpSource.SEARCH_ALIVE: SsdpChange.ALIVE, - SsdpSource.SEARCH_CHANGED: SsdpChange.ALIVE, - SsdpSource.ADVERTISEMENT_ALIVE: SsdpChange.ALIVE, - SsdpSource.ADVERTISEMENT_BYEBYE: SsdpChange.BYEBYE, - SsdpSource.ADVERTISEMENT_UPDATE: SsdpChange.UPDATE, -} - - def _format_err(name: str, *args: Any) -> str: """Format error message.""" return f"Exception in SSDP callback {name}: {args}" @@ -266,17 +199,6 @@ async def async_get_discovery_info_by_udn( return await scanner.async_get_discovery_info_by_udn(udn) -async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6Address]: - """Build the list of ssdp sources.""" - return { - source_ip - 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) - } - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the SSDP integration.""" @@ -292,676 +214,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await scanner.async_start() await server.async_start() + websocket_api.async_setup(hass) return True -@core_callback -def _async_process_callbacks( - hass: HomeAssistant, - callbacks: list[SsdpHassJobCallback], - discovery_info: _SsdpServiceInfo, - ssdp_change: SsdpChange, -) -> None: - for callback in callbacks: - try: - hass.async_run_hass_job( - callback, discovery_info, ssdp_change, background=True - ) - except Exception: - _LOGGER.exception("Failed to callback info: %s", discovery_info) - - -@core_callback -def _async_headers_match( - headers: CaseInsensitiveDict, lower_match_dict: dict[str, str] -) -> bool: - for header, val in lower_match_dict.items(): - if val == MATCH_ALL: - if header not in headers: - return False - elif headers.get_lower(header) != val: - return False - return True - - -class IntegrationMatchers: - """Optimized integration matching.""" - - def __init__(self) -> None: - """Init optimized integration matching.""" - self._match_by_key: ( - dict[str, dict[str, list[tuple[str, dict[str, str]]]]] | None - ) = None - - @core_callback - def async_setup( - self, integration_matchers: dict[str, list[dict[str, str]]] - ) -> None: - """Build matchers by key. - - Here we convert the primary match keys into their own - dicts so we can do lookups of the primary match - key to find the match dict. - """ - self._match_by_key = {} - for key in PRIMARY_MATCH_KEYS: - matchers_by_key = self._match_by_key[key] = {} - for domain, matchers in integration_matchers.items(): - for matcher in matchers: - if match_value := matcher.get(key): - matchers_by_key.setdefault(match_value, []).append( - (domain, matcher) - ) - - @core_callback - def async_matching_domains(self, info_with_desc: CaseInsensitiveDict) -> set[str]: - """Find domains matching the passed CaseInsensitiveDict.""" - assert self._match_by_key is not None - return { - domain - for key, matchers_by_key in self._match_by_key.items() - if (match_value := info_with_desc.get(key)) - for domain, matcher in matchers_by_key.get(match_value, ()) - if info_with_desc.items() >= matcher.items() - } - - -class Scanner: - """Class to manage SSDP searching and SSDP advertisements.""" - - def __init__( - self, hass: HomeAssistant, integration_matchers: IntegrationMatchers - ) -> None: - """Initialize class.""" - self.hass = hass - self._cancel_scan: Callable[[], None] | None = None - self._ssdp_listeners: list[SsdpListener] = [] - self._device_tracker = SsdpDeviceTracker() - self._callbacks: list[tuple[SsdpHassJobCallback, dict[str, str]]] = [] - self._description_cache: DescriptionCache | None = None - self.integration_matchers = integration_matchers - - @property - def _ssdp_devices(self) -> list[SsdpDevice]: - """Get all seen devices.""" - return list(self._device_tracker.devices.values()) - - async def async_register_callback( - self, callback: SsdpHassJobCallback, match_dict: dict[str, str] | None = None - ) -> Callable[[], None]: - """Register a callback.""" - if match_dict is None: - lower_match_dict = {} - else: - lower_match_dict = {k.lower(): v for k, v in match_dict.items()} - - # Make sure any entries that happened - # before the callback was registered are fired - for ssdp_device in self._ssdp_devices: - for headers in ssdp_device.all_combined_headers.values(): - if _async_headers_match(headers, lower_match_dict): - _async_process_callbacks( - self.hass, - [callback], - await self._async_headers_to_discovery_info( - ssdp_device, headers - ), - SsdpChange.ALIVE, - ) - - callback_entry = (callback, lower_match_dict) - self._callbacks.append(callback_entry) - - @core_callback - def _async_remove_callback() -> None: - self._callbacks.remove(callback_entry) - - return _async_remove_callback - - async def async_stop(self, *_: Any) -> None: - """Stop the scanner.""" - assert self._cancel_scan is not None - self._cancel_scan() - - await self._async_stop_ssdp_listeners() - - async def _async_stop_ssdp_listeners(self) -> None: - """Stop the SSDP listeners.""" - await asyncio.gather( - *( - create_eager_task(listener.async_stop()) - for listener in self._ssdp_listeners - ), - return_exceptions=True, - ) - - async def async_scan(self, *_: Any) -> None: - """Scan for new entries using ssdp listeners.""" - await self.async_scan_multicast() - await self.async_scan_broadcast() - - async def async_scan_multicast(self, *_: Any) -> None: - """Scan for new entries using multicase target.""" - for ssdp_listener in self._ssdp_listeners: - await ssdp_listener.async_search() - - async def async_scan_broadcast(self, *_: Any) -> None: - """Scan for new entries using broadcast target.""" - # Some sonos devices only seem to respond if we send to the broadcast - # address. This matches pysonos' behavior - # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 - for listener in self._ssdp_listeners: - if is_ipv4_address(listener.source): - await listener.async_search((str(IPV4_BROADCAST), SSDP_PORT)) - - async def async_start(self) -> None: - """Start the scanners.""" - session = async_get_clientsession(self.hass, verify_ssl=False) - requester = AiohttpSessionRequester(session, True, 10) - self._description_cache = DescriptionCache(requester) - - await self._async_start_ssdp_listeners() - - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) - self._cancel_scan = async_track_time_interval( - self.hass, self.async_scan, SCAN_INTERVAL, name="SSDP scanner" - ) - - async_dispatcher_connect( - self.hass, - config_entries.signal_discovered_config_entry_removed(DOMAIN), - self._handle_config_entry_removed, - ) - - # Trigger the initial-scan. - await self.async_scan() - - async def _async_start_ssdp_listeners(self) -> None: - """Start the SSDP Listeners.""" - # Devices are shared between all sources. - for source_ip in await async_build_source_set(self.hass): - source_ip_str = str(source_ip) - if source_ip.version == 6: - source_tuple: AddressTupleVXType = ( - source_ip_str, - 0, - 0, - int(getattr(source_ip, "scope_id")), - ) - else: - source_tuple = (source_ip_str, 0) - source, target = determine_source_target(source_tuple) - source = fix_ipv6_address_scope_id(source) or source - self._ssdp_listeners.append( - SsdpListener( - callback=self._ssdp_listener_callback, - source=source, - target=target, - device_tracker=self._device_tracker, - ) - ) - results = await asyncio.gather( - *( - create_eager_task(listener.async_start()) - for listener in self._ssdp_listeners - ), - return_exceptions=True, - ) - failed_listeners = [] - for idx, result in enumerate(results): - if isinstance(result, Exception): - _LOGGER.debug( - "Failed to setup listener for %s: %s", - self._ssdp_listeners[idx].source, - result, - ) - failed_listeners.append(self._ssdp_listeners[idx]) - for listener in failed_listeners: - self._ssdp_listeners.remove(listener) - - @core_callback - def _async_get_matching_callbacks( - self, - combined_headers: CaseInsensitiveDict, - ) -> list[SsdpHassJobCallback]: - """Return a list of callbacks that match.""" - return [ - callback - for callback, lower_match_dict in self._callbacks - if _async_headers_match(combined_headers, lower_match_dict) - ] - - def _ssdp_listener_callback( - self, - ssdp_device: SsdpDevice, - dst: DeviceOrServiceType, - source: SsdpSource, - ) -> None: - """Handle a device/service change.""" - _LOGGER.debug( - "SSDP: ssdp_device: %s, dst: %s, source: %s", ssdp_device, dst, source - ) - - assert self._description_cache - - location = ssdp_device.location - _, info_desc = self._description_cache.peek_description_dict(location) - if info_desc is None: - # Fetch info desc in separate task and process from there. - self.hass.async_create_background_task( - self._ssdp_listener_process_callback_with_lookup( - ssdp_device, dst, source - ), - name=f"ssdp_info_desc_lookup_{location}", - eager_start=True, - ) - return - - # Info desc known, process directly. - self._ssdp_listener_process_callback(ssdp_device, dst, source, info_desc) - - async def _ssdp_listener_process_callback_with_lookup( - self, - ssdp_device: SsdpDevice, - dst: DeviceOrServiceType, - source: SsdpSource, - ) -> None: - """Handle a device/service change.""" - location = ssdp_device.location - self._ssdp_listener_process_callback( - ssdp_device, - dst, - source, - await self._async_get_description_dict(location), - ) - - def _ssdp_listener_process_callback( - self, - ssdp_device: SsdpDevice, - dst: DeviceOrServiceType, - source: SsdpSource, - info_desc: Mapping[str, Any], - skip_callbacks: bool = False, - ) -> None: - """Handle a device/service change.""" - matching_domains: set[str] = set() - combined_headers = ssdp_device.combined_headers(dst) - callbacks = self._async_get_matching_callbacks(combined_headers) - - # If there are no changes from a search, do not trigger a config flow - if source != SsdpSource.SEARCH_ALIVE: - matching_domains = self.integration_matchers.async_matching_domains( - CaseInsensitiveDict(combined_headers.as_dict(), **info_desc) - ) - - if ( - not callbacks - and not matching_domains - and source != SsdpSource.ADVERTISEMENT_BYEBYE - ): - return - - discovery_info = discovery_info_from_headers_and_description( - ssdp_device, combined_headers, info_desc - ) - discovery_info.x_homeassistant_matching_domains = matching_domains - - if callbacks and not skip_callbacks: - ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] - _async_process_callbacks(self.hass, callbacks, discovery_info, ssdp_change) - - # Config flows should only be created for alive/update messages from alive devices - if source == SsdpSource.ADVERTISEMENT_BYEBYE: - self._async_dismiss_discoveries(discovery_info) - return - - _LOGGER.debug("Discovery info: %s", discovery_info) - - if not matching_domains: - return # avoid creating DiscoveryKey if there are no matches - - discovery_key = discovery_flow.DiscoveryKey( - domain=DOMAIN, key=ssdp_device.udn, version=1 - ) - for domain in matching_domains: - _LOGGER.debug("Discovered %s at %s", domain, ssdp_device.location) - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_SSDP}, - discovery_info, - discovery_key=discovery_key, - ) - - def _async_dismiss_discoveries( - 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, - lambda service_info: bool( - service_info.ssdp_st == byebye_discovery_info.ssdp_st - and service_info.ssdp_location == byebye_discovery_info.ssdp_location - ), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) - - async def _async_get_description_dict( - self, location: str | None - ) -> Mapping[str, str]: - """Get description dict.""" - assert self._description_cache is not None - cache = self._description_cache - - has_description, description = cache.peek_description_dict(location) - if has_description: - return description or {} - - return await cache.async_get_description_dict(location) or {} - - async def _async_headers_to_discovery_info( - self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict - ) -> _SsdpServiceInfo: - """Combine the headers and description into discovery_info. - - Building this is a bit expensive so we only do it on demand. - """ - location = headers["location"] - info_desc = await self._async_get_description_dict(location) - return discovery_info_from_headers_and_description( - ssdp_device, headers, info_desc - ) - - async def async_get_discovery_info_by_udn_st( - self, udn: str, st: str - ) -> _SsdpServiceInfo | None: - """Return discovery_info for a udn and st.""" - for ssdp_device in self._ssdp_devices: - if ssdp_device.udn == udn: - if headers := ssdp_device.combined_headers(st): - return await self._async_headers_to_discovery_info( - ssdp_device, headers - ) - return None - - 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) - for ssdp_device in self._ssdp_devices - if (headers := ssdp_device.combined_headers(st)) - ] - - 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) - for ssdp_device in self._ssdp_devices - for headers in ssdp_device.all_combined_headers.values() - if ssdp_device.udn == udn - ] - - @core_callback - def _handle_config_entry_removed( - self, - entry: config_entries.ConfigEntry, - ) -> None: - """Handle config entry changes.""" - if TYPE_CHECKING: - assert self._description_cache is not None - cache = self._description_cache - for discovery_key in entry.discovery_keys[DOMAIN]: - if discovery_key.version != 1 or not isinstance(discovery_key.key, str): - continue - udn = discovery_key.key - _LOGGER.debug("Rediscover service %s", udn) - - for ssdp_device in self._ssdp_devices: - if ssdp_device.udn != udn: - continue - for dst in ssdp_device.all_combined_headers: - has_cached_desc, info_desc = cache.peek_description_dict( - ssdp_device.location - ) - if has_cached_desc and info_desc: - self._ssdp_listener_process_callback( - ssdp_device, - dst, - SsdpSource.SEARCH, - info_desc, - True, # Skip integration callbacks - ) - - -def discovery_info_from_headers_and_description( - ssdp_device: SsdpDevice, - combined_headers: CaseInsensitiveDict, - info_desc: Mapping[str, Any], -) -> _SsdpServiceInfo: - """Convert headers and description to discovery_info.""" - ssdp_usn = combined_headers["usn"] - ssdp_st = combined_headers.get_lower("st") - if isinstance(info_desc, CaseInsensitiveDict): - upnp_info = {**info_desc.as_dict()} - else: - upnp_info = {**info_desc} - - # Increase compatibility: depending on the message type, - # either the ST (Search Target, from M-SEARCH messages) - # or NT (Notification Type, from NOTIFY messages) header is mandatory - if not ssdp_st: - ssdp_st = combined_headers["nt"] - - # Ensure UPnP "udn" is set - if _ATTR_UPNP_UDN not in upnp_info: - if udn := _udn_from_usn(ssdp_usn): - upnp_info[_ATTR_UPNP_UDN] = udn - - return _SsdpServiceInfo( - ssdp_usn=ssdp_usn, - ssdp_st=ssdp_st, - ssdp_ext=combined_headers.get_lower("ext"), - ssdp_server=combined_headers.get_lower("server"), - ssdp_location=combined_headers.get_lower("location"), - ssdp_udn=combined_headers.get_lower("_udn"), - ssdp_nt=combined_headers.get_lower("nt"), - ssdp_headers=combined_headers, - upnp=upnp_info, - ssdp_all_locations=set(ssdp_device.locations), - ) - - -def _udn_from_usn(usn: str | None) -> str | None: - """Get the UDN from the USN.""" - if usn is None: - return None - if usn.startswith("uuid:"): - return usn.split("::")[0] - return None - - -class HassUpnpServiceDevice(UpnpServerDevice): - """Hass Device.""" - - DEVICE_DEFINITION = DeviceInfo( - device_type="urn:home-assistant.io:device:HomeAssistant:1", - friendly_name="filled_later_on", - manufacturer="Home Assistant", - manufacturer_url="https://www.home-assistant.io", - model_description=None, - model_name="filled_later_on", - model_number=current_version, - model_url="https://www.home-assistant.io", - serial_number="filled_later_on", - udn="filled_later_on", - upc=None, - presentation_url="https://my.home-assistant.io/", - url="/device.xml", - icons=[ - DeviceIcon( - mimetype="image/png", - width=1024, - height=1024, - depth=24, - url="/static/icons/favicon-1024x1024.png", - ), - DeviceIcon( - mimetype="image/png", - width=512, - height=512, - depth=24, - url="/static/icons/favicon-512x512.png", - ), - DeviceIcon( - mimetype="image/png", - width=384, - height=384, - depth=24, - url="/static/icons/favicon-384x384.png", - ), - DeviceIcon( - mimetype="image/png", - width=192, - height=192, - depth=24, - url="/static/icons/favicon-192x192.png", - ), - ], - xml=ET.Element("server_device"), - ) - EMBEDDED_DEVICES: list[type[UpnpServerDevice]] = [] - SERVICES: list[type[UpnpServerService]] = [] - - -async def _async_find_next_available_port(source: AddressTupleVXType) -> int: - """Get a free TCP port.""" - family = socket.AF_INET if is_ipv4_address(source) else socket.AF_INET6 - test_socket = socket.socket(family, socket.SOCK_STREAM) - test_socket.setblocking(False) - test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT): - addr = (source[0],) + (port,) + source[2:] - try: - test_socket.bind(addr) - except OSError: - if port == UPNP_SERVER_MAX_PORT - 1: - raise - else: - return port - - raise RuntimeError("unreachable") - - -class Server: - """Class to be visible via SSDP searching and advertisements.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize class.""" - self.hass = hass - self._upnp_servers: list[UpnpServer] = [] - - async def async_start(self) -> None: - """Start the server.""" - bus = self.hass.bus - bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) - bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, - self._async_start_upnp_servers, - ) - - async def _async_get_instance_udn(self) -> str: - """Get Unique Device Name for this instance.""" - instance_id = await async_get_instance_id(self.hass) - return f"uuid:{instance_id[0:8]}-{instance_id[8:12]}-{instance_id[12:16]}-{instance_id[16:20]}-{instance_id[20:32]}".upper() - - async def _async_start_upnp_servers(self, event: Event) -> None: - """Start the UPnP/SSDP servers.""" - # Update UDN with our instance UDN. - udn = await self._async_get_instance_udn() - system_info = await async_get_system_info(self.hass) - model_name = system_info["installation_type"] - try: - presentation_url = get_url(self.hass, allow_ip=True, prefer_external=False) - except NoURLAvailableError: - _LOGGER.warning( - "Could not set up UPnP/SSDP server, as a presentation URL could" - " not be determined; Please configure your internal URL" - " in the Home Assistant general configuration" - ) - return - - serial_number = await async_get_instance_id(self.hass) - HassUpnpServiceDevice.DEVICE_DEFINITION = ( - HassUpnpServiceDevice.DEVICE_DEFINITION._replace( - udn=udn, - friendly_name=f"{self.hass.config.location_name} (Home Assistant)", - model_name=model_name, - presentation_url=presentation_url, - serial_number=serial_number, - ) - ) - - # Update icon URLs. - for index, icon in enumerate(HassUpnpServiceDevice.DEVICE_DEFINITION.icons): - new_url = urljoin(presentation_url, icon.url) - HassUpnpServiceDevice.DEVICE_DEFINITION.icons[index] = icon._replace( - url=new_url - ) - - # Start a server on all source IPs. - boot_id = int(time()) - for source_ip in await async_build_source_set(self.hass): - source_ip_str = str(source_ip) - if source_ip.version == 6: - source_tuple: AddressTupleVXType = ( - source_ip_str, - 0, - 0, - int(getattr(source_ip, "scope_id")), - ) - else: - source_tuple = (source_ip_str, 0) - source, target = determine_source_target(source_tuple) - source = fix_ipv6_address_scope_id(source) or source - http_port = await _async_find_next_available_port(source) - _LOGGER.debug("Binding UPnP HTTP server to: %s:%s", source_ip, http_port) - self._upnp_servers.append( - UpnpServer( - source=source, - target=target, - http_port=http_port, - server_device=HassUpnpServiceDevice, - boot_id=boot_id, - ) - ) - results = await asyncio.gather( - *(upnp_server.async_start() for upnp_server in self._upnp_servers), - return_exceptions=True, - ) - failed_servers = [] - for idx, result in enumerate(results): - if isinstance(result, Exception): - _LOGGER.debug( - "Failed to setup server for %s: %s", - self._upnp_servers[idx].source, - result, - ) - failed_servers.append(self._upnp_servers[idx]) - for server in failed_servers: - self._upnp_servers.remove(server) - - async def async_stop(self, *_: Any) -> None: - """Stop the server.""" - await self._async_stop_upnp_servers() - - async def _async_stop_upnp_servers(self) -> None: - """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( diff --git a/homeassistant/components/ssdp/common.py b/homeassistant/components/ssdp/common.py new file mode 100644 index 00000000000..47156b13ce7 --- /dev/null +++ b/homeassistant/components/ssdp/common.py @@ -0,0 +1,19 @@ +"""Common functions for SSDP discovery.""" + +from __future__ import annotations + +from ipaddress import IPv4Address, IPv6Address + +from homeassistant.components import network +from homeassistant.core import HomeAssistant + + +async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6Address]: + """Build the list of ssdp sources.""" + return { + source_ip + 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) + } diff --git a/homeassistant/components/ssdp/const.py b/homeassistant/components/ssdp/const.py new file mode 100644 index 00000000000..ee5f1c240c6 --- /dev/null +++ b/homeassistant/components/ssdp/const.py @@ -0,0 +1,7 @@ +"""Constants for the SSDP integration.""" + +from __future__ import annotations + +DOMAIN = "ssdp" +SSDP_SCANNER = "scanner" +UPNP_SERVER = "server" diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 6e1fba8c3a3..93943b0a9ea 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.43.0"] + "requirements": ["async-upnp-client==0.44.0"] } diff --git a/homeassistant/components/ssdp/scanner.py b/homeassistant/components/ssdp/scanner.py new file mode 100644 index 00000000000..d42c879e76a --- /dev/null +++ b/homeassistant/components/ssdp/scanner.py @@ -0,0 +1,558 @@ +"""The SSDP integration scanner.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine, Mapping +from datetime import timedelta +from enum import Enum +from ipaddress import IPv4Address +import logging +from typing import TYPE_CHECKING, Any + +from async_upnp_client.aiohttp import AiohttpSessionRequester +from async_upnp_client.const import AddressTupleVXType, DeviceOrServiceType, SsdpSource +from async_upnp_client.description_cache import DescriptionCache +from async_upnp_client.ssdp import ( + SSDP_PORT, + determine_source_target, + fix_ipv6_address_scope_id, + is_ipv4_address, +) +from async_upnp_client.ssdp_listener import SsdpDevice, SsdpDeviceTracker, SsdpListener +from async_upnp_client.utils import CaseInsensitiveDict + +from homeassistant import config_entries +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, MATCH_ALL +from homeassistant.core import HassJob, HomeAssistant, callback as core_callback +from homeassistant.helpers import discovery_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_time_interval +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_MANUFACTURER as _ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL as _ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_UDN as _ATTR_UPNP_UDN, + SsdpServiceInfo as _SsdpServiceInfo, +) +from homeassistant.util.async_ import create_eager_task + +from .common import async_build_source_set +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(minutes=10) + +IPV4_BROADCAST = IPv4Address("255.255.255.255") + + +PRIMARY_MATCH_KEYS = [ + _ATTR_UPNP_MANUFACTURER, + _ATTR_ST, + _ATTR_UPNP_DEVICE_TYPE, + _ATTR_NT, + _ATTR_UPNP_MANUFACTURER_URL, +] + +_LOGGER = logging.getLogger(__name__) + + +SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") +type SsdpHassJobCallback = HassJob[ + [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None +] + +SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { + SsdpSource.SEARCH_ALIVE: SsdpChange.ALIVE, + SsdpSource.SEARCH_CHANGED: SsdpChange.ALIVE, + SsdpSource.ADVERTISEMENT_ALIVE: SsdpChange.ALIVE, + SsdpSource.ADVERTISEMENT_BYEBYE: SsdpChange.BYEBYE, + SsdpSource.ADVERTISEMENT_UPDATE: SsdpChange.UPDATE, +} + + +@core_callback +def _async_process_callbacks( + hass: HomeAssistant, + callbacks: list[SsdpHassJobCallback], + discovery_info: _SsdpServiceInfo, + ssdp_change: SsdpChange, +) -> None: + for callback in callbacks: + try: + hass.async_run_hass_job( + callback, discovery_info, ssdp_change, background=True + ) + except Exception: + _LOGGER.exception("Failed to callback info: %s", discovery_info) + + +@core_callback +def _async_headers_match( + headers: CaseInsensitiveDict, lower_match_dict: dict[str, str] +) -> bool: + for header, val in lower_match_dict.items(): + if val == MATCH_ALL: + if header not in headers: + return False + elif headers.get_lower(header) != val: + return False + return True + + +class IntegrationMatchers: + """Optimized integration matching.""" + + def __init__(self) -> None: + """Init optimized integration matching.""" + self._match_by_key: ( + dict[str, dict[str, list[tuple[str, dict[str, str]]]]] | None + ) = None + + @core_callback + def async_setup( + self, integration_matchers: dict[str, list[dict[str, str]]] + ) -> None: + """Build matchers by key. + + Here we convert the primary match keys into their own + dicts so we can do lookups of the primary match + key to find the match dict. + """ + self._match_by_key = {} + for key in PRIMARY_MATCH_KEYS: + matchers_by_key = self._match_by_key[key] = {} + for domain, matchers in integration_matchers.items(): + for matcher in matchers: + if match_value := matcher.get(key): + matchers_by_key.setdefault(match_value, []).append( + (domain, matcher) + ) + + @core_callback + def async_matching_domains(self, info_with_desc: CaseInsensitiveDict) -> set[str]: + """Find domains matching the passed CaseInsensitiveDict.""" + assert self._match_by_key is not None + return { + domain + for key, matchers_by_key in self._match_by_key.items() + if (match_value := info_with_desc.get(key)) + for domain, matcher in matchers_by_key.get(match_value, ()) + if info_with_desc.items() >= matcher.items() + } + + +class Scanner: + """Class to manage SSDP searching and SSDP advertisements.""" + + def __init__( + self, hass: HomeAssistant, integration_matchers: IntegrationMatchers + ) -> None: + """Initialize class.""" + self.hass = hass + self._cancel_scan: Callable[[], None] | None = None + self._ssdp_listeners: list[SsdpListener] = [] + self._device_tracker = SsdpDeviceTracker() + self._callbacks: list[tuple[SsdpHassJobCallback, dict[str, str]]] = [] + self._description_cache: DescriptionCache | None = None + self.integration_matchers = integration_matchers + + @property + def _ssdp_devices(self) -> list[SsdpDevice]: + """Get all seen devices.""" + return list(self._device_tracker.devices.values()) + + async def async_register_callback( + self, callback: SsdpHassJobCallback, match_dict: dict[str, str] | None = None + ) -> Callable[[], None]: + """Register a callback.""" + if match_dict is None: + lower_match_dict = {} + else: + lower_match_dict = {k.lower(): v for k, v in match_dict.items()} + + # Make sure any entries that happened + # before the callback was registered are fired + for ssdp_device in self._ssdp_devices: + for headers in ssdp_device.all_combined_headers.values(): + if _async_headers_match(headers, lower_match_dict): + _async_process_callbacks( + self.hass, + [callback], + await self._async_headers_to_discovery_info( + ssdp_device, headers + ), + SsdpChange.ALIVE, + ) + + callback_entry = (callback, lower_match_dict) + self._callbacks.append(callback_entry) + + @core_callback + def _async_remove_callback() -> None: + self._callbacks.remove(callback_entry) + + return _async_remove_callback + + async def async_stop(self, *_: Any) -> None: + """Stop the scanner.""" + assert self._cancel_scan is not None + self._cancel_scan() + + await self._async_stop_ssdp_listeners() + + async def _async_stop_ssdp_listeners(self) -> None: + """Stop the SSDP listeners.""" + await asyncio.gather( + *( + create_eager_task(listener.async_stop()) + for listener in self._ssdp_listeners + ), + return_exceptions=True, + ) + + async def async_scan(self, *_: Any) -> None: + """Scan for new entries using ssdp listeners.""" + await self.async_scan_multicast() + await self.async_scan_broadcast() + + async def async_scan_multicast(self, *_: Any) -> None: + """Scan for new entries using multicase target.""" + for ssdp_listener in self._ssdp_listeners: + await ssdp_listener.async_search() + + async def async_scan_broadcast(self, *_: Any) -> None: + """Scan for new entries using broadcast target.""" + # Some sonos devices only seem to respond if we send to the broadcast + # address. This matches pysonos' behavior + # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 + for listener in self._ssdp_listeners: + if is_ipv4_address(listener.source): + await listener.async_search((str(IPV4_BROADCAST), SSDP_PORT)) + + async def async_start(self) -> None: + """Start the scanners.""" + session = async_get_clientsession(self.hass, verify_ssl=False) + requester = AiohttpSessionRequester(session, True, 10) + self._description_cache = DescriptionCache(requester) + + await self._async_start_ssdp_listeners() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) + self._cancel_scan = async_track_time_interval( + self.hass, self.async_scan, SCAN_INTERVAL, name="SSDP scanner" + ) + + async_dispatcher_connect( + self.hass, + config_entries.signal_discovered_config_entry_removed(DOMAIN), + self._handle_config_entry_removed, + ) + + # Trigger the initial-scan. + await self.async_scan() + + async def _async_start_ssdp_listeners(self) -> None: + """Start the SSDP Listeners.""" + # Devices are shared between all sources. + for source_ip in await async_build_source_set(self.hass): + source_ip_str = str(source_ip) + if source_ip.version == 6: + source_tuple: AddressTupleVXType = ( + source_ip_str, + 0, + 0, + int(getattr(source_ip, "scope_id")), + ) + else: + source_tuple = (source_ip_str, 0) + source, target = determine_source_target(source_tuple) + source = fix_ipv6_address_scope_id(source) or source + self._ssdp_listeners.append( + SsdpListener( + callback=self._ssdp_listener_callback, + source=source, + target=target, + device_tracker=self._device_tracker, + ) + ) + results = await asyncio.gather( + *( + create_eager_task(listener.async_start()) + for listener in self._ssdp_listeners + ), + return_exceptions=True, + ) + failed_listeners = [] + for idx, result in enumerate(results): + if isinstance(result, Exception): + _LOGGER.debug( + "Failed to setup listener for %s: %s", + self._ssdp_listeners[idx].source, + result, + ) + failed_listeners.append(self._ssdp_listeners[idx]) + for listener in failed_listeners: + self._ssdp_listeners.remove(listener) + + @core_callback + def _async_get_matching_callbacks( + self, + combined_headers: CaseInsensitiveDict, + ) -> list[SsdpHassJobCallback]: + """Return a list of callbacks that match.""" + return [ + callback + for callback, lower_match_dict in self._callbacks + if _async_headers_match(combined_headers, lower_match_dict) + ] + + def _ssdp_listener_callback( + self, + ssdp_device: SsdpDevice, + dst: DeviceOrServiceType, + source: SsdpSource, + ) -> None: + """Handle a device/service change.""" + _LOGGER.debug( + "SSDP: ssdp_device: %s, dst: %s, source: %s", ssdp_device, dst, source + ) + + assert self._description_cache + + location = ssdp_device.location + _, info_desc = self._description_cache.peek_description_dict(location) + if info_desc is None: + # Fetch info desc in separate task and process from there. + self.hass.async_create_background_task( + self._ssdp_listener_process_callback_with_lookup( + ssdp_device, dst, source + ), + name=f"ssdp_info_desc_lookup_{location}", + eager_start=True, + ) + return + + # Info desc known, process directly. + self._ssdp_listener_process_callback(ssdp_device, dst, source, info_desc) + + async def _ssdp_listener_process_callback_with_lookup( + self, + ssdp_device: SsdpDevice, + dst: DeviceOrServiceType, + source: SsdpSource, + ) -> None: + """Handle a device/service change.""" + location = ssdp_device.location + self._ssdp_listener_process_callback( + ssdp_device, + dst, + source, + await self._async_get_description_dict(location), + ) + + def _ssdp_listener_process_callback( + self, + ssdp_device: SsdpDevice, + dst: DeviceOrServiceType, + source: SsdpSource, + info_desc: Mapping[str, Any], + skip_callbacks: bool = False, + ) -> None: + """Handle a device/service change.""" + matching_domains: set[str] = set() + combined_headers = ssdp_device.combined_headers(dst) + callbacks = self._async_get_matching_callbacks(combined_headers) + + # If there are no changes from a search, do not trigger a config flow + if source != SsdpSource.SEARCH_ALIVE: + matching_domains = self.integration_matchers.async_matching_domains( + CaseInsensitiveDict(combined_headers.as_dict(), **info_desc) + ) + + if ( + not callbacks + and not matching_domains + and source != SsdpSource.ADVERTISEMENT_BYEBYE + ): + return + + discovery_info = discovery_info_from_headers_and_description( + ssdp_device, combined_headers, info_desc + ) + discovery_info.x_homeassistant_matching_domains = matching_domains + + if callbacks and not skip_callbacks: + ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] + _async_process_callbacks(self.hass, callbacks, discovery_info, ssdp_change) + + # Config flows should only be created for alive/update messages from alive devices + if source == SsdpSource.ADVERTISEMENT_BYEBYE: + self._async_dismiss_discoveries(discovery_info) + return + + _LOGGER.debug("Discovery info: %s", discovery_info) + + if not matching_domains: + return # avoid creating DiscoveryKey if there are no matches + + discovery_key = discovery_flow.DiscoveryKey( + domain=DOMAIN, key=ssdp_device.udn, version=1 + ) + for domain in matching_domains: + _LOGGER.debug("Discovered %s at %s", domain, ssdp_device.location) + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_SSDP}, + discovery_info, + discovery_key=discovery_key, + ) + + def _async_dismiss_discoveries( + 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, + lambda service_info: bool( + service_info.ssdp_st == byebye_discovery_info.ssdp_st + and service_info.ssdp_location == byebye_discovery_info.ssdp_location + ), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + + async def _async_get_description_dict( + self, location: str | None + ) -> Mapping[str, str]: + """Get description dict.""" + assert self._description_cache is not None + cache = self._description_cache + + has_description, description = cache.peek_description_dict(location) + if has_description: + return description or {} + + return await cache.async_get_description_dict(location) or {} + + async def _async_headers_to_discovery_info( + self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict + ) -> _SsdpServiceInfo: + """Combine the headers and description into discovery_info. + + Building this is a bit expensive so we only do it on demand. + """ + location = headers["location"] + info_desc = await self._async_get_description_dict(location) + return discovery_info_from_headers_and_description( + ssdp_device, headers, info_desc + ) + + async def async_get_discovery_info_by_udn_st( + self, udn: str, st: str + ) -> _SsdpServiceInfo | None: + """Return discovery_info for a udn and st.""" + for ssdp_device in self._ssdp_devices: + if ssdp_device.udn == udn: + if headers := ssdp_device.combined_headers(st): + return await self._async_headers_to_discovery_info( + ssdp_device, headers + ) + return None + + 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) + for ssdp_device in self._ssdp_devices + if (headers := ssdp_device.combined_headers(st)) + ] + + 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) + for ssdp_device in self._ssdp_devices + for headers in ssdp_device.all_combined_headers.values() + if ssdp_device.udn == udn + ] + + @core_callback + def _handle_config_entry_removed( + self, + entry: config_entries.ConfigEntry, + ) -> None: + """Handle config entry changes.""" + if TYPE_CHECKING: + assert self._description_cache is not None + cache = self._description_cache + for discovery_key in entry.discovery_keys[DOMAIN]: + if discovery_key.version != 1 or not isinstance(discovery_key.key, str): + continue + udn = discovery_key.key + _LOGGER.debug("Rediscover service %s", udn) + + for ssdp_device in self._ssdp_devices: + if ssdp_device.udn != udn: + continue + for dst in ssdp_device.all_combined_headers: + has_cached_desc, info_desc = cache.peek_description_dict( + ssdp_device.location + ) + if has_cached_desc and info_desc: + self._ssdp_listener_process_callback( + ssdp_device, + dst, + SsdpSource.SEARCH, + info_desc, + True, # Skip integration callbacks + ) + + +def discovery_info_from_headers_and_description( + ssdp_device: SsdpDevice, + combined_headers: CaseInsensitiveDict, + info_desc: Mapping[str, Any], +) -> _SsdpServiceInfo: + """Convert headers and description to discovery_info.""" + ssdp_usn = combined_headers["usn"] + ssdp_st = combined_headers.get_lower("st") + if isinstance(info_desc, CaseInsensitiveDict): + upnp_info = {**info_desc.as_dict()} + else: + upnp_info = {**info_desc} + + # Increase compatibility: depending on the message type, + # either the ST (Search Target, from M-SEARCH messages) + # or NT (Notification Type, from NOTIFY messages) header is mandatory + if not ssdp_st: + ssdp_st = combined_headers["nt"] + + # Ensure UPnP "udn" is set + if _ATTR_UPNP_UDN not in upnp_info: + if udn := _udn_from_usn(ssdp_usn): + upnp_info[_ATTR_UPNP_UDN] = udn + + return _SsdpServiceInfo( + ssdp_usn=ssdp_usn, + ssdp_st=ssdp_st, + ssdp_ext=combined_headers.get_lower("ext"), + ssdp_server=combined_headers.get_lower("server"), + ssdp_location=combined_headers.get_lower("location"), + ssdp_udn=combined_headers.get_lower("_udn"), + ssdp_nt=combined_headers.get_lower("nt"), + ssdp_headers=combined_headers, + upnp=upnp_info, + ssdp_all_locations=set(ssdp_device.locations), + ) + + +def _udn_from_usn(usn: str | None) -> str | None: + """Get the UDN from the USN.""" + if usn is None: + return None + if usn.startswith("uuid:"): + return usn.split("::")[0] + return None diff --git a/homeassistant/components/ssdp/server.py b/homeassistant/components/ssdp/server.py new file mode 100644 index 00000000000..6d89263ab20 --- /dev/null +++ b/homeassistant/components/ssdp/server.py @@ -0,0 +1,217 @@ +"""The SSDP integration server.""" + +from __future__ import annotations + +import asyncio +import logging +import socket +from time import time +from typing import Any +from urllib.parse import urljoin +import xml.etree.ElementTree as ET + +from async_upnp_client.const import AddressTupleVXType, DeviceIcon, DeviceInfo +from async_upnp_client.server import UpnpServer, UpnpServerDevice, UpnpServerService +from async_upnp_client.ssdp import ( + determine_source_target, + fix_ipv6_address_scope_id, + is_ipv4_address, +) + +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, + __version__ as current_version, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers.instance_id import async_get as async_get_instance_id +from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.system_info import async_get_system_info + +from .common import async_build_source_set + +UPNP_SERVER_MIN_PORT = 40000 +UPNP_SERVER_MAX_PORT = 40100 + +_LOGGER = logging.getLogger(__name__) + + +class HassUpnpServiceDevice(UpnpServerDevice): + """Hass Device.""" + + DEVICE_DEFINITION = DeviceInfo( + device_type="urn:home-assistant.io:device:HomeAssistant:1", + friendly_name="filled_later_on", + manufacturer="Home Assistant", + manufacturer_url="https://www.home-assistant.io", + model_description=None, + model_name="filled_later_on", + model_number=current_version, + model_url="https://www.home-assistant.io", + serial_number="filled_later_on", + udn="filled_later_on", + upc=None, + presentation_url="https://my.home-assistant.io/", + url="/device.xml", + icons=[ + DeviceIcon( + mimetype="image/png", + width=1024, + height=1024, + depth=24, + url="/static/icons/favicon-1024x1024.png", + ), + DeviceIcon( + mimetype="image/png", + width=512, + height=512, + depth=24, + url="/static/icons/favicon-512x512.png", + ), + DeviceIcon( + mimetype="image/png", + width=384, + height=384, + depth=24, + url="/static/icons/favicon-384x384.png", + ), + DeviceIcon( + mimetype="image/png", + width=192, + height=192, + depth=24, + url="/static/icons/favicon-192x192.png", + ), + ], + xml=ET.Element("server_device"), + ) + EMBEDDED_DEVICES: list[type[UpnpServerDevice]] = [] + SERVICES: list[type[UpnpServerService]] = [] + + +async def _async_find_next_available_port(source: AddressTupleVXType) -> int: + """Get a free TCP port.""" + family = socket.AF_INET if is_ipv4_address(source) else socket.AF_INET6 + test_socket = socket.socket(family, socket.SOCK_STREAM) + test_socket.setblocking(False) + test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT): + addr = (source[0],) + (port,) + source[2:] + try: + test_socket.bind(addr) + except OSError: + if port == UPNP_SERVER_MAX_PORT - 1: + raise + else: + return port + + raise RuntimeError("unreachable") + + +class Server: + """Class to be visible via SSDP searching and advertisements.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize class.""" + self.hass = hass + self._upnp_servers: list[UpnpServer] = [] + + async def async_start(self) -> None: + """Start the server.""" + bus = self.hass.bus + bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) + bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, + self._async_start_upnp_servers, + ) + + async def _async_get_instance_udn(self) -> str: + """Get Unique Device Name for this instance.""" + instance_id = await async_get_instance_id(self.hass) + return f"uuid:{instance_id[0:8]}-{instance_id[8:12]}-{instance_id[12:16]}-{instance_id[16:20]}-{instance_id[20:32]}".upper() + + async def _async_start_upnp_servers(self, event: Event) -> None: + """Start the UPnP/SSDP servers.""" + # Update UDN with our instance UDN. + udn = await self._async_get_instance_udn() + system_info = await async_get_system_info(self.hass) + model_name = system_info["installation_type"] + try: + presentation_url = get_url(self.hass, allow_ip=True, prefer_external=False) + except NoURLAvailableError: + _LOGGER.warning( + "Could not set up UPnP/SSDP server, as a presentation URL could" + " not be determined; Please configure your internal URL" + " in the Home Assistant general configuration" + ) + return + + serial_number = await async_get_instance_id(self.hass) + HassUpnpServiceDevice.DEVICE_DEFINITION = ( + HassUpnpServiceDevice.DEVICE_DEFINITION._replace( + udn=udn, + friendly_name=f"{self.hass.config.location_name} (Home Assistant)", + model_name=model_name, + presentation_url=presentation_url, + serial_number=serial_number, + ) + ) + + # Update icon URLs. + for index, icon in enumerate(HassUpnpServiceDevice.DEVICE_DEFINITION.icons): + new_url = urljoin(presentation_url, icon.url) + HassUpnpServiceDevice.DEVICE_DEFINITION.icons[index] = icon._replace( + url=new_url + ) + + # Start a server on all source IPs. + boot_id = int(time()) + for source_ip in await async_build_source_set(self.hass): + source_ip_str = str(source_ip) + if source_ip.version == 6: + source_tuple: AddressTupleVXType = ( + source_ip_str, + 0, + 0, + int(getattr(source_ip, "scope_id")), + ) + else: + source_tuple = (source_ip_str, 0) + source, target = determine_source_target(source_tuple) + source = fix_ipv6_address_scope_id(source) or source + http_port = await _async_find_next_available_port(source) + _LOGGER.debug("Binding UPnP HTTP server to: %s:%s", source_ip, http_port) + self._upnp_servers.append( + UpnpServer( + source=source, + target=target, + http_port=http_port, + server_device=HassUpnpServiceDevice, + boot_id=boot_id, + ) + ) + results = await asyncio.gather( + *(upnp_server.async_start() for upnp_server in self._upnp_servers), + return_exceptions=True, + ) + failed_servers = [] + for idx, result in enumerate(results): + if isinstance(result, Exception): + _LOGGER.debug( + "Failed to setup server for %s: %s", + self._upnp_servers[idx].source, + result, + ) + failed_servers.append(self._upnp_servers[idx]) + for server in failed_servers: + self._upnp_servers.remove(server) + + async def async_stop(self, *_: Any) -> None: + """Stop the server.""" + await self._async_stop_upnp_servers() + + async def _async_stop_upnp_servers(self) -> None: + """Stop UPnP/SSDP servers.""" + for server in self._upnp_servers: + await server.async_stop() diff --git a/homeassistant/components/ssdp/websocket_api.py b/homeassistant/components/ssdp/websocket_api.py new file mode 100644 index 00000000000..5342ec8035b --- /dev/null +++ b/homeassistant/components/ssdp/websocket_api.py @@ -0,0 +1,69 @@ +"""The ssdp integration websocket apis.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any, Final + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HassJob, HomeAssistant, callback +from homeassistant.helpers.json import json_bytes +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) + +from .const import DOMAIN, SSDP_SCANNER +from .scanner import Scanner, SsdpChange + +FIELD_SSDP_ST: Final = "ssdp_st" +FIELD_SSDP_LOCATION: Final = "ssdp_location" + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the ssdp websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_discovery) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "ssdp/subscribe_discovery", + } +) +@websocket_api.async_response +async def ws_subscribe_discovery( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe advertisements websocket command.""" + scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] + msg_id: int = msg["id"] + + def _async_event_message(message: dict[str, Any]) -> None: + connection.send_message( + json_bytes(websocket_api.event_message(msg_id, message)) + ) + + @callback + def _async_on_data(info: SsdpServiceInfo, change: SsdpChange) -> None: + if change is not SsdpChange.BYEBYE: + _async_event_message( + { + "add": [ + {"name": info.upnp.get(ATTR_UPNP_FRIENDLY_NAME), **asdict(info)} + ] + } + ) + return + remove_msg = { + FIELD_SSDP_ST: info.ssdp_st, + FIELD_SSDP_LOCATION: info.ssdp_location, + } + _async_event_message({"remove": [remove_msg]}) + + job = HassJob(_async_on_data) + connection.send_message(json_bytes(websocket_api.result_message(msg_id))) + connection.subscriptions[msg_id] = await scanner.async_register_callback(job, None) diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py index fa46d2a3773..1d238e232b9 100644 --- a/homeassistant/components/starline/button.py +++ b/homeassistant/components/starline/button.py @@ -64,7 +64,7 @@ class StarlineButton(StarlineEntity, ButtonEntity): self.entity_description = description @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return super().available and self._device.online diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 16988f1a9dc..916d0a9f26b 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -61,6 +61,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="fuel", translation_key="fuel", + device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index d07e8174b27..14cbf6fe876 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -113,7 +113,9 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( translation_key="last_boot_time", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: now() - timedelta(seconds=data.status["uptime"]), + value_fn=lambda data: ( + now() - timedelta(seconds=data.status["uptime"]) + ).replace(microsecond=0), ), StarlinkSensorEntityDescription( key="ping_drop_rate", diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 94a3bd1058b..d2824ab10e5 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -1,22 +1,29 @@ """The component for STIEBEL ELTRON heat pumps with ISGWeb Modbus module.""" -from datetime import timedelta import logging +from typing import Any from pymodbus.client import ModbusTcpClient -from pystiebeleltron import pystiebeleltron +from pystiebeleltron.pystiebeleltron import StiebelEltronAPI import voluptuous as vol -from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, Platform +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + DEVICE_DEFAULT_NAME, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -CONF_HUB = "hub" -DEFAULT_HUB = "modbus_hub" +from .const import CONF_HUB, DEFAULT_HUB, DOMAIN + MODBUS_DOMAIN = "modbus" -DOMAIN = "stiebel_eltron" CONFIG_SCHEMA = vol.Schema( { @@ -31,39 +38,109 @@ CONFIG_SCHEMA = vol.Schema( ) _LOGGER = logging.getLogger(__name__) - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) +_PLATFORMS: list[Platform] = [Platform.CLIMATE] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the STIEBEL ELTRON unit. +async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: + """Set up the STIEBEL ELTRON component.""" + hub_config: dict[str, Any] | None = None + if MODBUS_DOMAIN in config: + for hub in config[MODBUS_DOMAIN]: + if hub[CONF_NAME] == config[DOMAIN][CONF_HUB]: + hub_config = hub + break + if hub_config is None: + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_missing_hub", + breaks_in_ha_version="2025.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_missing_hub", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Stiebel Eltron", + }, + ) + return + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: hub_config[CONF_HOST], + CONF_PORT: hub_config[CONF_PORT], + CONF_NAME: config[DOMAIN][CONF_NAME], + }, + ) + if ( + result.get("type") is 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.11.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": "Stiebel Eltron", + }, + ) + return - Will automatically load climate platform. - """ - name = config[DOMAIN][CONF_NAME] - modbus_client = hass.data[MODBUS_DOMAIN][config[DOMAIN][CONF_HUB]] + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2025.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Stiebel Eltron", + }, + ) - hass.data[DOMAIN] = { - "name": name, - "ste_data": StiebelEltronData(name, modbus_client), - } - discovery.load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the STIEBEL ELTRON component.""" + if DOMAIN in config: + hass.async_create_task(_async_import(hass, config)) return True -class StiebelEltronData: - """Get the latest data and update the states.""" +type StiebelEltronConfigEntry = ConfigEntry[StiebelEltronAPI] - def __init__(self, name: str, modbus_client: ModbusTcpClient) -> None: - """Init the STIEBEL ELTRON data object.""" - self.api = pystiebeleltron.StiebelEltronAPI(modbus_client, 1) +async def async_setup_entry( + hass: HomeAssistant, entry: StiebelEltronConfigEntry +) -> bool: + """Set up STIEBEL ELTRON from a config entry.""" + client = StiebelEltronAPI( + ModbusTcpClient(entry.data[CONF_HOST], port=entry.data[CONF_PORT]), 1 + ) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self) -> None: - """Update unit data.""" - if not self.api.update(): - _LOGGER.warning("Modbus read failed") - else: - _LOGGER.debug("Data updated successfully") + success = await hass.async_add_executor_job(client.update) + if not success: + raise ConfigEntryNotReady("Could not connect to device") + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: StiebelEltronConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 4d302a0f70d..f10ef0df667 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -5,6 +5,8 @@ from __future__ import annotations import logging from typing import Any +from pystiebeleltron.pystiebeleltron import StiebelEltronAPI + from homeassistant.components.climate import ( PRESET_ECO, ClimateEntity, @@ -13,10 +15,9 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as STE_DOMAIN, StiebelEltronData +from . import StiebelEltronConfigEntry DEPENDENCIES = ["stiebel_eltron"] @@ -56,17 +57,14 @@ HA_TO_STE_HVAC = { HA_TO_STE_PRESET = {k: i for i, k in STE_TO_HA_PRESET.items()} -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: StiebelEltronConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the StiebelEltron platform.""" - name = hass.data[STE_DOMAIN]["name"] - ste_data = hass.data[STE_DOMAIN]["ste_data"] + """Set up STIEBEL ELTRON climate platform.""" - add_entities([StiebelEltron(name, ste_data)], True) + async_add_entities([StiebelEltron(entry.title, entry.runtime_data)], True) class StiebelEltron(ClimateEntity): @@ -81,7 +79,7 @@ class StiebelEltron(ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, name: str, ste_data: StiebelEltronData) -> None: + def __init__(self, name: str, client: StiebelEltronAPI) -> None: """Initialize the unit.""" self._name = name self._target_temperature: float | int | None = None @@ -89,19 +87,17 @@ class StiebelEltron(ClimateEntity): 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 + self._client = client def update(self) -> None: """Update unit attributes.""" - self._ste_data.update(no_throttle=self._force_update) - self._force_update = False + self._client.update() - self._target_temperature = self._ste_data.api.get_target_temp() - self._current_temperature = self._ste_data.api.get_current_temp() - self._current_humidity = self._ste_data.api.get_current_humidity() - self._filter_alarm = self._ste_data.api.get_filter_alarm_status() - self._operation = self._ste_data.api.get_operation() + self._target_temperature = self._client.get_target_temp() + self._current_temperature = self._client.get_current_temp() + self._current_humidity = self._client.get_current_humidity() + self._filter_alarm = self._client.get_filter_alarm_status() + self._operation = self._client.get_operation() _LOGGER.debug( "Update %s, current temp: %s", self._name, self._current_temperature @@ -170,20 +166,17 @@ class StiebelEltron(ClimateEntity): return new_mode = HA_TO_STE_HVAC.get(hvac_mode) _LOGGER.debug("set_hvac_mode: %s -> %s", self._operation, new_mode) - self._ste_data.api.set_operation(new_mode) - self._force_update = True + self._client.set_operation(new_mode) def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_temperature = kwargs.get(ATTR_TEMPERATURE) if target_temperature is not None: _LOGGER.debug("set_temperature: %s", target_temperature) - self._ste_data.api.set_target_temp(target_temperature) - self._force_update = True + self._client.set_target_temp(target_temperature) def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" new_mode = HA_TO_STE_PRESET.get(preset_mode) _LOGGER.debug("set_hvac_mode: %s -> %s", self._operation, new_mode) - self._ste_data.api.set_operation(new_mode) - self._force_update = True + self._client.set_operation(new_mode) diff --git a/homeassistant/components/stiebel_eltron/config_flow.py b/homeassistant/components/stiebel_eltron/config_flow.py new file mode 100644 index 00000000000..022fa50805a --- /dev/null +++ b/homeassistant/components/stiebel_eltron/config_flow.py @@ -0,0 +1,82 @@ +"""Config flow for the STIEBEL ELTRON integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pymodbus.client import ModbusTcpClient +from pystiebeleltron.pystiebeleltron import StiebelEltronAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT + +from .const import DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class StiebelEltronConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for STIEBEL ELTRON.""" + + VERSION = 1 + + 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: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + client = StiebelEltronAPI( + ModbusTcpClient(user_input[CONF_HOST], port=user_input[CONF_PORT]), 1 + ) + try: + success = await self.hass.async_add_executor_job(client.update) + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if not success: + errors["base"] = "cannot_connect" + if not errors: + return self.async_create_entry(title="Stiebel Eltron", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Handle import.""" + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + client = StiebelEltronAPI( + ModbusTcpClient(user_input[CONF_HOST], port=user_input[CONF_PORT]), 1 + ) + try: + success = await self.hass.async_add_executor_job(client.update) + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + if not success: + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + ) diff --git a/homeassistant/components/stiebel_eltron/const.py b/homeassistant/components/stiebel_eltron/const.py new file mode 100644 index 00000000000..e6241caa77e --- /dev/null +++ b/homeassistant/components/stiebel_eltron/const.py @@ -0,0 +1,8 @@ +"""Constants for the STIEBEL ELTRON integration.""" + +DOMAIN = "stiebel_eltron" + +CONF_HUB = "hub" + +DEFAULT_HUB = "modbus_hub" +DEFAULT_PORT = 502 diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index 9580cd4d4ca..f8140ed36d7 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -1,11 +1,10 @@ { "domain": "stiebel_eltron", "name": "STIEBEL ELTRON", - "codeowners": ["@fucm"], - "dependencies": ["modbus"], + "codeowners": ["@fucm", "@ThyMYthOS"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/stiebel_eltron", "iot_class": "local_polling", "loggers": ["pymodbus", "pystiebeleltron"], - "quality_scale": "legacy", - "requirements": ["pystiebeleltron==0.0.1.dev2"] + "requirements": ["pystiebeleltron==0.1.0"] } diff --git a/homeassistant/components/stiebel_eltron/strings.json b/homeassistant/components/stiebel_eltron/strings.json new file mode 100644 index 00000000000..8ff2b4025a9 --- /dev/null +++ b/homeassistant/components/stiebel_eltron/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "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 Stiebel Eltron device.", + "port": "The port of your Stiebel Eltron device." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "issues": { + "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 both the `{domain}` and the relevant Modbus configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "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." + }, + "deprecated_yaml_import_issue_missing_hub": { + "title": "YAML import failed due to incomplete config", + "description": "Configuring {integration_title} using YAML is being removed but the configuration was not complete, thus we could not import your 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_unknown": { + "title": "YAML import failed due to an 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." + } + } +} diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index 2849e0e976a..1f3ef4ee4ba 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -18,4 +18,5 @@ async def async_get_config_entry_diagnostics( "advice": client.advice, "air_quality_index": client.lki, "windspeed_ms": client.windspeed_ms, + "forecast": await client.async_get_forecast(), } diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index d7304fa1238..a028f1f19c5 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -29,7 +29,7 @@ }, "issues": { "location_migration_failed": { - "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integrations uses.\n\nMake sure you are connected to the internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", + "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integration uses.\n\nMake sure you are connected to the Internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", "title": "Migration of your location failed" } }, diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 00da729dccd..6aef0041874 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Subaru Starlink Configuration", + "title": "Subaru Starlink configuration", "description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -12,7 +12,7 @@ }, "two_factor": { "title": "[%key:component::subaru::config::step::user::title%]", - "description": "Two factor authentication required", + "description": "Two-factor authentication required", "data": { "contact_method": "Please select a contact method:" } @@ -49,7 +49,7 @@ "options": { "step": { "init": { - "title": "Subaru Starlink Options", + "title": "Subaru Starlink options", "description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).", "data": { "update_enabled": "Enable vehicle polling" @@ -106,7 +106,7 @@ "fields": { "door": { "name": "Door", - "description": "One of the following: 'all', 'driver', 'tailgate'." + "description": "Which door(s) to open." } } } diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index 38f94b8937e..10d4d3cdbcb 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -20,8 +20,8 @@ class SuezWaterAggregatedAttributes: this_month_consumption: dict[str, float] previous_month_consumption: dict[str, float] - last_year_overall: dict[str, float] - this_year_overall: dict[str, float] + last_year_overall: int + this_year_overall: int history: dict[str, float] highest_monthly_consumption: float diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 5d317ea5ba3..128f7aa4d8d 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.3"] + "requirements": ["pysuezV2==2.0.5"] } diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index be2d4849e76..a8632fcb24a 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -5,21 +5,21 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "counter_id": "Meter 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" + "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" + "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 meter 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/condition.py b/homeassistant/components/sun/condition.py new file mode 100644 index 00000000000..205f1bb8b5c --- /dev/null +++ b/homeassistant/components/sun/condition.py @@ -0,0 +1,143 @@ +"""Offer sun based automation rules.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import cast + +import voluptuous as vol + +from homeassistant.const import CONF_CONDITION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.condition import ( + ConditionCheckerType, + condition_trace_set_result, + condition_trace_update_result, + trace_condition_function, +) +from homeassistant.helpers.sun import get_astral_event_date +from homeassistant.helpers.typing import ConfigType, TemplateVarsType +from homeassistant.util import dt as dt_util + +_CONDITION_SCHEMA = vol.All( + vol.Schema( + { + **cv.CONDITION_BASE_SCHEMA, + vol.Required(CONF_CONDITION): "sun", + vol.Optional("before"): cv.sun_event, + vol.Optional("before_offset"): cv.time_period, + vol.Optional("after"): vol.All( + vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) + ), + vol.Optional("after_offset"): cv.time_period, + } + ), + cv.has_at_least_one_key("before", "after"), +) + + +async def async_validate_condition_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] + + +def sun( + hass: HomeAssistant, + before: str | None = None, + after: str | None = None, + before_offset: timedelta | None = None, + after_offset: timedelta | None = None, +) -> bool: + """Test if current time matches sun requirements.""" + utcnow = dt_util.utcnow() + today = dt_util.as_local(utcnow).date() + before_offset = before_offset or timedelta(0) + after_offset = after_offset or timedelta(0) + + sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today) + sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) + + has_sunrise_condition = SUN_EVENT_SUNRISE in (before, after) + has_sunset_condition = SUN_EVENT_SUNSET in (before, after) + + after_sunrise = today > dt_util.as_local(cast(datetime, sunrise)).date() + if after_sunrise and has_sunrise_condition: + tomorrow = today + timedelta(days=1) + sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, tomorrow) + + after_sunset = today > dt_util.as_local(cast(datetime, sunset)).date() + if after_sunset and has_sunset_condition: + tomorrow = today + timedelta(days=1) + sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, tomorrow) + + # Special case: before sunrise OR after sunset + # This will handle the very rare case in the polar region when the sun rises/sets + # but does not set/rise. + # However this entire condition does not handle those full days of darkness + # or light, the following should be used instead: + # + # condition: + # condition: state + # entity_id: sun.sun + # state: 'above_horizon' (or 'below_horizon') + # + if before == SUN_EVENT_SUNRISE and after == SUN_EVENT_SUNSET: + wanted_time_before = cast(datetime, sunrise) + before_offset + condition_trace_update_result(wanted_time_before=wanted_time_before) + wanted_time_after = cast(datetime, sunset) + after_offset + condition_trace_update_result(wanted_time_after=wanted_time_after) + return utcnow < wanted_time_before or utcnow > wanted_time_after + + if sunrise is None and has_sunrise_condition: + # There is no sunrise today + condition_trace_set_result(False, message="no sunrise today") + return False + + if sunset is None and has_sunset_condition: + # There is no sunset today + condition_trace_set_result(False, message="no sunset today") + return False + + if before == SUN_EVENT_SUNRISE: + wanted_time_before = cast(datetime, sunrise) + before_offset + condition_trace_update_result(wanted_time_before=wanted_time_before) + if utcnow > wanted_time_before: + return False + + if before == SUN_EVENT_SUNSET: + wanted_time_before = cast(datetime, sunset) + before_offset + condition_trace_update_result(wanted_time_before=wanted_time_before) + if utcnow > wanted_time_before: + return False + + if after == SUN_EVENT_SUNRISE: + wanted_time_after = cast(datetime, sunrise) + after_offset + condition_trace_update_result(wanted_time_after=wanted_time_after) + if utcnow < wanted_time_after: + return False + + if after == SUN_EVENT_SUNSET: + wanted_time_after = cast(datetime, sunset) + after_offset + condition_trace_update_result(wanted_time_after=wanted_time_after) + if utcnow < wanted_time_after: + return False + + return True + + +def async_condition_from_config(config: ConfigType) -> ConditionCheckerType: + """Wrap action method with sun based condition.""" + before = config.get("before") + after = config.get("after") + before_offset = config.get("before_offset") + after_offset = config.get("after_offset") + + @trace_condition_function + def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + """Validate time based if-condition.""" + return sun(hass, before, after, before_offset, after_offset) + + return sun_if diff --git a/homeassistant/components/sun/manifest.json b/homeassistant/components/sun/manifest.json index f6b4ae1976b..b693509b27a 100644 --- a/homeassistant/components/sun/manifest.json +++ b/homeassistant/components/sun/manifest.json @@ -1,7 +1,7 @@ { "domain": "sun", "name": "Sun", - "codeowners": ["@Swamp-Ig"], + "codeowners": ["@home-assistant/core"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sun", "iot_class": "calculated", diff --git a/homeassistant/components/sunweg/__init__.py b/homeassistant/components/sunweg/__init__.py index 86da0a247b1..0dfed0e6bb3 100644 --- a/homeassistant/components/sunweg/__init__.py +++ b/homeassistant/components/sunweg/__init__.py @@ -1,197 +1,39 @@ """The Sun WEG inverter sensor integration.""" -import datetime -import json -import logging - -from sunweg.api import APIHelper -from sunweg.plant import Plant - -from homeassistant import config_entries 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.typing import StateType, UndefinedType -from homeassistant.util import Throttle +from homeassistant.helpers import issue_registry as ir -from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS, DeviceType - -SCAN_INTERVAL = datetime.timedelta(minutes=5) - -_LOGGER = logging.getLogger(__name__) +DOMAIN = "sunweg" -async def async_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: """Load the saved entities.""" - api = APIHelper(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) - if not await hass.async_add_executor_job(api.authenticate): - raise ConfigEntryAuthFailed("Username or Password may be incorrect!") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SunWEGData( - api, entry.data[CONF_PLANT_ID] + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "issue": "https://github.com/rokam/sunweg/issues/13", + "entries": "/config/integrations/integration/sunweg", + }, ) - 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.""" - hass.data[DOMAIN].pop(entry.entry_id) - if len(hass.data[DOMAIN]) == 0: - hass.data.pop(DOMAIN) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return True -class SunWEGData: - """The class for handling data retrieval.""" - - def __init__( - self, - api: APIHelper, - plant_id: int, - ) -> None: - """Initialize the probe.""" - - self.api = api - self.plant_id = plant_id - self.data: Plant = None - self.previous_values: dict = {} - - @Throttle(SCAN_INTERVAL) - def update(self) -> None: - """Update probe data.""" - _LOGGER.debug("Updating data for plant %s", self.plant_id) - try: - self.data = self.api.plant(self.plant_id) - for inverter in self.data.inverters: - self.api.complete_inverter(inverter) - except json.decoder.JSONDecodeError: - _LOGGER.error("Unable to fetch data from SunWEG server") - _LOGGER.debug("Finished updating data for plant %s", self.plant_id) - - def get_api_value( - self, - variable: str, - device_type: DeviceType, - inverter_id: int = 0, - deep_name: str | None = None, - ): - """Retrieve from a Plant the desired variable value.""" - if device_type == DeviceType.TOTAL: - return self.data.__dict__.get(variable) - - inverter_list = [i for i in self.data.inverters if i.id == inverter_id] - if len(inverter_list) == 0: - return None - inverter = inverter_list[0] - - if device_type == DeviceType.INVERTER: - return inverter.__dict__.get(variable) - if device_type == DeviceType.PHASE: - for phase in inverter.phases: - if phase.name == deep_name: - return phase.__dict__.get(variable) - elif device_type == DeviceType.STRING: - for mppt in inverter.mppts: - for string in mppt.strings: - if string.name == deep_name: - return string.__dict__.get(variable) - return None - - def get_data( - self, - *, - api_variable_key: str, - api_variable_unit: str | None, - deep_name: str | None, - device_type: DeviceType, - inverter_id: int, - name: str | UndefinedType | None, - native_unit_of_measurement: str | None, - never_resets: bool, - previous_value_drop_threshold: float | None, - ) -> tuple[StateType | datetime.datetime, str | None]: - """Get the data.""" - _LOGGER.debug( - "Data request for: %s", - name, - ) - variable = api_variable_key - previous_unit = native_unit_of_measurement - api_value = self.get_api_value(variable, device_type, inverter_id, deep_name) - previous_value = self.previous_values.get(variable) - return_value = api_value - if api_variable_unit is not None: - native_unit_of_measurement = self.get_api_value( - api_variable_unit, - device_type, - inverter_id, - deep_name, - ) - - # If we have a 'drop threshold' specified, then check it and correct if needed - if ( - previous_value_drop_threshold is not None - and previous_value is not None - and api_value is not None - and previous_unit == native_unit_of_measurement - ): - _LOGGER.debug( - ( - "%s - Drop threshold specified (%s), checking for drop... API" - " Value: %s, Previous Value: %s" - ), - name, - previous_value_drop_threshold, - api_value, - previous_value, - ) - diff = float(api_value) - float(previous_value) - - # Check if the value has dropped (negative value i.e. < 0) and it has only - # dropped by a small amount, if so, use the previous value. - # Note - The energy dashboard takes care of drops within 10% - # of the current value, however if the value is low e.g. 0.2 - # and drops by 0.1 it classes as a reset. - if -(previous_value_drop_threshold) <= diff < 0: - _LOGGER.debug( - ( - "Diff is negative, but only by a small amount therefore not a" - " nightly reset, using previous value (%s) instead of api value" - " (%s)" - ), - previous_value, - api_value, - ) - return_value = previous_value - else: - _LOGGER.debug("%s - No drop detected, using API value", name) - - # Lifetime total values should always be increasing, they will never reset, - # however the API sometimes returns 0 values when the clock turns to 00:00 - # local time in that scenario we should just return the previous value - # Scenarios: - # 1 - System has a genuine 0 value when it it first commissioned: - # - will return 0 until a non-zero value is registered - # 2 - System has been running fine but temporarily resets to 0 briefly - # at midnight: - # - will return the previous value - # 3 - HA is restarted during the midnight 'outage' - Not handled: - # - Previous value will not exist meaning 0 will be returned - # - This is an edge case that would be better handled by looking - # up the previous value of the entity from the recorder - if never_resets and api_value == 0 and previous_value: - _LOGGER.debug( - ( - "API value is 0, but this value should never reset, returning" - " previous value (%s) instead" - ), - previous_value, - ) - return_value = previous_value - - self.previous_values[variable] = return_value - - return (return_value, native_unit_of_measurement) +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/sunweg/config_flow.py b/homeassistant/components/sunweg/config_flow.py index 24df8c02f55..42535a9ef58 100644 --- a/homeassistant/components/sunweg/config_flow.py +++ b/homeassistant/components/sunweg/config_flow.py @@ -1,129 +1,11 @@ """Config flow for Sun WEG integration.""" -from collections.abc import Mapping -from typing import Any +from homeassistant.config_entries import ConfigFlow -from sunweg.api import APIHelper, SunWegApiError -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback - -from .const import CONF_PLANT_ID, DOMAIN +from . import DOMAIN class SunWEGConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow class.""" VERSION = 1 - - def __init__(self) -> None: - """Initialise sun weg server flow.""" - self.api: APIHelper = None - self.data: dict[str, Any] = {} - - @callback - def _async_show_user_form(self, step_id: str, errors=None) -> ConfigFlowResult: - """Show the form to the user.""" - default_username = "" - if CONF_USERNAME in self.data: - default_username = self.data[CONF_USERNAME] - data_schema = vol.Schema( - { - vol.Required(CONF_USERNAME, default=default_username): str, - vol.Required(CONF_PASSWORD): str, - } - ) - - return self.async_show_form( - step_id=step_id, data_schema=data_schema, errors=errors - ) - - def _set_auth_data( - self, step: str, username: str, password: str - ) -> ConfigFlowResult | None: - """Set username and password.""" - if self.api: - # Set username and password - self.api.username = username - self.api.password = password - else: - # Initialise the library with the username & password - self.api = APIHelper(username, password) - - try: - if not self.api.authenticate(): - return self._async_show_user_form(step, {"base": "invalid_auth"}) - except SunWegApiError: - return self._async_show_user_form(step, {"base": "timeout_connect"}) - - return None - - async def async_step_user(self, user_input=None) -> ConfigFlowResult: - """Handle the start of the config flow.""" - if not user_input: - return self._async_show_user_form("user") - - # Store authentication info - self.data = user_input - - conf_result = await self.hass.async_add_executor_job( - self._set_auth_data, - "user", - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) - - return await self.async_step_plant() if conf_result is None else conf_result - - async def async_step_plant(self, user_input=None) -> ConfigFlowResult: - """Handle adding a "plant" to Home Assistant.""" - plant_list = await self.hass.async_add_executor_job(self.api.listPlants) - - if len(plant_list) == 0: - return self.async_abort(reason="no_plants") - - plants = {plant.id: plant.name for plant in plant_list} - - if user_input is None and len(plant_list) > 1: - data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) - - return self.async_show_form(step_id="plant", data_schema=data_schema) - - if user_input is None and len(plant_list) == 1: - user_input = {CONF_PLANT_ID: plant_list[0].id} - - user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] - await self.async_set_unique_id(user_input[CONF_PLANT_ID]) - self._abort_if_unique_id_configured() - self.data.update(user_input) - return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Handle reauthorization request from SunWEG.""" - self.data.update(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 reauthorization flow.""" - if user_input is None: - return self._async_show_user_form("reauth_confirm") - - self.data.update(user_input) - conf_result = await self.hass.async_add_executor_job( - self._set_auth_data, - "reauth_confirm", - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) - if conf_result is not None: - return conf_result - - return self.async_update_reload_and_abort( - self._get_reauth_entry(), data=self.data - ) diff --git a/homeassistant/components/sunweg/const.py b/homeassistant/components/sunweg/const.py deleted file mode 100644 index 11d24352962..00000000000 --- a/homeassistant/components/sunweg/const.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Define constants for the Sun WEG component.""" - -from enum import Enum - -from homeassistant.const import Platform - - -class DeviceType(Enum): - """Device Type Enum.""" - - TOTAL = 1 - INVERTER = 2 - PHASE = 3 - STRING = 4 - - -CONF_PLANT_ID = "plant_id" - -DEFAULT_PLANT_ID = 0 - -DEFAULT_NAME = "Sun WEG" - -DOMAIN = "sunweg" - -PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json index 3ebe9ef8cb4..3e5c669f37f 100644 --- a/homeassistant/components/sunweg/manifest.json +++ b/homeassistant/components/sunweg/manifest.json @@ -1,10 +1,10 @@ { "domain": "sunweg", "name": "Sun WEG", - "codeowners": ["@rokam"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sunweg", "iot_class": "cloud_polling", - "loggers": ["sunweg"], - "requirements": ["sunweg==3.0.2"] + "loggers": [], + "requirements": [] } diff --git a/homeassistant/components/sunweg/sensor/__init__.py b/homeassistant/components/sunweg/sensor/__init__.py deleted file mode 100644 index f71d992bea9..00000000000 --- a/homeassistant/components/sunweg/sensor/__init__.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Read status of SunWEG inverters.""" - -from __future__ import annotations - -import logging -from types import MappingProxyType -from typing import Any - -from sunweg.api import APIHelper -from sunweg.device import Inverter -from sunweg.plant import Plant - -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .. import SunWEGData -from ..const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType -from .inverter import INVERTER_SENSOR_TYPES -from .phase import PHASE_SENSOR_TYPES -from .sensor_entity_description import SunWEGSensorEntityDescription -from .string import STRING_SENSOR_TYPES -from .total import TOTAL_SENSOR_TYPES - -_LOGGER = logging.getLogger(__name__) - - -def get_device_list( - api: APIHelper, config: MappingProxyType[str, Any] -) -> tuple[list[Inverter], int]: - """Retrieve the device list for the selected plant.""" - plant_id = int(config[CONF_PLANT_ID]) - - if plant_id == DEFAULT_PLANT_ID: - plant_info: list[Plant] = api.listPlants() - plant_id = plant_info[0].id - - devices: list[Inverter] = [] - # Get a list of devices for specified plant to add sensors for. - for inverter in api.plant(plant_id).inverters: - api.complete_inverter(inverter) - devices.append(inverter) - return (devices, plant_id) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the SunWEG sensor.""" - name = config_entry.data[CONF_NAME] - - probe: SunWEGData = hass.data[DOMAIN][config_entry.entry_id] - - devices, plant_id = await hass.async_add_executor_job( - get_device_list, probe.api, config_entry.data - ) - - entities = [ - SunWEGInverter( - probe, - name=f"{name} Total", - unique_id=f"{plant_id}-{description.key}", - description=description, - device_type=DeviceType.TOTAL, - ) - for description in TOTAL_SENSOR_TYPES - ] - - # Add sensors for each device in the specified plant. - entities.extend( - [ - SunWEGInverter( - probe, - name=f"{device.name}", - unique_id=f"{device.sn}-{description.key}", - description=description, - device_type=DeviceType.INVERTER, - inverter_id=device.id, - ) - for device in devices - for description in INVERTER_SENSOR_TYPES - ] - ) - - entities.extend( - [ - SunWEGInverter( - probe, - name=f"{device.name} {phase.name}", - unique_id=f"{device.sn}-{phase.name}-{description.key}", - description=description, - inverter_id=device.id, - device_type=DeviceType.PHASE, - deep_name=phase.name, - ) - for device in devices - for phase in device.phases - for description in PHASE_SENSOR_TYPES - ] - ) - - entities.extend( - [ - SunWEGInverter( - probe, - name=f"{device.name} {string.name}", - unique_id=f"{device.sn}-{string.name}-{description.key}", - description=description, - inverter_id=device.id, - device_type=DeviceType.STRING, - deep_name=string.name, - ) - for device in devices - for mppt in device.mppts - for string in mppt.strings - for description in STRING_SENSOR_TYPES - ] - ) - - async_add_entities(entities, True) - - -class SunWEGInverter(SensorEntity): - """Representation of a SunWEG Sensor.""" - - entity_description: SunWEGSensorEntityDescription - - def __init__( - self, - probe: SunWEGData, - name: str, - unique_id: str, - description: SunWEGSensorEntityDescription, - device_type: DeviceType, - inverter_id: int = 0, - deep_name: str | None = None, - ) -> None: - """Initialize a sensor.""" - self.probe = probe - self.entity_description = description - self.device_type = device_type - self.inverter_id = inverter_id - self.deep_name = deep_name - - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = unique_id - self._attr_icon = ( - description.icon if description.icon is not None else "mdi:solar-power" - ) - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(probe.plant_id))}, - manufacturer="SunWEG", - name=name, - ) - - def update(self) -> None: - """Get the latest data from the Sun WEG API and updates the state.""" - self.probe.update() - ( - self._attr_native_value, - self._attr_native_unit_of_measurement, - ) = self.probe.get_data( - api_variable_key=self.entity_description.api_variable_key, - api_variable_unit=self.entity_description.api_variable_unit, - deep_name=self.deep_name, - device_type=self.device_type, - inverter_id=self.inverter_id, - name=self.entity_description.name, - native_unit_of_measurement=self.native_unit_of_measurement, - never_resets=self.entity_description.never_resets, - previous_value_drop_threshold=self.entity_description.previous_value_drop_threshold, - ) diff --git a/homeassistant/components/sunweg/sensor/inverter.py b/homeassistant/components/sunweg/sensor/inverter.py deleted file mode 100644 index 1010488b38a..00000000000 --- a/homeassistant/components/sunweg/sensor/inverter.py +++ /dev/null @@ -1,70 +0,0 @@ -"""SunWEG Sensor definitions for the Inverter type.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import ( - UnitOfEnergy, - UnitOfFrequency, - UnitOfPower, - UnitOfTemperature, -) - -from .sensor_entity_description import SunWEGSensorEntityDescription - -INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="inverter_energy_today", - name="Energy today", - api_variable_key="_today_energy", - api_variable_unit="_today_energy_metric", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_energy_total", - name="Lifetime energy output", - api_variable_key="_total_energy", - api_variable_unit="_total_energy_metric", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - suggested_display_precision=1, - state_class=SensorStateClass.TOTAL, - never_resets=True, - ), - SunWEGSensorEntityDescription( - key="inverter_frequency", - name="AC frequency", - api_variable_key="_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_current_wattage", - name="Output power", - api_variable_key="_power", - api_variable_unit="_power_metric", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_temperature", - name="Temperature", - api_variable_key="_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:temperature-celsius", - suggested_display_precision=1, - ), - SunWEGSensorEntityDescription( - key="inverter_power_factor", - name="Power Factor", - api_variable_key="_power_factor", - suggested_display_precision=1, - ), -) diff --git a/homeassistant/components/sunweg/sensor/phase.py b/homeassistant/components/sunweg/sensor/phase.py deleted file mode 100644 index d9db6c7c714..00000000000 --- a/homeassistant/components/sunweg/sensor/phase.py +++ /dev/null @@ -1,27 +0,0 @@ -"""SunWEG Sensor definitions for the Phase type.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential - -from .sensor_entity_description import SunWEGSensorEntityDescription - -PHASE_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="voltage", - name="Voltage", - api_variable_key="_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - suggested_display_precision=2, - ), - SunWEGSensorEntityDescription( - key="amperage", - name="Amperage", - api_variable_key="_amperage", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - suggested_display_precision=1, - ), -) diff --git a/homeassistant/components/sunweg/sensor/sensor_entity_description.py b/homeassistant/components/sunweg/sensor/sensor_entity_description.py deleted file mode 100644 index 8c792ab617f..00000000000 --- a/homeassistant/components/sunweg/sensor/sensor_entity_description.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Sensor Entity Description for the SunWEG integration.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from homeassistant.components.sensor import SensorEntityDescription - - -@dataclass(frozen=True) -class SunWEGRequiredKeysMixin: - """Mixin for required keys.""" - - api_variable_key: str - - -@dataclass(frozen=True) -class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): - """Describes SunWEG sensor entity.""" - - api_variable_unit: str | None = None - previous_value_drop_threshold: float | None = None - never_resets: bool = False - icon: str | None = None diff --git a/homeassistant/components/sunweg/sensor/string.py b/homeassistant/components/sunweg/sensor/string.py deleted file mode 100644 index ec59da5d20d..00000000000 --- a/homeassistant/components/sunweg/sensor/string.py +++ /dev/null @@ -1,27 +0,0 @@ -"""SunWEG Sensor definitions for the String type.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential - -from .sensor_entity_description import SunWEGSensorEntityDescription - -STRING_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="voltage", - name="Voltage", - api_variable_key="_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - suggested_display_precision=2, - ), - SunWEGSensorEntityDescription( - key="amperage", - name="Amperage", - api_variable_key="_amperage", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - suggested_display_precision=1, - ), -) diff --git a/homeassistant/components/sunweg/sensor/total.py b/homeassistant/components/sunweg/sensor/total.py deleted file mode 100644 index 2b94446a165..00000000000 --- a/homeassistant/components/sunweg/sensor/total.py +++ /dev/null @@ -1,50 +0,0 @@ -"""SunWEG Sensor definitions for Totals.""" - -from __future__ import annotations - -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import UnitOfEnergy, UnitOfPower - -from .sensor_entity_description import SunWEGSensorEntityDescription - -TOTAL_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( - SunWEGSensorEntityDescription( - key="total_money_total", - name="Money lifetime", - api_variable_key="_saving", - icon="mdi:cash", - native_unit_of_measurement="R$", - suggested_display_precision=2, - ), - SunWEGSensorEntityDescription( - key="total_energy_today", - name="Energy Today", - api_variable_key="_today_energy", - api_variable_unit="_today_energy_metric", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - SunWEGSensorEntityDescription( - key="total_output_power", - name="Output Power", - api_variable_key="_total_power", - native_unit_of_measurement=UnitOfPower.KILO_WATT, - device_class=SensorDeviceClass.POWER, - ), - SunWEGSensorEntityDescription( - key="total_energy_output", - name="Lifetime energy output", - api_variable_key="_total_energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - never_resets=True, - ), - SunWEGSensorEntityDescription( - key="last_update", - name="Last Update", - api_variable_key="_last_update", - device_class=SensorDeviceClass.DATE, - ), -) diff --git a/homeassistant/components/sunweg/strings.json b/homeassistant/components/sunweg/strings.json index 9ab7be053b1..75abf5d9271 100644 --- a/homeassistant/components/sunweg/strings.json +++ b/homeassistant/components/sunweg/strings.json @@ -1,35 +1,8 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_plants": "No plants have been found on this account", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" - }, - "step": { - "plant": { - "data": { - "plant_id": "Plant" - }, - "title": "Select your plant" - }, - "user": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "title": "Enter your Sun WEG information" - }, - "reauth_confirm": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "title": "[%key:common::config_flow::title::reauth%]" - } + "issues": { + "integration_removed": { + "title": "The SunWEG integration has been removed", + "description": "The SunWEG integration has been removed from Home Assistant.\n\nThe library that Home Assistant uses to connect with SunWEG services, [doesn't work as expected anymore, demanding daily token renew]({issue}).\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing SunWEG integration entries]({entries})." } } } diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py index c443e1e63df..c14eb6fb353 100644 --- a/homeassistant/components/supervisord/sensor.py +++ b/homeassistant/components/supervisord/sensor.py @@ -71,7 +71,7 @@ class SupervisorProcessSensor(SensorEntity): return self._info.get("statename") @property - def available(self): + def available(self) -> bool: """Could the device be accessed during the last update call.""" return self._available diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 416d56d1bdd..60183518c93 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -133,12 +133,15 @@ class DeviceConnectivity(SurePetcareBinarySensor): @callback def _update_attr(self, surepy_entity: SurepyEntity) -> None: - state = surepy_entity.raw_data()["status"] - 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}", - } - else: - self._attr_extra_state_attributes = {} + state = surepy_entity.raw_data().get("status", {}) + online = bool(state.get("online", False)) + self._attr_is_on = online + self._attr_extra_state_attributes = {} + if online: + device_rssi = state.get("signal", {}).get("device_rssi") + self._attr_extra_state_attributes["device_rssi"] = ( + f"{device_rssi:.2f}" if device_rssi else "Unknown" + ) + hub_rssi = state.get("signal", {}).get("hub_rssi") + if hub_rssi is not None: + self._attr_extra_state_attributes["hub_rssi"] = f"{hub_rssi:.2f}" diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 4dc6efc2e85..872044097d6 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -190,7 +190,7 @@ class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN): return "cannot_connect" except OpendataTransportError: return "bad_config" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error") return "unknown" return None diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py index e41901337f4..72dc1afab8a 100644 --- a/homeassistant/components/swiss_public_transport/helper.py +++ b/homeassistant/components/swiss_public_transport/helper.py @@ -1,7 +1,7 @@ """Helper functions for swiss_public_transport.""" +from collections.abc import Mapping from datetime import timedelta -from types import MappingProxyType from typing import Any from opendata_transport import OpendataTransport @@ -36,7 +36,7 @@ def dict_duration_to_str_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: +def unique_id_from_config(config: Mapping[str, Any]) -> str: """Build a unique id from a config entry.""" return ( f"{config[CONF_START]} {config[CONF_DESTINATION]}" diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 1cdbd527467..f1b28f5ed14 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -83,8 +83,8 @@ }, "services": { "fetch_connections": { - "name": "Fetch Connections", - "description": "Fetch a list of connections from the swiss public transport.", + "name": "Fetch connections", + "description": "Fetches a list of connections from Swiss public transport.", "fields": { "config_entry_id": { "name": "Instance", @@ -92,7 +92,7 @@ }, "limit": { "name": "Limit", - "description": "Number of connections to fetch from [1-15]" + "description": "Number of connections to fetch." } } } diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index 0663384fe2c..b73cf8f849d 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -25,10 +25,18 @@ } }, "switch": { - "name": "[%key:component::switch::title%]" + "name": "[%key:component::switch::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } }, "outlet": { - "name": "Outlet" + "name": "Outlet", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } }, "services": { diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 09bc157d4d2..22119a5442e 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, + DOMAIN, ENCRYPTED_MODELS, HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL, SupportedModels, @@ -66,6 +67,18 @@ PLATFORMS_BY_TYPE = { SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH], SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.REMOTE.value: [Platform.SENSOR], + SupportedModels.ROLLER_SHADE.value: [ + Platform.COVER, + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], + SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR], + SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR], + SupportedModels.K20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], + SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], + SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], + SupportedModels.K10_PRO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], + SupportedModels.K10_PRO_COMBO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -80,6 +93,13 @@ CLASS_BY_DEVICE = { SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt, SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch, SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch, + SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade, + SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan, + SupportedModels.K20_VACUUM.value: switchbot.SwitchbotVacuum, + SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum, + SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum, + SupportedModels.K10_PRO_VACUUM.value: switchbot.SwitchbotVacuum, + SupportedModels.K10_PRO_COMBO_VACUUM.value: switchbot.SwitchbotVacuum, } @@ -119,7 +139,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> ) if not ble_device: raise ConfigEntryNotReady( - f"Could not find Switchbot {sensor_type} with address {address}" + translation_domain=DOMAIN, + translation_key="device_not_found_error", + translation_placeholders={"sensor_type": sensor_type, "address": address}, ) cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice) @@ -134,7 +156,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> ) except ValueError as error: raise ConfigEntryNotReady( - "Invalid encryption configuration provided" + translation_domain=DOMAIN, + translation_key="value_error", + translation_placeholders={"error": str(error)}, ) from error else: device = cls( @@ -155,7 +179,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> ) entry.async_on_unload(coordinator.async_start()) if not await coordinator.async_wait_ready(): - raise ConfigEntryNotReady(f"{address} is not advertising state") + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="advertising_state_error", + translation_placeholders={"address": address}, + ) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups( diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 16b41d75541..327b6e704a0 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -35,6 +35,14 @@ class SupportedModels(StrEnum): RELAY_SWITCH_1 = "relay_switch_1" LEAK = "leak" REMOTE = "remote" + ROLLER_SHADE = "roller_shade" + HUBMINI_MATTER = "hubmini_matter" + CIRCULATOR_FAN = "circulator_fan" + K20_VACUUM = "k20_vacuum" + S10_VACUUM = "s10_vacuum" + K10_VACUUM = "k10_vacuum" + K10_PRO_VACUUM = "k10_pro_vacuum" + K10_PRO_COMBO_VACUUM = "k10_pro_combo_vacumm" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -51,6 +59,13 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.HUB2: SupportedModels.HUB2, SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM, SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1, + SwitchbotModel.ROLLER_SHADE: SupportedModels.ROLLER_SHADE, + SwitchbotModel.CIRCULATOR_FAN: SupportedModels.CIRCULATOR_FAN, + SwitchbotModel.K20_VACUUM: SupportedModels.K20_VACUUM, + SwitchbotModel.S10_VACUUM: SupportedModels.S10_VACUUM, + SwitchbotModel.K10_VACUUM: SupportedModels.K10_VACUUM, + SwitchbotModel.K10_PRO_VACUUM: SupportedModels.K10_PRO_VACUUM, + SwitchbotModel.K10_PRO_COMBO_VACUUM: SupportedModels.K10_PRO_COMBO_VACUUM, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -62,6 +77,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.LEAK: SupportedModels.LEAK, SwitchbotModel.REMOTE: SupportedModels.REMOTE, + SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER, } SUPPORTED_MODEL_TYPES = ( diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index 807132d13e8..3e3b59f9e06 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -91,6 +91,7 @@ class SwitchbotDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]) """Handle the device going unavailable.""" super()._async_handle_unavailable(service_info) self._was_unavailable = True + _LOGGER.info("Device %s is unavailable", self.device_name) @callback def _async_handle_bluetooth_event( @@ -114,6 +115,7 @@ class SwitchbotDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]) if not self.device.advertisement_changed(adv) and not self._was_unavailable: return self._was_unavailable = False + _LOGGER.info("Device %s is online", self.device_name) self.device.update_from_advertisement(adv) super()._async_handle_bluetooth_event(service_info, change) diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 3ef0f5625c2..9124dc7f846 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator -from .entity import SwitchbotEntity +from .entity import SwitchbotEntity, exception_handler # Initialize the logger _LOGGER = logging.getLogger(__name__) @@ -37,6 +37,8 @@ async def async_setup_entry( coordinator = entry.runtime_data if isinstance(coordinator.device, switchbot.SwitchbotBlindTilt): async_add_entities([SwitchBotBlindTiltEntity(coordinator)]) + elif isinstance(coordinator.device, switchbot.SwitchbotRollerShade): + async_add_entities([SwitchBotRollerShadeEntity(coordinator)]) else: async_add_entities([SwitchBotCurtainEntity(coordinator)]) @@ -74,6 +76,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): if self._attr_current_cover_position is not None: self._attr_is_closed = self._attr_current_cover_position <= 20 + @exception_handler async def async_open_cover(self, **kwargs: Any) -> None: """Open the curtain.""" @@ -83,6 +86,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() + @exception_handler async def async_close_cover(self, **kwargs: Any) -> None: """Close the curtain.""" @@ -92,6 +96,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() + @exception_handler async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the moving of this device.""" @@ -101,6 +106,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() + @exception_handler async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" position = kwargs.get(ATTR_POSITION) @@ -154,11 +160,12 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): ATTR_CURRENT_TILT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") - if (_tilt := self._attr_current_cover_position) is not None: + if (_tilt := self._attr_current_cover_tilt_position) is not None: self._attr_is_closed = (_tilt < self.CLOSED_DOWN_THRESHOLD) or ( _tilt > self.CLOSED_UP_THRESHOLD ) + @exception_handler async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the tilt.""" @@ -166,6 +173,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._last_run_success = bool(await self._device.open()) self.async_write_ha_state() + @exception_handler async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the tilt.""" @@ -173,6 +181,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._last_run_success = bool(await self._device.close()) self.async_write_ha_state() + @exception_handler async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the moving of this device.""" @@ -180,6 +189,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._last_run_success = bool(await self._device.stop()) self.async_write_ha_state() + @exception_handler async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" position = kwargs.get(ATTR_TILT_POSITION) @@ -199,3 +209,89 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_opening = self.parsed_data["motionDirection"]["opening"] self._attr_is_closing = self.parsed_data["motionDirection"]["closing"] self.async_write_ha_state() + + +class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): + """Representation of a Switchbot.""" + + _device: switchbot.SwitchbotRollerShade + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + _attr_translation_key = "cover" + _attr_name = None + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the switchbot.""" + super().__init__(coordinator) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + return + + self._attr_current_cover_position = last_state.attributes.get( + ATTR_CURRENT_POSITION + ) + self._last_run_success = last_state.attributes.get("last_run_success") + if self._attr_current_cover_position is not None: + self._attr_is_closed = self._attr_current_cover_position <= 20 + + @exception_handler + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the roller shade.""" + + _LOGGER.debug("Switchbot to open roller shade %s", self._address) + self._last_run_success = bool(await self._device.open()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + @exception_handler + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the roller shade.""" + + _LOGGER.debug("Switchbot to close roller shade %s", self._address) + self._last_run_success = bool(await self._device.close()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + @exception_handler + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the moving of roller shade.""" + + _LOGGER.debug("Switchbot to stop roller shade %s", self._address) + self._last_run_success = bool(await self._device.stop()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + @exception_handler + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + + position = kwargs.get(ATTR_POSITION) + _LOGGER.debug("Switchbot to move at %d %s", position, self._address) + self._last_run_success = bool(await self._device.set_position(position)) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_closing = self._device.is_closing() + self._attr_is_opening = self._device.is_opening() + self._attr_current_cover_position = self.parsed_data["position"] + self._attr_is_closed = self.parsed_data["position"] <= 20 + + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/diagnostics.py b/homeassistant/components/switchbot/diagnostics.py new file mode 100644 index 00000000000..71c913c6411 --- /dev/null +++ b/homeassistant/components/switchbot/diagnostics.py @@ -0,0 +1,30 @@ +"""Diagnostics support for switchbot integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components import bluetooth +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .const import CONF_ENCRYPTION_KEY, CONF_KEY_ID +from .coordinator import SwitchbotConfigEntry + +TO_REDACT = [CONF_KEY_ID, CONF_ENCRYPTION_KEY] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: SwitchbotConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + service_info = bluetooth.async_last_service_info( + hass, coordinator.ble_device.address, connectable=coordinator.connectable + ) + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "service_info": service_info, + } diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py index 282d23bfd1a..b7ee36fc1ae 100644 --- a/homeassistant/components/switchbot/entity.py +++ b/homeassistant/components/switchbot/entity.py @@ -2,22 +2,24 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Coroutine, Mapping import logging -from typing import Any +from typing import Any, Concatenate from switchbot import Switchbot, SwitchbotDevice +from switchbot.devices.device import SwitchbotOperationError from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothCoordinatorEntity, ) from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import ToggleEntity -from .const import MANUFACTURER +from .const import DOMAIN, MANUFACTURER from .coordinator import SwitchbotDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -88,11 +90,33 @@ class SwitchbotEntity( await self._device.update() +def exception_handler[_EntityT: SwitchbotEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Switchbot calls to handle exceptions.. + + A decorator that wraps the passed in function, catches Switchbot errors. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except SwitchbotOperationError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="operation_error", + translation_placeholders={"error": str(error)}, + ) from error + + return handler + + class SwitchbotSwitchedEntity(SwitchbotEntity, ToggleEntity): """Base class for Switchbot entities that can be turned on and off.""" _device: Switchbot + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn device on.""" _LOGGER.debug("Turn Switchbot device on %s", self._address) @@ -102,6 +126,7 @@ class SwitchbotSwitchedEntity(SwitchbotEntity, ToggleEntity): self._attr_is_on = True self.async_write_ha_state() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn device off.""" _LOGGER.debug("Turn Switchbot device off %s", self._address) diff --git a/homeassistant/components/switchbot/fan.py b/homeassistant/components/switchbot/fan.py new file mode 100644 index 00000000000..f704af309bf --- /dev/null +++ b/homeassistant/components/switchbot/fan.py @@ -0,0 +1,122 @@ +"""Support for SwitchBot Fans.""" + +from __future__ import annotations + +import logging +from typing import Any + +import switchbot +from switchbot import FanMode + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Switchbot fan based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities([SwitchBotFanEntity(coordinator)]) + + +class SwitchBotFanEntity(SwitchbotEntity, FanEntity, RestoreEntity): + """Representation of a Switchbot.""" + + _device: switchbot.SwitchbotFan + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.OSCILLATE + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + _attr_preset_modes = FanMode.get_modes() + _attr_translation_key = "fan" + _attr_name = None + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the switchbot.""" + super().__init__(coordinator) + self._attr_is_on = False + + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + return self._device.is_on() + + @property + def percentage(self) -> int | None: + """Return the current speed as a percentage.""" + return self._device.get_current_percentage() + + @property + def oscillating(self) -> bool | None: + """Return whether or not the fan is currently oscillating.""" + return self._device.get_oscillating_state() + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self._device.get_current_mode() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + + _LOGGER.debug( + "Switchbot fan to set preset mode %s %s", preset_mode, self._address + ) + self._last_run_success = bool(await self._device.set_preset_mode(preset_mode)) + self.async_write_ha_state() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + + _LOGGER.debug( + "Switchbot fan to set percentage %d %s", percentage, self._address + ) + self._last_run_success = bool(await self._device.set_percentage(percentage)) + self.async_write_ha_state() + + async def async_oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + + _LOGGER.debug( + "Switchbot fan to set oscillating %s %s", oscillating, self._address + ) + self._last_run_success = bool(await self._device.set_oscillation(oscillating)) + self.async_write_ha_state() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + + _LOGGER.debug( + "Switchbot fan to set turn on %s %s %s", + percentage, + preset_mode, + self._address, + ) + self._last_run_success = bool(await self._device.turn_on()) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + + _LOGGER.debug("Switchbot fan to set turn off %s", self._address) + self._last_run_success = bool(await self._device.turn_off()) + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/humidifier.py b/homeassistant/components/switchbot/humidifier.py index 34a24948df1..c15cf7ac9c6 100644 --- a/homeassistant/components/switchbot/humidifier.py +++ b/homeassistant/components/switchbot/humidifier.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry -from .entity import SwitchbotSwitchedEntity +from .entity import SwitchbotSwitchedEntity, exception_handler PARALLEL_UPDATES = 0 @@ -55,11 +55,13 @@ class SwitchBotHumidifier(SwitchbotSwitchedEntity, HumidifierEntity): """Return the humidity we try to reach.""" return self._device.get_target_humidity() + @exception_handler async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" self._last_run_success = bool(await self._device.set_level(humidity)) self.async_write_ha_state() + @exception_handler async def async_set_mode(self, mode: str) -> None: """Set new target humidity.""" if mode == MODE_AUTO: diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json new file mode 100644 index 00000000000..a1c1682d255 --- /dev/null +++ b/homeassistant/components/switchbot/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "fan": { + "fan": { + "state_attributes": { + "preset_mode": { + "state": { + "normal": "mdi:fan", + "natural": "mdi:leaf", + "sleep": "mdi:power-sleep", + "baby": "mdi:baby-face-outline" + } + } + } + } + } + } +} diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py index 0a2c342ecf0..ad37f3ebec0 100644 --- a/homeassistant/components/switchbot/light.py +++ b/homeassistant/components/switchbot/light.py @@ -2,9 +2,10 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast -from switchbot import ColorMode as SwitchBotColorMode, SwitchbotBaseLight +import switchbot +from switchbot import ColorMode as SwitchBotColorMode from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -17,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator -from .entity import SwitchbotEntity +from .entity import SwitchbotEntity, exception_handler SWITCHBOT_COLOR_MODE_TO_HASS = { SwitchBotColorMode.RGB: ColorMode.RGB, @@ -39,7 +40,7 @@ async def async_setup_entry( class SwitchbotLightEntity(SwitchbotEntity, LightEntity): """Representation of switchbot light bulb.""" - _device: SwitchbotBaseLight + _device: switchbot.SwitchbotBaseLight _attr_name = None def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: @@ -66,9 +67,12 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): self._attr_rgb_color = device.rgb self._attr_color_mode = ColorMode.RGB + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - brightness = round(kwargs.get(ATTR_BRIGHTNESS, self.brightness) / 255 * 100) + brightness = round( + cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness)) / 255 * 100 + ) if ( self.supported_color_modes @@ -87,6 +91,7 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): return await self._device.turn_on() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" await self._device.turn_off() diff --git a/homeassistant/components/switchbot/lock.py b/homeassistant/components/switchbot/lock.py index 6bad154813a..069b01521c4 100644 --- a/homeassistant/components/switchbot/lock.py +++ b/homeassistant/components/switchbot/lock.py @@ -11,7 +11,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator -from .entity import SwitchbotEntity +from .entity import SwitchbotEntity, exception_handler + +PARALLEL_UPDATES = 0 async def async_setup_entry( @@ -52,11 +54,13 @@ class SwitchBotLock(SwitchbotEntity, LockEntity): LockStatus.UNLOCKING_STOP, } + @exception_handler async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" self._last_run_success = await self._device.lock() self.async_write_ha_state() + @exception_handler async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" if self._attr_supported_features & (LockEntityFeature.OPEN): @@ -65,6 +69,7 @@ class SwitchBotLock(SwitchbotEntity, LockEntity): self._last_run_success = await self._device.unlock() self.async_write_ha_state() + @exception_handler async def async_open(self, **kwargs: Any) -> None: """Open the lock.""" self._last_run_success = await self._device.unlock() diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 92a1c25d6f5..064ebf5e2f4 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -32,12 +32,13 @@ "@RenierM26", "@murtas", "@Eloston", - "@dsypniewski" + "@dsypniewski", + "@zerzhang" ], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.56.0"] + "requirements": ["PySwitchbot==0.62.2"] } diff --git a/homeassistant/components/switchbot/quality_scale.yaml b/homeassistant/components/switchbot/quality_scale.yaml index 3b8976aeb8e..b8db573f405 100644 --- a/homeassistant/components/switchbot/quality_scale.yaml +++ b/homeassistant/components/switchbot/quality_scale.yaml @@ -7,7 +7,7 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: todo + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: @@ -16,7 +16,7 @@ rules: No custom actions docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: done @@ -28,16 +28,17 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done - log-when-unavailable: todo - parallel-updates: - status: todo + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt comment: | - set `PARALLEL_UPDATES` in lock.py - reauthentication-flow: todo + Once a cryptographic key is successfully obtained for SwitchBot devices, + it will be granted perpetual validity with no expiration constraints. test-coverage: status: todo comment: | @@ -54,13 +55,13 @@ rules: 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-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done - docs-supported-functions: todo + docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo + docs-use-cases: done dynamic-devices: status: exempt comment: | @@ -68,11 +69,8 @@ rules: 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 + entity-translations: done + exception-translations: done icon-translations: status: exempt comment: | diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 025c40bff9e..d68c913db15 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, @@ -71,9 +72,14 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.HUMIDITY, ), + "illuminance": SensorEntityDescription( + key="illuminance", + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ILLUMINANCE, + ), "temperature": SensorEntityDescription( key="temperature", - name=None, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index c9f93cce604..a5f502a261b 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -160,6 +160,52 @@ } } } + }, + "fan": { + "fan": { + "state_attributes": { + "last_run_success": { + "state": { + "true": "[%key:component::binary_sensor::entity_component::problem::state::off%]", + "false": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + } + }, + "preset_mode": { + "state": { + "normal": "Normal", + "natural": "Natural", + "sleep": "Sleep", + "baby": "Baby" + } + } + } + } + }, + "vacuum": { + "vacuum": { + "state_attributes": { + "last_run_success": { + "state": { + "true": "[%key:component::binary_sensor::entity_component::problem::state::off%]", + "false": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + } + } + } + } + } + }, + "exceptions": { + "operation_error": { + "message": "An error occurred while performing the action: {error}" + }, + "value_error": { + "message": "Switchbot device initialization failed because of incorrect configuration parameters: {error}" + }, + "advertising_state_error": { + "message": "{address} is not advertising state" + }, + "device_not_found_error": { + "message": "Could not find Switchbot {sensor_type} with address {address}" } } } diff --git a/homeassistant/components/switchbot/vacuum.py b/homeassistant/components/switchbot/vacuum.py new file mode 100644 index 00000000000..9dade6b7f46 --- /dev/null +++ b/homeassistant/components/switchbot/vacuum.py @@ -0,0 +1,126 @@ +"""Support for switchbot vacuums.""" + +from __future__ import annotations + +from typing import Any + +import switchbot +from switchbot import SwitchbotModel + +from homeassistant.components.vacuum import ( + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity + +PARALLEL_UPDATES = 0 + +DEVICE_SUPPORT_PROTOCOL_VERSION_1 = [ + SwitchbotModel.K10_VACUUM, + SwitchbotModel.K10_PRO_VACUUM, +] + +PROTOCOL_VERSION_1_STATE_TO_HA_STATE: dict[int, VacuumActivity] = { + 0: VacuumActivity.CLEANING, + 1: VacuumActivity.DOCKED, +} + +PROTOCOL_VERSION_2_STATE_TO_HA_STATE: dict[int, VacuumActivity] = { + 1: VacuumActivity.IDLE, # idle + 2: VacuumActivity.DOCKED, # charge + 3: VacuumActivity.DOCKED, # charge complete + 4: VacuumActivity.IDLE, # self-check + 5: VacuumActivity.IDLE, # the drum is moist + 6: VacuumActivity.CLEANING, # exploration + 7: VacuumActivity.CLEANING, # re-location + 8: VacuumActivity.CLEANING, # cleaning and sweeping + 9: VacuumActivity.CLEANING, # cleaning + 10: VacuumActivity.CLEANING, # sweeping + 11: VacuumActivity.PAUSED, # pause + 12: VacuumActivity.CLEANING, # getting out of trouble + 13: VacuumActivity.ERROR, # trouble + 14: VacuumActivity.CLEANING, # mpo cleaning + 15: VacuumActivity.RETURNING, # returning + 16: VacuumActivity.CLEANING, # deep cleaning + 17: VacuumActivity.CLEANING, # Sewage extraction + 18: VacuumActivity.CLEANING, # replenish water for mop + 19: VacuumActivity.CLEANING, # dust collection + 20: VacuumActivity.CLEANING, # dry + 21: VacuumActivity.IDLE, # dormant + 22: VacuumActivity.IDLE, # network configuration + 23: VacuumActivity.CLEANING, # remote control + 24: VacuumActivity.RETURNING, # return to base + 25: VacuumActivity.IDLE, # shut down + 26: VacuumActivity.IDLE, # mark water base station + 27: VacuumActivity.IDLE, # rinse the filter screen + 28: VacuumActivity.IDLE, # mark humidifier location + 29: VacuumActivity.IDLE, # on the way to the humidifier + 30: VacuumActivity.IDLE, # add water for humidifier + 31: VacuumActivity.IDLE, # upgrading + 32: VacuumActivity.PAUSED, # pause during recharging + 33: VacuumActivity.IDLE, # integrated with the platform + 34: VacuumActivity.CLEANING, # working for the platform +} + +SWITCHBOT_VACUUM_STATE_MAP: dict[int, dict[int, VacuumActivity]] = { + 1: PROTOCOL_VERSION_1_STATE_TO_HA_STATE, + 2: PROTOCOL_VERSION_2_STATE_TO_HA_STATE, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switchbot vacuum.""" + async_add_entities([SwitchbotVacuumEntity(entry.runtime_data)]) + + +class SwitchbotVacuumEntity(SwitchbotEntity, StateVacuumEntity): + """Representation of a SwitchBot vacuum.""" + + _device: switchbot.SwitchbotVacuum + _attr_supported_features = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.START + | VacuumEntityFeature.STATE + ) + _attr_translation_key = "vacuum" + _attr_name = None + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the Switchbot.""" + super().__init__(coordinator) + self.protocol_version = ( + 1 if coordinator.model in DEVICE_SUPPORT_PROTOCOL_VERSION_1 else 2 + ) + + @property + def activity(self) -> VacuumActivity | None: + """Return the status of the vacuum cleaner.""" + status_code = self._device.get_work_status() + return SWITCHBOT_VACUUM_STATE_MAP[self.protocol_version].get(status_code) + + @property + def battery_level(self) -> int: + """Return the vacuum battery.""" + return self._device.get_battery() + + async def async_start(self) -> None: + """Start or resume the cleaning task.""" + self._last_run_success = bool( + await self._device.clean_up(self.protocol_version) + ) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return to dock.""" + self._last_run_success = bool( + await self._device.return_to_dock(self.protocol_version) + ) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 44e130cc7a4..6f36739e2fc 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -140,11 +140,13 @@ async def make_device_data( hass, entry, api, device, coordinators_by_id ) devices_data.locks.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) if isinstance(device, Device) and device.device_type in ["Bot"]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id ) + devices_data.sensors.append((device, coordinator)) if coordinator.data is not None: if coordinator.data.get("deviceMode") == "pressMode": devices_data.buttons.append((device, coordinator)) diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py index 74adcb049c1..5eb96ed3ac8 100644 --- a/homeassistant/components/switchbot_cloud/entity.py +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -29,11 +29,15 @@ class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]): super().__init__(coordinator) self._api = api self._attr_unique_id = device.device_id + _sw_version = None + if self.coordinator.data is not None: + _sw_version = self.coordinator.data.get("version") self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.device_id)}, name=device.device_name, manufacturer="SwitchBot", model=device.device_type, + sw_version=_sw_version, ) async def send_api_command( diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 28384ffd4d5..9975bd49186 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -90,6 +90,7 @@ CO2_DESCRIPTION = SensorEntityDescription( ) SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { + "Bot": (BATTERY_DESCRIPTION,), "Meter": ( TEMPERATURE_DESCRIPTION, HUMIDITY_DESCRIPTION, @@ -133,6 +134,8 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { BATTERY_DESCRIPTION, CO2_DESCRIPTION, ), + "Smart Lock Pro": (BATTERY_DESCRIPTION,), + "Smart Lock": (BATTERY_DESCRIPTION,), } diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index 30597ed0738..efd07698eee 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -6,14 +6,10 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, cast -from aioswitcher.api import ( - DeviceState, - SwitcherApi, - SwitcherBaseResponse, - ThermostatSwing, -) +from aioswitcher.api import SwitcherApi +from aioswitcher.api.messages import SwitcherBaseResponse from aioswitcher.api.remotes import SwitcherBreezeRemote -from aioswitcher.device import DeviceCategory +from aioswitcher.device import DeviceCategory, DeviceState, ThermostatSwing from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index c8bf33eca09..1b5ac2bfc18 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -117,20 +117,15 @@ class SwitcherClimateEntity(SwitcherEntity, ClimateEntity): self._attr_supported_features |= ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - self._update_data(True) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" self._update_data() - self.async_write_ha_state() - def _update_data(self, force_update: bool = False) -> None: + def _update_data(self) -> None: """Update data from device.""" data = cast(SwitcherThermostat, self.coordinator.data) features = self._remote.modes_features[data.mode] - if data.target_temperature == 0 and not force_update: + # Ignore empty update from device that was power cycled + if data.target_temperature == 0 and self.target_temperature is not None: return self._attr_current_temperature = data.temperature diff --git a/homeassistant/components/switcher_kis/config_flow.py b/homeassistant/components/switcher_kis/config_flow.py index e6c2e8e8589..ee015cb1a25 100644 --- a/homeassistant/components/switcher_kis/config_flow.py +++ b/homeassistant/components/switcher_kis/config_flow.py @@ -6,7 +6,7 @@ from collections.abc import Mapping import logging from typing import Any, Final -from aioswitcher.bridge import SwitcherBase +from aioswitcher.device import SwitcherBase from aioswitcher.device.tools import validate_token import voluptuous as vol @@ -21,8 +21,8 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA: Final = vol.Schema( { - vol.Required(CONF_USERNAME, default=""): str, - vol.Required(CONF_TOKEN, default=""): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_TOKEN): str, } ) @@ -32,9 +32,12 @@ class SwitcherFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - username: str | None = None - token: str | None = None - discovered_devices: dict[str, SwitcherBase] = {} + def __init__(self) -> None: + """Init the config flow.""" + super().__init__() + self.discovered_devices: dict[str, SwitcherBase] = {} + self.username: str | None = None + self.token: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 5d8e4a4b0ac..c0ab90e1268 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -69,12 +69,6 @@ class SwitcherBaseCoverEntity(SwitcherEntity, CoverEntity): ) _cover_id: int - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._update_data() - self.async_write_ha_state() - def _update_data(self) -> None: """Update data from device.""" data = cast(SwitcherShutter, self.coordinator.data) diff --git a/homeassistant/components/switcher_kis/entity.py b/homeassistant/components/switcher_kis/entity.py index 82b892d548d..0cd56d2c462 100644 --- a/homeassistant/components/switcher_kis/entity.py +++ b/homeassistant/components/switcher_kis/entity.py @@ -6,6 +6,7 @@ from typing import Any from aioswitcher.api import SwitcherApi from aioswitcher.api.messages import SwitcherBaseResponse +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -28,6 +29,15 @@ class SwitcherEntity(CoordinatorEntity[SwitcherDataUpdateCoordinator]): connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_data() + super()._handle_coordinator_update() + + def _update_data(self) -> None: + """Update data from device.""" + 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) diff --git a/homeassistant/components/switcher_kis/icons.json b/homeassistant/components/switcher_kis/icons.json index bd770d3e656..6ca8e0e8351 100644 --- a/homeassistant/components/switcher_kis/icons.json +++ b/homeassistant/components/switcher_kis/icons.json @@ -20,9 +20,6 @@ }, "auto_shutdown": { "default": "mdi:progress-clock" - }, - "temperature": { - "default": "mdi:thermometer" } } }, diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py index b9dc78f5bdf..77e2a8cdd97 100644 --- a/homeassistant/components/switcher_kis/light.py +++ b/homeassistant/components/switcher_kis/light.py @@ -59,31 +59,37 @@ class SwitcherBaseLightEntity(SwitcherEntity, LightEntity): control_result: bool | None = None _light_id: int - @callback - def _handle_coordinator_update(self) -> None: - """When device updates, clear control result that overrides state.""" - self.control_result = None - self.async_write_ha_state() + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + light_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._light_id = light_id + self.control_result: bool | None = None + self._update_data() - @property - def is_on(self) -> bool: - """Return True if entity is on.""" + def _update_data(self) -> None: + """Update data from device.""" if self.control_result is not None: - return self.control_result + self._attr_is_on = self.control_result + self.control_result = None + return data = cast(SwitcherLight, self.coordinator.data) - return bool(data.light[self._light_id] == DeviceState.ON) + self._attr_is_on = bool(data.light[self._light_id] == DeviceState.ON) 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) - self.control_result = True + self._attr_is_on = self.control_result = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" await self._async_call_api(API_SET_LIGHT, DeviceState.OFF, self._light_id) - self.control_result = False + self._attr_is_on = self.control_result = False self.async_write_ha_state() @@ -98,11 +104,7 @@ class SwitcherSingleLightEntity(SwitcherBaseLightEntity): light_id: int, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) - self._light_id = light_id - self.control_result: bool | None = None - - # Entity class attributes + super().__init__(coordinator, light_id) self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" @@ -117,11 +119,7 @@ class SwitcherMultiLightEntity(SwitcherBaseLightEntity): light_id: int, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) - self._light_id = light_id - self.control_result: bool | None = None - - # Entity class attributes + super().__init__(coordinator, light_id) self._attr_translation_placeholders = {"light_id": str(light_id + 1)} self._attr_unique_id = ( f"{coordinator.device_id}-{coordinator.mac_address}-{light_id}" diff --git a/homeassistant/components/switcher_kis/quality_scale.yaml b/homeassistant/components/switcher_kis/quality_scale.yaml new file mode 100644 index 00000000000..88f82f270d5 --- /dev/null +++ b/homeassistant/components/switcher_kis/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration uses entity services. + appropriate-polling: + status: exempt + comment: The integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: + status: todo + comment: make sure flows end with created entry or abort + 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: done + runtime-data: todo + test-before-configure: done + test-before-setup: + status: exempt + comment: devices are setup asynchronously and marked as unavailable until they are ready. + unique-config-entry: + status: exempt + comment: The integration only supports a single config entry. + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + 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: + status: exempt + comment: There is no option to discover devices without adding the integration. + docs-data-update: todo + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: + status: todo + comment: Migrate time sensors to timestamp or a duration device class + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: + status: exempt + comment: The integration does not have anything to reconfigure. + repair-issues: + status: exempt + comment: The integration does not have any issues to repair. + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: + status: todo + comment: validate_token method does not allow to pass websession + strict-typing: done diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 029d517bb09..e918b8eb4c1 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -2,7 +2,17 @@ from __future__ import annotations -from aioswitcher.device import DeviceCategory +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast + +from aioswitcher.device import ( + DeviceCategory, + SwitcherBase, + SwitcherPowerBase, + SwitcherThermostatBase, + SwitcherTimedBase, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -11,7 +21,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfElectricCurrent, UnitOfPower +from homeassistant.const import UnitOfElectricCurrent, UnitOfPower, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -21,35 +31,50 @@ from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator from .entity import SwitcherEntity -POWER_SENSORS: list[SensorEntityDescription] = [ - SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class SwitcherSensorEntityDescription(SensorEntityDescription): + """Class to describe a Switcher sensor entity.""" + + value_fn: Callable[[SwitcherBase], StateType] + + +POWER_SENSORS: list[SwitcherSensorEntityDescription] = [ + SwitcherSensorEntityDescription( key="power_consumption", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(SwitcherPowerBase, data).power_consumption, ), - SensorEntityDescription( + SwitcherSensorEntityDescription( key="electric_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(SwitcherPowerBase, data).electric_current, ), ] -TIME_SENSORS: list[SensorEntityDescription] = [ - SensorEntityDescription( +TIME_SENSORS: list[SwitcherSensorEntityDescription] = [ + SwitcherSensorEntityDescription( key="remaining_time", translation_key="remaining_time", + value_fn=lambda data: cast(SwitcherTimedBase, data).remaining_time, ), - SensorEntityDescription( + SwitcherSensorEntityDescription( key="auto_off_set", translation_key="auto_shutdown", entity_registry_enabled_default=False, + value_fn=lambda data: cast(SwitcherTimedBase, data).auto_shutdown, ), ] -TEMPERATURE_SENSORS: list[SensorEntityDescription] = [ - SensorEntityDescription( +TEMPERATURE_SENSORS: list[SwitcherSensorEntityDescription] = [ + SwitcherSensorEntityDescription( key="temperature", - translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(SwitcherThermostatBase, data).temperature, ), ] @@ -95,11 +120,11 @@ class SwitcherSensorEntity(SwitcherEntity, SensorEntity): def __init__( self, coordinator: SwitcherDataUpdateCoordinator, - description: SensorEntityDescription, + description: SwitcherSensorEntityDescription, ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self.entity_description = description + self.entity_description: SwitcherSensorEntityDescription = description self._attr_unique_id = ( f"{coordinator.device_id}-{coordinator.mac_address}-{description.key}" @@ -108,4 +133,4 @@ class SwitcherSensorEntity(SwitcherEntity, SensorEntity): @property def native_value(self) -> StateType: """Return value of sensor.""" - return getattr(self.coordinator.data, self.entity_description.key) # type: ignore[no-any-return] + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index e380711303d..5eece295aa8 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -9,13 +9,21 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "username": "The email address used to sign in to the Switcher app.", + "token": "The local control token received from Switcher." } }, "reauth_confirm": { - "description": "Found a Switcher device that requires a token\nEnter your username and token\nFor more information see https://www.home-assistant.io/integrations/switcher_kis/#prerequisites", + "description": "[%key:component::switcher_kis::config::step::credentials::description%]", "data": { "username": "[%key:common::config_flow::data::username%]", "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "username": "[%key:component::switcher_kis::config::step::credentials::data_description::username%]", + "token": "[%key:component::switcher_kis::config::step::credentials::data_description::token%]" } } }, @@ -59,9 +67,6 @@ }, "auto_shutdown": { "name": "Auto shutdown" - }, - "temperature": { - "name": "Current temperature" } }, "switch": { @@ -75,11 +80,11 @@ }, "services": { "set_auto_off": { - "name": "Set auto off", - "description": "Updates Switcher device auto off setting.", + "name": "Set auto-off", + "description": "Updates Switcher device auto-off setting.", "fields": { "auto_off": { - "name": "Auto off", + "name": "Auto-off", "description": "Time period string containing hours and minutes." } } @@ -90,7 +95,7 @@ "fields": { "timer_minutes": { "name": "Timer", - "description": "Time to turn on." + "description": "Duration to turn on the Switcher." } } } diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 30b0b4161b1..1e602061c2c 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -6,8 +6,13 @@ from datetime import timedelta import logging from typing import Any, cast -from aioswitcher.api import Command, ShutterChildLock -from aioswitcher.device import DeviceCategory, DeviceState, SwitcherShutter +from aioswitcher.api import Command +from aioswitcher.device import ( + DeviceCategory, + DeviceState, + ShutterChildLock, + SwitcherShutter, +) import voluptuous as vol from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity @@ -83,11 +88,11 @@ async def async_setup_entry( number_of_covers = len(cast(SwitcherShutter, coordinator.data).position) if number_of_covers == 1: entities.append( - SwitchereShutterChildLockSingleSwitchEntity(coordinator, 0) + SwitcherShutterChildLockSingleSwitchEntity(coordinator, 0) ) else: entities.extend( - SwitchereShutterChildLockMultiSwitchEntity(coordinator, i) + SwitcherShutterChildLockMultiSwitchEntity(coordinator, i) for i in range(number_of_covers) ) async_add_entities(entities) @@ -106,34 +111,28 @@ class SwitcherBaseSwitchEntity(SwitcherEntity, SwitchEntity): """Initialize the entity.""" super().__init__(coordinator) self.control_result: bool | None = None - - # Entity class attributes self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" + self._update_data() - @callback - def _handle_coordinator_update(self) -> None: - """When device updates, clear control result that overrides state.""" - self.control_result = None - self.async_write_ha_state() - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" + def _update_data(self) -> None: + """Update data from device.""" if self.control_result is not None: - return self.control_result + self._attr_is_on = self.control_result + self.control_result = None + return - return bool(self.coordinator.data.device_state == DeviceState.ON) + self._attr_is_on = bool(self.coordinator.data.device_state == DeviceState.ON) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._async_call_api(API_CONTROL_DEVICE, Command.ON) - self.control_result = True + self._attr_is_on = 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_CONTROL_DEVICE, Command.OFF) - self.control_result = False + self._attr_is_on = self.control_result = False self.async_write_ha_state() async def async_set_auto_off_service(self, auto_off: timedelta) -> None: @@ -172,44 +171,45 @@ class SwitcherWaterHeaterSwitchEntity(SwitcherBaseSwitchEntity): async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None: """Use for turning device on with a timer service calls.""" await self._async_call_api(API_CONTROL_DEVICE, Command.ON, timer_minutes) - self.control_result = True + self._attr_is_on = self.control_result = True self.async_write_ha_state() -class SwitchereShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): - """Representation of a Switcher shutter base switch entity.""" +class SwitcherShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): + """Representation of a Switcher child lock 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: + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: """Initialize the entity.""" super().__init__(coordinator) + self._cover_id = cover_id self.control_result: bool | None = None + self._update_data() - @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.""" + def _update_data(self) -> None: + """Update data from device.""" if self.control_result is not None: - return self.control_result + self._attr_is_on = self.control_result + self.control_result = None + return data = cast(SwitcherShutter, self.coordinator.data) - return bool(data.child_lock[self._cover_id] == ShutterChildLock.ON) + self._attr_is_on = 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._attr_is_on = self.control_result = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -217,12 +217,12 @@ class SwitchereShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): await self._async_call_api( API_SET_CHILD_LOCK, ShutterChildLock.OFF, self._cover_id ) - self.control_result = False + self._attr_is_on = self.control_result = False self.async_write_ha_state() -class SwitchereShutterChildLockSingleSwitchEntity( - SwitchereShutterChildLockBaseSwitchEntity +class SwitcherShutterChildLockSingleSwitchEntity( + SwitcherShutterChildLockBaseSwitchEntity ): """Representation of a Switcher runner child lock single switch entity.""" @@ -234,16 +234,14 @@ class SwitchereShutterChildLockSingleSwitchEntity( cover_id: int, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) - self._cover_id = cover_id - + super().__init__(coordinator, cover_id) self._attr_unique_id = ( f"{coordinator.device_id}-{coordinator.mac_address}-child_lock" ) -class SwitchereShutterChildLockMultiSwitchEntity( - SwitchereShutterChildLockBaseSwitchEntity +class SwitcherShutterChildLockMultiSwitchEntity( + SwitcherShutterChildLockBaseSwitchEntity ): """Representation of a Switcher runner child lock multiple switch entity.""" @@ -255,8 +253,7 @@ class SwitchereShutterChildLockMultiSwitchEntity( cover_id: int, ) -> None: """Initialize the entity.""" - super().__init__(coordinator) - self._cover_id = cover_id + super().__init__(coordinator, cover_id) self._attr_translation_placeholders = {"cover_id": str(cover_id + 1)} self._attr_unique_id = ( diff --git a/homeassistant/components/switcher_kis/utils.py b/homeassistant/components/switcher_kis/utils.py index 50bfb883e6c..44f906aef44 100644 --- a/homeassistant/components/switcher_kis/utils.py +++ b/homeassistant/components/switcher_kis/utils.py @@ -6,7 +6,8 @@ import asyncio import logging from aioswitcher.api.remotes import SwitcherBreezeRemoteManager -from aioswitcher.bridge import SwitcherBase, SwitcherBridge +from aioswitcher.bridge import SwitcherBridge +from aioswitcher.device import SwitcherBase from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import singleton diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index 697ea8aea6e..d6ad17969db 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -111,7 +111,7 @@ class FolderSensor(SensorEntity): return self._state["state"] @property - def available(self): + def available(self) -> bool: """Could the device be accessed during the last update call.""" return self._state is not None diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index 2817f4c21ce..f514f538821 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -2,100 +2,31 @@ from __future__ import annotations -import asyncio -from datetime import timedelta -import logging +from pysyncthru import SyncThruAPINotSupported -from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, device_registry as dr -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import SyncThruConfigEntry, SyncthruCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SyncThruConfigEntry) -> bool: """Set up config entry.""" - session = aiohttp_client.async_get_clientsession(hass) - hass.data.setdefault(DOMAIN, {}) - printer = SyncThru( - entry.data[CONF_URL], session, connection_mode=ConnectionMode.API - ) - - async def async_update_data() -> SyncThru: - """Fetch data from the printer.""" - try: - async with asyncio.timeout(10): - await printer.update() - except SyncThruAPINotSupported as api_error: - # if an exception is thrown, printer does not support syncthru - _LOGGER.debug( - "Configured printer at %s does not provide SyncThru JSON API", - printer.url, - exc_info=api_error, - ) - raise - - # if the printer is offline, we raise an UpdateFailed - if printer.is_unknown_state(): - raise UpdateFailed(f"Configured printer at {printer.url} does not respond.") - return printer - - coordinator = DataUpdateCoordinator[SyncThru]( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=async_update_data, - update_interval=timedelta(seconds=30), - ) + coordinator = SyncthruCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator if isinstance(coordinator.last_exception, SyncThruAPINotSupported): # this means that the printer does not support the syncthru JSON API # and the config should simply be discarded return False - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - configuration_url=printer.url, - connections=device_connections(printer), - manufacturer="Samsung", - identifiers=device_identifiers(printer), - model=printer.model(), - name=printer.hostname(), - ) - 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: SyncThruConfigEntry) -> bool: """Unload the config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN].pop(entry.entry_id, None) - return unload_ok - - -def device_identifiers(printer: SyncThru) -> set[tuple[str, str]] | None: - """Get device identifiers for device registry.""" - serial = printer.serial_number() - if serial is None: - return None - return {(DOMAIN, serial)} - - -def device_connections(printer: SyncThru) -> set[tuple[str, str]]: - """Get device connections for device registry.""" - if mac := printer.raw().get("identity", {}).get("mac_addr"): - return {(dr.CONNECTION_NETWORK_MAC, mac)} - return set() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index e6d26d22433..56edff38680 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -2,24 +2,21 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from pysyncthru import SyncThru, SyncthruState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) -from . import device_identifiers -from .const import DOMAIN +from .coordinator import SyncThruConfigEntry +from .entity import SyncthruEntity SYNCTHRU_STATE_PROBLEM = { SyncthruState.INVALID: True, @@ -32,81 +29,47 @@ SYNCTHRU_STATE_PROBLEM = { } +@dataclass(frozen=True, kw_only=True) +class SyncThruBinarySensorDescription(BinarySensorEntityDescription): + """Describes Syncthru binary sensor entities.""" + + value_fn: Callable[[SyncThru], bool | None] + + +BINARY_SENSORS: tuple[SyncThruBinarySensorDescription, ...] = ( + SyncThruBinarySensorDescription( + key="online", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=lambda printer: printer.is_online(), + ), + SyncThruBinarySensorDescription( + key="problem", + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda printer: SYNCTHRU_STATE_PROBLEM[printer.device_status()], + ), +) + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SyncThruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - coordinator: DataUpdateCoordinator[SyncThru] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data - name: str = config_entry.data[CONF_NAME] - entities = [ - SyncThruOnlineSensor(coordinator, name), - SyncThruProblemSensor(coordinator, name), - ] - - async_add_entities(entities) + async_add_entities( + SyncThruBinarySensor(coordinator, description) for description in BINARY_SENSORS + ) -class SyncThruBinarySensor( - CoordinatorEntity[DataUpdateCoordinator[SyncThru]], BinarySensorEntity -): +class SyncThruBinarySensor(SyncthruEntity, BinarySensorEntity): """Implementation of an abstract Samsung Printer binary sensor platform.""" - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.syncthru = coordinator.data - self._attr_name = name - self._id_suffix = "" + entity_description: SyncThruBinarySensorDescription @property - def unique_id(self): - """Return unique ID for the sensor.""" - serial = self.syncthru.serial_number() - return f"{serial}{self._id_suffix}" if serial else None - - @property - def device_info(self) -> DeviceInfo | None: - """Return device information.""" - if (identifiers := device_identifiers(self.syncthru)) is None: - return None - return DeviceInfo( - identifiers=identifiers, - ) - - -class SyncThruOnlineSensor(SyncThruBinarySensor): - """Implementation of a sensor that checks whether is turned on/online.""" - - _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._id_suffix = "_online" - - @property - def is_on(self): - """Set the state to whether the printer is online.""" - return self.syncthru.is_online() - - -class SyncThruProblemSensor(SyncThruBinarySensor): - """Implementation of a sensor that checks whether the printer works correctly.""" - - _attr_device_class = BinarySensorDeviceClass.PROBLEM - - def __init__(self, syncthru, name): - """Initialize the sensor.""" - super().__init__(syncthru, name) - self._id_suffix = "_problem" - - @property - def is_on(self): - """Set the state to whether there is a problem with the printer.""" - return SYNCTHRU_STATE_PROBLEM[self.syncthru.device_status()] + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index 1407814f838..c245b181cc2 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -1,7 +1,7 @@ """Config flow for Samsung SyncThru.""" import re -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported @@ -44,12 +44,14 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN): 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( - ATTR_UPNP_PRESENTATION_URL, - f"http://{urlparse(discovery_info.ssdp_location or '').hostname}/", - ) + norm_url = url_normalize( + discovery_info.upnp.get(ATTR_UPNP_PRESENTATION_URL) + or f"http://{urlparse(discovery_info.ssdp_location or '').hostname}/" ) + if TYPE_CHECKING: + # url_normalize only returns None if passed None, and we don't do that + assert norm_url is not None + self.url = norm_url for existing_entry in ( x for x in self._async_current_entries() if x.data[CONF_URL] == self.url diff --git a/homeassistant/components/syncthru/coordinator.py b/homeassistant/components/syncthru/coordinator.py new file mode 100644 index 00000000000..0b96b354436 --- /dev/null +++ b/homeassistant/components/syncthru/coordinator.py @@ -0,0 +1,46 @@ +"""Coordinator for Syncthru integration.""" + +import asyncio +from datetime import timedelta +import logging + +from pysyncthru import ConnectionMode, SyncThru + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +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 = logging.getLogger(__name__) + +type SyncThruConfigEntry = ConfigEntry[SyncthruCoordinator] + + +class SyncthruCoordinator(DataUpdateCoordinator[SyncThru]): + """Class to manage fetching Syncthru data.""" + + def __init__(self, hass: HomeAssistant, entry: SyncThruConfigEntry) -> None: + """Initialize the Syncthru coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.syncthru = SyncThru( + entry.data[CONF_URL], + async_get_clientsession(hass), + connection_mode=ConnectionMode.API, + ) + + async def _async_update_data(self) -> SyncThru: + async with asyncio.timeout(10): + await self.syncthru.update() + if self.syncthru.is_unknown_state(): + raise UpdateFailed( + f"Configured printer at {self.syncthru.url} does not respond." + ) + return self.syncthru diff --git a/homeassistant/components/rtsp_to_webrtc/diagnostics.py b/homeassistant/components/syncthru/diagnostics.py similarity index 51% rename from homeassistant/components/rtsp_to_webrtc/diagnostics.py rename to homeassistant/components/syncthru/diagnostics.py index ab13e0a64ee..169d354ef76 100644 --- a/homeassistant/components/rtsp_to_webrtc/diagnostics.py +++ b/homeassistant/components/syncthru/diagnostics.py @@ -1,17 +1,17 @@ -"""Diagnostics support for Nest.""" +"""Diagnostics support for Syncthru.""" from __future__ import annotations from typing import Any -from rtsp_to_webrtc import client - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from .coordinator import SyncThruConfigEntry + async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, entry: SyncThruConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return dict(client.get_diagnostics()) + + return entry.runtime_data.data.raw() diff --git a/homeassistant/components/syncthru/entity.py b/homeassistant/components/syncthru/entity.py new file mode 100644 index 00000000000..3f1aecbf0d4 --- /dev/null +++ b/homeassistant/components/syncthru/entity.py @@ -0,0 +1,36 @@ +"""Base class for Syncthru entities.""" + +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 + +from .const import DOMAIN +from .coordinator import SyncthruCoordinator + + +class SyncthruEntity(CoordinatorEntity[SyncthruCoordinator]): + """Base class for Syncthru entities.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: SyncthruCoordinator, entity_description: EntityDescription + ) -> None: + """Initialize the Syncthru entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + serial_number = coordinator.syncthru.serial_number() + assert serial_number is not None + self._attr_unique_id = f"{serial_number}_{entity_description.key}" + connections = set() + if mac := coordinator.syncthru.raw().get("identity", {}).get("mac_addr"): + connections.add((dr.CONNECTION_NETWORK_MAC, mac)) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + connections=connections, + configuration_url=coordinator.syncthru.url, + manufacturer="Samsung", + model=coordinator.syncthru.model(), + name=coordinator.syncthru.hostname(), + ) diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index 461ce9bfd3a..a33cefd2c70 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.8.0", "url-normalize==1.4.3"], + "requirements": ["PySyncThru==0.8.0", "url-normalize==2.2.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/homeassistant/components/syncthru/quality_scale.yaml b/homeassistant/components/syncthru/quality_scale.yaml new file mode 100644 index 00000000000..bc65d0828ea --- /dev/null +++ b/homeassistant/components/syncthru/quality_scale.yaml @@ -0,0 +1,86 @@ +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: todo + config-flow: todo + 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: todo + config-entry-unloading: done + 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 + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: todo + comment: DHCP or zeroconf is still possible + discovery: + status: todo + comment: DHCP or zeroconf is still 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: + status: exempt + comment: | + This integration has a fixed single device. + 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: + 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: todo diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index c2063bf6c0a..569bf65f37d 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -2,32 +2,19 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, cast + from pysyncthru import SyncThru, SyncthruState -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, PERCENTAGE +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) -from . import device_identifiers -from .const import DOMAIN - -COLORS = ["black", "cyan", "magenta", "yellow"] -DRUM_COLORS = COLORS -TONER_COLORS = COLORS -TRAYS = range(1, 6) -OUTPUT_TRAYS = range(6) -DEFAULT_MONITORED_CONDITIONS = [] -DEFAULT_MONITORED_CONDITIONS.extend([f"toner_{key}" for key in TONER_COLORS]) -DEFAULT_MONITORED_CONDITIONS.extend([f"drum_{key}" for key in DRUM_COLORS]) -DEFAULT_MONITORED_CONDITIONS.extend([f"tray_{key}" for key in TRAYS]) -DEFAULT_MONITORED_CONDITIONS.extend([f"output_tray_{key}" for key in OUTPUT_TRAYS]) +from .coordinator import SyncThruConfigEntry +from .entity import SyncthruEntity SYNCTHRU_STATE_HUMAN = { SyncthruState.INVALID: "invalid", @@ -40,212 +27,141 @@ SYNCTHRU_STATE_HUMAN = { } +@dataclass(frozen=True, kw_only=True) +class SyncThruSensorDescription(SensorEntityDescription): + """Describes a SyncThru sensor entity.""" + + value_fn: Callable[[SyncThru], str | None] + extra_state_attributes_fn: Callable[[SyncThru], dict[str, str | int]] | None = None + + +def get_toner_entity_description(color: str) -> SyncThruSensorDescription: + """Get toner entity description for a specific color.""" + return SyncThruSensorDescription( + key=f"toner_{color}", + translation_key=f"toner_{color}", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda printer: printer.toner_status().get(color, {}).get("remaining"), + extra_state_attributes_fn=lambda printer: printer.toner_status().get(color, {}), + ) + + +def get_drum_entity_description(color: str) -> SyncThruSensorDescription: + """Get drum entity description for a specific color.""" + return SyncThruSensorDescription( + key=f"drum_{color}", + translation_key=f"drum_{color}", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda printer: printer.drum_status().get(color, {}).get("remaining"), + extra_state_attributes_fn=lambda printer: printer.drum_status().get(color, {}), + ) + + +def get_input_tray_entity_description(tray: str) -> SyncThruSensorDescription: + """Get input tray entity description for a specific tray.""" + placeholders = {} + translation_key = f"tray_{tray}" + if "_" in tray: + _, identifier = tray.split("_") + placeholders["tray_number"] = identifier + translation_key = "tray" + return SyncThruSensorDescription( + key=f"tray_{tray}", + translation_key=translation_key, + entity_category=EntityCategory.DIAGNOSTIC, + translation_placeholders=placeholders, + value_fn=( + lambda printer: printer.input_tray_status().get(tray, {}).get("newError") + or "Ready" + ), + extra_state_attributes_fn=( + lambda printer: printer.input_tray_status().get(tray, {}) + ), + ) + + +def get_output_tray_entity_description(tray: int) -> SyncThruSensorDescription: + """Get output tray entity description for a specific tray.""" + return SyncThruSensorDescription( + key=f"output_tray_{tray}", + translation_key="output_tray", + entity_category=EntityCategory.DIAGNOSTIC, + translation_placeholders={"tray_number": str(tray)}, + value_fn=( + lambda printer: printer.output_tray_status().get(tray, {}).get("status") + or "Ready" + ), + extra_state_attributes_fn=( + lambda printer: cast( + dict[str, str | int], printer.output_tray_status().get(tray, {}) + ) + ), + ) + + +SENSOR_TYPES: tuple[SyncThruSensorDescription, ...] = ( + SyncThruSensorDescription( + key="active_alerts", + translation_key="active_alerts", + value_fn=lambda printer: printer.raw().get("GXI_ACTIVE_ALERT_TOTAL"), + ), + SyncThruSensorDescription( + key="main", + name=None, + value_fn=lambda printer: SYNCTHRU_STATE_HUMAN[printer.device_status()], + extra_state_attributes_fn=lambda printer: { + "display_text": printer.device_status_details(), + }, + ), +) + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SyncThruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - coordinator: DataUpdateCoordinator[SyncThru] = hass.data[DOMAIN][ - config_entry.entry_id - ] - printer: SyncThru = coordinator.data + coordinator = config_entry.runtime_data + printer = coordinator.data supp_toner = printer.toner_status(filter_supported=True) supp_drum = printer.drum_status(filter_supported=True) supp_tray = printer.input_tray_status(filter_supported=True) supp_output_tray = printer.output_tray_status() - name: str = config_entry.data[CONF_NAME] - entities: list[SyncThruSensor] = [ - SyncThruMainSensor(coordinator, name), - SyncThruActiveAlertSensor(coordinator, name), + entities: list[SyncThruSensorDescription] = [ + get_toner_entity_description(color) for color in supp_toner ] - entities.extend(SyncThruTonerSensor(coordinator, name, key) for key in supp_toner) - entities.extend(SyncThruDrumSensor(coordinator, name, key) for key in supp_drum) - entities.extend( - SyncThruInputTraySensor(coordinator, name, key) for key in supp_tray - ) - entities.extend( - SyncThruOutputTraySensor(coordinator, name, int_key) - for int_key in supp_output_tray + entities.extend(get_drum_entity_description(color) for color in supp_drum) + entities.extend(get_input_tray_entity_description(key) for key in supp_tray) + entities.extend(get_output_tray_entity_description(key) for key in supp_output_tray) + + async_add_entities( + SyncThruSensor(coordinator, description) + for description in SENSOR_TYPES + tuple(entities) ) - async_add_entities(entities) - -class SyncThruSensor(CoordinatorEntity[DataUpdateCoordinator[SyncThru]], SensorEntity): +class SyncThruSensor(SyncthruEntity, SensorEntity): """Implementation of an abstract Samsung Printer sensor platform.""" _attr_icon = "mdi:printer" - - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.syncthru = coordinator.data - self._attr_name = name - self._id_suffix = "" + entity_description: SyncThruSensorDescription @property - def unique_id(self): - """Return unique ID for the sensor.""" - serial = self.syncthru.serial_number() - return f"{serial}{self._id_suffix}" if serial else None + def native_value(self) -> str | int | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) @property - def device_info(self) -> DeviceInfo | None: - """Return device information.""" - if (identifiers := device_identifiers(self.syncthru)) is None: - return None - return DeviceInfo( - identifiers=identifiers, - ) - - -class SyncThruMainSensor(SyncThruSensor): - """Implementation of the main sensor, conducting the actual polling. - - It also shows the detailed state and presents - the displayed current status message. - """ - - _attr_entity_registry_enabled_default = False - - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._id_suffix = "_main" - - @property - def native_value(self): - """Set state to human readable version of syncthru status.""" - return SYNCTHRU_STATE_HUMAN[self.syncthru.device_status()] - - @property - def extra_state_attributes(self): - """Show current printer display text.""" - return { - "display_text": self.syncthru.device_status_details(), - } - - -class SyncThruTonerSensor(SyncThruSensor): - """Implementation of a Samsung Printer toner sensor platform.""" - - _attr_native_unit_of_measurement = PERCENTAGE - - def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, color: str - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Toner {color}" - self._color = color - self._id_suffix = f"_toner_{color}" - - @property - def extra_state_attributes(self): - """Show all data returned for this toner.""" - return self.syncthru.toner_status().get(self._color, {}) - - @property - def native_value(self): - """Show amount of remaining toner.""" - return self.syncthru.toner_status().get(self._color, {}).get("remaining") - - -class SyncThruDrumSensor(SyncThruSensor): - """Implementation of a Samsung Printer drum sensor platform.""" - - _attr_native_unit_of_measurement = PERCENTAGE - - def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, color: str - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Drum {color}" - self._color = color - self._id_suffix = f"_drum_{color}" - - @property - def extra_state_attributes(self): - """Show all data returned for this drum.""" - return self.syncthru.drum_status().get(self._color, {}) - - @property - def native_value(self): - """Show amount of remaining drum.""" - return self.syncthru.drum_status().get(self._color, {}).get("remaining") - - -class SyncThruInputTraySensor(SyncThruSensor): - """Implementation of a Samsung Printer input tray sensor platform.""" - - def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, number: str - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Tray {number}" - self._number = number - self._id_suffix = f"_tray_{number}" - - @property - def extra_state_attributes(self): - """Show all data returned for this input tray.""" - return self.syncthru.input_tray_status().get(self._number, {}) - - @property - def native_value(self): - """Display ready unless there is some error, then display error.""" - tray_state = ( - self.syncthru.input_tray_status().get(self._number, {}).get("newError") - ) - if tray_state == "": - tray_state = "Ready" - return tray_state - - -class SyncThruOutputTraySensor(SyncThruSensor): - """Implementation of a Samsung Printer output tray sensor platform.""" - - def __init__( - self, coordinator: DataUpdateCoordinator[SyncThru], name: str, number: int - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Output Tray {number}" - self._number = number - self._id_suffix = f"_output_tray_{number}" - - @property - def extra_state_attributes(self): - """Show all data returned for this output tray.""" - return self.syncthru.output_tray_status().get(self._number, {}) - - @property - def native_value(self): - """Display ready unless there is some error, then display error.""" - tray_state = ( - self.syncthru.output_tray_status().get(self._number, {}).get("status") - ) - if tray_state == "": - tray_state = "Ready" - return tray_state - - -class SyncThruActiveAlertSensor(SyncThruSensor): - """Implementation of a Samsung Printer active alerts sensor platform.""" - - def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._attr_name = f"{name} Active Alerts" - self._id_suffix = "_active_alerts" - - @property - def native_value(self): - """Show number of active alerts.""" - return self.syncthru.raw().get("GXI_ACTIVE_ALERT_TOTAL") + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes.""" + if self.entity_description.extra_state_attributes_fn: + return self.entity_description.extra_state_attributes_fn( + self.coordinator.data + ) + return None diff --git a/homeassistant/components/syncthru/strings.json b/homeassistant/components/syncthru/strings.json index c4087bdee04..d78d51db86d 100644 --- a/homeassistant/components/syncthru/strings.json +++ b/homeassistant/components/syncthru/strings.json @@ -23,5 +23,49 @@ } } } + }, + "entity": { + "sensor": { + "toner_black": { + "name": "Black toner level" + }, + "toner_cyan": { + "name": "Cyan toner level" + }, + "toner_magenta": { + "name": "Magenta toner level" + }, + "toner_yellow": { + "name": "Yellow toner level" + }, + "drum_black": { + "name": "Black drum level" + }, + "drum_cyan": { + "name": "Cyan drum level" + }, + "drum_magenta": { + "name": "Magenta drum level" + }, + "drum_yellow": { + "name": "Yellow drum level" + }, + "tray_mp": { + "name": "Multi-purpose tray" + }, + "tray_manual": { + "name": "Manual feed tray" + }, + "tray": { + "name": "Input tray {tray_number}" + }, + "output_tray": { + "name": "Output tray {tray_number}" + }, + "active_alerts": { + "name": "Active alerts", + "unit_of_measurement": "alerts" + } + } } } diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 1b26b7df84d..d9319beb595 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -9,7 +9,6 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import SynologyDSMNotLoggedInException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -31,15 +30,16 @@ from .const import ( from .coordinator import ( SynologyDSMCameraUpdateCoordinator, SynologyDSMCentralUpdateCoordinator, + SynologyDSMConfigEntry, + SynologyDSMData, SynologyDSMSwitchUpdateCoordinator, ) -from .models import SynologyDSMData from .service import async_setup_services _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) -> bool: """Set up Synology DSM sensors.""" # Migrate device identifiers @@ -120,13 +120,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SYNOLOGY_CONNECTION_EXCEPTIONS as ex: raise ConfigEntryNotReady from ex - synology_data = SynologyDSMData( + entry.runtime_data = SynologyDSMData( api=api, coordinator_central=coordinator_central, + coordinator_central_old_update_success=True, coordinator_cameras=coordinator_cameras, coordinator_switches=coordinator_switches, ) - hass.data.setdefault(DOMAIN, {})[entry.unique_id] = synology_data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -140,28 +140,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_state_change(async_notify_backup_listeners) ) + def async_check_last_update_success() -> None: + if ( + last := coordinator_central.last_update_success + ) is not entry.runtime_data.coordinator_central_old_update_success: + entry.runtime_data.coordinator_central_old_update_success = last + async_notify_backup_listeners() + + entry.runtime_data.coordinator_central.async_add_listener( + async_check_last_update_success + ) + return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SynologyDSMConfigEntry +) -> bool: """Unload Synology DSM sensors.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + entry_data = entry.runtime_data await entry_data.api.async_unload() - hass.data[DOMAIN].pop(entry.unique_id) return unload_ok -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: SynologyDSMConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) async def async_remove_config_entry_device( - hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: SynologyDSMConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove synology_dsm config entry from a device.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api assert api.information is not None serial = api.information.serial diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index 670c4c9bef0..46e47ebde16 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -17,7 +17,6 @@ from homeassistant.components.backup import ( BackupNotFound, suggested_filename, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator from homeassistant.helpers.json import json_dumps @@ -29,7 +28,7 @@ from .const import ( DATA_BACKUP_AGENT_LISTENERS, DOMAIN, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -47,19 +46,19 @@ async def async_get_backup_agents( hass: HomeAssistant, ) -> list[BackupAgent]: """Return a list of backup agents.""" - if not ( - entries := hass.config_entries.async_loaded_entries(DOMAIN) - ) or not hass.data.get(DOMAIN): + entries: list[SynologyDSMConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + if not entries: LOGGER.debug("No proper config entry found") return [] - syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN] return [ SynologyDSMBackupAgent(hass, entry, entry.unique_id) for entry in entries if entry.unique_id is not None - and (syno_data := syno_datas.get(entry.unique_id)) - and syno_data.api.file_station + and entry.runtime_data.api.file_station and entry.options.get(CONF_BACKUP_PATH) + and entry.runtime_data.coordinator_central.last_update_success ] @@ -91,7 +90,9 @@ class SynologyDSMBackupAgent(BackupAgent): domain = DOMAIN - def __init__(self, hass: HomeAssistant, entry: ConfigEntry, unique_id: str) -> None: + def __init__( + self, hass: HomeAssistant, entry: SynologyDSMConfigEntry, unique_id: str + ) -> None: """Initialize the Synology DSM backup agent.""" super().__init__() LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id) @@ -100,7 +101,7 @@ class SynologyDSMBackupAgent(BackupAgent): self.path = ( f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}" ) - syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + syno_data = entry.runtime_data self.api = syno_data.api self.backup_base_names: dict[str, str] = {} @@ -120,8 +121,7 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: A tuple of tar_filename and meta_filename """ - if await self.async_get_backup(backup_id) is None: - raise BackupNotFound + await self.async_get_backup(backup_id) base_name = self.backup_base_names[backup_id] return (f"{base_name}.tar", f"{base_name}_meta.json") @@ -195,13 +195,7 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - try: - (filename_tar, filename_meta) = await self._async_backup_filenames( - backup_id - ) - except BackupAgentError: - # backup meta data could not be found, so we can't delete the backup - return + (filename_tar, filename_meta) = await self._async_backup_filenames(backup_id) for filename in (filename_tar, filename_meta): try: @@ -269,7 +263,9 @@ class SynologyDSMBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" backups = await self._async_list_backups() - return backups.get(backup_id) + if backup_id not in backups: + raise BackupNotFound(f"Backup {backup_id} not found") + return backups[backup_id] diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 2f7d041cb10..1ae5fa90760 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -12,20 +12,17 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISKS, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi -from .const import DOMAIN -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -64,11 +61,11 @@ STORAGE_DISK_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ... async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS binary sensor.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api coordinator = data.coordinator_central assert api.storage is not None diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index 6512c370334..79297b1f1b4 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -12,7 +12,6 @@ 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 @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -52,11 +51,11 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set buttons for device.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data async_add_entities(SynologyDSMButton(data.api, button) for button in BUTTONS) diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index acbcccb8894..f393b8efb55 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -16,7 +16,6 @@ from homeassistant.components.camera import ( CameraEntityDescription, CameraEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -29,9 +28,8 @@ from .const import ( DOMAIN, SIGNAL_CAMERA_SOURCE_CHANGED, ) -from .coordinator import SynologyDSMCameraUpdateCoordinator +from .coordinator import SynologyDSMCameraUpdateCoordinator, SynologyDSMConfigEntry from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -47,11 +45,11 @@ class SynologyDSMCameraEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS cameras.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data if coordinator := data.coordinator_cameras: async_add_entities( SynoDSMCamera(data.api, coordinator, camera_id) diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 2e80624ca5d..8b4cf655388 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -9,6 +9,7 @@ import logging from awesomeversion import AwesomeVersion from synology_dsm import SynologyDSM +from synology_dsm.api.core.external_usb import SynoCoreExternalUSB from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.system import SynoCoreSystem from synology_dsm.api.core.upgrade import SynoCoreUpgrade @@ -78,6 +79,7 @@ class SynoApi: self.system: SynoCoreSystem | None = None self.upgrade: SynoCoreUpgrade | None = None self.utilisation: SynoCoreUtilization | None = None + self.external_usb: SynoCoreExternalUSB | None = None # Should we fetch them self._fetching_entities: dict[str, set[str]] = {} @@ -90,6 +92,7 @@ class SynoApi: self._with_system = True self._with_upgrade = True self._with_utilisation = True + self._with_external_usb = True self._login_future: asyncio.Future[None] | None = None @@ -261,6 +264,9 @@ class SynoApi: self._with_information = bool( self._fetching_entities.get(SynoDSMInformation.API_KEY) ) + self._with_external_usb = bool( + self._fetching_entities.get(SynoCoreExternalUSB.API_KEY) + ) # Reset not used API, information is not reset since it's used in device_info if not self._with_security: @@ -322,6 +328,15 @@ class SynoApi: self.dsm.reset(self.utilisation) self.utilisation = None + if not self._with_external_usb: + LOGGER.debug( + "Disable external usb api from being updated for '%s'", + self._entry.unique_id, + ) + if self.external_usb: + self.dsm.reset(self.external_usb) + self.external_usb = None + async def _fetch_device_configuration(self) -> None: """Fetch initial device config.""" self.network = self.dsm.network @@ -366,6 +381,12 @@ class SynoApi: ) self.surveillance_station = self.dsm.surveillance_station + if self._with_external_usb: + LOGGER.debug( + "Enable external usb api updates for '%s'", self._entry.unique_id + ) + self.external_usb = self.dsm.external_usb + async def _syno_api_executer(self, api_call: Callable) -> None: """Synology api call wrapper.""" try: diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 58784862305..f0da6f8fe47 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -72,7 +72,7 @@ from .const import ( DOMAIN, SYNOLOGY_CONNECTION_EXCEPTIONS, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry _LOGGER = logging.getLogger(__name__) @@ -131,7 +131,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SynologyDSMConfigEntry, ) -> SynologyDSMOptionsFlowHandler: """Get the options flow for this handler.""" return SynologyDSMOptionsFlowHandler() @@ -444,6 +444,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): class SynologyDSMOptionsFlowHandler(OptionsFlow): """Handle a option flow.""" + config_entry: SynologyDSMConfigEntry + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -451,7 +453,7 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) - syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.config_entry.unique_id] + syno_data = self.config_entry.runtime_data data_schema = vol.Schema( { diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 1b3e21090b8..dd97dedf65e 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, Concatenate @@ -28,6 +29,20 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +@dataclass +class SynologyDSMData: + """Data for the synology_dsm integration.""" + + api: SynoApi + coordinator_central: SynologyDSMCentralUpdateCoordinator + coordinator_central_old_update_success: bool + coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None + coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None + + +type SynologyDSMConfigEntry = ConfigEntry[SynologyDSMData] + + def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( func: Callable[Concatenate[_T, _P], Awaitable[_R]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]: @@ -57,12 +72,12 @@ def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator base class for synology_dsm.""" - config_entry: ConfigEntry + config_entry: SynologyDSMConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, update_interval: timedelta, ) -> None: @@ -85,7 +100,7 @@ class SynologyDSMSwitchUpdateCoordinator( def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for switch devices.""" @@ -116,7 +131,7 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for central device.""" @@ -136,7 +151,7 @@ class SynologyDSMCameraUpdateCoordinator( def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for cameras.""" diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py index b30955ae682..5cba9ed5aac 100644 --- a/homeassistant/components/synology_dsm/diagnostics.py +++ b/homeassistant/components/synology_dsm/diagnostics.py @@ -6,21 +6,20 @@ from typing import Any from homeassistant.components.camera import diagnostics as camera_diagnostics from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import CONF_DEVICE_TOKEN, DOMAIN -from .models import SynologyDSMData +from .const import CONF_DEVICE_TOKEN +from .coordinator import SynologyDSMConfigEntry TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_DEVICE_TOKEN} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SynologyDSMConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data syno_api = data.api dsm_info = syno_api.dsm.information @@ -33,6 +32,7 @@ async def async_get_config_entry_diagnostics( "uptime": dsm_info.uptime, "temperature": dsm_info.temperature, }, + "external_usb": {"devices": {}, "partitions": {}}, "network": {"interfaces": {}}, "storage": {"disks": {}, "volumes": {}}, "surveillance_station": {"cameras": {}, "camera_diagnostics": {}}, @@ -44,6 +44,27 @@ async def async_get_config_entry_diagnostics( }, } + if syno_api.external_usb is not None: + for device in syno_api.external_usb.get_devices.values(): + if device is not None: + diag_data["external_usb"]["devices"][device.device_id] = { + "name": device.device_name, + "manufacturer": device.device_manufacturer, + "model": device.device_product_name, + "type": device.device_type, + "status": device.device_status, + "size_total": device.device_size_total(False), + } + for partition in device.device_partitions.values(): + if partition is not None: + diag_data["external_usb"]["partitions"][partition.name_id] = { + "name": partition.partition_title, + "filesystem": partition.filesystem, + "share_name": partition.share_name, + "size_used": partition.partition_size_used(False), + "size_total": partition.partition_size_total(False), + } + if syno_api.network is not None: for intf in syno_api.network.interfaces: diag_data["network"]["interfaces"][intf["id"]] = { diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index d8800282c21..85269b9c480 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -93,6 +93,7 @@ class SynologyDSMDeviceEntity( storage = api.storage information = api.information network = api.network + external_usb = api.external_usb assert information is not None assert storage is not None assert network is not None @@ -121,6 +122,26 @@ class SynologyDSMDeviceEntity( self._device_model = disk["model"].strip() self._device_firmware = disk["firm"] self._device_type = disk["diskType"] + elif "device" in description.key: + assert self._device_id is not None + assert external_usb is not None + for device in external_usb.get_devices.values(): + if device.device_name == self._device_id: + self._device_name = device.device_name + self._device_manufacturer = device.device_manufacturer + self._device_model = device.device_product_name + self._device_type = device.device_type + break + elif "partition" in description.key: + assert self._device_id is not None + assert external_usb is not None + for device in external_usb.get_devices.values(): + for partition in device.device_partitions.values(): + if partition.partition_title == self._device_id: + self._device_name = partition.partition_title + self._device_manufacturer = "Synology" + self._device_model = partition.filesystem + break self._attr_unique_id += f"_{self._device_id}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/synology_dsm/icons.json b/homeassistant/components/synology_dsm/icons.json index 3c4d028dc7a..cc3f42a33fd 100644 --- a/homeassistant/components/synology_dsm/icons.json +++ b/homeassistant/components/synology_dsm/icons.json @@ -22,6 +22,12 @@ "cpu_15min_load": { "default": "mdi:chip" }, + "device_size_total": { + "default": "mdi:chart-pie" + }, + "device_status": { + "default": "mdi:checkbox-marked-circle-outline" + }, "memory_real_usage": { "default": "mdi:memory" }, @@ -49,6 +55,15 @@ "network_down": { "default": "mdi:download" }, + "partition_percentage_used": { + "default": "mdi:chart-pie" + }, + "partition_size_total": { + "default": "mdi:chart-pie" + }, + "partition_size_used": { + "default": "mdi:chart-pie" + }, "volume_status": { "default": "mdi:checkbox-marked-circle-outline", "state": { diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index dc5634e7a84..3804de7f3f1 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.7.0"], + "requirements": ["py-synologydsm-api==2.7.1"], "ssdp": [ { "manufacturer": "Synology", diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index d35b262809c..6234f5e8dd0 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -2,6 +2,7 @@ from __future__ import annotations +from logging import getLogger import mimetypes from aiohttp import web @@ -22,7 +23,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import DOMAIN, SHARED_SUFFIX -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry, SynologyDSMData + +LOGGER = getLogger(__name__) async def async_get_media_source(hass: HomeAssistant) -> MediaSource: @@ -41,15 +44,13 @@ class SynologyPhotosMediaSourceIdentifier: """Split identifier into parts.""" parts = identifier.split("/") - self.unique_id = None + self.unique_id = parts[0] self.album_id = None self.cache_key = None self.file_name = None self.is_shared = False self.passphrase = "" - self.unique_id = parts[0] - if len(parts) > 1: album_parts = parts[1].split("_") self.album_id = album_parts[0] @@ -82,7 +83,7 @@ class SynologyPhotosMediaSource(MediaSource): item: MediaSourceItem, ) -> BrowseMediaSource: """Return media.""" - if not self.hass.data.get(DOMAIN): + if not self.hass.config_entries.async_loaded_entries(DOMAIN): raise BrowseError("Diskstation not initialized") return BrowseMediaSource( domain=DOMAIN, @@ -116,7 +117,13 @@ class SynologyPhotosMediaSource(MediaSource): for entry in self.entries ] identifier = SynologyPhotosMediaSourceIdentifier(item.identifier) - diskstation: SynologyDSMData = self.hass.data[DOMAIN][identifier.unique_id] + entry: SynologyDSMConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, identifier.unique_id + ) + ) + assert entry + diskstation = entry.runtime_data assert diskstation.api.photos is not None if identifier.album_id is None: @@ -244,7 +251,7 @@ class SynologyDsmMediaView(http.HomeAssistantView): self, request: web.Request, source_dir_id: str, location: str ) -> web.Response: """Start a GET request.""" - if not self.hass.data.get(DOMAIN): + if not self.hass.config_entries.async_loaded_entries(DOMAIN): raise web.HTTPNotFound # location: {cache_key}/{filename} cache_key, file_name, passphrase = location.split("/") @@ -257,7 +264,13 @@ class SynologyDsmMediaView(http.HomeAssistantView): if not isinstance(mime_type, str): raise web.HTTPNotFound - diskstation: SynologyDSMData = self.hass.data[DOMAIN][source_dir_id] + entry: SynologyDSMConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, source_dir_id + ) + ) + assert entry + diskstation = entry.runtime_data assert diskstation.api.photos is not None item = SynoPhotosItem(image_id, "", "", "", cache_key, "xl", shared, passphrase) try: diff --git a/homeassistant/components/synology_dsm/models.py b/homeassistant/components/synology_dsm/models.py deleted file mode 100644 index 4f51d329ded..00000000000 --- a/homeassistant/components/synology_dsm/models.py +++ /dev/null @@ -1,22 +0,0 @@ -"""The synology_dsm integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from .common import SynoApi -from .coordinator import ( - SynologyDSMCameraUpdateCoordinator, - SynologyDSMCentralUpdateCoordinator, - SynologyDSMSwitchUpdateCoordinator, -) - - -@dataclass -class SynologyDSMData: - """Data for the synology_dsm integration.""" - - api: SynoApi - coordinator_central: SynologyDSMCentralUpdateCoordinator - coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None - coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None diff --git a/homeassistant/components/synology_dsm/repairs.py b/homeassistant/components/synology_dsm/repairs.py index 725e77a2593..8a4e47a32b5 100644 --- a/homeassistant/components/synology_dsm/repairs.py +++ b/homeassistant/components/synology_dsm/repairs.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant import data_entry_flow from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.selector import ( @@ -28,7 +27,7 @@ from .const import ( ISSUE_MISSING_BACKUP_SETUP, SYNOLOGY_CONNECTION_EXCEPTIONS, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -36,7 +35,7 @@ LOGGER = logging.getLogger(__name__) class MissingBackupSetupRepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" - def __init__(self, entry: ConfigEntry, issue_id: str) -> None: + def __init__(self, entry: SynologyDSMConfigEntry, issue_id: str) -> None: """Create flow.""" self.entry = entry self.issue_id = issue_id @@ -59,7 +58,7 @@ class MissingBackupSetupRepairFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" - syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.entry.unique_id] + syno_data = self.entry.runtime_data if user_input is not None: self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 2987de7a7c7..613938f078f 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import cast +from synology_dsm.api.core.external_usb import SynoCoreExternalUSB from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.storage.storage import SynoStorage @@ -16,8 +17,8 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICES, CONF_DISKS, PERCENTAGE, EntityCategory, @@ -31,14 +32,13 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import SynoApi -from .const import CONF_VOLUMES, DOMAIN, ENTITY_UNIT_LOAD -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .const import CONF_VOLUMES, ENTITY_UNIT_LOAD +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -263,6 +263,53 @@ STORAGE_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), ) +EXTERNAL_USB_DISK_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="device_status", + translation_key="device_status", + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="device_size_total", + translation_key="device_size_total", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), +) +EXTERNAL_USB_PARTITION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="partition_size_total", + translation_key="partition_size_total", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="partition_size_used", + translation_key="partition_size_used", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + ), + SynologyDSMSensorEntityDescription( + api_key=SynoCoreExternalUSB.API_KEY, + key="partition_percentage_used", + translation_key="partition_percentage_used", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +) INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( @@ -287,17 +334,23 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS Sensor.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api coordinator = data.coordinator_central storage = api.storage assert storage is not None + external_usb = api.external_usb - entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [ + entities: list[ + SynoDSMUtilSensor + | SynoDSMStorageSensor + | SynoDSMInfoSensor + | SynoDSMExternalUSBSensor + ] = [ SynoDSMUtilSensor(api, coordinator, description) for description in UTILISATION_SENSORS ] @@ -322,6 +375,32 @@ async def async_setup_entry( ] ) + # Handle all external usb + if external_usb is not None and external_usb.get_devices: + entities.extend( + [ + SynoDSMExternalUSBSensor( + api, coordinator, description, device.device_name + ) + for device in entry.data.get( + CONF_DEVICES, external_usb.get_devices.values() + ) + for description in EXTERNAL_USB_DISK_SENSORS + ] + ) + entities.extend( + [ + SynoDSMExternalUSBSensor( + api, coordinator, description, partition.partition_title + ) + for device in entry.data.get( + CONF_DEVICES, external_usb.get_devices.values() + ) + for partition in device.device_partitions.values() + for description in EXTERNAL_USB_PARTITION_SENSORS + ] + ) + entities.extend( [ SynoDSMInfoSensor(api, coordinator, description) @@ -398,6 +477,45 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): ) +class SynoDSMExternalUSBSensor(SynologyDSMDeviceEntity, SynoDSMSensor): + """Representation a Synology Storage sensor.""" + + entity_description: SynologyDSMSensorEntityDescription + + def __init__( + self, + api: SynoApi, + coordinator: SynologyDSMCentralUpdateCoordinator, + description: SynologyDSMSensorEntityDescription, + device_id: str | None = None, + ) -> None: + """Initialize the Synology DSM external usb sensor entity.""" + super().__init__(api, coordinator, description, device_id) + + @property + def native_value(self) -> StateType: + """Return the state.""" + external_usb = self._api.external_usb + assert external_usb is not None + if "device" in self.entity_description.key: + for device in external_usb.get_devices.values(): + if device.device_name == self._device_id: + attr = getattr(device, self.entity_description.key) + break + elif "partition" in self.entity_description.key: + for device in external_usb.get_devices.values(): + for partition in device.device_partitions.values(): + if partition.partition_title == self._device_id: + attr = getattr(partition, self.entity_description.key) + break + if callable(attr): + attr = attr() + if attr is None: + return None + + return attr # type: ignore[no-any-return] + + class SynoDSMInfoSensor(SynoDSMSensor): """Representation a Synology information sensor.""" diff --git a/homeassistant/components/synology_dsm/service.py b/homeassistant/components/synology_dsm/service.py index 366f7d4ba3a..40b6fd4bc30 100644 --- a/homeassistant/components/synology_dsm/service.py +++ b/homeassistant/components/synology_dsm/service.py @@ -3,13 +3,14 @@ from __future__ import annotations import logging +from typing import cast from synology_dsm.exceptions import SynologyDSMException from homeassistant.core import HomeAssistant, ServiceCall from .const import CONF_SERIAL, DOMAIN, SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -19,11 +20,20 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def service_handler(call: ServiceCall) -> None: """Handle service call.""" - serial = call.data.get(CONF_SERIAL) - dsm_devices = hass.data[DOMAIN] + serial: str | None = call.data.get(CONF_SERIAL) + entries: list[SynologyDSMConfigEntry] = ( + hass.config_entries.async_loaded_entries(DOMAIN) + ) + dsm_devices = { + cast(str, entry.unique_id): entry.runtime_data for entry in entries + } if serial: - dsm_device: SynologyDSMData = hass.data[DOMAIN][serial] + entry: SynologyDSMConfigEntry | None = ( + hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) + ) + assert entry + dsm_device = entry.runtime_data elif len(dsm_devices) == 1: dsm_device = next(iter(dsm_devices.values())) serial = next(iter(dsm_devices)) @@ -39,7 +49,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: return if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]: - if serial not in hass.data[DOMAIN]: + if serial not in dsm_devices: LOGGER.error("DSM with specified serial %s not found", serial) return LOGGER.debug("%s DSM with serial %s", call.service, serial) @@ -50,7 +60,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: ), call.service, ) - dsm_device = hass.data[DOMAIN][serial] + dsm_device = dsm_devices[serial] dsm_api = dsm_device.api try: await getattr(dsm_api, f"async_{call.service}")() diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f51184ef1cb..2589f04959c 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -28,7 +28,7 @@ "backup_path": "Path" }, "data_description": { - "backup_share": "Select the shared folder, where the automatic Home-Assistant backup should be stored.", + "backup_share": "Select the shared folder where the automatic Home Assistant backup should be stored.", "backup_path": "Define the path on the selected shared folder (will automatically be created, if not exist)." } }, @@ -54,14 +54,14 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_data": "Missing data: please retry later or an other configuration", - "otp_failed": "Two-step authentication failed, retry with a new pass code", + "otp_failed": "Two-step authentication failed, retry with a new passcode", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "no_mac_address": "The MAC address is missing from the zeroconf record", + "no_mac_address": "The MAC address is missing from the Zeroconf record", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "Re-configuration was successful" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "options": { @@ -113,6 +113,12 @@ "cpu_user_load": { "name": "CPU utilization (user)" }, + "device_size_total": { + "name": "Device size" + }, + "device_status": { + "name": "Status" + }, "disk_smart_status": { "name": "Status (smart)" }, @@ -149,6 +155,15 @@ "network_up": { "name": "Upload throughput" }, + "partition_percentage_used": { + "name": "Partition used" + }, + "partition_size_total": { + "name": "Partition size" + }, + "partition_size_used": { + "name": "Partition used space" + }, "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index c4f1572ceea..91863ff3a26 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -9,16 +9,14 @@ from typing import Any from synology_dsm.api.surveillance_station import SynoSurveillanceStation from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN -from .coordinator import SynologyDSMSwitchUpdateCoordinator +from .coordinator import SynologyDSMConfigEntry, SynologyDSMSwitchUpdateCoordinator from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -41,11 +39,11 @@ SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS switch.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data if coordinator := data.coordinator_switches: assert coordinator.version is not None async_add_entities( diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index 71eed2d7f1f..3048a38cb9c 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -9,15 +9,12 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade from yarl import URL from homeassistant.components.update import UpdateEntity, UpdateEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -39,11 +36,11 @@ UPDATE_ENTITIES: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Synology DSM update entities.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data async_add_entities( SynoDSMUpdateEntity(data.api, data.coordinator_central, description) for description in UPDATE_ENTITIES diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 3bda29867cc..e1ee57e42b2 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -11,6 +11,7 @@ from systembridgeconnector.exceptions import ( AuthenticationException, ConnectionClosedException, ConnectionErrorException, + DataMissingException, ) from systembridgeconnector.version import Version from systembridgemodels.keyboard_key import KeyboardKey @@ -184,7 +185,7 @@ async def async_setup_entry( "host": entry.data[CONF_HOST], }, ) from exception - except TimeoutError as exception: + except (DataMissingException, TimeoutError) as exception: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="timeout", diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index 32507f6d84e..235d7e6b986 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -18,4 +18,6 @@ MODULES: Final[list[Module]] = [ Module.SYSTEM, ] -DATA_WAIT_TIMEOUT: Final[int] = 10 +DATA_WAIT_TIMEOUT: Final[int] = 20 + +GET_DATA_WAIT_TIMEOUT: Final[int] = 15 diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 1690bad4a4d..7e545f39e46 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -33,7 +33,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, MODULES +from .const import DOMAIN, GET_DATA_WAIT_TIMEOUT, MODULES from .data import SystemBridgeData @@ -119,7 +119,10 @@ class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData]) """Get data from WebSocket.""" await self.check_websocket_connected() - modules_data = await self.websocket_client.get_data(GetData(modules=modules)) + modules_data = await self.websocket_client.get_data( + GetData(modules=modules), + timeout=GET_DATA_WAIT_TIMEOUT, + ) # Merge new data with existing data for module in MODULES: diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index c7cae2f347b..d9226e7de6e 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -251,6 +251,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.GIGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=2, icon="mdi:speedometer", value=cpu_speed, ), @@ -261,6 +262,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, value=lambda data: data.cpu.temperature, ), SystemBridgeSensorEntityDescription( @@ -270,6 +272,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=2, value=lambda data: data.cpu.voltage, ), SystemBridgeSensorEntityDescription( @@ -284,6 +287,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, icon="mdi:memory", value=memory_free, ), @@ -291,6 +295,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( key="memory_used_percentage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:memory", value=lambda data: data.memory.virtual.percent, ), @@ -301,6 +306,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, icon="mdi:memory", value=memory_used, ), @@ -322,6 +328,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( translation_key="load", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, icon="mdi:percent", value=lambda data: data.cpu.usage, ), @@ -345,6 +352,7 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, value=lambda data: data.battery.percentage, ), SystemBridgeSensorEntityDescription( @@ -381,6 +389,7 @@ async def async_setup_entry( name=f"{partition.mount_point} space used", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:harddisk", value=( lambda data, @@ -457,6 +466,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:monitor", value=lambda data, k=index: display_refresh_rate(data, k), ), @@ -476,6 +486,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:speedometer", value=lambda data, k=index: gpu_core_clock_speed(data, k), ), @@ -490,6 +501,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=0, icon="mdi:speedometer", value=lambda data, k=index: gpu_memory_clock_speed(data, k), ), @@ -503,6 +515,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=0, icon="mdi:memory", value=lambda data, k=index: gpu_memory_free(data, k), ), @@ -515,6 +528,7 @@ async def async_setup_entry( name=f"{gpu.name} memory used %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:memory", value=lambda data, k=index: gpu_memory_used_percentage(data, k), ), @@ -529,6 +543,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=0, icon="mdi:memory", value=lambda data, k=index: gpu_memory_used(data, k), ), @@ -569,6 +584,7 @@ async def async_setup_entry( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, value=lambda data, k=index: gpu_temperature(data, k), ), entry.data[CONF_PORT], @@ -580,6 +596,7 @@ async def async_setup_entry( name=f"{gpu.name} usage %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, icon="mdi:percent", value=lambda data, k=index: gpu_usage_percentage(data, k), ), @@ -601,6 +618,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", + suggested_display_precision=2, value=lambda data, k=cpu.id: cpu_usage_per_cpu(data, k), ), entry.data[CONF_PORT], @@ -614,6 +632,7 @@ async def async_setup_entry( native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:chip", + suggested_display_precision=2, value=lambda data, k=cpu.id: cpu_power_per_cpu(data, k), ), entry.data[CONF_PORT], diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index ef7495ef74f..1c079c1ef0c 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -109,7 +109,7 @@ "message": "No data received from {host}" }, "process_not_found": { - "message": "Could not find process with id {id}." + "message": "Could not find process with ID {id}." }, "timeout": { "message": "A timeout occurred for {title} ({host})" @@ -120,7 +120,7 @@ }, "issues": { "unsupported_version": { - "title": "System Bridge Upgrade Required", + "title": "System Bridge upgrade required", "description": "Your version of System Bridge for host {host} is not supported.\n\nPlease upgrade to the latest version." } }, diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index bd16464b290..9302746aa17 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,6 +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.1"], + "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.0.0"], "single_config_entry": true } diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index fb8a318ff45..134fe390357 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -48,13 +48,13 @@ "name": "Last boot" }, "load_15m": { - "name": "Load (15m)" + "name": "Load (15 min)" }, "load_1m": { - "name": "Load (1m)" + "name": "Load (1 min)" }, "load_5m": { - "name": "Load (5m)" + "name": "Load (5 min)" }, "memory_free": { "name": "Memory free" diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 4b0203acda3..d1994075f12 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -10,12 +10,17 @@ 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 ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_MODE, @@ -56,23 +61,34 @@ type TadoConfigEntry = ConfigEntry[TadoData] async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: """Set up Tado from a config entry.""" + if CONF_REFRESH_TOKEN not in entry.data: + raise ConfigEntryAuthFailed _async_import_options_from_data_if_missing(hass, entry) _LOGGER.debug("Setting up Tado connection") + _LOGGER.debug( + "Creating tado instance with refresh token: %s", + entry.data[CONF_REFRESH_TOKEN], + ) + + def create_tado_instance() -> tuple[Tado, str]: + """Create a Tado instance, this time with a previously obtained refresh token.""" + tado = Tado(saved_refresh_token=entry.data[CONF_REFRESH_TOKEN]) + return tado, tado.device_activation_status() + try: - tado = await hass.async_add_executor_job( - Tado, - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - ) + tado, device_status = await hass.async_add_executor_job(create_tado_instance) 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] - ) + if device_status != "COMPLETED": + raise ConfigEntryAuthFailed( + f"Device login flow status is {device_status}. Starting re-authentication." + ) + + _LOGGER.debug("Tado connection established") coordinator = TadoDataUpdateCoordinator(hass, entry, tado) await coordinator.async_config_entry_first_refresh() @@ -82,11 +98,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool 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 +async def async_migrate_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: + """Migrate old entry.""" + + if entry.version < 2: + _LOGGER.debug("Migrating Tado entry to version 2. Current data: %s", entry.data) + data = dict(entry.data) + data.pop(CONF_USERNAME, None) + data.pop(CONF_PASSWORD, None) + hass.config_entries.async_update_entry(entry=entry, data=data, version=2) + _LOGGER.debug("Migration to version 2 successful") + return True + + @callback def _async_import_options_from_data_if_missing( hass: HomeAssistant, entry: TadoConfigEntry @@ -106,11 +134,6 @@ def _async_import_options_from_data_if_missing( hass.config_entries.async_update_entry(entry, options=options) -async def update_listener(hass: HomeAssistant, entry: TadoConfigEntry): - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index e6aa921d428..e6ae623d1fc 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -157,8 +157,8 @@ async def create_climate_entity( TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF], TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_SMART_SCHEDULE], ] - supported_fan_modes = None - supported_swing_modes = None + supported_fan_modes: list[str] | None = None + supported_swing_modes: list[str] | None = None heat_temperatures = None cool_temperatures = None @@ -477,11 +477,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - # If the target temperature will be None - # if the device is performing an action - # that does not affect the temperature or - # the device is switching states - return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp + if self._current_tado_hvac_mode == CONST_MODE_OFF: + return TADO_DEFAULT_MIN_TEMP + return self._tado_zone_data.target_temp async def set_timer( self, diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index f251a292800..48c3d30cb2b 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -2,160 +2,176 @@ from __future__ import annotations +import asyncio +from collections.abc import Mapping import logging from typing import Any -import PyTado +from PyTado.exceptions import TadoException +from PyTado.http import DeviceActivationStatus from PyTado.interface import Tado -import requests.exceptions import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ( + SOURCE_REAUTH, ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.service_info.zeroconf import ( - ATTR_PROPERTIES_ID, - ZeroconfServiceInfo, -) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_OPTIONS, DOMAIN, - UNIQUE_ID, ) _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) - - -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - - try: - tado = await hass.async_add_executor_job( - Tado, data[CONF_USERNAME], data[CONF_PASSWORD] - ) - tado_me = await hass.async_add_executor_job(tado.get_me) - except KeyError as ex: - raise InvalidAuth from ex - except RuntimeError as ex: - raise CannotConnect from ex - except requests.exceptions.HTTPError as ex: - if ex.response.status_code > 400 and ex.response.status_code < 500: - raise InvalidAuth from ex - raise CannotConnect from ex - - if "homes" not in tado_me or len(tado_me["homes"]) == 0: - raise NoHomes - - home = tado_me["homes"][0] - unique_id = str(home["id"]) - name = home["name"] - - return {"title": name, UNIQUE_ID: unique_id} - class TadoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Tado.""" - VERSION = 1 + VERSION = 2 + login_task: asyncio.Task | None = None + refresh_token: str | None = None + tado: Tado | None = None + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth on credential failure.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare reauth.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + + return await self.async_step_user() 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: + """Handle users reauth credentials.""" + + if self.tado is None: + _LOGGER.debug("Initiating device activation") try: - validated = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except NoHomes: - errors["base"] = "no_homes" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + self.tado = await self.hass.async_add_executor_job(Tado) + except TadoException: + _LOGGER.exception("Error while initiating Tado") + return self.async_abort(reason="cannot_connect") + assert self.tado is not None + tado_device_url = self.tado.device_verification_url() + user_code = URL(tado_device_url).query["user_code"] - if "base" not in errors: - await self.async_set_unique_id(validated[UNIQUE_ID]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=validated["title"], data=user_input - ) + async def _wait_for_login() -> None: + """Wait for the user to login.""" + assert self.tado is not None + _LOGGER.debug("Waiting for device activation") + try: + await self.hass.async_add_executor_job(self.tado.device_activation) + except Exception as ex: + _LOGGER.exception("Error while waiting for device activation") + raise CannotConnect from ex - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + if ( + self.tado.device_activation_status() + is not DeviceActivationStatus.COMPLETED + ): + raise CannotConnect + + _LOGGER.debug("Checking login task") + if self.login_task is None: + _LOGGER.debug("Creating task for device activation") + self.login_task = self.hass.async_create_task(_wait_for_login()) + + if self.login_task.done(): + _LOGGER.debug("Login task is done, checking results") + if self.login_task.exception(): + return self.async_show_progress_done(next_step_id="timeout") + self.refresh_token = await self.hass.async_add_executor_job( + self.tado.get_refresh_token + ) + return self.async_show_progress_done(next_step_id="finish_login") + + return self.async_show_progress( + step_id="user", + progress_action="wait_for_device", + description_placeholders={ + "url": tado_device_url, + "code": user_code, + }, + progress_task=self.login_task, ) + async def async_step_finish_login( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the finalization of reauth.""" + _LOGGER.debug("Finalizing reauth") + assert self.tado is not None + tado_me = await self.hass.async_add_executor_job(self.tado.get_me) + + if "homes" not in tado_me or len(tado_me["homes"]) == 0: + return self.async_abort(reason="no_homes") + + home = tado_me["homes"][0] + unique_id = str(home["id"]) + name = home["name"] + + if self.source != SOURCE_REAUTH: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=name, + data={CONF_REFRESH_TOKEN: self.refresh_token}, + ) + + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data={CONF_REFRESH_TOKEN: self.refresh_token}, + ) + + async def async_step_timeout( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle issues that need transition await from progress step.""" + if user_input is None: + return self.async_show_form( + step_id="timeout", + ) + del self.login_task + return await self.async_step_user() + async def async_step_homekit( 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[ATTR_PROPERTIES_ID]) - self._abort_if_unique_id_configured() - return await self.async_step_user() + await self._async_handle_discovery_without_unique_id() + return await self.async_step_homekit_confirm() - async def async_step_reconfigure( + async def async_step_homekit_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle a reconfiguration flow initialized by the user.""" - errors: dict[str, str] = {} - reconfigure_entry = self._get_reconfigure_entry() + """Prepare for Homekit.""" + if user_input is None: + return self.async_show_form(step_id="homekit_confirm") - if user_input is not None: - user_input[CONF_USERNAME] = reconfigure_entry.data[CONF_USERNAME] - try: - await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except PyTado.exceptions.TadoWrongCredentialsException: - errors["base"] = "invalid_auth" - except NoHomes: - errors["base"] = "no_homes" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - 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=vol.Schema( - { - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors, - description_placeholders={ - CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME] - }, - ) + return await self.async_step_user() @staticmethod @callback @@ -173,8 +189,10 @@ class OptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(data=user_input) + if user_input: + result = self.async_create_entry(data=user_input) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + return result data_schema = vol.Schema( { @@ -191,11 +209,3 @@ class OptionsFlowHandler(OptionsFlow): class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class NoHomes(HomeAssistantError): - """Error to indicate the account has no homes.""" diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index bdc4bff1943..7720ff09110 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -37,6 +37,7 @@ TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = { # Configuration CONF_FALLBACK = "fallback" CONF_HOME_ID = "home_id" +CONF_REFRESH_TOKEN = "refresh_token" DATA = "data" # Weather diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 559bc4a16fb..5f3aa1de1e4 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -10,7 +10,6 @@ from PyTado.interface import Tado from requests import RequestException from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME -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 @@ -20,6 +19,7 @@ if TYPE_CHECKING: from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, DOMAIN, INSIDE_TEMPERATURE_MEASUREMENT, @@ -58,8 +58,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): update_interval=SCAN_INTERVAL, ) self._tado = tado - self._username = config_entry.data[CONF_USERNAME] - self._password = config_entry.data[CONF_PASSWORD] + self._refresh_token = config_entry.data[CONF_REFRESH_TOKEN] self._fallback = config_entry.options.get( CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT ) @@ -108,6 +107,18 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): self.data["weather"] = home["weather"] self.data["geofence"] = home["geofence"] + refresh_token = await self.hass.async_add_executor_job( + self._tado.get_refresh_token + ) + + if refresh_token != self._refresh_token: + _LOGGER.debug("New refresh token obtained from Tado: %s", refresh_token) + self._refresh_token = refresh_token + self.hass.config_entries.async_update_entry( + self.config_entry, + data={**self.config_entry.data, CONF_REFRESH_TOKEN: refresh_token}, + ) + return self.data async def _async_update_devices(self) -> dict[str, dict]: diff --git a/homeassistant/components/tado/diagnostics.py b/homeassistant/components/tado/diagnostics.py new file mode 100644 index 00000000000..0426707c6a9 --- /dev/null +++ b/homeassistant/components/tado/diagnostics.py @@ -0,0 +1,20 @@ +"""Provides diagnostics for Tado.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import TadoConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: TadoConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a Tado config entry.""" + + return { + "data": config_entry.runtime_data.coordinator.data, + "mobile_devices": config_entry.runtime_data.mobile_coordinator.data, + } diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py index 571a757a3e8..5c515e00cf0 100644 --- a/homeassistant/components/tado/helper.py +++ b/homeassistant/components/tado/helper.py @@ -53,13 +53,13 @@ def decide_duration( return duration -def generate_supported_fanmodes(tado_to_ha_mapping: dict[str, str], options: list[str]): +def generate_supported_fanmodes( + tado_to_ha_mapping: dict[str, str], options: list[str] +) -> list[str] | None: """Return correct list of fan modes or None.""" supported_fanmodes = [ - tado_to_ha_mapping.get(option) - for option in options - if tado_to_ha_mapping.get(option) is not None + val for option in options if (val := tado_to_ha_mapping.get(option)) is not None ] if not supported_fanmodes: return None diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index b83e2695137..b252a396689 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.6"] + "requirements": ["python-tado==0.18.14"] } diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index ff1afc3c03d..5d9c4237be8 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -1,33 +1,28 @@ { "config": { + "progress": { + "wait_for_device": "To authenticate, open the following URL and login at Tado:\n{url}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{code}```\n\n\nThe login attempt will time out after five minutes." + }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "could_not_authenticate": "Could not authenticate with Tado.", + "no_homes": "There are no homes linked to this Tado account.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "step": { - "user": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "title": "Connect to your Tado account" + "reauth_confirm": { + "title": "Authenticate with Tado", + "description": "You need to reauthenticate with Tado. Press `Submit` to start the authentication process." }, - "reconfigure": { - "title": "Reconfigure your Tado", - "description": "Reconfigure the entry for your account: `{username}`.", - "data": { - "password": "[%key:common::config_flow::data::password%]" - }, - "data_description": { - "password": "Enter the (new) password for Tado." - } + "homekit": { + "title": "Authenticate with Tado", + "description": "Your device has been discovered and needs to authenticate with Tado. Press `Submit` to start the authentication process." + }, + "timeout": { + "description": "The authentication process timed out. Please try again." } - }, - "error": { - "unknown": "[%key:common::config_flow::error::unknown%]", - "no_homes": "There are no homes linked to this Tado account.", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, "options": { @@ -58,7 +53,7 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "Auto" + "auto": "[%key:common::state::auto%]" } } } @@ -144,7 +139,7 @@ "description": "Adds a meter reading to Tado Energy IQ.", "fields": { "config_entry": { - "name": "Config Entry", + "name": "Config entry", "description": "Config entry to add meter reading to." }, "reading": { diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index 6569b40ada2..65ac69d89c7 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -46,37 +46,61 @@ BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( key="client_supports_hair_pinning", translation_key="client_supports_hair_pinning", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.hair_pinning, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.hair_pinning + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_ipv6", translation_key="client_supports_ipv6", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.ipv6, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.ipv6 + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_pcp", translation_key="client_supports_pcp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.pcp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.pcp + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_pmp", translation_key="client_supports_pmp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.pmp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.pmp + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_udp", translation_key="client_supports_udp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.udp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.udp + if device.client_connectivity is not None + else None + ), ), TailscaleBinarySensorEntityDescription( key="client_supports_upnp", translation_key="client_supports_upnp", entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda device: device.client_connectivity.client_supports.upnp, + is_on_fn=lambda device: ( + device.client_connectivity.client_supports.upnp + if device.client_connectivity is not None + else None + ), ), ) diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json index 7d571fe0675..8c005888387 100644 --- a/homeassistant/components/tailscale/manifest.json +++ b/homeassistant/components/tailscale/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tailscale", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["tailscale==0.6.1"] + "requirements": ["tailscale==0.6.2"] } diff --git a/homeassistant/components/tami4/strings.json b/homeassistant/components/tami4/strings.json index 040c18fc56d..b89ccbe8bd9 100644 --- a/homeassistant/components/tami4/strings.json +++ b/homeassistant/components/tami4/strings.json @@ -29,17 +29,17 @@ "config": { "step": { "user": { - "title": "SMS Verification", + "title": "SMS verification", "description": "Enter your phone number (same as what you used to register to the tami4 app)", "data": { - "phone": "Phone Number" + "phone": "Phone number" } }, "otp": { "title": "[%key:component::tami4::config::step::user::title%]", "description": "Enter the code you received via SMS", "data": { - "otp": "SMS Code" + "otp": "SMS code" } } }, diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 8796ae46ab7..b269eaaaf55 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -39,7 +39,7 @@ from homeassistant.helpers.selector import ( NumberSelectorConfig, ) -from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_TYPES +from .const import CONF_STATIONS, DEFAULT_RADIUS, DOMAIN async def async_get_nearby_stations( @@ -175,10 +175,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): vol.Required( CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") ): cv.string, - vol.Required( - CONF_FUEL_TYPES, - default=user_input.get(CONF_FUEL_TYPES, list(FUEL_TYPES)), - ): cv.multi_select(FUEL_TYPES), vol.Required( CONF_LOCATION, default=user_input.get( diff --git a/homeassistant/components/tankerkoenig/const.py b/homeassistant/components/tankerkoenig/const.py index c2a1dba9b6a..6761d20f4ce 100644 --- a/homeassistant/components/tankerkoenig/const.py +++ b/homeassistant/components/tankerkoenig/const.py @@ -3,14 +3,11 @@ DOMAIN = "tankerkoenig" NAME = "tankerkoenig" -CONF_FUEL_TYPES = "fuel_types" CONF_STATIONS = "stations" DEFAULT_RADIUS = 2 DEFAULT_SCAN_INTERVAL = 30 -FUEL_TYPES = {"e5": "Super", "e10": "Super E10", "diesel": "Diesel"} - ATTR_BRAND = "brand" ATTR_CITY = "city" ATTR_FUEL_TYPE = "fuel_type" diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 1f73d0577b3..f1e6bc8c865 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN +from .const import CONF_STATIONS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -54,7 +54,6 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf self._selected_stations: list[str] = self.config_entry.data[CONF_STATIONS] self.stations: dict[str, Station] = {} - self.fuel_types: list[str] = self.config_entry.data[CONF_FUEL_TYPES] self.show_on_map: bool = self.config_entry.options[CONF_SHOW_ON_MAP] self._tankerkoenig = Tankerkoenig( diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 29f4f439dd5..db620b2b11c 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -5,7 +5,6 @@ "data": { "name": "Region name", "api_key": "[%key:common::config_flow::data::api_key%]", - "fuel_types": "Fuel types", "location": "[%key:common::config_flow::data::location%]", "stations": "Additional fuel stations", "radius": "Search radius" diff --git a/homeassistant/components/tasmota/strings.json b/homeassistant/components/tasmota/strings.json index 22af3304297..13edee55110 100644 --- a/homeassistant/components/tasmota/strings.json +++ b/homeassistant/components/tasmota/strings.json @@ -20,11 +20,11 @@ "issues": { "topic_duplicated": { "title": "Several Tasmota devices are sharing the same topic", - "description": "Several Tasmota devices are sharing the topic {topic}.\n\n Tasmota devices with this problem: {offenders}." + "description": "Several Tasmota devices are sharing the topic {topic}.\n\nTasmota devices with this problem: {offenders}." }, "topic_no_prefix": { "title": "Tasmota device {name} has an invalid MQTT topic", - "description": "Tasmota device {name} with IP {ip} does not include `%prefix%` in its fulltopic.\n\nEntities for this devices are disabled until the configuration has been corrected." + "description": "Tasmota device {name} with IP {ip} does not include `%prefix%` in its FullTopic.\n\nEntities for this device are disabled until the configuration has been corrected." } } } diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 05260845a03..29aba780f26 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -76,10 +76,10 @@ }, "switch": { "auto_charge": { - "name": "Auto charge" + "name": "Auto-charge" }, "session_active": { - "name": "Charging Enabled" + "name": "Charging enabled" } } }, diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index a01b889ef8f..6570d9c5428 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -41,7 +41,7 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( TedeeBinarySensorEntityDescription( key="semi_locked", translation_key="semi_locked", - is_on_fn=lambda lock: lock.state == TedeeLockState.HALF_OPEN, + is_on_fn=lambda lock: lock.state is TedeeLockState.HALF_OPEN, entity_category=EntityCategory.DIAGNOSTIC, ), TedeeBinarySensorEntityDescription( @@ -53,7 +53,10 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( TedeeBinarySensorEntityDescription( key="uncalibrated", translation_key="uncalibrated", - is_on_fn=lambda lock: lock.state == TedeeLockState.UNCALIBRATED, + is_on_fn=( + lambda lock: lock.state is TedeeLockState.UNCALIBRATED + or lock.state is TedeeLockState.UNKNOWN + ), device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/telegram/notify.py b/homeassistant/components/telegram/notify.py index adb947bcf6b..6b9cf43bf71 100644 --- a/homeassistant/components/telegram/notify.py +++ b/homeassistant/components/telegram/notify.py @@ -20,17 +20,17 @@ from homeassistant.components.telegram_bot import ( ATTR_MESSAGE_TAG, ATTR_MESSAGE_THREAD_ID, ATTR_PARSER, + DOMAIN as TELEGRAM_BOT_DOMAIN, ) from homeassistant.const import ATTR_LOCATION from homeassistant.core import HomeAssistant from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as TELEGRAM_DOMAIN, PLATFORMS +from . import DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) -DOMAIN = "telegram_bot" ATTR_KEYBOARD = "keyboard" ATTR_INLINE_KEYBOARD = "inline_keyboard" ATTR_PHOTO = "photo" @@ -52,7 +52,7 @@ def get_service( ) -> TelegramNotificationService: """Get the Telegram notification service.""" - setup_reload_service(hass, TELEGRAM_DOMAIN, PLATFORMS) + setup_reload_service(hass, DOMAIN, PLATFORMS) chat_id = config.get(CONF_CHAT_ID) return TelegramNotificationService(hass, chat_id) @@ -115,37 +115,45 @@ class TelegramNotificationService(BaseNotificationService): photos = photos if isinstance(photos, list) else [photos] for photo_data in photos: service_data.update(photo_data) - self.hass.services.call(DOMAIN, "send_photo", service_data=service_data) + self.hass.services.call( + TELEGRAM_BOT_DOMAIN, "send_photo", service_data=service_data + ) return None if data is not None and ATTR_VIDEO in data: videos = data.get(ATTR_VIDEO) videos = videos if isinstance(videos, list) else [videos] for video_data in videos: service_data.update(video_data) - self.hass.services.call(DOMAIN, "send_video", service_data=service_data) + self.hass.services.call( + TELEGRAM_BOT_DOMAIN, "send_video", service_data=service_data + ) return None if data is not None and ATTR_VOICE in data: voices = data.get(ATTR_VOICE) voices = voices if isinstance(voices, list) else [voices] for voice_data in voices: service_data.update(voice_data) - self.hass.services.call(DOMAIN, "send_voice", service_data=service_data) + self.hass.services.call( + TELEGRAM_BOT_DOMAIN, "send_voice", service_data=service_data + ) return None if data is not None and ATTR_LOCATION in data: service_data.update(data.get(ATTR_LOCATION)) return self.hass.services.call( - DOMAIN, "send_location", service_data=service_data + TELEGRAM_BOT_DOMAIN, "send_location", service_data=service_data ) if data is not None and ATTR_DOCUMENT in data: service_data.update(data.get(ATTR_DOCUMENT)) return self.hass.services.call( - DOMAIN, "send_document", service_data=service_data + TELEGRAM_BOT_DOMAIN, "send_document", service_data=service_data ) # Send message _LOGGER.debug( - "TELEGRAM NOTIFIER calling %s.send_message with %s", DOMAIN, service_data + "TELEGRAM NOTIFIER calling %s.send_message with %s", + TELEGRAM_BOT_DOMAIN, + service_data, ) return self.hass.services.call( - DOMAIN, "send_message", service_data=service_data + TELEGRAM_BOT_DOMAIN, "send_message", service_data=service_data ) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index b3c09049ae5..15e1f7d4f0e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -548,7 +548,7 @@ class TelegramNotificationService: """Initialize the service.""" self.allowed_chat_ids = allowed_chat_ids self._default_user = self.allowed_chat_ids[0] - self._last_message_id = {user: None for user in self.allowed_chat_ids} + self._last_message_id = dict.fromkeys(self.allowed_chat_ids) self._parsers = { PARSER_HTML: ParseMode.HTML, PARSER_MD: ParseMode.MARKDOWN, diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 15a73cf3de5..c3f832b0c54 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_NAME, + CONF_TRIGGERS, CONF_UNIQUE_ID, SERVICE_RELOAD, ) @@ -27,7 +28,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.util.hass_dict import HassKey -from .const import CONF_MAX, CONF_MIN, CONF_STEP, CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN, PLATFORMS from .coordinator import TriggerUpdateCoordinator from .helpers import async_get_blueprints @@ -136,7 +137,7 @@ async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None: coordinator_tasks: list[Coroutine[Any, Any, TriggerUpdateCoordinator]] = [] for conf_section in hass_config[DOMAIN]: - if CONF_TRIGGER in conf_section: + if CONF_TRIGGERS in conf_section: coordinator_tasks.append(init_coordinator(hass, conf_section)) continue diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 0a468994295..d035edd26ac 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -4,7 +4,7 @@ from __future__ import annotations from enum import Enum import logging -from typing import Any +from typing import TYPE_CHECKING import voluptuous as vol @@ -21,6 +21,7 @@ from homeassistant.const import ( ATTR_CODE, CONF_DEVICE_ID, CONF_NAME, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_UNAVAILABLE, @@ -28,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( @@ -36,12 +37,16 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, ) from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify -from .const import DOMAIN -from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, + TEMPLATE_ENTITY_ICON_SCHEMA, + TemplateEntity, + rewrite_common_legacy_to_modern_conf, +) _LOGGER = logging.getLogger(__name__) _VALID_STATES = [ @@ -52,21 +57,22 @@ _VALID_STATES = [ AlarmControlPanelState.ARMED_VACATION, AlarmControlPanelState.ARMING, AlarmControlPanelState.DISARMED, + AlarmControlPanelState.DISARMING, AlarmControlPanelState.PENDING, AlarmControlPanelState.TRIGGERED, STATE_UNAVAILABLE, ] +CONF_ALARM_CONTROL_PANELS = "panels" CONF_ARM_AWAY_ACTION = "arm_away" CONF_ARM_CUSTOM_BYPASS_ACTION = "arm_custom_bypass" CONF_ARM_HOME_ACTION = "arm_home" CONF_ARM_NIGHT_ACTION = "arm_night" CONF_ARM_VACATION_ACTION = "arm_vacation" -CONF_DISARM_ACTION = "disarm" -CONF_TRIGGER_ACTION = "trigger" -CONF_ALARM_CONTROL_PANELS = "panels" CONF_CODE_ARM_REQUIRED = "code_arm_required" CONF_CODE_FORMAT = "code_format" +CONF_DISARM_ACTION = "disarm" +CONF_TRIGGER_ACTION = "trigger" class TemplateCodeFormat(Enum): @@ -77,73 +83,140 @@ class TemplateCodeFormat(Enum): text = CodeFormat.TEXT -ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_VALUE_TEMPLATE: CONF_STATE, +} + +DEFAULT_NAME = "Template Alarm Control Panel" + +ALARM_CONTROL_PANEL_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, + vol.Optional( + CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name + ): cv.enum(TemplateCodeFormat), + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), +) + + +LEGACY_ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( { - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( TemplateCodeFormat ), + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, } ) PLATFORM_SCHEMA = ALARM_CONTROL_PANEL_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ALARM_CONTROL_PANELS): cv.schema_with_slug_keys( - ALARM_CONTROL_PANEL_SCHEMA + LEGACY_ALARM_CONTROL_PANEL_SCHEMA ), } ) ALARM_CONTROL_PANEL_CONFIG_SCHEMA = vol.Schema( { - vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( TemplateCodeFormat ), vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_NAME): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, } ) -async def _async_create_entities( - hass: HomeAssistant, config: dict[str, Any] -) -> list[AlarmControlPanelTemplate]: - """Create Template Alarm Control Panels.""" +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy alarm control panel configuration definitions to modern ones.""" alarm_control_panels = [] - for object_id, entity_config in config[CONF_ALARM_CONTROL_PANELS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) - unique_id = entity_config.get(CONF_UNIQUE_ID) + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_common_legacy_to_modern_conf( + hass, entity_conf, LEGACY_FIELDS + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + alarm_control_panels.append(entity_conf) + + return alarm_control_panels + + +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template alarm control panels.""" + alarm_control_panels = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" alarm_control_panels.append( AlarmControlPanelTemplate( hass, - object_id, - entity_config, + entity_conf, unique_id, ) ) - return alarm_control_panels + async_add_entities(alarm_control_panels) + + +def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: + """Rewrite option configuration to modern configuration.""" + option_config = {**option_config} + + if CONF_VALUE_TEMPLATE in option_config: + option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE) + + return option_config async def async_setup_entry( @@ -154,12 +227,12 @@ async def async_setup_entry( """Initialize config entry.""" _options = dict(config_entry.options) _options.pop("template_type") + _options = rewrite_options_to_modern_conf(_options) validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA(_options) async_add_entities( [ AlarmControlPanelTemplate( hass, - slugify(_options[CONF_NAME]), validated_config, config_entry.entry_id, ) @@ -173,8 +246,22 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Template Alarm Control Panels.""" - async_add_entities(await _async_create_entities(hass, config)) + """Set up the Template cover.""" + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_ALARM_CONTROL_PANELS]), + None, + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, RestoreEntity): @@ -185,84 +272,46 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore def __init__( self, hass: HomeAssistant, - object_id: str, config: dict, unique_id: str | None, ) -> None: """Initialize the panel.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id - ) - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, object_id, hass=hass + ) name = self._attr_name - assert name is not None - self._template = config.get(CONF_VALUE_TEMPLATE) - self._disarm_script = None + if TYPE_CHECKING: + assert name is not None + self._template = config.get(CONF_STATE) + self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] self._attr_code_format = config[CONF_CODE_FORMAT].value - if (disarm_action := config.get(CONF_DISARM_ACTION)) is not None: - self._disarm_script = Script(hass, disarm_action, name, DOMAIN) - self._arm_away_script = None - if (arm_away_action := config.get(CONF_ARM_AWAY_ACTION)) is not None: - self._arm_away_script = Script(hass, arm_away_action, name, DOMAIN) - self._arm_home_script = None - if (arm_home_action := config.get(CONF_ARM_HOME_ACTION)) is not None: - self._arm_home_script = Script(hass, arm_home_action, name, DOMAIN) - self._arm_night_script = None - if (arm_night_action := config.get(CONF_ARM_NIGHT_ACTION)) is not None: - self._arm_night_script = Script(hass, arm_night_action, name, DOMAIN) - self._arm_vacation_script = None - if (arm_vacation_action := config.get(CONF_ARM_VACATION_ACTION)) is not None: - self._arm_vacation_script = Script(hass, arm_vacation_action, name, DOMAIN) - self._arm_custom_bypass_script = None - if ( - arm_custom_bypass_action := config.get(CONF_ARM_CUSTOM_BYPASS_ACTION) - ) is not None: - self._arm_custom_bypass_script = Script( - hass, arm_custom_bypass_action, name, DOMAIN - ) - self._trigger_script = None - if (trigger_action := config.get(CONF_TRIGGER_ACTION)) is not None: - self._trigger_script = Script(hass, trigger_action, name, DOMAIN) + + self._attr_supported_features = AlarmControlPanelEntityFeature(0) + for action_id, supported_feature in ( + (CONF_DISARM_ACTION, 0), + (CONF_ARM_AWAY_ACTION, AlarmControlPanelEntityFeature.ARM_AWAY), + (CONF_ARM_HOME_ACTION, AlarmControlPanelEntityFeature.ARM_HOME), + (CONF_ARM_NIGHT_ACTION, AlarmControlPanelEntityFeature.ARM_NIGHT), + (CONF_ARM_VACATION_ACTION, AlarmControlPanelEntityFeature.ARM_VACATION), + ( + CONF_ARM_CUSTOM_BYPASS_ACTION, + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + ), + (CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER), + ): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature self._state: AlarmControlPanelState | None = None self._attr_device_info = async_device_info_to_link_from_device_id( hass, config.get(CONF_DEVICE_ID), ) - supported_features = AlarmControlPanelEntityFeature(0) - if self._arm_night_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_NIGHT - ) - - if self._arm_home_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_HOME - ) - - if self._arm_away_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_AWAY - ) - - if self._arm_vacation_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_VACATION - ) - - if self._arm_custom_bypass_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - ) - - if self._trigger_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.TRIGGER - ) - self._attr_supported_features = supported_features async def async_added_to_hass(self) -> None: """Restore last state.""" @@ -330,7 +379,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Away.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_AWAY, - script=self._arm_away_script, + script=self._action_scripts.get(CONF_ARM_AWAY_ACTION), code=code, ) @@ -338,7 +387,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Home.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_HOME, - script=self._arm_home_script, + script=self._action_scripts.get(CONF_ARM_HOME_ACTION), code=code, ) @@ -346,7 +395,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Night.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_NIGHT, - script=self._arm_night_script, + script=self._action_scripts.get(CONF_ARM_NIGHT_ACTION), code=code, ) @@ -354,7 +403,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Vacation.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_VACATION, - script=self._arm_vacation_script, + script=self._action_scripts.get(CONF_ARM_VACATION_ACTION), code=code, ) @@ -362,20 +411,22 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Custom Bypass.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_CUSTOM_BYPASS, - script=self._arm_custom_bypass_script, + script=self._action_scripts.get(CONF_ARM_CUSTOM_BYPASS_ACTION), code=code, ) async def async_alarm_disarm(self, code: str | None = None) -> None: """Disarm the panel.""" await self._async_alarm_arm( - AlarmControlPanelState.DISARMED, script=self._disarm_script, code=code + AlarmControlPanelState.DISARMED, + script=self._action_scripts.get(CONF_DISARM_ACTION), + code=code, ) async def async_alarm_trigger(self, code: str | None = None) -> None: """Trigger the panel.""" await self._async_alarm_arm( AlarmControlPanelState.TRIGGERED, - script=self._trigger_script, + script=self._action_scripts.get(CONF_TRIGGER_ACTION), code=code, ) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index f43fc242bba..4ee8844d6e7 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -23,7 +23,6 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN @@ -121,11 +120,9 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity): """Initialize the button.""" super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None - self._command_press = ( - Script(hass, config.get(CONF_PRESS), self._attr_name, DOMAIN) - if config.get(CONF_PRESS, None) is not None - else None - ) + # Scripts can be an empty list, therefore we need to check for None + if (action := config.get(CONF_PRESS)) is not None: + self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None self._attr_device_info = async_device_info_to_link_from_device_id( @@ -135,5 +132,5 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - if self._command_press: - await self.async_run_script(self._command_press, context=self._context) + if script := self._action_scripts.get(CONF_PRESS): + await self.async_run_script(script, context=self._context) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index e0c5514def9..f1b58ebffa0 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -1,26 +1,43 @@ """Template config validator.""" +from collections.abc import Callable from contextlib import suppress import logging +from typing import Any import voluptuous as vol -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.blueprint import ( - BLUEPRINT_INSTANCE_FIELDS, - is_blueprint_instance_config, +from homeassistant.components.alarm_control_panel import ( + DOMAIN as DOMAIN_ALARM_CONTROL_PANEL, ) -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN -from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.select import DOMAIN as SELECT_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.components.binary_sensor import DOMAIN as DOMAIN_BINARY_SENSOR +from homeassistant.components.blueprint import ( + is_blueprint_instance_config, + schemas as blueprint_schemas, +) +from homeassistant.components.button import DOMAIN as DOMAIN_BUTTON +from homeassistant.components.cover import DOMAIN as DOMAIN_COVER +from homeassistant.components.fan import DOMAIN as DOMAIN_FAN +from homeassistant.components.image import DOMAIN as DOMAIN_IMAGE +from homeassistant.components.light import DOMAIN as DOMAIN_LIGHT +from homeassistant.components.lock import DOMAIN as DOMAIN_LOCK +from homeassistant.components.number import DOMAIN as DOMAIN_NUMBER +from homeassistant.components.select import DOMAIN as DOMAIN_SELECT +from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR +from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH +from homeassistant.components.vacuum import DOMAIN as DOMAIN_VACUUM +from homeassistant.components.weather import DOMAIN as DOMAIN_WEATHER from homeassistant.config import async_log_schema_error, config_without_domain from homeassistant.const import ( + CONF_ACTION, + CONF_ACTIONS, CONF_BINARY_SENSORS, + CONF_CONDITION, + CONF_CONDITIONS, CONF_NAME, CONF_SENSORS, + CONF_TRIGGER, + CONF_TRIGGERS, CONF_UNIQUE_ID, CONF_VARIABLES, ) @@ -32,69 +49,126 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_notify_setup_error from . import ( + alarm_control_panel as alarm_control_panel_platform, binary_sensor as binary_sensor_platform, button as button_platform, + cover as cover_platform, + fan as fan_platform, image as image_platform, + light as light_platform, + lock as lock_platform, number as number_platform, select as select_platform, sensor as sensor_platform, + switch as switch_platform, + vacuum as vacuum_platform, weather as weather_platform, ) -from .const import ( - CONF_ACTION, - CONF_CONDITION, - CONF_TRIGGER, - DOMAIN, - PLATFORMS, - TemplateConfig, -) +from .const import DOMAIN, PLATFORMS, TemplateConfig from .helpers import async_get_blueprints PACKAGE_MERGE_HINT = "list" -CONFIG_SECTION_SCHEMA = vol.Schema( - { - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, - vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA, - vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, - vol.Optional(NUMBER_DOMAIN): vol.All( - cv.ensure_list, [number_platform.NUMBER_SCHEMA] - ), - vol.Optional(SENSOR_DOMAIN): vol.All( - cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] - ), - vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( - sensor_platform.LEGACY_SENSOR_SCHEMA - ), - vol.Optional(BINARY_SENSOR_DOMAIN): vol.All( - cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] - ), - vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( - binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA - ), - vol.Optional(SELECT_DOMAIN): vol.All( - cv.ensure_list, [select_platform.SELECT_SCHEMA] - ), - vol.Optional(BUTTON_DOMAIN): vol.All( - cv.ensure_list, [button_platform.BUTTON_SCHEMA] - ), - vol.Optional(IMAGE_DOMAIN): vol.All( - cv.ensure_list, [image_platform.IMAGE_SCHEMA] - ), - vol.Optional(WEATHER_DOMAIN): vol.All( - cv.ensure_list, [weather_platform.WEATHER_SCHEMA] - ), - }, + +def ensure_domains_do_not_have_trigger_or_action(*keys: str) -> Callable[[dict], dict]: + """Validate that config does not contain trigger and action.""" + domains = set(keys) + + def validate(obj: dict): + options = set(obj.keys()) + if found_domains := domains.intersection(options): + invalid = {CONF_TRIGGERS, CONF_ACTIONS} + if found_invalid := invalid.intersection(set(obj.keys())): + raise vol.Invalid( + f"Unsupported option(s) found for domain {found_domains.pop()}, please remove ({', '.join(found_invalid)}) from your configuration", + ) + + return obj + + return validate + + +def _backward_compat_schema(value: Any | None) -> Any: + """Backward compatibility for automations.""" + + value = cv.renamed(CONF_TRIGGER, CONF_TRIGGERS)(value) + value = cv.renamed(CONF_ACTION, CONF_ACTIONS)(value) + return cv.renamed(CONF_CONDITION, CONF_CONDITIONS)(value) + + +CONFIG_SECTION_SCHEMA = vol.All( + _backward_compat_schema, + vol.Schema( + { + vol.Optional(CONF_ACTIONS): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( + binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA + ), + vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, + vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( + sensor_platform.LEGACY_SENSOR_SCHEMA + ), + vol.Optional(CONF_TRIGGERS): cv.TRIGGER_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, + vol.Optional(DOMAIN_ALARM_CONTROL_PANEL): vol.All( + cv.ensure_list, + [alarm_control_panel_platform.ALARM_CONTROL_PANEL_SCHEMA], + ), + vol.Optional(DOMAIN_BINARY_SENSOR): vol.All( + cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] + ), + vol.Optional(DOMAIN_BUTTON): vol.All( + cv.ensure_list, [button_platform.BUTTON_SCHEMA] + ), + vol.Optional(DOMAIN_COVER): vol.All( + cv.ensure_list, [cover_platform.COVER_SCHEMA] + ), + vol.Optional(DOMAIN_FAN): vol.All( + cv.ensure_list, [fan_platform.FAN_SCHEMA] + ), + vol.Optional(DOMAIN_IMAGE): vol.All( + cv.ensure_list, [image_platform.IMAGE_SCHEMA] + ), + vol.Optional(DOMAIN_LIGHT): vol.All( + cv.ensure_list, [light_platform.LIGHT_SCHEMA] + ), + vol.Optional(DOMAIN_LOCK): vol.All( + cv.ensure_list, [lock_platform.LOCK_SCHEMA] + ), + vol.Optional(DOMAIN_NUMBER): vol.All( + cv.ensure_list, [number_platform.NUMBER_SCHEMA] + ), + vol.Optional(DOMAIN_SELECT): vol.All( + cv.ensure_list, [select_platform.SELECT_SCHEMA] + ), + vol.Optional(DOMAIN_SENSOR): vol.All( + cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] + ), + vol.Optional(DOMAIN_SWITCH): vol.All( + cv.ensure_list, [switch_platform.SWITCH_SCHEMA] + ), + vol.Optional(DOMAIN_VACUUM): vol.All( + cv.ensure_list, [vacuum_platform.VACUUM_SCHEMA] + ), + vol.Optional(DOMAIN_WEATHER): vol.All( + cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + ), + }, + ), + ensure_domains_do_not_have_trigger_or_action( + DOMAIN_ALARM_CONTROL_PANEL, + DOMAIN_BUTTON, + DOMAIN_COVER, + DOMAIN_FAN, + DOMAIN_LOCK, + DOMAIN_VACUUM, + ), ) -TEMPLATE_BLUEPRINT_INSTANCE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -).extend(BLUEPRINT_INSTANCE_FIELDS.schema) +TEMPLATE_BLUEPRINT_SCHEMA = vol.All( + _backward_compat_schema, blueprint_schemas.BLUEPRINT_SCHEMA +) async def _async_resolve_blueprints( @@ -109,10 +183,11 @@ async def _async_resolve_blueprints( raw_config = dict(config) if is_blueprint_instance_config(config): - config = TEMPLATE_BLUEPRINT_INSTANCE_SCHEMA(config) blueprints = async_get_blueprints(hass) - blueprint_inputs = await blueprints.async_inputs_from_config(config) + blueprint_inputs = await blueprints.async_inputs_from_config( + _backward_compat_schema(config) + ) raw_blueprint_inputs = blueprint_inputs.config_with_inputs config = blueprint_inputs.async_substitute() @@ -122,9 +197,15 @@ async def _async_resolve_blueprints( raise vol.Invalid("more than one platform defined per blueprint") if len(platforms) == 1: platform = platforms.pop() - for prop in (CONF_NAME, CONF_UNIQUE_ID, CONF_VARIABLES): + for prop in (CONF_NAME, CONF_UNIQUE_ID): if prop in config: config[platform][prop] = config.pop(prop) + # For regular template entities, CONF_VARIABLES should be removed because they just + # house input results for template entities. For Trigger based template entities + # CONF_VARIABLES should not be removed because the variables are always + # executed between the trigger and action. + if CONF_TRIGGERS not in config and CONF_VARIABLES in config: + config[platform][CONF_VARIABLES] = config.pop(CONF_VARIABLES) raw_config = dict(config) template_config = TemplateConfig(CONFIG_SECTION_SCHEMA(config)) @@ -141,14 +222,14 @@ async def async_validate_config_section( validated_config = await _async_resolve_blueprints(hass, config) - if CONF_TRIGGER in validated_config: - validated_config[CONF_TRIGGER] = await async_validate_trigger_config( - hass, validated_config[CONF_TRIGGER] + if CONF_TRIGGERS in validated_config: + validated_config[CONF_TRIGGERS] = await async_validate_trigger_config( + hass, validated_config[CONF_TRIGGERS] ) - if CONF_CONDITION in validated_config: - validated_config[CONF_CONDITION] = await async_validate_conditions_config( - hass, validated_config[CONF_CONDITION] + if CONF_CONDITIONS in validated_config: + validated_config[CONF_CONDITIONS] = await async_validate_conditions_config( + hass, validated_config[CONF_CONDITIONS] ) return validated_config @@ -176,12 +257,12 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf for old_key, new_key, transform in ( ( CONF_SENSORS, - SENSOR_DOMAIN, + DOMAIN_SENSOR, sensor_platform.rewrite_legacy_to_modern_conf, ), ( CONF_BINARY_SENSORS, - BINARY_SENSOR_DOMAIN, + DOMAIN_BINARY_SENSOR, binary_sensor_platform.rewrite_legacy_to_modern_conf, ), ): diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index f333d14797e..53c0fa3af13 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -1,22 +1,18 @@ """Constants for the Template Platform Components.""" -from homeassistant.components.blueprint import BLUEPRINT_SCHEMA from homeassistant.const import Platform from homeassistant.helpers.typing import ConfigType -CONF_ACTION = "action" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_ATTRIBUTES = "attributes" CONF_AVAILABILITY = "availability" CONF_AVAILABILITY_TEMPLATE = "availability_template" -CONF_CONDITION = "condition" CONF_MAX = "max" CONF_MIN = "min" CONF_OBJECT_ID = "object_id" CONF_PICTURE = "picture" CONF_PRESS = "press" CONF_STEP = "step" -CONF_TRIGGER = "trigger" CONF_TURN_OFF = "turn_off" CONF_TURN_ON = "turn_on" @@ -41,8 +37,6 @@ PLATFORMS = [ Platform.WEATHER, ] -TEMPLATE_BLUEPRINT_SCHEMA = BLUEPRINT_SCHEMA - class TemplateConfig(dict): """Dummy class to allow adding attributes.""" diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index 4d8fe78f2b5..a2823233336 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -2,17 +2,26 @@ from collections.abc import Callable, Mapping import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.components.blueprint import CONF_USE_BLUEPRINT +from homeassistant.const import ( + CONF_ACTIONS, + CONF_CONDITIONS, + CONF_PATH, + CONF_TRIGGERS, + CONF_VARIABLES, + EVENT_HOMEASSISTANT_START, +) from homeassistant.core import Context, CoreState, Event, HomeAssistant, callback from homeassistant.helpers import condition, discovery, trigger as trigger_helper from homeassistant.helpers.script import Script +from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.trace import trace_get from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) @@ -22,7 +31,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): REMOVE_TRIGGER = object() - def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: """Instantiate trigger data.""" super().__init__( hass, _LOGGER, config_entry=None, name="Trigger Update Coordinator" @@ -32,6 +41,18 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): self._unsub_start: Callable[[], None] | None = None self._unsub_trigger: Callable[[], None] | None = None self._script: Script | None = None + self._run_variables: ScriptVariables | None = None + self._blueprint_inputs: dict | None = None + if config is not None: + self._run_variables = config.get(CONF_VARIABLES) + self._blueprint_inputs = getattr(config, "raw_blueprint_inputs", None) + + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + if self._blueprint_inputs is None: + return None + return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH]) @property def unique_id(self) -> str | None: @@ -70,17 +91,17 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def _attach_triggers(self, start_event: Event | None = None) -> None: """Attach the triggers.""" - if CONF_ACTION in self.config: + if CONF_ACTIONS in self.config: self._script = Script( self.hass, - self.config[CONF_ACTION], + self.config[CONF_ACTIONS], self.name, DOMAIN, ) - if CONF_CONDITION in self.config: + if CONF_CONDITIONS in self.config: self._cond_func = await condition.async_conditions_from_config( - self.hass, self.config[CONF_CONDITION], _LOGGER, "template entity" + self.hass, self.config[CONF_CONDITIONS], _LOGGER, "template entity" ) if start_event is not None: @@ -93,7 +114,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): self._unsub_trigger = await trigger_helper.async_initialize_triggers( self.hass, - self.config[CONF_TRIGGER], + self.config[CONF_TRIGGERS], action, DOMAIN, self.name, @@ -104,6 +125,10 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def _handle_triggered_with_script( self, run_variables: TemplateVarsType, context: Context | None = None ) -> None: + # Render run variables after the trigger, before checking conditions. + if self._run_variables: + run_variables = self._run_variables.async_render(self.hass, run_variables) + if not self._check_condition(run_variables): return # Create a context referring to the trigger context. @@ -119,6 +144,9 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def _handle_triggered( self, run_variables: TemplateVarsType, context: Context | None = None ) -> None: + if self._run_variables: + run_variables = self._run_variables.async_render(self.hass, run_variables) + if not self._check_condition(run_variables): return self._execute_update(run_variables, context) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 306b4405c6a..e15180173b4 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -21,21 +21,25 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, + CONF_NAME, CONF_OPTIMISTIC, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template 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 .const import DOMAIN +from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) @@ -57,7 +61,9 @@ _VALID_STATES = [ "none", ] +CONF_POSITION = "position" CONF_POSITION_TEMPLATE = "position_template" +CONF_TILT = "tilt" CONF_TILT_TEMPLATE = "tilt_template" OPEN_ACTION = "open_cover" CLOSE_ACTION = "close_cover" @@ -75,7 +81,39 @@ TILT_FEATURES = ( | CoverEntityFeature.SET_TILT_POSITION ) +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_VALUE_TEMPLATE: CONF_STATE, + CONF_POSITION_TEMPLATE: CONF_POSITION, + CONF_TILT_TEMPLATE: CONF_TILT, +} + +DEFAULT_NAME = "Template Cover" + COVER_SCHEMA = vol.All( + vol.Schema( + { + vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, + vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_POSITION): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_TILT): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), +) + +LEGACY_COVER_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -99,29 +137,56 @@ COVER_SCHEMA = vol.All( ) PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA)} + {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(LEGACY_COVER_SCHEMA)} ) -async def _async_create_entities(hass, config): - """Create the Template cover.""" +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy switch configuration definitions to modern ones.""" covers = [] - for object_id, entity_config in config[CONF_COVERS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - unique_id = entity_config.get(CONF_UNIQUE_ID) + entity_conf = rewrite_common_legacy_to_modern_conf( + hass, entity_conf, LEGACY_FIELDS + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + covers.append(entity_conf) + + return covers + + +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template switches.""" + covers = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" covers.append( CoverTemplate( hass, - object_id, - entity_config, + entity_conf, unique_id, ) ) - return covers + async_add_entities(covers) async def async_setup_platform( @@ -131,7 +196,21 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template cover.""" - async_add_entities(await _async_create_entities(hass, config)) + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_COVERS]), + None, + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) class CoverTemplate(TemplateEntity, CoverEntity): @@ -141,57 +220,52 @@ class CoverTemplate(TemplateEntity, CoverEntity): def __init__( self, - hass, - object_id, - config, + hass: HomeAssistant, + config: dict[str, Any], unique_id, - ): + ) -> None: """Initialize the Template cover.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id - ) - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) - friendly_name = self._attr_name - self._template = config.get(CONF_VALUE_TEMPLATE) - self._position_template = config.get(CONF_POSITION_TEMPLATE) - self._tilt_template = config.get(CONF_TILT_TEMPLATE) + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, object_id, hass=hass + ) + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + self._template = config.get(CONF_STATE) + + self._position_template = config.get(CONF_POSITION) + self._tilt_template = config.get(CONF_TILT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._open_script = None - if (open_action := config.get(OPEN_ACTION)) is not None: - self._open_script = Script(hass, open_action, friendly_name, DOMAIN) - self._close_script = None - if (close_action := config.get(CLOSE_ACTION)) is not None: - self._close_script = Script(hass, close_action, friendly_name, DOMAIN) - self._stop_script = None - if (stop_action := config.get(STOP_ACTION)) is not None: - self._stop_script = Script(hass, stop_action, friendly_name, DOMAIN) - self._position_script = None - if (position_action := config.get(POSITION_ACTION)) is not None: - self._position_script = Script(hass, position_action, friendly_name, DOMAIN) - self._tilt_script = None - if (tilt_action := config.get(TILT_ACTION)) is not None: - self._tilt_script = Script(hass, tilt_action, friendly_name, DOMAIN) + + # The config requires (open and close scripts) or a set position script, + # therefore the base supported features will always include them. + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + for action_id, supported_feature in ( + (OPEN_ACTION, 0), + (CLOSE_ACTION, 0), + (STOP_ACTION, CoverEntityFeature.STOP), + (POSITION_ACTION, CoverEntityFeature.SET_POSITION), + (TILT_ACTION, TILT_FEATURES), + ): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + optimistic = config.get(CONF_OPTIMISTIC) self._optimistic = optimistic or ( optimistic is None and not self._template and not self._position_template ) tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC) self._tilt_optimistic = tilt_optimistic or not self._tilt_template - self._position = None + self._position: int | None = None self._is_opening = False self._is_closing = False - self._tilt_value = None - - supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - if self._stop_script is not None: - supported_features |= CoverEntityFeature.STOP - if self._position_script is not None: - supported_features |= CoverEntityFeature.SET_POSITION - if self._tilt_script is not None: - supported_features |= TILT_FEATURES - self._attr_supported_features = supported_features + self._tilt_value: int | None = None @callback def _async_setup_templates(self) -> None: @@ -317,7 +391,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): None is unknown, 0 is closed, 100 is fully open. """ - if self._position_template or self._position_script: + if self._position_template or self._action_scripts.get(POSITION_ACTION): return self._position return None @@ -331,11 +405,11 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" - if self._open_script: - await self.async_run_script(self._open_script, context=self._context) - elif self._position_script: + if open_script := self._action_scripts.get(OPEN_ACTION): + await self.async_run_script(open_script, context=self._context) + elif position_script := self._action_scripts.get(POSITION_ACTION): await self.async_run_script( - self._position_script, + position_script, run_variables={"position": 100}, context=self._context, ) @@ -345,11 +419,11 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" - if self._close_script: - await self.async_run_script(self._close_script, context=self._context) - elif self._position_script: + if close_script := self._action_scripts.get(CLOSE_ACTION): + await self.async_run_script(close_script, context=self._context) + elif position_script := self._action_scripts.get(POSITION_ACTION): await self.async_run_script( - self._position_script, + position_script, run_variables={"position": 0}, context=self._context, ) @@ -359,14 +433,14 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Fire the stop action.""" - if self._stop_script: - await self.async_run_script(self._stop_script, context=self._context) + if stop_script := self._action_scripts.get(STOP_ACTION): + await self.async_run_script(stop_script, context=self._context) async def async_set_cover_position(self, **kwargs: Any) -> None: """Set cover position.""" self._position = kwargs[ATTR_POSITION] await self.async_run_script( - self._position_script, + self._action_scripts[POSITION_ACTION], run_variables={"position": self._position}, context=self._context, ) @@ -377,7 +451,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): """Tilt the cover open.""" self._tilt_value = 100 await self.async_run_script( - self._tilt_script, + self._action_scripts[TILT_ACTION], run_variables={"tilt": self._tilt_value}, context=self._context, ) @@ -388,7 +462,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): """Tilt the cover closed.""" self._tilt_value = 0 await self.async_run_script( - self._tilt_script, + self._action_scripts[TILT_ACTION], run_variables={"tilt": self._tilt_value}, context=self._context, ) @@ -399,7 +473,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] await self.async_run_script( - self._tilt_script, + self._action_scripts[TILT_ACTION], run_variables={"tilt": self._tilt_value}, context=self._context, ) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py new file mode 100644 index 00000000000..3617d9acdee --- /dev/null +++ b/homeassistant/components/template/entity.py @@ -0,0 +1,64 @@ +"""Template entity base class.""" + +from collections.abc import Sequence +from typing import Any + +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.template import TemplateStateFromEntityId + + +class AbstractTemplateEntity(Entity): + """Actions linked to a template entity.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the entity.""" + + self.hass = hass + self._action_scripts: dict[str, Script] = {} + + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + raise NotImplementedError + + @callback + def _render_script_variables(self) -> dict: + """Render configured variables.""" + raise NotImplementedError + + def add_script( + self, + script_id: str, + config: Sequence[dict[str, Any]], + name: str, + domain: str, + ): + """Add an action script.""" + + self._action_scripts[script_id] = Script( + self.hass, + config, + f"{name} {script_id}", + domain, + ) + + async def async_run_script( + self, + script: Script, + *, + run_variables: _VarsType | None = None, + context: Context | None = None, + ) -> None: + """Run an action script.""" + if run_variables is None: + run_variables = {} + await script.async_run( + run_variables={ + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **self._render_script_variables(), + **run_variables, + }, + context=context, + ) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 6ed525fd45f..32e6b06d108 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -21,6 +21,8 @@ from homeassistant.components.fan import ( from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, + CONF_NAME, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_ON, @@ -29,15 +31,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template 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 .const import DOMAIN +from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) @@ -60,54 +64,121 @@ CONF_SET_PRESET_MODE_ACTION = "set_preset_mode" _VALID_DIRECTIONS = [DIRECTION_FORWARD, DIRECTION_REVERSE] +CONF_DIRECTION = "direction" +CONF_OSCILLATING = "oscillating" +CONF_PERCENTAGE = "percentage" +CONF_PRESET_MODE = "preset_mode" + +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_DIRECTION_TEMPLATE: CONF_DIRECTION, + CONF_OSCILLATING_TEMPLATE: CONF_OSCILLATING, + CONF_PERCENTAGE_TEMPLATE: CONF_PERCENTAGE, + CONF_PRESET_MODE_TEMPLATE: CONF_PRESET_MODE, + CONF_VALUE_TEMPLATE: CONF_STATE, +} + +DEFAULT_NAME = "Template Fan" + FAN_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_DIRECTION): cv.template, + vol.Optional(CONF_NAME): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OSCILLATING): cv.template, + vol.Optional(CONF_PERCENTAGE): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_PRESET_MODE): cv.template, + vol.Optional(CONF_PRESET_MODES): cv.ensure_list, + vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), +) + +LEGACY_FAN_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { + vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template, vol.Optional(CONF_PERCENTAGE_TEMPLATE): cv.template, vol.Optional(CONF_PRESET_MODE_TEMPLATE): cv.template, - vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template, - vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_PRESET_MODES): cv.ensure_list, + vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), - vol.Optional(CONF_PRESET_MODES): cv.ensure_list, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, } ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema), ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_FANS): cv.schema_with_slug_keys(FAN_SCHEMA)} + {vol.Required(CONF_FANS): cv.schema_with_slug_keys(LEGACY_FAN_SCHEMA)} ) -async def _async_create_entities(hass, config): - """Create the Template Fans.""" +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy fan configuration definitions to modern ones.""" fans = [] - for object_id, entity_config in config[CONF_FANS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - unique_id = entity_config.get(CONF_UNIQUE_ID) + entity_conf = rewrite_common_legacy_to_modern_conf( + hass, entity_conf, LEGACY_FIELDS + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + fans.append(entity_conf) + + return fans + + +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template fans.""" + fans = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" fans.append( TemplateFan( hass, - object_id, - entity_config, + entity_conf, unique_id, ) ) - return fans + async_add_entities(fans) async def async_setup_platform( @@ -117,7 +188,21 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template fans.""" - async_add_entities(await _async_create_entities(hass, config)) + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_FANS]), + None, + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) class TemplateFan(TemplateEntity, FanEntity): @@ -127,78 +212,53 @@ class TemplateFan(TemplateEntity, FanEntity): def __init__( self, - hass, - object_id, - config, + hass: HomeAssistant, + config: dict[str, Any], unique_id, - ): + ) -> None: """Initialize the fan.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, object_id, hass=hass + ) + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + + self._template = config.get(CONF_STATE) + self._percentage_template = config.get(CONF_PERCENTAGE) + self._preset_mode_template = config.get(CONF_PRESET_MODE) + self._oscillating_template = config.get(CONF_OSCILLATING) + self._direction_template = config.get(CONF_DIRECTION) + + self._attr_supported_features |= ( + FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - self.hass = hass - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) - friendly_name = self._attr_name - - self._template = config.get(CONF_VALUE_TEMPLATE) - self._percentage_template = config.get(CONF_PERCENTAGE_TEMPLATE) - self._preset_mode_template = config.get(CONF_PRESET_MODE_TEMPLATE) - self._oscillating_template = config.get(CONF_OSCILLATING_TEMPLATE) - self._direction_template = config.get(CONF_DIRECTION_TEMPLATE) - - self._on_script = Script(hass, config[CONF_ON_ACTION], friendly_name, DOMAIN) - self._off_script = Script(hass, config[CONF_OFF_ACTION], friendly_name, DOMAIN) - - self._set_percentage_script = None - if set_percentage_action := config.get(CONF_SET_PERCENTAGE_ACTION): - self._set_percentage_script = Script( - hass, set_percentage_action, friendly_name, DOMAIN - ) - - self._set_preset_mode_script = None - if set_preset_mode_action := config.get(CONF_SET_PRESET_MODE_ACTION): - self._set_preset_mode_script = Script( - hass, set_preset_mode_action, friendly_name, DOMAIN - ) - - self._set_oscillating_script = None - if set_oscillating_action := config.get(CONF_SET_OSCILLATING_ACTION): - self._set_oscillating_script = Script( - hass, set_oscillating_action, friendly_name, DOMAIN - ) - - self._set_direction_script = None - if set_direction_action := config.get(CONF_SET_DIRECTION_ACTION): - self._set_direction_script = Script( - hass, set_direction_action, friendly_name, DOMAIN - ) + for action_id, supported_feature in ( + (CONF_ON_ACTION, 0), + (CONF_OFF_ACTION, 0), + (CONF_SET_PERCENTAGE_ACTION, FanEntityFeature.SET_SPEED), + (CONF_SET_PRESET_MODE_ACTION, FanEntityFeature.PRESET_MODE), + (CONF_SET_OSCILLATING_ACTION, FanEntityFeature.OSCILLATE), + (CONF_SET_DIRECTION_ACTION, FanEntityFeature.DIRECTION), + ): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature self._state: bool | None = False - self._percentage = None - self._preset_mode = None - self._oscillating = None - self._direction = None + self._percentage: int | None = None + self._preset_mode: str | None = None + self._oscillating: bool | None = None + self._direction: str | None = None # Number of valid speeds self._speed_count = config.get(CONF_SPEED_COUNT) # List of valid preset modes - self._preset_modes = config.get(CONF_PRESET_MODES) - - if self._percentage_template: - self._attr_supported_features |= FanEntityFeature.SET_SPEED - if self._preset_mode_template and self._preset_modes: - self._attr_supported_features |= FanEntityFeature.PRESET_MODE - if self._oscillating_template: - self._attr_supported_features |= FanEntityFeature.OSCILLATE - if self._direction_template: - self._attr_supported_features |= FanEntityFeature.DIRECTION - self._attr_supported_features |= ( - FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - ) - + self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) self._attr_assumed_state = self._template is None @property @@ -207,7 +267,7 @@ class TemplateFan(TemplateEntity, FanEntity): return self._speed_count or 100 @property - def preset_modes(self) -> list[str]: + def preset_modes(self) -> list[str] | None: """Get the list of available preset modes.""" return self._preset_modes @@ -244,7 +304,7 @@ class TemplateFan(TemplateEntity, FanEntity): ) -> None: """Turn on the fan.""" await self.async_run_script( - self._on_script, + self._action_scripts[CONF_ON_ACTION], run_variables={ ATTR_PERCENTAGE: percentage, ATTR_PRESET_MODE: preset_mode, @@ -254,7 +314,7 @@ class TemplateFan(TemplateEntity, FanEntity): if preset_mode is not None: await self.async_set_preset_mode(preset_mode) - elif percentage is not None: + if percentage is not None: await self.async_set_percentage(percentage) if self._template is None: @@ -263,7 +323,9 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - await self.async_run_script(self._off_script, context=self._context) + await self.async_run_script( + self._action_scripts[CONF_OFF_ACTION], context=self._context + ) if self._template is None: self._state = False @@ -273,56 +335,65 @@ class TemplateFan(TemplateEntity, FanEntity): """Set the percentage speed of the fan.""" self._percentage = percentage - if self._set_percentage_script: + if script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION): await self.async_run_script( - self._set_percentage_script, + script, run_variables={ATTR_PERCENTAGE: self._percentage}, context=self._context, ) if self._template is None: self._state = percentage != 0 + + if self._template is None or self._percentage_template is None: self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset_mode of the fan.""" self._preset_mode = preset_mode - if self._set_preset_mode_script: + if script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION): await self.async_run_script( - self._set_preset_mode_script, + script, run_variables={ATTR_PRESET_MODE: self._preset_mode}, context=self._context, ) if self._template is None: self._state = True + + if self._template is None or self._preset_mode_template is None: self.async_write_ha_state() async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation of the fan.""" - if self._set_oscillating_script is None: - return - self._oscillating = oscillating - await self.async_run_script( - self._set_oscillating_script, - run_variables={ATTR_OSCILLATING: self.oscillating}, - context=self._context, - ) + if ( + script := self._action_scripts.get(CONF_SET_OSCILLATING_ACTION) + ) is not None: + await self.async_run_script( + script, + run_variables={ATTR_OSCILLATING: self.oscillating}, + context=self._context, + ) + + if self._oscillating_template is None: + self.async_write_ha_state() async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" - if self._set_direction_script is None: - return - if direction in _VALID_DIRECTIONS: self._direction = direction - await self.async_run_script( - self._set_direction_script, - run_variables={ATTR_DIRECTION: direction}, - context=self._context, - ) + if ( + script := self._action_scripts.get(CONF_SET_DIRECTION_ACTION) + ) is not None: + await self.async_run_script( + script, + run_variables={ATTR_DIRECTION: direction}, + context=self._context, + ) + if self._direction_template is None: + self.async_write_ha_state() else: _LOGGER.error( "Received invalid direction: %s for entity %s. Expected: %s", diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index b320f2128cd..660227f65dc 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -8,8 +8,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.singleton import singleton -from .const import DOMAIN, TEMPLATE_BLUEPRINT_SCHEMA -from .template_entity import TemplateEntity +from .const import DOMAIN +from .entity import AbstractTemplateEntity DATA_BLUEPRINTS = "template_blueprints" @@ -23,7 +23,7 @@ def templates_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[s entity_id for platform in async_get_platforms(hass, DOMAIN) for entity_id, template_entity in platform.entities.items() - if isinstance(template_entity, TemplateEntity) + if isinstance(template_entity, AbstractTemplateEntity) and template_entity.referenced_blueprint == blueprint_path ] @@ -33,7 +33,8 @@ def blueprint_in_template(hass: HomeAssistant, entity_id: str) -> str | None: """Return the blueprint the template entity is based on or None.""" for platform in async_get_platforms(hass, DOMAIN): if isinstance( - (template_entity := platform.entities.get(entity_id)), TemplateEntity + (template_entity := platform.entities.get(entity_id)), + AbstractTemplateEntity, ): return template_entity.referenced_blueprint return None @@ -53,6 +54,9 @@ async def _reload_blueprint_templates(hass: HomeAssistant, blueprint_path: str) @callback def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: """Get template blueprints.""" + # pylint: disable-next=import-outside-toplevel + from .config import TEMPLATE_BLUEPRINT_SCHEMA + return blueprint.DomainBlueprints( hass, DOMAIN, diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 206703ddcce..3b64cca26b4 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Generator, Sequence import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -18,6 +19,7 @@ from homeassistant.components.light import ( ATTR_TRANSITION, DEFAULT_MAX_KELVIN, DEFAULT_MIN_KELVIN, + DOMAIN as LIGHT_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, @@ -26,9 +28,13 @@ from homeassistant.components.light import ( filter_supported_color_modes, ) from homeassistant.const import ( + CONF_EFFECT, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_LIGHTS, + CONF_NAME, + CONF_RGB, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_OFF, @@ -36,19 +42,23 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template 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 . import TriggerUpdateCoordinator +from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] @@ -57,33 +67,96 @@ _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] CONF_COLOR_ACTION = "set_color" CONF_COLOR_TEMPLATE = "color_template" +CONF_HS = "hs" CONF_HS_ACTION = "set_hs" CONF_HS_TEMPLATE = "hs_template" CONF_RGB_ACTION = "set_rgb" CONF_RGB_TEMPLATE = "rgb_template" +CONF_RGBW = "rgbw" CONF_RGBW_ACTION = "set_rgbw" CONF_RGBW_TEMPLATE = "rgbw_template" +CONF_RGBWW = "rgbww" CONF_RGBWW_ACTION = "set_rgbww" CONF_RGBWW_TEMPLATE = "rgbww_template" CONF_EFFECT_ACTION = "set_effect" +CONF_EFFECT_LIST = "effect_list" CONF_EFFECT_LIST_TEMPLATE = "effect_list_template" CONF_EFFECT_TEMPLATE = "effect_template" +CONF_LEVEL = "level" CONF_LEVEL_ACTION = "set_level" CONF_LEVEL_TEMPLATE = "level_template" +CONF_MAX_MIREDS = "max_mireds" CONF_MAX_MIREDS_TEMPLATE = "max_mireds_template" +CONF_MIN_MIREDS = "min_mireds" CONF_MIN_MIREDS_TEMPLATE = "min_mireds_template" CONF_OFF_ACTION = "turn_off" CONF_ON_ACTION = "turn_on" -CONF_SUPPORTS_TRANSITION = "supports_transition_template" +CONF_SUPPORTS_TRANSITION = "supports_transition" +CONF_SUPPORTS_TRANSITION_TEMPLATE = "supports_transition_template" CONF_TEMPERATURE_ACTION = "set_temperature" +CONF_TEMPERATURE = "temperature" CONF_TEMPERATURE_TEMPLATE = "temperature_template" CONF_WHITE_VALUE_ACTION = "set_white_value" +CONF_WHITE_VALUE = "white_value" CONF_WHITE_VALUE_TEMPLATE = "white_value_template" DEFAULT_MIN_MIREDS = 153 DEFAULT_MAX_MIREDS = 500 -LIGHT_SCHEMA = vol.All( +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_COLOR_ACTION: CONF_HS_ACTION, + CONF_COLOR_TEMPLATE: CONF_HS, + CONF_EFFECT_LIST_TEMPLATE: CONF_EFFECT_LIST, + CONF_EFFECT_TEMPLATE: CONF_EFFECT, + CONF_HS_TEMPLATE: CONF_HS, + CONF_LEVEL_TEMPLATE: CONF_LEVEL, + CONF_MAX_MIREDS_TEMPLATE: CONF_MAX_MIREDS, + CONF_MIN_MIREDS_TEMPLATE: CONF_MIN_MIREDS, + CONF_RGB_TEMPLATE: CONF_RGB, + CONF_RGBW_TEMPLATE: CONF_RGBW, + CONF_RGBWW_TEMPLATE: CONF_RGBWW, + CONF_SUPPORTS_TRANSITION_TEMPLATE: CONF_SUPPORTS_TRANSITION, + CONF_TEMPERATURE_TEMPLATE: CONF_TEMPERATURE, + CONF_VALUE_TEMPLATE: CONF_STATE, + CONF_WHITE_VALUE_TEMPLATE: CONF_WHITE_VALUE, +} + +DEFAULT_NAME = "Template Light" + +LIGHT_SCHEMA = ( + vol.Schema( + { + vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, + vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, + vol.Inclusive(CONF_EFFECT, "effect"): cv.template, + vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_HS): cv.template, + vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_LEVEL): cv.template, + vol.Optional(CONF_MAX_MIREDS): cv.template, + vol.Optional(CONF_MIN_MIREDS): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGB): cv.template, + vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBW): cv.template, + vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBWW): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, + vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TEMPERATURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) +) + +LEGACY_LIGHT_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -108,7 +181,7 @@ LIGHT_SCHEMA = vol.All( vol.Optional(CONF_MIN_MIREDS_TEMPLATE): cv.template, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, + vol.Optional(CONF_SUPPORTS_TRANSITION_TEMPLATE): cv.template, vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, @@ -122,29 +195,50 @@ PLATFORM_SCHEMA = vol.All( cv.removed(CONF_WHITE_VALUE_ACTION), cv.removed(CONF_WHITE_VALUE_TEMPLATE), LIGHT_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_SCHEMA)} + {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LEGACY_LIGHT_SCHEMA)} ), ) -async def _async_create_entities(hass, config): +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy switch configuration definitions to modern ones.""" + lights = [] + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_common_legacy_to_modern_conf( + hass, entity_conf, LEGACY_FIELDS + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + lights.append(entity_conf) + + return lights + + +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: """Create the Template Lights.""" lights = [] - for object_id, entity_config in config[CONF_LIGHTS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) - unique_id = entity_config.get(CONF_UNIQUE_ID) + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) - lights.append( - LightTemplate( - hass, - object_id, - entity_config, - unique_id, - ) - ) + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" - return lights + lights.append(LightTemplate(hass, entity_conf, unique_id)) + + async_add_entities(lights) async def async_setup_platform( @@ -154,113 +248,87 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template lights.""" - async_add_entities(await _async_create_entities(hass, config)) + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_LIGHTS]), + None, + ) + return + + if "coordinator" in discovery_info: + async_add_entities( + TriggerLightEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) -class LightTemplate(TemplateEntity, LightEntity): - """Representation of a templated Light, including dimmable.""" - - _attr_should_poll = False +class AbstractTemplateLight(LightEntity): + """Representation of a template lights features.""" def __init__( - self, - hass, - object_id, - config, - unique_id, - ): - """Initialize the light.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id - ) - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) - friendly_name = self._attr_name - self._template = config.get(CONF_VALUE_TEMPLATE) - self._on_script = Script(hass, config[CONF_ON_ACTION], friendly_name, DOMAIN) - self._off_script = Script(hass, config[CONF_OFF_ACTION], friendly_name, DOMAIN) - self._level_script = None - if (level_action := config.get(CONF_LEVEL_ACTION)) is not None: - self._level_script = Script(hass, level_action, friendly_name, DOMAIN) - self._level_template = config.get(CONF_LEVEL_TEMPLATE) - self._temperature_script = None - if (temperature_action := config.get(CONF_TEMPERATURE_ACTION)) is not None: - self._temperature_script = Script( - hass, temperature_action, friendly_name, DOMAIN - ) - self._temperature_template = config.get(CONF_TEMPERATURE_TEMPLATE) - self._color_script = None - if (color_action := config.get(CONF_COLOR_ACTION)) is not None: - self._color_script = Script(hass, color_action, friendly_name, DOMAIN) - self._color_template = config.get(CONF_COLOR_TEMPLATE) - self._hs_script = None - if (hs_action := config.get(CONF_HS_ACTION)) is not None: - self._hs_script = Script(hass, hs_action, friendly_name, DOMAIN) - self._hs_template = config.get(CONF_HS_TEMPLATE) - self._rgb_script = None - if (rgb_action := config.get(CONF_RGB_ACTION)) is not None: - self._rgb_script = Script(hass, rgb_action, friendly_name, DOMAIN) - self._rgb_template = config.get(CONF_RGB_TEMPLATE) - self._rgbw_script = None - if (rgbw_action := config.get(CONF_RGBW_ACTION)) is not None: - self._rgbw_script = Script(hass, rgbw_action, friendly_name, DOMAIN) - self._rgbw_template = config.get(CONF_RGBW_TEMPLATE) - self._rgbww_script = None - if (rgbww_action := config.get(CONF_RGBWW_ACTION)) is not None: - self._rgbww_script = Script(hass, rgbww_action, friendly_name, DOMAIN) - self._rgbww_template = config.get(CONF_RGBWW_TEMPLATE) - self._effect_script = None - if (effect_action := config.get(CONF_EFFECT_ACTION)) is not None: - self._effect_script = Script(hass, effect_action, friendly_name, DOMAIN) - self._effect_list_template = config.get(CONF_EFFECT_LIST_TEMPLATE) - self._effect_template = config.get(CONF_EFFECT_TEMPLATE) - self._max_mireds_template = config.get(CONF_MAX_MIREDS_TEMPLATE) - self._min_mireds_template = config.get(CONF_MIN_MIREDS_TEMPLATE) + self, config: dict[str, Any], initial_state: bool | None = False + ) -> None: + """Initialize the features.""" + + self._registered_scripts: list[str] = [] + + # Template attributes + self._template = config.get(CONF_STATE) + self._level_template = config.get(CONF_LEVEL) + self._temperature_template = config.get(CONF_TEMPERATURE) + self._hs_template = config.get(CONF_HS) + self._rgb_template = config.get(CONF_RGB) + self._rgbw_template = config.get(CONF_RGBW) + self._rgbww_template = config.get(CONF_RGBWW) + self._effect_list_template = config.get(CONF_EFFECT_LIST) + self._effect_template = config.get(CONF_EFFECT) + self._max_mireds_template = config.get(CONF_MAX_MIREDS) + self._min_mireds_template = config.get(CONF_MIN_MIREDS) self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION) - self._state = False + # Stored values for template attributes + self._state = initial_state self._brightness = None - self._temperature = None + self._temperature: int | None = None self._hs_color = None self._rgb_color = None self._rgbw_color = None self._rgbww_color = None self._effect = None self._effect_list = None - self._color_mode = None self._max_mireds = None self._min_mireds = None self._supports_transition = False - self._supported_color_modes = None + self._color_mode: ColorMode | None = None + self._supported_color_modes: set[ColorMode] | None = None - color_modes = {ColorMode.ONOFF} - if self._level_script is not None: - color_modes.add(ColorMode.BRIGHTNESS) - if self._temperature_script is not None: - color_modes.add(ColorMode.COLOR_TEMP) - if self._hs_script is not None: - color_modes.add(ColorMode.HS) - if self._color_script is not None: - color_modes.add(ColorMode.HS) - if self._rgb_script is not None: - color_modes.add(ColorMode.RGB) - if self._rgbw_script is not None: - color_modes.add(ColorMode.RGBW) - if self._rgbww_script is not None: - color_modes.add(ColorMode.RGBWW) - - self._supported_color_modes = filter_supported_color_modes(color_modes) - if len(self._supported_color_modes) > 1: - self._color_mode = ColorMode.UNKNOWN - if len(self._supported_color_modes) == 1: - self._color_mode = next(iter(self._supported_color_modes)) - - self._attr_supported_features = LightEntityFeature(0) - if self._effect_script is not None: - self._attr_supported_features |= LightEntityFeature.EFFECT - if self._supports_transition is True: - self._attr_supported_features |= LightEntityFeature.TRANSITION + def _register_scripts( + self, config: dict[str, Any] + ) -> Generator[tuple[str, Sequence[dict[str, Any]], ColorMode | None]]: + for action_id, color_mode in ( + (CONF_ON_ACTION, None), + (CONF_OFF_ACTION, None), + (CONF_EFFECT_ACTION, None), + (CONF_TEMPERATURE_ACTION, ColorMode.COLOR_TEMP), + (CONF_LEVEL_ACTION, ColorMode.BRIGHTNESS), + (CONF_HS_ACTION, ColorMode.HS), + (CONF_RGB_ACTION, ColorMode.RGB), + (CONF_RGBW_ACTION, ColorMode.RGBW), + (CONF_RGBWW_ACTION, ColorMode.RGBWW), + ): + if (action_config := config.get(action_id)) is not None: + self._registered_scripts.append(action_id) + yield (action_id, action_config, color_mode) @property def brightness(self) -> int | None: @@ -321,12 +389,12 @@ class LightTemplate(TemplateEntity, LightEntity): return self._effect_list @property - def color_mode(self): + def color_mode(self) -> ColorMode | None: """Return current color mode.""" return self._color_mode @property - def supported_color_modes(self): + def supported_color_modes(self) -> set[ColorMode] | None: """Flag supported color modes.""" return self._supported_color_modes @@ -335,115 +403,12 @@ class LightTemplate(TemplateEntity, LightEntity): """Return true if device is on.""" return self._state - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - if self._template: - self.add_template_attribute( - "_state", self._template, None, self._update_state - ) - if self._level_template: - self.add_template_attribute( - "_brightness", - self._level_template, - None, - self._update_brightness, - none_on_template_error=True, - ) - if self._max_mireds_template: - self.add_template_attribute( - "_max_mireds_template", - self._max_mireds_template, - None, - self._update_max_mireds, - none_on_template_error=True, - ) - if self._min_mireds_template: - self.add_template_attribute( - "_min_mireds_template", - self._min_mireds_template, - None, - self._update_min_mireds, - none_on_template_error=True, - ) - if self._temperature_template: - self.add_template_attribute( - "_temperature", - self._temperature_template, - None, - self._update_temperature, - none_on_template_error=True, - ) - if self._color_template: - self.add_template_attribute( - "_hs_color", - self._color_template, - None, - self._update_hs, - none_on_template_error=True, - ) - if self._hs_template: - self.add_template_attribute( - "_hs_color", - self._hs_template, - None, - self._update_hs, - none_on_template_error=True, - ) - if self._rgb_template: - self.add_template_attribute( - "_rgb_color", - self._rgb_template, - None, - self._update_rgb, - none_on_template_error=True, - ) - if self._rgbw_template: - self.add_template_attribute( - "_rgbw_color", - self._rgbw_template, - None, - self._update_rgbw, - none_on_template_error=True, - ) - if self._rgbww_template: - self.add_template_attribute( - "_rgbww_color", - self._rgbww_template, - None, - self._update_rgbww, - none_on_template_error=True, - ) - if self._effect_list_template: - self.add_template_attribute( - "_effect_list", - self._effect_list_template, - None, - self._update_effect_list, - none_on_template_error=True, - ) - if self._effect_template: - self.add_template_attribute( - "_effect", - self._effect_template, - None, - self._update_effect, - none_on_template_error=True, - ) - if self._supports_transition_template: - self.add_template_attribute( - "_supports_transition_template", - self._supports_transition_template, - None, - self._update_supports_transition, - none_on_template_error=True, - ) - super()._async_setup_templates() + def set_optimistic_attributes(self, **kwargs) -> bool: # noqa: C901 + """Update attributes which should be set optimistically. - async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901 - """Turn the light on.""" + Returns True if any attribute was updated. + """ optimistic_set = False - # set optimistic states if self._template is None: self._state = True optimistic_set = True @@ -465,7 +430,7 @@ class LightTemplate(TemplateEntity, LightEntity): ) self._color_mode = ColorMode.COLOR_TEMP self._temperature = color_temp - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgb_template is None: self._rgb_color = None @@ -475,11 +440,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbww_color = None optimistic_set = True - if ( - self._hs_template is None - and self._color_template is None - and ATTR_HS_COLOR in kwargs - ): + if self._hs_template is None and ATTR_HS_COLOR in kwargs: _LOGGER.debug( "Optimistically setting hs color to %s", kwargs[ATTR_HS_COLOR], @@ -505,7 +466,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgb_color = kwargs[ATTR_RGB_COLOR] if self._temperature_template is None: self._temperature = None - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgbw_template is None: self._rgbw_color = None @@ -522,7 +483,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbw_color = kwargs[ATTR_RGBW_COLOR] if self._temperature_template is None: self._temperature = None - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgb_template is None: self._rgb_color = None @@ -539,7 +500,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbww_color = kwargs[ATTR_RGBWW_COLOR] if self._temperature_template is None: self._temperature = None - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgb_template is None: self._rgb_color = None @@ -547,6 +508,10 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbw_color = None optimistic_set = True + return optimistic_set + + def get_registered_script(self, **kwargs) -> tuple[str, dict]: + """Get registered script for turn_on.""" common_params = {} if ATTR_BRIGHTNESS in kwargs: @@ -555,19 +520,23 @@ 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_KELVIN in kwargs and self._temperature_script: + if ( + ATTR_COLOR_TEMP_KELVIN in kwargs + and (script := CONF_TEMPERATURE_ACTION) in self._registered_scripts + ): common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired( kwargs[ATTR_COLOR_TEMP_KELVIN] ) - await self.async_run_script( - self._temperature_script, - run_variables=common_params, - context=self._context, - ) - elif ATTR_EFFECT in kwargs and self._effect_script: + return (script, common_params) + + if ( + ATTR_EFFECT in kwargs + and (script := CONF_EFFECT_ACTION) in self._registered_scripts + ): + assert self._effect_list is not None effect = kwargs[ATTR_EFFECT] - if effect not in self._effect_list: + if self._effect_list is not None and effect not in self._effect_list: _LOGGER.error( "Received invalid effect: %s for entity %s. Expected one of: %s", effect, @@ -578,28 +547,23 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["effect"] = effect - await self.async_run_script( - self._effect_script, run_variables=common_params, context=self._context - ) - elif ATTR_HS_COLOR in kwargs and self._color_script: + return (script, common_params) + + if ( + ATTR_HS_COLOR in kwargs + and (script := CONF_HS_ACTION) in self._registered_scripts + ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value common_params["h"] = int(hs_value[0]) common_params["s"] = int(hs_value[1]) - await self.async_run_script( - self._color_script, run_variables=common_params, context=self._context - ) - elif ATTR_HS_COLOR in kwargs and self._hs_script: - hs_value = kwargs[ATTR_HS_COLOR] - common_params["hs"] = hs_value - common_params["h"] = int(hs_value[0]) - common_params["s"] = int(hs_value[1]) + return (script, common_params) - await self.async_run_script( - self._hs_script, run_variables=common_params, context=self._context - ) - elif ATTR_RGBWW_COLOR in kwargs and self._rgbww_script: + if ( + ATTR_RGBWW_COLOR in kwargs + and (script := CONF_RGBWW_ACTION) in self._registered_scripts + ): rgbww_value = kwargs[ATTR_RGBWW_COLOR] common_params["rgbww"] = rgbww_value common_params["rgb"] = ( @@ -613,10 +577,12 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["cw"] = int(rgbww_value[3]) common_params["ww"] = int(rgbww_value[4]) - await self.async_run_script( - self._rgbww_script, run_variables=common_params, context=self._context - ) - elif ATTR_RGBW_COLOR in kwargs and self._rgbw_script: + return (script, common_params) + + if ( + ATTR_RGBW_COLOR in kwargs + and (script := CONF_RGBW_ACTION) in self._registered_scripts + ): rgbw_value = kwargs[ATTR_RGBW_COLOR] common_params["rgbw"] = rgbw_value common_params["rgb"] = ( @@ -629,44 +595,27 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["b"] = int(rgbw_value[2]) common_params["w"] = int(rgbw_value[3]) - await self.async_run_script( - self._rgbw_script, run_variables=common_params, context=self._context - ) - elif ATTR_RGB_COLOR in kwargs and self._rgb_script: + return (script, common_params) + + if ( + ATTR_RGB_COLOR in kwargs + and (script := CONF_RGB_ACTION) in self._registered_scripts + ): rgb_value = kwargs[ATTR_RGB_COLOR] common_params["rgb"] = rgb_value common_params["r"] = int(rgb_value[0]) common_params["g"] = int(rgb_value[1]) common_params["b"] = int(rgb_value[2]) - await self.async_run_script( - self._rgb_script, run_variables=common_params, context=self._context - ) - elif ATTR_BRIGHTNESS in kwargs and self._level_script: - await self.async_run_script( - self._level_script, run_variables=common_params, context=self._context - ) - else: - await self.async_run_script( - self._on_script, run_variables=common_params, context=self._context - ) + return (script, common_params) - if optimistic_set: - self.async_write_ha_state() + if ( + ATTR_BRIGHTNESS in kwargs + and (script := CONF_LEVEL_ACTION) in self._registered_scripts + ): + return (script, common_params) - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the light off.""" - if ATTR_TRANSITION in kwargs and self._supports_transition is True: - await self.async_run_script( - self._off_script, - run_variables={"transition": kwargs[ATTR_TRANSITION]}, - context=self._context, - ) - else: - await self.async_run_script(self._off_script, context=self._context) - if self._template is None: - self._state = False - self.async_write_ha_state() + return (CONF_ON_ACTION, common_params) @callback def _update_brightness(self, brightness): @@ -734,33 +683,6 @@ class LightTemplate(TemplateEntity, LightEntity): self._effect = effect - @callback - def _update_state(self, result): - """Update the state from the template.""" - if isinstance(result, TemplateError): - # This behavior is legacy - self._state = False - if not self._availability_template: - self._attr_available = True - return - - if isinstance(result, bool): - self._state = result - return - - state = str(result).lower() - if state in _VALID_STATES: - self._state = state in ("true", STATE_ON) - return - - _LOGGER.error( - "Received invalid light is_on state: %s for entity %s. Expected: %s", - state, - self.entity_id, - ", ".join(_VALID_STATES), - ) - self._state = None - @callback def _update_temperature(self, render): """Update the temperature from the template.""" @@ -1017,3 +939,338 @@ class LightTemplate(TemplateEntity, LightEntity): self._supports_transition = bool(render) if self._supports_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION + + +class LightTemplate(TemplateEntity, AbstractTemplateLight): + """Representation of a templated Light, including dimmable.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: dict[str, Any], + unique_id: str | None, + ) -> None: + """Initialize the light.""" + TemplateEntity.__init__( + self, hass, config=config, fallback_name=None, unique_id=unique_id + ) + AbstractTemplateLight.__init__(self, config) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, object_id, hass=hass + ) + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + + color_modes = {ColorMode.ONOFF} + for action_id, action_config, color_mode in self._register_scripts(config): + self.add_script(action_id, action_config, name, DOMAIN) + if color_mode: + color_modes.add(color_mode) + + self._supported_color_modes = filter_supported_color_modes(color_modes) + if len(self._supported_color_modes) > 1: + self._color_mode = ColorMode.UNKNOWN + if len(self._supported_color_modes) == 1: + self._color_mode = next(iter(self._supported_color_modes)) + + self._attr_supported_features = LightEntityFeature(0) + if self._action_scripts.get(CONF_EFFECT_ACTION): + self._attr_supported_features |= LightEntityFeature.EFFECT + if self._supports_transition is True: + self._attr_supported_features |= LightEntityFeature.TRANSITION + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if self._template: + self.add_template_attribute( + "_state", self._template, None, self._update_state + ) + if self._level_template: + self.add_template_attribute( + "_brightness", + self._level_template, + None, + self._update_brightness, + none_on_template_error=True, + ) + if self._max_mireds_template: + self.add_template_attribute( + "_max_mireds_template", + self._max_mireds_template, + None, + self._update_max_mireds, + none_on_template_error=True, + ) + if self._min_mireds_template: + self.add_template_attribute( + "_min_mireds_template", + self._min_mireds_template, + None, + self._update_min_mireds, + none_on_template_error=True, + ) + if self._temperature_template: + self.add_template_attribute( + "_temperature", + self._temperature_template, + None, + self._update_temperature, + none_on_template_error=True, + ) + if self._hs_template: + self.add_template_attribute( + "_hs_color", + self._hs_template, + None, + self._update_hs, + none_on_template_error=True, + ) + if self._rgb_template: + self.add_template_attribute( + "_rgb_color", + self._rgb_template, + None, + self._update_rgb, + none_on_template_error=True, + ) + if self._rgbw_template: + self.add_template_attribute( + "_rgbw_color", + self._rgbw_template, + None, + self._update_rgbw, + none_on_template_error=True, + ) + if self._rgbww_template: + self.add_template_attribute( + "_rgbww_color", + self._rgbww_template, + None, + self._update_rgbww, + none_on_template_error=True, + ) + if self._effect_list_template: + self.add_template_attribute( + "_effect_list", + self._effect_list_template, + None, + self._update_effect_list, + none_on_template_error=True, + ) + if self._effect_template: + self.add_template_attribute( + "_effect", + self._effect_template, + None, + self._update_effect, + none_on_template_error=True, + ) + if self._supports_transition_template: + self.add_template_attribute( + "_supports_transition_template", + self._supports_transition_template, + None, + self._update_supports_transition, + none_on_template_error=True, + ) + super()._async_setup_templates() + + @callback + def _update_state(self, result): + """Update the state from the template.""" + if isinstance(result, TemplateError): + # This behavior is legacy + self._state = False + if not self._availability_template: + self._attr_available = True + return + + if isinstance(result, bool): + self._state = result + return + + state = str(result).lower() + if state in _VALID_STATES: + self._state = state in ("true", STATE_ON) + return + + _LOGGER.error( + "Received invalid light is_on state: %s for entity %s. Expected: %s", + state, + self.entity_id, + ", ".join(_VALID_STATES), + ) + self._state = None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + optimistic_set = self.set_optimistic_attributes(**kwargs) + script_id, script_params = self.get_registered_script(**kwargs) + await self.async_run_script( + self._action_scripts[script_id], + run_variables=script_params, + context=self._context, + ) + + if optimistic_set: + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + off_script = self._action_scripts[CONF_OFF_ACTION] + if ATTR_TRANSITION in kwargs and self._supports_transition is True: + await self.async_run_script( + off_script, + run_variables={"transition": kwargs[ATTR_TRANSITION]}, + context=self._context, + ) + else: + await self.async_run_script(off_script, context=self._context) + if self._template is None: + self._state = False + self.async_write_ha_state() + + +class TriggerLightEntity(TriggerEntity, AbstractTemplateLight): + """Light entity based on trigger data.""" + + domain = LIGHT_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateLight.__init__(self, config, None) + + # Render the _attr_name before initializing TemplateLightEntity + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + + self._optimistic_attrs: dict[str, str] = {} + self._optimistic = True + for key in ( + CONF_STATE, + CONF_LEVEL, + CONF_TEMPERATURE, + CONF_RGB, + CONF_RGBW, + CONF_RGBWW, + CONF_EFFECT, + CONF_MAX_MIREDS, + CONF_MIN_MIREDS, + CONF_SUPPORTS_TRANSITION, + ): + if isinstance(config.get(key), template.Template): + if key == CONF_STATE: + self._optimistic = False + self._to_render_simple.append(key) + self._parse_result.add(key) + + for key in (CONF_EFFECT_LIST, CONF_HS): + if isinstance(config.get(key), template.Template): + self._to_render_complex.append(key) + self._parse_result.add(key) + + color_modes = {ColorMode.ONOFF} + for action_id, action_config, color_mode in self._register_scripts(config): + self.add_script(action_id, action_config, name, DOMAIN) + if color_mode: + color_modes.add(color_mode) + + self._supported_color_modes = filter_supported_color_modes(color_modes) + if len(self._supported_color_modes) > 1: + self._color_mode = ColorMode.UNKNOWN + if len(self._supported_color_modes) == 1: + self._color_mode = next(iter(self._supported_color_modes)) + + self._attr_supported_features = LightEntityFeature(0) + if self._action_scripts.get(CONF_EFFECT_ACTION): + self._attr_supported_features |= LightEntityFeature.EFFECT + if self._supports_transition is True: + self._attr_supported_features |= LightEntityFeature.TRANSITION + + @callback + def _handle_coordinator_update(self) -> None: + """Handle update of the data.""" + self._process_data() + + if not self.available: + self.async_write_ha_state() + return + + write_ha_state = False + for key, updater in ( + (CONF_LEVEL, self._update_brightness), + (CONF_EFFECT_LIST, self._update_effect_list), + (CONF_EFFECT, self._update_effect), + (CONF_TEMPERATURE, self._update_temperature), + (CONF_HS, self._update_hs), + (CONF_RGB, self._update_rgb), + (CONF_RGBW, self._update_rgbw), + (CONF_RGBWW, self._update_rgbww), + (CONF_MAX_MIREDS, self._update_max_mireds), + (CONF_MIN_MIREDS, self._update_min_mireds), + ): + if (rendered := self._rendered.get(key)) is not None: + updater(rendered) + write_ha_state = True + + if (rendered := self._rendered.get(CONF_SUPPORTS_TRANSITION)) is not None: + self._update_supports_transition(rendered) + write_ha_state = True + + if not self._optimistic: + raw = self._rendered.get(CONF_STATE) + self._state = template.result_as_boolean(raw) + + self.async_set_context(self.coordinator.data["context"]) + write_ha_state = True + elif self._optimistic and len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + optimistic_set = self.set_optimistic_attributes(**kwargs) + script_id, script_params = self.get_registered_script(**kwargs) + if self._template and self._state is None: + # Ensure an optimistic state is set on the entity when turn_on + # is called and the main state hasn't rendered. This will only + # occur when the state is unknown, the template hasn't triggered, + # and turn_on is called. + self._state = True + + await self.async_run_script( + self._action_scripts[script_id], + run_variables=script_params, + context=self._context, + ) + + if optimistic_set: + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + off_script = self._action_scripts[CONF_OFF_ACTION] + if ATTR_TRANSITION in kwargs and self._supports_transition is True: + await self.async_run_script( + off_script, + run_variables={"transition": kwargs[ATTR_TRANSITION]}, + context=self._context, + ) + else: + await self.async_run_script(off_script, context=self._context) + if self._template is None: + self._state = False + self.async_write_ha_state() diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 0804f92e46d..c858325e0ea 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ATTR_CODE, CONF_NAME, CONF_OPTIMISTIC, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) @@ -23,17 +24,20 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN +from .const import CONF_PICTURE, DOMAIN from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) CONF_CODE_FORMAT_TEMPLATE = "code_format_template" +CONF_CODE_FORMAT = "code_format" CONF_LOCK = "lock" CONF_UNLOCK = "unlock" CONF_OPEN = "open" @@ -41,26 +45,69 @@ CONF_OPEN = "open" DEFAULT_NAME = "Template Lock" DEFAULT_OPTIMISTIC = False +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_CODE_FORMAT_TEMPLATE: CONF_CODE_FORMAT, + CONF_VALUE_TEMPLATE: CONF_STATE, +} + +LOCK_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_CODE_FORMAT): cv.template, + vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PICTURE): cv.template, + vol.Required(CONF_STATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), +) + + 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.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, + vol.Required(CONF_VALUE_TEMPLATE): cv.template, } ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) -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))] +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template fans.""" + fans = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + + fans.append( + TemplateLock( + hass, + entity_conf, + unique_id, + ) + ) + + async_add_entities(fans) async def async_setup_platform( @@ -69,8 +116,22 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the template lock.""" - async_add_entities(await _async_create_entities(hass, config)) + """Set up the template fans.""" + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + [rewrite_common_legacy_to_modern_conf(hass, config, LEGACY_FIELDS)], + None, + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) class TemplateLock(TemplateEntity, LockEntity): @@ -90,14 +151,20 @@ class TemplateLock(TemplateEntity, LockEntity): ) 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) + if TYPE_CHECKING: + assert name is not None + + self._state_template = config.get(CONF_STATE) + for action_id, supported_feature in ( + (CONF_LOCK, 0), + (CONF_UNLOCK, 0), + (CONF_OPEN, LockEntityFeature.OPEN), + ): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + self._code_format_template = config.get(CONF_CODE_FORMAT) self._code_format: str | None = None self._code_format_template_error: TemplateError | None = None self._optimistic = config.get(CONF_OPTIMISTIC) @@ -210,7 +277,9 @@ class TemplateLock(TemplateEntity, LockEntity): tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} await self.async_run_script( - self._command_lock, run_variables=tpl_vars, context=self._context + self._action_scripts[CONF_LOCK], + run_variables=tpl_vars, + context=self._context, ) async def async_unlock(self, **kwargs: Any) -> None: @@ -226,7 +295,9 @@ class TemplateLock(TemplateEntity, LockEntity): tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} await self.async_run_script( - self._command_unlock, run_variables=tpl_vars, context=self._context + self._action_scripts[CONF_UNLOCK], + run_variables=tpl_vars, + context=self._context, ) async def async_open(self, **kwargs: Any) -> None: @@ -242,7 +313,9 @@ class TemplateLock(TemplateEntity, LockEntity): 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 + self._action_scripts[CONF_OPEN], + run_variables=tpl_vars, + context=self._context, ) def _raise_template_error_if_available(self): diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json index f1225f74f06..32bfd8ce02e 100644 --- a/homeassistant/components/template/manifest.json +++ b/homeassistant/components/template/manifest.json @@ -2,7 +2,7 @@ "domain": "template", "name": "Template", "after_dependencies": ["group"], - "codeowners": ["@PhracturedBlue", "@home-assistant/core"], + "codeowners": ["@Petro31", "@PhracturedBlue", "@home-assistant/core"], "config_flow": true, "dependencies": ["blueprint"], "documentation": "https://www.home-assistant.io/integrations/template", diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 661dbb45dc1..3ecf1db565a 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -31,7 +31,6 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator @@ -157,9 +156,7 @@ class TemplateNumber(TemplateEntity, NumberEntity): super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None self._value_template = config[CONF_STATE] - self._command_set_value = Script( - hass, config[CONF_SET_VALUE], self._attr_name, DOMAIN - ) + self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN) self._step_template = config[CONF_STEP] self._min_value_template = config[CONF_MIN] @@ -210,9 +207,9 @@ class TemplateNumber(TemplateEntity, NumberEntity): if self._optimistic: self._attr_native_value = value self.async_write_ha_state() - if self._command_set_value: + if set_value := self._action_scripts.get(CONF_SET_VALUE): await self.async_run_script( - self._command_set_value, + set_value, run_variables={ATTR_VALUE: value}, context=self._context, ) @@ -238,12 +235,8 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): """Initialize the entity.""" super().__init__(hass, coordinator, config) - self._command_set_value = Script( - hass, - config[CONF_SET_VALUE], - self._rendered.get(CONF_NAME, DEFAULT_NAME), - DOMAIN, - ) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], name, DOMAIN) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) @@ -278,6 +271,9 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): if self._config[CONF_OPTIMISTIC]: self._attr_native_value = value self.async_write_ha_state() - await self._command_set_value.async_run( - {ATTR_VALUE: value}, context=self._context - ) + if set_value := self._action_scripts.get(CONF_SET_VALUE): + await self.async_run_script( + set_value, + run_variables={ATTR_VALUE: value}, + context=self._context, + ) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index a42ee3d0612..74d88ee96c4 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -28,7 +28,6 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator @@ -142,10 +141,9 @@ class TemplateSelect(TemplateEntity, SelectEntity): super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None self._value_template = config[CONF_STATE] - if (selection_option := config.get(CONF_SELECT_OPTION)) is not None: - self._command_select_option = Script( - hass, selection_option, self._attr_name, DOMAIN - ) + # Scripts can be an empty list, therefore we need to check for None + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: + self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN) self._options_template = config[ATTR_OPTIONS] self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) self._attr_options = [] @@ -177,9 +175,9 @@ class TemplateSelect(TemplateEntity, SelectEntity): if self._optimistic: self._attr_current_option = option self.async_write_ha_state() - if self._command_select_option: + if select_option := self._action_scripts.get(CONF_SELECT_OPTION): await self.async_run_script( - self._command_select_option, + select_option, run_variables={ATTR_OPTION: option}, context=self._context, ) @@ -200,12 +198,14 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) - self._command_select_option = Script( - hass, - config[CONF_SELECT_OPTION], - self._rendered.get(CONF_NAME, DEFAULT_NAME), - DOMAIN, - ) + # Scripts can be an empty list, therefore we need to check for None + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: + self.add_script( + CONF_SELECT_OPTION, + select_option, + self._rendered.get(CONF_NAME, DEFAULT_NAME), + DOMAIN, + ) @property def current_option(self) -> str | None: @@ -222,6 +222,9 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): if self._config[CONF_OPTIMISTIC]: self._attr_current_option = option self.async_write_ha_state() - await self._command_select_option.async_run( - {ATTR_OPTION: option}, context=self._context - ) + if select_option := self._action_scripts.get(CONF_SELECT_OPTION): + await self.async_run_script( + select_option, + run_variables={ATTR_OPTION: option}, + context=self._context, + ) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index ca3736ebf76..508c8b2aed4 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -33,6 +33,8 @@ from homeassistant.const import ( CONF_NAME, CONF_SENSORS, CONF_STATE, + CONF_TRIGGER, + CONF_TRIGGERS, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, @@ -53,12 +55,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import ( - CONF_ATTRIBUTE_TEMPLATES, - CONF_AVAILABILITY_TEMPLATE, - CONF_OBJECT_ID, - CONF_TRIGGER, -) +from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity, @@ -132,7 +129,7 @@ LEGACY_SENSOR_SCHEMA = vol.All( def extra_validation_checks(val): """Run extra validation checks.""" - if CONF_TRIGGER in val: + if CONF_TRIGGERS in val or CONF_TRIGGER in val: raise vol.Invalid( "You can only add triggers to template entities if they are defined under" " `template:`. See the template documentation for more information:" @@ -170,6 +167,7 @@ PLATFORM_SCHEMA = vol.All( SENSOR_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_TRIGGER): cv.match_all, # to raise custom warning + vol.Optional(CONF_TRIGGERS): cv.match_all, # to raise custom warning vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(LEGACY_SENSOR_SCHEMA), } ), diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 66864a027ba..729f76a84ec 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -290,8 +290,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%]", @@ -302,6 +304,7 @@ "distance": "[%key:component::sensor::entity_component::distance::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", "frequency": "[%key:component::sensor::entity_component::frequency::name%]", "gas": "[%key:component::sensor::entity_component::gas::name%]", @@ -323,6 +326,7 @@ "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]", "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", @@ -338,6 +342,7 @@ "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", "water": "[%key:component::sensor::entity_component::water::name%]", "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" } }, diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 756866cfd44..0f6d45f46ca 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -2,11 +2,12 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, SwitchEntity, @@ -17,15 +18,18 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_DEVICE_ID, CONF_NAME, + CONF_STATE, CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( @@ -33,19 +37,45 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, ) from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from . import TriggerUpdateCoordinator +from .const import CONF_OBJECT_ID, CONF_PICTURE, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] -SWITCH_SCHEMA = vol.All( +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_VALUE_TEMPLATE: CONF_STATE, +} + +DEFAULT_NAME = "Template Switch" + + +SWITCH_SCHEMA = ( + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_PICTURE): cv.template, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) +) + +LEGACY_SWITCH_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -60,13 +90,13 @@ SWITCH_SCHEMA = vol.All( ) PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA)} + {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(LEGACY_SWITCH_SCHEMA)} ) SWITCH_CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), @@ -74,24 +104,62 @@ SWITCH_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities(hass, config): - """Create the Template switches.""" +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy switch configuration definitions to modern ones.""" switches = [] - for object_id, entity_config in config[CONF_SWITCHES].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) - unique_id = entity_config.get(CONF_UNIQUE_ID) + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_common_legacy_to_modern_conf( + hass, entity_conf, LEGACY_FIELDS + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + switches.append(entity_conf) + + return switches + + +def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: + """Rewrite option configuration to modern configuration.""" + option_config = {**option_config} + + if CONF_VALUE_TEMPLATE in option_config: + option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE) + + return option_config + + +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template switches.""" + switches = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" switches.append( SwitchTemplate( hass, - object_id, - entity_config, + entity_conf, unique_id, ) ) - return switches + async_add_entities(switches) async def async_setup_platform( @@ -101,7 +169,28 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template switches.""" - async_add_entities(await _async_create_entities(hass, config)) + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_SWITCHES]), + None, + ) + return + + if "coordinator" in discovery_info: + async_add_entities( + TriggerSwitchEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) async def async_setup_entry( @@ -112,10 +201,9 @@ async def async_setup_entry( """Initialize config entry.""" _options = dict(config_entry.options) _options.pop("template_type") + _options = rewrite_options_to_modern_conf(_options) validated_config = SWITCH_CONFIG_SCHEMA(_options) - async_add_entities( - [SwitchTemplate(hass, None, validated_config, config_entry.entry_id)] - ) + async_add_entities([SwitchTemplate(hass, validated_config, config_entry.entry_id)]) @callback @@ -123,8 +211,9 @@ def async_create_preview_switch( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> SwitchTemplate: """Create a preview switch.""" - validated_config = SWITCH_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return SwitchTemplate(hass, None, validated_config, None) + updated_config = rewrite_options_to_modern_conf(config) + validated_config = SWITCH_CONFIG_SCHEMA(updated_config | {CONF_NAME: name}) + return SwitchTemplate(hass, validated_config, None) class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): @@ -134,31 +223,27 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): def __init__( self, - hass, - object_id, - config, - unique_id, - ): + hass: HomeAssistant, + config: ConfigType, + unique_id: str | None, + ) -> None: """Initialize the Template switch.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id - ) - if object_id is not None: + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - friendly_name = self._attr_name - self._template = config.get(CONF_VALUE_TEMPLATE) - self._on_script = ( - Script(hass, config.get(CONF_TURN_ON), friendly_name, DOMAIN) - if config.get(CONF_TURN_ON) is not None - else None - ) - self._off_script = ( - Script(hass, config.get(CONF_TURN_OFF), friendly_name, DOMAIN) - if config.get(CONF_TURN_OFF) is not None - else None - ) + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + self._template = config.get(CONF_STATE) + + # Scripts can be an empty list, therefore we need to check for None + if (on_action := config.get(CONF_TURN_ON)) is not None: + self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) + if (off_action := config.get(CONF_TURN_OFF)) is not None: + self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) + self._state: bool | None = False self._attr_assumed_state = self._template is None self._attr_device_info = async_device_info_to_link_from_device_id( @@ -209,16 +294,96 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Fire the on action.""" - if self._on_script: - await self.async_run_script(self._on_script, context=self._context) + if on_script := self._action_scripts.get(CONF_TURN_ON): + await self.async_run_script(on_script, context=self._context) if self._template is None: self._state = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Fire the off action.""" - if self._off_script: - await self.async_run_script(self._off_script, context=self._context) + if off_script := self._action_scripts.get(CONF_TURN_OFF): + await self.async_run_script(off_script, context=self._context) if self._template is None: self._state = False self.async_write_ha_state() + + +class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): + """Switch entity based on trigger data.""" + + domain = SWITCH_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + super().__init__(hass, coordinator, config) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + self._template = config.get(CONF_STATE) + if on_action := config.get(CONF_TURN_ON): + self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) + if off_action := config.get(CONF_TURN_OFF): + self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) + + self._attr_assumed_state = self._template is None + if not self._attr_assumed_state: + self._to_render_simple.append(CONF_STATE) + self._parse_result.add(CONF_STATE) + + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + (last_state := await self.async_get_last_state()) is not None + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + # The trigger might have fired already while we waited for stored data, + # then we should not restore state + and self.is_on is None + ): + self._attr_is_on = last_state.state == STATE_ON + self.restore_attributes(last_state) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle update of the data.""" + self._process_data() + + if not self.available: + self.async_write_ha_state() + return + + if not self._attr_assumed_state: + raw = self._rendered.get(CONF_STATE) + self._attr_is_on = template.result_as_boolean(raw) + + self.async_set_context(self.coordinator.data["context"]) + self.async_write_ha_state() + elif self._attr_assumed_state and len(self._rendered) > 0: + # In case name, icon, or friendly name have a template but + # states does not + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Fire the on action.""" + if on_script := self._action_scripts.get(CONF_TURN_ON): + await self.async_run_script(on_script, context=self._context) + if self._template is None: + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Fire the off action.""" + if off_script := self._action_scripts.get(CONF_TURN_OFF): + await self.async_run_script(off_script, context=self._context) + if self._template is None: + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 8f9edca5976..41ebf5bc1be 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -24,7 +24,6 @@ from homeassistant.const import ( ) from homeassistant.core import ( CALLBACK_TYPE, - Context, Event, EventStateChangedData, HomeAssistant, @@ -41,7 +40,7 @@ from homeassistant.helpers.event import ( TrackTemplateResultInfo, async_track_template_result, ) -from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.start import async_at_start from homeassistant.helpers.template import ( Template, @@ -61,6 +60,7 @@ from .const import ( CONF_AVAILABILITY_TEMPLATE, CONF_PICTURE, ) +from .entity import AbstractTemplateEntity _LOGGER = logging.getLogger(__name__) @@ -76,23 +76,35 @@ TEMPLATE_ENTITY_ICON_SCHEMA = vol.Schema( } ) -TEMPLATE_ENTITY_COMMON_SCHEMA = vol.Schema( +TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA = vol.Schema( { vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}), - vol.Optional(CONF_AVAILABILITY): cv.template, - vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, } -).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) +) + +TEMPLATE_ENTITY_COMMON_SCHEMA = ( + vol.Schema( + { + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, + } + ) + .extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) +) def make_template_entity_common_schema(default_name: str) -> vol.Schema: """Return a schema with default name.""" - return vol.Schema( - { - vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}), - vol.Optional(CONF_AVAILABILITY): cv.template, - } - ).extend(make_template_entity_base_schema(default_name).schema) + return ( + vol.Schema( + { + vol.Optional(CONF_AVAILABILITY): cv.template, + } + ) + .extend(make_template_entity_base_schema(default_name).schema) + .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) + ) TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY = vol.Schema( @@ -248,7 +260,7 @@ class _TemplateAttribute: return -class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module +class TemplateEntity(AbstractTemplateEntity): """Entity that uses templates to calculate attributes.""" _attr_available = True @@ -268,6 +280,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module unique_id: str | None = None, ) -> None: """Template Entity.""" + super().__init__(hass) self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} @@ -285,6 +298,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module ] | None ) = None + self._run_variables: ScriptVariables | dict if config is None: self._attribute_templates = attribute_templates self._availability_template = availability_template @@ -339,18 +353,6 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module variables=variables, parse_result=False ) - @callback - def _render_variables(self) -> dict: - if isinstance(self._run_variables, dict): - return self._run_variables - - return self._run_variables.async_render( - self.hass, - { - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - }, - ) - @callback def _update_available(self, result: str | TemplateError) -> None: if isinstance(result, TemplateError): @@ -387,6 +389,18 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module return None return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH]) + def _render_script_variables(self) -> dict[str, Any]: + """Render configured variables.""" + if isinstance(self._run_variables, dict): + return self._run_variables + + return self._run_variables.async_render( + self.hass, + { + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + }, + ) + def add_template_attribute( self, attribute: str, @@ -488,7 +502,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module variables = { "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **self._render_variables(), + **self._render_script_variables(), } for template, attributes in self._template_attrs.items(): @@ -581,22 +595,3 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module """Call for forced update.""" assert self._template_result_info self._template_result_info.async_refresh() - - async def async_run_script( - self, - script: Script, - *, - run_variables: _VarsType | None = None, - context: Context | None = None, - ) -> None: - """Run an action script.""" - if run_variables is None: - run_variables = {} - await script.async_run( - run_variables={ - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **self._render_variables(), - **run_variables, - }, - context=context, - ) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 5130f332d5b..4565e86843a 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -2,16 +2,22 @@ from __future__ import annotations +from typing import Any + +from homeassistant.const import CONF_STATE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.template import TemplateStateFromEntityId +from homeassistant.helpers.template import _SENTINEL from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TriggerUpdateCoordinator +from .entity import AbstractTemplateEntity class TriggerEntity( # pylint: disable=hass-enforce-class-module - TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinator] + TriggerBaseEntity, + CoordinatorEntity[TriggerUpdateCoordinator], + AbstractTemplateEntity, ): """Template entity based on trigger data.""" @@ -24,6 +30,9 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module """Initialize the entity.""" CoordinatorEntity.__init__(self, coordinator) TriggerBaseEntity.__init__(self, hass, config) + AbstractTemplateEntity.__init__(self, hass) + + self._state_render_error = False async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -38,17 +47,54 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module else: self._unique_id = unique_id + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + return self.coordinator.referenced_blueprint + + @property + def available(self) -> bool: + """Return availability of the entity.""" + if self._state_render_error: + return False + + return super().available + + @callback + def _render_script_variables(self) -> dict: + """Render configured variables.""" + if self.coordinator.data is None: + return {} + return self.coordinator.data["run_variables"] or {} + + def _render_templates(self, variables: dict[str, Any]) -> None: + """Render templates.""" + self._state_render_error = False + rendered = dict(self._static_rendered) + + # If state fails to render, the entity should go unavailable. Render the + # state as a simple template because the result should always be a string or None. + if CONF_STATE in self._to_render_simple: + if ( + result := self._render_single_template(CONF_STATE, variables) + ) is _SENTINEL: + self._rendered = self._static_rendered + self._state_render_error = True + return + + rendered[CONF_STATE] = result + + self._render_single_templates(rendered, variables, [CONF_STATE]) + self._render_attributes(rendered, variables) + self._rendered = rendered + @callback def _process_data(self) -> None: """Process new data.""" - run_variables = self.coordinator.data["run_variables"] - variables = { - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **(run_variables or {}), - } - - self._render_templates(variables) + variables = self._template_variables(self.coordinator.data["run_variables"]) + if self._render_availability_template(variables): + self._render_templates(variables) self.async_set_context(self.coordinator.data["context"]) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index b977f4e659a..462f7d672ff 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -24,22 +24,27 @@ from homeassistant.components.vacuum import ( from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, + CONF_NAME, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template 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 .const import DOMAIN +from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA, TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) @@ -47,8 +52,10 @@ from .template_entity import ( _LOGGER = logging.getLogger(__name__) CONF_VACUUMS = "vacuums" +CONF_BATTERY_LEVEL = "battery_level" CONF_BATTERY_LEVEL_TEMPLATE = "battery_level_template" CONF_FAN_SPEED_LIST = "fan_speeds" +CONF_FAN_SPEED = "fan_speed" CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" ENTITY_ID_FORMAT = VACUUM_DOMAIN + ".{}" @@ -61,24 +68,55 @@ _VALID_STATES = [ VacuumActivity.ERROR, ] +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_BATTERY_LEVEL_TEMPLATE: CONF_BATTERY_LEVEL, + CONF_FAN_SPEED_TEMPLATE: CONF_FAN_SPEED, + CONF_VALUE_TEMPLATE: CONF_STATE, +} + VACUUM_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_BATTERY_LEVEL): cv.template, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, + vol.Optional(CONF_FAN_SPEED): cv.template, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, + vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, + } + ) + .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), +) + +LEGACY_VACUUM_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_BATTERY_LEVEL_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_FAN_SPEED_TEMPLATE): cv.template, - vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, - vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, } ) .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY.schema) @@ -86,28 +124,56 @@ VACUUM_SCHEMA = vol.All( ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_VACUUMS): vol.Schema({cv.slug: VACUUM_SCHEMA})} + {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(LEGACY_VACUUM_SCHEMA)} ) -async def _async_create_entities(hass, config): - """Create the Template Vacuums.""" +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy switch configuration definitions to modern ones.""" vacuums = [] - for object_id, entity_config in config[CONF_VACUUMS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) - unique_id = entity_config.get(CONF_UNIQUE_ID) + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_common_legacy_to_modern_conf( + hass, entity_conf, LEGACY_FIELDS + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + vacuums.append(entity_conf) + + return vacuums + + +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template switches.""" + vacuums = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" vacuums.append( TemplateVacuum( hass, - object_id, - entity_config, + entity_conf, unique_id, ) ) - return vacuums + async_add_entities(vacuums) async def async_setup_platform( @@ -116,8 +182,22 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the template vacuums.""" - async_add_entities(await _async_create_entities(hass, config)) + """Set up the Template cover.""" + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_VACUUMS]), + None, + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) class TemplateVacuum(TemplateEntity, StateVacuumEntity): @@ -127,64 +207,40 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): def __init__( self, - hass, - object_id, - config, + hass: HomeAssistant, + config: ConfigType, unique_id, - ): + ) -> None: """Initialize the vacuum.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id - ) - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) - friendly_name = self._attr_name + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, object_id, hass=hass + ) + name = self._attr_name + if TYPE_CHECKING: + assert name is not None - self._template = config.get(CONF_VALUE_TEMPLATE) - self._battery_level_template = config.get(CONF_BATTERY_LEVEL_TEMPLATE) - self._fan_speed_template = config.get(CONF_FAN_SPEED_TEMPLATE) + self._template = config.get(CONF_STATE) + self._battery_level_template = config.get(CONF_BATTERY_LEVEL) + self._fan_speed_template = config.get(CONF_FAN_SPEED) self._attr_supported_features = ( VacuumEntityFeature.START | VacuumEntityFeature.STATE ) - self._start_script = Script(hass, config[SERVICE_START], friendly_name, DOMAIN) - - self._pause_script = None - if pause_action := config.get(SERVICE_PAUSE): - self._pause_script = Script(hass, pause_action, friendly_name, DOMAIN) - self._attr_supported_features |= VacuumEntityFeature.PAUSE - - self._stop_script = None - if stop_action := config.get(SERVICE_STOP): - self._stop_script = Script(hass, stop_action, friendly_name, DOMAIN) - self._attr_supported_features |= VacuumEntityFeature.STOP - - self._return_to_base_script = None - if return_to_base_action := config.get(SERVICE_RETURN_TO_BASE): - self._return_to_base_script = Script( - hass, return_to_base_action, friendly_name, DOMAIN - ) - self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME - - self._clean_spot_script = None - if clean_spot_action := config.get(SERVICE_CLEAN_SPOT): - self._clean_spot_script = Script( - hass, clean_spot_action, friendly_name, DOMAIN - ) - self._attr_supported_features |= VacuumEntityFeature.CLEAN_SPOT - - self._locate_script = None - if locate_action := config.get(SERVICE_LOCATE): - self._locate_script = Script(hass, locate_action, friendly_name, DOMAIN) - self._attr_supported_features |= VacuumEntityFeature.LOCATE - - self._set_fan_speed_script = None - if set_fan_speed_action := config.get(SERVICE_SET_FAN_SPEED): - self._set_fan_speed_script = Script( - hass, set_fan_speed_action, friendly_name, DOMAIN - ) - self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED + for action_id, supported_feature in ( + (SERVICE_START, 0), + (SERVICE_PAUSE, VacuumEntityFeature.PAUSE), + (SERVICE_STOP, VacuumEntityFeature.STOP), + (SERVICE_RETURN_TO_BASE, VacuumEntityFeature.RETURN_HOME), + (SERVICE_CLEAN_SPOT, VacuumEntityFeature.CLEAN_SPOT), + (SERVICE_LOCATE, VacuumEntityFeature.LOCATE), + (SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED), + ): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature self._state = None self._battery_level = None @@ -203,62 +259,50 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): async def async_start(self) -> None: """Start or resume the cleaning task.""" - await self.async_run_script(self._start_script, context=self._context) + await self.async_run_script( + self._action_scripts[SERVICE_START], context=self._context + ) async def async_pause(self) -> None: """Pause the cleaning task.""" - if self._pause_script is None: - return - - await self.async_run_script(self._pause_script, context=self._context) + if script := self._action_scripts.get(SERVICE_PAUSE): + await self.async_run_script(script, context=self._context) async def async_stop(self, **kwargs: Any) -> None: """Stop the cleaning task.""" - if self._stop_script is None: - return - - await self.async_run_script(self._stop_script, context=self._context) + if script := self._action_scripts.get(SERVICE_STOP): + await self.async_run_script(script, context=self._context) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" - if self._return_to_base_script is None: - return - - await self.async_run_script(self._return_to_base_script, context=self._context) + if script := self._action_scripts.get(SERVICE_RETURN_TO_BASE): + await self.async_run_script(script, context=self._context) async def async_clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" - if self._clean_spot_script is None: - return - - await self.async_run_script(self._clean_spot_script, context=self._context) + if script := self._action_scripts.get(SERVICE_CLEAN_SPOT): + await self.async_run_script(script, context=self._context) async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" - if self._locate_script is None: - return - - await self.async_run_script(self._locate_script, context=self._context) + if script := self._action_scripts.get(SERVICE_LOCATE): + await self.async_run_script(script, context=self._context) async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" - if self._set_fan_speed_script is None: - return - - if fan_speed in self._attr_fan_speed_list: - self._attr_fan_speed = fan_speed - await self.async_run_script( - self._set_fan_speed_script, - run_variables={ATTR_FAN_SPEED: fan_speed}, - context=self._context, - ) - else: + if fan_speed not in self._attr_fan_speed_list: _LOGGER.error( "Received invalid fan speed: %s for entity %s. Expected: %s", fan_speed, self.entity_id, self._attr_fan_speed_list, ) + return + + if script := self._action_scripts.get(SERVICE_SET_FAN_SPEED): + await self.async_run_script( + script, run_variables={ATTR_FAN_SPEED: fan_speed}, context=self._context + ) @callback def _async_setup_templates(self) -> None: diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 7f597f1d9a8..86bab6f5ad1 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -135,6 +135,33 @@ WEATHER_SCHEMA = vol.Schema( PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the weather entities.""" + entities = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + + entities.append( + WeatherTemplate( + hass, + entity_conf, + unique_id, + ) + ) + + async_add_entities(entities) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -142,24 +169,32 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" - if discovery_info and "coordinator" in discovery_info: + if discovery_info is None: + config = rewrite_common_legacy_to_modern_conf(hass, config) + unique_id = config.get(CONF_UNIQUE_ID) + async_add_entities( + [ + WeatherTemplate( + hass, + config, + unique_id, + ) + ] + ) + return + + if "coordinator" in discovery_info: async_add_entities( TriggerWeatherEntity(hass, discovery_info["coordinator"], config) for config in discovery_info["entities"] ) return - config = rewrite_common_legacy_to_modern_conf(hass, config) - unique_id = config.get(CONF_UNIQUE_ID) - - async_add_entities( - [ - WeatherTemplate( - hass, - config, - unique_id, - ) - ] + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], ) diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 81705e326f7..11e1b1d3485 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -11,6 +11,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==2.2.2", - "Pillow==11.1.0" + "Pillow==11.2.1" ] } diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 27bfb9134ab..2642bd2f7d5 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -5,12 +5,7 @@ from typing import Final from aiohttp.client_exceptions import ClientResponseError import jwt -from tesla_fleet_api import ( - EnergySpecific, - TeslaFleetApi, - VehicleSigned, - VehicleSpecific, -) +from tesla_fleet_api import TeslaFleetApi from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( InvalidRegion, @@ -128,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - vehicles: list[TeslaFleetVehicleData] = [] energysites: list[TeslaFleetEnergyData] = [] for product in products: - if "vin" in product and hasattr(tesla, "vehicle"): + if "vin" in product 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"] @@ -136,9 +131,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - if signing: if not tesla.private_key: await tesla.get_private_key(hass.config.path("tesla_fleet.key")) - api = VehicleSigned(tesla.vehicle, vin) + api = tesla.vehicles.createSigned(vin) else: - api = VehicleSpecific(tesla.vehicle, vin) + api = tesla.vehicles.createFleet(vin) coordinator = TeslaFleetVehicleDataCoordinator(hass, entry, api, product) await coordinator.async_config_entry_first_refresh() @@ -160,7 +155,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - signing=signing, ) ) - elif "energy_site_id" in product and hasattr(tesla, "energy"): + elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes: site_id = product["energy_site_id"] if not ( product["components"]["battery"] @@ -173,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - ) continue - api = EnergySpecific(tesla.energy, site_id) + api = tesla.energySites.create(site_id) live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, entry, api) history_coordinator = TeslaFleetEnergySiteHistoryCoordinator( @@ -227,7 +222,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - # Setup Platforms entry.runtime_data = TeslaFleetData(vehicles, energysites, scopes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 128c15068f6..20d2d70b5dc 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -7,7 +7,6 @@ from random import randint from time import time from typing import TYPE_CHECKING, Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( InvalidToken, @@ -17,6 +16,7 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, VehicleOffline, ) +from tesla_fleet_api.tesla import EnergySite, VehicleFleet from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -27,7 +27,7 @@ if TYPE_CHECKING: from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState -VEHICLE_INTERVAL_SECONDS = 300 +VEHICLE_INTERVAL_SECONDS = 600 VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS) VEHICLE_WAIT = timedelta(minutes=15) @@ -70,7 +70,7 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: VehicleSpecific, + api: VehicleFleet, product: dict, ) -> None: """Initialize TeslaFleet Vehicle Update Coordinator.""" @@ -149,7 +149,7 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: EnergySpecific, + api: EnergySite, ) -> None: """Initialize TeslaFleet Energy Site Live coordinator.""" super().__init__( @@ -202,7 +202,7 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: EnergySpecific, + api: EnergySite, ) -> None: """Initialize Tesla Fleet Energy Site History coordinator.""" super().__init__( @@ -248,7 +248,7 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any self.updated_once = True # Add all time periods together - output = {key: 0 for key in ENERGY_HISTORY_FIELDS} + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: output[key] += period.get(key, 0) @@ -266,7 +266,7 @@ class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: EnergySpecific, + api: EnergySite, product: dict, ) -> None: """Initialize TeslaFleet Energy Info coordinator.""" diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index 0260acf368e..583e92595d0 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -3,8 +3,9 @@ from abc import abstractmethod from typing import Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.tesla.energysite import EnergySite +from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo @@ -41,7 +42,7 @@ class TeslaFleetEntity( | TeslaFleetEnergySiteLiveCoordinator | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator, - api: VehicleSpecific | EnergySpecific, + api: VehicleFleet | EnergySite, key: str, ) -> None: """Initialize common aspects of a TeslaFleet entity.""" diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index bb8f6041771..53c8e7d554c 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.9.10"] + "requirements": ["tesla-fleet-api==1.0.17"] } diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py index 469ebdca914..17a2bf50ed1 100644 --- a/homeassistant/components/tesla_fleet/models.py +++ b/homeassistant/components/tesla_fleet/models.py @@ -5,8 +5,8 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.tesla import EnergySite, VehicleFleet from homeassistant.helpers.device_registry import DeviceInfo @@ -31,7 +31,7 @@ class TeslaFleetData: class TeslaFleetVehicleData: """Data for a vehicle in the TeslaFleet integration.""" - api: VehicleSpecific + api: VehicleFleet coordinator: TeslaFleetVehicleDataCoordinator vin: str device: DeviceInfo @@ -43,7 +43,7 @@ class TeslaFleetVehicleData: class TeslaFleetEnergyData: """Data for a vehicle in the TeslaFleet integration.""" - api: EnergySpecific + api: EnergySite live_coordinator: TeslaFleetEnergySiteLiveCoordinator history_coordinator: TeslaFleetEnergySiteHistoryCoordinator info_coordinator: TeslaFleetEnergySiteInfoCoordinator diff --git a/homeassistant/components/tesla_fleet/number.py b/homeassistant/components/tesla_fleet/number.py index a1123ab9553..b4f7e42cafd 100644 --- a/homeassistant/components/tesla_fleet/number.py +++ b/homeassistant/components/tesla_fleet/number.py @@ -7,8 +7,8 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.tesla import EnergySite, VehicleFleet from homeassistant.components.number import ( NumberDeviceClass, @@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0 class TeslaFleetNumberVehicleEntityDescription(NumberEntityDescription): """Describes TeslaFleet Number entity.""" - func: Callable[[VehicleSpecific, float], Awaitable[Any]] + func: Callable[[VehicleFleet, float], Awaitable[Any]] native_min_value: float native_max_value: float min_key: str | None = None @@ -74,7 +74,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetNumberVehicleEntityDescription, ...] = ( class TeslaFleetNumberBatteryEntityDescription(NumberEntityDescription): """Describes TeslaFleet Number entity.""" - func: Callable[[EnergySpecific, float], Awaitable[Any]] + func: Callable[[EnergySite, float], Awaitable[Any]] requires: str | None = None diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index 64ecc35469b..bdd5ce2c001 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -466,6 +466,7 @@ async def async_setup_entry( for energysite in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" ), ( # Add energy site history TeslaFleetEnergyHistorySensorEntity(energysite, description) diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 331885893fe..04ccbd13b44 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -141,7 +141,7 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "keep": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" @@ -199,79 +199,79 @@ "name": "Charge limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "select": { "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", - "off": "Off" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater front right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { "name": "Steering wheel heater", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "off": "[%key:common::state::off%]" } }, "components_customer_preferred_export_rule": { @@ -287,7 +287,7 @@ "state": { "autonomous": "Autonomous", "backup": "Backup", - "self_consumption": "Self consumption" + "self_consumption": "Self-consumption" } } }, @@ -330,8 +330,8 @@ "state": { "starting": "Starting", "charging": "[%key:common::state::charging%]", - "disconnected": "Disconnected", - "stopped": "Stopped", + "disconnected": "[%key:common::state::disconnected%]", + "stopped": "[%key:common::state::stopped%]", "complete": "Complete", "no_power": "No power" } @@ -418,8 +418,8 @@ "name": "Grid Status", "state": { "island_status_unknown": "Unknown", - "on_grid": "Connected", - "off_grid": "Disconnected", + "on_grid": "[%key:common::state::connected%]", + "off_grid": "[%key:common::state::disconnected%]", "off_grid_unintentional": "Disconnected unintentionally", "off_grid_intentional": "Disconnected intentionally" } diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py index 614af8772cc..4c64acfafa6 100644 --- a/homeassistant/components/tesla_fleet/switch.py +++ b/homeassistant/components/tesla_fleet/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api.const import Scope, Seat +from tesla_fleet_api.const import AutoSeat, Scope, Seat from homeassistant.components.switch import ( SwitchDeviceClass, @@ -46,7 +46,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( ), TeslaFleetSwitchEntityDescription( key="climate_state_auto_seat_climate_left", - on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True), + on_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_LEFT, True + ), off_func=lambda api: api.remote_auto_seat_climate_request( Seat.FRONT_LEFT, False ), @@ -55,10 +57,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( TeslaFleetSwitchEntityDescription( key="climate_state_auto_seat_climate_right", on_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, True + AutoSeat.FRONT_RIGHT, True ), off_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, False + AutoSeat.FRONT_RIGHT, False ), scopes=[Scope.VEHICLE_CMDS], ), diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index b356a9f3ebc..f1247ea8f9f 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -38,7 +38,7 @@ "connected": "Vehicle connected", "ready": "Ready to charge", "negotiating": "Negotiating connection", - "error": "Error", + "error": "[%key:common::state::error%]", "charging_finished": "Charging finished", "waiting_car": "Waiting for car", "charging_reduced": "Charging (reduced)", diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index eef974cc5a7..5d9a757b9e6 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Callable 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, @@ -12,6 +11,7 @@ from tesla_fleet_api.exceptions import ( SubscriptionRequired, TeslaFleetError, ) +from tesla_fleet_api.teslemetry import Teslemetry from teslemetry_stream import TeslemetryStream from homeassistant.config_entries import ConfigEntry @@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, LOGGER, MODELS +from .const import DOMAIN, LOGGER from .coordinator import ( TeslemetryEnergyHistoryCoordinator, TeslemetryEnergySiteInfoCoordinator, @@ -95,12 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - energysites: list[TeslemetryEnergyData] = [] # Create the stream - stream = TeslemetryStream( - session, - access_token, - server=f"{region.lower()}.teslemetry.com", - parse_timestamp=True, - ) + stream: TeslemetryStream | None = None for product in products: if ( @@ -111,29 +106,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - # Remove the protobuff 'cached_data' that we do not use to save memory product.pop("cached_data", None) vin = product["vin"] - api = VehicleSpecific(teslemetry.vehicle, vin) + api = teslemetry.vehicles.create(vin) coordinator = TeslemetryVehicleDataCoordinator(hass, entry, api, product) device = DeviceInfo( identifiers={(DOMAIN, vin)}, manufacturer="Tesla", configuration_url="https://teslemetry.com/console", name=product["display_name"], - model=MODELS.get(vin[3]), + model=api.model, serial_number=vin, ) + # Create stream if required + if not stream: + stream = TeslemetryStream( + session, + access_token, + server=f"{region.lower()}.teslemetry.com", + parse_timestamp=True, + manual=True, + ) + remove_listener = stream.async_add_listener( create_handle_vehicle_stream(vin, coordinator), {"vin": vin}, ) firmware = vehicle_metadata[vin].get("firmware", "Unknown") stream_vehicle = stream.get_vehicle(vin) + poll = product["command_signing"] == "off" vehicles.append( TeslemetryVehicleData( api=api, config_entry=entry, coordinator=coordinator, + poll=poll, stream=stream, stream_vehicle=stream_vehicle, vin=vin, @@ -156,7 +163,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) continue - api = EnergySpecific(teslemetry.energy, site_id) + api = teslemetry.energySites.create(site_id) device = DeviceInfo( identifiers={(DOMAIN, str(site_id))}, manufacturer="Tesla", @@ -202,6 +209,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - *( vehicle.coordinator.async_config_entry_first_refresh() for vehicle in vehicles + if vehicle.poll ), *( energysite.info_coordinator.async_config_entry_first_refresh() @@ -233,9 +241,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) # Setup Platforms - entry.runtime_data = TeslemetryData(vehicles, energysites, scopes) + entry.runtime_data = TeslemetryData(vehicles, energysites, scopes, stream) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + if stream: + entry.async_create_background_task(hass, stream.listen(), "Teslemetry Stream") + return True diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 9d14df4501b..99c21cbe03e 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -6,8 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import cast -from teslemetry_stream import Signal -from teslemetry_stream.const import WindowState +from teslemetry_stream.vehicle import TeslemetryStreamVehicle from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -25,13 +24,19 @@ from .const import TeslemetryState from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .models import TeslemetryEnergyData, TeslemetryVehicleData PARALLEL_UPDATES = 0 +WINDOW_STATES = { + "Opened": True, + "PartiallyOpen": True, + "Closed": False, +} + @dataclass(frozen=True, kw_only=True) class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -39,24 +44,42 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): polling_value_fn: Callable[[StateType], bool | None] = bool polling: bool = False - streaming_key: Signal | None = None + streaming_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[bool | None], None]], + Callable[[], None], + ] + | 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, + polling_value_fn=lambda value: value == TeslemetryState.ONLINE, + streaming_listener=lambda vehicle, callback: vehicle.listen_State(callback), device_class=BinarySensorDeviceClass.CONNECTIVITY, ), + TeslemetryBinarySensorEntityDescription( + key="cellular", + streaming_listener=lambda vehicle, callback: vehicle.listen_Cellular(callback), + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="wifi", + streaming_listener=lambda vehicle, callback: vehicle.listen_Wifi(callback), + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), TeslemetryBinarySensorEntityDescription( key="charge_state_battery_heater_on", polling=True, - streaming_key=Signal.BATTERY_HEATER_ON, + streaming_listener=lambda vehicle, callback: vehicle.listen_BatteryHeaterOn( + callback + ), device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -64,15 +87,17 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="charge_state_charger_phases", polling=True, - streaming_key=Signal.CHARGER_PHASES, + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargerPhases( + lambda value: callback(None if value is None else value > 1) + ), 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, + streaming_listener=lambda vehicle, + callback: vehicle.listen_PreconditioningEnabled(callback), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -85,7 +110,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="charge_state_scheduled_charging_pending", polling=True, - streaming_key=Signal.SCHEDULED_CHARGING_PENDING, + streaming_listener=lambda vehicle, + callback: vehicle.listen_ScheduledChargingPending(callback), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -153,32 +179,37 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="vehicle_state_fd_window", polling=True, - streaming_key=Signal.FD_WINDOW, - streaming_value_fn=lambda x: WindowState.get(x) != "Closed", + streaming_listener=lambda vehicle, callback: vehicle.listen_FrontDriverWindow( + lambda value: callback(None if value is None else WINDOW_STATES.get(value)) + ), 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", + streaming_listener=lambda vehicle, + callback: vehicle.listen_FrontPassengerWindow( + lambda value: callback(None if value is None else WINDOW_STATES.get(value)) + ), 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", + streaming_listener=lambda vehicle, callback: vehicle.listen_RearDriverWindow( + lambda value: callback(None if value is None else WINDOW_STATES.get(value)) + ), 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", + streaming_listener=lambda vehicle, callback: vehicle.listen_RearPassengerWindow( + lambda value: callback(None if value is None else WINDOW_STATES.get(value)) + ), device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -186,190 +217,313 @@ VEHICLE_DESCRIPTIONS: tuple[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"), + streaming_listener=lambda vehicle, callback: vehicle.listen_FrontDriverDoor( + callback + ), 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"), + streaming_listener=lambda vehicle, callback: vehicle.listen_RearDriverDoor( + callback + ), 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"), + streaming_listener=lambda vehicle, callback: vehicle.listen_FrontPassengerDoor( + callback + ), 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"), + streaming_listener=lambda vehicle, callback: vehicle.listen_RearPassengerDoor( + callback + ), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="automatic_blind_spot_camera", - streaming_key=Signal.AUTOMATIC_BLIND_SPOT_CAMERA, + streaming_listener=lambda vehicle, + callback: vehicle.listen_AutomaticBlindSpotCamera(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="automatic_emergency_braking_off", - streaming_key=Signal.AUTOMATIC_EMERGENCY_BRAKING_OFF, + streaming_listener=lambda vehicle, + callback: vehicle.listen_AutomaticEmergencyBrakingOff(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="blind_spot_collision_warning_chime", - streaming_key=Signal.BLIND_SPOT_COLLISION_WARNING_CHIME, + streaming_listener=lambda vehicle, + callback: vehicle.listen_BlindSpotCollisionWarningChime(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="bms_full_charge_complete", - streaming_key=Signal.BMS_FULL_CHARGE_COMPLETE, + streaming_listener=lambda vehicle, + callback: vehicle.listen_BmsFullchargecomplete(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="brake_pedal", - streaming_key=Signal.BRAKE_PEDAL, + streaming_listener=lambda vehicle, callback: vehicle.listen_BrakePedal( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_port_cold_weather_mode", - streaming_key=Signal.CHARGE_PORT_COLD_WEATHER_MODE, + streaming_listener=lambda vehicle, + callback: vehicle.listen_ChargePortColdWeatherMode(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="service_mode", - streaming_key=Signal.SERVICE_MODE, + streaming_listener=lambda vehicle, callback: vehicle.listen_ServiceMode( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="pin_to_drive_enabled", - streaming_key=Signal.PIN_TO_DRIVE_ENABLED, + streaming_listener=lambda vehicle, callback: vehicle.listen_PinToDriveEnabled( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="drive_rail", - streaming_key=Signal.DRIVE_RAIL, + streaming_listener=lambda vehicle, callback: vehicle.listen_DriveRail(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="driver_seat_belt", - streaming_key=Signal.DRIVER_SEAT_BELT, + streaming_listener=lambda vehicle, callback: vehicle.listen_DriverSeatBelt( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="driver_seat_occupied", - streaming_key=Signal.DRIVER_SEAT_OCCUPIED, + streaming_listener=lambda vehicle, callback: vehicle.listen_DriverSeatOccupied( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="passenger_seat_belt", - streaming_key=Signal.PASSENGER_SEAT_BELT, + streaming_listener=lambda vehicle, callback: vehicle.listen_PassengerSeatBelt( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="fast_charger_present", - streaming_key=Signal.FAST_CHARGER_PRESENT, + streaming_listener=lambda vehicle, callback: vehicle.listen_FastChargerPresent( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="gps_state", - streaming_key=Signal.GPS_STATE, + streaming_listener=lambda vehicle, callback: vehicle.listen_GpsState(callback), entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), TeslemetryBinarySensorEntityDescription( key="guest_mode_enabled", - streaming_key=Signal.GUEST_MODE_ENABLED, + streaming_listener=lambda vehicle, callback: vehicle.listen_GuestModeEnabled( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="dc_dc_enable", - streaming_key=Signal.DC_DC_ENABLE, + streaming_listener=lambda vehicle, callback: vehicle.listen_DCDCEnable( + callback + ), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="emergency_lane_departure_avoidance", - streaming_key=Signal.EMERGENCY_LANE_DEPARTURE_AVOIDANCE, + streaming_listener=lambda vehicle, + callback: vehicle.listen_EmergencyLaneDepartureAvoidance(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="supercharger_session_trip_planner", - streaming_key=Signal.SUPERCHARGER_SESSION_TRIP_PLANNER, + streaming_listener=lambda vehicle, + callback: vehicle.listen_SuperchargerSessionTripPlanner(callback), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="wiper_heat_enabled", - streaming_key=Signal.WIPER_HEAT_ENABLED, + streaming_listener=lambda vehicle, callback: vehicle.listen_WiperHeatEnabled( + callback + ), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="rear_display_hvac_enabled", - streaming_key=Signal.REAR_DISPLAY_HVAC_ENABLED, + streaming_listener=lambda vehicle, + callback: vehicle.listen_RearDisplayHvacEnabled(callback), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="offroad_lightbar_present", - streaming_key=Signal.OFFROAD_LIGHTBAR_PRESENT, + streaming_listener=lambda vehicle, + callback: vehicle.listen_OffroadLightbarPresent(callback), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="homelink_nearby", - streaming_key=Signal.HOMELINK_NEARBY, + streaming_listener=lambda vehicle, callback: vehicle.listen_HomelinkNearby( + callback + ), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="europe_vehicle", - streaming_key=Signal.EUROPE_VEHICLE, + streaming_listener=lambda vehicle, callback: vehicle.listen_EuropeVehicle( + callback + ), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="right_hand_drive", - streaming_key=Signal.RIGHT_HAND_DRIVE, + streaming_listener=lambda vehicle, callback: vehicle.listen_RightHandDrive( + callback + ), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="located_at_home", - streaming_key=Signal.LOCATED_AT_HOME, + streaming_listener=lambda vehicle, callback: vehicle.listen_LocatedAtHome( + callback + ), streaming_firmware="2024.44.32", ), TeslemetryBinarySensorEntityDescription( key="located_at_work", - streaming_key=Signal.LOCATED_AT_WORK, + streaming_listener=lambda vehicle, callback: vehicle.listen_LocatedAtWork( + callback + ), streaming_firmware="2024.44.32", ), TeslemetryBinarySensorEntityDescription( key="located_at_favorite", - streaming_key=Signal.LOCATED_AT_FAVORITE, + streaming_listener=lambda vehicle, callback: vehicle.listen_LocatedAtFavorite( + callback + ), streaming_firmware="2024.44.32", entity_registry_enabled_default=False, ), + TeslemetryBinarySensorEntityDescription( + key="charge_enable_request", + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargeEnableRequest( + callback + ), + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="defrost_for_preconditioning", + streaming_listener=lambda vehicle, + callback: vehicle.listen_DefrostForPreconditioning(callback), + entity_registry_enabled_default=False, + streaming_firmware="2024.44.25", + ), + TeslemetryBinarySensorEntityDescription( + key="lights_hazards_active", + streaming_listener=lambda x, y: x.listen_LightsHazardsActive(y), + entity_registry_enabled_default=False, + streaming_firmware="2025.2.6", + ), + TeslemetryBinarySensorEntityDescription( + key="lights_high_beams", + streaming_listener=lambda vehicle, callback: vehicle.listen_LightsHighBeams( + callback + ), + entity_registry_enabled_default=False, + streaming_firmware="2025.2.6", + ), + TeslemetryBinarySensorEntityDescription( + key="seat_vent_enabled", + streaming_listener=lambda vehicle, callback: vehicle.listen_SeatVentEnabled( + callback + ), + entity_registry_enabled_default=False, + streaming_firmware="2025.2.6", + ), + TeslemetryBinarySensorEntityDescription( + key="speed_limit_mode", + streaming_listener=lambda vehicle, callback: vehicle.listen_SpeedLimitMode( + callback + ), + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="remote_start_enabled", + streaming_listener=lambda vehicle, callback: vehicle.listen_RemoteStartEnabled( + callback + ), + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="hvil", + streaming_listener=lambda vehicle, callback: vehicle.listen_Hvil( + lambda value: callback(None if value is None else value == "Fault") + ), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="hvac_auto_mode", + streaming_listener=lambda vehicle, callback: vehicle.listen_HvacAutoMode( + lambda value: callback(None if value is None else value == "On") + ), + entity_registry_enabled_default=False, + ), ) -ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription(key="backup_capable"), - BinarySensorEntityDescription(key="grid_services_active"), - BinarySensorEntityDescription(key="storm_mode_active"), + +ENERGY_LIVE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( + TeslemetryBinarySensorEntityDescription( + key="grid_status", + polling_value_fn=lambda value: value == "Active", + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="backup_capable", entity_category=EntityCategory.DIAGNOSTIC + ), + TeslemetryBinarySensorEntityDescription( + key="grid_services_active", entity_category=EntityCategory.DIAGNOSTIC + ), + TeslemetryBinarySensorEntityDescription(key="storm_mode_active"), ) -ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( +ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( + TeslemetryBinarySensorEntityDescription( key="components_grid_services_enabled", + entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -386,7 +540,7 @@ async def async_setup_entry( for description in VEHICLE_DESCRIPTIONS: if ( not vehicle.api.pre2021 - and description.streaming_key + and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): entities.append( @@ -415,7 +569,7 @@ async def async_setup_entry( class TeslemetryVehiclePollingBinarySensorEntity( - TeslemetryVehicleEntity, BinarySensorEntity + TeslemetryVehiclePollingEntity, BinarySensorEntity ): """Base class for Teslemetry vehicle binary sensors.""" @@ -453,8 +607,7 @@ class TeslemetryVehicleStreamingBinarySensorEntity( ) -> None: """Initialize the sensor.""" self.entity_description = description - assert description.streaming_key - super().__init__(data, description.key, description.streaming_key) + super().__init__(data, description.key) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -462,11 +615,18 @@ class TeslemetryVehicleStreamingBinarySensorEntity( 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: + assert self.entity_description.streaming_listener + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._async_value_from_stream + ) + ) + + def _async_value_from_stream(self, value: bool | None) -> 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) + self._attr_is_on = value + self.async_write_ha_state() class TeslemetryEnergyLiveBinarySensorEntity( @@ -474,12 +634,12 @@ class TeslemetryEnergyLiveBinarySensorEntity( ): """Base class for Teslemetry energy live binary sensors.""" - entity_description: BinarySensorEntityDescription + entity_description: TeslemetryBinarySensorEntityDescription def __init__( self, data: TeslemetryEnergyData, - description: BinarySensorEntityDescription, + description: TeslemetryBinarySensorEntityDescription, ) -> None: """Initialize the binary sensor.""" self.entity_description = description @@ -487,7 +647,7 @@ class TeslemetryEnergyLiveBinarySensorEntity( def _async_update_attrs(self) -> None: """Update the attributes of the binary sensor.""" - self._attr_is_on = self._value + self._attr_is_on = self.entity_description.polling_value_fn(self._value) class TeslemetryEnergyInfoBinarySensorEntity( @@ -495,12 +655,12 @@ class TeslemetryEnergyInfoBinarySensorEntity( ): """Base class for Teslemetry energy info binary sensors.""" - entity_description: BinarySensorEntityDescription + entity_description: TeslemetryBinarySensorEntityDescription def __init__( self, data: TeslemetryEnergyData, - description: BinarySensorEntityDescription, + description: TeslemetryBinarySensorEntityDescription, ) -> None: """Initialize the binary sensor.""" self.entity_description = description @@ -508,4 +668,4 @@ class TeslemetryEnergyInfoBinarySensorEntity( def _async_update_attrs(self) -> None: """Update the attributes of the binary sensor.""" - self._attr_is_on = self._value + self._attr_is_on = self.entity_description.polling_value_fn(self._value) diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index 4ca2fd9b166..cf1d6157ec1 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -7,13 +7,14 @@ from dataclasses import dataclass from typing import Any from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import TeslemetryVehiclePollingEntity from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryVehicleData @@ -50,8 +51,8 @@ DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( key="homelink", func=lambda self: handle_vehicle_command( self.api.trigger_homelink( - lat=self.coordinator.data["drive_state_latitude"], - lon=self.coordinator.data["drive_state_longitude"], + lat=self.hass.config.latitude, + lon=self.hass.config.longitude, ) ), ), @@ -73,9 +74,10 @@ async def async_setup_entry( ) -class TeslemetryButtonEntity(TeslemetryVehicleEntity, ButtonEntity): +class TeslemetryButtonEntity(TeslemetryVehiclePollingEntity, ButtonEntity): """Base class for Teslemetry buttons.""" + api: Vehicle entity_description: TeslemetryButtonEntityDescription def __init__( diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 86811131ab6..1bc52b23026 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -6,9 +6,11 @@ from itertools import chain from typing import Any, cast from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.climate import ( ATTR_HVAC_MODE, + HVAC_MODES, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -22,15 +24,32 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .const import DOMAIN, TeslemetryClimateSide -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehiclePollingEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData DEFAULT_MIN_TEMP = 15 DEFAULT_MAX_TEMP = 28 +COP_TEMPERATURES = { + 30: CabinOverheatProtectionTemp.LOW, + 35: CabinOverheatProtectionTemp.MEDIUM, + 40: CabinOverheatProtectionTemp.HIGH, +} +PRESET_MODES = { + "Off": "off", + "On": "keep", + "Dog": "dog", + "Party": "camp", +} + PARALLEL_UPDATES = 0 @@ -45,13 +64,21 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryClimateEntity( + TeslemetryVehiclePollingClimateEntity( + vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) for vehicle in entry.runtime_data.vehicles ), ( - TeslemetryCabinOverheatProtectionEntity( + TeslemetryVehiclePollingCabinOverheatProtectionEntity( + vehicle, entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) for vehicle in entry.runtime_data.vehicles @@ -60,66 +87,21 @@ async def async_setup_entry( ) -class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): - """Telemetry vehicle climate entity.""" +class TeslemetryClimateEntity(TeslemetryRootEntity, ClimateEntity): + """Vehicle Climate Control.""" + api: Vehicle _attr_precision = PRECISION_HALVES - _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] - _attr_supported_features = ( - ClimateEntityFeature.TURN_ON - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.PRESET_MODE - ) - _attr_preset_modes = ["off", "keep", "dog", "camp"] - - def __init__( - self, - data: TeslemetryVehicleData, - side: TeslemetryClimateSide, - scopes: Scope, - ) -> None: - """Initialize the climate.""" - self.scoped = Scope.VEHICLE_CMDS in scopes - - if not self.scoped: - self._attr_supported_features = ClimateEntityFeature(0) - self._attr_hvac_modes = [] - - super().__init__( - data, - side, - ) - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - value = self.get("climate_state_is_climate_on") - if value: - self._attr_hvac_mode = HVACMode.HEAT_COOL - else: - self._attr_hvac_mode = HVACMode.OFF - - # If not scoped, prevent the user from changing the HVAC mode by making it the only option - if self._attr_hvac_mode and not self.scoped: - self._attr_hvac_modes = [self._attr_hvac_mode] - - self._attr_current_temperature = self.get("climate_state_inside_temp") - self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting") - self._attr_preset_mode = self.get("climate_state_climate_keeper_mode") - self._attr_min_temp = cast( - float, self.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP) - ) - self._attr_max_temp = cast( - float, self.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP) - ) + _attr_preset_modes = list(PRESET_MODES.values()) + _attr_fan_modes = ["off", "bioweapon"] + _enable_turn_on_off_backwards_compatibility = False async def async_turn_on(self) -> None: """Set the climate state to on.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.auto_conditioning_start()) self._attr_hvac_mode = HVACMode.HEAT_COOL @@ -127,19 +109,21 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_turn_off(self) -> None: """Set the climate state to off.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.auto_conditioning_stop()) self._attr_hvac_mode = HVACMode.OFF self._attr_preset_mode = self._attr_preset_modes[0] + self._attr_fan_mode = self._attr_fan_modes[0] self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set the climate temperature.""" + if temp := kwargs.get(ATTR_TEMPERATURE): - await self.wake_up_if_asleep() + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command( self.api.set_temps( driver_temp=temp, @@ -163,18 +147,212 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the climate preset mode.""" - await self.wake_up_if_asleep() + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command( self.api.set_climate_keeper_mode( climate_keeper_mode=self._attr_preset_modes.index(preset_mode) ) ) self._attr_preset_mode = preset_mode - if preset_mode != self._attr_preset_modes[0]: - # Changing preset mode will also turn on climate + if preset_mode == self._attr_preset_modes[0]: + self._attr_hvac_mode = HVACMode.OFF + else: self._attr_hvac_mode = HVACMode.HEAT_COOL self.async_write_ha_state() + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the Bioweapon defense mode.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command( + self.api.set_bioweapon_mode( + on=(fan_mode != "off"), + manual_override=True, + ) + ) + self._attr_fan_mode = fan_mode + if fan_mode == self._attr_fan_modes[1]: + self._attr_hvac_mode = HVACMode.HEAT_COOL + self.async_write_ha_state() + + +class TeslemetryVehiclePollingClimateEntity( + TeslemetryClimateEntity, TeslemetryVehiclePollingEntity +): + """Polling vehicle climate entity.""" + + _attr_supported_features = ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + ) + + def __init__( + self, + data: TeslemetryVehicleData, + side: TeslemetryClimateSide, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + + super().__init__(data, side) + + 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 + if value: + self._attr_hvac_mode = HVACMode.HEAT_COOL + else: + self._attr_hvac_mode = HVACMode.OFF + + self._attr_current_temperature = self.get("climate_state_inside_temp") + self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting") + self._attr_preset_mode = self.get("climate_state_climate_keeper_mode") + if self.get("climate_state_bioweapon_mode"): + self._attr_fan_mode = "bioweapon" + else: + self._attr_fan_mode = "off" + self._attr_min_temp = cast( + float, self.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP) + ) + self._attr_max_temp = cast( + float, self.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP) + ) + + +class TeslemetryStreamingClimateEntity( + TeslemetryClimateEntity, TeslemetryVehicleStreamEntity, RestoreEntity +): + """Teslemetry steering wheel climate control.""" + + _attr_supported_features = ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + ) + + def __init__( + self, + data: TeslemetryVehicleData, + side: TeslemetryClimateSide, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + + # Initialize defaults + self._attr_hvac_mode = None + self._attr_current_temperature = None + self._attr_target_temperature = None + self._attr_fan_mode = None + self._attr_preset_mode = None + + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + self.side = side + super().__init__( + data, + side, + ) + + self._attr_min_temp = cast( + float, + data.coordinator.data.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP), + ) + self._attr_max_temp = cast( + float, + data.coordinator.data.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP), + ) + self.rhd: bool = data.coordinator.data.get("vehicle_config_rhd", False) + + 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_hvac_mode = ( + HVACMode(state.state) if state.state in HVAC_MODES else None + ) + self._attr_current_temperature = state.attributes.get("current_temperature") + self._attr_target_temperature = state.attributes.get("temperature") + self._attr_preset_mode = state.attributes.get("preset_mode") + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_InsideTemp( + self._async_handle_inside_temp + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacACEnabled( + self._async_handle_hvac_ac_enabled + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_ClimateKeeperMode( + self._async_handle_climate_keeper_mode + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_RightHandDrive(self._async_handle_rhd) + ) + + if self.side == TeslemetryClimateSide.DRIVER: + if self.rhd: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacRightTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + else: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacLeftTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + elif self.side == TeslemetryClimateSide.PASSENGER: + if self.rhd: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacLeftTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + else: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacRightTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + + def _async_handle_inside_temp(self, data: float | None): + self._attr_current_temperature = data + self.async_write_ha_state() + + def _async_handle_hvac_ac_enabled(self, data: bool | None): + self._attr_hvac_mode = ( + None if data is None else HVACMode.HEAT_COOL if data else HVACMode.OFF + ) + self.async_write_ha_state() + + def _async_handle_climate_keeper_mode(self, data: str | None): + self._attr_preset_mode = PRESET_MODES.get(data) if data else None + self.async_write_ha_state() + + def _async_handle_hvac_temperature_request(self, data: float | None): + self._attr_target_temperature = data + self.async_write_ha_state() + + def _async_handle_rhd(self, data: bool | None): + if data is not None: + self.rhd = data + COP_MODES = { "Off": HVACMode.OFF, @@ -182,73 +360,26 @@ COP_MODES = { "FanOnly": HVACMode.FAN_ONLY, } -# String to celsius COP_LEVELS = { "Low": 30, "Medium": 35, "High": 40, } -# Celsius to IntEnum -TEMP_LEVELS = { - 30: CabinOverheatProtectionTemp.LOW, - 35: CabinOverheatProtectionTemp.MEDIUM, - 40: CabinOverheatProtectionTemp.HIGH, -} +class TeslemetryCabinOverheatProtectionEntity(TeslemetryRootEntity, ClimateEntity): + """Vehicle Cabin Overheat Protection.""" -class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEntity): - """Telemetry vehicle cabin overheat protection entity.""" - + api: Vehicle _attr_precision = PRECISION_WHOLE _attr_target_temperature_step = 5 - _attr_min_temp = COP_LEVELS["Low"] - _attr_max_temp = COP_LEVELS["High"] + _attr_min_temp = 30 + _attr_max_temp = 40 _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(COP_MODES.values()) - _attr_entity_registry_enabled_default = False - def __init__( - self, - data: TeslemetryVehicleData, - scopes: Scope, - ) -> None: - """Initialize the climate.""" - - self.scoped = Scope.VEHICLE_CMDS in scopes - if self.scoped: - self._attr_supported_features = ( - ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF - ) - else: - self._attr_supported_features = ClimateEntityFeature(0) - self._attr_hvac_modes = [] - - super().__init__(data, "climate_state_cabin_overheat_protection") - - # Supported Features from data - if self.scoped and self.get("vehicle_config_cop_user_set_temp_supported"): - self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - - if (state := self.get("climate_state_cabin_overheat_protection")) is None: - self._attr_hvac_mode = None - else: - self._attr_hvac_mode = COP_MODES.get(state) - - # If not scoped, prevent the user from changing the HVAC mode by making it the only option - if self._attr_hvac_mode and not self.scoped: - self._attr_hvac_modes = [self._attr_hvac_mode] - - if (level := self.get("climate_state_cop_activation_temperature")) is None: - self._attr_target_temperature = None - else: - self._attr_target_temperature = COP_LEVELS.get(level) - - self._attr_current_temperature = self.get("climate_state_inside_temp") + _enable_turn_on_off_backwards_compatibility = False async def async_turn_on(self) -> None: """Set the climate state to on.""" @@ -260,26 +391,28 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn async def async_set_temperature(self, **kwargs: Any) -> None: """Set the climate temperature.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - if (temp := kwargs.get(ATTR_TEMPERATURE)) is None or ( - cop_mode := TEMP_LEVELS.get(temp) - ) is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_cop_temp", - ) + if temp := kwargs.get(ATTR_TEMPERATURE): + if (cop_mode := COP_TEMPERATURES.get(temp)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_cop_temp", + ) + self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.set_cop_temp(cop_mode)) - self._attr_target_temperature = temp + await handle_vehicle_command(self.api.set_cop_temp(cop_mode)) + self._attr_target_temperature = temp if mode := kwargs.get(ATTR_HVAC_MODE): - await self._async_set_cop(mode) + # Set HVAC mode will call write_ha_state + await self.async_set_hvac_mode(mode) + else: + self.async_write_ha_state() - self.async_write_ha_state() + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) - async def _async_set_cop(self, hvac_mode: HVACMode) -> None: if hvac_mode == HVACMode.OFF: await handle_vehicle_command( self.api.set_cabin_overheat_protection(on=False, fan_only=False) @@ -294,10 +427,125 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn ) self._attr_hvac_mode = hvac_mode - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set the climate mode and state.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await self._async_set_cop(hvac_mode) + self.async_write_ha_state() + + +class TeslemetryVehiclePollingCabinOverheatProtectionEntity( + TeslemetryVehiclePollingEntity, TeslemetryCabinOverheatProtectionEntity +): + """Vehicle Cabin Overheat Protection.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + + super().__init__( + data, + "climate_state_cabin_overheat_protection", + ) + + # Supported Features + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + ) + if self.get("vehicle_config_cop_user_set_temp_supported"): + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + + # Scopes + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + if (state := self.get("climate_state_cabin_overheat_protection")) is None: + self._attr_hvac_mode = None + else: + self._attr_hvac_mode = COP_MODES.get(state) + + if (level := self.get("climate_state_cop_activation_temperature")) is None: + self._attr_target_temperature = None + else: + self._attr_target_temperature = COP_LEVELS.get(level) + + self._attr_current_temperature = self.get("climate_state_inside_temp") + + +class TeslemetryStreamingCabinOverheatProtectionEntity( + TeslemetryVehicleStreamEntity, + TeslemetryCabinOverheatProtectionEntity, + RestoreEntity, +): + """Vehicle Cabin Overheat Protection.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + + # Initialize defaults + self._attr_hvac_mode = None + self._attr_current_temperature = None + self._attr_target_temperature = None + self._attr_fan_mode = None + self._attr_preset_mode = None + + super().__init__(data, "climate_state_cabin_overheat_protection") + + # Supported Features + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + ) + if data.coordinator.data.get("vehicle_config_cop_user_set_temp_supported"): + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + + # Scopes + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + + 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_hvac_mode = ( + HVACMode(state.state) if state.state in HVAC_MODES else None + ) + self._attr_current_temperature = state.attributes.get("temperature") + self._attr_target_temperature = state.attributes.get("target_temperature") + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_InsideTemp( + self._async_handle_inside_temp + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_CabinOverheatProtectionMode( + self._async_handle_protection_mode + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_CabinOverheatProtectionTemperatureLimit( + self._async_handle_temperature_limit + ) + ) + + def _async_handle_inside_temp(self, value: float | None): + self._attr_current_temperature = value + self.async_write_ha_state() + + def _async_handle_protection_mode(self, value: str | None): + self._attr_hvac_mode = COP_MODES.get(value) if value is not None else None + self.async_write_ha_state() + + def _async_handle_temperature_limit(self, value: str | None): + self._attr_target_temperature = ( + COP_LEVELS.get(value) if value is not None else None + ) self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index d8cf2bd7945..a25a98d6c68 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -6,12 +6,12 @@ from collections.abc import Mapping from typing import Any from aiohttp import ClientConnectionError -from tesla_fleet_api import Teslemetry from tesla_fleet_api.exceptions import ( InvalidToken, SubscriptionRequired, TeslaFleetError, ) +from tesla_fleet_api.teslemetry import Teslemetry import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/teslemetry/const.py b/homeassistant/components/teslemetry/const.py index 01c6c33f505..ebda486aedf 100644 --- a/homeassistant/components/teslemetry/const.py +++ b/homeassistant/components/teslemetry/const.py @@ -9,13 +9,6 @@ DOMAIN = "teslemetry" LOGGER = logging.getLogger(__package__) -MODELS = { - "S": "Model S", - "3": "Model 3", - "X": "Model X", - "Y": "Model Y", -} - ENERGY_HISTORY_FIELDS = [ "solar_energy_exported", "generator_energy_exported", diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 0cd2a5a62d6..406b9cb2d84 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -5,13 +5,13 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( InvalidToken, SubscriptionRequired, TeslaFleetError, ) +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -49,7 +49,7 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: VehicleSpecific, + api: Vehicle, product: dict, ) -> None: """Initialize Teslemetry Vehicle Update Coordinator.""" @@ -58,8 +58,11 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): LOGGER, config_entry=config_entry, name="Teslemetry Vehicle", - update_interval=VEHICLE_INTERVAL, ) + if product["command_signing"] == "off": + # Only allow automatic polling if its included + self.update_interval = VEHICLE_INTERVAL + self.api = api self.data = flatten(product) self.last_active = datetime.now() @@ -87,7 +90,7 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: EnergySpecific, + api: EnergySite, data: dict, ) -> None: """Initialize Teslemetry Energy Site Live coordinator.""" @@ -133,7 +136,7 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: EnergySpecific, + api: EnergySite, product: dict, ) -> None: """Initialize Teslemetry Energy Info coordinator.""" @@ -169,7 +172,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: EnergySpecific, + api: EnergySite, ) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( @@ -192,7 +195,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise UpdateFailed(e.message) from e # Add all time periods together - output = {key: 0 for key in ENERGY_HISTORY_FIELDS} + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: output[key] += period.get(key, 0) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index de91f43f084..be85a877c86 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -6,6 +6,7 @@ from itertools import chain from typing import Any from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand +from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import Signal from teslemetry_stream.const import WindowState @@ -21,7 +22,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .entity import ( TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_vehicle_command @@ -43,13 +44,15 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryPollingWindowEntity(vehicle, entry.runtime_data.scopes) + TeslemetryVehiclePollingWindowEntity(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) + TeslemetryVehiclePollingChargePortEntity( + vehicle, entry.runtime_data.scopes + ) if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" else TeslemetryStreamingChargePortEntity( vehicle, entry.runtime_data.scopes @@ -57,7 +60,9 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles ), ( - TeslemetryPollingFrontTrunkEntity(vehicle, entry.runtime_data.scopes) + TeslemetryVehiclePollingFrontTrunkEntity( + vehicle, entry.runtime_data.scopes + ) if vehicle.api.pre2021 or vehicle.firmware < "2024.26" else TeslemetryStreamingFrontTrunkEntity( vehicle, entry.runtime_data.scopes @@ -65,7 +70,9 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles ), ( - TeslemetryPollingRearTrunkEntity(vehicle, entry.runtime_data.scopes) + TeslemetryVehiclePollingRearTrunkEntity( + vehicle, entry.runtime_data.scopes + ) if vehicle.api.pre2021 or vehicle.firmware < "2024.26" else TeslemetryStreamingRearTrunkEntity( vehicle, entry.runtime_data.scopes @@ -97,6 +104,7 @@ class CoverRestoreEntity(RestoreEntity, CoverEntity): class TeslemetryWindowEntity(TeslemetryRootEntity, CoverEntity): """Base class for window cover entities.""" + api: Vehicle _attr_device_class = CoverDeviceClass.WINDOW _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -121,8 +129,8 @@ class TeslemetryWindowEntity(TeslemetryRootEntity, CoverEntity): self.async_write_ha_state() -class TeslemetryPollingWindowEntity( - TeslemetryVehicleEntity, TeslemetryWindowEntity, CoverEntity +class TeslemetryVehiclePollingWindowEntity( + TeslemetryVehiclePollingEntity, TeslemetryWindowEntity, CoverEntity ): """Polling cover entity for windows.""" @@ -175,7 +183,7 @@ class TeslemetryStreamingWindowEntity( self.async_on_remove( self.stream.async_add_listener( self._handle_stream_update, - {"vin": self.vin, "data": {self.streaming_key: None}}, + {"vin": self.vin, "data": None}, ) ) for signal in ( @@ -218,6 +226,7 @@ class TeslemetryChargePortEntity( ): """Base class for for charge port cover entities.""" + api: Vehicle _attr_device_class = CoverDeviceClass.DOOR _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -238,8 +247,8 @@ class TeslemetryChargePortEntity( self.async_write_ha_state() -class TeslemetryPollingChargePortEntity( - TeslemetryVehicleEntity, TeslemetryChargePortEntity +class TeslemetryVehiclePollingChargePortEntity( + TeslemetryVehiclePollingEntity, TeslemetryChargePortEntity ): """Polling cover entity for the charge port.""" @@ -298,6 +307,7 @@ class TeslemetryStreamingChargePortEntity( class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity): """Base class for the front trunk cover entities.""" + api: Vehicle _attr_device_class = CoverDeviceClass.DOOR _attr_supported_features = CoverEntityFeature.OPEN @@ -312,8 +322,8 @@ class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity): # In the future this could be extended to add aftermarket close support through a option flow -class TeslemetryPollingFrontTrunkEntity( - TeslemetryVehicleEntity, TeslemetryFrontTrunkEntity +class TeslemetryVehiclePollingFrontTrunkEntity( + TeslemetryVehiclePollingEntity, TeslemetryFrontTrunkEntity ): """Polling cover entity for the front trunk.""" @@ -359,6 +369,7 @@ class TeslemetryStreamingFrontTrunkEntity( class TeslemetryRearTrunkEntity(TeslemetryRootEntity, CoverEntity): """Cover entity for the rear trunk.""" + api: Vehicle _attr_device_class = CoverDeviceClass.DOOR _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -381,8 +392,8 @@ class TeslemetryRearTrunkEntity(TeslemetryRootEntity, CoverEntity): self.async_write_ha_state() -class TeslemetryPollingRearTrunkEntity( - TeslemetryVehicleEntity, TeslemetryRearTrunkEntity +class TeslemetryVehiclePollingRearTrunkEntity( + TeslemetryVehiclePollingEntity, TeslemetryRearTrunkEntity ): """Base class for the rear trunk cover entities.""" @@ -424,9 +435,10 @@ class TeslemetryStreamingRearTrunkEntity( self._attr_is_closed = None if value is None else not value -class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): +class TeslemetrySunroofEntity(TeslemetryVehiclePollingEntity, CoverEntity): """Cover entity for the sunroof.""" + api: Vehicle _attr_device_class = CoverDeviceClass.WINDOW _attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 6a758e68497..6a3df6ecb6a 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from tesla_fleet_api.const import Scope from teslemetry_stream import TeslemetryStreamVehicle from teslemetry_stream.const import TeslaLocation @@ -18,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity, TeslemetryVehicleStreamEntity +from .entity import TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity from .models import TeslemetryVehicleData PARALLEL_UPDATES = 0 @@ -73,14 +74,21 @@ async def async_setup_entry( """Set up the Teslemetry device tracker platform from a config entry.""" entities: list[ - TeslemetryPollingDeviceTrackerEntity | TeslemetryStreamingDeviceTrackerEntity + TeslemetryVehiclePollingDeviceTrackerEntity + | TeslemetryStreamingDeviceTrackerEntity ] = [] + # Only add vehicle location entities if the user has granted vehicle location scope. + if Scope.VEHICLE_LOCATION not in entry.runtime_data.scopes: + return + 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) + TeslemetryVehiclePollingDeviceTrackerEntity( + vehicle, description + ) ) else: entities.append( @@ -90,7 +98,9 @@ async def async_setup_entry( async_add_entities(entities) -class TeslemetryPollingDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): +class TeslemetryVehiclePollingDeviceTrackerEntity( + TeslemetryVehiclePollingEntity, TrackerEntity +): """Base class for Teslemetry Tracker Entities.""" entity_description: TeslemetryDeviceTrackerEntityDescription diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index 82d3db123c3..588bf0b1b65 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -3,14 +3,13 @@ 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 tesla_fleet_api.teslemetry import EnergySite, Vehicle from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -20,7 +19,6 @@ from .coordinator import ( TeslemetryEnergySiteLiveCoordinator, TeslemetryVehicleDataCoordinator, ) -from .helpers import wake_up_vehicle from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -29,7 +27,6 @@ class TeslemetryRootEntity(Entity): _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.""" @@ -41,7 +38,7 @@ class TeslemetryRootEntity(Entity): ) -class TeslemetryEntity( +class TeslemetryPollingEntity( TeslemetryRootEntity, CoordinatorEntity[ TeslemetryVehicleDataCoordinator @@ -101,11 +98,11 @@ class TeslemetryEntity( """Update the attributes of the entity.""" -class TeslemetryVehicleEntity(TeslemetryEntity): +class TeslemetryVehiclePollingEntity(TeslemetryPollingEntity): """Parent class for Teslemetry Vehicle entities.""" _last_update: int = 0 - api: VehicleSpecific + api: Vehicle vehicle: TeslemetryVehicleData def __init__( @@ -119,6 +116,12 @@ class TeslemetryVehicleEntity(TeslemetryEntity): self.vehicle = data self._attr_unique_id = f"{data.vin}-{key}" self._attr_device_info = data.device + + if not data.poll: + # This entities data is not available for free + # so disable it by default + self._attr_entity_registry_enabled_default = False + super().__init__(data.coordinator, key) @property @@ -126,15 +129,11 @@ class TeslemetryVehicleEntity(TeslemetryEntity): """Return a specific value from coordinator data.""" return self.coordinator.data.get(self.key) - async def wake_up_if_asleep(self) -> None: - """Wake up the vehicle if its asleep.""" - await wake_up_vehicle(self.vehicle) - -class TeslemetryEnergyLiveEntity(TeslemetryEntity): +class TeslemetryEnergyLiveEntity(TeslemetryPollingEntity): """Parent class for Teslemetry Energy Site Live entities.""" - api: EnergySpecific + api: EnergySite def __init__( self, @@ -152,10 +151,10 @@ class TeslemetryEnergyLiveEntity(TeslemetryEntity): super().__init__(data.live_coordinator, key) -class TeslemetryEnergyInfoEntity(TeslemetryEntity): +class TeslemetryEnergyInfoEntity(TeslemetryPollingEntity): """Parent class for Teslemetry Energy Site Info Entities.""" - api: EnergySpecific + api: EnergySite def __init__( self, @@ -171,7 +170,7 @@ class TeslemetryEnergyInfoEntity(TeslemetryEntity): super().__init__(data.info_coordinator, key) -class TeslemetryEnergyHistoryEntity(TeslemetryEntity): +class TeslemetryEnergyHistoryEntity(TeslemetryPollingEntity): """Parent class for Teslemetry Energy History Entities.""" def __init__( @@ -190,11 +189,11 @@ class TeslemetryEnergyHistoryEntity(TeslemetryEntity): super().__init__(data.history_coordinator, key) -class TeslemetryWallConnectorEntity(TeslemetryEntity): +class TeslemetryWallConnectorEntity(TeslemetryPollingEntity): """Parent class for Teslemetry Wall Connector Entities.""" _attr_has_entity_name = True - api: EnergySpecific + api: EnergySite def __init__( self, @@ -230,7 +229,7 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): super().__init__(data.live_coordinator, key) @property - def _value(self) -> int: + def _value(self) -> StateType: """Return a specific wall connector value from coordinator data.""" return ( self.coordinator.data.get("wall_connectors", {}) @@ -249,11 +248,10 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): """Parent class for Teslemetry Vehicle Stream entities.""" - def __init__( - self, data: TeslemetryVehicleData, key: str, streaming_key: Signal | None = None - ) -> None: + api: Vehicle + + def __init__(self, data: TeslemetryVehicleData, key: str) -> None: """Initialize common aspects of a Teslemetry entity.""" - self.streaming_key = streaming_key self.vehicle = data self.api = data.api @@ -265,32 +263,7 @@ class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): 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 + @property def available(self) -> bool: """Return True if entity is available.""" return self.stream.connected diff --git a/homeassistant/components/teslemetry/helpers.py b/homeassistant/components/teslemetry/helpers.py index 30601feccbc..c6f15d7bfdf 100644 --- a/homeassistant/components/teslemetry/helpers.py +++ b/homeassistant/components/teslemetry/helpers.py @@ -1,13 +1,12 @@ """Teslemetry helper functions.""" -import asyncio from typing import Any from tesla_fleet_api.exceptions import TeslaFleetError from homeassistant.exceptions import HomeAssistantError -from .const import DOMAIN, LOGGER, TeslemetryState +from .const import DOMAIN, LOGGER def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]: @@ -23,34 +22,6 @@ def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]: return result -async def wake_up_vehicle(vehicle) -> None: - """Wake up a vehicle.""" - async with vehicle.wakelock: - times = 0 - while vehicle.coordinator.data["state"] != TeslemetryState.ONLINE: - try: - if times == 0: - cmd = await vehicle.api.wake_up() - else: - cmd = await vehicle.api.vehicle() - state = cmd["response"]["state"] - except TeslaFleetError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="wake_up_failed", - translation_placeholders={"message": e.message}, - ) from e - vehicle.coordinator.data["state"] = state - if state != TeslemetryState.ONLINE: - times += 1 - if times >= 4: # Give up after 30 seconds total - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="wake_up_timeout", - ) - await asyncio.sleep(times * 5) - - async def handle_command(command) -> dict[str, Any]: """Handle a command.""" try: diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 9996a508177..edd5d404499 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -1,6 +1,24 @@ { "entity": { "binary_sensor": { + "state": { + "state": { + "off": "mdi:sleep", + "on": "mdi:car-connected" + } + }, + "cellular": { + "state": { + "off": "mdi:signal-cellular-outline", + "on": "mdi:signal-cellular-3" + } + }, + "wifi": { + "state": { + "off": "mdi:wifi-off", + "on": "mdi:wifi" + } + }, "climate_state_is_preconditioning": { "state": { "off": "mdi:hvac-off", @@ -42,6 +60,78 @@ "off": "mdi:tire", "on": "mdi:car-tire-alert" } + }, + "charge_enable_request": { + "state": { + "off": "mdi:battery-off-outline", + "on": "mdi:battery-charging-outline" + } + }, + "defrost_for_preconditioning": { + "state": { + "off": "mdi:snowflake-off", + "on": "mdi:snowflake-melt" + } + }, + "lights_hazards_active": { + "state": { + "off": "mdi:car-light-dimmed", + "on": "mdi:hazard-lights" + } + }, + "lights_high_beams": { + "state": { + "off": "mdi:car-light-dimmed", + "on": "mdi:car-light-high" + } + }, + "seat_vent_enabled": { + "state": { + "off": "mdi:car-seat", + "on": "mdi:fan" + } + }, + "speed_limit_mode": { + "state": { + "off": "mdi:speedometer", + "on": "mdi:car-speed-limiter" + } + }, + "remote_start_enabled": { + "state": { + "off": "mdi:remote-off", + "on": "mdi:remote" + } + }, + "hvac_auto_mode": { + "state": { + "off": "mdi:hvac-off", + "on": "mdi:hvac" + } + }, + "backup_capable": { + "state": { + "off": "mdi:battery-off", + "on": "mdi:home-battery" + } + }, + "grid_status": { + "state": { + "off": "mdi:transmission-tower-off", + "on": "mdi:transmission-tower" + } + }, + "grid_services_active": { + "state": { + "on": "mdi:sine-wave", + "off": "mdi:transmission-tower-off" + } + }, + "components_grid_services_enabled": { + "state": { + "on": "mdi:sine-wave", + "off": "mdi:transmission-tower-off" + } } }, "button": { @@ -165,6 +255,7 @@ "default": "mdi:ev-plug-ccs2" } }, + "sensor": { "battery_power": { "default": "mdi:home-battery" @@ -288,6 +379,344 @@ }, "consumer_energy_imported_from_generator": { "default": "mdi:generator-stationary" + }, + "sentry_mode": { + "default": "mdi:shield-car", + "state": { + "off": "mdi:shield-off-outline", + "idle": "mdi:shield-outline", + "armed": "mdi:shield-check", + "aware": "mdi:shield-alert", + "panic": "mdi:shield-alert-outline", + "quiet": "mdi:shield-half-full" + } + }, + "bms_state": { + "default": "mdi:battery-heart-variant", + "state": { + "standby": "mdi:battery-clock", + "drive": "mdi:car-electric", + "support": "mdi:battery-check", + "charge": "mdi:battery-charging", + "full_electric_in_motion": "mdi:battery-arrow-up", + "clear_fault": "mdi:battery-alert-variant-outline", + "fault": "mdi:battery-alert", + "weld": "mdi:battery-lock", + "test": "mdi:battery-sync", + "system_not_available": "mdi:battery-off" + } + }, + "brake_pedal_position": { + "default": "mdi:car-brake-alert" + }, + "brick_voltage_max": { + "default": "mdi:battery-high" + }, + "brick_voltage_min": { + "default": "mdi:battery-low" + }, + "credit_balance": { + "default": "mdi:credit-card" + }, + "cruise_follow_distance": { + "default": "mdi:car-cruise-control" + }, + "cruise_set_speed": { + "default": "mdi:speedometer" + }, + "current_limit_mph": { + "default": "mdi:car-cruise-control" + }, + "dc_charging_energy_in": { + "default": "mdi:ev-station" + }, + "dc_charging_power": { + "default": "mdi:lightning-bolt" + }, + "di_axle_speed_f": { + "default": "mdi:speedometer" + }, + "di_axle_speed_r": { + "default": "mdi:speedometer" + }, + "di_axle_speed_rel": { + "default": "mdi:speedometer" + }, + "di_axle_speed_rer": { + "default": "mdi:speedometer" + }, + "di_heatsink_tf": { + "default": "mdi:thermometer" + }, + "di_heatsink_tr": { + "default": "mdi:thermometer" + }, + "di_heatsink_trel": { + "default": "mdi:thermometer" + }, + "di_heatsink_trer": { + "default": "mdi:thermometer" + }, + "di_inverter_tf": { + "default": "mdi:sine-wave" + }, + "di_inverter_tr": { + "default": "mdi:sine-wave" + }, + "di_inverter_trel": { + "default": "mdi:sine-wave" + }, + "di_inverter_trer": { + "default": "mdi:sine-wave" + }, + "di_motor_current_f": { + "default": "mdi:current-ac" + }, + "di_motor_current_r": { + "default": "mdi:current-ac" + }, + "di_motor_current_rel": { + "default": "mdi:current-ac" + }, + "di_motor_current_rer": { + "default": "mdi:current-ac" + }, + "di_slave_torque_cmd": { + "default": "mdi:engine" + }, + "di_state_f": { + "default": "mdi:car-electric", + "state": { + "unavailable": "mdi:car-off", + "standby": "mdi:power-sleep", + "fault": "mdi:alert-circle", + "abort": "mdi:stop-circle", + "enabled": "mdi:check-circle" + } + }, + "di_state_r": { + "default": "mdi:car-electric", + "state": { + "unavailable": "mdi:car-off", + "standby": "mdi:power-sleep", + "fault": "mdi:alert-circle", + "abort": "mdi:stop-circle", + "enabled": "mdi:check-circle" + } + }, + "di_state_rel": { + "default": "mdi:car-electric", + "state": { + "unavailable": "mdi:car-off", + "standby": "mdi:power-sleep", + "fault": "mdi:alert-circle", + "abort": "mdi:stop-circle", + "enabled": "mdi:check-circle" + } + }, + "di_state_rer": { + "default": "mdi:car-electric", + "state": { + "unavailable": "mdi:car-off", + "standby": "mdi:power-sleep", + "fault": "mdi:alert-circle", + "abort": "mdi:stop-circle", + "enabled": "mdi:check-circle" + } + }, + "di_stator_temp_f": { + "default": "mdi:thermometer" + }, + "di_stator_temp_r": { + "default": "mdi:thermometer" + }, + "di_stator_temp_rel": { + "default": "mdi:thermometer" + }, + "di_stator_temp_rer": { + "default": "mdi:thermometer" + }, + "energy_remaining": { + "default": "mdi:battery-medium" + }, + "estimated_hours_to_charge_termination": { + "default": "mdi:battery-clock" + }, + "forward_collision_warning": { + "default": "mdi:car-crash", + "state": { + "off": "mdi:car-off", + "late": "mdi:alert", + "average": "mdi:alert-circle", + "early": "mdi:alert-octagon" + } + }, + "gps_heading": { + "default": "mdi:compass" + }, + "guest_mode_mobile_access_state": { + "default": "mdi:account-key", + "state": { + "init": "mdi:cog-refresh", + "not_authenticated": "mdi:account-off", + "authenticated": "mdi:account-check", + "aborted_driving": "mdi:car-off", + "aborted_using_remote_start": "mdi:remote-off", + "aborted_using_ble_keys": "mdi:bluetooth-off", + "aborted_valet_mode": "mdi:car-key", + "aborted_guest_mode_off": "mdi:power-off", + "aborted_drive_auth_time_exceeded": "mdi:timer-off", + "aborted_no_data_received": "mdi:network-off", + "requesting_from_mothership": "mdi:cloud-download", + "requesting_from_auth_d": "mdi:shield-key", + "aborted_fetch_failed": "mdi:wifi-off", + "aborted_bad_data_received": "mdi:file-alert", + "showing_qr_code": "mdi:qrcode", + "swiped_away": "mdi:gesture-swipe", + "dismissed_qr_code_expired": "mdi:clock-alert", + "succeeded_paired_new_ble_key": "mdi:bluetooth-connect" + } + }, + "homelink_device_count": { + "default": "mdi:garage" + }, + "hvac_fan_speed": { + "default": "mdi:fan" + }, + "hvac_fan_status": { + "default": "mdi:fan" + }, + "hvac_left_temperature_request": { + "default": "mdi:thermometer" + }, + "hvac_right_temperature_request": { + "default": "mdi:thermometer" + }, + "isolation_resistance": { + "default": "mdi:resistor" + }, + "lane_departure_avoidance": { + "default": "mdi:road-variant", + "state": { + "warning": "mdi:alert", + "assist": "mdi:steering" + } + }, + "lateral_acceleration": { + "default": "mdi:axis-arrow" + }, + "lifetime_energy_used": { + "default": "mdi:lightning-bolt" + }, + "lifetime_energy_used_drive": { + "default": "mdi:lightning-bolt" + }, + "longitudinal_acceleration": { + "default": "mdi:axis-arrow" + }, + "module_temp_max": { + "default": "mdi:thermometer-high" + }, + "module_temp_min": { + "default": "mdi:thermometer-low" + }, + "pack_current": { + "default": "mdi:current-dc" + }, + "pack_voltage": { + "default": "mdi:lightning-bolt" + }, + "paired_phone_key_and_key_fob_qty": { + "default": "mdi:key" + }, + "pedal_position": { + "default": "mdi:pedestal" + }, + "powershare_hours_left": { + "default": "mdi:clock-time-eight-outline" + }, + "powershare_instantaneous_power_kw": { + "default": "mdi:flash" + }, + "powershare_status": { + "default": "mdi:power-socket", + "state": { + "inactive": "mdi:power-plug-off-outline", + "handshaking": "mdi:handshake", + "init": "mdi:cog-refresh", + "enabled": "mdi:check-circle", + "reconnecting": "mdi:wifi-refresh", + "stopped": "mdi:stop-circle" + } + }, + "powershare_stop_reason": { + "default": "mdi:stop-circle", + "state": { + "soc_too_low": "mdi:battery-low", + "retry": "mdi:refresh", + "fault": "mdi:alert-circle", + "user": "mdi:account", + "reconnecting": "mdi:wifi-refresh", + "authentication": "mdi:shield-key" + } + }, + "powershare_type": { + "default": "mdi:power-socket", + "state": { + "load": "mdi:power-plug", + "home": "mdi:home" + } + }, + "rated_range": { + "default": "mdi:map-marker-distance" + }, + "route_last_updated": { + "default": "mdi:map-clock" + }, + "scheduled_charging_mode": { + "default": "mdi:calendar-clock", + "state": { + "off": "mdi:calendar" + } + }, + "software_update_expected_duration_minutes": { + "default": "mdi:update" + }, + "speed_limit_warning": { + "default": "mdi:car-cruise-control" + }, + "tonneau_tent_mode": { + "default": "mdi:tent", + "state": { + "moving": "mdi:sync", + "failed": "mdi:alert" + } + }, + "tpms_hard_warnings": { + "default": "mdi:car-tire-alert" + }, + "tpms_soft_warnings": { + "default": "mdi:car-tire-alert" + }, + "lights_turn_signal": { + "default": "mdi:car-light-dimmed", + "state": { + "left": "mdi:arrow-left-bold-box", + "right": "mdi:arrow-right-bold-box", + "both": "mdi:hazard-lights" + } + }, + "charge_rate_mile_per_hour": { + "default": "mdi:speedometer" + }, + "hvac_power_state": { + "default": "mdi:hvac", + "state": { + "precondition": "mdi:sun-thermometer", + "overheat_protection": "mdi:thermometer-alert", + "off": "mdi:hvac-off", + "on": "mdi:hvac" + } } }, "switch": { diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index 68505a12a13..fda52357f5c 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -6,6 +6,7 @@ from itertools import chain from typing import Any from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant @@ -17,7 +18,7 @@ from . import TeslemetryConfigEntry from .const import DOMAIN from .entity import ( TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_vehicle_command @@ -38,7 +39,7 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryPollingVehicleLockEntity( + TeslemetryVehiclePollingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) if vehicle.api.pre2021 or vehicle.firmware < "2024.26" @@ -48,7 +49,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles ), ( - TeslemetryPollingCableLockEntity( + TeslemetryVehiclePollingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) if vehicle.api.pre2021 or vehicle.firmware < "2024.26" @@ -64,6 +65,8 @@ async def async_setup_entry( class TeslemetryVehicleLockEntity(TeslemetryRootEntity, LockEntity): """Base vehicle lock entity for Teslemetry.""" + api: Vehicle + async def async_lock(self, **kwargs: Any) -> None: """Lock the doors.""" self.raise_for_scope(Scope.VEHICLE_CMDS) @@ -81,8 +84,8 @@ class TeslemetryVehicleLockEntity(TeslemetryRootEntity, LockEntity): self.async_write_ha_state() -class TeslemetryPollingVehicleLockEntity( - TeslemetryVehicleEntity, TeslemetryVehicleLockEntity +class TeslemetryVehiclePollingVehicleLockEntity( + TeslemetryVehiclePollingEntity, TeslemetryVehicleLockEntity ): """Polling vehicle lock entity for Teslemetry.""" @@ -135,6 +138,8 @@ class TeslemetryStreamingVehicleLockEntity( class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity): """Base cable Lock entity for Teslemetry.""" + api: Vehicle + async def async_lock(self, **kwargs: Any) -> None: """Charge cable Lock cannot be manually locked.""" raise ServiceValidationError( @@ -152,8 +157,8 @@ class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity): self.async_write_ha_state() -class TeslemetryPollingCableLockEntity( - TeslemetryVehicleEntity, TeslemetryCableLockEntity +class TeslemetryVehiclePollingCableLockEntity( + TeslemetryVehiclePollingEntity, TeslemetryCableLockEntity ): """Polling cable lock entity for Teslemetry.""" diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index dfe6d7cb3f9..5b7454b87b6 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.9.10", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.7.7"] } diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index 1bfc9bf66dc..bf1fffed583 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -3,6 +3,7 @@ from __future__ import annotations from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -12,9 +13,14 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehiclePollingEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -24,8 +30,16 @@ STATES = { "Stopped": MediaPlayerState.IDLE, "Off": MediaPlayerState.OFF, } -VOLUME_MAX = 11.0 -VOLUME_STEP = 1.0 / 3 +DISPLAY_STATES = { + "On": MediaPlayerState.IDLE, + "Accessory": MediaPlayerState.IDLE, + "Charging": MediaPlayerState.OFF, + "Sentry": MediaPlayerState.OFF, + "Off": MediaPlayerState.OFF, +} +# Tesla uses 31 steps, in 0.333 increments up to 10.333 +VOLUME_STEP = 1 / 31 +VOLUME_FACTOR = 31 / 3 # 10.333 PARALLEL_UPDATES = 0 @@ -38,68 +52,100 @@ async def async_setup_entry( """Set up the Teslemetry Media platform from a config entry.""" async_add_entities( - TeslemetryMediaEntity(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) + TeslemetryVehiclePollingMediaEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2025.2.6" + else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) -class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity): - """Vehicle media player class.""" +class TeslemetryMediaEntity(TeslemetryRootEntity, MediaPlayerEntity): + """Base vehicle media player class.""" + api: Vehicle _attr_device_class = MediaPlayerDeviceClass.SPEAKER - _attr_supported_features = ( - MediaPlayerEntityFeature.NEXT_TRACK - | MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.PREVIOUS_TRACK - | MediaPlayerEntityFeature.VOLUME_SET - ) - _volume_max: float = VOLUME_MAX + _attr_volume_step = VOLUME_STEP + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.adjust_volume(volume * VOLUME_FACTOR)) + self._attr_volume_level = volume + self.async_write_ha_state() + + async def async_media_play(self) -> None: + """Send play command.""" + if self.state != MediaPlayerState.PLAYING: + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PLAYING + self.async_write_ha_state() + + async def async_media_pause(self) -> None: + """Send pause command.""" + + if self.state == MediaPlayerState.PLAYING: + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PAUSED + self.async_write_ha_state() + + async def async_media_next_track(self) -> None: + """Send next track command.""" + + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command(self.api.media_next_track()) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command(self.api.media_prev_track()) + + +class TeslemetryVehiclePollingMediaEntity( + TeslemetryVehiclePollingEntity, TeslemetryMediaEntity +): + """Polling vehicle media player class.""" def __init__( self, data: TeslemetryVehicleData, - scoped: bool, + scopes: list[Scope], ) -> None: """Initialize the media player entity.""" super().__init__(data, "media") - self.scoped = scoped - if not scoped: + + self._attr_supported_features = ( + MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + ) + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: self._attr_supported_features = MediaPlayerEntityFeature(0) def _async_update_attrs(self) -> None: """Update entity attributes.""" - self._volume_max = ( - self.get("vehicle_state_media_info_audio_volume_max") or VOLUME_MAX - ) - self._attr_state = STATES.get( - self.get("vehicle_state_media_info_media_playback_status") or "Off", - ) - self._attr_volume_step = ( - 1.0 - / self._volume_max - / ( - self.get("vehicle_state_media_info_audio_volume_increment") - or VOLUME_STEP - ) - ) + state = self.get("vehicle_state_media_info_media_playback_status") + self._attr_state = STATES.get(state) if state else None + self._attr_volume_level = ( + self.get("vehicle_state_media_info_audio_volume") or 0 + ) / VOLUME_FACTOR - if volume := self.get("vehicle_state_media_info_audio_volume"): - self._attr_volume_level = volume / self._volume_max - else: - self._attr_volume_level = None + duration = self.get("vehicle_state_media_info_now_playing_duration") + self._attr_media_duration = duration / 1000 if duration is not None else None - if duration := self.get("vehicle_state_media_info_now_playing_duration"): - self._attr_media_duration = duration / 1000 - else: - self._attr_media_duration = None - - if duration and ( - position := self.get("vehicle_state_media_info_now_playing_elapsed") - ): - self._attr_media_position = position / 1000 - else: - self._attr_media_position = None + # Return media position only when a media duration is > 0. + elapsed = self.get("vehicle_state_media_info_now_playing_elapsed") + self._attr_media_position = ( + elapsed / 1000 if duration and elapsed is not None else None + ) self._attr_media_title = self.get("vehicle_state_media_info_now_playing_title") self._attr_media_artist = self.get( @@ -113,42 +159,151 @@ class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity): ) self._attr_source = self.get("vehicle_state_media_info_now_playing_source") - async def async_set_volume_level(self, volume: float) -> None: - """Set volume level, range 0..1.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command( - self.api.adjust_volume(int(volume * self._volume_max)) + +class TeslemetryStreamingMediaEntity( + TeslemetryVehicleStreamEntity, TeslemetryMediaEntity, RestoreEntity +): + """Streaming vehicle media player class.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the media player entity.""" + super().__init__(data, "media") + + self._attr_supported_features = ( + MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET ) - self._attr_volume_level = volume + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = MediaPlayerEntityFeature(0) + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + try: + self._attr_state = MediaPlayerState(state.state) + except ValueError: + self._attr_state = None + self._attr_volume_level = state.attributes.get("volume_level") + self._attr_media_title = state.attributes.get("media_title") + self._attr_media_artist = state.attributes.get("media_artist") + self._attr_media_album_name = state.attributes.get("media_album_name") + self._attr_media_playlist = state.attributes.get("media_playlist") + self._attr_media_duration = state.attributes.get("media_duration") + self._attr_media_position = state.attributes.get("media_position") + self._attr_source = state.attributes.get("source") + + self.async_write_ha_state() + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_CenterDisplay( + self._async_handle_center_display + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaPlaybackStatus( + self._async_handle_media_playback_status + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaPlaybackSource( + self._async_handle_media_playback_source + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaAudioVolume( + self._async_handle_media_audio_volume + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingDuration( + self._async_handle_media_now_playing_duration + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingElapsed( + self._async_handle_media_now_playing_elapsed + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingArtist( + self._async_handle_media_now_playing_artist + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingAlbum( + self._async_handle_media_now_playing_album + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingTitle( + self._async_handle_media_now_playing_title + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingStation( + self._async_handle_media_now_playing_station + ) + ) + + def _async_handle_center_display(self, value: str | None) -> None: + """Update entity attributes.""" + if value is not None: + self._attr_state = DISPLAY_STATES.get(value) + self.async_write_ha_state() + + def _async_handle_media_playback_status(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_state = MediaPlayerState.OFF if value is None else STATES.get(value) self.async_write_ha_state() - async def async_media_play(self) -> None: - """Send play command.""" - if self.state != MediaPlayerState.PLAYING: - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_toggle_playback()) - self._attr_state = MediaPlayerState.PLAYING - self.async_write_ha_state() + def _async_handle_media_playback_source(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_source = value + self.async_write_ha_state() - async def async_media_pause(self) -> None: - """Send pause command.""" - if self.state == MediaPlayerState.PLAYING: - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_toggle_playback()) - self._attr_state = MediaPlayerState.PAUSED - self.async_write_ha_state() + def _async_handle_media_audio_volume(self, value: float | None) -> None: + """Update entity attributes.""" + self._attr_volume_level = None if value is None else value / VOLUME_FACTOR + self.async_write_ha_state() - async def async_media_next_track(self) -> None: - """Send next track command.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_next_track()) + def _async_handle_media_now_playing_duration(self, value: int | None) -> None: + """Update entity attributes.""" + self._attr_media_duration = None if value is None else int(value / 1000) + self.async_write_ha_state() - async def async_media_previous_track(self) -> None: - """Send previous track command.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_prev_track()) + def _async_handle_media_now_playing_elapsed(self, value: int | None) -> None: + """Update entity attributes.""" + self._attr_media_position = None if value is None else int(value / 1000) + self.async_write_ha_state() + + def _async_handle_media_now_playing_artist(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_artist = value # Check if this is album artist or not + self.async_write_ha_state() + + def _async_handle_media_now_playing_album(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_album_name = value + self.async_write_ha_state() + + def _async_handle_media_now_playing_title(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_title = value + self.async_write_ha_state() + + def _async_handle_media_now_playing_station(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_channel = ( + value # could also be _attr_media_playlist when Spotify + ) + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 5b78386c68a..51eed97227e 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -6,8 +6,8 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle from homeassistant.config_entries import ConfigEntry @@ -28,15 +28,17 @@ class TeslemetryData: vehicles: list[TeslemetryVehicleData] energysites: list[TeslemetryEnergyData] scopes: list[Scope] + stream: TeslemetryStream @dataclass class TeslemetryVehicleData: """Data for a vehicle in the Teslemetry integration.""" - api: VehicleSpecific + api: Vehicle config_entry: ConfigEntry coordinator: TeslemetryVehicleDataCoordinator + poll: bool stream: TeslemetryStream stream_vehicle: TeslemetryStreamVehicle vin: str @@ -50,7 +52,7 @@ class TeslemetryVehicleData: class TeslemetryEnergyData: """Data for a vehicle in the Teslemetry integration.""" - api: EnergySpecific + api: EnergySite live_coordinator: TeslemetryEnergySiteLiveCoordinator | None info_coordinator: TeslemetryEnergySiteInfoCoordinator history_coordinator: TeslemetryEnergyHistoryCoordinator | None diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 10c15a68b09..bb9f5b588a0 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -7,8 +7,8 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.number import ( @@ -33,7 +33,7 @@ from . import TeslemetryConfigEntry from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_command, handle_vehicle_command @@ -46,7 +46,7 @@ PARALLEL_UPDATES = 0 class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription): """Describes Teslemetry Number entity.""" - func: Callable[[VehicleSpecific, int], Awaitable[Any]] + func: Callable[[Vehicle, int], Awaitable[Any]] min_key: str | None = None max_key: str native_min_value: float @@ -99,7 +99,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( class TeslemetryNumberBatteryEntityDescription(NumberEntityDescription): """Describes Teslemetry Number entity.""" - func: Callable[[EnergySpecific, float], Awaitable[Any]] + func: Callable[[EnergySite, float], Awaitable[Any]] requires: str | None = None scopes: list[Scope] @@ -140,7 +140,7 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryPollingNumberEntity( + TeslemetryVehiclePollingNumberEntity( vehicle, description, entry.runtime_data.scopes, @@ -172,6 +172,7 @@ async def async_setup_entry( class TeslemetryVehicleNumberEntity(TeslemetryRootEntity, NumberEntity): """Vehicle number entity base class.""" + api: Vehicle entity_description: TeslemetryNumberVehicleEntityDescription async def async_set_native_value(self, value: float) -> None: @@ -183,8 +184,8 @@ class TeslemetryVehicleNumberEntity(TeslemetryRootEntity, NumberEntity): self.async_write_ha_state() -class TeslemetryPollingNumberEntity( - TeslemetryVehicleEntity, TeslemetryVehicleNumberEntity +class TeslemetryVehiclePollingNumberEntity( + TeslemetryVehiclePollingEntity, TeslemetryVehicleNumberEntity ): """Vehicle polling number entity.""" @@ -243,6 +244,7 @@ class TeslemetryStreamingNumberEntity( self._attr_native_value = last_number_data.native_value if last_number_data.native_max_value: self._attr_native_max_value = last_number_data.native_max_value + self.async_write_ha_state() # Add listeners self.async_on_remove( diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 0d268e302de..c24c47feb2e 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -7,8 +7,8 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat +from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -20,7 +20,7 @@ from . import TeslemetryConfigEntry from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryRootEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, ) from .helpers import handle_command, handle_vehicle_command @@ -40,7 +40,7 @@ LEVEL = {OFF: 0, LOW: 1, MEDIUM: 2, HIGH: 3} class TeslemetrySelectEntityDescription(SelectEntityDescription): """Seat Heater entity description.""" - select_fn: Callable[[VehicleSpecific, int], Awaitable[Any]] + select_fn: Callable[[Vehicle, int], Awaitable[Any]] supported_fn: Callable[[dict], bool] = lambda _: True streaming_listener: ( Callable[ @@ -177,7 +177,7 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryPollingSelectEntity( + TeslemetryVehiclePollingSelectEntity( vehicle, description, entry.runtime_data.scopes ) if vehicle.api.pre2021 @@ -208,6 +208,7 @@ async def async_setup_entry( class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity): """Parent vehicle select entity class.""" + api: Vehicle entity_description: TeslemetrySelectEntityDescription _climate: bool = False @@ -223,7 +224,9 @@ class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity): self.async_write_ha_state() -class TeslemetryPollingSelectEntity(TeslemetryVehicleEntity, TeslemetrySelectEntity): +class TeslemetryVehiclePollingSelectEntity( + TeslemetryVehiclePollingEntity, TeslemetrySelectEntity +): """Base polling vehicle select entity class.""" def __init__( diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 70315e92da0..ab075d18132 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -6,8 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from propcache.api import cached_property -from teslemetry_stream import Signal +from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle from homeassistant.components.sensor import ( RestoreSensor, @@ -17,6 +16,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + DEGREE, PERCENTAGE, EntityCategory, UnitOfElectricCurrent, @@ -41,14 +41,27 @@ from .entity import ( TeslemetryEnergyHistoryEntity, TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, - TeslemetryVehicleEntity, + TeslemetryVehiclePollingEntity, TeslemetryVehicleStreamEntity, TeslemetryWallConnectorEntity, ) -from .models import TeslemetryEnergyData, TeslemetryVehicleData +from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData PARALLEL_UPDATES = 0 +BMS_STATES = { + "Standby": "standby", + "Drive": "drive", + "Support": "support", + "Charge": "charge", + "FEIM": "full_electric_in_motion", + "ClearFault": "clear_fault", + "Fault": "fault", + "Weld": "weld", + "Test": "test", + "SNA": "system_not_available", +} + CHARGE_STATES = { "Starting": "starting", "Charging": "charging", @@ -58,8 +71,117 @@ CHARGE_STATES = { "NoPower": "no_power", } +DRIVE_INVERTER_STATES = { + "Unavailable": "unavailable", + "Standby": "standby", + "Fault": "fault", + "Abort": "abort", + "Enable": "enabled", +} + SHIFT_STATES = {"P": "p", "D": "d", "R": "r", "N": "n"} +SENTRY_MODE_STATES = { + "Off": "off", + "Idle": "idle", + "Armed": "armed", + "Aware": "aware", + "Panic": "panic", + "Quiet": "quiet", +} + +POWER_SHARE_STATES = { + "Inactive": "inactive", + "Handshaking": "handshaking", + "Init": "init", + "Enabled": "enabled", + "EnabledReconnectingSoon": "reconnecting", + "Stopped": "stopped", +} + +POWER_SHARE_STOP_REASONS = { + "None": "none", + "SOCTooLow": "soc_too_low", + "Retry": "retry", + "Fault": "fault", + "User": "user", + "Reconnecting": "reconnecting", + "Authentication": "authentication", +} + +POWER_SHARE_TYPES = { + "None": "none", + "Load": "load", + "Home": "home", +} + +FORWARD_COLLISION_SENSITIVITIES = { + "Off": "off", + "Late": "late", + "Average": "average", + "Early": "early", +} + +GUEST_MODE_MOBILE_ACCESS_STATES = { + "Init": "init", + "NotAuthenticated": "not_authenticated", + "Authenticated": "authenticated", + "AbortedDriving": "aborted_driving", + "AbortedUsingRemoteStart": "aborted_using_remote_start", + "AbortedUsingBLEKeys": "aborted_using_ble_keys", + "AbortedValetMode": "aborted_valet_mode", + "AbortedGuestModeOff": "aborted_guest_mode_off", + "AbortedDriveAuthTimeExceeded": "aborted_drive_auth_time_exceeded", + "AbortedNoDataReceived": "aborted_no_data_received", + "RequestingFromMothership": "requesting_from_mothership", + "RequestingFromAuthD": "requesting_from_auth_d", + "AbortedFetchFailed": "aborted_fetch_failed", + "AbortedBadDataReceived": "aborted_bad_data_received", + "ShowingQRCode": "showing_qr_code", + "SwipedAway": "swiped_away", + "DismissedQRCodeExpired": "dismissed_qr_code_expired", + "SucceededPairedNewBLEKey": "succeeded_paired_new_ble_key", +} + +HVAC_POWER_STATES = { + "Off": "off", + "On": "on", + "Precondition": "precondition", + "OverheatProtect": "overheat_protection", +} + +LANE_ASSIST_LEVELS = { + "None": "off", + "Warning": "warning", + "Assist": "assist", +} + +SCHEDULED_CHARGING_MODES = { + "Off": "off", + "StartAt": "start_at", + "DepartBy": "depart_by", +} + +SPEED_ASSIST_LEVELS = { + "None": "none", + "Display": "display", + "Chime": "chime", +} + +TONNEAU_TENT_MODE_STATES = { + "Inactive": "inactive", + "Moving": "moving", + "Failed": "failed", + "Active": "active", +} + +TURN_SIGNAL_STATES = { + "Off": "off", + "Left": "left", + "Right": "right", + "Both": "both", +} + @dataclass(frozen=True, kw_only=True) class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): @@ -67,9 +189,14 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): 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 + nullable: bool = False + streaming_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[StateType], None]], + Callable[[], None], + ] + | None + ) = None streaming_firmware: str = "2024.26" @@ -77,18 +204,19 @@ 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", "") + streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( + lambda value: None if value is None else callback(value.lower()) ), + polling_value_fn=lambda value: CHARGE_STATES.get(str(value)), options=list(CHARGE_STATES.values()), device_class=SensorDeviceClass.ENUM, ), TeslemetryVehicleSensorEntityDescription( key="charge_state_battery_level", polling=True, - streaming_key=Signal.BATTERY_LEVEL, + streaming_listener=lambda vehicle, callback: vehicle.listen_BatteryLevel( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -97,15 +225,19 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_usable_battery_level", polling=True, + streaming_listener=lambda vehicle, callback: vehicle.listen_Soc(callback), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_registry_enabled_default=False, + suggested_display_precision=1, ), TeslemetryVehicleSensorEntityDescription( key="charge_state_charge_energy_added", polling=True, - streaming_key=Signal.AC_CHARGING_ENERGY_IN, + streaming_listener=lambda vehicle, callback: vehicle.listen_ACChargingEnergyIn( + callback + ), state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -114,7 +246,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_power", polling=True, - streaming_key=Signal.AC_CHARGING_POWER, + streaming_listener=lambda vehicle, callback: vehicle.listen_ACChargingPower( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, @@ -122,6 +256,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_voltage", polling=True, + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargerVoltage( + callback + ), + streaming_firmware="2024.44.32", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -130,7 +268,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_actual_current", polling=True, - streaming_key=Signal.CHARGE_AMPS, + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargeAmps( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -147,14 +287,18 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_conn_charge_cable", polling=True, - streaming_key=Signal.CHARGING_CABLE_TYPE, + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( + callback + ), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryVehicleSensorEntityDescription( key="charge_state_fast_charger_type", polling=True, - streaming_key=Signal.FAST_CHARGER_TYPE, + streaming_listener=lambda vehicle, callback: vehicle.listen_FastChargerType( + callback + ), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -169,7 +313,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_est_battery_range", polling=True, - streaming_key=Signal.EST_BATTERY_RANGE, + streaming_listener=lambda vehicle, callback: vehicle.listen_EstBatteryRange( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -179,7 +325,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_ideal_battery_range", polling=True, - streaming_key=Signal.IDEAL_BATTERY_RANGE, + streaming_listener=lambda vehicle, callback: vehicle.listen_IdealBatteryRange( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -190,7 +338,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( key="drive_state_speed", polling=True, polling_value_fn=lambda value: value or 0, - streaming_key=Signal.VEHICLE_SPEED, + streaming_listener=lambda vehicle, callback: vehicle.listen_VehicleSpeed( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, @@ -209,10 +359,11 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( 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)), + nullable=True, + streaming_listener=lambda vehicle, callback: vehicle.listen_Gear( + lambda value: callback("p" if value is None else value.lower()) + ), options=list(SHIFT_STATES.values()), device_class=SensorDeviceClass.ENUM, entity_registry_enabled_default=False, @@ -220,7 +371,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_odometer", polling=True, - streaming_key=Signal.ODOMETER, + streaming_listener=lambda vehicle, callback: vehicle.listen_Odometer(callback), state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -231,7 +382,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fl", polling=True, - streaming_key=Signal.TPMS_PRESSURE_FL, + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureFl( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -243,7 +396,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fr", polling=True, - streaming_key=Signal.TPMS_PRESSURE_FR, + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureFr( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -255,7 +410,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rl", polling=True, - streaming_key=Signal.TPMS_PRESSURE_RL, + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureRl( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -267,7 +424,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rr", polling=True, - streaming_key=Signal.TPMS_PRESSURE_RR, + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsPressureRr( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -279,7 +438,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="climate_state_inside_temp", polling=True, - streaming_key=Signal.INSIDE_TEMP, + streaming_listener=lambda vehicle, callback: vehicle.listen_InsideTemp( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -288,7 +449,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="climate_state_outside_temp", polling=True, - streaming_key=Signal.OUTSIDE_TEMP, + streaming_listener=lambda vehicle, callback: vehicle.listen_OutsideTemp( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -314,10 +477,33 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + TeslemetryVehicleSensorEntityDescription( + key="hvac_left_temperature_request", + streaming_listener=lambda vehicle, + callback: vehicle.listen_HvacLeftTemperatureRequest(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="hvac_right_temperature_request", + streaming_listener=lambda vehicle, + callback: vehicle.listen_HvacRightTemperatureRequest(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_traffic_minutes_delay", polling=True, - streaming_key=Signal.ROUTE_TRAFFIC_MINUTES_DELAY, + streaming_listener=lambda vehicle, + callback: vehicle.listen_RouteTrafficMinutesDelay(callback), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MINUTES, device_class=SensorDeviceClass.DURATION, @@ -326,7 +512,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_energy_at_arrival", polling=True, - streaming_key=Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL, + streaming_listener=lambda vehicle, + callback: vehicle.listen_ExpectedEnergyPercentAtTripArrival(callback), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -336,11 +523,840 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_miles_to_arrival", polling=True, - streaming_key=Signal.MILES_TO_ARRIVAL, + streaming_listener=lambda vehicle, callback: vehicle.listen_MilesToArrival( + callback + ), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, ), + TeslemetryVehicleSensorEntityDescription( + key="bms_state", + streaming_listener=lambda vehicle, callback: vehicle.listen_BMSState( + lambda value: None if value is None else callback(BMS_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(BMS_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="brake_pedal_position", + streaming_listener=lambda vehicle, callback: vehicle.listen_BrakePedalPos( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="brick_voltage_max", + streaming_listener=lambda vehicle, callback: vehicle.listen_BrickVoltageMax( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="brick_voltage_min", + streaming_listener=lambda vehicle, callback: vehicle.listen_BrickVoltageMin( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="cruise_follow_distance", + streaming_listener=lambda vehicle, + callback: vehicle.listen_CruiseFollowDistance(callback), + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="cruise_set_speed", + streaming_listener=lambda vehicle, callback: vehicle.listen_CruiseSetSpeed( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="current_limit_mph", + streaming_listener=lambda vehicle, callback: vehicle.listen_CurrentLimitMph( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="dc_charging_energy_in", + streaming_listener=lambda vehicle, callback: vehicle.listen_DCChargingEnergyIn( + callback + ), + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="dc_charging_power", + streaming_listener=lambda vehicle, callback: vehicle.listen_DCChargingPower( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_axle_speed_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiAxleSpeedF( + callback + ), + native_unit_of_measurement="rad/s", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_axle_speed_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiAxleSpeedR( + callback + ), + native_unit_of_measurement="rad/s", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_axle_speed_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiAxleSpeedREL( + callback + ), + native_unit_of_measurement="rad/s", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_axle_speed_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiAxleSpeedRER( + callback + ), + native_unit_of_measurement="rad/s", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_heatsink_tf", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiHeatsinkTF( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_heatsink_tr", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiHeatsinkTR( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_heatsink_trel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiHeatsinkTREL( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_heatsink_trer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiHeatsinkTRER( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_inverter_tf", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiInverterTF( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_inverter_tr", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiInverterTR( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_inverter_trel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiInverterTREL( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_inverter_trer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiInverterTRER( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_motor_current_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiMotorCurrentF( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_motor_current_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiMotorCurrentR( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_motor_current_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiMotorCurrentREL( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_motor_current_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiMotorCurrentRER( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_slave_torque_cmd", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiSlaveTorqueCmd( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_state_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateF( + lambda value: None + if value is None + else callback(DRIVE_INVERTER_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(DRIVE_INVERTER_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_state_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateR( + lambda value: None + if value is None + else callback(DRIVE_INVERTER_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(DRIVE_INVERTER_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_state_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateREL( + lambda value: None + if value is None + else callback(DRIVE_INVERTER_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(DRIVE_INVERTER_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_state_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateRER( + lambda value: None + if value is None + else callback(DRIVE_INVERTER_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(DRIVE_INVERTER_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_stator_temp_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStatorTempF( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_stator_temp_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStatorTempR( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_stator_temp_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStatorTempREL( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_stator_temp_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiStatorTempRER( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_torque_actual_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiTorqueActualF( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_torque_actual_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiTorqueActualR( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_torque_actual_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiTorqueActualREL( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_torque_actual_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiTorqueActualRER( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_torquemotor", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiTorquemotor( + callback + ), + native_unit_of_measurement="Nm", # Newton-meters + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_vbat_f", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiVBatF(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_vbat_r", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiVBatR(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_vbat_rel", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiVBatREL(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="di_vbat_rer", + streaming_listener=lambda vehicle, callback: vehicle.listen_DiVBatRER(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="sentry_mode", + streaming_listener=lambda vehicle, callback: vehicle.listen_SentryMode( + lambda value: None + if value is None + else callback(SENTRY_MODE_STATES.get(value)) + ), + options=list(SENTRY_MODE_STATES.values()), + device_class=SensorDeviceClass.ENUM, + ), + TeslemetryVehicleSensorEntityDescription( + key="energy_remaining", + streaming_listener=lambda vehicle, callback: vehicle.listen_EnergyRemaining( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="estimated_hours_to_charge_termination", + streaming_listener=lambda vehicle, + callback: vehicle.listen_EstimatedHoursToChargeTermination(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="forward_collision_warning", + streaming_listener=lambda vehicle, + callback: vehicle.listen_ForwardCollisionWarning( + lambda value: None + if value is None + else callback(FORWARD_COLLISION_SENSITIVITIES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(FORWARD_COLLISION_SENSITIVITIES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="gps_heading", + streaming_listener=lambda vehicle, callback: vehicle.listen_GpsHeading( + callback + ), + native_unit_of_measurement=DEGREE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="guest_mode_mobile_access_state", + streaming_listener=lambda vehicle, + callback: vehicle.listen_GuestModeMobileAccessState( + lambda value: None + if value is None + else callback(GUEST_MODE_MOBILE_ACCESS_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(GUEST_MODE_MOBILE_ACCESS_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="homelink_device_count", + streaming_listener=lambda vehicle, callback: vehicle.listen_HomelinkDeviceCount( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="hvac_fan_speed", + streaming_listener=lambda vehicle, callback: vehicle.listen_HvacFanSpeed( + lambda x: callback(None) if x is None else callback(x * 10) + ), + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="hvac_fan_status", + streaming_listener=lambda vehicle, callback: vehicle.listen_HvacFanStatus( + lambda x: callback(None) if x is None else callback(x * 10) + ), + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="isolation_resistance", + streaming_listener=lambda vehicle, callback: vehicle.listen_IsolationResistance( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="Ω", + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="lane_departure_avoidance", + streaming_listener=lambda vehicle, + callback: vehicle.listen_LaneDepartureAvoidance( + lambda value: None + if value is None + else callback(LANE_ASSIST_LEVELS.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(LANE_ASSIST_LEVELS.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="lateral_acceleration", + streaming_listener=lambda vehicle, callback: vehicle.listen_LateralAcceleration( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="g", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="lifetime_energy_used", + streaming_listener=lambda vehicle, callback: vehicle.listen_LifetimeEnergyUsed( + callback + ), + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="longitudinal_acceleration", + streaming_listener=lambda vehicle, + callback: vehicle.listen_LongitudinalAcceleration(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="g", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="module_temp_max", + streaming_listener=lambda vehicle, callback: vehicle.listen_ModuleTempMax( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="module_temp_min", + streaming_listener=lambda vehicle, callback: vehicle.listen_ModuleTempMin( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="pack_current", + streaming_listener=lambda vehicle, callback: vehicle.listen_PackCurrent( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="pack_voltage", + streaming_listener=lambda vehicle, callback: vehicle.listen_PackVoltage( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="paired_phone_key_and_key_fob_qty", + streaming_listener=lambda vehicle, + callback: vehicle.listen_PairedPhoneKeyAndKeyFobQty(callback), + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="pedal_position", + streaming_listener=lambda vehicle, callback: vehicle.listen_PedalPosition( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="powershare_hours_left", + streaming_listener=lambda vehicle, callback: vehicle.listen_PowershareHoursLeft( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="powershare_instantaneous_power_kw", + streaming_listener=lambda vehicle, + callback: vehicle.listen_PowershareInstantaneousPowerKW(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="powershare_status", + streaming_listener=lambda vehicle, callback: vehicle.listen_PowershareStatus( + lambda value: None + if value is None + else callback(POWER_SHARE_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(POWER_SHARE_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="powershare_stop_reason", + streaming_listener=lambda vehicle, + callback: vehicle.listen_PowershareStopReason( + lambda value: None + if value is None + else callback(POWER_SHARE_STOP_REASONS.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(POWER_SHARE_STOP_REASONS.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="powershare_type", + streaming_listener=lambda vehicle, callback: vehicle.listen_PowershareType( + lambda value: None + if value is None + else callback(POWER_SHARE_TYPES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(POWER_SHARE_TYPES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="rated_range", + streaming_listener=lambda vehicle, callback: vehicle.listen_RatedRange( + callback + ), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="scheduled_charging_mode", + streaming_listener=lambda vehicle, + callback: vehicle.listen_ScheduledChargingMode( + lambda value: None + if value is None + else callback(SCHEDULED_CHARGING_MODES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(SCHEDULED_CHARGING_MODES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="software_update_expected_duration_minutes", + streaming_listener=lambda vehicle, + callback: vehicle.listen_SoftwareUpdateExpectedDurationMinutes(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="speed_limit_warning", + streaming_listener=lambda vehicle, callback: vehicle.listen_SpeedLimitWarning( + lambda value: None + if value is None + else callback(SPEED_ASSIST_LEVELS.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(SPEED_ASSIST_LEVELS.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="tonneau_tent_mode", + streaming_listener=lambda vehicle, callback: vehicle.listen_TonneauTentMode( + lambda value: None + if value is None + else callback(TONNEAU_TENT_MODE_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(TONNEAU_TENT_MODE_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="tpms_hard_warnings", + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsHardWarnings( + callback + ), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="tpms_soft_warnings", + streaming_listener=lambda vehicle, callback: vehicle.listen_TpmsSoftWarnings( + callback + ), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="lights_turn_signal", + streaming_listener=lambda vehicle, callback: vehicle.listen_LightsTurnSignal( + lambda value: None + if value is None + else callback(TURN_SIGNAL_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(TURN_SIGNAL_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="charge_rate_mile_per_hour", + streaming_listener=lambda vehicle, + callback: vehicle.listen_ChargeRateMilePerHour(callback), + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryVehicleSensorEntityDescription( + key="hvac_power_state", + streaming_listener=lambda vehicle, callback: vehicle.listen_HvacPower( + lambda value: None + if value is None + else callback(HVAC_POWER_STATES.get(value)) + ), + device_class=SensorDeviceClass.ENUM, + options=list(HVAC_POWER_STATES.values()), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ) @@ -349,21 +1365,31 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription): """Describes Teslemetry Sensor entity.""" variance: int - streaming_key: Signal + streaming_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[float | None], None]], + Callable[[], None], + ] streaming_firmware: str = "2024.26" + streaming_unit: str VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( TeslemetryTimeEntityDescription( key="charge_state_minutes_to_full_charge", - streaming_key=Signal.TIME_TO_FULL_CHARGE, + streaming_listener=lambda vehicle, callback: vehicle.listen_TimeToFullCharge( + callback + ), + streaming_unit="hours", 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, + streaming_listener=lambda vehicle, callback: vehicle.listen_MinutesToArrival( + callback + ), + streaming_unit="minutes", device_class=SensorDeviceClass.TIMESTAMP, variance=1, ), @@ -507,7 +1533,10 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, ), - SensorEntityDescription(key="version"), + SensorEntityDescription( + key="version", + entity_category=EntityCategory.DIAGNOSTIC, + ), ) ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple( @@ -538,7 +1567,7 @@ async def async_setup_entry( for description in VEHICLE_DESCRIPTIONS: if ( not vehicle.api.pre2021 - and description.streaming_key + and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): entities.append(TeslemetryStreamSensorEntity(vehicle, description)) @@ -589,6 +1618,12 @@ async def async_setup_entry( if energysite.history_coordinator is not None ) + entities.append( + TeslemetryCreditBalanceSensor( + entry.unique_id or entry.entry_id, entry.runtime_data + ) + ) + async_add_entities(entities) @@ -604,8 +1639,7 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor) ) -> None: """Initialize the sensor.""" self.entity_description = description - assert description.streaming_key - super().__init__(data, description.key, description.streaming_key) + super().__init__(data, description.key) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -614,20 +1648,20 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor) 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 + if self.entity_description.streaming_listener is not None: + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._async_value_from_stream + ) + ) - def _async_value_from_stream(self, value) -> None: + def _async_value_from_stream(self, value: StateType) -> 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) + self._attr_native_value = value + self.async_write_ha_state() -class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): +class TeslemetryVehicleSensorEntity(TeslemetryVehiclePollingEntity, SensorEntity): """Base class for Teslemetry vehicle metric sensors.""" entity_description: TeslemetryVehicleSensorEntityDescription @@ -643,7 +1677,7 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - if self.entity_description.polling_available_fn(self._value): + if self.entity_description.nullable or self._value is not None: self._attr_available = True self._attr_native_value = self.entity_description.polling_value_fn( self._value @@ -666,26 +1700,31 @@ class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEnti """Initialize the sensor.""" self.entity_description = description self._get_timestamp = ignore_variance( - func=lambda value: dt_util.now() + timedelta(minutes=value), + func=lambda value: dt_util.now() + + timedelta(**{self.entity_description.streaming_unit: value}), ignored_variance=timedelta(minutes=description.variance), ) - assert description.streaming_key - super().__init__(data, description.key, description.streaming_key) + super().__init__(data, description.key) - @cached_property - def available(self) -> bool: - """Return True if entity is available.""" - return self.stream.connected + 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.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._value_callback + ) + ) - def _async_value_from_stream(self, value) -> None: + def _value_callback(self, value: float | None) -> 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) + self.async_write_ha_state() -class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): +class TeslemetryVehicleTimeSensorEntity(TeslemetryVehiclePollingEntity, SensorEntity): """Base class for Teslemetry vehicle time sensors.""" entity_description: TeslemetryTimeEntityDescription @@ -752,8 +1791,7 @@ class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorE def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - if self.exists: - self._attr_native_value = self.entity_description.value_fn(self._value) + self._attr_native_value = self.entity_description.value_fn(self._value) class TeslemetryEnergyInfoSensorEntity(TeslemetryEnergyInfoEntity, SensorEntity): @@ -793,3 +1831,33 @@ class TeslemetryEnergyHistorySensorEntity(TeslemetryEnergyHistoryEntity, SensorE def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" self._attr_native_value = self._value + + +class TeslemetryCreditBalanceSensor(RestoreSensor): + """Entity for Teslemetry Credit balance.""" + + _attr_has_entity_name = True + stream: TeslemetryStream + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_suggested_display_precision = 0 + + def __init__(self, uid: str, data: TeslemetryData) -> None: + """Initialize common aspects of a Teslemetry entity.""" + + self._attr_translation_key = "credit_balance" + self._attr_unique_id = f"{uid}_credit_balance" + self.stream = data.stream + + 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 + + self.async_on_remove(self.stream.listen_Balance(self._async_update)) + + def _async_update(self, value: int) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 8215adb5711..2f21073d227 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import DOMAIN -from .helpers import handle_command, handle_vehicle_command, wake_up_vehicle +from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData _LOGGER = logging.getLogger(__name__) @@ -107,7 +107,6 @@ def async_register_services(hass: HomeAssistant) -> None: config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.navigation_gps_request( lat=call.data[ATTR_GPS][CONF_LATITUDE], @@ -148,7 +147,6 @@ def async_register_services(hass: HomeAssistant) -> None: translation_domain=DOMAIN, translation_key="set_scheduled_charging_time" ) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.set_scheduled_charging(enable=call.data["enable"], time=time) ) @@ -205,7 +203,6 @@ def async_register_services(hass: HomeAssistant) -> None: translation_key="set_scheduled_departure_off_peak", ) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.set_scheduled_departure( enable, @@ -242,7 +239,6 @@ def async_register_services(hass: HomeAssistant) -> None: config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) - await wake_up_vehicle(vehicle) await handle_vehicle_command( vehicle.api.set_valet_mode( call.data.get("enable"), call.data.get("pin", "") @@ -268,7 +264,6 @@ def async_register_services(hass: HomeAssistant) -> None: config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) - await wake_up_vehicle(vehicle) enable = call.data.get("enable") if enable is True: await handle_vehicle_command( diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 9dc17fd2ef7..57b6053bb48 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1,7 +1,13 @@ { + "common": { + "unavailable": "Unavailable", + "abort": "Abort", + "vehicle": "Vehicle", + "descr_pin": "4-digit code to enable or disable the setting" + }, "config": { "abort": { - "already_configured": "Account is already configured", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_account_mismatch": "The reauthentication account does not match the original account" }, @@ -65,6 +71,12 @@ "state": { "name": "Status" }, + "cellular": { + "name": "Cellular" + }, + "wifi": { + "name": "Wi-Fi" + }, "storm_mode_active": { "name": "Storm watch active" }, @@ -132,7 +144,7 @@ "name": "Tire pressure warning rear right" }, "pin_to_drive_enabled": { - "name": "Pin to drive enabled" + "name": "PIN to Drive enabled" }, "drive_rail": { "name": "Drive rail" @@ -190,6 +202,36 @@ }, "located_at_favorite": { "name": "Located at favorite" + }, + "charge_enable_request": { + "name": "Charge enable request" + }, + "defrost_for_preconditioning": { + "name": "Defrost for preconditioning" + }, + "lights_hazards_active": { + "name": "Hazard lights" + }, + "lights_high_beams": { + "name": "High beams" + }, + "seat_vent_enabled": { + "name": "Seat vent enabled" + }, + "speed_limit_mode": { + "name": "Speed limited" + }, + "remote_start_enabled": { + "name": "Remote start" + }, + "hvil": { + "name": "High voltage interlock loop fault" + }, + "hvac_auto_mode": { + "name": "HVAC auto mode" + }, + "grid_status": { + "name": "Grid status" } }, "button": { @@ -221,11 +263,17 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "keep": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" } + }, + "fan_mode": { + "state": { + "off": "[%key:common::state::off%]", + "bioweapon": "Bioweapon defense" + } } } } @@ -256,72 +304,72 @@ "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", - "off": "Off" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater front right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { "name": "Steering wheel heater", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "off": "[%key:common::state::off%]" } }, "components_customer_preferred_export_rule": { @@ -337,7 +385,7 @@ "state": { "autonomous": "Autonomous", "backup": "Backup", - "self_consumption": "Self consumption" + "self_consumption": "Self-consumption" } } }, @@ -357,7 +405,7 @@ "name": "Charge limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "cover": { @@ -416,8 +464,8 @@ "state": { "starting": "Starting", "charging": "[%key:common::state::charging%]", - "disconnected": "Disconnected", - "stopped": "Stopped", + "disconnected": "[%key:common::state::disconnected%]", + "stopped": "[%key:common::state::stopped%]", "complete": "Complete", "no_power": "No power" } @@ -443,6 +491,10 @@ "climate_state_passenger_temp_setting": { "name": "Passenger temperature setting" }, + "credit_balance": { + "name": "Teslemetry credits", + "unit_of_measurement": "credits" + }, "drive_state_active_route_destination": { "name": "Destination" }, @@ -489,10 +541,10 @@ "name": "Island status", "state": { "island_status_unknown": "Unknown", - "on_grid": "On grid", - "off_grid": "Off grid", - "off_grid_intentional": "Off grid intentional", - "off_grid_unintentional": "Off grid unintentional" + "on_grid": "On-grid", + "off_grid": "Off-grid", + "off_grid_intentional": "Off-grid intentional", + "off_grid_unintentional": "Off-grid unintentional" } }, "load_power": { @@ -523,12 +575,12 @@ "name": "Tire pressure rear right" }, "version": { - "name": "version" + "name": "Version" }, "vin": { - "name": "Vehicle", + "name": "[%key:component::teslemetry::common::vehicle%]", "state": { - "disconnected": "Disconnected" + "disconnected": "[%key:common::state::disconnected%]" } }, "vpp_backup_reserve_percent": { @@ -605,6 +657,384 @@ }, "total_grid_energy_exported": { "name": "Grid exported" + }, + + "sentry_mode": { + "name": "Sentry mode", + "state": { + "off": "[%key:common::state::off%]", + "idle": "[%key:common::state::idle%]", + "armed": "Armed", + "aware": "Aware", + "panic": "Panic", + "quiet": "Quiet" + } + }, + "bms_state": { + "name": "BMS state", + "state": { + "standby": "[%key:common::state::standby%]", + "drive": "Drive", + "support": "Support", + "charge": "Charge", + "full_electric_in_motion": "Full electric in motion", + "clear_fault": "Clear fault", + "fault": "[%key:common::state::fault%]", + "weld": "Weld", + "test": "Test", + "system_not_available": "System not available" + } + }, + "brake_pedal_position": { + "name": "Brake pedal position" + }, + "brick_voltage_max": { + "name": "Brick voltage max" + }, + "brick_voltage_min": { + "name": "Brick voltage min" + }, + "cruise_follow_distance": { + "name": "Cruise follow distance" + }, + "cruise_set_speed": { + "name": "Cruise set speed" + }, + "current_limit_mph": { + "name": "Current speed limit" + }, + "dc_charging_energy_in": { + "name": "DC charging energy in" + }, + "dc_charging_power": { + "name": "DC charging power" + }, + "di_axle_speed_f": { + "name": "Front drive inverter axle speed" + }, + "di_axle_speed_r": { + "name": "Rear drive inverter axle speed" + }, + "di_axle_speed_rel": { + "name": "Rear left drive inverter axle speed" + }, + "di_axle_speed_rer": { + "name": "Rear right drive inverter axle speed" + }, + "di_heatsink_tf": { + "name": "Front drive inverter heatsink temperature" + }, + "di_heatsink_tr": { + "name": "Rear drive inverter heatsink temperature" + }, + "di_heatsink_trel": { + "name": "Rear left drive inverter heatsink temperature" + }, + "di_heatsink_trer": { + "name": "Rear right drive inverter heatsink temperature" + }, + "di_inverter_tf": { + "name": "Front drive inverter temperature" + }, + "di_inverter_tr": { + "name": "Rear drive inverter temperature" + }, + "di_inverter_trel": { + "name": "Rear left drive inverter temperature" + }, + "di_inverter_trer": { + "name": "Rear right drive inverter temperature" + }, + "di_motor_current_f": { + "name": "Front drive inverter motor current" + }, + "di_motor_current_r": { + "name": "Rear drive inverter motor current" + }, + "di_motor_current_rel": { + "name": "Rear left drive inverter motor current" + }, + "di_motor_current_rer": { + "name": "Rear right drive inverter motor current" + }, + "di_slave_torque_cmd": { + "name": "Secondary drive unit torque" + }, + "di_state_f": { + "name": "Front drive inverter", + "state": { + "unavailable": "[%key:component::teslemetry::common::unavailable%]", + "standby": "[%key:common::state::standby%]", + "fault": "[%key:common::state::fault%]", + "abort": "[%key:component::teslemetry::common::abort%]", + "enabled": "[%key:common::state::enabled%]" + } + }, + "di_state_r": { + "name": "Rear drive inverter", + "state": { + "unavailable": "[%key:component::teslemetry::common::unavailable%]", + "standby": "[%key:common::state::standby%]", + "fault": "[%key:common::state::fault%]", + "abort": "[%key:component::teslemetry::common::abort%]", + "enabled": "[%key:common::state::enabled%]" + } + }, + "di_state_rel": { + "name": "Rear left drive inverter", + "state": { + "unavailable": "[%key:component::teslemetry::common::unavailable%]", + "standby": "[%key:common::state::standby%]", + "fault": "[%key:common::state::fault%]", + "abort": "[%key:component::teslemetry::common::abort%]", + "enabled": "[%key:common::state::enabled%]" + } + }, + "di_state_rer": { + "name": "Rear right drive inverter", + "state": { + "unavailable": "[%key:component::teslemetry::common::unavailable%]", + "standby": "[%key:common::state::standby%]", + "fault": "[%key:common::state::fault%]", + "abort": "[%key:component::teslemetry::common::abort%]", + "enabled": "[%key:common::state::enabled%]" + } + }, + "di_stator_temp_f": { + "name": "Front drive unit stator temperature" + }, + "di_stator_temp_r": { + "name": "Rear drive unit stator temperature" + }, + "di_stator_temp_rel": { + "name": "Rear left drive unit stator temperature" + }, + "di_stator_temp_rer": { + "name": "Rear right drive unit stator temperature" + }, + "di_torque_actual_f": { + "name": "Front drive unit actual torque" + }, + "di_torque_actual_r": { + "name": "Rear drive unit actual torque" + }, + "di_torque_actual_rel": { + "name": "Rear left drive unit actual torque" + }, + "di_torque_actual_rer": { + "name": "Rear right drive unit actual torque" + }, + "di_torquemotor": { + "name": "Drive unit torque" + }, + "di_vbat_f": { + "name": "Front drive inverter battery voltage" + }, + "di_vbat_r": { + "name": "Rear drive inverter battery voltage" + }, + "di_vbat_rel": { + "name": "Rear left drive inverter battery voltage" + }, + "di_vbat_rer": { + "name": "Rear right drive inverter battery voltage" + }, + "energy_remaining": { + "name": "Energy remaining" + }, + "estimated_hours_to_charge_termination": { + "name": "Estimated hours to charge termination" + }, + "forward_collision_warning": { + "name": "Forward collision warning", + "state": { + "off": "[%key:common::state::off%]", + "late": "Late", + "average": "Average", + "early": "Early" + } + }, + "gps_heading": { + "name": "GPS heading" + }, + "guest_mode_mobile_access_state": { + "name": "Guest mode mobile access", + "state": { + "init": "Init", + "not_authenticated": "Not authenticated", + "authenticated": "Authenticated", + "aborted_driving": "Aborted driving", + "aborted_using_remote_start": "Aborted using remote start", + "aborted_using_ble_keys": "Aborted using BLE keys", + "aborted_valet_mode": "Aborted valet mode", + "aborted_guest_mode_off": "Aborted guest mode off", + "aborted_drive_auth_time_exceeded": "Aborted drive auth time exceeded", + "aborted_no_data_received": "Aborted no data received", + "requesting_from_mothership": "Requesting from mothership", + "requesting_from_auth_d": "Requesting from Authd", + "aborted_fetch_failed": "Aborted fetch failed", + "aborted_bad_data_received": "Aborted bad data received", + "showing_qr_code": "Showing QR code", + "swiped_away": "Swiped away", + "dismissed_qr_code_expired": "Dismissed QR code expired", + "succeeded_paired_new_ble_key": "Succeeded paired new BLE key" + } + }, + "homelink_device_count": { + "name": "Homelink devices", + "unit_of_measurement": "devices" + }, + "hvac_fan_speed": { + "name": "HVAC fan speed setting" + }, + "hvac_fan_status": { + "name": "HVAC fan speed" + }, + "isolation_resistance": { + "name": "Isolation resistance" + }, + "lane_departure_avoidance": { + "name": "Lane departure avoidance", + "state": { + "off": "[%key:common::state::off%]", + "warning": "Warning", + "assist": "Assist" + } + }, + "lateral_acceleration": { + "name": "Lateral acceleration" + }, + "lifetime_energy_used": { + "name": "Lifetime energy used" + }, + "lifetime_energy_used_drive": { + "name": "Lifetime energy used drive" + }, + "longitudinal_acceleration": { + "name": "Longitudinal acceleration" + }, + "module_temp_max": { + "name": "Module temperature maximum" + }, + "module_temp_min": { + "name": "Module temperature minimum" + }, + "pack_current": { + "name": "Pack current" + }, + "pack_voltage": { + "name": "Pack voltage" + }, + "paired_phone_key_and_key_fob_qty": { + "name": "Paired phone key and key fob quantity" + }, + "pedal_position": { + "name": "Pedal position" + }, + "powershare_hours_left": { + "name": "Powershare hours left" + }, + "powershare_instantaneous_power_kw": { + "name": "Powershare instantaneous power" + }, + "powershare_status": { + "name": "Powershare status", + "state": { + "inactive": "Inactive", + "handshaking": "Handshaking", + "init": "Initializing", + "enabled": "[%key:common::state::enabled%]", + "reconnecting": "Reconnecting", + "stopped": "[%key:common::state::stopped%]" + } + }, + "powershare_stop_reason": { + "name": "Powershare stop reason", + "state": { + "soc_too_low": "SOC too low", + "retry": "Retry", + "fault": "[%key:common::state::fault%]", + "user": "User", + "reconnecting": "Reconnecting", + "authentication": "Authentication" + } + }, + "powershare_type": { + "name": "Powershare type", + "state": { + "none": "None", + "load": "Load", + "home": "Home" + } + }, + "rated_range": { + "name": "Rated range" + }, + "route_last_updated": { + "name": "Route last updated" + }, + "scheduled_charging_mode": { + "name": "Scheduled charging mode", + "state": { + "off": "[%key:common::state::off%]", + "departure": "Departure", + "start_at": "Start at" + } + }, + "software_update_expected_duration_minutes": { + "name": "Software update expected duration" + }, + "speed_limit_warning": { + "name": "Speed limit warning", + "state": { + "none": "None", + "display": "Display", + "chime": "Chime" + } + }, + "tonneau_tent_mode": { + "name": "Tonneau tent mode", + "state": { + "inactive": "Inactive", + "moving": "Moving", + "failed": "Failed", + "active": "Active" + } + }, + "tpms_hard_warnings": { + "name": "Tire pressure hard warnings", + "unit_of_measurement": "warnings" + }, + "tpms_soft_warnings": { + "name": "Tire pressure soft warnings", + "unit_of_measurement": "warnings" + }, + "lights_turn_signal": { + "name": "Turn signal", + "state": { + "off": "[%key:common::state::off%]", + "left": "Left", + "right": "Right", + "both": "Both" + } + }, + "charge_rate_mile_per_hour": { + "name": "Charge rate" + }, + "hvac_left_temperature_request": { + "name": "Left temperature request" + }, + "hvac_right_temperature_request": { + "name": "Right temperature request" + }, + "hvac_power_state": { + "name": "HVAC power state", + "state": { + "precondition": "Precondition", + "overheat_protection": "Overheat protection", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } }, "switch": { @@ -656,7 +1086,7 @@ "message": "Departure time required to enable preconditioning" }, "set_scheduled_departure_off_peak": { - "message": "To enable scheduled departure, end off peak time is required." + "message": "To enable scheduled departure, 'End off-peak time' is required." }, "invalid_device": { "message": "Invalid device ID: {device_id}" @@ -698,7 +1128,7 @@ "fields": { "device_id": { "description": "Vehicle to share to.", - "name": "Vehicle" + "name": "[%key:component::teslemetry::common::vehicle%]" }, "gps": { "description": "Location to navigate to.", @@ -716,11 +1146,11 @@ "fields": { "device_id": { "description": "Vehicle to schedule.", - "name": "Vehicle" + "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { "description": "Enable or disable scheduled charging.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "time": { "description": "Time to start charging.", @@ -738,23 +1168,23 @@ }, "device_id": { "description": "Vehicle to schedule.", - "name": "Vehicle" + "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { "description": "Enable or disable scheduled departure.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "end_off_peak_time": { "description": "Time to complete charging by.", - "name": "End off peak time" + "name": "End off-peak time" }, "off_peak_charging_enabled": { - "description": "Enable off peak charging.", - "name": "Off peak charging enabled" + "description": "Enable off-peak charging.", + "name": "Off-peak charging enabled" }, "off_peak_charging_weekdays_only": { - "description": "Enable off peak charging on weekdays only.", - "name": "Off peak charging weekdays only" + "description": "Enable off-peak charging on weekdays only.", + "name": "Off-peak charging weekdays only" }, "preconditioning_enabled": { "description": "Enable preconditioning.", @@ -772,15 +1202,15 @@ "fields": { "device_id": { "description": "Vehicle to limit.", - "name": "Vehicle" + "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { "description": "Enable or disable speed limit.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "pin": { - "description": "4 digit PIN.", - "name": "PIN" + "description": "[%key:component::teslemetry::common::descr_pin%]", + "name": "[%key:common::config_flow::data::pin%]" } }, "name": "Set speed limit" @@ -804,15 +1234,15 @@ "fields": { "device_id": { "description": "Vehicle to limit.", - "name": "Vehicle" + "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { "description": "Enable or disable valet mode.", - "name": "Enable" + "name": "[%key:common::action::enable%]" }, "pin": { - "description": "4 digit PIN.", - "name": "PIN" + "description": "[%key:component::teslemetry::common::descr_pin%]", + "name": "[%key:common::config_flow::data::pin%]" } }, "name": "Set valet mode" diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 83441e6c4f6..f607429be46 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -2,12 +2,14 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api.const import Scope, Seat +from tesla_fleet_api.const import AutoSeat, Scope +from tesla_fleet_api.teslemetry import Vehicle +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.switch import ( SwitchDeviceClass, @@ -16,10 +18,16 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry -from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryRootEntity, + TeslemetryVehiclePollingEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -30,40 +38,67 @@ PARALLEL_UPDATES = 0 class TeslemetrySwitchEntityDescription(SwitchEntityDescription): """Describes Teslemetry Switch entity.""" - on_func: Callable - off_func: Callable + on_func: Callable[[Vehicle], Awaitable[dict[str, Any]]] + off_func: Callable[[Vehicle], Awaitable[dict[str, Any]]] scopes: list[Scope] value_func: Callable[[StateType], bool] = bool + streaming_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[bool | None], None]], + Callable[[], None], + ] + streaming_firmware: str = "2024.26" unique_id: str | None = None VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="vehicle_state_sentry_mode", + streaming_listener=lambda vehicle, callback: vehicle.listen_SentryMode( + lambda value: callback(None if value is None else value != "Off") + ), on_func=lambda api: api.set_sentry_mode(on=True), off_func=lambda api: api.set_sentry_mode(on=False), scopes=[Scope.VEHICLE_CMDS], ), + TeslemetrySwitchEntityDescription( + key="vehicle_state_valet_mode", + streaming_listener=lambda vehicle, value: vehicle.listen_ValetModeEnabled( + value + ), + streaming_firmware="2024.44.25", + on_func=lambda api: api.set_valet_mode(on=True, password=""), + off_func=lambda api: api.set_valet_mode(on=False, password=""), + scopes=[Scope.VEHICLE_CMDS], + ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_left", - on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True), + streaming_listener=lambda vehicle, callback: vehicle.listen_AutoSeatClimateLeft( + callback + ), + on_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_LEFT, True + ), off_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_LEFT, False + AutoSeat.FRONT_LEFT, False ), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_right", + streaming_listener=lambda vehicle, + callback: vehicle.listen_AutoSeatClimateRight(callback), on_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, True + AutoSeat.FRONT_RIGHT, True ), off_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, False + AutoSeat.FRONT_RIGHT, False ), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( key="climate_state_auto_steering_wheel_heat", + streaming_listener=lambda vehicle, + callback: vehicle.listen_HvacSteeringWheelHeatAuto(callback), on_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request( on=True ), @@ -74,6 +109,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), TeslemetrySwitchEntityDescription( key="climate_state_defrost_mode", + streaming_listener=lambda vehicle, callback: vehicle.listen_DefrostMode( + lambda value: callback(None if value is None else value != "Off") + ), on_func=lambda api: api.set_preconditioning_max(on=True, manual_override=False), off_func=lambda api: api.set_preconditioning_max( on=False, manual_override=False @@ -83,9 +121,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="charge_state_charging_state", unique_id="charge_state_user_charge_enable_request", + value_func=lambda state: state in {"Starting", "Charging"}, + streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( + lambda value: callback( + None if value is None else value in {"Starting", "Charging"} + ) + ), on_func=lambda api: api.charge_start(), off_func=lambda api: api.charge_stop(), - value_func=lambda state: state in {"Starting", "Charging"}, scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], ), ) @@ -101,12 +144,16 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryVehicleSwitchEntity( + TeslemetryVehiclePollingVehicleSwitchEntity( + vehicle, description, entry.runtime_data.scopes + ) + if vehicle.api.pre2021 + or vehicle.firmware < description.streaming_firmware + else TeslemetryStreamingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS - if description.key in vehicle.coordinator.data ), ( TeslemetryChargeFromGridSwitchEntity( @@ -126,15 +173,32 @@ async def async_setup_entry( ) -class TeslemetrySwitchEntity(SwitchEntity): +class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity): """Base class for all Teslemetry switch entities.""" + api: Vehicle _attr_device_class = SwitchDeviceClass.SWITCH entity_description: TeslemetrySwitchEntityDescription + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + self.raise_for_scope(self.entity_description.scopes[0]) + await handle_vehicle_command(self.entity_description.on_func(self.api)) + self._attr_is_on = True + self.async_write_ha_state() -class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEntity): - """Base class for Teslemetry vehicle switch entities.""" + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + self.raise_for_scope(self.entity_description.scopes[0]) + await handle_vehicle_command(self.entity_description.off_func(self.api)) + self._attr_is_on = False + self.async_write_ha_state() + + +class TeslemetryVehiclePollingVehicleSwitchEntity( + TeslemetryVehiclePollingEntity, TeslemetryVehicleSwitchEntity +): + """Base class for Teslemetry polling vehicle switch entities.""" def __init__( self, @@ -151,30 +215,61 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_is_on = self.entity_description.value_func(self._value) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the Switch.""" - self.raise_for_scope(self.entity_description.scopes[0]) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.entity_description.on_func(self.api)) - self._attr_is_on = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the Switch.""" - self.raise_for_scope(self.entity_description.scopes[0]) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.entity_description.off_func(self.api)) - self._attr_is_on = False - self.async_write_ha_state() + self._attr_is_on = ( + None + if self._value is None + else self.entity_description.value_func(self._value) + ) -class TeslemetryChargeFromGridSwitchEntity( - TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity +class TeslemetryStreamingVehicleSwitchEntity( + TeslemetryVehicleStreamEntity, TeslemetryVehicleSwitchEntity, RestoreEntity ): + """Base class for Teslemetry streaming vehicle switch entities.""" + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetrySwitchEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the Switch.""" + + self.entity_description = description + self.scoped = any(scope in scopes for scope in description.scopes) + super().__init__(data, description.key) + if description.unique_id: + self._attr_unique_id = f"{data.vin}-{description.unique_id}" + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore previous state + if (state := await self.async_get_last_state()) is not None: + if state.state == "on": + self._attr_is_on = True + elif state.state == "off": + self._attr_is_on = False + + # Add listener + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._value_callback + ) + ) + + def _value_callback(self, value: bool | None) -> None: + """Update the value of the entity.""" + self._attr_is_on = value + self.async_write_ha_state() + + +class TeslemetryChargeFromGridSwitchEntity(TeslemetryEnergyInfoEntity, SwitchEntity): """Entity class for Charge From Grid switch.""" + _attr_device_class = SwitchDeviceClass.SWITCH + def __init__( self, data: TeslemetryEnergyData, @@ -215,11 +310,11 @@ class TeslemetryChargeFromGridSwitchEntity( self.async_write_ha_state() -class TeslemetryStormModeSwitchEntity( - TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity -): +class TeslemetryStormModeSwitchEntity(TeslemetryEnergyInfoEntity, SwitchEntity): """Entity class for Storm Mode switch.""" + _attr_device_class = SwitchDeviceClass.SWITCH + def __init__( self, data: TeslemetryEnergyData, diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index f560f25a8ff..144a97039fc 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -2,16 +2,22 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehiclePollingEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -32,12 +38,33 @@ async def async_setup_entry( """Set up the Teslemetry update platform from a config entry.""" async_add_entities( - TeslemetryUpdateEntity(vehicle, entry.runtime_data.scopes) + TeslemetryVehiclePollingUpdateEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) -class TeslemetryUpdateEntity(TeslemetryVehicleEntity, UpdateEntity): +class TeslemetryUpdateEntity(TeslemetryRootEntity, UpdateEntity): + """Teslemetry Updates entity.""" + + api: Vehicle + _attr_supported_features = UpdateEntityFeature.PROGRESS + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.schedule_software_update(offset_sec=0)) + self._attr_in_progress = True + self.async_write_ha_state() + + +class TeslemetryVehiclePollingUpdateEntity( + TeslemetryVehiclePollingEntity, TeslemetryUpdateEntity +): """Teslemetry Updates entity.""" def __init__( @@ -94,18 +121,125 @@ class TeslemetryUpdateEntity(TeslemetryVehicleEntity, UpdateEntity): ): self._attr_in_progress = True if install_perc := self.get("vehicle_state_software_update_install_perc"): - self._attr_update_percentage = cast(int, install_perc) + self._attr_update_percentage = install_perc else: self._attr_in_progress = False self._attr_update_percentage = None - async def async_install( - self, version: str | None, backup: bool, **kwargs: Any + +class TeslemetryStreamingUpdateEntity( + TeslemetryVehicleStreamEntity, TeslemetryUpdateEntity, RestoreEntity +): + """Teslemetry Updates entity.""" + + _download_percentage: int = 0 + _install_percentage: int = 0 + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], ) -> None: - """Install an update.""" - self.raise_for_scope(Scope.ENERGY_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.schedule_software_update(offset_sec=60)) - self._attr_in_progress = True - self._attr_update_percentage = None + """Initialize the Update.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + super().__init__( + data, + "vehicle_state_software_update_status", + ) + + 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_in_progress = state.attributes.get("in_progress", False) + self._install_percentage = state.attributes.get("install_percentage", False) + self._attr_installed_version = state.attributes.get("installed_version") + self._attr_latest_version = state.attributes.get("latest_version") + self._attr_supported_features = UpdateEntityFeature( + state.attributes.get( + "supported_features", self._attr_supported_features + ) + ) + self.async_write_ha_state() + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateDownloadPercentComplete( + self._async_handle_software_update_download_percent_complete + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateInstallationPercentComplete( + self._async_handle_software_update_installation_percent_complete + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateScheduledStartTime( + self._async_handle_software_update_scheduled_start_time + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateVersion( + self._async_handle_software_update_version + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_Version(self._async_handle_version) + ) + + def _async_handle_software_update_download_percent_complete( + self, value: float | None + ): + """Handle software update download percent complete.""" + + self._download_percentage = round(value) if value is not None else 0 + if self.scoped and self._download_percentage == 100: + self._attr_supported_features = ( + UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL + ) + else: + self._attr_supported_features = UpdateEntityFeature.PROGRESS + self._async_update_progress() self.async_write_ha_state() + + def _async_handle_software_update_installation_percent_complete( + self, value: float | None + ): + """Handle software update installation percent complete.""" + + self._install_percentage = round(value) if value is not None else 0 + self._async_update_progress() + self.async_write_ha_state() + + def _async_handle_software_update_scheduled_start_time(self, value: str | None): + """Handle software update scheduled start time.""" + + self._attr_in_progress = value is not None + self.async_write_ha_state() + + def _async_handle_software_update_version(self, value: str | None): + """Handle software update version.""" + + self._attr_latest_version = ( + value if value and value != " " else self._attr_installed_version + ) + self.async_write_ha_state() + + def _async_handle_version(self, value: str | None): + """Handle version.""" + + if value is not None: + self._attr_installed_version = value.split(" ")[0] + self.async_write_ha_state() + + def _async_update_progress(self) -> None: + """Update the progress of the update.""" + + if self._download_percentage > 1 and self._download_percentage < 100: + self._attr_in_progress = True + self._attr_update_percentage = self._download_percentage + elif self._install_percentage > 1: + self._attr_in_progress = True + self._attr_update_percentage = self._install_percentage + else: + self._attr_in_progress = False + self._attr_update_percentage = None diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index f73ecc7a729..7fd2729ef03 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -5,9 +5,14 @@ from http import HTTPStatus import logging from aiohttp import ClientError, ClientResponseError -from tesla_fleet_api import EnergySpecific, Tessie from tesla_fleet_api.const import Scope -from tesla_fleet_api.exceptions import TeslaFleetError +from tesla_fleet_api.exceptions import ( + Forbidden, + InvalidToken, + SubscriptionRequired, + TeslaFleetError, +) +from tesla_fleet_api.tessie import Tessie from tessie_api import get_state_of_all_vehicles from homeassistant.config_entries import ConfigEntry @@ -123,13 +128,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo ) continue - api = EnergySpecific(tessie.energy, site_id) + api = tessie.energySites.create(site_id) + + 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( TessieEnergyData( api=api, id=site_id, - live_coordinator=TessieEnergySiteLiveCoordinator( - hass, entry, api + live_coordinator=( + TessieEnergySiteLiveCoordinator( + hass, entry, api, live_status + ) + if isinstance(live_status, dict) + else None ), info_coordinator=TessieEnergySiteInfoCoordinator( hass, entry, api @@ -147,6 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo *( energysite.live_coordinator.async_config_entry_first_refresh() for energysite in energysites + if energysite.live_coordinator is not None ), *( energysite.info_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index 515339c3da8..cdf3b0035fc 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -191,6 +191,7 @@ async def async_setup_entry( TessieEnergyLiveBinarySensorEntity(energy, description) for energy in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS + if energy.live_coordinator is not None ), ( TessieEnergyInfoBinarySensorEntity(vehicle, description) @@ -233,6 +234,7 @@ class TessieEnergyLiveBinarySensorEntity(TessieEnergyEntity, BinarySensorEntity) ) -> None: """Initialize the binary sensor.""" self.entity_description = description + assert data.live_coordinator is not None super().__init__(data, data.live_coordinator, description.key) def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index b06fe6123a5..8b6fb639a64 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -8,8 +8,8 @@ import logging from typing import TYPE_CHECKING, Any from aiohttp import ClientResponseError -from tesla_fleet_api import EnergySpecific from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError +from tesla_fleet_api.tessie import EnergySite from tessie_api import get_state, get_status from homeassistant.core import HomeAssistant @@ -102,7 +102,11 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry: TessieConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific + self, + hass: HomeAssistant, + config_entry: TessieConfigEntry, + api: EnergySite, + data: dict[str, Any], ) -> None: """Initialize Tessie Energy Site Live coordinator.""" super().__init__( @@ -114,6 +118,12 @@ class TessieEnergySiteLiveCoordinator(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 Tessie API.""" @@ -138,7 +148,7 @@ class TessieEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry: TessieConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific + self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySite ) -> None: """Initialize Tessie Energy Info coordinator.""" super().__init__( diff --git a/homeassistant/components/tessie/diagnostics.py b/homeassistant/components/tessie/diagnostics.py index bd2db772b57..21fc208612d 100644 --- a/homeassistant/components/tessie/diagnostics.py +++ b/homeassistant/components/tessie/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/tessie/entity.py b/homeassistant/components/tessie/entity.py index a2b6d3c9761..fb49d02f42e 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -155,7 +155,7 @@ class TessieWallConnectorEntity(TessieBaseEntity): via_device=(DOMAIN, str(data.id)), serial_number=din.split("-")[-1], ) - + assert data.live_coordinator super().__init__(data.live_coordinator, key) @property diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index d777cf5051e..3f71bcb95e3 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.9.10"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.0.17"] } diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py index ca670b9650b..5330d2d0bf0 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass -from tesla_fleet_api import EnergySpecific +from tesla_fleet_api.tessie import EnergySite from homeassistant.helpers.device_registry import DeviceInfo @@ -27,8 +27,8 @@ class TessieData: class TessieEnergyData: """Data for a Energy Site in the Tessie integration.""" - api: EnergySpecific - live_coordinator: TessieEnergySiteLiveCoordinator + api: EnergySite + live_coordinator: TessieEnergySiteLiveCoordinator | None info_coordinator: TessieEnergySiteInfoCoordinator id: int device: DeviceInfo diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py index 1e857345278..77d8037fb14 100644 --- a/homeassistant/components/tessie/number.py +++ b/homeassistant/components/tessie/number.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import EnergySpecific +from tesla_fleet_api.tessie import EnergySite from tessie_api import set_charge_limit, set_charging_amps, set_speed_limit from homeassistant.components.number import ( @@ -90,7 +90,7 @@ VEHICLE_DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( class TessieNumberBatteryEntityDescription(NumberEntityDescription): """Describes Tessie Number entity.""" - func: Callable[[EnergySpecific, float], Awaitable[Any]] + func: Callable[[EnergySite, float], Awaitable[Any]] requires: str diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 4f62e1b1855..52accb15575 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -148,7 +148,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( key="drive_state_shift_state", options=["p", "d", "r", "n"], device_class=SensorDeviceClass.ENUM, - value_fn=lambda x: x.lower() if isinstance(x, str) else x, + value_fn=lambda x: x.lower() if isinstance(x, str) else "p", ), TessieSensorEntityDescription( key="vehicle_state_odometer", @@ -396,11 +396,16 @@ async def async_setup_entry( TessieEnergyLiveSensorEntity(energysite, description) for energysite in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS - if description.key in energysite.live_coordinator.data + if energysite.live_coordinator is not None + and ( + description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" + ) ), ( # Add wall connectors TessieWallConnectorSensorEntity(energysite, din, description) for energysite in entry.runtime_data.energysites + if energysite.live_coordinator is not None for din in energysite.live_coordinator.data.get("wall_connectors", {}) for description in WALL_CONNECTOR_DESCRIPTIONS ), @@ -445,11 +450,11 @@ class TessieEnergyLiveSensorEntity(TessieEnergyEntity, SensorEntity): ) -> None: """Initialize the sensor.""" self.entity_description = description + assert data.live_coordinator is not None super().__init__(data, data.live_coordinator, description.key) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_available = self._value is not None self._attr_native_value = self.entity_description.value_fn(self._value) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 4f0f5f67ebd..f3455845fd7 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -48,7 +48,7 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "on": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" @@ -76,8 +76,8 @@ "state": { "starting": "Starting", "charging": "[%key:common::state::charging%]", - "disconnected": "Disconnected", - "stopped": "Stopped", + "disconnected": "[%key:common::state::disconnected%]", + "stopped": "[%key:common::state::stopped%]", "complete": "Complete", "no_power": "No power" } @@ -217,7 +217,7 @@ "connected": "[%key:common::state::connected%]", "scheduled": "Scheduled", "negotiating": "Negotiating", - "error": "Error", + "error": "[%key:common::state::error%]", "charging_finished": "Charging finished", "waiting_car": "Waiting car", "charging_reduced": "Charging reduced" @@ -246,81 +246,81 @@ "name": "Seat heater left", "state": { "off": "[%key:common::state::off%]", - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_fan_front_left": { "name": "Seat cooler left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_fan_front_right": { "name": "Seat cooler right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "components_customer_preferred_export_rule": { @@ -336,7 +336,7 @@ "state": { "autonomous": "Autonomous", "backup": "Backup", - "self_consumption": "Self consumption" + "self_consumption": "Self-consumption" } } }, @@ -495,7 +495,7 @@ "name": "Speed limit" }, "off_grid_vehicle_charging_reserve_percent": { - "name": "Off grid reserve" + "name": "Off-grid reserve" } }, "update": { diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index 9571597abe6..7fc6e2594c4 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -95,7 +95,7 @@ class TfiacClimate(ClimateEntity): self._available = True @property - def available(self): + def available(self) -> bool: """Return if the device is available.""" return self._available diff --git a/homeassistant/components/thermador/__init__.py b/homeassistant/components/thermador/__init__.py new file mode 100644 index 00000000000..2bd83b2ff71 --- /dev/null +++ b/homeassistant/components/thermador/__init__.py @@ -0,0 +1 @@ +"""Thermador virtual integration.""" diff --git a/homeassistant/components/thermador/manifest.json b/homeassistant/components/thermador/manifest.json new file mode 100644 index 00000000000..b09861623de --- /dev/null +++ b/homeassistant/components/thermador/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "thermador", + "name": "Thermador", + "integration_type": "virtual", + "supported_by": "home_connect" +} diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index e060cbd91bf..d672de5adde 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -54,5 +54,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.8.0"] + "requirements": ["thermobeacon-ble==0.10.0"] } diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 6027e4bc99c..29dadfd3d63 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.11.0"] + "requirements": ["thermopro-ble==0.13.0"] } diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index ba512d07f18..5aa851d99ae 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -40,7 +40,7 @@ async def async_setup_entry( if (device_id, field_id) not in sensors and isinstance(ttn_value, TTNSensorValue) } - if len(new_sensors): + if new_sensors: async_add_entities(new_sensors.values()) sensors.update(new_sensors.keys()) diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 2de9ebd1ec6..8335cc2d773 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -9,7 +9,11 @@ from typing import cast import tibber from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -21,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN as TIBBER_DOMAIN +from .const import DOMAIN FIVE_YEARS = 5 * 365 * 24 @@ -76,7 +80,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): for sensor_type, is_production, unit in sensors: statistic_id = ( - f"{TIBBER_DOMAIN}:energy_" + f"{DOMAIN}:energy_" f"{sensor_type.lower()}_" f"{home.home_id.replace('-', '')}" ) @@ -159,10 +163,10 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): ) metadata = StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{home.name} {sensor_type}", - source=TIBBER_DOMAIN, + source=DOMAIN, statistic_id=statistic_id, unit_of_measurement=unit, ) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 3a3a772a934..43cbd79afef 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.30.8"] + "requirements": ["pyTibber==0.31.2"] } diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index df6541591e0..5a10d8e0890 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN as TIBBER_DOMAIN +from . import DOMAIN async def async_setup_entry( @@ -30,7 +30,7 @@ class TibberNotificationEntity(NotifyEntity): """Implement the notification entity service for Tibber.""" _attr_supported_features = NotifyEntityFeature.TITLE - _attr_name = TIBBER_DOMAIN + _attr_name = DOMAIN _attr_icon = "mdi:message-flash" def __init__(self, unique_id: str) -> None: @@ -39,12 +39,12 @@ class TibberNotificationEntity(NotifyEntity): async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message to Tibber devices.""" - tibber_connection: Tibber = self.hass.data[TIBBER_DOMAIN] + tibber_connection: Tibber = self.hass.data[DOMAIN] try: await tibber_connection.send_notification( title or ATTR_TITLE_DEFAULT, message ) except TimeoutError as exc: raise HomeAssistantError( - translation_domain=TIBBER_DOMAIN, translation_key="send_message_timeout" + translation_domain=DOMAIN, translation_key="send_message_timeout" ) from exc diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 9f87b8a8490..26b8f5400a0 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -41,7 +41,7 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import Throttle, dt as dt_util -from .const import DOMAIN as TIBBER_DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER from .coordinator import TibberDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -267,7 +267,7 @@ async def async_setup_entry( ) -> None: """Set up the Tibber sensor.""" - tibber_connection = hass.data[TIBBER_DOMAIN] + tibber_connection = hass.data[DOMAIN] entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -309,21 +309,17 @@ async def async_setup_entry( continue # migrate to new device ids - old_entity_id = entity_registry.async_get_entity_id( - "sensor", TIBBER_DOMAIN, old_id - ) + old_entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, old_id) if old_entity_id is not None: entity_registry.async_update_entity( old_entity_id, new_unique_id=home.home_id ) # migrate to new device ids - device_entry = device_registry.async_get_device( - identifiers={(TIBBER_DOMAIN, old_id)} - ) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, old_id)}) if device_entry and entry.entry_id in device_entry.config_entries: device_registry.async_update_device( - device_entry.id, new_identifiers={(TIBBER_DOMAIN, home.home_id)} + device_entry.id, new_identifiers={(DOMAIN, home.home_id)} ) async_add_entities(entities, True) @@ -352,7 +348,7 @@ class TibberSensor(SensorEntity): def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" device_info = DeviceInfo( - identifiers={(TIBBER_DOMAIN, self._tibber_home.home_id)}, + identifiers={(DOMAIN, self._tibber_home.home_id)}, name=self._device_name, manufacturer=MANUFACTURER, ) @@ -553,19 +549,19 @@ class TibberRtEntityCreator: if translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION_SIMPLE: entity_id = self._entity_registry.async_get_entity_id( "sensor", - TIBBER_DOMAIN, + DOMAIN, f"{home_id}_rt_{translation_key.replace('_', ' ')}", ) elif translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION: entity_id = self._entity_registry.async_get_entity_id( "sensor", - TIBBER_DOMAIN, + DOMAIN, f"{home_id}_rt_{RT_SENSORS_UNIQUE_ID_MIGRATION[translation_key]}", ) elif translation_key != description_key: entity_id = self._entity_registry.async_get_entity_id( "sensor", - TIBBER_DOMAIN, + DOMAIN, f"{home_id}_rt_{translation_key}", ) diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index 05b98b97995..ec2c005d4e3 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -87,7 +87,7 @@ "services": { "get_prices": { "name": "Get energy prices", - "description": "Get hourly energy prices from Tibber", + "description": "Fetches hourly energy prices including price level.", "fields": { "start": { "name": "Start", diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 66a3b8b0e27..c81c791cd5d 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -64,7 +64,7 @@ class TileDeviceTracker(TileEntity, TrackerEntity): ) self._attr_latitude = None if not self._tile.latitude else self._tile.latitude self._attr_location_accuracy = ( - 0 if not self._tile.accuracy else int(self._tile.accuracy) + 0 if not self._tile.accuracy else self._tile.accuracy ) self._attr_extra_state_attributes = { diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 60e55c214fe..1e3c37b55b3 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -72,7 +72,7 @@ class TimeEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Time entity.""" entity_description: TimeEntityDescription - _attr_native_value: time | None + _attr_native_value: time | None = None _attr_device_class: None = None _attr_state: None = None diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 937187c1c6f..ea0448b7499 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -95,6 +95,12 @@ TODO_ITEM_FIELD_SCHEMA = { vol.Optional(desc.service_field): desc.validation for desc in TODO_ITEM_FIELDS } TODO_ITEM_FIELD_VALIDATIONS = [cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATETIME)] +TODO_SERVICE_GET_ITEMS_SCHEMA = { + vol.Optional(ATTR_STATUS): vol.All( + cv.ensure_list, + [vol.In({TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED})], + ), +} def _validate_supported_features( @@ -129,7 +135,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.All( cv.make_entity_service_schema( { - vol.Required(ATTR_ITEM): vol.All(cv.string, vol.Length(min=1)), + vol.Required(ATTR_ITEM): vol.All( + cv.string, str.strip, vol.Length(min=1) + ), **TODO_ITEM_FIELD_SCHEMA, } ), @@ -144,7 +152,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cv.make_entity_service_schema( { vol.Required(ATTR_ITEM): vol.All(cv.string, vol.Length(min=1)), - vol.Optional(ATTR_RENAME): vol.All(cv.string, vol.Length(min=1)), + vol.Optional(ATTR_RENAME): vol.All( + cv.string, str.strip, vol.Length(min=1) + ), vol.Optional(ATTR_STATUS): vol.In( {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED}, ), @@ -173,14 +183,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) component.async_register_entity_service( TodoServices.GET_ITEMS, - cv.make_entity_service_schema( - { - vol.Optional(ATTR_STATUS): vol.All( - cv.ensure_list, - [vol.In({TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED})], - ), - } - ), + cv.make_entity_service_schema(TODO_SERVICE_GET_ITEMS_SCHEMA), _async_get_todo_items, supports_response=SupportsResponse.ONLY, ) @@ -219,18 +222,10 @@ class TodoItem: """A status or confirmation of the To-do item.""" due: datetime.date | datetime.datetime | None = None - """The date and time that a to-do is expected to be completed. - - This field may be a date or datetime depending whether the entity feature - DUE_DATE or DUE_DATETIME are set. - """ + """The date and time that a to-do is expected to be completed.""" description: str | None = None - """A more complete description of than that provided by the summary. - - This field may be set when TodoListEntityFeature.DESCRIPTION is supported by - the entity. - """ + """A more complete description than that provided by the summary.""" CACHED_PROPERTIES_WITH_ATTR_ = { diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index c678408a576..d679a57bf96 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -11,11 +11,13 @@ from . import TodoItem, TodoItemStatus, TodoListEntity from .const import DATA_COMPONENT, DOMAIN INTENT_LIST_ADD_ITEM = "HassListAddItem" +INTENT_LIST_COMPLETE_ITEM = "HassListCompleteItem" async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the todo intents.""" intent.async_register(hass, ListAddItemIntent()) + intent.async_register(hass, ListCompleteItemIntent()) class ListAddItemIntent(intent.IntentHandler): @@ -53,14 +55,92 @@ class ListAddItemIntent(intent.IntentHandler): match_result.states[0].entity_id ) if target_list is None: - raise intent.IntentHandleError(f"No to-do list: {list_name}") + raise intent.IntentHandleError( + f"No to-do list: {list_name}", "list_not_found" + ) # Add to list await target_list.async_create_todo_item( TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) ) - response = intent_obj.create_response() + response: intent.IntentResponse = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_results( + [ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name=list_name, + id=match_result.states[0].entity_id, + ) + ] + ) + return response + + +class ListCompleteItemIntent(intent.IntentHandler): + """Handle ListCompleteItem intents.""" + + intent_type = INTENT_LIST_COMPLETE_ITEM + description = "Complete item on a todo list" + slot_schema = { + vol.Required("item"): intent.non_empty_string, + vol.Required("name"): intent.non_empty_string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + + slots = self.async_validate_slots(intent_obj.slots) + item = slots["item"]["value"] + list_name = slots["name"]["value"] + + target_list: TodoListEntity | None = None + + # Find matching list + match_constraints = intent.MatchTargetsConstraints( + name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + target_list = hass.data[DATA_COMPONENT].get_entity( + match_result.states[0].entity_id + ) + if target_list is None: + raise intent.IntentHandleError( + f"No to-do list: {list_name}", "list_not_found" + ) + + # Find item in list + matching_item = None + for todo_item in target_list.todo_items or (): + if ( + item in (todo_item.uid, todo_item.summary) + and todo_item.status == TodoItemStatus.NEEDS_ACTION + ): + matching_item = todo_item + break + if not matching_item or not matching_item.uid: + raise intent.IntentHandleError( + f"Item '{item}' not found on list", "item_not_found" + ) + + # Mark as completed + await target_list.async_update_todo_item( + TodoItem( + uid=matching_item.uid, + summary=matching_item.summary, + status=TodoItemStatus.COMPLETED, + ) + ) + + response: intent.IntentResponse = intent_obj.create_response() response.response_type = intent.IntentResponseType.ACTION_DONE response.async_set_results( [ diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 07f91e12e22..8c26b8e9c76 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -100,6 +100,7 @@ remove_item: fields: item: required: true + example: "Submit income tax return" selector: text: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index cffb22e89f0..1354ab6777b 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -40,11 +40,11 @@ }, "update_item": { "name": "Update item", - "description": "Updates an existing to-do list item based on its name.", + "description": "Updates an existing to-do list item based on its name or UID.", "fields": { "item": { - "name": "Item name", - "description": "The current name of the to-do item." + "name": "Item name or UID", + "description": "The name/summary of the to-do item. If you have items with duplicate names, you can reference specific ones using their UID instead." }, "rename": { "name": "Rename item", @@ -55,16 +55,16 @@ "description": "A status or confirmation of the to-do item." }, "due_date": { - "name": "Due date", - "description": "The date the to-do item is expected to be completed." + "name": "[%key:component::todo::services::add_item::fields::due_date::name%]", + "description": "[%key:component::todo::services::add_item::fields::due_date::description%]" }, "due_datetime": { - "name": "Due date and time", - "description": "The date and time the to-do item is expected to be completed." + "name": "[%key:component::todo::services::add_item::fields::due_datetime::name%]", + "description": "[%key:component::todo::services::add_item::fields::due_datetime::description%]" }, "description": { - "name": "Description", - "description": "A more complete description of the to-do item than provided by the item name." + "name": "[%key:component::todo::services::add_item::fields::description::name%]", + "description": "[%key:component::todo::services::add_item::fields::description::description%]" } } }, @@ -74,11 +74,11 @@ }, "remove_item": { "name": "Remove item", - "description": "Removes an existing to-do list item by its name.", + "description": "Removes an existing to-do list item by its name or UID.", "fields": { "item": { - "name": "Item name", - "description": "The name for the to-do list item." + "name": "[%key:component::todo::services::update_item::fields::item::name%]", + "description": "[%key:component::todo::services::update_item::fields::item::description%]" } } } diff --git a/homeassistant/components/tolo/strings.json b/homeassistant/components/tolo/strings.json index c55498b8d92..82b6ecee9e7 100644 --- a/homeassistant/components/tolo/strings.json +++ b/homeassistant/components/tolo/strings.json @@ -59,7 +59,7 @@ "name": "Lamp mode", "state": { "automatic": "Automatic", - "manual": "Manual" + "manual": "[%key:common::state::manual%]" } }, "aroma_therapy_slot": { diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index 03a8a169920..c3f52155d29 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -115,33 +115,33 @@ "name": "Tree pollen index", "state": { "none": "None", - "very_low": "Very low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very high" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "weed_pollen_index": { "name": "Weed pollen index", "state": { "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", - "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", - "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", - "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", - "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", - "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "grass_pollen_index": { "name": "Grass pollen index", "state": { "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", - "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", - "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", - "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", - "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", - "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "fire_index": { @@ -153,10 +153,10 @@ "uv_radiation_health_concern": { "name": "UV radiation health concern", "state": { - "low": "Low", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "high": "High", - "very_high": "Very high", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]", "extreme": "Extreme" } } diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 9ed29ea01c8..e31e6085832 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -97,22 +97,6 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): @property def alarm_state(self) -> AlarmControlPanelState | None: """Return the state of the device.""" - # State attributes can be removed in 2025.3 - attr = { - "location_id": self._location.location_id, - "partition": self._partition_id, - "ac_loss": self._location.ac_loss, - "low_battery": self._location.low_battery, - "cover_tampered": self._location.is_cover_tampered(), - "triggered_source": None, - "triggered_zone": None, - } - - if self._partition_id == 1: - attr["location_name"] = self.device.name - else: - attr["location_name"] = f"{self.device.name} partition {self._partition_id}" - state: AlarmControlPanelState | None = None if self._partition.arming_state.is_disarmed(): state = AlarmControlPanelState.DISARMED @@ -128,17 +112,12 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): state = AlarmControlPanelState.ARMING elif self._partition.arming_state.is_disarming(): state = AlarmControlPanelState.DISARMING - elif self._partition.arming_state.is_triggered_police(): + elif ( + self._partition.arming_state.is_triggered_police() + or self._partition.arming_state.is_triggered_fire() + or self._partition.arming_state.is_triggered_gas() + ): state = AlarmControlPanelState.TRIGGERED - attr["triggered_source"] = "Police/Medical" - elif self._partition.arming_state.is_triggered_fire(): - state = AlarmControlPanelState.TRIGGERED - attr["triggered_source"] = "Fire/Smoke" - elif self._partition.arming_state.is_triggered_gas(): - state = AlarmControlPanelState.TRIGGERED - attr["triggered_source"] = "Carbon Monoxide" - - self._attr_extra_state_attributes = attr return state diff --git a/homeassistant/components/totalconnect/diagnostics.py b/homeassistant/components/totalconnect/diagnostics.py index f42ed5e44c3..fc310bf850c 100644 --- a/homeassistant/components/totalconnect/diagnostics.py +++ b/homeassistant/components/totalconnect/diagnostics.py @@ -83,6 +83,7 @@ async def async_get_config_entry_diagnostics( "is_new_partition": partition.is_new_partition, "is_night_stay_enabled": partition.is_night_stay_enabled, "exit_delay_timer": partition.exit_delay_timer, + "arming_state": partition.arming_state, } new_location["partitions"].append(new_partition) diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index daf720084a5..f3174b72a8e 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Total Connect 2.0 Account Credentials", + "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%]", @@ -14,13 +14,13 @@ } }, "locations": { - "title": "Location Usercodes", + "title": "Location usercodes", "description": "Enter the usercode for this user at location {location_id}", "data": { "usercodes": "Usercode" }, "data_description": { - "usercodes": "The usercode is usually a 4 digit number" + "usercodes": "The usercode is usually a 4-digit number" } }, "reauth_confirm": { @@ -41,13 +41,13 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "no_locations": "No locations are available for this user, check TotalConnect settings" + "no_locations": "No locations are available for this user, check Total Connect settings" } }, "options": { "step": { "init": { - "title": "TotalConnect Options", + "title": "Total Connect options", "data": { "auto_bypass_low_battery": "Auto bypass low battery", "code_required": "Require user to enter code for alarm actions" @@ -62,11 +62,11 @@ "services": { "arm_away_instant": { "name": "Arm away instant", - "description": "Arms Away with zero entry delay." + "description": "Arms away with zero entry delay." }, "arm_home_instant": { "name": "Arm home instant", - "description": "Arms Home with zero entry delay." + "description": "Arms home with zero entry delay." } }, "entity": { diff --git a/homeassistant/components/touchline_sl/strings.json b/homeassistant/components/touchline_sl/strings.json index e3a0ef5a741..469fb8a50a6 100644 --- a/homeassistant/components/touchline_sl/strings.json +++ b/homeassistant/components/touchline_sl/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "Touchline SL Setup Flow", + "flow_title": "Touchline SL setup flow", "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", @@ -8,7 +8,7 @@ }, "step": { "user": { - "title": "Login to Touchline SL", + "title": "Log in to Touchline SL", "description": "Your credentials for the Roth Touchline SL mobile app/web service", "data": { "username": "[%key:common::config_flow::data::username%]", diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 291a7e78c62..0914c4191cf 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -567,7 +567,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): ) async def _async_reload_requires_auth_entries(self) -> None: - """Reload any in progress config flow that now have credentials.""" + """Reload all config entries after auth update.""" _config_entries = self.hass.config_entries if self.source == SOURCE_REAUTH: @@ -579,11 +579,9 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): context = flow["context"] if context.get("source") != SOURCE_REAUTH: continue - entry_id: str = context["entry_id"] + entry_id = context["entry_id"] if entry := _config_entries.async_get_entry(entry_id): await _config_entries.async_reload(entry.entry_id) - if entry.state is ConfigEntryState.LOADED: - _config_entries.flow.async_abort(flow["flow_id"]) @callback def _async_create_or_update_entry_from_device( diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index ded4806a726..856b4d339a5 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -209,7 +209,7 @@ "name": "Last water leak alert" }, "auto_off_at": { - "name": "Auto off at" + "name": "Auto-off at" }, "report_interval": { "name": "Report interval" @@ -297,10 +297,10 @@ "name": "LED" }, "auto_update_enabled": { - "name": "Auto update enabled" + "name": "Auto-update enabled" }, "auto_off_enabled": { - "name": "Auto off enabled" + "name": "Auto-off enabled" }, "smooth_transitions": { "name": "Smooth transitions" @@ -388,7 +388,7 @@ }, "segments": { "name": "Segments", - "description": "List of Segments (0 for all)." + "description": "List of segments (0 for all)." }, "brightness": { "name": "Brightness", diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index eeeddb62495..6fec7d30381 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Mapping import logging import re -from types import MappingProxyType from typing import Any, NamedTuple from urllib.parse import urlsplit @@ -45,7 +44,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( async def create_omada_client( - hass: HomeAssistant, data: MappingProxyType[str, Any] + hass: HomeAssistant, data: Mapping[str, Any] ) -> OmadaClient: """Create a TP-Link Omada client API for the given config entry.""" @@ -84,7 +83,7 @@ class HubInfo(NamedTuple): async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> HubInfo: """Validate the user input allows us to connect.""" - client = await create_omada_client(hass, MappingProxyType(data)) + client = await create_omada_client(hass, data) controller_id = await client.login() name = await client.get_controller_name() sites = await client.get_sites() diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index af20b54675b..274f2815330 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.4.3"] + "requirements": ["tplink-omada-client==1.4.4"] } diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 73cea692dbf..99c509a73a7 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -24,14 +24,14 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, - "title": "Update TP-Link Omada Credentials", + "title": "Update TP-Link Omada credentials", "description": "The provided credentials have stopped working. Please update them." } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unsupported_controller": "Omada Controller version not supported.", + "unsupported_controller": "Omada controller version not supported.", "unknown": "[%key:common::config_flow::error::unknown%]", "no_sites_found": "No sites found which the user can manage." }, @@ -46,31 +46,31 @@ "name": "Port {port_name} PoE" }, "wan_connect_ipv4": { - "name": "Port {port_name} Internet Connected" + "name": "Port {port_name} Internet connected" }, "wan_connect_ipv6": { - "name": "Port {port_name} Internet Connected (IPv6)" + "name": "Port {port_name} Internet connected (IPv6)" } }, "binary_sensor": { "wan_link": { - "name": "Port {port_name} Internet Link" + "name": "Port {port_name} Internet link" }, "online_detection": { - "name": "Port {port_name} Online Detection" + "name": "Port {port_name} online detection" }, "lan_status": { - "name": "Port {port_name} LAN Status" + "name": "Port {port_name} LAN status" }, "poe_delivery": { - "name": "Port {port_name} PoE Delivery" + "name": "Port {port_name} PoE delivery" } }, "sensor": { "device_status": { "name": "Device status", "state": { - "error": "Error", + "error": "[%key:common::state::error%]", "disconnected": "[%key:common::state::disconnected%]", "connected": "[%key:common::state::connected%]", "pending": "Pending", @@ -91,7 +91,7 @@ "services": { "reconnect_client": { "name": "Reconnect wireless client", - "description": "Tries to get wireless client to reconnect to Omada Network.", + "description": "Tries to get wireless client to reconnect to Omada network.", "fields": { "mac": { "name": "MAC address", diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py index 7f2a6dd7c40..33a7e511d09 100644 --- a/homeassistant/components/traccar_server/device_tracker.py +++ b/homeassistant/components/traccar_server/device_tracker.py @@ -54,6 +54,6 @@ class TraccarServerDeviceTracker(TraccarServerEntity, TrackerEntity): return self.traccar_position["longitude"] @property - def location_accuracy(self) -> int: + def location_accuracy(self) -> float: """Return the gps accuracy of the device.""" return self.traccar_position["accuracy"] diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json index 8bec4b112ac..a4b57562388 100644 --- a/homeassistant/components/traccar_server/strings.json +++ b/homeassistant/components/traccar_server/strings.json @@ -12,7 +12,7 @@ }, "data_description": { "host": "The hostname or IP address of your Traccar Server", - "username": "The username (email) you use to login to your Traccar Server" + "username": "The username (email) you use to log in to your Traccar Server" } } }, @@ -47,7 +47,7 @@ "motion": { "name": "Motion", "state": { - "off": "Stopped", + "off": "[%key:common::state::stopped%]", "on": "Moving" } }, diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 8bc2d11d047..60bae9bfd2e 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -31,6 +31,7 @@ from .const import ( ATTR_MINUTES_DAY_SLEEP, ATTR_MINUTES_NIGHT_SLEEP, ATTR_MINUTES_REST, + ATTR_POWER_SAVING, ATTR_SLEEP_LABEL, ATTR_TRACKER_STATE, CLIENT_ID, @@ -277,6 +278,7 @@ class TractiveClient: payload = { ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"], ATTR_TRACKER_STATE: event["tracker_state"].lower(), + ATTR_POWER_SAVING: event.get("tracker_state_reason") == "POWER_SAVING", ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING", } self._dispatch_tracker_event( diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index 2978d369344..9ded1f699c3 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any from homeassistant.components.binary_sensor import ( @@ -14,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Trackables, TractiveClient, TractiveConfigEntry -from .const import TRACKER_HARDWARE_STATUS_UPDATED +from .const import ATTR_POWER_SAVING, TRACKER_HARDWARE_STATUS_UPDATED from .entity import TractiveEntity @@ -25,7 +27,7 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): self, client: TractiveClient, item: Trackables, - description: BinarySensorEntityDescription, + description: TractiveBinarySensorEntityDescription, ) -> None: """Initialize sensor entity.""" super().__init__( @@ -47,12 +49,27 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): super().handle_status_update(event) -SENSOR_TYPE = BinarySensorEntityDescription( - key=ATTR_BATTERY_CHARGING, - translation_key="tracker_battery_charging", - device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - entity_category=EntityCategory.DIAGNOSTIC, -) +@dataclass(frozen=True, kw_only=True) +class TractiveBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Tractive binary sensor entities.""" + + supported: Callable[[dict], bool] = lambda _: True + + +SENSOR_TYPES = [ + TractiveBinarySensorEntityDescription( + key=ATTR_BATTERY_CHARGING, + translation_key="tracker_battery_charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + supported=lambda details: details.get("charging_state") is not None, + ), + TractiveBinarySensorEntityDescription( + key=ATTR_POWER_SAVING, + translation_key="tracker_power_saving", + entity_category=EntityCategory.DIAGNOSTIC, + ), +] async def async_setup_entry( @@ -65,9 +82,10 @@ async def async_setup_entry( trackables = entry.runtime_data.trackables entities = [ - TractiveBinarySensor(client, item, SENSOR_TYPE) + TractiveBinarySensor(client, item, description) + for description in SENSOR_TYPES for item in trackables - if item.tracker_details.get("charging_state") is not None + if description.supported(item.tracker_details) ] async_add_entities(entities) diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index cb5d4066dd9..9b925015772 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -16,6 +16,7 @@ ATTR_MINUTES_ACTIVE = "minutes_active" ATTR_MINUTES_DAY_SLEEP = "minutes_day_sleep" ATTR_MINUTES_NIGHT_SLEEP = "minutes_night_sleep" ATTR_MINUTES_REST = "minutes_rest" +ATTR_POWER_SAVING = "power_saving" ATTR_SLEEP_LABEL = "sleep_label" ATTR_TRACKER_STATE = "tracker_state" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 73be7216a2f..09a4e3faf1f 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -49,17 +49,15 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): self._battery_level: int | None = item.hw_info.get("battery_level") self._attr_latitude = item.pos_report["latlong"][0] self._attr_longitude = item.pos_report["latlong"][1] - self._attr_location_accuracy: int = item.pos_report["pos_uncertainty"] + self._attr_location_accuracy: float = item.pos_report["pos_uncertainty"] self._source_type: str = item.pos_report["sensor_used"] self._attr_unique_id = item.trackable["_id"] @property def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" + """Return the source type of the device.""" if self._source_type == "PHONE": return SourceType.BLUETOOTH - if self._source_type == "KNOWN_WIFI": - return SourceType.ROUTER return SourceType.GPS @property diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json index 0690328c99c..a56a2982057 100644 --- a/homeassistant/components/tractive/strings.json +++ b/homeassistant/components/tractive/strings.json @@ -22,6 +22,9 @@ "binary_sensor": { "tracker_battery_charging": { "name": "Tracker battery charging" + }, + "tracker_power_saving": { + "name": "Tracker power saving" } }, "device_tracker": { diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 2073829e021..c3e8938b244 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -159,7 +159,7 @@ def remove_stale_devices( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids = {device.id for device in devices} + all_device_ids = {str(device.id) for device in devices} for device_entry in device_entries: device_id: str | None = None @@ -176,7 +176,7 @@ def remove_stale_devices( gateway_id = _id break - device_id = _id + device_id = _id.replace(f"{config_entry.data[CONF_GATEWAY_ID]}-", "") break if gateway_id is not None: @@ -190,3 +190,93 @@ def remove_stale_devices( device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry.entry_id ) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + LOGGER.debug( + "Migrating Tradfri configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + # Migrate to version 2 + migrate_config_entry_and_identifiers(hass, config_entry) + + hass.config_entries.async_update_entry(config_entry, version=2) + + LOGGER.debug( + "Migration to Tradfri configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + +def migrate_config_entry_and_identifiers( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate old non-unique identifiers to new unique identifiers.""" + + related_device_flag: bool + device_id: str + + device_reg = dr.async_get(hass) + # Get all devices associated to contextual gateway config_entry + # and loop through list of devices. + for device in dr.async_entries_for_config_entry(device_reg, config_entry.entry_id): + related_device_flag = False + for identifier in device.identifiers: + if identifier[0] != DOMAIN: + continue + + related_device_flag = True + + _id = identifier[1] + + # Identify gateway device. + if _id == config_entry.data[CONF_GATEWAY_ID]: + # Using this to avoid updating gateway's own device registry entry + related_device_flag = False + break + + device_id = str(_id) + break + + # Check that device is related to tradfri domain (and is not the gateway itself) + if not related_device_flag: + continue + + # Loop through list of config_entry_ids for device + config_entry_ids = device.config_entries + for config_entry_id in config_entry_ids: + # Check that the config entry in list is not the device's primary config entry + if config_entry_id == device.primary_config_entry: + continue + + # Check that the 'other' config entry is also a tradfri config entry + other_entry = hass.config_entries.async_get_entry(config_entry_id) + + if other_entry is None or other_entry.domain != DOMAIN: + continue + + # Remove non-primary 'tradfri' config entry from device's config_entry_ids + device_reg.async_update_device( + device.id, remove_config_entry_id=config_entry_id + ) + + if config_entry.data[CONF_GATEWAY_ID] in device_id: + continue + + device_reg.async_update_device( + device.id, + new_identifiers={ + (DOMAIN, f"{config_entry.data[CONF_GATEWAY_ID]}-{device_id}") + }, + ) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 29d876346a7..f4adb1cc09e 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, cast from uuid import uuid4 from pytradfri import Gateway, RequestError @@ -35,7 +35,7 @@ class AuthError(Exception): class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize flow.""" @@ -54,7 +54,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - host = user_input.get(CONF_HOST, self._host) + host = cast(str, user_input.get(CONF_HOST, self._host)) try: auth = await authenticate( self.hass, host, user_input[KEY_SECURITY_CODE] diff --git a/homeassistant/components/tradfri/entity.py b/homeassistant/components/tradfri/entity.py index b06d0081477..41c20b19de5 100644 --- a/homeassistant/components/tradfri/entity.py +++ b/homeassistant/components/tradfri/entity.py @@ -58,7 +58,7 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): info = self._device.device_info self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, + identifiers={(DOMAIN, f"{gateway_id}-{self._device_id}")}, manufacturer=info.manufacturer, model=info.model_number, name=self._device.name, diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 9ed7e167e71..8b86a6df9ab 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -6,7 +6,7 @@ "description": "You can find the security code on the back of your gateway.", "data": { "host": "[%key:common::config_flow::data::host%]", - "security_code": "Security Code" + "security_code": "Security code" }, "data_description": { "host": "Hostname or IP address of your Trådfri gateway." @@ -14,10 +14,10 @@ } }, "error": { - "invalid_security_code": "Failed to register with provided key. If this keeps happening, try restarting the gateway.", + "invalid_security_code": "Failed to register with provided code. If this keeps happening, try restarting the gateway.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout": "Timeout validating the code.", - "cannot_authenticate": "Cannot authenticate, is Gateway paired with another server like e.g. Homekit?" + "cannot_authenticate": "Cannot authenticate, is your gateway paired with another server like e.g. HomeKit?" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index b6e2209fc57..8fdc6357156 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -18,7 +18,7 @@ "location": "[%key:common::config_flow::data::location%]" }, "data_description": { - "location": "Equal or part of name, description or camera id. Be as specific as possible to avoid getting multiple cameras as result" + "location": "Equal or part of name, description or camera ID. Be as specific as possible to avoid getting multiple cameras as result" } }, "multiple_cameras": { @@ -60,7 +60,7 @@ "name": "[%key:common::config_flow::data::location%]" }, "photo_url": { - "name": "Photo url" + "name": "Photo URL" }, "status": { "name": "Status" @@ -87,7 +87,7 @@ "name": "Photo time" }, "photo_url": { - "name": "Photo url" + "name": "Photo URL" }, "status": { "name": "Status" diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index 002dc421273..dfa64ed2953 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from pytrafikverket import TrafikverketFerry @@ -17,6 +18,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN from .util import create_unique_id +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): selector.TextSelector( @@ -81,7 +84,8 @@ class TVFerryConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NoFerryFound: errors["base"] = "invalid_route" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: return self.async_update_reload_and_abort( @@ -120,7 +124,8 @@ class TVFerryConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NoFerryFound: errors["base"] = "invalid_route" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: if not errors: diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index f6a58e464a1..fb39e14815e 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -86,8 +86,8 @@ async def validate_station( except UnknownError as error: _LOGGER.error("Unknown error occurred during validation %s", str(error)) errors["base"] = "cannot_connect" - except Exception as error: # noqa: BLE001 - _LOGGER.error("Unknown exception occurred during validation %s", str(error)) + except Exception: + _LOGGER.exception("Unknown exception occurred during validation") errors["base"] = "cannot_connect" return (stations, errors) @@ -266,7 +266,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): { CONF_API_KEY: api_key, CONF_FROM: train_from, - CONF_TO: user_input[CONF_TO], + CONF_TO: train_to, CONF_TIME: train_time, CONF_WEEKDAY: train_days, CONF_FILTER_PRODUCT: filter_product, diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index f4316b887b3..ee9fe264692 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from pytrafikverket.exceptions import ( @@ -25,6 +26,8 @@ from homeassistant.helpers.selector import ( from .const import CONF_STATION, DOMAIN +_LOGGER = logging.getLogger(__name__) + class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Weatherstation integration.""" @@ -56,7 +59,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_station" except MultipleWeatherStationsFound: errors["base"] = "more_stations" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected error") errors["base"] = "cannot_connect" else: return self.async_create_entry( @@ -102,7 +106,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_station" except MultipleWeatherStationsFound: errors["base"] = "more_stations" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: return self.async_update_reload_and_abort( @@ -132,7 +137,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_station" except MultipleWeatherStationsFound: errors["base"] = "more_stations" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: return self.async_update_reload_and_abort( diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index cb923037a24..bbc6764e3ef 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -89,7 +89,8 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( translation_key="wind_direction", value_fn=lambda data: data.winddirection, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), TrafikverketSensorEntityDescription( key="wind_speed", diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index a0babe7464a..feb84f09fa8 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -211,6 +211,7 @@ def _torrents_info_attr( "percent_done": f"{torrent.percent_done * 100:.2f}", "status": torrent.status, "id": torrent.id, + "ratio": torrent.ratio, } with suppress(ValueError): info["eta"] = str(torrent.eta) diff --git a/homeassistant/components/triggercmd/config_flow.py b/homeassistant/components/triggercmd/config_flow.py index fc02dd0b2fc..48c4eacfd5a 100644 --- a/homeassistant/components/triggercmd/config_flow.py +++ b/homeassistant/components/triggercmd/config_flow.py @@ -57,7 +57,7 @@ class TriggerCMDConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_TOKEN] = "invalid_token" except TRIGGERcmdConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 6c7e521f3ef..526be21ad76 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -3,9 +3,9 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping +from collections.abc import AsyncGenerator, MutableMapping +from dataclasses import dataclass, field from datetime import datetime -from functools import partial import hashlib from http import HTTPStatus import io @@ -14,9 +14,8 @@ import mimetypes import os import re import secrets -import subprocess -import tempfile -from typing import Any, Final, TypedDict, final +from time import monotonic +from typing import Any, Final, Generic, Protocol, TypeVar from aiohttp import web import mutagen @@ -26,30 +25,24 @@ import voluptuous as vol from homeassistant.components import ffmpeg, websocket_api from homeassistant.components.http import HomeAssistantView -from homeassistant.components.media_player import ( - ATTR_MEDIA_ANNOUNCE, - ATTR_MEDIA_CONTENT_ID, - ATTR_MEDIA_CONTENT_TYPE, - DOMAIN as DOMAIN_MP, - SERVICE_PLAY_MEDIA, - MediaType, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - PLATFORM_FORMAT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, PLATFORM_FORMAT +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + ServiceCall, + callback, ) -from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import UNDEFINED, ConfigType -from homeassistant.util import dt as dt_util, language as language_util +from homeassistant.util import language as language_util, ulid as ulid_util from .const import ( ATTR_CACHE, @@ -67,9 +60,10 @@ from .const import ( DOMAIN, TtsAudioType, ) +from .entity import TextToSpeechEntity, TTSAudioRequest, TTSAudioResponse from .helper import get_engine_instance from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy -from .media_source import generate_media_source_id, media_source_id_to_kwargs +from .media_source import generate_media_source_id, parse_media_source_id from .models import Voice __all__ = [ @@ -83,12 +77,14 @@ __all__ = [ "PLATFORM_SCHEMA", "PLATFORM_SCHEMA_BASE", "Provider", + "ResultStream", "SampleFormat", + "TTSAudioResponse", + "TextToSpeechEntity", "TtsAudioType", "Voice", "async_default_engine", "async_get_media_source_audio", - "async_support_options", "generate_media_source_id", ] @@ -126,12 +122,94 @@ KEY_PATTERN = "{0}_{1}_{2}_{3}" SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) -class TTSCache(TypedDict): - """Cached TTS file.""" +class TTSCache: + """Cached bytes of a TTS result.""" - filename: str - voice: bytes - pending: asyncio.Task | None + _result_data: bytes | None = None + """When fully loaded, contains the result data.""" + + _partial_data: list[bytes] | None = None + """While loading, contains the data already received from the generator.""" + + _loading_error: Exception | None = None + """If an error occurred while loading, contains the error.""" + + _consumers: list[asyncio.Queue[bytes | None]] | None = None + """A queue for each current consumer to notify of new data while the generator is loading.""" + + def __init__( + self, + cache_key: str, + extension: str, + data_gen: AsyncGenerator[bytes], + ) -> None: + """Initialize the TTS cache.""" + self.cache_key = cache_key + self.extension = extension + self.last_used = monotonic() + self._data_gen = data_gen + + async def async_load_data(self) -> bytes: + """Load the data from the generator.""" + if self._result_data is not None or self._partial_data is not None: + raise RuntimeError("Data already being loaded") + + self._partial_data = [] + self._consumers = [] + + try: + async for chunk in self._data_gen: + self._partial_data.append(chunk) + for queue in self._consumers: + queue.put_nowait(chunk) + except Exception as err: + self._loading_error = err + raise + finally: + for queue in self._consumers: + queue.put_nowait(None) + self._consumers = None + + self._result_data = b"".join(self._partial_data) + self._partial_data = None + return self._result_data + + async def async_stream_data(self) -> AsyncGenerator[bytes]: + """Stream the data. + + Will return all data already returned from the generator. + Will listen for future data returned from the generator. + Raises error if one occurred. + """ + if self._result_data is not None: + yield self._result_data + return + if self._loading_error: + raise self._loading_error + + if self._partial_data is None: + raise RuntimeError("Data not being loaded") + + queue: asyncio.Queue[bytes | None] | None = None + # Check if generator is still feeding data + if self._consumers is not None: + queue = asyncio.Queue() + self._consumers.append(queue) + + for chunk in list(self._partial_data): + yield chunk + + if self._loading_error: + raise self._loading_error + + if queue is not None: + while (chunk2 := await queue.get()) is not None: + yield chunk2 + + if self._loading_error: + raise self._loading_error + + self.last_used = monotonic() @callback @@ -169,22 +247,25 @@ def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: return async_default_engine(hass) -async def async_support_options( +@callback +def async_create_stream( hass: HomeAssistant, engine: str, language: str | None = None, options: dict | None = None, -) -> bool: - """Return if an engine supports options.""" - if (engine_instance := get_engine_instance(hass, engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") +) -> ResultStream: + """Create a streaming URL where the rendered TTS can be retrieved.""" + return hass.data[DATA_TTS_MANAGER].async_create_result_stream( + engine=engine, + language=language, + options=options, + ) - try: - hass.data[DATA_TTS_MANAGER].process_options(engine_instance, language, options) - except HomeAssistantError: - return False - return True +@callback +def async_get_stream(hass: HomeAssistant, token: str) -> ResultStream | None: + """Return a result stream given a token.""" + return hass.data[DATA_TTS_MANAGER].async_get_result_stream(token) async def async_get_media_source_audio( @@ -192,9 +273,11 @@ async def async_get_media_source_audio( media_source_id: str, ) -> tuple[str, bytes]: """Get TTS audio as extension, data.""" - return await hass.data[DATA_TTS_MANAGER].async_get_tts_audio( - **media_source_id_to_kwargs(media_source_id), - ) + parsed = parse_media_source_id(media_source_id) + stream = hass.data[DATA_TTS_MANAGER].async_create_result_stream(**parsed["options"]) + stream.async_set_message(parsed["message"]) + data = b"".join([chunk async for chunk in stream.async_stream_result()]) + return stream.extension, data @callback @@ -213,89 +296,84 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]: return languages -async def async_convert_audio( +async def _async_convert_audio( hass: HomeAssistant, from_extension: str, - audio_bytes: bytes, + audio_bytes_gen: AsyncGenerator[bytes], to_extension: str, to_sample_rate: int | None = None, to_sample_channels: int | None = None, to_sample_bytes: int | None = None, -) -> bytes: +) -> AsyncGenerator[bytes]: """Convert audio to a preferred format using ffmpeg.""" ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) - return await hass.async_add_executor_job( - lambda: _convert_audio( - ffmpeg_manager.binary, - from_extension, - audio_bytes, - to_extension, - to_sample_rate=to_sample_rate, - to_sample_channels=to_sample_channels, - to_sample_bytes=to_sample_bytes, - ) + + command = [ + ffmpeg_manager.binary, + "-hide_banner", + "-loglevel", + "error", + "-f", + from_extension, + "-i", + "pipe:", + "-f", + to_extension, + ] + if to_sample_rate is not None: + command.extend(["-ar", str(to_sample_rate)]) + if to_sample_channels is not None: + command.extend(["-ac", str(to_sample_channels)]) + if to_extension == "mp3": + # Max quality for MP3. + command.extend(["-q:a", "0"]) + if to_sample_bytes == 2: + # 16-bit samples. + command.extend(["-sample_fmt", "s16"]) + command.append("pipe:1") # Send output to stdout. + + process = await asyncio.create_subprocess_exec( + *command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) + async def write_input() -> None: + assert process.stdin + try: + async for chunk in audio_bytes_gen: + process.stdin.write(chunk) + await process.stdin.drain() + finally: + if process.stdin: + process.stdin.close() -def _convert_audio( - ffmpeg_binary: str, - from_extension: str, - audio_bytes: bytes, - to_extension: str, - to_sample_rate: int | None = None, - to_sample_channels: int | None = None, - to_sample_bytes: int | None = None, -) -> bytes: - """Convert audio to a preferred format using ffmpeg.""" + writer_task = hass.async_create_background_task( + write_input(), "tts_ffmpeg_conversion" + ) - # We have to use a temporary file here because some formats like WAV store - # the length of the file in the header, and therefore cannot be written in a - # streaming fashion. - with tempfile.NamedTemporaryFile( - mode="wb+", suffix=f".{to_extension}" - ) as output_file: - # input - command = [ - ffmpeg_binary, - "-y", # overwrite temp file - "-f", - from_extension, - "-i", - "pipe:", # input from stdin - ] - - # output - command.extend(["-f", to_extension]) - - if to_sample_rate is not None: - command.extend(["-ar", str(to_sample_rate)]) - - if to_sample_channels is not None: - command.extend(["-ac", str(to_sample_channels)]) - - if to_extension == "mp3": - # Max quality for MP3 - command.extend(["-q:a", "0"]) - - if to_sample_bytes == 2: - # 16-bit samples - command.extend(["-sample_fmt", "s16"]) - - command.append(output_file.name) - - with subprocess.Popen( - command, stdin=subprocess.PIPE, stderr=subprocess.PIPE - ) as proc: - _stdout, stderr = proc.communicate(input=audio_bytes) - if proc.returncode != 0: - _LOGGER.error(stderr.decode()) - raise RuntimeError( - f"Unexpected error while running ffmpeg with arguments: {command}." - "See log for details." - ) - - output_file.seek(0) - return output_file.read() + assert process.stdout + chunk_size = 4096 + try: + while True: + chunk = await process.stdout.read(chunk_size) + if not chunk: + break + yield chunk + finally: + # Ensure we wait for the input writer to complete. + await writer_task + # Wait for process termination and check for errors. + retcode = await process.wait() + if retcode != 0: + assert process.stderr + stderr_data = await process.stderr.read() + _LOGGER.error(stderr_data.decode()) + raise RuntimeError( + f"Unexpected error while running ffmpeg with arguments: {command}. " + "See log for details." + ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -306,11 +384,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Legacy config options conf = config[DOMAIN][0] if config.get(DOMAIN) else {} - use_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) + use_file_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) cache_dir: str = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) - time_memory: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) + memory_cache_maxage: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - tts = SpeechManager(hass, use_cache, cache_dir, time_memory) + tts = SpeechManager(hass, use_file_cache, cache_dir, memory_cache_maxage) try: await tts.async_init_cache() @@ -375,141 +453,75 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -CACHED_PROPERTIES_WITH_ATTR_ = { - "default_language", - "default_options", - "supported_languages", - "supported_options", -} +@dataclass +class ResultStream: + """Class that will stream the result when available.""" + last_used: float = field(default_factory=monotonic, init=False) -class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): - """Represent a single TTS engine.""" + # Streaming/conversion properties + token: str + extension: str + content_type: str - _attr_should_poll = False - __last_tts_loaded: str | None = None + # TTS properties + engine: str + use_file_cache: bool + language: str + options: dict + supports_streaming_input: bool - _attr_default_language: str - _attr_default_options: Mapping[str, Any] | None = None - _attr_supported_languages: list[str] - _attr_supported_options: list[str] | None = None - - @property - @final - def state(self) -> str | None: - """Return the state of the entity.""" - if self.__last_tts_loaded is None: - return None - return self.__last_tts_loaded + _manager: SpeechManager @cached_property - def supported_languages(self) -> list[str]: - """Return a list of supported languages.""" - return self._attr_supported_languages + def url(self) -> str: + """Get the URL to stream the result.""" + return f"/api/tts_proxy/{self.token}" @cached_property - def default_language(self) -> str: - """Return the default language.""" - return self._attr_default_language - - @cached_property - def supported_options(self) -> list[str] | None: - """Return a list of supported options like voice, emotions.""" - return self._attr_supported_options - - @cached_property - def default_options(self) -> Mapping[str, Any] | None: - """Return a mapping with the default options.""" - return self._attr_default_options + def _result_cache(self) -> asyncio.Future[TTSCache]: + """Get the future that returns the cache.""" + return asyncio.Future() @callback - def async_get_supported_voices(self, language: str) -> list[Voice] | None: - """Return a list of supported voices for a language.""" - return None + def async_set_message(self, message: str) -> None: + """Set message to be generated. - async def async_internal_added_to_hass(self) -> None: - """Call when the entity is added to hass.""" - await super().async_internal_added_to_hass() - try: - _ = self.default_language - except AttributeError as err: - raise AttributeError( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - ) from err - try: - _ = self.supported_languages - except AttributeError as err: - raise AttributeError( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - ) from err - state = await self.async_get_last_state() - if ( - state is not None - and state.state is not None - and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - ): - self.__last_tts_loaded = state.state - - async def async_speak( - self, - media_player_entity_id: list[str], - message: str, - cache: bool, - language: str | None = None, - options: dict | None = None, - ) -> None: - """Speak via a Media Player.""" - await self.hass.services.async_call( - DOMAIN_MP, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: media_player_entity_id, - ATTR_MEDIA_CONTENT_ID: generate_media_source_id( - self.hass, - message=message, - engine=self.entity_id, - language=language, - options=options, - cache=cache, - ), - ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - ATTR_MEDIA_ANNOUNCE: True, - }, - blocking=True, - context=self._context, - ) - - @final - async def internal_async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Process an audio stream to TTS service. - - Only streaming content is allowed! + This method will leverage a disk cache to speed up generation. """ - self.__last_tts_loaded = dt_util.utcnow().isoformat() - self.async_write_ha_state() - return await self.async_get_tts_audio( - message=message, language=language, options=options + self._result_cache.set_result( + self._manager.async_cache_message_in_memory( + engine=self.engine, + message=message, + use_file_cache=self.use_file_cache, + language=self.language, + options=self.options, + ) ) - def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine.""" - raise NotImplementedError + @callback + def async_set_message_stream(self, message_stream: AsyncGenerator[str]) -> None: + """Set a stream that will generate the message. - async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine. - - Return a tuple of file extension and data as bytes. + This method can result in faster first byte when generating long responses. """ - return await self.hass.async_add_executor_job( - partial(self.get_tts_audio, message, language, options=options) + self._result_cache.set_result( + self._manager.async_cache_message_stream_in_memory( + engine=self.engine, + message_stream=message_stream, + language=self.language, + options=self.options, + ) ) + async def async_stream_result(self) -> AsyncGenerator[bytes]: + """Get the stream of this result.""" + cache = await self._result_cache + async for chunk in cache.async_stream_data(): + yield chunk + + self.last_used = monotonic() + def _hash_options(options: dict) -> str: """Hashes an options dictionary.""" @@ -521,29 +533,95 @@ def _hash_options(options: dict) -> str: return opts_hash.hexdigest() +class HasLastUsed(Protocol): + """Protocol for objects that have a last_used attribute.""" + + last_used: float + + +T = TypeVar("T", bound=HasLastUsed) + + +class DictCleaning(Generic[T]): + """Helper to clean up the stale sessions.""" + + unsub: CALLBACK_TYPE | None = None + + def __init__( + self, + hass: HomeAssistant, + maxage: float, + memcache: MutableMapping[str, T], + ) -> None: + """Initialize the cleanup.""" + self.hass = hass + self.maxage = maxage + self.memcache = memcache + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop) + self.cleanup_job = HassJob( + self._cleanup, "chat_session_cleanup", job_type=HassJobType.Callback + ) + + @callback + def schedule(self) -> None: + """Schedule the cleanup.""" + if self.unsub: + return + self.unsub = async_call_later( + self.hass, + self.maxage + 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 and schedule follow-up if necessary.""" + self.unsub = None + memcache = self.memcache + maxage = self.maxage + now = monotonic() + + for cache_key, info in list(memcache.items()): + if info.last_used + maxage < now: + _LOGGER.debug("Cleaning up %s", cache_key) + del memcache[cache_key] + + # Still items left, check again in timeout time. + if memcache: + self.schedule() + + class SpeechManager: """Representation of a speech store.""" def __init__( self, hass: HomeAssistant, - use_cache: bool, + use_file_cache: bool, cache_dir: str, - time_memory: int, + memory_cache_maxage: int, ) -> None: """Initialize a speech store.""" self.hass = hass self.providers: dict[str, Provider] = {} - self.use_cache = use_cache + self.use_file_cache = use_file_cache self.cache_dir = cache_dir - self.time_memory = time_memory + self.memory_cache_maxage = memory_cache_maxage self.file_cache: dict[str, str] = {} self.mem_cache: dict[str, TTSCache] = {} - - # filename <-> token - self.filename_to_token: dict[str, str] = {} - self.token_to_filename: dict[str, str] = {} + self.token_to_stream: dict[str, ResultStream] = {} + self.memcache_cleanup = DictCleaning(hass, memory_cache_maxage, self.mem_cache) + self.token_to_stream_cleanup = DictCleaning( + hass, memory_cache_maxage, self.token_to_stream + ) def _init_cache(self) -> dict[str, str]: """Init cache folder and fetch files.""" @@ -563,18 +641,21 @@ class SpeechManager: async def async_clear_cache(self) -> None: """Read file cache and delete files.""" - self.mem_cache = {} + self.mem_cache.clear() - def remove_files() -> None: + def remove_files(files: list[str]) -> None: """Remove files from filesystem.""" - for filename in self.file_cache.values(): + for filename in files: try: os.remove(os.path.join(self.cache_dir, filename)) except OSError as err: _LOGGER.warning("Can't remove cache file '%s': %s", filename, err) - await self.hass.async_add_executor_job(remove_files) - self.file_cache = {} + task = self.hass.async_add_executor_job( + remove_files, list(self.file_cache.values()) + ) + self.file_cache.clear() + await task @callback def async_register_legacy_engine( @@ -629,110 +710,212 @@ class SpeechManager: return language, merged_options - async def async_get_url_path( + @callback + def async_get_result_stream( + self, + token: str, + ) -> ResultStream | None: + """Return a result stream given a token.""" + stream = self.token_to_stream.get(token, None) + if stream: + stream.last_used = monotonic() + return stream + + @callback + def async_create_result_stream( self, engine: str, - message: str, - cache: bool | None = None, + use_file_cache: bool | None = None, language: str | None = None, options: dict | None = None, - ) -> str: - """Get URL for play message. + ) -> ResultStream: + """Create a streaming URL where the rendered TTS can be retrieved.""" + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") - This method is a coroutine. + supports_streaming_input = ( + isinstance(engine_instance, TextToSpeechEntity) + and engine_instance.async_supports_streaming_input() + ) + language, options = self.process_options(engine_instance, language, options) + if use_file_cache is None: + use_file_cache = self.use_file_cache + + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) + token = f"{secrets.token_urlsafe(16)}.{extension}" + content, _ = mimetypes.guess_type(token) + result_stream = ResultStream( + token=token, + extension=extension, + content_type=content or "audio/mpeg", + use_file_cache=use_file_cache, + engine=engine, + language=language, + options=options, + supports_streaming_input=supports_streaming_input, + _manager=self, + ) + self.token_to_stream[token] = result_stream + self.token_to_stream_cleanup.schedule() + return result_stream + + @callback + def async_cache_message_stream_in_memory( + self, + engine: str, + message_stream: AsyncGenerator[str], + language: str, + options: dict, + ) -> TTSCache: + """Make sure a message stream will be cached in memory and returns cache object. + + Requires options, language to be processed. """ if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise HomeAssistantError(f"Provider {engine} not found") - language, options = self.process_options(engine_instance, language, options) - cache_key = self._generate_cache_key(message, language, options, engine) - use_cache = cache if cache is not None else self.use_cache + cache_key = ulid_util.ulid_now() + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) + data_gen = self._async_generate_tts_audio( + engine_instance, message_stream, language, options + ) - # Is speech already in memory - if cache_key in self.mem_cache: - filename = self.mem_cache[cache_key]["filename"] - # Is file store in file cache - elif use_cache and cache_key in self.file_cache: - filename = self.file_cache[cache_key] - self.hass.async_create_task(self._async_file_to_mem(cache_key)) - # Load speech from engine into memory - else: - filename = await self._async_get_tts_audio( - engine_instance, cache_key, message, use_cache, language, options - ) + cache = TTSCache( + cache_key=cache_key, + extension=extension, + data_gen=data_gen, + ) + self.mem_cache[cache_key] = cache + self.hass.async_create_background_task( + self._load_data_into_cache( + cache, engine_instance, "[Streaming TTS]", False, language, options + ), + f"tts_load_data_into_cache_{engine_instance.name}", + ) + self.memcache_cleanup.schedule() + return cache - # Use a randomly generated token instead of exposing the filename - token = self.filename_to_token.get(filename) - if not token: - # Keep extension (.mp3, etc.) - token = secrets.token_urlsafe(16) + os.path.splitext(filename)[1] - - # Map token <-> filename - self.filename_to_token[filename] = token - self.token_to_filename[token] = filename - - return f"/api/tts_proxy/{token}" - - async def async_get_tts_audio( + @callback + def async_cache_message_in_memory( self, engine: str, message: str, - cache: bool | None = None, - language: str | None = None, - options: dict | None = None, - ) -> tuple[str, bytes]: - """Fetch TTS audio.""" + use_file_cache: bool, + language: str, + options: dict, + ) -> TTSCache: + """Make sure a message will be cached in memory and returns cache object. + + Requires options, language to be processed. + """ if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise HomeAssistantError(f"Provider {engine} not found") - language, options = self.process_options(engine_instance, language, options) - cache_key = self._generate_cache_key(message, language, options, engine) - use_cache = cache if cache is not None else self.use_cache - - # If we have the file, load it into memory if necessary - if cache_key not in self.mem_cache: - if use_cache and cache_key in self.file_cache: - await self._async_file_to_mem(cache_key) - else: - await self._async_get_tts_audio( - engine_instance, cache_key, message, use_cache, language, options - ) - - extension = os.path.splitext(self.mem_cache[cache_key]["filename"])[1][1:] - cached = self.mem_cache[cache_key] - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - return extension, cached["voice"] - - @callback - def _generate_cache_key( - self, - message: str, - language: str, - options: dict | None, - engine: str, - ) -> str: - """Generate a cache key for a message.""" options_key = _hash_options(options) if options else "-" msg_hash = hashlib.sha1(bytes(message, "utf-8")).hexdigest() - return KEY_PATTERN.format( + cache_key = KEY_PATTERN.format( msg_hash, language.replace("_", "-"), options_key, engine ).lower() - async def _async_get_tts_audio( + # Is speech already in memory + if cache := self.mem_cache.get(cache_key): + _LOGGER.debug("Found audio in cache for %s", message[0:32]) + return cache + + store_to_disk = use_file_cache + + if use_file_cache and (filename := self.file_cache.get(cache_key)): + _LOGGER.debug("Loading audio from disk for %s", message[0:32]) + extension = os.path.splitext(filename)[1][1:] + data_gen = self._async_load_file(cache_key) + store_to_disk = False + else: + _LOGGER.debug("Generating audio for %s", message[0:32]) + + async def message_stream() -> AsyncGenerator[str]: + yield message + + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) + data_gen = self._async_generate_tts_audio( + engine_instance, message_stream(), language, options + ) + + cache = TTSCache( + cache_key=cache_key, + extension=extension, + data_gen=data_gen, + ) + self.mem_cache[cache_key] = cache + self.hass.async_create_background_task( + self._load_data_into_cache( + cache, engine_instance, message, store_to_disk, language, options + ), + f"tts_load_data_into_cache_{engine_instance.name}", + ) + self.memcache_cleanup.schedule() + return cache + + async def _load_data_into_cache( + self, + cache: TTSCache, + engine_instance: TextToSpeechEntity | Provider, + message: str, + store_to_disk: bool, + language: str, + options: dict, + ) -> None: + """Load and process a finished loading TTS Cache.""" + try: + data = await cache.async_load_data() + except Exception as err: # pylint: disable=broad-except # noqa: BLE001 + # Truncate message so we don't flood the logs. Cutting off at 32 chars + # but since we add 3 dots to truncated message, we cut off at 35. + trunc_msg = message if len(message) < 35 else f"{message[0:32]}…" + _LOGGER.error("Error getting audio for %s: %s", trunc_msg, err) + self.mem_cache.pop(cache.cache_key, None) + return + + if not store_to_disk: + return + + filename = f"{cache.cache_key}.{cache.extension}".lower() + + # Validate filename + if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( + filename + ): + raise HomeAssistantError( + f"TTS filename '{filename}' from {engine_instance.name} is invalid!" + ) + + if cache.extension == "mp3": + name = ( + engine_instance.name if isinstance(engine_instance.name, str) else "-" + ) + data = self.write_tags(filename, data, name, message, language, options) + + voice_file = os.path.join(self.cache_dir, filename) + + def save_speech() -> None: + """Store speech to filesystem.""" + with open(voice_file, "wb") as speech: + speech.write(data) + + try: + await self.hass.async_add_executor_job(save_speech) + except OSError as err: + _LOGGER.error("Can't write %s: %s", filename, err) + else: + self.file_cache[cache.cache_key] = filename + + async def _async_generate_tts_audio( self, engine_instance: TextToSpeechEntity | Provider, - cache_key: str, - message: str, - cache: bool, + message_stream: AsyncGenerator[str], language: str, options: dict[str, Any], - ) -> str: - """Receive TTS, store for view in cache and return filename. - - This method is a coroutine. - """ + ) -> AsyncGenerator[bytes]: + """Generate TTS audio from an engine.""" options = dict(options or {}) supported_options = engine_instance.supported_options or [] @@ -773,114 +956,58 @@ class SpeechManager: if sample_bytes is not None: sample_bytes = int(sample_bytes) - async def get_tts_data() -> str: - """Handle data available.""" - if engine_instance.name is None or engine_instance.name is UNDEFINED: - raise HomeAssistantError("TTS engine name is not set.") + if engine_instance.name is None or engine_instance.name is UNDEFINED: + raise HomeAssistantError("TTS engine name is not set.") - if isinstance(engine_instance, Provider): - extension, data = await engine_instance.async_get_tts_audio( - message, language, options - ) - else: - extension, data = await engine_instance.internal_async_get_tts_audio( - message, language, options - ) + if isinstance(engine_instance, Provider): + message = "".join([chunk async for chunk in message_stream]) + extension, data = await engine_instance.async_get_tts_audio( + message, language, options + ) if data is None or extension is None: raise HomeAssistantError( f"No TTS from {engine_instance.name} for '{message}'" ) - # Only convert if we have a preferred format different than the - # expected format from the TTS system, or if a specific sample - # rate/format/channel count is requested. - needs_conversion = ( - (final_extension != extension) - or (sample_rate is not None) - or (sample_channels is not None) - or (sample_bytes is not None) + async def make_data_generator(data: bytes) -> AsyncGenerator[bytes]: + yield data + + data_gen = make_data_generator(data) + + else: + tts_result = await engine_instance.internal_async_stream_tts_audio( + TTSAudioRequest(language, options, message_stream) + ) + extension = tts_result.extension + data_gen = tts_result.data_gen + + # Only convert if we have a preferred format different than the + # expected format from the TTS system, or if a specific sample + # rate/format/channel count is requested. + needs_conversion = ( + (final_extension != extension) + or (sample_rate is not None) + or (sample_channels is not None) + or (sample_bytes is not None) + ) + + if needs_conversion: + data_gen = _async_convert_audio( + self.hass, + extension, + data_gen, + to_extension=final_extension, + to_sample_rate=sample_rate, + to_sample_channels=sample_channels, + to_sample_bytes=sample_bytes, ) - if needs_conversion: - data = await async_convert_audio( - self.hass, - extension, - data, - to_extension=final_extension, - to_sample_rate=sample_rate, - to_sample_channels=sample_channels, - to_sample_bytes=sample_bytes, - ) + async for chunk in data_gen: + yield chunk - # Create file infos - filename = f"{cache_key}.{final_extension}".lower() - - # Validate filename - if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( - filename - ): - raise HomeAssistantError( - f"TTS filename '{filename}' from {engine_instance.name} is invalid!" - ) - - # Save to memory - if final_extension == "mp3": - data = self.write_tags( - filename, data, engine_instance.name, message, language, options - ) - - self._async_store_to_memcache(cache_key, filename, data) - - if cache: - self.hass.async_create_task( - self._async_save_tts_audio(cache_key, filename, data) - ) - - return filename - - audio_task = self.hass.async_create_task(get_tts_data(), eager_start=False) - - def handle_error(_future: asyncio.Future) -> None: - """Handle error.""" - if audio_task.exception(): - self.mem_cache.pop(cache_key, None) - - audio_task.add_done_callback(handle_error) - - filename = f"{cache_key}.{final_extension}".lower() - self.mem_cache[cache_key] = { - "filename": filename, - "voice": b"", - "pending": audio_task, - } - return filename - - async def _async_save_tts_audio( - self, cache_key: str, filename: str, data: bytes - ) -> None: - """Store voice data to file and file_cache. - - This method is a coroutine. - """ - voice_file = os.path.join(self.cache_dir, filename) - - def save_speech() -> None: - """Store speech to filesystem.""" - with open(voice_file, "wb") as speech: - speech.write(data) - - try: - await self.hass.async_add_executor_job(save_speech) - self.file_cache[cache_key] = filename - except OSError as err: - _LOGGER.error("Can't write %s: %s", filename, err) - - async def _async_file_to_mem(self, cache_key: str) -> None: - """Load voice from file cache into memory. - - This method is a coroutine. - """ + async def _async_load_file(self, cache_key: str) -> AsyncGenerator[bytes]: + """Load TTS audio from disk.""" if not (filename := self.file_cache.get(cache_key)): raise HomeAssistantError(f"Key {cache_key} not in file cache!") @@ -897,64 +1024,7 @@ class SpeechManager: del self.file_cache[cache_key] raise HomeAssistantError(f"Can't read {voice_file}") from err - self._async_store_to_memcache(cache_key, filename, data) - - @callback - def _async_store_to_memcache( - self, cache_key: str, filename: str, data: bytes - ) -> None: - """Store data to memcache and set timer to remove it.""" - self.mem_cache[cache_key] = { - "filename": filename, - "voice": data, - "pending": None, - } - - @callback - def async_remove_from_mem(_: datetime) -> None: - """Cleanup memcache.""" - self.mem_cache.pop(cache_key, None) - - async_call_later( - self.hass, - self.time_memory, - HassJob( - async_remove_from_mem, - name="tts remove_from_mem", - cancel_on_shutdown=True, - ), - ) - - async def async_read_tts(self, token: str) -> tuple[str | None, bytes]: - """Read a voice file and return binary. - - This method is a coroutine. - """ - filename = self.token_to_filename.get(token) - if not filename: - raise HomeAssistantError(f"{token} was not recognized!") - - if not (record := _RE_VOICE_FILE.match(filename.lower())) and not ( - record := _RE_LEGACY_VOICE_FILE.match(filename.lower()) - ): - raise HomeAssistantError("Wrong tts file format!") - - cache_key = KEY_PATTERN.format( - record.group(1), record.group(2), record.group(3), record.group(4) - ) - - if cache_key not in self.mem_cache: - if cache_key not in self.file_cache: - raise HomeAssistantError(f"{cache_key} not in cache!") - await self._async_file_to_mem(cache_key) - - cached = self.mem_cache[cache_key] - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - - content, _ = mimetypes.guess_type(filename) - return content, cached["voice"] + yield data @staticmethod def write_tags( @@ -1042,9 +1112,9 @@ class TextToSpeechUrlView(HomeAssistantView): url = "/api/tts_get_url" name = "api:tts:geturl" - def __init__(self, tts: SpeechManager) -> None: + def __init__(self, manager: SpeechManager) -> None: """Initialize a tts view.""" - self.tts = tts + self.manager = manager async def post(self, request: web.Request) -> web.Response: """Generate speech and provide url.""" @@ -1061,45 +1131,66 @@ class TextToSpeechUrlView(HomeAssistantView): engine = data.get("engine_id") or data[ATTR_PLATFORM] message = data[ATTR_MESSAGE] - cache = data.get(ATTR_CACHE) + use_file_cache = data.get(ATTR_CACHE) language = data.get(ATTR_LANGUAGE) options = data.get(ATTR_OPTIONS) try: - path = await self.tts.async_get_url_path( - engine, message, cache=cache, language=language, options=options + stream = self.manager.async_create_result_stream( + engine, + use_file_cache=use_file_cache, + language=language, + options=options, ) except HomeAssistantError as err: _LOGGER.error("Error on init tts: %s", err) return self.json({"error": err}, HTTPStatus.BAD_REQUEST) - base = get_url(self.tts.hass) - url = base + path + stream.async_set_message(message) - return self.json({"url": url, "path": path}) + base = get_url(self.manager.hass) + url = base + stream.url + + return self.json({"url": url, "path": stream.url}) class TextToSpeechView(HomeAssistantView): """TTS view to serve a speech audio.""" requires_auth = False - url = "/api/tts_proxy/{filename}" + url = "/api/tts_proxy/{token}" name = "api:tts_speech" - def __init__(self, tts: SpeechManager) -> None: + def __init__(self, manager: SpeechManager) -> None: """Initialize a tts view.""" - self.tts = tts + self.manager = manager - async def get(self, request: web.Request, filename: str) -> web.Response: + async def get(self, request: web.Request, token: str) -> web.StreamResponse: """Start a get request.""" - try: - # filename is actually token, but we keep its name for compatibility - content, data = await self.tts.async_read_tts(filename) - except HomeAssistantError as err: - _LOGGER.error("Error on load tts: %s", err) + stream = self.manager.token_to_stream.get(token) + + if stream is None: return web.Response(status=HTTPStatus.NOT_FOUND) - return web.Response(body=data, content_type=content) + response: web.StreamResponse | None = None + try: + async for data in stream.async_stream_result(): + if response is None: + response = web.StreamResponse() + response.content_type = stream.content_type + await response.prepare(request) + + await response.write(data) + # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 + _LOGGER.error("Error streaming tts: %s", err) + + # Empty result or exception happened + if response is None: + return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) + + await response.write_eof() + return response @websocket_api.websocket_command( @@ -1133,6 +1224,9 @@ def websocket_list_engines( if entity.platform: entity_domains.add(entity.platform.platform_name) for engine_id, provider in hass.data[DATA_TTS_MANAGER].providers.items(): + if provider.has_entity: + continue + provider_info = { "engine_id": engine_id, "name": provider.name, diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py new file mode 100644 index 00000000000..1f01a41c5ab --- /dev/null +++ b/homeassistant/components/tts/entity.py @@ -0,0 +1,203 @@ +"""Entity for Text-to-Speech.""" + +from collections.abc import AsyncGenerator, Mapping +from dataclasses import dataclass +from functools import partial +from typing import Any, final + +from propcache.api import cached_property + +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, + MediaType, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + +from .const import TtsAudioType +from .media_source import generate_media_source_id +from .models import Voice + +CACHED_PROPERTIES_WITH_ATTR_ = { + "default_language", + "default_options", + "supported_languages", + "supported_options", +} + + +@dataclass +class TTSAudioRequest: + """Request to get TTS audio.""" + + language: str + options: dict[str, Any] + message_gen: AsyncGenerator[str] + + +@dataclass +class TTSAudioResponse: + """Response containing TTS audio stream.""" + + extension: str + data_gen: AsyncGenerator[bytes] + + +class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): + """Represent a single TTS engine.""" + + _attr_should_poll = False + __last_tts_loaded: str | None = None + + _attr_default_language: str + _attr_default_options: Mapping[str, Any] | None = None + _attr_supported_languages: list[str] + _attr_supported_options: list[str] | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the entity.""" + if self.__last_tts_loaded is None: + return None + return self.__last_tts_loaded + + @cached_property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return self._attr_supported_languages + + @cached_property + def default_language(self) -> str: + """Return the default language.""" + return self._attr_default_language + + @cached_property + def supported_options(self) -> list[str] | None: + """Return a list of supported options like voice, emotions.""" + return self._attr_supported_options + + @cached_property + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" + return self._attr_default_options + + @classmethod + def async_supports_streaming_input(cls) -> bool: + """Return if the TTS engine supports streaming input.""" + return ( + cls.async_stream_tts_audio is not TextToSpeechEntity.async_stream_tts_audio + ) + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return None + + async def async_internal_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + await super().async_internal_added_to_hass() + try: + _ = self.default_language + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + ) from err + try: + _ = self.supported_languages + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + ) from err + state = await self.async_get_last_state() + if ( + state is not None + and state.state is not None + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_tts_loaded = state.state + + async def async_speak( + self, + media_player_entity_id: list[str], + message: str, + cache: bool, + language: str | None = None, + options: dict | None = None, + ) -> None: + """Speak via a Media Player.""" + await self.hass.services.async_call( + DOMAIN_MP, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player_entity_id, + ATTR_MEDIA_CONTENT_ID: generate_media_source_id( + self.hass, + message=message, + engine=self.entity_id, + language=language, + options=options, + cache=cache, + ), + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + context=self._context, + ) + + @final + async def internal_async_stream_tts_audio( + self, request: TTSAudioRequest + ) -> TTSAudioResponse: + """Process an audio stream to TTS service. + + Only streaming content is allowed! + """ + self.__last_tts_loaded = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_stream_tts_audio(request) + + async def async_stream_tts_audio( + self, request: TTSAudioRequest + ) -> TTSAudioResponse: + """Generate speech from an incoming message. + + The default implementation is backwards compatible with async_get_tts_audio. + """ + message = "".join([chunk async for chunk in request.message_gen]) + extension, data = await self.async_get_tts_audio( + message, request.language, request.options + ) + + if extension is None or data is None: + raise HomeAssistantError(f"No TTS from {self.entity_id} for '{message}'") + + async def data_gen() -> AsyncGenerator[bytes]: + yield data + + return TTSAudioResponse(extension, data_gen()) + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine.""" + raise NotImplementedError + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine. + + Return a tuple of file extension and data as bytes. + """ + return await self.hass.async_add_executor_job( + partial(self.get_tts_audio, message, language, options=options) + ) diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 6f0541734d1..877ecc034d6 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -207,6 +207,7 @@ class Provider: hass: HomeAssistant | None = None name: str | None = None + has_entity: bool = False @property def default_language(self) -> str | None: diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 4f1fa59f001..f096e082364 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import mimetypes from typing import TypedDict from yarl import URL @@ -70,14 +69,20 @@ class MediaSourceOptions(TypedDict): """Media source options.""" engine: str - message: str language: str | None options: dict | None - cache: bool | None + use_file_cache: bool | None + + +class ParsedMediaSourceId(TypedDict): + """Parsed media source ID.""" + + options: MediaSourceOptions + message: str @callback -def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: +def parse_media_source_id(media_source_id: str) -> ParsedMediaSourceId: """Turn a media source ID into options.""" parsed = URL(media_source_id) if URL_QUERY_TTS_OPTIONS in parsed.query: @@ -95,15 +100,14 @@ def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: raise Unresolvable("No message specified.") kwargs: MediaSourceOptions = { "engine": parsed.name, - "message": parsed.query["message"], "language": parsed.query.get("language"), "options": options, - "cache": None, + "use_file_cache": None, } if "cache" in parsed.query: - kwargs["cache"] = parsed.query["cache"] == "true" + kwargs["use_file_cache"] = parsed.query["cache"] == "true" - return kwargs + return {"message": parsed.query["message"], "options": kwargs} class TTSMediaSource(MediaSource): @@ -119,17 +123,17 @@ class TTSMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" try: - url = await self.hass.data[DATA_TTS_MANAGER].async_get_url_path( - **media_source_id_to_kwargs(item.identifier) + parsed = parse_media_source_id(item.identifier) + stream = self.hass.data[DATA_TTS_MANAGER].async_create_result_stream( + **parsed["options"] ) + stream.async_set_message(parsed["message"]) except Unresolvable: raise except HomeAssistantError as err: raise Unresolvable(str(err)) from err - mime_type = mimetypes.guess_type(url)[0] or "audio/mpeg" - - return PlayMedia(url, mime_type) + return PlayMedia(stream.url, stream.content_type) async def async_browse_media( self, @@ -141,13 +145,20 @@ class TTSMediaSource(MediaSource): return self._engine_item(engine, params) # Root. List providers. - children = [ - self._engine_item(engine) - for engine in self.hass.data[DATA_TTS_MANAGER].providers - ] + [ - self._engine_item(entity.entity_id) - for entity in self.hass.data[DATA_COMPONENT].entities - ] + children = sorted( + [ + self._engine_item(engine_id) + for engine_id, provider in self.hass.data[ + DATA_TTS_MANAGER + ].providers.items() + if not provider.has_entity + ] + + [ + self._engine_item(entity.entity_id) + for entity in self.hass.data[DATA_COMPONENT].entities + ], + key=lambda x: x.title, + ) return BrowseMediaSource( domain=DOMAIN, identifier=None, @@ -169,7 +180,7 @@ class TTSMediaSource(MediaSource): raise BrowseError("Unknown provider") if isinstance(engine_instance, TextToSpeechEntity): - engine_domain = engine_instance.platform.domain + engine_domain = engine_instance.platform.platform_name else: engine_domain = engine diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index c8a639cd239..32119add5f4 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -3,7 +3,8 @@ from __future__ import annotations import logging -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple +from urllib.parse import urlsplit from tuya_sharing import ( CustomerDevice, @@ -11,6 +12,7 @@ from tuya_sharing import ( SharingDeviceListener, SharingTokenListener, ) +from tuya_sharing.mq import SharingMQ, SharingMQConfig from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -45,13 +47,81 @@ class HomeAssistantTuyaData(NamedTuple): listener: SharingDeviceListener +if TYPE_CHECKING: + import paho.mqtt.client as mqtt + + +class ManagerCompat(Manager): + """Extended Manager class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides extend refresh_mq method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def refresh_mq(self): + """Refresh the MQTT connection.""" + if self.mq is not None: + self.mq.stop() + self.mq = None + + home_ids = [home.id for home in self.user_homes] + device = [ + device + for device in self.device_map.values() + if hasattr(device, "id") and getattr(device, "set_up", False) + ] + + sharing_mq = SharingMQCompat(self.customer_api, home_ids, device) + sharing_mq.start() + sharing_mq.add_message_listener(self.on_message) + self.mq = sharing_mq + + +class SharingMQCompat(SharingMQ): + """Extended SharingMQ class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides _start method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def _start(self, mq_config: SharingMQConfig) -> mqtt.Client: + """Start the MQTT client.""" + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + mqttc = mqtt.Client(client_id=mq_config.client_id) + mqttc.username_pw_set(mq_config.username, mq_config.password) + mqttc.user_data_set({"mqConfig": mq_config}) + mqttc.on_connect = self._on_connect + mqttc.on_message = self._on_message + mqttc.on_subscribe = self._on_subscribe + mqttc.on_log = self._on_log + mqttc.on_disconnect = self._on_disconnect + + url = urlsplit(mq_config.url) + if url.scheme == "ssl": + mqttc.tls_set() + + mqttc.connect(url.hostname, url.port) + + mqttc.loop_start() + return mqttc + + async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool: """Async setup hass config entry.""" if CONF_APP_TYPE in entry.data: raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") token_listener = TokenListener(hass, entry) - manager = Manager( + manager = ManagerCompat( TUYA_CLIENT_ID, entry.data[CONF_USER_CODE], entry.data[CONF_TERMINAL_ID], diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 1e13f101110..486dd6e1387 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -291,6 +291,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { # Temperature and Humidity Sensor # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 "wsdcg": (TAMPER_BINARY_SENSOR,), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": (TAMPER_BINARY_SENSOR,), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 08bdef474ef..a40260ed787 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -333,6 +333,12 @@ class DPCode(StrEnum): TEMP_CONTROLLER = "temp_controller" TEMP_CURRENT = "temp_current" # Current temperature in °C TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F + TEMP_CURRENT_EXTERNAL = ( + "temp_current_external" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_F = ( + "temp_current_external_f" # Current external temperature in Fahrenheit + ) TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C TEMP_SET = "temp_set" # Set the temperature in °C TEMP_SET_F = "temp_set_f" # Set the temperature in °F diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index d94308ebd33..67a94c4e267 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -327,6 +327,18 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness_min=DPCode.BRIGHTNESS_MIN_1, ), ), + # Outdoor Flood Light + # Not documented + "tyd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), # Solar Light # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 "tyndj": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 073202bed94..9e40bda5d4d 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -454,6 +454,37 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, ), # Luminance Sensor # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 @@ -715,6 +746,47 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL, + translation_key="temperature_external", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( @@ -760,7 +832,6 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="total_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, subkey="power", ), TuyaSensorEntityDescription( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 8ec61cc8aa5..c6f6bfe9776 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -14,7 +14,7 @@ } }, "scan": { - "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login.\n\nContinue to the next step once you have completed this step in the app." + "description": "Use the Smart Life app or Tuya Smart app to scan the following QR code to complete the login.\n\nContinue to the next step once you have completed this step in the app." } }, "error": { @@ -288,9 +288,9 @@ "motion_sensitivity": { "name": "Motion detection sensitivity", "state": { - "0": "Low sensitivity", - "1": "Medium sensitivity", - "2": "High sensitivity" + "0": "[%key:common::state::low%]", + "1": "[%key:common::state::medium%]", + "2": "[%key:common::state::high%]" } }, "record_mode": { @@ -321,9 +321,9 @@ "vacuum_cistern": { "name": "Water tank adjustment", "state": { - "low": "Low", + "low": "[%key:common::state::low%]", "middle": "Middle", - "high": "High", + "high": "[%key:common::state::high%]", "closed": "[%key:common::state::closed%]" } }, @@ -404,7 +404,7 @@ "humidifier_spray_mode": { "name": "Spray mode", "state": { - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "health": "Health", "sleep": "Sleep", "humidity": "Humidity", @@ -469,6 +469,9 @@ "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, + "temperature_external": { + "name": "Probe temperature" + }, "humidity": { "name": "[%key:component::sensor::entity_component::humidity::name%]" }, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 76d8b481a90..4000e8d9b24 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -612,6 +612,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), + # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), # Ceiling Light # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r "xdd": ( diff --git a/homeassistant/components/twilio/strings.json b/homeassistant/components/twilio/strings.json index 871711ff087..bfac7fa80b6 100644 --- a/homeassistant/components/twilio/strings.json +++ b/homeassistant/components/twilio/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up the Twilio Webhook", + "title": "Set up the Twilio webhook", "description": "[%key:common::config_flow::description::confirm_setup%]" } }, @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to setup [Webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "default": "To send events to Home Assistant, you will need to set up a [webhook with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } } } diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index b06d0e24891..a52750282df 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -32,6 +32,7 @@ ATTR_NEXT_BUSES = "next_buses" ATTR_STATION_CODE = "station_code" ATTR_CALLING_AT = "calling_at" ATTR_NEXT_TRAINS = "next_trains" +ATTR_LAST_UPDATED = "last_updated" CONF_API_APP_KEY = "app_key" CONF_API_APP_ID = "app_id" @@ -162,7 +163,7 @@ class UkTransportLiveBusTimeSensor(UkTransportSensor): self._destination_re = re.compile(f"{bus_direction}", re.IGNORECASE) sensor_name = f"Next bus to {bus_direction}" - stop_url = f"bus/stop/{stop_atcocode}/live.json" + stop_url = f"bus/stop/{stop_atcocode}.json" UkTransportSensor.__init__(self, sensor_name, api_app_id, api_app_key, stop_url) self.update = Throttle(interval)(self._update) @@ -199,7 +200,9 @@ class UkTransportLiveBusTimeSensor(UkTransportSensor): def extra_state_attributes(self) -> dict[str, Any] | None: """Return other details about the sensor state.""" if self._data is not None: - attrs = {ATTR_NEXT_BUSES: self._next_buses} + attrs = { + ATTR_NEXT_BUSES: self._next_buses, + } for key in ( ATTR_ATCOCODE, ATTR_LOCALITY, @@ -223,7 +226,7 @@ class UkTransportLiveTrainTimeSensor(UkTransportSensor): self._next_trains = [] sensor_name = f"Next train to {calling_at}" - query_url = f"train/station/{station_code}/live.json" + query_url = f"train/station/{station_code}.json" UkTransportSensor.__init__( self, sensor_name, api_app_id, api_app_key, query_url @@ -272,6 +275,7 @@ class UkTransportLiveTrainTimeSensor(UkTransportSensor): attrs = { ATTR_STATION_CODE: self._station_code, ATTR_CALLING_AT: self._calling_at, + ATTR_LAST_UPDATED: self._data[ATTR_REQUEST_TIME], } if self._next_trains: attrs[ATTR_NEXT_TRAINS] = self._next_trains diff --git a/homeassistant/components/ukraine_alarm/coordinator.py b/homeassistant/components/ukraine_alarm/coordinator.py index 267358e4aa6..b4e1decb1a1 100644 --- a/homeassistant/components/ukraine_alarm/coordinator.py +++ b/homeassistant/components/ukraine_alarm/coordinator.py @@ -52,7 +52,7 @@ class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): except aiohttp.ClientError as error: raise UpdateFailed(f"Error fetching alerts from API: {error}") from error - current = {alert_type: False for alert_type in ALERT_TYPES} + current = dict.fromkeys(ALERT_TYPES, False) for alert in res[0]["activeAlerts"]: current[alert["type"]] = True diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index b893b612f2a..71404ef4bc2 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN as UNIFI_DOMAIN, PLATFORMS, UNIFI_WIRELESS_CLIENTS +from .const import DOMAIN, PLATFORMS, UNIFI_WIRELESS_CLIENTS from .errors import AuthenticationRequired, CannotConnect from .hub import UnifiHub, get_unifi_api from .services import async_setup_services @@ -22,7 +22,7 @@ SAVE_DELAY = 10 STORAGE_KEY = "unifi_data" STORAGE_VERSION = 1 -CONFIG_SCHEMA = cv.config_entry_only_config_schema(UNIFI_DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 3878e4c60eb..c8c6a54f9fe 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -56,7 +56,7 @@ from .const import ( CONF_TRACK_DEVICES, CONF_TRACK_WIRED_CLIENTS, DEFAULT_DPI_RESTRICTIONS, - DOMAIN as UNIFI_DOMAIN, + DOMAIN, ) from .errors import AuthenticationRequired, CannotConnect from .hub import UnifiHub, get_unifi_api @@ -72,7 +72,7 @@ MODEL_PORTS = { } -class UnifiFlowHandler(ConfigFlow, domain=UNIFI_DOMAIN): +class UnifiFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a UniFi Network config flow.""" VERSION = 1 diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index a26232664a8..1084c29e75f 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -30,7 +30,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import UnifiConfigEntry -from .const import DOMAIN as UNIFI_DOMAIN +from .const import DOMAIN from .entity import ( HandlerT, UnifiEntity, @@ -204,14 +204,12 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) def update_unique_id(obj_id: str) -> None: """Rework unique ID.""" new_unique_id = f"{hub.site}-{obj_id}" - if ent_reg.async_get_entity_id( - DEVICE_TRACKER_DOMAIN, UNIFI_DOMAIN, new_unique_id - ): + if ent_reg.async_get_entity_id(DEVICE_TRACKER_DOMAIN, DOMAIN, new_unique_id): return unique_id = f"{obj_id}-{hub.site}" if entity_id := ent_reg.async_get_entity_id( - DEVICE_TRACKER_DOMAIN, UNIFI_DOMAIN, unique_id + DEVICE_TRACKER_DOMAIN, DOMAIN, unique_id ): ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) diff --git a/homeassistant/components/unifi/diagnostics.py b/homeassistant/components/unifi/diagnostics.py index 21174342594..49a9b678b0f 100644 --- a/homeassistant/components/unifi/diagnostics.py +++ b/homeassistant/components/unifi/diagnostics.py @@ -27,7 +27,7 @@ REDACT_DEVICES = { "x_ssh_hostkey_fingerprint", "x_vwirekey", } -REDACT_WLANS = {"bc_filter_list", "x_passphrase"} +REDACT_WLANS = {"bc_filter_list", "password", "x_passphrase"} @callback diff --git a/homeassistant/components/unifi/hub/api.py b/homeassistant/components/unifi/hub/api.py index acdd941dd15..8cfe06c1b55 100644 --- a/homeassistant/components/unifi/hub/api.py +++ b/homeassistant/components/unifi/hub/api.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import ssl -from types import MappingProxyType from typing import Any, Literal from aiohttp import CookieJar @@ -27,7 +27,7 @@ from ..errors import AuthenticationRequired, CannotConnect async def get_unifi_api( hass: HomeAssistant, - config: MappingProxyType[str, Any], + config: Mapping[str, Any], ) -> aiounifi.Controller: """Create a aiounifi object and verify authentication.""" ssl_context: ssl.SSLContext | Literal[False] = False diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f5ad99b72f7..dd255c57c13 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==82"], + "requirements": ["aiounifi==83"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index 9d4d92839fc..6cd652871d8 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from .const import DOMAIN as UNIFI_DOMAIN +from .const import DOMAIN SERVICE_RECONNECT_CLIENT = "reconnect_client" SERVICE_REMOVE_CLIENTS = "remove_clients" @@ -42,7 +42,7 @@ def async_setup_services(hass: HomeAssistant) -> None: for service in SUPPORTED_SERVICES: hass.services.async_register( - UNIFI_DOMAIN, + DOMAIN, service, async_call_unifi_service, schema=SERVICE_TO_SCHEMA.get(service), @@ -66,7 +66,7 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - if mac == "": return - for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN): + for config_entry in hass.config_entries.async_loaded_entries(DOMAIN): if ( (not (hub := config_entry.runtime_data).available) or (client := hub.api.clients.get(mac)) is None @@ -84,7 +84,7 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> - Total time between first seen and last seen is less than 15 minutes. - Neither IP, hostname nor name is configured. """ - for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN): + for config_entry in hass.config_entries.async_loaded_entries(DOMAIN): if not (hub := config_entry.runtime_data).available: continue diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 8f4f2b420a5..5b88055e62a 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -117,7 +117,7 @@ }, "remove_clients": { "name": "Remove clients from the UniFi Network", - "description": "Cleans up clients that has only been associated with the controller for a short period of time." + "description": "Cleans up clients that have only been associated with the controller for a short period of time." } } } diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a4bb6d20841..e23568480ca 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==7.5.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.6.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index e5da4c4d621..b40388be71b 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.6.0"] + "requirements": ["upb-lib==0.6.1"] } diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 0ff8c448197..47cc5aa369b 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -226,7 +226,7 @@ class UpdateEntity( _attr_installed_version: str | None = None _attr_device_class: UpdateDeviceClass | None _attr_display_precision: int - _attr_in_progress: bool | int = False + _attr_in_progress: bool = False _attr_latest_version: str | None = None _attr_release_summary: str | None = None _attr_release_url: str | None = None @@ -295,7 +295,7 @@ class UpdateEntity( ) @cached_property - def in_progress(self) -> bool | int | None: + def in_progress(self) -> bool | None: """Update installation progress. Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used. @@ -442,7 +442,7 @@ class UpdateEntity( 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): - update_percentage = in_progress + update_percentage = in_progress # type: ignore[unreachable] in_progress = True else: in_progress = self.__in_progress diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index df4daa8782c..62ee4ede7d9 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.43.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index b8619b1fe39..e5829882200 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -4,19 +4,17 @@ from __future__ import annotations from pyuptimerobot import UptimeRobot -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.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS -from .coordinator import UptimeRobotDataUpdateCoordinator +from .const import PLATFORMS +from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: UptimeRobotConfigEntry) -> bool: """Set up UptimeRobot from a config entry.""" - hass.data.setdefault(DOMAIN, {}) key: str = entry.data[CONF_API_KEY] if key.startswith(("ur", "m")): raise ConfigEntryAuthFailed( @@ -24,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) uptime_robot_api = UptimeRobot(key, async_get_clientsession(hass)) - hass.data[DOMAIN][entry.entry_id] = coordinator = UptimeRobotDataUpdateCoordinator( + coordinator = UptimeRobotDataUpdateCoordinator( hass, entry, api=uptime_robot_api, @@ -32,15 +30,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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: UptimeRobotConfigEntry +) -> 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/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 73f9400c013..e8803b6ad89 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -7,22 +7,23 @@ 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 AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot binary_sensors.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( UptimeRobotBinarySensor( coordinator, @@ -42,4 +43,4 @@ class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return True if the entity is on.""" - return self.monitor_available + return bool(self.monitor.status == 2) diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index ffe3c3e4563..5fc165c0f27 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -44,11 +44,9 @@ class UptimeRobotConfigFlow(ConfigFlow, domain=DOMAIN): try: response = await uptime_robot_api.async_get_account_details() - except UptimeRobotAuthenticationException as exception: - LOGGER.error(exception) + except UptimeRobotAuthenticationException: errors["base"] = "invalid_api_key" - except UptimeRobotException as exception: - LOGGER.error(exception) + except UptimeRobotException: errors["base"] = "cannot_connect" except Exception as exception: # noqa: BLE001 LOGGER.exception(exception) diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py index fbadc237965..2f6225fa498 100644 --- a/homeassistant/components/uptimerobot/coordinator.py +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -17,16 +17,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER +type UptimeRobotConfigEntry = ConfigEntry[UptimeRobotDataUpdateCoordinator] + class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]): """Data update coordinator for UptimeRobot.""" - config_entry: ConfigEntry + config_entry: UptimeRobotConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UptimeRobotConfigEntry, api: UptimeRobot, ) -> None: """Initialize coordinator.""" diff --git a/homeassistant/components/uptimerobot/diagnostics.py b/homeassistant/components/uptimerobot/diagnostics.py index 23c65373045..c3c2acbfbf1 100644 --- a/homeassistant/components/uptimerobot/diagnostics.py +++ b/homeassistant/components/uptimerobot/diagnostics.py @@ -6,19 +6,17 @@ from typing import Any from pyuptimerobot import UptimeRobotException -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry async def async_get_config_entry_diagnostics( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data account: dict[str, Any] | str | None = None try: response = await coordinator.api.async_get_account_details() diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index 71f7a2f1c00..a27d4a6f80e 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -59,8 +59,3 @@ class UptimeRobotEntity(CoordinatorEntity[UptimeRobotDataUpdateCoordinator]): ), self._monitor, ) - - @property - def monitor_available(self) -> bool: - """Returtn if the monitor is available.""" - return bool(self.monitor.status == 2) diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index 67e57f46986..6fe8083ffc6 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/uptimerobot", "iot_class": "cloud_polling", "loggers": ["pyuptimerobot"], + "quality_scale": "bronze", "requirements": ["pyuptimerobot==22.2.0"] } diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml new file mode 100644 index 00000000000..43076320b8f --- /dev/null +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no 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: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: 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: todo + comment: we should not swallow the exception in switch.py + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: todo + comment: Change the type of the coordinator data to be a dict[str, UptimeRobotMonitor] so we can just do a dict look up instead of iterating over the whole list + 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: device not discoverable + discovery: + status: exempt + comment: device not discoverable + docs-data-update: done + docs-examples: done + docs-known-limitations: + status: exempt + comment: no known limitations, yet + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: todo + comment: create entities on runtime instead of triggering a reload + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: no known use case + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: todo + comment: handle API key change/update + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: todo + comment: We should remove the config entry from the device rather than remove the device + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: + status: todo + comment: Requirement 'pyuptimerobot==22.2.0' appears untyped diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 724c3075a3b..3ed97d17508 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -7,13 +7,11 @@ 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 AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity SENSORS_INFO = { @@ -24,14 +22,17 @@ SENSORS_INFO = { 9: "down", } +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot sensors.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( UptimeRobotSensor( coordinator, diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index 588dc3ebf5c..6bcd1554b16 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -2,16 +2,20 @@ "config": { "step": { "user": { - "description": "You need to supply the 'main' API key from UptimeRobot", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The 'main' API key for your UptimeRobot account" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "You need to supply a new 'main' API key from UptimeRobot", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::uptimerobot::config::step::user::data_description::api_key%]" } } }, diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 31401ac7eb4..9b25570393a 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -11,22 +11,24 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import API_ATTR_OK, DOMAIN, LOGGER -from .coordinator import UptimeRobotDataUpdateCoordinator +from .const import API_ATTR_OK, LOGGER +from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity +# Limit the number of parallel updates to 1 +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot switches.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( UptimeRobotSwitch( coordinator, diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index d68742522a0..90433b0f728 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -14,8 +14,6 @@ import sys from typing import Any, overload from aiousbwatcher import AIOUSBWatcher, InotifyNotAvailableError -from serial.tools.list_ports import comports -from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol from homeassistant import config_entries @@ -43,7 +41,10 @@ from homeassistant.loader import USBMatcher, async_get_usb from .const import DOMAIN from .models import USBDevice -from .utils import usb_device_from_port +from .utils import ( + scan_serial_ports, + usb_device_from_port, # noqa: F401 +) _LOGGER = logging.getLogger(__name__) @@ -241,6 +242,13 @@ def _is_matching(device: USBDevice, matcher: USBMatcher | USBCallbackMatcher) -> return True +async def async_request_scan(hass: HomeAssistant) -> None: + """Request a USB scan.""" + usb_discovery: USBDiscovery = hass.data[DOMAIN] + if not usb_discovery.observer_active: + await usb_discovery.async_request_scan() + + class USBDiscovery: """Manage USB Discovery.""" @@ -265,8 +273,15 @@ class USBDiscovery: async def async_setup(self) -> None: """Set up USB Discovery.""" - if self._async_supports_monitoring(): - await self._async_start_monitor() + try: + await self._async_start_aiousbwatcher() + except InotifyNotAvailableError as ex: + _LOGGER.info( + "Falling back to periodic filesystem polling for development, " + "aiousbwatcher is not available on this system: %s", + ex, + ) + self._async_start_monitor_polling() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) @@ -281,22 +296,6 @@ class USBDiscovery: if self._request_debouncer: self._request_debouncer.async_shutdown() - @hass_callback - def _async_supports_monitoring(self) -> bool: - return sys.platform == "linux" - - async def _async_start_monitor(self) -> None: - """Start monitoring hardware.""" - try: - await self._async_start_aiousbwatcher() - except InotifyNotAvailableError as ex: - _LOGGER.info( - "Falling back to periodic filesystem polling for development, aiousbwatcher " - "is not available on this system: %s", - ex, - ) - self._async_start_monitor_polling() - @hass_callback def _async_start_monitor_polling(self) -> None: """Start monitoring hardware with polling (for development only!).""" @@ -426,14 +425,8 @@ class USBDiscovery: service_info, ) - async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: + async def _async_process_ports(self, usb_devices: Sequence[USBDevice]) -> None: """Process each discovered port.""" - _LOGGER.debug("Processing ports: %r", ports) - usb_devices = { - usb_device_from_port(port) - for port in ports - if port.vid is not None or port.pid is not None - } _LOGGER.debug("USB devices: %r", usb_devices) # CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and @@ -445,7 +438,7 @@ class USBDiscovery: if dev.device.startswith("/dev/cu.SLAB_USBtoUART") } - usb_devices = { + filtered_usb_devices = { dev for dev in usb_devices if dev.serial_number not in silabs_serials @@ -454,10 +447,12 @@ class USBDiscovery: and dev.device.startswith("/dev/cu.SLAB_USBtoUART") ) } + else: + filtered_usb_devices = set(usb_devices) - added_devices = usb_devices - self._last_processed_devices - removed_devices = self._last_processed_devices - usb_devices - self._last_processed_devices = usb_devices + added_devices = filtered_usb_devices - self._last_processed_devices + removed_devices = self._last_processed_devices - filtered_usb_devices + self._last_processed_devices = filtered_usb_devices _LOGGER.debug( "Added devices: %r, removed devices: %r", added_devices, removed_devices @@ -470,7 +465,7 @@ class USBDiscovery: except Exception: _LOGGER.exception("Error in USB port event callback") - for usb_device in usb_devices: + for usb_device in filtered_usb_devices: await self._async_process_discovered_usb_device(usb_device) @hass_callback @@ -492,7 +487,7 @@ class USBDiscovery: _LOGGER.debug("Executing comports scan") async with self._scan_lock: await self._async_process_ports( - await self.hass.async_add_executor_job(comports) + await self.hass.async_add_executor_job(scan_serial_ports) ) if self.initial_scan_done: return @@ -530,9 +525,7 @@ async def websocket_usb_scan( msg: dict[str, Any], ) -> None: """Scan for new usb devices.""" - usb_discovery: USBDiscovery = hass.data[DOMAIN] - if not usb_discovery.observer_active: - await usb_discovery.async_request_scan() + await async_request_scan(hass) connection.send_result(msg["id"]) diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py index d1d6fb17f3c..1bb620ec5f7 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -2,6 +2,9 @@ from __future__ import annotations +from collections.abc import Sequence + +from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo from .models import USBDevice @@ -17,3 +20,12 @@ def usb_device_from_port(port: ListPortInfo) -> USBDevice: manufacturer=port.manufacturer, description=port.description, ) + + +def scan_serial_ports() -> Sequence[USBDevice]: + """Scan serial ports for USB devices.""" + return [ + usb_device_from_port(port) + for port in comports() + if port.vid is not None or port.pid is not None + ] diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 425dfa2c3fd..cda538386c1 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -577,10 +577,10 @@ class UtilityMeterSensor(RestoreSensor): async def _async_reset_meter(self, event): """Reset the utility meter status.""" - await self._program_reset() - await self.async_reset_meter(self._tariff_entity) + await self._program_reset() + async def async_reset_meter(self, entity_id): """Reset meter.""" if self._tariff_entity is not None and self._tariff_entity != entity_id: diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 1efaf87e748..f9e7a2844cd 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -23,7 +23,7 @@ "state": { "cleaning": "Cleaning", "docked": "Docked", - "error": "Error", + "error": "[%key:common::state::error%]", "idle": "[%key:common::state::idle%]", "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py index 30d1d153d9e..c7e6af8891a 100644 --- a/homeassistant/components/vallox/config_flow.py +++ b/homeassistant/components/vallox/config_flow.py @@ -108,7 +108,7 @@ class ValloxConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_HOST] = "invalid_host" except ValloxApiException: errors[CONF_HOST] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors[CONF_HOST] = "unknown" else: diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index f00206826d3..2a074cf2015 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -152,8 +152,8 @@ "selector": { "profile": { "options": { - "home": "Home", - "away": "Away", + "home": "[%key:common::state::home%]", + "away": "[%key:common::state::not_home%]", "boost": "Boost", "fireplace": "Fireplace", "extra": "Extra" diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index b86ec371b34..39dc297fe7d 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -5,10 +5,10 @@ "name": "[%key:component::valve::title%]", "state": { "open": "[%key:common::state::open%]", - "opening": "Opening", + "opening": "[%key:common::state::opening%]", "closed": "[%key:common::state::closed%]", - "closing": "Closing", - "stopped": "Stopped" + "closing": "[%key:common::state::closing%]", + "stopped": "[%key:common::state::stopped%]" }, "state_attributes": { "current_position": { diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index fc5da92588a..7c93d8784ad 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -63,7 +63,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self._device = "tls://" else: self._device = "" - if user_input[CONF_PASSWORD] != "": + if CONF_PASSWORD in user_input and user_input[CONF_PASSWORD] != "": self._device += f"{user_input[CONF_PASSWORD]}@" self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" self._async_abort_entries_match({CONF_PORT: self._device}) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 29504277651..d64a1361987 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.1.1"], + "requirements": ["velbus-aio==2025.5.0"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index a50395af115..4ef7ccf62c2 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -2,10 +2,11 @@ "config": { "step": { "user": { - "title": "Define the Velbus connection type", - "data": { - "name": "The name for this Velbus connection", - "port": "Connection string" + "title": "Define the Velbus connection", + "description": "How do you want to configure the Velbus hub?", + "menu_options": { + "network": "Via network connection", + "usbselect": "Via USB device" } }, "network": { @@ -20,7 +21,7 @@ "tls": "Enable this if you use a secure connection to your Velbus interface, like a Signum.", "host": "The IP address or hostname of the Velbus interface.", "port": "The port number of the Velbus interface.", - "password": "The password of the Velbus interface, this is only needed if the interface is password protected." + "password": "The password of the Velbus interface, this is only needed if the interface is password-protected." }, "description": "TCP/IP configuration, in case you use a Signum, VelServ, velbus-tcp or any other Velbus to TCP/IP interface." }, @@ -57,7 +58,7 @@ "services": { "sync_clock": { "name": "Sync clock", - "description": "Syncs the Velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", + "description": "Syncs the clock of the Velbus modules to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", "fields": { "interface": { "name": "Interface", @@ -103,7 +104,7 @@ }, "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.", + "description": "Sets the memo text to the display of modules like VMBGPO, VMBGPOD. Be sure the pages of the modules are configured to display the memo text.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index fdc75162651..1d916d0b8f6 100644 --- a/homeassistant/components/venstar/strings.json +++ b/homeassistant/components/venstar/strings.json @@ -32,7 +32,7 @@ "name": "Filter usage" }, "schedule_part": { - "name": "Schedule Part", + "name": "Schedule part", "state": { "morning": "Morning", "day": "Day", @@ -44,7 +44,7 @@ "active_stage": { "name": "Active stage", "state": { - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "first_stage": "First stage", "second_stage": "Second stage" } diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index 051f17262a0..6241225ed4e 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "data": { - "description": "Sign-in with your Verisure My Pages account.", + "description": "Sign in with your Verisure My Pages account.", "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } @@ -11,7 +11,7 @@ "mfa": { "data": { "description": "Your account has 2-step verification enabled. Please enter the verification code Verisure sends to you.", - "code": "Verification Code" + "code": "Verification code" } }, "installation": { @@ -37,7 +37,7 @@ "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "unknown_mfa": "Unknown error occurred during MFA set up" + "unknown_mfa": "Unknown error occurred during MFA setup" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", diff --git a/homeassistant/components/versasense/sensor.py b/homeassistant/components/versasense/sensor.py index 4c861bf5787..3956bd21fea 100644 --- a/homeassistant/components/versasense/sensor.py +++ b/homeassistant/components/versasense/sensor.py @@ -86,7 +86,7 @@ class VSensor(SensorEntity): return self._unit @property - def available(self): + def available(self) -> bool: """Return if the sensor is available.""" return self._available diff --git a/homeassistant/components/versasense/switch.py b/homeassistant/components/versasense/switch.py index 10bca79e536..828dbf6d9af 100644 --- a/homeassistant/components/versasense/switch.py +++ b/homeassistant/components/versasense/switch.py @@ -84,7 +84,7 @@ class VActuator(SwitchEntity): return self._is_on @property - def available(self): + def available(self) -> bool: """Return if the actuator is available.""" return self._available diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index f817c1d0714..6dda6800c62 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -9,7 +9,7 @@ from pyvesync.vesyncswitch import VeSyncWallSwitch from homeassistant.core import HomeAssistant -from .const import VeSyncHumidifierDevice +from .const import VeSyncFanDevice, VeSyncHumidifierDevice _LOGGER = logging.getLogger(__name__) @@ -58,6 +58,12 @@ def is_humidifier(device: VeSyncBaseDevice) -> bool: return isinstance(device, VeSyncHumidifierDevice) +def is_fan(device: VeSyncBaseDevice) -> bool: + """Check if the device represents a fan.""" + + return isinstance(device, VeSyncFanDevice) + + def is_outlet(device: VeSyncBaseDevice) -> bool: """Check if the device represents an outlet.""" diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 1273ab914f8..08db4463e07 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -1,6 +1,12 @@ """Constants for VeSync Component.""" -from pyvesync.vesyncfan import VeSyncHumid200300S, VeSyncSuperior6000S +from pyvesync.vesyncfan import ( + VeSyncAir131, + VeSyncAirBaseV2, + VeSyncAirBypass, + VeSyncHumid200300S, + VeSyncSuperior6000S, +) DOMAIN = "vesync" VS_DISCOVERY = "vesync_discovery_{}" @@ -30,13 +36,42 @@ VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" VS_HUMIDIFIER_MODE_MANUAL = "manual" VS_HUMIDIFIER_MODE_SLEEP = "sleep" +VS_FAN_MODE_AUTO = "auto" +VS_FAN_MODE_SLEEP = "sleep" +VS_FAN_MODE_ADVANCED_SLEEP = "advancedSleep" +VS_FAN_MODE_TURBO = "turbo" +VS_FAN_MODE_PET = "pet" +VS_FAN_MODE_MANUAL = "manual" +VS_FAN_MODE_NORMAL = "normal" + +# not a full list as manual is used as speed not present +VS_FAN_MODE_PRESET_LIST_HA = [ + VS_FAN_MODE_AUTO, + VS_FAN_MODE_SLEEP, + VS_FAN_MODE_ADVANCED_SLEEP, + VS_FAN_MODE_TURBO, + VS_FAN_MODE_PET, + VS_FAN_MODE_NORMAL, +] NIGHT_LIGHT_LEVEL_BRIGHT = "bright" NIGHT_LIGHT_LEVEL_DIM = "dim" NIGHT_LIGHT_LEVEL_OFF = "off" +FAN_NIGHT_LIGHT_LEVEL_DIM = "dim" +FAN_NIGHT_LIGHT_LEVEL_OFF = "off" +FAN_NIGHT_LIGHT_LEVEL_ON = "on" + +HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT = "bright" +HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim" +HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF = "off" + VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S """Humidifier device types""" +VeSyncFanDevice = VeSyncAirBypass | VeSyncAirBypass | VeSyncAirBaseV2 | VeSyncAir131 +"""Fan device types""" + + DEV_TYPE_TO_HA = { "wifi-switch-1.3": "outlet", "ESW03-USA": "outlet", @@ -93,6 +128,7 @@ SKU_TO_BASE_DEVICE = { "LAP-V102S-AASR": "Vital100S", # Alt ID Model Vital100S "LAP-V102S-WEU": "Vital100S", # Alt ID Model Vital100S "LAP-V102S-WUK": "Vital100S", # Alt ID Model Vital100S + "LAP-V102S-AUSR": "Vital100S", # Alt ID Model Vital100S "EverestAir": "EverestAir", "LAP-EL551S-AUS": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index daf734d50a8..d9336552744 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -11,6 +11,7 @@ from pyvesync.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.fan import FanEntity, FanEntityFeature 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 AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -19,43 +20,27 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range +from .common import is_fan from .const import ( - DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, + VS_FAN_MODE_ADVANCED_SLEEP, + VS_FAN_MODE_AUTO, + VS_FAN_MODE_MANUAL, + VS_FAN_MODE_NORMAL, + VS_FAN_MODE_PET, + VS_FAN_MODE_PRESET_LIST_HA, + VS_FAN_MODE_SLEEP, + VS_FAN_MODE_TURBO, ) from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) -FAN_MODE_AUTO = "auto" -FAN_MODE_SLEEP = "sleep" -FAN_MODE_PET = "pet" -FAN_MODE_TURBO = "turbo" -FAN_MODE_ADVANCED_SLEEP = "advancedSleep" -FAN_MODE_NORMAL = "normal" - - -PRESET_MODES = { - "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "Core200S": [FAN_MODE_SLEEP], - "Core300S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "Core600S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "EverestAir": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_TURBO], - "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], - "Vital100S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], - "SmartTowerFan": [ - FAN_MODE_ADVANCED_SLEEP, - FAN_MODE_AUTO, - FAN_MODE_TURBO, - FAN_MODE_NORMAL, - ], -} SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), "Core200S": (1, 3), @@ -97,13 +82,8 @@ def _setup_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) + async_add_entities(VeSyncFanHA(dev, coordinator) for dev in devices if is_fan(dev)) class VeSyncFanHA(VeSyncBaseEntity, FanEntity): @@ -118,13 +98,6 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): _attr_name = None _attr_translation_key = "vesync" - def __init__( - self, fan: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: - """Initialize the VeSync fan device.""" - super().__init__(fan, coordinator) - self.smartfan = fan - @property def is_on(self) -> bool: """Return True if device is on.""" @@ -134,8 +107,8 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): def percentage(self) -> int | None: """Return the current speed.""" if ( - self.smartfan.mode == "manual" - and (current_level := self.smartfan.fan_level) is not None + self.device.mode == VS_FAN_MODE_MANUAL + and (current_level := self.device.fan_level) is not None ): return ranged_value_to_percentage( SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], current_level @@ -152,13 +125,21 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): @property def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" - return PRESET_MODES[SKU_TO_BASE_DEVICE[self.device.device_type]] + if hasattr(self.device, "modes"): + return sorted( + [ + mode + for mode in self.device.modes + if mode in VS_FAN_MODE_PRESET_LIST_HA + ] + ) + return [] @property def preset_mode(self) -> str | None: """Get the current preset mode.""" - if self.smartfan.mode in (FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_TURBO): - return self.smartfan.mode + if self.device.mode in VS_FAN_MODE_PRESET_LIST_HA: + return self.device.mode return None @property @@ -166,65 +147,73 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): """Return the state attributes of the fan.""" attr = {} - if hasattr(self.smartfan, "active_time"): - attr["active_time"] = self.smartfan.active_time + if hasattr(self.device, "active_time"): + attr["active_time"] = self.device.active_time - if hasattr(self.smartfan, "screen_status"): - attr["screen_status"] = self.smartfan.screen_status + if hasattr(self.device, "screen_status"): + attr["screen_status"] = self.device.screen_status - if hasattr(self.smartfan, "child_lock"): - attr["child_lock"] = self.smartfan.child_lock + if hasattr(self.device, "child_lock"): + attr["child_lock"] = self.device.child_lock - if hasattr(self.smartfan, "night_light"): - attr["night_light"] = self.smartfan.night_light + if hasattr(self.device, "night_light"): + attr["night_light"] = self.device.night_light - if hasattr(self.smartfan, "mode"): - attr["mode"] = self.smartfan.mode + if hasattr(self.device, "mode"): + attr["mode"] = self.device.mode return attr def set_percentage(self, percentage: int) -> None: """Set the speed of the device.""" if percentage == 0: - self.smartfan.turn_off() - return + success = self.device.turn_off() + if not success: + raise HomeAssistantError("An error occurred while turning off.") + elif not self.device.is_on: + success = self.device.turn_on() + if not success: + raise HomeAssistantError("An error occurred while turning on.") - if not self.smartfan.is_on: - self.smartfan.turn_on() - - self.smartfan.manual_mode() - self.smartfan.change_fan_speed( + success = self.device.manual_mode() + if not success: + raise HomeAssistantError("An error occurred while manual mode.") + success = self.device.change_fan_speed( math.ceil( percentage_to_ranged_value( SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], percentage ) ) ) + if not success: + raise HomeAssistantError("An error occurred while changing fan speed.") self.schedule_update_ha_state() def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of device.""" - if preset_mode not in self.preset_modes: + if preset_mode not in VS_FAN_MODE_PRESET_LIST_HA: raise ValueError( f"{preset_mode} is not one of the valid preset modes: " - f"{self.preset_modes}" + f"{VS_FAN_MODE_PRESET_LIST_HA}" ) - if not self.smartfan.is_on: - self.smartfan.turn_on() + if not self.device.is_on: + self.device.turn_on() - if preset_mode == FAN_MODE_AUTO: - self.smartfan.auto_mode() - elif preset_mode == FAN_MODE_SLEEP: - self.smartfan.sleep_mode() - elif preset_mode == FAN_MODE_ADVANCED_SLEEP: - self.smartfan.advanced_sleep_mode() - elif preset_mode == FAN_MODE_PET: - self.smartfan.pet_mode() - elif preset_mode == FAN_MODE_TURBO: - self.smartfan.turbo_mode() - elif preset_mode == FAN_MODE_NORMAL: - self.smartfan.normal_mode() + if preset_mode == VS_FAN_MODE_AUTO: + success = self.device.auto_mode() + elif preset_mode == VS_FAN_MODE_SLEEP: + success = self.device.sleep_mode() + elif preset_mode == VS_FAN_MODE_ADVANCED_SLEEP: + success = self.device.advanced_sleep_mode() + elif preset_mode == VS_FAN_MODE_PET: + success = self.device.pet_mode() + elif preset_mode == VS_FAN_MODE_TURBO: + success = self.device.turbo_mode() + elif preset_mode == VS_FAN_MODE_NORMAL: + success = self.device.normal_mode() + if not success: + raise HomeAssistantError("An error occurred while setting preset mode.") self.schedule_update_ha_state() @@ -244,4 +233,7 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - self.device.turn_off() + success = self.device.turn_off() + if not success: + raise HomeAssistantError("An error occurred while turning off.") + self.schedule_update_ha_state() diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py index c266985fc2b..a9d2e1b533a 100644 --- a/homeassistant/components/vesync/select.py +++ b/homeassistant/components/vesync/select.py @@ -15,9 +15,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import rgetattr from .const import ( DOMAIN, - NIGHT_LIGHT_LEVEL_BRIGHT, - NIGHT_LIGHT_LEVEL_DIM, - NIGHT_LIGHT_LEVEL_OFF, + FAN_NIGHT_LIGHT_LEVEL_DIM, + FAN_NIGHT_LIGHT_LEVEL_OFF, + FAN_NIGHT_LIGHT_LEVEL_ON, + HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT, + HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM, + HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, @@ -27,14 +30,14 @@ from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) -VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP = { - 100: NIGHT_LIGHT_LEVEL_BRIGHT, - 50: NIGHT_LIGHT_LEVEL_DIM, - 0: NIGHT_LIGHT_LEVEL_OFF, +VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP = { + 100: HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT, + 50: HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM, + 0: HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF, } -HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP = { - v: k for k, v in VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.items() +HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP = { + v: k for k, v in VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.items() } @@ -48,20 +51,39 @@ class VeSyncSelectEntityDescription(SelectEntityDescription): SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [ + # night_light for humidifier VeSyncSelectEntityDescription( key="night_light_level", translation_key="night_light_level", - options=list(VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.values()), + options=list(VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.values()), icon="mdi:brightness-6", - exists_fn=lambda device: rgetattr(device, "night_light"), + exists_fn=lambda device: rgetattr(device, "set_night_light_brightness"), # The select_option service framework ensures that only options specified are # accepted. ServiceValidationError gets raised for invalid value. select_option_fn=lambda device, value: device.set_night_light_brightness( - HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) + HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) ), # Reporting "off" as the choice for unhandled level. - current_option_fn=lambda device: VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.get( - device.details.get("night_light_brightness"), NIGHT_LIGHT_LEVEL_OFF + current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get( + device.details.get("night_light_brightness"), + HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF, + ), + ), + # night_light for fan devices based on pyvesync.VeSyncAirBypass + VeSyncSelectEntityDescription( + key="night_light_level", + translation_key="night_light_level", + options=[ + FAN_NIGHT_LIGHT_LEVEL_OFF, + FAN_NIGHT_LIGHT_LEVEL_DIM, + FAN_NIGHT_LIGHT_LEVEL_ON, + ], + icon="mdi:brightness-6", + exists_fn=lambda device: rgetattr(device, "set_night_light"), + select_option_fn=lambda device, value: device.set_night_light(value), + current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get( + device.details.get("night_light"), + FAN_NIGHT_LIGHT_LEVEL_OFF, ), ), ] diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index eabb2969580..b74ebc4f00e 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -65,13 +65,19 @@ "name": "Mist level" } }, + "switch": { + "display": { + "name": "Display" + } + }, "select": { "night_light_level": { "name": "Night light level", "state": { "bright": "Bright", "dim": "Dim", - "off": "[%key:common::state::off%]" + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" } } }, @@ -80,7 +86,7 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "sleep": "Sleep", "advanced_sleep": "Advanced sleep", "pet": "Pet", diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 3e8deedb4ad..06fbd3606bd 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -14,10 +14,11 @@ from homeassistant.components.switch 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 AddConfigEntryEntitiesCallback -from .common import is_outlet, is_wall_switch +from .common import is_outlet, is_wall_switch, rgetattr from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -45,6 +46,14 @@ SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = ( on_fn=lambda device: device.turn_on(), off_fn=lambda device: device.turn_off(), ), + VeSyncSwitchEntityDescription( + key="display", + is_on=lambda device: device.display_state, + exists_fn=lambda device: rgetattr(device, "display_state") is not None, + translation_key="display", + on_fn=lambda device: device.turn_on_display(), + off_fn=lambda device: device.turn_off_display(), + ), ) @@ -111,10 +120,14 @@ class VeSyncSwitchEntity(SwitchEntity, VeSyncBaseEntity): def turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - if self.entity_description.off_fn(self.device): - self.schedule_update_ha_state() + if not self.entity_description.off_fn(self.device): + raise HomeAssistantError("An error occurred while turning off.") + + self.schedule_update_ha_state() def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - if self.entity_description.on_fn(self.device): - self.schedule_update_ha_state() + if not self.entity_description.on_fn(self.device): + raise HomeAssistantError("An error occurred while turning on.") + + self.schedule_update_ha_state() diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 902dfd18d30..a032b1fbbcb 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -112,6 +112,11 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getOneTimeCharge(), ), + ViCareBinarySensorEntityDescription( + key="device_error", + device_class=BinarySensorDeviceClass.PROBLEM, + value_getter=lambda api: len(api.getDeviceErrors()) > 0, + ), ) diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 26136260a4b..88d42503a03 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -127,6 +127,7 @@ class ViCareFan(ViCareEntity, FanEntity): _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) _attr_translation_key = "ventilation" + _attributes: dict[str, Any] = {} def __init__( self, @@ -155,7 +156,7 @@ class ViCareFan(ViCareEntity, FanEntity): self._attr_supported_features |= FanEntityFeature.SET_SPEED # evaluate quickmodes - quickmodes: list[str] = ( + self._attributes["vicare_quickmodes"] = quickmodes = list[str]( device.getVentilationQuickmodes() if is_supported( "getVentilationQuickmodes", @@ -196,20 +197,23 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + if VentilationQuickmode.STANDBY in self._attributes[ + "vicare_quickmodes" + ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): return False return self.percentage is not None and self.percentage > 0 def turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self._api.activateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) @property def icon(self) -> str | None: """Return the icon to use in the frontend.""" - if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + if VentilationQuickmode.STANDBY in self._attributes[ + "vicare_quickmodes" + ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): return "mdi:fan-off" if hasattr(self, "_attr_preset_mode"): if self._attr_preset_mode == VentilationMode.VENTILATION: @@ -236,7 +240,9 @@ class ViCareFan(ViCareEntity, FanEntity): """Set the speed of the fan, as a percentage.""" if self._attr_preset_mode != str(VentilationMode.PERMANENT): self.set_preset_mode(VentilationMode.PERMANENT) - elif self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + elif VentilationQuickmode.STANDBY in self._attributes[ + "vicare_quickmodes" + ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): self._api.deactivateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) @@ -248,3 +254,8 @@ class ViCareFan(ViCareEntity, FanEntity): target_mode = VentilationMode.to_vicare_mode(preset_mode) _LOGGER.debug("changing ventilation mode to %s", target_mode) self._api.activateVentilationMode(target_mode) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Show Device Attributes.""" + return self._attributes diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index e39adaf6c4c..fed777e6435 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.43.1"] + "requirements": ["PyViCare==2.44.0"] } diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 733cda363e5..dd8d93e609a 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "{name} ({host})", + "flow_title": "{name}", "step": { "user": { "description": "Set up ViCare integration. To generate client ID go to https://app.developer.viessmann.com", @@ -11,8 +11,8 @@ "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.", + "username": "The email address to log in to your ViCare account.", + "password": "The password to log in 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." } @@ -362,9 +362,9 @@ "ess_state": { "name": "Battery state", "state": { - "charge": "Charging", - "discharge": "Discharging", - "standby": "Standby" + "charge": "[%key:common::state::charging%]", + "discharge": "[%key:common::state::discharging%]", + "standby": "[%key:common::state::standby%]" } }, "ess_discharge_today": { @@ -412,7 +412,7 @@ "photovoltaic_status": { "name": "PV state", "state": { - "ready": "Standby", + "ready": "[%key:common::state::standby%]", "production": "Producing" } }, @@ -515,11 +515,11 @@ "services": { "set_vicare_mode": { "name": "Set ViCare mode", - "description": "Set a ViCare mode.", + "description": "Sets the mode of the climate device as defined by Viessmann.", "fields": { "vicare_mode": { "name": "ViCare mode", - "description": "ViCare mode." + "description": "For supported values, see the `vicare_modes` attribute of the climate entity." } } } diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py index cdba7f1b8c2..5612591c595 100644 --- a/homeassistant/components/vilfo/config_flow.py +++ b/homeassistant/components/vilfo/config_flow.py @@ -114,8 +114,8 @@ class DomainConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception as err: # noqa: BLE001 - _LOGGER.error("Unexpected exception: %s", err) + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: await self.async_set_unique_id(info[CONF_ID]) diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 871afe09a2e..5efc33ca882 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -1,28 +1,29 @@ """Vodafone Station integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import VodafoneStationRouter +from .coordinator import VodafoneConfigEntry, VodafoneStationRouter +from .utils import async_client_session PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: """Set up Vodafone Station platform.""" + session = await async_client_session(hass) coordinator = VodafoneStationRouter( hass, entry.data[CONF_HOST], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], entry, + session, ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -31,18 +32,17 @@ 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: VodafoneConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data await coordinator.api.logout() await coordinator.api.close() - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: VodafoneConfigEntry) -> None: """Update when config_entry options update.""" if entry.options: await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index 9812cef48d6..8dda4d49c7b 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -4,21 +4,32 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from json.decoder import JSONDecodeError from typing import Any, Final +from aiovodafone.exceptions import ( + AlreadyLogged, + CannotAuthenticate, + CannotConnect, + GenericLoginError, +) + from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN -from .coordinator import VodafoneStationRouter +from .coordinator import VodafoneConfigEntry, VodafoneStationRouter + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -68,13 +79,13 @@ BUTTON_TYPES: Final = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VodafoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up Vodafone Station buttons") - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors_data = coordinator.data.sensors @@ -106,4 +117,25 @@ class VodafoneStationSensorEntity( async def async_press(self) -> None: """Triggers the Shelly button press service.""" - await self.entity_description.press_action(self.coordinator) + + try: + await self.entity_description.press_action(self.coordinator) + except CannotAuthenticate as err: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + translation_placeholders={"error": repr(err)}, + ) from err + except ( + CannotConnect, + AlreadyLogged, + GenericLoginError, + JSONDecodeError, + ) as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_execute_action", + translation_placeholders={"error": repr(err)}, + ) from err diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 7a80244f8d6..b69078b8ce6 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -12,16 +12,13 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN +from .coordinator import VodafoneConfigEntry +from .utils import async_client_session def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: @@ -42,8 +39,9 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: """Validate the user input allows us to connect.""" + session = await async_client_session(hass) api = VodafoneStationSercommApi( - data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD] + data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD], session ) try: @@ -63,7 +61,7 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: VodafoneConfigEntry, ) -> VodafoneStationOptionsFlowHandler: """Get the options flow for this handler.""" return VodafoneStationOptionsFlowHandler() @@ -143,6 +141,45 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the device.""" + reconfigure_entry = self._get_reconfigure_entry() + if not user_input: + return self.async_show_form( + step_id="reconfigure", data_schema=user_form_schema(user_input) + ) + + updated_host = user_input[CONF_HOST] + + if reconfigure_entry.data[CONF_HOST] != updated_host: + self._async_abort_entries_match({CONF_HOST: updated_host}) + + errors: dict[str, str] = {} + + try: + await validate_input(self.hass, user_input) + except aiovodafone_exceptions.AlreadyLogged: + errors["base"] = "already_logged" + except aiovodafone_exceptions.CannotConnect: + errors["base"] = "cannot_connect" + except aiovodafone_exceptions.CannotAuthenticate: + errors["base"] = "invalid_auth" + except Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates={CONF_HOST: updated_host} + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=user_form_schema(user_input), + errors=errors, + ) + class VodafoneStationOptionsFlowHandler(OptionsFlow): """Handle a option flow.""" diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index cd640d10cb6..846d4b042c0 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -3,8 +3,9 @@ from dataclasses import dataclass from datetime import datetime, timedelta from json.decoder import JSONDecodeError -from typing import Any +from typing import Any, cast +from aiohttp import ClientSession from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME @@ -21,6 +22,8 @@ from .helpers import cleanup_device_tracker CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds() +type VodafoneConfigEntry = ConfigEntry[VodafoneStationRouter] + @dataclass(slots=True) class VodafoneStationDeviceInfo: @@ -42,7 +45,7 @@ class UpdateCoordinatorDataType: class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Queries router running Vodafone Station firmware.""" - config_entry: ConfigEntry + config_entry: VodafoneConfigEntry def __init__( self, @@ -50,12 +53,13 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): host: str, username: str, password: str, - config_entry: ConfigEntry, + config_entry: VodafoneConfigEntry, + session: ClientSession, ) -> None: """Initialize the scanner.""" self._host = host - self.api = VodafoneStationSercommApi(host, username, password) + self.api = VodafoneStationSercommApi(host, username, password, session) # Last resort as no MAC or S/N can be retrieved via API self._id = config_entry.unique_id @@ -120,14 +124,22 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): data_sensors = await self.api.get_sensor_data() await self.api.logout() except exceptions.CannotAuthenticate as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + translation_placeholders={"error": repr(err)}, + ) from err except ( exceptions.CannotConnect, exceptions.AlreadyLogged, exceptions.GenericLoginError, JSONDecodeError, ) as err: - raise UpdateFailed(f"Error fetching data: {err!r}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": repr(err)}, + ) from err except (ConfigEntryAuthFailed, UpdateFailed): await self.api.close() raise @@ -164,7 +176,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): @property def serial_number(self) -> str: """Device serial number.""" - return self.data.sensors["sys_serial_number"] + return cast(str, self.data.sensors["sys_serial_number"]) @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py index ece4bd05a02..4efa26cda8c 100644 --- a/homeassistant/components/vodafone_station/device_tracker.py +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -3,25 +3,31 @@ from __future__ import annotations from homeassistant.components.device_tracker import ScannerEntity -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 AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER, DOMAIN -from .coordinator import VodafoneStationDeviceInfo, VodafoneStationRouter +from .const import _LOGGER +from .coordinator import ( + VodafoneConfigEntry, + VodafoneStationDeviceInfo, + VodafoneStationRouter, +) + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VodafoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Vodafone Station component.""" _LOGGER.debug("Start device trackers setup") - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data tracked: set = set() diff --git a/homeassistant/components/vodafone_station/diagnostics.py b/homeassistant/components/vodafone_station/diagnostics.py index e306d6caca2..4778e7d5a4e 100644 --- a/homeassistant/components/vodafone_station/diagnostics.py +++ b/homeassistant/components/vodafone_station/diagnostics.py @@ -5,22 +5,20 @@ 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, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import VodafoneStationRouter +from .coordinator import VodafoneConfigEntry TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: VodafoneConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors_data = coordinator.data.sensors return { diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 4acafc8df3a..4c33cf1a4a5 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.6.1"] + "quality_scale": "platinum", + "requirements": ["aiovodafone==0.10.0"] } diff --git a/homeassistant/components/vodafone_station/quality_scale.yaml b/homeassistant/components/vodafone_station/quality_scale.yaml new file mode 100644 index 00000000000..d60020f5e47 --- /dev/null +++ b/homeassistant/components/vodafone_station/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no 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: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: 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: done + 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: device not discoverable + discovery: + status: exempt + comment: device not discoverable + docs-data-update: done + docs-examples: done + docs-known-limitations: + status: exempt + comment: no known limitations, yet + 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: + status: exempt + comment: no known use case + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index d29fb7f21e9..2573864330d 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -12,14 +12,16 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER, DOMAIN, LINE_TYPES -from .coordinator import VodafoneStationRouter +from .const import _LOGGER, LINE_TYPES +from .coordinator import VodafoneConfigEntry, VodafoneStationRouter + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] UPTIME_DEVIATION = 60 @@ -166,13 +168,13 @@ SENSOR_TYPES: Final = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VodafoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up Vodafone Station sensors") - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors_data = coordinator.data.sensors diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 8910d7178b7..958b774a485 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -3,9 +3,11 @@ "flow_title": "{host}", "step": { "reauth_confirm": { - "description": "Please enter the correct password for host: {host}", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::vodafone_station::config::step::user::data_description::password%]" } }, "user": { @@ -15,7 +17,21 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your Vodafone Station." + "host": "The hostname or IP address of your Vodafone Station.", + "username": "The username for your Vodafone Station.", + "password": "The password for your Vodafone Station." + } + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "[%key:component::vodafone_station::config::step::user::data_description::host%]", + "username": "[%key:component::vodafone_station::config::step::user::data_description::username%]", + "password": "[%key:component::vodafone_station::config::step::user::data_description::password%]" } } }, @@ -23,16 +39,17 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "already_logged": "User already logged-in, please try again later.", "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%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "model_not_supported": "The device model is currently unsupported.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { - "already_logged": "User already logged-in, please try again later.", + "already_logged": "[%key:component::vodafone_station::config::abort::already_logged%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "model_not_supported": "The device model is currently unsupported.", + "model_not_supported": "[%key:component::vodafone_station::config::abort::model_not_supported%]", "unknown": "[%key:common::config_flow::error::unknown%]" } }, @@ -41,20 +58,35 @@ "init": { "data": { "consider_home": "Seconds to consider a device at 'home'" + }, + "data_description": { + "consider_home": "The number of seconds to wait until marking a device as not home after it disconnects from the network." } } } }, "entity": { "button": { - "dsl_reconnect": { "name": "DSL reconnect" }, - "fiber_reconnect": { "name": "Fiber reconnect" }, - "internet_key_reconnect": { "name": "Internet key reconnect" } + "dsl_reconnect": { + "name": "DSL reconnect" + }, + "fiber_reconnect": { + "name": "Fiber reconnect" + }, + "internet_key_reconnect": { + "name": "Internet key reconnect" + } }, "sensor": { - "external_ipv4": { "name": "WAN IPv4 address" }, - "external_ipv6": { "name": "WAN IPv6 address" }, - "external_ip_key": { "name": "WAN internet key address" }, + "external_ipv4": { + "name": "WAN IPv4 address" + }, + "external_ipv6": { + "name": "WAN IPv6 address" + }, + "external_ip_key": { + "name": "WAN internet key address" + }, "active_connection": { "name": "Active connection", "state": { @@ -64,15 +96,44 @@ "internet_key": "Internet key" } }, - "down_stream": { "name": "WAN download rate" }, - "up_stream": { "name": "WAN upload rate" }, - "fw_version": { "name": "Firmware version" }, - "phone_num1": { "name": "Phone number (1)" }, - "phone_num2": { "name": "Phone number (2)" }, - "sys_uptime": { "name": "Uptime" }, - "sys_cpu_usage": { "name": "CPU usage" }, - "sys_memory_usage": { "name": "Memory usage" }, - "sys_reboot_cause": { "name": "Reboot cause" } + "down_stream": { + "name": "WAN download rate" + }, + "up_stream": { + "name": "WAN upload rate" + }, + "fw_version": { + "name": "Firmware version" + }, + "phone_num1": { + "name": "Phone number (1)" + }, + "phone_num2": { + "name": "Phone number (2)" + }, + "sys_uptime": { + "name": "Uptime" + }, + "sys_cpu_usage": { + "name": "CPU usage" + }, + "sys_memory_usage": { + "name": "Memory usage" + }, + "sys_reboot_cause": { + "name": "Reboot cause" + } + } + }, + "exceptions": { + "update_failed": { + "message": "Error fetching data: {error}" + }, + "cannot_execute_action": { + "message": "Cannot execute requested action: {error}" + }, + "cannot_authenticate": { + "message": "Error authenticating: {error}" } } } diff --git a/homeassistant/components/vodafone_station/utils.py b/homeassistant/components/vodafone_station/utils.py new file mode 100644 index 00000000000..4f900412faf --- /dev/null +++ b/homeassistant/components/vodafone_station/utils.py @@ -0,0 +1,13 @@ +"""Utils for Vodafone Station.""" + +from aiohttp import ClientSession, CookieJar + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + + +async def async_client_session(hass: HomeAssistant) -> ClientSession: + """Return a new aiohttp session.""" + return aiohttp_client.async_create_clientsession( + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + ) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index a0aeaaf38d3..7b34d7a11ba 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import timedelta from enum import IntFlag from functools import partial import io @@ -16,7 +17,7 @@ import wave from voip_utils import SIP_PORT, RtpDatagramProtocol from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint -from homeassistant.components import tts +from homeassistant.components import intent, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteAnnouncement, @@ -25,9 +26,11 @@ from homeassistant.components.assist_satellite import ( AssistSatelliteEntityDescription, AssistSatelliteEntityFeature, ) +from homeassistant.components.intent import TimerEventType, TimerInfo from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -48,9 +51,9 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) _PIPELINE_TIMEOUT_SEC: Final = 30 +_HANGUP_SEC: Final = 0.5 _ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 _ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 -_ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 _ANNOUNCEMENT_RING_TIMEOUT: Final = 30 @@ -98,6 +101,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None + _attr_icon = "mdi:phone-classic" _attr_supported_features = ( AssistSatelliteEntityFeature.ANNOUNCE | AssistSatelliteEntityFeature.START_CONVERSATION @@ -128,9 +132,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._processing_tone_done = asyncio.Event() self._announcement: AssistSatelliteAnnouncement | None = None - self._announcement_future: asyncio.Future[Any] = asyncio.Future() self._announcment_start_time: float = 0.0 - self._check_announcement_ended_task: asyncio.Task | None = None + self._check_announcement_pickup_task: asyncio.Task | None = None + self._check_hangup_task: asyncio.Task | None = None + self._call_end_future: asyncio.Future[Any] = asyncio.Future() self._last_chunk_time: float | None = None self._rtp_port: int | None = None self._run_pipeline_after_announce: bool = False @@ -160,6 +165,13 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol await super().async_added_to_hass() self.voip_device.protocol = self + assert self.device_entry is not None + self.async_on_remove( + intent.async_register_timer_handler( + self.hass, self.device_entry.id, self.async_handle_timer_event + ) + ) + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() @@ -173,6 +185,29 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Get the current satellite configuration.""" raise NotImplementedError + @callback + def async_handle_timer_event( + self, + event_type: TimerEventType, + timer_info: TimerInfo, + ) -> None: + """Handle timer event.""" + if event_type != TimerEventType.FINISHED: + return + + if timer_info.name: + message = f"{timer_info.name} finished" + else: + message = f"{timedelta(seconds=timer_info.created_seconds)} timer finished" + + async def announce_message(): + announcement = await self._resolve_announcement_media_id(message, None) + await self.async_announce(announcement) + + self.config_entry.async_create_background_task( + self.hass, announce_message(), "voip_announce_timer" + ) + async def async_set_configuration( self, config: AssistSatelliteConfiguration ) -> None: @@ -193,7 +228,13 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Optionally run a voice pipeline after the announcement has finished. """ - self._announcement_future = asyncio.Future() + if announcement.media_id_source != "tts": + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="non_tts_announcement", + ) + + self._call_end_future = asyncio.Future() self._run_pipeline_after_announce = run_pipeline_after if self._rtp_port is None: @@ -234,53 +275,77 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol rtp_port=self._rtp_port, ) - # Check if caller hung up or didn't pick up - self._check_announcement_ended_task = ( + # Check if caller didn't pick up + self._check_announcement_pickup_task = ( self.config_entry.async_create_background_task( self.hass, - self._check_announcement_ended(), - "voip_announcement_ended", + self._check_announcement_pickup(), + "voip_announcement_pickup", ) ) try: - await self._announcement_future + await self._call_end_future except TimeoutError: # Stop ringing + _LOGGER.debug("Caller did not pick up in time") sip_protocol.cancel_call(call_info) raise - async def _check_announcement_ended(self) -> None: + async def _check_announcement_pickup(self) -> None: """Continuously checks if an audio chunk was received within a time limit. - If not, the caller is presumed to have hung up and the announcement is ended. + If not, the caller is presumed to have not picked up the phone and the announcement is ended. """ - while self._announcement is not None: + while True: current_time = time.monotonic() if (self._last_chunk_time is None) and ( (current_time - self._announcment_start_time) > _ANNOUNCEMENT_RING_TIMEOUT ): # Ring timeout + _LOGGER.debug("Ring timeout") self._announcement = None - self._check_announcement_ended_task = None - self._announcement_future.set_exception( + self._check_announcement_pickup_task = None + self._call_end_future.set_exception( TimeoutError("User did not pick up in time") ) _LOGGER.debug("Timed out waiting for the user to pick up the phone") break - - if (self._last_chunk_time is not None) and ( - (current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC - ): - # Caller hung up - self._announcement = None - self._announcement_future.set_result(None) - self._check_announcement_ended_task = None - _LOGGER.debug("Announcement ended") + if self._last_chunk_time is not None: + _LOGGER.debug("Picked up the phone") + self._check_announcement_pickup_task = None break - await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2) + await asyncio.sleep(_HANGUP_SEC / 2) + + async def _check_hangup(self) -> None: + """Continuously checks if an audio chunk was received within a time limit. + + If not, the caller is presumed to have hung up and the call is ended. + """ + try: + while True: + current_time = time.monotonic() + if (self._last_chunk_time is not None) and ( + (current_time - self._last_chunk_time) > _HANGUP_SEC + ): + # Caller hung up + _LOGGER.debug("Hang up") + self._announcement = None + if self._run_pipeline_task is not None: + _LOGGER.debug("Cancelling running pipeline") + self._run_pipeline_task.cancel() + self._call_end_future.set_result(None) + self.disconnect() + break + + await asyncio.sleep(_HANGUP_SEC / 2) + except asyncio.CancelledError: + # Don't swallow cancellation + if (current_task := asyncio.current_task()) and current_task.cancelling(): + raise + _LOGGER.debug("Check hangup cancelled") async def async_start_conversation( self, start_announcement: AssistSatelliteAnnouncement @@ -292,6 +357,24 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol # VoIP # ------------------------------------------------------------------------- + def disconnect(self): + """Server disconnected.""" + super().disconnect() + if self._check_hangup_task is not None: + self._check_hangup_task.cancel() + self._check_hangup_task = None + + def connection_made(self, transport): + """Server is ready.""" + super().connection_made(transport) + self._last_chunk_time = time.monotonic() + # Check if caller hung up + self._check_hangup_task = self.config_entry.async_create_background_task( + self.hass, + self._check_hangup(), + "voip_hangup", + ) + def on_chunk(self, audio_bytes: bytes) -> None: """Handle raw audio chunk.""" self._last_chunk_time = time.monotonic() @@ -328,13 +411,22 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self.voip_device.set_is_active(True) async def stt_stream(): + retry: bool = True while True: - async with asyncio.timeout(self._audio_chunk_timeout): - chunk = await self._audio_queue.get() - if not chunk: - break + try: + async with asyncio.timeout(self._audio_chunk_timeout): + chunk = await self._audio_queue.get() + if not chunk: + _LOGGER.debug("STT stream got None") + break yield chunk + except TimeoutError: + _LOGGER.debug("STT Stream timed out") + if not retry: + _LOGGER.debug("No more retries, ending STT stream") + break + retry = False # Play listening tone at the start of each cycle await self._play_tone(Tones.LISTENING, silence_before=0.2) @@ -345,6 +437,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol ) if self._pipeline_had_error: + _LOGGER.debug("Pipeline error") self._pipeline_had_error = False await self._play_tone(Tones.ERROR) else: @@ -354,7 +447,14 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol # length of the TTS audio. await self._tts_done.wait() except TimeoutError: + # This shouldn't happen anymore, we are detecting hang ups with a separate task + _LOGGER.exception("Timeout error") self.disconnect() # caller hung up + except asyncio.CancelledError: + _LOGGER.debug("Pipeline cancelled") + # Don't swallow cancellation + if (current_task := asyncio.current_task()) and current_task.cancelling(): + raise finally: # Stop audio stream await self._audio_queue.put(None) @@ -369,10 +469,18 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Play an announcement once.""" _LOGGER.debug("Playing announcement") - try: - await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) - await self._send_tts(announcement.original_media_id, wait_for_tone=False) + if announcement.tts_token is None: + _LOGGER.error("Only TTS announcements are supported") + return + await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) + stream = tts.async_get_stream(self.hass, announcement.tts_token) + if stream is None: + _LOGGER.error("TTS stream no longer available") + return + + try: + await self._send_tts(stream, wait_for_tone=False) if not self._run_pipeline_after_announce: # Delay before looping announcement await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) @@ -385,8 +493,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol if self._run_pipeline_after_announce: # Clear announcement to allow pipeline to run + _LOGGER.debug("Clearing announcement") self._announcement = None - self._announcement_future.set_result(None) def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" @@ -403,34 +511,41 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol ) elif event.type == PipelineEventType.TTS_END: # Send TTS audio to caller over RTP - if event.data and (tts_output := event.data["tts_output"]): - media_id = tts_output["media_id"] + if ( + event.data + and (tts_output := event.data["tts_output"]) + and (stream := tts.async_get_stream(self.hass, tts_output["token"])) + ): self.config_entry.async_create_background_task( self.hass, - self._send_tts(media_id), + self._send_tts(tts_stream=stream), "voip_pipeline_tts", ) else: # Empty TTS response + _LOGGER.debug("Empty TTS response") self._tts_done.set() elif event.type == PipelineEventType.ERROR: # Play error tone instead of wait for TTS when pipeline is finished. self._pipeline_had_error = True _LOGGER.warning(event) - async def _send_tts(self, media_id: str, wait_for_tone: bool = True) -> None: + async def _send_tts( + self, + tts_stream: tts.ResultStream, + wait_for_tone: bool = True, + ) -> None: """Send TTS audio to caller via RTP.""" try: if self.transport is None: return # not connected - extension, data = await tts.async_get_media_source_audio( - self.hass, - media_id, - ) + data = b"".join([chunk async for chunk in tts_stream.async_stream_result()]) - if extension != "wav": - raise ValueError(f"Only WAV audio can be streamed, got {extension}") + if tts_stream.extension != "wav": + raise ValueError( + f"Only TTS WAV audio can be streamed, got {tts_stream.extension}" + ) if wait_for_tone and ((self._tones & Tones.PROCESSING) == Tones.PROCESSING): # Don't overlap TTS and processing beep diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index e3b2861dbe5..59e54bfefea 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -1,11 +1,12 @@ { "domain": "voip", "name": "Voice over IP", - "codeowners": ["@balloob", "@synesthesiam"], + "codeowners": ["@balloob", "@synesthesiam", "@jaminh"], "config_flow": true, - "dependencies": ["assist_pipeline", "assist_satellite", "network"], + "dependencies": ["assist_pipeline", "assist_satellite", "intent", "network"], "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", + "loggers": ["voip_utils"], "quality_scale": "internal", - "requirements": ["voip-utils==0.3.1"] + "requirements": ["voip-utils==0.3.2"] } diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index 96c902bf39a..4f37ad1d6f7 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -58,5 +58,10 @@ } } } + }, + "exceptions": { + "non_tts_announcement": { + "message": "VoIP does not currently support non-TTS announcements" + } } } diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index 63102646508..cda1f0ced3d 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wallbox", "iot_class": "cloud_polling", "loggers": ["wallbox"], - "requirements": ["wallbox==0.7.0"] + "requirements": ["wallbox==0.9.0"] } diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 462266636d7..a5880f6e0f7 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -71,9 +71,7 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_ICP_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_ICP_CURRENT_KEY, translation_key="maximum_icp_current", - max_value_fn=lambda coordinator: cast( - float, coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] - ), + max_value_fn=lambda _: 255, min_value_fn=lambda _: 6, set_value_fn=lambda coordinator: coordinator.async_set_icp_current, native_step=1, diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 07e132a0b5b..9cc3a84c3cd 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -14,8 +14,8 @@ "eco": "Eco", "electric": "Electric", "gas": "Gas", - "high_demand": "High Demand", - "heat_pump": "Heat Pump", + "high_demand": "High demand", + "heat_pump": "Heat pump", "performance": "Performance" }, "state_attributes": { diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 1e03ad88cc8..9153520e703 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from . import DOMAIN as WF_DOMAIN, UPDATE_TOPIC, WaterFurnaceData +from . import DOMAIN, UPDATE_TOPIC, WaterFurnaceData SENSORS = [ SensorEntityDescription(name="Furnace Mode", key="mode", icon="mdi:gauge"), @@ -104,7 +104,7 @@ def setup_platform( if discovery_info is None: return - client = hass.data[WF_DOMAIN] + client = hass.data[DOMAIN] add_entities(WaterFurnaceSensor(client, description) for description in SENSORS) diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py index c1747af1f11..4f075a57228 100644 --- a/homeassistant/components/watergate/__init__.py +++ b/homeassistant/components/watergate/__init__.py @@ -15,11 +15,13 @@ from homeassistant.components.webhook import ( Response, async_generate_url, async_register, + async_unregister, ) from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN +from .const import AUTO_SHUT_OFF_EVENT_NAME, DOMAIN from .coordinator import WatergateConfigEntry, WatergateDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,8 +30,10 @@ WEBHOOK_TELEMETRY_TYPE = "telemetry" WEBHOOK_VALVE_TYPE = "valve" WEBHOOK_WIFI_CHANGED_TYPE = "wifi-changed" WEBHOOK_POWER_SUPPLY_CHANGED_TYPE = "power-supply-changed" +WEBHOOK_AUTO_SHUT_OFF = "auto-shut-off-report" PLATFORMS: list[Platform] = [ + Platform.EVENT, Platform.SENSOR, Platform.VALVE, ] @@ -72,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> 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) + async_unregister(hass, webhook_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -120,6 +124,10 @@ def get_webhook_handler( coordinator_data.networking.rssi = data.rssi elif body_type == WEBHOOK_POWER_SUPPLY_CHANGED_TYPE: coordinator_data.state.power_supply = data.supply + elif body_type == WEBHOOK_AUTO_SHUT_OFF: + async_dispatcher_send( + hass, AUTO_SHUT_OFF_EVENT_NAME.format(data.type.lower()), data + ) coordinator.async_set_updated_data(coordinator_data) diff --git a/homeassistant/components/watergate/const.py b/homeassistant/components/watergate/const.py index 22a14330af9..c6726d9185f 100644 --- a/homeassistant/components/watergate/const.py +++ b/homeassistant/components/watergate/const.py @@ -3,3 +3,5 @@ DOMAIN = "watergate" MANUFACTURER = "Watergate" + +AUTO_SHUT_OFF_EVENT_NAME = "watergate_{}" diff --git a/homeassistant/components/watergate/event.py b/homeassistant/components/watergate/event.py new file mode 100644 index 00000000000..cf2447df4b3 --- /dev/null +++ b/homeassistant/components/watergate/event.py @@ -0,0 +1,78 @@ +"""Module contains the AutoShutOffEvent class for handling auto shut off events.""" + +from watergate_local_api.models.auto_shut_off_report import AutoShutOffReport + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WatergateConfigEntry +from .const import AUTO_SHUT_OFF_EVENT_NAME +from .coordinator import WatergateDataCoordinator +from .entity import WatergateEntity + +VOLUME_AUTO_SHUT_OFF = "volume_threshold" +DURATION_AUTO_SHUT_OFF = "duration_threshold" + + +DESCRIPTIONS: list[EventEntityDescription] = [ + EventEntityDescription( + translation_key="auto_shut_off_volume", + key="auto_shut_off_volume", + event_types=[VOLUME_AUTO_SHUT_OFF], + ), + EventEntityDescription( + translation_key="auto_shut_off_duration", + key="auto_shut_off_duration", + event_types=[DURATION_AUTO_SHUT_OFF], + ), +] + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WatergateConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Event entities from config entry.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + AutoShutOffEvent(coordinator, description) for description in DESCRIPTIONS + ) + + +class AutoShutOffEvent(WatergateEntity, EventEntity): + """Event for Auto Shut Off.""" + + def __init__( + self, + coordinator: WatergateDataCoordinator, + entity_description: EventEntityDescription, + ) -> None: + """Initialize Auto Shut Off Entity.""" + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + + async def async_added_to_hass(self): + """Register the callback for event handling when the entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + AUTO_SHUT_OFF_EVENT_NAME.format(self.event_types[0]), + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self, event: AutoShutOffReport) -> None: + self._trigger_event( + event.type.lower(), + {"volume": event.volume, "duration": event.duration}, + ) + self.async_write_ha_state() diff --git a/homeassistant/components/watergate/icons.json b/homeassistant/components/watergate/icons.json new file mode 100644 index 00000000000..28a0bfbc825 --- /dev/null +++ b/homeassistant/components/watergate/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "event": { + "auto_shut_off_volume": { + "default": "mdi:water" + }, + "auto_shut_off_duration": { + "default": "mdi:timelapse" + } + } + } +} diff --git a/homeassistant/components/watergate/quality_scale.yaml b/homeassistant/components/watergate/quality_scale.yaml index b116eff970e..73a39bd5264 100644 --- a/homeassistant/components/watergate/quality_scale.yaml +++ b/homeassistant/components/watergate/quality_scale.yaml @@ -17,10 +17,7 @@ rules: 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-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done diff --git a/homeassistant/components/watergate/strings.json b/homeassistant/components/watergate/strings.json index c312525e420..634e05e7973 100644 --- a/homeassistant/components/watergate/strings.json +++ b/homeassistant/components/watergate/strings.json @@ -19,6 +19,42 @@ } }, "entity": { + "event": { + "auto_shut_off_volume": { + "name": "Volume auto shut-off", + "state_attributes": { + "event_type": { + "state": { + "volume_threshold": "Volume", + "duration_threshold": "Duration" + } + }, + "volume": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]" + }, + "duration": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]" + } + } + }, + "auto_shut_off_duration": { + "name": "Duration auto shut-off", + "state_attributes": { + "event_type": { + "state": { + "volume_threshold": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]", + "duration_threshold": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]" + } + }, + "volume": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]" + }, + "duration": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]" + } + } + } + }, "sensor": { "water_meter_volume": { "name": "Water meter volume" diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index 683413236c1..10c04b3283b 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -267,16 +267,17 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( WeatherFlowSensorEntityDescription( key="wind_direction", translation_key="wind_direction", + device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), WeatherFlowSensorEntityDescription( key="wind_direction_average", translation_key="wind_direction_average", + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), ) diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 98c98cfbac7..9ffa457a355 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==1.0.6"] + "requirements": ["weatherflow4py==1.3.1"] } diff --git a/homeassistant/components/webdav/__init__.py b/homeassistant/components/webdav/__init__.py index 952a68d829f..36a03dce4d7 100644 --- a/homeassistant/components/webdav/__init__.py +++ b/homeassistant/components/webdav/__init__.py @@ -13,7 +13,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN -from .helpers import async_create_client, async_ensure_path_exists +from .helpers import ( + async_create_client, + async_ensure_path_exists, + async_migrate_wrong_folder_path, +) type WebDavConfigEntry = ConfigEntry[Client] @@ -46,10 +50,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bo translation_key="cannot_connect", ) + path = entry.data.get(CONF_BACKUP_PATH, "/") + await async_migrate_wrong_folder_path(client, path) + # Ensure the backup directory exists - if not await async_ensure_path_exists( - client, entry.data.get(CONF_BACKUP_PATH, "/") - ): + if not await async_ensure_path_exists(client, path): raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_access_or_create_backup_path", diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index a51866fde61..a9afb5fe930 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -5,10 +5,10 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import logging +from time import time from typing import Any, Concatenate from aiohttp import ClientTimeout -from aiowebdav2 import Property, PropertyRequest from aiowebdav2.exceptions import UnauthorizedError, WebDavError from propcache.api import cached_property @@ -28,8 +28,8 @@ from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) -METADATA_VERSION = "1" BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) +CACHE_TTL = 300 async def async_get_backup_agents( @@ -95,23 +95,6 @@ def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: return f"{base_name}.tar", f"{base_name}.metadata.json" -def _is_current_metadata_version(properties: list[Property]) -> bool: - """Check if any property is of the current metadata version.""" - return any( - prop.value == METADATA_VERSION - for prop in properties - if prop.namespace == "homeassistant" and prop.name == "metadata_version" - ) - - -def _backup_id_from_properties(properties: list[Property]) -> str | None: - """Return the backup ID from properties.""" - for prop in properties: - if prop.namespace == "homeassistant" and prop.name == "backup_id": - return prop.value - return None - - class WebDavBackupAgent(BackupAgent): """Backup agent interface.""" @@ -125,6 +108,8 @@ class WebDavBackupAgent(BackupAgent): self._client = entry.runtime_data self.name = entry.title self.unique_id = entry.entry_id + self._cache_metadata_files: dict[str, AgentBackup] = {} + self._cache_expiration = time() @cached_property def _backup_path(self) -> str: @@ -143,8 +128,6 @@ class WebDavBackupAgent(BackupAgent): :return: An async iterator that yields bytes. """ backup = await self._find_backup_by_id(backup_id) - if backup is None: - raise BackupNotFound("Backup not found") return await self._client.download_iter( f"{self._backup_path}/{suggested_filename(backup)}", @@ -170,6 +153,7 @@ class WebDavBackupAgent(BackupAgent): await open_stream(), f"{self._backup_path}/{filename_tar}", timeout=BACKUP_TIMEOUT, + content_length=backup.size, ) _LOGGER.debug( @@ -182,27 +166,14 @@ class WebDavBackupAgent(BackupAgent): f"{self._backup_path}/{filename_meta}", ) - await self._client.set_property_batch( - f"{self._backup_path}/{filename_meta}", - [ - Property( - namespace="homeassistant", - name="backup_id", - value=backup.backup_id, - ), - Property( - namespace="homeassistant", - name="metadata_version", - value=METADATA_VERSION, - ), - ], - ) - _LOGGER.debug( "Uploaded metadata file for %s", f"{self._backup_path}/{filename_meta}", ) + # reset cache + self._cache_expiration = time() + @handle_backup_errors async def async_delete_backup( self, @@ -214,8 +185,6 @@ class WebDavBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ backup = await self._find_backup_by_id(backup_id) - if backup is None: - return (filename_tar, filename_meta) = suggested_filenames(backup) backup_path = f"{self._backup_path}/{filename_tar}" @@ -228,56 +197,52 @@ class WebDavBackupAgent(BackupAgent): backup_path, ) + # reset cache + self._cache_expiration = time() + @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" - metadata_files = await self._list_metadata_files() - return [ - await self._download_metadata(metadata_file) - for metadata_file in metadata_files.values() - ] + return list((await self._list_cached_metadata_files()).values()) @handle_backup_errors async def async_get_backup( self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" return await self._find_backup_by_id(backup_id) - async def _list_metadata_files(self) -> dict[str, str]: - """List metadata files.""" - files = await self._client.list_with_properties( - self._backup_path, - [ - PropertyRequest( - namespace="homeassistant", - name="metadata_version", - ), - PropertyRequest( - namespace="homeassistant", - name="backup_id", - ), - ], - ) - return { - backup_id: file_name - for file_name, properties in files.items() - if file_name.endswith(".json") and _is_current_metadata_version(properties) - if (backup_id := _backup_id_from_properties(properties)) - } + async def _list_cached_metadata_files(self) -> dict[str, AgentBackup]: + """List metadata files with a cache.""" + if time() <= self._cache_expiration: + return self._cache_metadata_files - async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: + async def _download_metadata(path: str) -> AgentBackup: + """Download metadata file.""" + iterator = await self._client.download_iter(path) + metadata = await anext(iterator) + return AgentBackup.from_dict(json_loads_object(metadata)) + + async def _list_metadata_files() -> dict[str, AgentBackup]: + """List metadata files.""" + files = await self._client.list_files(self._backup_path) + return { + metadata_content.backup_id: metadata_content + for file_name in files + if file_name.endswith(".metadata.json") + if (metadata_content := await _download_metadata(file_name)) + } + + self._cache_metadata_files = await _list_metadata_files() + self._cache_expiration = time() + CACHE_TTL + return self._cache_metadata_files + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup: """Find a backup by its backup ID on remote.""" - metadata_files = await self._list_metadata_files() + metadata_files = await self._list_cached_metadata_files() if metadata_file := metadata_files.get(backup_id): - return await self._download_metadata(metadata_file) + return metadata_file - return None - - async def _download_metadata(self, path: str) -> AgentBackup: - """Download metadata file.""" - iterator = await self._client.download_iter(path) - metadata = await anext(iterator) - return AgentBackup.from_dict(json_loads_object(metadata)) + raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py index f75544d25ad..e3e46d2575a 100644 --- a/homeassistant/components/webdav/config_flow.py +++ b/homeassistant/components/webdav/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from aiowebdav2.exceptions import UnauthorizedError +from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError import voluptuous as vol import yarl @@ -65,7 +65,9 @@ class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): result = await client.check() except UnauthorizedError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except MethodNotSupportedError: + errors["base"] = "invalid_method" + except Exception: _LOGGER.exception("Unexpected error") errors["base"] = "unknown" else: diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py index 9f91ed3bdb3..442f69b4d3c 100644 --- a/homeassistant/components/webdav/helpers.py +++ b/homeassistant/components/webdav/helpers.py @@ -1,10 +1,18 @@ """Helper functions for the WebDAV component.""" +import logging + from aiowebdav2.client import Client, ClientOptions +from aiowebdav2.exceptions import WebDavError from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + @callback def async_create_client( @@ -36,3 +44,25 @@ async def async_ensure_path_exists(client: Client, path: str) -> bool: return False return True + + +async def async_migrate_wrong_folder_path(client: Client, path: str) -> None: + """Migrate the wrong encoded folder path to the correct one.""" + wrong_path = path.replace(" ", "%20") + # migrate folder when the old folder exists + if wrong_path != path and await client.check(wrong_path): + try: + await client.move(wrong_path, path) + except WebDavError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_migrate_folder", + translation_placeholders={ + "wrong_path": wrong_path, + "correct_path": path, + }, + ) from err + + _LOGGER.debug( + "Migrated wrong encoded folder path from %s to %s", wrong_path, path + ) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 75a8d7ddfe2..63d093745d1 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.3.0"] + "requirements": ["aiowebdav2==0.4.5"] } diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index 57117cdd9de..ac6418f1239 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -21,6 +21,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_method": "The server does not support the required methods. Please check whether you have the correct URL. Check with your provider for the correct URL.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -36,6 +37,9 @@ }, "cannot_access_or_create_backup_path": { "message": "Cannot access or create backup path. Please check the path and permissions." + }, + "failed_to_migrate_folder": { + "message": "Failed to migrate wrong encoded folder \"{wrong_path}\" to \"{correct_path}\"." } } } diff --git a/homeassistant/components/webmin/strings.json b/homeassistant/components/webmin/strings.json index 9a6d6d4fbe4..b92986f917a 100644 --- a/homeassistant/components/webmin/strings.json +++ b/homeassistant/components/webmin/strings.json @@ -29,13 +29,13 @@ "entity": { "sensor": { "load_1m": { - "name": "Load (1m)" + "name": "Load (1 min)" }, "load_5m": { - "name": "Load (5m)" + "name": "Load (5 min)" }, "load_15m": { - "name": "Load (15m)" + "name": "Load (15 min)" }, "mem_total": { "name": "Memory total" diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 06cbca32453..8ac470ae922 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.1"], + "requirements": ["aiowebostv==0.7.3"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 4a360b4a43c..ddcdd4f1cf8 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -59,7 +59,11 @@ from homeassistant.loader import ( async_get_integration_descriptions, async_get_integrations, ) -from homeassistant.setup import async_get_loaded_integrations, async_get_setup_timings +from homeassistant.setup import ( + async_get_loaded_integrations, + async_get_setup_timings, + async_wait_component, +) from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages @@ -98,6 +102,7 @@ def async_register_commands( async_reg(hass, handle_subscribe_entities) async_reg(hass, handle_supported_features) async_reg(hass, handle_integration_descriptions) + async_reg(hass, handle_integration_wait) def pong_message(iden: int) -> dict[str, Any]: @@ -923,3 +928,21 @@ async def handle_integration_descriptions( ) -> None: """Get metadata for all brands and integrations.""" connection.send_result(msg["id"], await async_get_integration_descriptions(hass)) + + +@decorators.websocket_command( + { + vol.Required("type"): "integration/wait", + vol.Required("domain"): str, + } +) +@decorators.async_response +async def handle_integration_wait( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle wait for integration command.""" + + domain: str = msg["domain"] + connection.send_result( + msg["id"], {"integration_loaded": await async_wait_component(hass, domain)} + ) diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index a0d031834ae..fce85339430 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -21,7 +21,7 @@ type AsyncWebSocketCommandHandler = Callable[ DOMAIN: Final = "websocket_api" URL: Final = "/api/websocket" PENDING_MSG_PEAK: Final = 1024 -PENDING_MSG_PEAK_TIME: Final = 5 +PENDING_MSG_PEAK_TIME: Final = 10 # Maximum number of messages that can be pending at any given time. # This is effectively the upper limit of the number of entities # that can fire state changes within ~1 second. diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index ebca497193b..4250da149ad 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -14,7 +14,7 @@ from aiohttp import WSMsgType, web from aiohttp.http_websocket import WebSocketWriter from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -73,6 +73,7 @@ class WebSocketHandler: "_authenticated", "_closing", "_connection", + "_debug", "_handle_task", "_hass", "_logger", @@ -107,6 +108,12 @@ class WebSocketHandler: self._message_queue: deque[bytes] = deque() self._ready_future: asyncio.Future[int] | None = None self._release_ready_queue_size: int = 0 + self._async_logging_changed() + + @callback + def _async_logging_changed(self, event: Event | None = None) -> None: + """Handle logging change.""" + self._debug = self._logger.isEnabledFor(logging.DEBUG) def __repr__(self) -> str: """Return the representation.""" @@ -137,7 +144,6 @@ class WebSocketHandler: logger = self._logger wsock = self._wsock loop = self._loop - is_debug_log_enabled = partial(logger.isEnabledFor, logging.DEBUG) debug = logger.debug can_coalesce = connection.can_coalesce ready_message_count = len(message_queue) @@ -157,14 +163,14 @@ class WebSocketHandler: if not can_coalesce or ready_message_count == 1: message = message_queue.popleft() - if is_debug_log_enabled(): + if self._debug: debug("%s: Sending %s", self.description, message) await send_bytes_text(message) continue coalesced_messages = b"".join((b"[", b",".join(message_queue), b"]")) message_queue.clear() - if is_debug_log_enabled(): + if self._debug: debug("%s: Sending %s", self.description, coalesced_messages) await send_bytes_text(coalesced_messages) except asyncio.CancelledError: @@ -325,6 +331,9 @@ class WebSocketHandler: unsub_stop = hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_handle_hass_stop ) + cancel_logging_listener = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) writer = wsock._writer # noqa: SLF001 if TYPE_CHECKING: @@ -354,6 +363,7 @@ class WebSocketHandler: "%s: Unexpected error inside websocket API", self.description ) finally: + cancel_logging_listener() unsub_stop() self._cancel_peak_checker() @@ -401,7 +411,7 @@ class WebSocketHandler: except ValueError as err: raise Disconnect("Received invalid JSON during auth phase") from err - if self._logger.isEnabledFor(logging.DEBUG): + if self._debug: self._logger.debug("%s: Received %s", self.description, auth_msg_data) connection = await auth.async_handle(auth_msg_data) # As the webserver is now started before the start @@ -463,7 +473,6 @@ class WebSocketHandler: wsock = self._wsock async_handle_str = connection.async_handle async_handle_binary = connection.async_handle_binary - _debug_enabled = partial(self._logger.isEnabledFor, logging.DEBUG) # Command phase while not wsock.closed: @@ -496,7 +505,7 @@ class WebSocketHandler: except ValueError as ex: raise Disconnect("Received invalid JSON.") from ex - if _debug_enabled(): + if self._debug: self._logger.debug( "%s: Received %s", self.description, command_msg_data ) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 0a8200c5700..6ae7de2c4b7 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -207,7 +207,7 @@ def _state_diff_event( additions[COMPRESSED_STATE_STATE] = new_state.state if old_state.last_changed != new_state.last_changed: additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed_timestamp - elif old_state.last_updated != new_state.last_updated: + elif old_state.last_updated_timestamp != new_state.last_updated_timestamp: additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated_timestamp if old_state_context.parent_id != new_state_context.parent_id: additions[COMPRESSED_STATE_CONTEXT] = {"parent_id": new_state_context.parent_id} diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index b67c3540dc5..15935f3e418 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from http import HTTPStatus import aiohttp @@ -18,7 +19,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from .const import API_URL, LOGGER -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import ( + HeatPumpInfo, + WeheatConfigEntry, + WeheatData, + WeheatDataUpdateCoordinator, + WeheatEnergyUpdateCoordinator, +) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -52,14 +59,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo except UnauthorizedException as error: raise ConfigEntryAuthFailed from error + nr_of_pumps = len(discovered_heat_pumps) + for pump_info in discovered_heat_pumps: LOGGER.debug("Adding %s", pump_info) - # for each pump, add a coordinator - new_coordinator = WeheatDataUpdateCoordinator(hass, entry, session, pump_info) + # for each pump, add the coordinators - await new_coordinator.async_config_entry_first_refresh() + new_heat_pump = HeatPumpInfo(pump_info) + new_data_coordinator = WeheatDataUpdateCoordinator( + hass, entry, session, pump_info, nr_of_pumps + ) + new_energy_coordinator = WeheatEnergyUpdateCoordinator( + hass, entry, session, pump_info + ) - entry.runtime_data.append(new_coordinator) + entry.runtime_data.append( + WeheatData( + heat_pump_info=new_heat_pump, + data_coordinator=new_data_coordinator, + energy_coordinator=new_energy_coordinator, + ) + ) + + await asyncio.gather( + *[ + data.data_coordinator.async_config_entry_first_refresh() + for data in entry.runtime_data + ], + *[ + data.energy_coordinator.async_config_entry_first_refresh() + for data in entry.runtime_data + ], + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py index 6a4a03a1e48..5e4c91fde60 100644 --- a/homeassistant/components/weheat/binary_sensor.py +++ b/homeassistant/components/weheat/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import HeatPumpInfo, WeheatConfigEntry, WeheatDataUpdateCoordinator from .entity import WeheatEntity # Coordinator is used to centralize the data updates @@ -68,10 +68,14 @@ async def async_setup_entry( ) -> None: """Set up the sensors for weheat heat pump.""" entities = [ - WeheatHeatPumpBinarySensor(coordinator, entity_description) + WeheatHeatPumpBinarySensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for weheatdata in entry.runtime_data for entity_description in BINARY_SENSORS - for coordinator in entry.runtime_data - if entity_description.value_fn(coordinator.data) is not None + if entity_description.value_fn(weheatdata.data_coordinator.data) is not None ] async_add_entities(entities) @@ -80,20 +84,21 @@ async def async_setup_entry( class WeheatHeatPumpBinarySensor(WeheatEntity, BinarySensorEntity): """Defines a Weheat heat pump binary sensor.""" + heat_pump_info: HeatPumpInfo coordinator: WeheatDataUpdateCoordinator entity_description: WeHeatBinarySensorEntityDescription def __init__( self, + heat_pump_info: HeatPumpInfo, coordinator: WeheatDataUpdateCoordinator, entity_description: WeHeatBinarySensorEntityDescription, ) -> None: """Pass coordinator to CoordinatorEntity.""" - super().__init__(coordinator) - + super().__init__(heat_pump_info, coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}" + self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}" @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/weheat/const.py b/homeassistant/components/weheat/const.py index e33fd983572..cd521afd2ea 100644 --- a/homeassistant/components/weheat/const.py +++ b/homeassistant/components/weheat/const.py @@ -17,10 +17,12 @@ API_URL = "https://api.weheat.nl" OAUTH2_SCOPES = ["openid", "offline_access"] -UPDATE_INTERVAL = 30 +LOG_UPDATE_INTERVAL = 120 +ENERGY_UPDATE_INTERVAL = 1800 LOGGER: Logger = getLogger(__package__) DISPLAY_PRECISION_WATTS = 0 DISPLAY_PRECISION_COP = 1 DISPLAY_PRECISION_WATER_TEMP = 1 +DISPLAY_PRECISION_FLOW = 1 diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index d7e53258e9b..30ca61d0387 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -1,5 +1,6 @@ """Define a custom coordinator for the Weheat heatpump integration.""" +from dataclasses import dataclass from datetime import timedelta from weheat.abstractions.discovery import HeatPumpDiscovery @@ -10,6 +11,7 @@ from weheat.exceptions import ( ForbiddenException, NotFoundException, ServiceException, + TooManyRequestsException, UnauthorizedException, ) @@ -21,7 +23,9 @@ 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 -from .const import API_URL, DOMAIN, LOGGER, UPDATE_INTERVAL +from .const import API_URL, DOMAIN, ENERGY_UPDATE_INTERVAL, LOG_UPDATE_INTERVAL, LOGGER + +type WeheatConfigEntry = ConfigEntry[list[WeheatData]] EXCEPTIONS = ( ServiceException, @@ -29,9 +33,43 @@ EXCEPTIONS = ( ForbiddenException, BadRequestException, ApiException, + TooManyRequestsException, ) -type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] + +class HeatPumpInfo(HeatPumpDiscovery.HeatPumpInfo): + """Heat pump info with additional properties.""" + + def __init__(self, pump_info: HeatPumpDiscovery.HeatPumpInfo) -> None: + """Initialize the HeatPump object with the provided pump information. + + Args: + pump_info (HeatPumpDiscovery.HeatPumpInfo): An object containing the heat pump's discovery information, including: + - uuid (str): Unique identifier for the heat pump. + - uuid (str): Unique identifier for the heat pump. + - device_name (str): Name of the heat pump device. + - model (str): Model of the heat pump. + - sn (str): Serial number of the heat pump. + - has_dhw (bool): Indicates if the heat pump has domestic hot water functionality. + + """ + super().__init__( + pump_info.uuid, + pump_info.device_name, + pump_info.model, + pump_info.sn, + pump_info.has_dhw, + ) + + @property + def readable_name(self) -> str | None: + """Return the readable name of the heat pump.""" + return self.device_name if self.device_name else self.model + + @property + def heatpump_id(self) -> str: + """Return the heat pump id.""" + return self.uuid class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): @@ -45,45 +83,28 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): config_entry: WeheatConfigEntry, session: OAuth2Session, heat_pump: HeatPumpDiscovery.HeatPumpInfo, + nr_of_heat_pumps: int, ) -> None: """Initialize the data coordinator.""" super().__init__( hass, - logger=LOGGER, config_entry=config_entry, + logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=UPDATE_INTERVAL), + update_interval=timedelta(seconds=LOG_UPDATE_INTERVAL * nr_of_heat_pumps), ) - self.heat_pump_info = heat_pump self._heat_pump_data = HeatPump( API_URL, heat_pump.uuid, async_get_clientsession(hass) ) self.session = session - @property - def heatpump_id(self) -> str: - """Return the heat pump id.""" - return self.heat_pump_info.uuid - - @property - def readable_name(self) -> str | None: - """Return the readable name of the heat pump.""" - if self.heat_pump_info.name: - return self.heat_pump_info.name - return self.heat_pump_info.model - - @property - def model(self) -> str: - """Return the model of the heat pump.""" - return self.heat_pump_info.model - async def _async_update_data(self) -> HeatPump: """Fetch data from the API.""" await self.session.async_ensure_token_valid() try: - await self._heat_pump_data.async_get_status( + await self._heat_pump_data.async_get_logs( self.session.token[CONF_ACCESS_TOKEN] ) except UnauthorizedException as error: @@ -92,3 +113,54 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): raise UpdateFailed(error) from error return self._heat_pump_data + + +class WeheatEnergyUpdateCoordinator(DataUpdateCoordinator[HeatPump]): + """A custom Energy coordinator for the Weheat heatpump integration.""" + + config_entry: WeheatConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: WeheatConfigEntry, + session: OAuth2Session, + heat_pump: HeatPumpDiscovery.HeatPumpInfo, + ) -> None: + """Initialize the data coordinator.""" + super().__init__( + hass, + config_entry=config_entry, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=ENERGY_UPDATE_INTERVAL), + ) + self._heat_pump_data = HeatPump( + API_URL, heat_pump.uuid, async_get_clientsession(hass) + ) + + self.session = session + + async def _async_update_data(self) -> HeatPump: + """Fetch data from the API.""" + await self.session.async_ensure_token_valid() + + try: + await self._heat_pump_data.async_get_energy( + 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 + + +@dataclass +class WeheatData: + """Data for the Weheat integration.""" + + heat_pump_info: HeatPumpInfo + data_coordinator: WeheatDataUpdateCoordinator + energy_coordinator: WeheatEnergyUpdateCoordinator diff --git a/homeassistant/components/weheat/entity.py b/homeassistant/components/weheat/entity.py index 079db596e19..7a12b2edcfa 100644 --- a/homeassistant/components/weheat/entity.py +++ b/homeassistant/components/weheat/entity.py @@ -3,25 +3,30 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import HeatPumpInfo from .const import DOMAIN, MANUFACTURER -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatDataUpdateCoordinator, WeheatEnergyUpdateCoordinator -class WeheatEntity(CoordinatorEntity[WeheatDataUpdateCoordinator]): +class WeheatEntity[ + _WeheatEntityT: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator +](CoordinatorEntity[_WeheatEntityT]): """Defines a base Weheat entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: WeheatDataUpdateCoordinator, + heat_pump_info: HeatPumpInfo, + coordinator: _WeheatEntityT, ) -> None: """Initialize the Weheat entity.""" super().__init__(coordinator) + self.heat_pump_info = heat_pump_info self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.heatpump_id)}, - name=coordinator.readable_name, + identifiers={(DOMAIN, heat_pump_info.heatpump_id)}, + name=heat_pump_info.readable_name, manufacturer=MANUFACTURER, - model=coordinator.model, + model=heat_pump_info.model, ) diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json index e7f54b478c6..c0955cd051d 100644 --- a/homeassistant/components/weheat/icons.json +++ b/homeassistant/components/weheat/icons.json @@ -42,6 +42,12 @@ "heat_pump_state": { "default": "mdi:state-machine" }, + "dhw_flow_volume": { + "default": "mdi:pump" + }, + "central_heating_flow_volume": { + "default": "mdi:pump" + }, "electricity_used": { "default": "mdi:flash" }, diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index a408303d062..cd631866fdb 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==2025.2.22"] + "requirements": ["weheat==2025.4.29"] } diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 615bfd30d18..8ff80aeac08 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfPower, UnitOfTemperature, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,10 +25,16 @@ from homeassistant.helpers.typing import StateType from .const import ( DISPLAY_PRECISION_COP, + DISPLAY_PRECISION_FLOW, DISPLAY_PRECISION_WATER_TEMP, DISPLAY_PRECISION_WATTS, ) -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import ( + HeatPumpInfo, + WeheatConfigEntry, + WeheatDataUpdateCoordinator, + WeheatEnergyUpdateCoordinator, +) from .entity import WeheatEntity # Coordinator is used to centralize the data updates @@ -142,22 +149,6 @@ SENSORS = [ else None ), ), - WeHeatSensorEntityDescription( - translation_key="electricity_used", - key="electricity_used", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - 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", @@ -172,9 +163,17 @@ SENSORS = [ native_unit_of_measurement=PERCENTAGE, value_fn=lambda status: status.compressor_percentage, ), + WeHeatSensorEntityDescription( + translation_key="central_heating_flow_volume", + key="central_heating_flow_volume", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_FLOW, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + value_fn=lambda status: status.central_heating_flow_volume, + ), ] - DHW_SENSORS = [ WeHeatSensorEntityDescription( translation_key="dhw_top_temperature", @@ -194,6 +193,34 @@ DHW_SENSORS = [ suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, value_fn=lambda status: status.dhw_bottom_temperature, ), + WeHeatSensorEntityDescription( + translation_key="dhw_flow_volume", + key="dhw_flow_volume", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_FLOW, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + value_fn=lambda status: status.dhw_flow_volume, + ), +] + +ENERGY_SENSORS = [ + WeHeatSensorEntityDescription( + translation_key="electricity_used", + key="electricity_used", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + 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, + ), ] @@ -203,17 +230,39 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors for weheat heat pump.""" - entities = [ - WeheatHeatPumpSensor(coordinator, entity_description) - for entity_description in SENSORS - for coordinator in entry.runtime_data - ] - entities.extend( - WeheatHeatPumpSensor(coordinator, entity_description) - for entity_description in DHW_SENSORS - for coordinator in entry.runtime_data - if coordinator.heat_pump_info.has_dhw - ) + + entities: list[WeheatHeatPumpSensor] = [] + for weheatdata in entry.runtime_data: + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for entity_description in SENSORS + if entity_description.value_fn(weheatdata.data_coordinator.data) is not None + ) + if weheatdata.heat_pump_info.has_dhw: + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for entity_description in DHW_SENSORS + if entity_description.value_fn(weheatdata.data_coordinator.data) + is not None + ) + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.energy_coordinator, + entity_description, + ) + for entity_description in ENERGY_SENSORS + if entity_description.value_fn(weheatdata.energy_coordinator.data) + is not None + ) async_add_entities(entities) @@ -221,20 +270,21 @@ async def async_setup_entry( class WeheatHeatPumpSensor(WeheatEntity, SensorEntity): """Defines a Weheat heat pump sensor.""" - coordinator: WeheatDataUpdateCoordinator + heat_pump_info: HeatPumpInfo + coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator entity_description: WeHeatSensorEntityDescription def __init__( self, - coordinator: WeheatDataUpdateCoordinator, + heat_pump_info: HeatPumpInfo, + coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator, entity_description: WeHeatSensorEntityDescription, ) -> None: """Pass coordinator to CoordinatorEntity.""" - super().__init__(coordinator) - + super().__init__(heat_pump_info, coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}" + self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index 3959acad053..93a3fbaad30 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -86,6 +86,12 @@ "dhw_bottom_temperature": { "name": "DHW bottom temperature" }, + "dhw_flow_volume": { + "name": "DHW pump flow" + }, + "central_heating_flow_volume": { + "name": "Central heating pump flow" + }, "heat_pump_state": { "state": { "standby": "[%key:common::state::standby%]", @@ -95,7 +101,7 @@ "dhw": "Heating DHW", "legionella_prevention": "Legionella prevention", "defrosting": "Defrosting", - "self_test": "Self test", + "self_test": "Self-test", "manual_control": "Manual control" } }, diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 3ef7ac92f98..96e61dfded6 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -21,7 +21,7 @@ from homeassistant.util.async_ import gather_with_limited_concurrency from .const import DOMAIN from .coordinator import DeviceCoordinator, async_register_device -from .models import WemoConfigEntryData, WemoData, async_wemo_data +from .models import DATA_WEMO, WemoConfigEntryData, WemoData # Max number of devices to initialize at once. This limit is in place to # avoid tying up too many executor threads with WeMo device setup. @@ -117,7 +117,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a wemo config entry.""" - wemo_data = async_wemo_data(hass) + wemo_data = hass.data[DATA_WEMO] dispatcher = WemoDispatcher(entry) discovery = WemoDiscovery(hass, dispatcher, wemo_data.static_config, entry) wemo_data.config_entry_data = WemoConfigEntryData( @@ -138,7 +138,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a wemo config entry.""" _LOGGER.debug("Unloading WeMo") - wemo_data = async_wemo_data(hass) + wemo_data = hass.data[DATA_WEMO] wemo_data.config_entry_data.discovery.async_stop_discovery() @@ -161,7 +161,7 @@ async def async_wemo_dispatcher_connect( module = dispatch.__module__ # Example: "homeassistant.components.wemo.switch" platform = Platform(module.rsplit(".", 1)[1]) - dispatcher = async_wemo_data(hass).config_entry_data.dispatcher + dispatcher = hass.data[DATA_WEMO].config_entry_data.dispatcher await dispatcher.async_connect_platform(platform, dispatch) diff --git a/homeassistant/components/wemo/coordinator.py b/homeassistant/components/wemo/coordinator.py index 0aaedf598d2..6cda83f6419 100644 --- a/homeassistant/components/wemo/coordinator.py +++ b/homeassistant/components/wemo/coordinator.py @@ -29,7 +29,7 @@ from homeassistant.helpers.device_registry import CONNECTION_UPNP, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT -from .models import async_wemo_data +from .models import DATA_WEMO _LOGGER = logging.getLogger(__name__) @@ -316,9 +316,9 @@ def async_get_coordinator(hass: HomeAssistant, device_id: str) -> DeviceCoordina @callback def _async_coordinators(hass: HomeAssistant) -> dict[str, DeviceCoordinator]: - return async_wemo_data(hass).config_entry_data.device_coordinators + return hass.data[DATA_WEMO].config_entry_data.device_coordinators @callback def _async_registry(hass: HomeAssistant) -> SubscriptionRegistry: - return async_wemo_data(hass).registry + return hass.data[DATA_WEMO].registry diff --git a/homeassistant/components/wemo/device_trigger.py b/homeassistant/components/wemo/device_trigger.py index 560c95523cd..353b0470476 100644 --- a/homeassistant/components/wemo/device_trigger.py +++ b/homeassistant/components/wemo/device_trigger.py @@ -12,7 +12,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN as WEMO_DOMAIN, WEMO_SUBSCRIPTION_EVENT +from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT from .coordinator import async_get_coordinator TRIGGER_TYPES = {EVENT_TYPE_LONG_PRESS} @@ -32,7 +32,7 @@ async def async_get_triggers( wemo_trigger = { # Required fields of TRIGGER_BASE_SCHEMA CONF_PLATFORM: "device", - CONF_DOMAIN: WEMO_DOMAIN, + CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, } diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 838073be84a..6d032a0a7b6 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import async_wemo_dispatcher_connect -from .const import DOMAIN as WEMO_DOMAIN +from .const import DOMAIN from .coordinator import DeviceCoordinator from .entity import WemoBinaryStateEntity, WemoEntity @@ -110,7 +110,7 @@ class WemoLight(WemoEntity, LightEntity): """Return the device info.""" return DeviceInfo( connections={(CONNECTION_ZIGBEE, self._unique_id)}, - identifiers={(WEMO_DOMAIN, self._unique_id)}, + identifiers={(DOMAIN, self._unique_id)}, manufacturer="Belkin", model=self._model_name, name=self.name, diff --git a/homeassistant/components/wemo/models.py b/homeassistant/components/wemo/models.py index 80213c9ba33..b96cd502cd4 100644 --- a/homeassistant/components/wemo/models.py +++ b/homeassistant/components/wemo/models.py @@ -4,11 +4,11 @@ from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import pywemo -from homeassistant.core import HomeAssistant, callback +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN @@ -16,6 +16,8 @@ if TYPE_CHECKING: # Avoid circular dependencies. from . import HostPortTuple, WemoDiscovery, WemoDispatcher from .coordinator import DeviceCoordinator +DATA_WEMO: HassKey[WemoData] = HassKey(DOMAIN) + @dataclass class WemoConfigEntryData: @@ -37,9 +39,3 @@ class WemoData: # unloaded. It's a programmer error if config_entry_data is accessed when the # config entry is not loaded config_entry_data: WemoConfigEntryData = None # type: ignore[assignment] - - -@callback -def async_wemo_data(hass: HomeAssistant) -> WemoData: - """Fetch WemoData with proper typing.""" - return cast(WemoData, hass.data[DOMAIN]) diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 6231324bb0d..56cdf52c649 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -1,6 +1,5 @@ """The Whirlpool Appliances integration.""" -from dataclasses import dataclass import logging from aiohttp import ClientError @@ -14,22 +13,20 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_BRAND, CONF_BRANDS_MAP, CONF_REGIONS_MAP, DOMAIN +from .const import BRANDS_CONF_MAP, CONF_BRAND, DOMAIN, REGIONS_CONF_MAP _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] -type WhirlpoolConfigEntry = ConfigEntry[WhirlpoolData] +type WhirlpoolConfigEntry = ConfigEntry[AppliancesManager] async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool: """Set up Whirlpool Sixth Sense from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - session = async_get_clientsession(hass) - region = CONF_REGIONS_MAP[entry.data.get(CONF_REGION, "EU")] - brand = CONF_BRANDS_MAP[entry.data.get(CONF_BRAND, "Whirlpool")] + region = REGIONS_CONF_MAP[entry.data.get(CONF_REGION, "EU")] + brand = BRANDS_CONF_MAP[entry.data.get(CONF_BRAND, "Whirlpool")] backend_selector = BackendSelector(brand, region) auth = Auth( @@ -50,10 +47,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> appliances_manager = AppliancesManager(backend_selector, auth, session) if not await appliances_manager.fetch_appliances(): - _LOGGER.error("Cannot fetch appliances") - return False + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="appliances_fetch_failed" + ) + await appliances_manager.connect() - entry.runtime_data = WhirlpoolData(appliances_manager, auth, backend_selector) + entry.runtime_data = appliances_manager await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -61,13 +60,5 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool: """Unload a config entry.""" + await entry.runtime_data.disconnect() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -@dataclass -class WhirlpoolData: - """Whirlpool integaration shared data.""" - - appliances_manager: AppliancesManager - auth: Auth - backend_selector: BackendSelector diff --git a/homeassistant/components/whirlpool/binary_sensor.py b/homeassistant/components/whirlpool/binary_sensor.py new file mode 100644 index 00000000000..d8ec373f026 --- /dev/null +++ b/homeassistant/components/whirlpool/binary_sensor.py @@ -0,0 +1,68 @@ +"""Binary sensors for the Whirlpool Appliances integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta + +from whirlpool.appliance import Appliance + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WhirlpoolConfigEntry +from .entity import WhirlpoolEntity + +SCAN_INTERVAL = timedelta(minutes=5) + + +@dataclass(frozen=True, kw_only=True) +class WhirlpoolBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Whirlpool binary sensor entity.""" + + value_fn: Callable[[Appliance], bool | None] + + +WASHER_DRYER_SENSORS: list[WhirlpoolBinarySensorEntityDescription] = [ + WhirlpoolBinarySensorEntityDescription( + key="door", + device_class=BinarySensorDeviceClass.DOOR, + value_fn=lambda appliance: appliance.get_door_open(), + ) +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WhirlpoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Config flow entry for Whirlpool binary sensors.""" + entities: list = [] + appliances_manager = config_entry.runtime_data + for washer_dryer in appliances_manager.washer_dryers: + entities.extend( + WhirlpoolBinarySensor(washer_dryer, description) + for description in WASHER_DRYER_SENSORS + ) + async_add_entities(entities) + + +class WhirlpoolBinarySensor(WhirlpoolEntity, BinarySensorEntity): + """A class for the Whirlpool binary sensors.""" + + def __init__( + self, appliance: Appliance, description: WhirlpoolBinarySensorEntityDescription + ) -> None: + """Initialize the washer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description: WhirlpoolBinarySensorEntityDescription = description + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self._appliance) diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 6baf738e54e..75967bb81d4 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -2,16 +2,11 @@ from __future__ import annotations -import logging from typing import Any -from aiohttp import ClientSession from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode -from whirlpool.auth import Auth -from whirlpool.backendselector import BackendSelector from homeassistant.components.climate import ( - ENTITY_ID_FORMAT, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -25,16 +20,10 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WhirlpoolConfigEntry -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - +from .entity import WhirlpoolEntity AIRCON_MODE_MAP = { AirconMode.Cool: HVACMode.COOL, @@ -73,32 +62,20 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" - whirlpool_data = config_entry.runtime_data - - aircons = [ - AirConEntity( - hass, - ac_data["SAID"], - ac_data["NAME"], - whirlpool_data.backend_selector, - whirlpool_data.auth, - async_get_clientsession(hass), - ) - for ac_data in whirlpool_data.appliances_manager.aircons - ] - async_add_entities(aircons, True) + appliances_manager = config_entry.runtime_data + async_add_entities(AirConEntity(aircon) for aircon in appliances_manager.aircons) -class AirConEntity(ClimateEntity): +class AirConEntity(WhirlpoolEntity, ClimateEntity): """Representation of an air conditioner.""" + _appliance: Aircon + _attr_fan_modes = SUPPORTED_FAN_MODES - _attr_has_entity_name = True _attr_name = None _attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_max_temp = SUPPORTED_MAX_TEMP _attr_min_temp = SUPPORTED_MIN_TEMP - _attr_should_poll = False _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE @@ -110,117 +87,81 @@ class AirConEntity(ClimateEntity): _attr_target_temperature_step = SUPPORTED_TARGET_TEMPERATURE_STEP _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__( - self, - hass: HomeAssistant, - said: str, - name: str | None, - backend_selector: BackendSelector, - auth: Auth, - session: ClientSession, - ) -> None: - """Initialize the entity.""" - self._aircon = Aircon(backend_selector, auth, said, session) - self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, said, hass=hass) - self._attr_unique_id = said - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, said)}, - name=name if name is not None else said, - manufacturer="Whirlpool", - model="Sixth Sense", - ) - - async def async_added_to_hass(self) -> None: - """Connect aircon to the cloud.""" - self._aircon.register_attr_callback(self.async_write_ha_state) - await self._aircon.connect() - - async def async_will_remove_from_hass(self) -> None: - """Close Whrilpool Appliance sockets before removing.""" - self._aircon.unregister_attr_callback(self.async_write_ha_state) - await self._aircon.disconnect() - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._aircon.get_online() - @property def current_temperature(self) -> float: """Return the current temperature.""" - return self._aircon.get_current_temp() + return self._appliance.get_current_temp() @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - return self._aircon.get_temp() + return self._appliance.get_temp() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - await self._aircon.set_temp(kwargs.get(ATTR_TEMPERATURE)) + await self._appliance.set_temp(kwargs.get(ATTR_TEMPERATURE)) @property def current_humidity(self) -> int: """Return the current humidity.""" - return self._aircon.get_current_humidity() + return self._appliance.get_current_humidity() @property def target_humidity(self) -> int: """Return the humidity we try to reach.""" - return self._aircon.get_humidity() + return self._appliance.get_humidity() async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - await self._aircon.set_humidity(humidity) + await self._appliance.set_humidity(humidity) @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, fan.""" - if not self._aircon.get_power_on(): + if not self._appliance.get_power_on(): return HVACMode.OFF - mode: AirconMode = self._aircon.get_mode() + mode: AirconMode = self._appliance.get_mode() return AIRCON_MODE_MAP.get(mode) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set HVAC mode.""" if hvac_mode == HVACMode.OFF: - await self._aircon.set_power_on(False) + await self._appliance.set_power_on(False) return if not (mode := HVAC_MODE_TO_AIRCON_MODE.get(hvac_mode)): raise ValueError(f"Invalid hvac mode {hvac_mode}") - await self._aircon.set_mode(mode) - if not self._aircon.get_power_on(): - await self._aircon.set_power_on(True) + await self._appliance.set_mode(mode) + if not self._appliance.get_power_on(): + await self._appliance.set_power_on(True) @property def fan_mode(self) -> str: """Return the fan setting.""" - fanspeed = self._aircon.get_fanspeed() + fanspeed = self._appliance.get_fanspeed() return AIRCON_FANSPEED_MAP.get(fanspeed, FAN_OFF) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" if not (fanspeed := FAN_MODE_TO_AIRCON_FANSPEED.get(fan_mode)): raise ValueError(f"Invalid fan mode {fan_mode}") - await self._aircon.set_fanspeed(fanspeed) + await self._appliance.set_fanspeed(fanspeed) @property def swing_mode(self) -> str: """Return the swing setting.""" - return SWING_HORIZONTAL if self._aircon.get_h_louver_swing() else SWING_OFF + return SWING_HORIZONTAL if self._appliance.get_h_louver_swing() else SWING_OFF async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target temperature.""" - await self._aircon.set_h_louver_swing(swing_mode == SWING_HORIZONTAL) + await self._appliance.set_h_louver_swing(swing_mode == SWING_HORIZONTAL) async def async_turn_on(self) -> None: """Turn device on.""" - await self._aircon.set_power_on(True) + await self._appliance.set_power_on(True) async def async_turn_off(self) -> None: """Turn device off.""" - await self._aircon.set_power_on(False) + await self._appliance.set_power_on(False) diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 19715643e3a..61d6883d70f 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_BRAND, CONF_BRANDS_MAP, CONF_REGIONS_MAP, DOMAIN +from .const import BRANDS_CONF_MAP, CONF_BRAND, DOMAIN, REGIONS_CONF_MAP _LOGGER = logging.getLogger(__name__) @@ -26,15 +26,15 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_REGION): vol.In(list(CONF_REGIONS_MAP)), - vol.Required(CONF_BRAND): vol.In(list(CONF_BRANDS_MAP)), + vol.Required(CONF_REGION): vol.In(list(REGIONS_CONF_MAP)), + vol.Required(CONF_BRAND): vol.In(list(BRANDS_CONF_MAP)), } ) REAUTH_SCHEMA = vol.Schema( { vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_BRAND): vol.In(list(CONF_BRANDS_MAP)), + vol.Required(CONF_BRAND): vol.In(list(BRANDS_CONF_MAP)), } ) @@ -48,8 +48,8 @@ async def authenticate( Returns the error translation key if authentication fails, or None on success. """ session = async_get_clientsession(hass) - region = CONF_REGIONS_MAP[data[CONF_REGION]] - brand = CONF_BRANDS_MAP[data[CONF_BRAND]] + region = REGIONS_CONF_MAP[data[CONF_REGION]] + brand = BRANDS_CONF_MAP[data[CONF_BRAND]] backend_selector = BackendSelector(brand, region) auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD], session) diff --git a/homeassistant/components/whirlpool/const.py b/homeassistant/components/whirlpool/const.py index 63a58f54c1d..163229e4a21 100644 --- a/homeassistant/components/whirlpool/const.py +++ b/homeassistant/components/whirlpool/const.py @@ -5,12 +5,12 @@ from whirlpool.backendselector import Brand, Region DOMAIN = "whirlpool" CONF_BRAND = "brand" -CONF_REGIONS_MAP = { +REGIONS_CONF_MAP = { "EU": Region.EU, "US": Region.US, } -CONF_BRANDS_MAP = { +BRANDS_CONF_MAP = { "Whirlpool": Brand.Whirlpool, "Maytag": Brand.Maytag, "KitchenAid": Brand.KitchenAid, diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py index 87d6ea827e2..09338396de4 100644 --- a/homeassistant/components/whirlpool/diagnostics.py +++ b/homeassistant/components/whirlpool/diagnostics.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from whirlpool.appliance import Appliance + from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant @@ -26,18 +28,25 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - whirlpool = config_entry.runtime_data + def get_appliance_diagnostics(appliance: Appliance) -> dict[str, Any]: + return { + "data_model": appliance.appliance_info.data_model, + "category": appliance.appliance_info.category, + "model_number": appliance.appliance_info.model_number, + } + + appliances_manager = config_entry.runtime_data diagnostics_data = { - "Washer_dryers": { - wd["NAME"]: dict(wd.items()) - for wd in whirlpool.appliances_manager.washer_dryers + "washer_dryers": { + wd.name: get_appliance_diagnostics(wd) + for wd in appliances_manager.washer_dryers }, "aircons": { - ac["NAME"]: dict(ac.items()) for ac in whirlpool.appliances_manager.aircons + ac.name: get_appliance_diagnostics(ac) for ac in appliances_manager.aircons }, "ovens": { - oven["NAME"]: dict(oven.items()) - for oven in whirlpool.appliances_manager.ovens + oven.name: get_appliance_diagnostics(oven) + for oven in appliances_manager.ovens }, } diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py new file mode 100644 index 00000000000..a53fe0af263 --- /dev/null +++ b/homeassistant/components/whirlpool/entity.py @@ -0,0 +1,40 @@ +"""Base entity for the Whirlpool integration.""" + +from whirlpool.appliance import Appliance + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class WhirlpoolEntity(Entity): + """Base class for Whirlpool entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None: + """Initialize the entity.""" + self._appliance = appliance + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, appliance.said)}, + name=appliance.name.capitalize() if appliance.name else appliance.said, + manufacturer="Whirlpool", + model_id=appliance.appliance_info.model_number, + ) + self._attr_unique_id = f"{appliance.said}{unique_id_suffix}" + + async def async_added_to_hass(self) -> None: + """Register attribute updates callback.""" + self._appliance.register_attr_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Unregister attribute updates callback.""" + self._appliance.unregister_attr_callback(self.async_write_ha_state) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._appliance.get_online() diff --git a/homeassistant/components/whirlpool/icons.json b/homeassistant/components/whirlpool/icons.json new file mode 100644 index 00000000000..574b491090e --- /dev/null +++ b/homeassistant/components/whirlpool/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "washer_state": { + "default": "mdi:washing-machine" + }, + "dryer_state": { + "default": "mdi:tumble-dryer" + } + } + } +} diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index 67901eea482..919fa54c834 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.12"] + "quality_scale": "bronze", + "requirements": ["whirlpool-sixth-sense==0.20.0"] } diff --git a/homeassistant/components/whirlpool/quality_scale.yaml b/homeassistant/components/whirlpool/quality_scale.yaml new file mode 100644 index 00000000000..1323a064d5c --- /dev/null +++ b/homeassistant/components/whirlpool/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any 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: 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: todo + comment: | + - The calls to the api can be changed to return bool, and services can then raise HomeAssistantError + - Current services raise ValueError and should raise ServiceValidationError instead. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: + status: todo + comment: | + - Test helper init_integration() does not set a unique_id + - Merge test_setup_http_exception and test_setup_auth_account_locked + - The climate platform is at 94% + + # Gold + devices: done + diagnostics: done + 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: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: + status: todo + comment: The "unknown" state should not be part of the enum for the dispense level sensor. + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: + status: todo + comment: | + Time remaining sensor still has hardcoded icon. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No known use cases for repair issues or flows, yet + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index f4811feb2c9..6b052834656 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -1,12 +1,11 @@ """The Washer/Dryer Sensor for Whirlpool Appliances.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -import logging +from typing import override +from whirlpool.appliance import Appliance from whirlpool.washerdryer import MachineState, WasherDryer from homeassistant.components.sensor import ( @@ -15,26 +14,26 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import WhirlpoolConfigEntry -from .const import DOMAIN +from .entity import WhirlpoolEntity -TANK_FILL = { - "0": "unknown", - "1": "empty", - "2": "25", - "3": "50", - "4": "100", - "5": "active", +SCAN_INTERVAL = timedelta(minutes=5) + +WASHER_TANK_FILL = { + 0: None, + 1: "empty", + 2: "25", + 3: "50", + 4: "100", + 5: "active", } -MACHINE_STATE = { +WASHER_DRYER_MACHINE_STATE = { MachineState.Standby: "standby", MachineState.Setting: "setting", MachineState.DelayCountdownMode: "delay_countdown", @@ -56,75 +55,92 @@ MACHINE_STATE = { MachineState.SystemInit: "system_initialize", } -CYCLE_FUNC = [ - (WasherDryer.get_cycle_status_filling, "cycle_filling"), - (WasherDryer.get_cycle_status_rinsing, "cycle_rinsing"), - (WasherDryer.get_cycle_status_sensing, "cycle_sensing"), - (WasherDryer.get_cycle_status_soaking, "cycle_soaking"), - (WasherDryer.get_cycle_status_spinning, "cycle_spinning"), - (WasherDryer.get_cycle_status_washing, "cycle_washing"), -] - -DOOR_OPEN = "door_open" -ICON_D = "mdi:tumble-dryer" -ICON_W = "mdi:washing-machine" - -_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=5) +STATE_CYCLE_FILLING = "cycle_filling" +STATE_CYCLE_RINSING = "cycle_rinsing" +STATE_CYCLE_SENSING = "cycle_sensing" +STATE_CYCLE_SOAKING = "cycle_soaking" +STATE_CYCLE_SPINNING = "cycle_spinning" +STATE_CYCLE_WASHING = "cycle_washing" +STATE_DOOR_OPEN = "door_open" -def washer_state(washer: WasherDryer) -> str | None: - """Determine correct states for a washer.""" +def washer_dryer_state(washer_dryer: WasherDryer) -> str | None: + """Determine correct states for a washer/dryer.""" - if washer.get_attribute("Cavity_OpStatusDoorOpen") == "1": - return DOOR_OPEN + if washer_dryer.get_door_open(): + return STATE_DOOR_OPEN - machine_state = washer.get_machine_state() + machine_state = washer_dryer.get_machine_state() if machine_state == MachineState.RunningMainCycle: - for func, cycle_name in CYCLE_FUNC: - if func(washer): - return cycle_name + if washer_dryer.get_cycle_status_filling(): + return STATE_CYCLE_FILLING + if washer_dryer.get_cycle_status_rinsing(): + return STATE_CYCLE_RINSING + if washer_dryer.get_cycle_status_sensing(): + return STATE_CYCLE_SENSING + if washer_dryer.get_cycle_status_soaking(): + return STATE_CYCLE_SOAKING + if washer_dryer.get_cycle_status_spinning(): + return STATE_CYCLE_SPINNING + if washer_dryer.get_cycle_status_washing(): + return STATE_CYCLE_WASHING - return MACHINE_STATE.get(machine_state) + return WASHER_DRYER_MACHINE_STATE.get(machine_state) @dataclass(frozen=True, kw_only=True) class WhirlpoolSensorEntityDescription(SensorEntityDescription): - """Describes Whirlpool Washer sensor entity.""" + """Describes a Whirlpool sensor entity.""" - value_fn: Callable + value_fn: Callable[[Appliance], str | None] -SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( +WASHER_DRYER_STATE_OPTIONS = [ + *WASHER_DRYER_MACHINE_STATE.values(), + STATE_CYCLE_FILLING, + STATE_CYCLE_RINSING, + STATE_CYCLE_SENSING, + STATE_CYCLE_SOAKING, + STATE_CYCLE_SPINNING, + STATE_CYCLE_WASHING, + STATE_DOOR_OPEN, +] + +WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( WhirlpoolSensorEntityDescription( key="state", - translation_key="whirlpool_machine", + translation_key="washer_state", device_class=SensorDeviceClass.ENUM, - options=( - list(MACHINE_STATE.values()) - + [value for _, value in CYCLE_FUNC] - + [DOOR_OPEN] - ), - value_fn=washer_state, + options=WASHER_DRYER_STATE_OPTIONS, + value_fn=washer_dryer_state, ), WhirlpoolSensorEntityDescription( key="DispenseLevel", translation_key="whirlpool_tank", entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, - options=list(TANK_FILL.values()), - value_fn=lambda WasherDryer: TANK_FILL.get( - WasherDryer.get_attribute("WashCavity_OpStatusBulkDispense1Level") - ), + options=[value for value in WASHER_TANK_FILL.values() if value], + value_fn=lambda washer: WASHER_TANK_FILL.get(washer.get_dispense_1_level()), ), ) -SENSOR_TIMER: tuple[SensorEntityDescription] = ( +DRYER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( + WhirlpoolSensorEntityDescription( + key="state", + translation_key="dryer_state", + device_class=SensorDeviceClass.ENUM, + options=WASHER_DRYER_STATE_OPTIONS, + value_fn=washer_dryer_state, + ), +) + +WASHER_DRYER_TIME_SENSORS: tuple[SensorEntityDescription] = ( SensorEntityDescription( key="timeremaining", translation_key="end_time", device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:progress-clock", ), ) @@ -134,144 +150,73 @@ async def async_setup_entry( config_entry: WhirlpoolConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Config flow entry for Whrilpool Laundry.""" + """Config flow entry for Whirlpool sensors.""" entities: list = [] - whirlpool_data = config_entry.runtime_data - for appliance in whirlpool_data.appliances_manager.washer_dryers: - _wd = WasherDryer( - whirlpool_data.backend_selector, - whirlpool_data.auth, - appliance["SAID"], - async_get_clientsession(hass), + appliances_manager = config_entry.runtime_data + for washer_dryer in appliances_manager.washer_dryers: + sensor_descriptions = ( + DRYER_SENSORS + if "dryer" in washer_dryer.appliance_info.data_model.lower() + else WASHER_SENSORS ) - await _wd.connect() entities.extend( - [ - WasherDryerClass( - appliance["SAID"], - appliance["NAME"], - description, - _wd, - ) - for description in SENSORS - ] + WhirlpoolSensor(washer_dryer, description) + for description in sensor_descriptions ) entities.extend( - [ - WasherDryerTimeClass( - appliance["SAID"], - appliance["NAME"], - description, - _wd, - ) - for description in SENSOR_TIMER - ] + WasherDryerTimeSensor(washer_dryer, description) + for description in WASHER_DRYER_TIME_SENSORS ) async_add_entities(entities) -class WasherDryerClass(SensorEntity): - """A class for the whirlpool/maytag washer account.""" - - _attr_should_poll = False - _attr_has_entity_name = True +class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): + """A class for the Whirlpool sensors.""" def __init__( - self, - said: str, - name: str, - description: WhirlpoolSensorEntityDescription, - washdry: WasherDryer, + self, appliance: Appliance, description: WhirlpoolSensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washdry - - if name == "dryer": - self._attr_icon = ICON_D - else: - self._attr_icon = ICON_W - + super().__init__(appliance, unique_id_suffix=f"-{description.key}") self.entity_description: WhirlpoolSensorEntityDescription = description - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, said)}, - name=name.capitalize(), - manufacturer="Whirlpool", - ) - self._attr_unique_id = f"{said}-{description.key}" - - async def async_added_to_hass(self) -> None: - """Connect washer/dryer to the cloud.""" - self._wd.register_attr_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Close Whirlpool Appliance sockets before removing.""" - self._wd.unregister_attr_callback(self.async_write_ha_state) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._wd.get_online() @property def native_value(self) -> StateType | str: """Return native value of sensor.""" - return self.entity_description.value_fn(self._wd) + return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeClass(RestoreSensor): - """A timestamp class for the whirlpool/maytag washer account.""" +class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): + """A timestamp class for the Whirlpool washer/dryer.""" _attr_should_poll = True - _attr_has_entity_name = True def __init__( - self, - said: str, - name: str, - description: SensorEntityDescription, - washdry: WasherDryer, + self, washer_dryer: WasherDryer, description: SensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washdry + super().__init__(washer_dryer, unique_id_suffix=f"-{description.key}") + self.entity_description = description - if name == "dryer": - self._attr_icon = ICON_D - else: - self._attr_icon = ICON_W - - self.entity_description: SensorEntityDescription = description + self._wd = washer_dryer self._running: bool | None = None - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, said)}, - name=name.capitalize(), - manufacturer="Whirlpool", - ) - self._attr_unique_id = f"{said}-{description.key}" + self._value: datetime | None = None async def async_added_to_hass(self) -> None: - """Connect washer/dryer to the cloud.""" + """Register attribute updates callback.""" if restored_data := await self.async_get_last_sensor_data(): - self._attr_native_value = restored_data.native_value + if isinstance(restored_data.native_value, datetime): + self._value = restored_data.native_value await super().async_added_to_hass() - self._wd.register_attr_callback(self.update_from_latest_data) - - async def async_will_remove_from_hass(self) -> None: - """Close Whrilpool Appliance sockets before removing.""" - self._wd.unregister_attr_callback(self.update_from_latest_data) - await self._wd.disconnect() - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._wd.get_online() async def async_update(self) -> None: """Update status of Whirlpool.""" await self._wd.fetch_data() - @callback - def update_from_latest_data(self) -> None: + @override + @property + def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" machine_state = self._wd.get_machine_state() now = utcnow() @@ -281,19 +226,14 @@ class WasherDryerTimeClass(RestoreSensor): and self._running ): self._running = False - self._attr_native_value = now - self._async_write_ha_state() + self._value = now if machine_state is MachineState.RunningMainCycle: self._running = True - - new_timestamp = now + timedelta( - seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) - ) - - 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) + new_timestamp = now + timedelta(seconds=self._wd.get_time_remaining()) + if self._value is None or ( + isinstance(self._value, datetime) + and abs(new_timestamp - self._value) > timedelta(seconds=60) ): - self._attr_native_value = new_timestamp - self._async_write_ha_state() + self._value = new_timestamp + return self._value diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 95df3fb9098..2a22a2e8e4e 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -13,19 +13,23 @@ "brand": "Brand" }, "data_description": { - "brand": "Please choose the brand of the mobile app you use, or the brand of the appliances in your account" + "username": "The username or email address you use to log in to the Whirlpool/Maytag app", + "password": "The password you use to log in to the Whirlpool/Maytag app", + "region": "The region where your appliances where purchased", + "brand": "The brand of the mobile app you use, or the brand of the appliances in your account" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "For 'brand', please choose the brand of the mobile app you use, or the brand of the appliances in your account", "data": { "password": "[%key:common::config_flow::data::password%]", - "region": "Region", - "brand": "Brand" + "region": "[%key:component::whirlpool::config::step::user::data::region%]", + "brand": "[%key:component::whirlpool::config::step::user::data::brand%]" }, "data_description": { - "brand": "Please choose the brand of the mobile app you use, or the brand of the appliances in your account" + "password": "[%key:component::whirlpool::config::step::user::data_description::password%]", + "brand": "[%key:component::whirlpool::config::step::user::data_description::brand%]", + "region": "[%key:component::whirlpool::config::step::user::data_description::region%]" } } }, @@ -43,35 +47,66 @@ }, "entity": { "sensor": { - "whirlpool_machine": { + "washer_state": { "name": "State", "state": { "standby": "[%key:common::state::standby%]", "setting": "Setting", - "delay_countdown": "Delay Countdown", - "delay_paused": "Delay Paused", - "smart_delay": "Smart Delay", - "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::whirlpool_machine::state::smart_delay%]", + "delay_countdown": "Delay countdown", + "delay_paused": "Delay paused", + "smart_delay": "Smart delay", + "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", "pause": "[%key:common::state::paused%]", - "running_maincycle": "Running Maincycle", - "running_postcycle": "Running Postcycle", + "running_maincycle": "Running maincycle", + "running_postcycle": "Running postcycle", "exception": "Exception", "complete": "Complete", - "power_failure": "Power Failure", - "service_diagnostic_mode": "Service Diagnostic Mode", - "factory_diagnostic_mode": "Factory Diagnostic Mode", - "life_test": "Life Test", - "customer_focus_mode": "Customer Focus Mode", - "demo_mode": "Demo Mode", - "hard_stop_or_error": "Hard Stop or Error", - "system_initialize": "System Initialize", - "cycle_filling": "Cycle Filling", - "cycle_rinsing": "Cycle Rinsing", - "cycle_sensing": "Cycle Sensing", - "cycle_soaking": "Cycle Soaking", - "cycle_spinning": "Cycle Spinning", - "cycle_washing": "Cycle Washing", - "door_open": "Door Open" + "power_failure": "Power failure", + "service_diagnostic_mode": "Service diagnostic mode", + "factory_diagnostic_mode": "Factory diagnostic mode", + "life_test": "Life test", + "customer_focus_mode": "Customer focus mode", + "demo_mode": "Demo mode", + "hard_stop_or_error": "Hard stop or error", + "system_initialize": "System initialize", + "cycle_filling": "Cycle filling", + "cycle_rinsing": "Cycle rinsing", + "cycle_sensing": "Cycle sensing", + "cycle_soaking": "Cycle soaking", + "cycle_spinning": "Cycle spinning", + "cycle_washing": "Cycle washing", + "door_open": "Door open" + } + }, + "dryer_state": { + "name": "[%key:component::whirlpool::entity::sensor::washer_state::name%]", + "state": { + "standby": "[%key:common::state::standby%]", + "setting": "[%key:component::whirlpool::entity::sensor::washer_state::state::setting%]", + "delay_countdown": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_countdown%]", + "delay_paused": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_paused%]", + "smart_delay": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", + "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", + "pause": "[%key:common::state::paused%]", + "running_maincycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_maincycle%]", + "running_postcycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_postcycle%]", + "exception": "[%key:component::whirlpool::entity::sensor::washer_state::state::exception%]", + "complete": "[%key:component::whirlpool::entity::sensor::washer_state::state::complete%]", + "power_failure": "[%key:component::whirlpool::entity::sensor::washer_state::state::power_failure%]", + "service_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::service_diagnostic_mode%]", + "factory_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::factory_diagnostic_mode%]", + "life_test": "[%key:component::whirlpool::entity::sensor::washer_state::state::life_test%]", + "customer_focus_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::customer_focus_mode%]", + "demo_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::demo_mode%]", + "hard_stop_or_error": "[%key:component::whirlpool::entity::sensor::washer_state::state::hard_stop_or_error%]", + "system_initialize": "[%key:component::whirlpool::entity::sensor::washer_state::state::system_initialize%]", + "cycle_filling": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_filling%]", + "cycle_rinsing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_rinsing%]", + "cycle_sensing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_sensing%]", + "cycle_soaking": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_soaking%]", + "cycle_spinning": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_spinning%]", + "cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]", + "door_open": "[%key:component::whirlpool::entity::sensor::washer_state::state::door_open%]" } }, "whirlpool_tank": { @@ -93,6 +128,9 @@ "exceptions": { "account_locked": { "message": "[%key:component::whirlpool::common::account_locked_error%]" + }, + "appliances_fetch_failed": { + "message": "Failed to fetch appliances" } } } diff --git a/homeassistant/components/whois/config_flow.py b/homeassistant/components/whois/config_flow.py index cb4326d996d..a8306be7632 100644 --- a/homeassistant/components/whois/config_flow.py +++ b/homeassistant/components/whois/config_flow.py @@ -11,6 +11,8 @@ from whois.exceptions import ( UnknownDateFormat, UnknownTld, WhoisCommandFailed, + WhoisPrivateRegistry, + WhoisQuotaExceeded, ) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -48,6 +50,10 @@ class WhoisFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "unexpected_response" except UnknownDateFormat: errors["base"] = "unknown_date_format" + except WhoisPrivateRegistry: + errors["base"] = "private_registry" + except WhoisQuotaExceeded: + errors["base"] = "quota_exceeded" else: return self.async_create_entry( title=self.imported_name or user_input[CONF_DOMAIN], diff --git a/homeassistant/components/whois/const.py b/homeassistant/components/whois/const.py index f196053f48d..0b1d1717474 100644 --- a/homeassistant/components/whois/const.py +++ b/homeassistant/components/whois/const.py @@ -19,3 +19,31 @@ ATTR_EXPIRES = "expires" ATTR_NAME_SERVERS = "name_servers" ATTR_REGISTRAR = "registrar" ATTR_UPDATED = "updated" + +# Mapping of ICANN status codes to Home Assistant status types. +# From https://www.icann.org/resources/pages/epp-status-codes-2014-06-16-en +STATUS_TYPES = { + "addPeriod": "add_period", + "autoRenewPeriod": "auto_renew_period", + "inactive": "inactive", + "active": "active", + "pendingCreate": "pending_create", + "pendingRenew": "pending_renew", + "pendingRestore": "pending_restore", + "pendingTransfer": "pending_transfer", + "pendingUpdate": "pending_update", + "redemptionPeriod": "redemption_period", + "renewPeriod": "renew_period", + "serverDeleteProhibited": "server_delete_prohibited", + "serverHold": "server_hold", + "serverRenewProhibited": "server_renew_prohibited", + "serverTransferProhibited": "server_transfer_prohibited", + "serverUpdateProhibited": "server_update_prohibited", + "transferPeriod": "transfer_period", + "clientDeleteProhibited": "client_delete_prohibited", + "clientHold": "client_hold", + "clientRenewProhibited": "client_renew_prohibited", + "clientTransferProhibited": "client_transfer_prohibited", + "clientUpdateProhibited": "client_update_prohibited", + "ok": "ok", +} diff --git a/homeassistant/components/whois/icons.json b/homeassistant/components/whois/icons.json index 459ae252138..5ce1fb9717b 100644 --- a/homeassistant/components/whois/icons.json +++ b/homeassistant/components/whois/icons.json @@ -18,6 +18,9 @@ }, "reseller": { "default": "mdi:store" + }, + "status": { + "default": "mdi:check-circle" } } } diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 8098e052575..474ac366be2 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -25,7 +25,14 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import dt as dt_util -from .const import ATTR_EXPIRES, ATTR_NAME_SERVERS, ATTR_REGISTRAR, ATTR_UPDATED, DOMAIN +from .const import ( + ATTR_EXPIRES, + ATTR_NAME_SERVERS, + ATTR_REGISTRAR, + ATTR_UPDATED, + DOMAIN, + STATUS_TYPES, +) @dataclass(frozen=True, kw_only=True) @@ -58,6 +65,24 @@ def _ensure_timezone(timestamp: datetime | None) -> datetime | None: return timestamp +def _get_status_type(status: str | None) -> str | None: + """Get the status type from the status string. + + Returns the status type in snake_case, so it can be used as a key for the translations. + E.g: "clientDeleteProhibited https://icann.org/epp#clientDeleteProhibited" -> "client_delete_prohibited". + """ + if status is None: + return None + + # If the status is not in the STATUS_TYPES, return the status as is. + for icann_status, hass_status in STATUS_TYPES.items(): + if icann_status in status: + return hass_status + + # If the status is not in the STATUS_TYPES, return None. + return None + + SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( WhoisSensorEntityDescription( key="admin", @@ -121,6 +146,15 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, value_fn=lambda domain: getattr(domain, "reseller", None), ), + WhoisSensorEntityDescription( + key="status", + translation_key="status", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=list(STATUS_TYPES.values()), + entity_registry_enabled_default=False, + value_fn=lambda domain: _get_status_type(domain.status), + ), ) diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json index c28c079784d..b236bb06208 100644 --- a/homeassistant/components/whois/strings.json +++ b/homeassistant/components/whois/strings.json @@ -11,7 +11,9 @@ "unexpected_response": "Unexpected response from whois server", "unknown_date_format": "Unknown date format in whois server response", "unknown_tld": "The given TLD is unknown or not available to this integration", - "whois_command_failed": "Whois command failed: could not retrieve whois information" + "whois_command_failed": "Whois command failed: could not retrieve whois information", + "private_registry": "The given domain is registered in a private registry and cannot be monitored", + "quota_exceeded": "Your whois quota has been exceeded for this TLD" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" @@ -45,6 +47,34 @@ }, "reseller": { "name": "Reseller" + }, + "status": { + "name": "Status", + "state": { + "add_period": "Add period", + "auto_renew_period": "Auto renew period", + "inactive": "Inactive", + "ok": "Active", + "active": "Active", + "pending_create": "Pending create", + "pending_renew": "Pending renew", + "pending_restore": "Pending restore", + "pending_transfer": "Pending transfer", + "pending_update": "Pending update", + "redemption_period": "Redemption period", + "renew_period": "Renew period", + "server_delete_prohibited": "Server delete prohibited", + "server_hold": "Server hold", + "server_renew_prohibited": "Server renew prohibited", + "server_transfer_prohibited": "Server transfer prohibited", + "server_update_prohibited": "Server update prohibited", + "transfer_period": "Transfer period", + "client_delete_prohibited": "Client delete prohibited", + "client_hold": "Client hold", + "client_renew_prohibited": "Client renew prohibited", + "client_transfer_prohibited": "Client transfer prohibited", + "client_update_prohibited": "Client update prohibited" + } } } } diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py index 93fdb7cce1c..abb6dd11235 100644 --- a/homeassistant/components/wiffi/binary_sensor.py +++ b/homeassistant/components/wiffi/binary_sensor.py @@ -44,7 +44,7 @@ class BoolEntity(WiffiEntity, BinarySensorEntity): self.reset_expiration_date() @property - def available(self): + def available(self) -> bool: """Return true if value is valid.""" return self._attr_is_on is not None diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index 9afcc719c9b..f28c68dc31c 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -86,7 +86,7 @@ class NumberEntity(WiffiEntity, SensorEntity): self.reset_expiration_date() @property - def available(self): + def available(self) -> bool: """Return true if value is valid.""" return self._attr_native_value is not None @@ -116,7 +116,7 @@ class StringEntity(WiffiEntity, SensorEntity): self.reset_expiration_date() @property - def available(self): + def available(self) -> bool: """Return true if value is valid.""" return self._attr_native_value is not None diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 28a0fbd1492..f20145f8bf9 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -122,7 +122,7 @@ MEASUREMENT_SENSORS: dict[ measurement_type=MeasurementType.HEIGHT, translation_key="height", native_unit_of_measurement=UnitOfLength.METERS, - suggested_display_precision=1, + suggested_display_precision=2, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -326,6 +326,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.deep_sleep_duration, translation_key="deep_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), @@ -334,6 +335,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.sleep_latency, translation_key="time_to_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -343,6 +345,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.wake_up_latency, translation_key="time_to_wakeup", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -376,6 +379,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.light_sleep_duration, translation_key="light_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -385,6 +389,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.rem_sleep_duration, translation_key="rem_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -451,6 +456,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.total_time_awake, translation_key="wakeup_time", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 775ef5cdaab..746fa244c8e 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -313,9 +313,9 @@ "battery": { "name": "[%key:component::sensor::entity_component::battery::name%]", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index 7b1ecdcdb6b..947e7f0b638 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -26,5 +26,5 @@ ], "documentation": "https://www.home-assistant.io/integrations/wiz", "iot_class": "local_push", - "requirements": ["pywizlight==0.5.14"] + "requirements": ["pywizlight==0.6.2"] } diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index e340c323151..76837652ae5 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -79,9 +79,10 @@ class WLEDPresetSelect(WLEDEntity, SelectEntity): super().__init__(coordinator=coordinator) self._attr_unique_id = f"{coordinator.data.info.mac_address}_preset" - self._attr_options = [ - preset.name for preset in self.coordinator.data.presets.values() - ] + sorted_values = sorted( + coordinator.data.presets.values(), key=lambda preset: preset.name + ) + self._attr_options = [preset.name for preset in sorted_values] @property def available(self) -> bool: @@ -115,9 +116,10 @@ class WLEDPlaylistSelect(WLEDEntity, SelectEntity): super().__init__(coordinator=coordinator) self._attr_unique_id = f"{coordinator.data.info.mac_address}_playlist" - self._attr_options = [ - playlist.name for playlist in self.coordinator.data.playlists.values() - ] + sorted_values = sorted( + coordinator.data.playlists.values(), key=lambda playlist: playlist.name + ) + self._attr_options = [playlist.name for playlist in sorted_values] @property def available(self) -> bool: @@ -159,9 +161,10 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity): self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = f"{coordinator.data.info.mac_address}_palette_{segment}" - self._attr_options = [ - palette.name for palette in self.coordinator.data.palettes.values() - ] + sorted_values = sorted( + coordinator.data.palettes.values(), key=lambda palette: palette.name + ) + self._attr_options = [palette.name for palette in sorted_values] self._segment = segment @property diff --git a/homeassistant/components/wmspro/__init__.py b/homeassistant/components/wmspro/__init__.py index 37bf1495a56..ebfdf5b8b34 100644 --- a/homeassistant/components/wmspro/__init__.py +++ b/homeassistant/components/wmspro/__init__.py @@ -15,7 +15,12 @@ from homeassistant.helpers.typing import UNDEFINED from .const import DOMAIN, MANUFACTURER -PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SCENE] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.COVER, + Platform.LIGHT, + Platform.SCENE, +] type WebControlProConfigEntry = ConfigEntry[WebControlPro] diff --git a/homeassistant/components/wmspro/button.py b/homeassistant/components/wmspro/button.py new file mode 100644 index 00000000000..f1ab0489b86 --- /dev/null +++ b/homeassistant/components/wmspro/button.py @@ -0,0 +1,40 @@ +"""Identify support for WMS WebControl pro.""" + +from __future__ import annotations + +from wmspro.const import WMS_WebControl_pro_API_actionDescription + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WebControlProConfigEntry +from .entity import WebControlProGenericEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WebControlProConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the WMS based identify buttons from a config entry.""" + hub = config_entry.runtime_data + + entities: list[WebControlProGenericEntity] = [ + WebControlProIdentifyButton(config_entry.entry_id, dest) + for dest in hub.dests.values() + if dest.action(WMS_WebControl_pro_API_actionDescription.Identify) + ] + + async_add_entities(entities) + + +class WebControlProIdentifyButton(WebControlProGenericEntity, ButtonEntity): + """Representation of a WMS based identify button.""" + + _attr_device_class = ButtonDeviceClass.IDENTIFY + + async def async_press(self) -> None: + """Handle the button press.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.Identify) + await action() diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 715add3023f..0d9ccb8547d 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import timedelta from typing import Any @@ -17,7 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WebControlProConfigEntry from .entity import WebControlProGenericEntity -SCAN_INTERVAL = timedelta(seconds=5) +ACTION_DELAY = 0.5 +SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 1 @@ -32,26 +34,32 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): if dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive): - entities.append(WebControlProAwning(config_entry.entry_id, dest)) # noqa: PERF401 + entities.append(WebControlProAwning(config_entry.entry_id, dest)) + elif dest.action( + WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive + ): + entities.append(WebControlProRollerShutter(config_entry.entry_id, dest)) async_add_entities(entities) -class WebControlProAwning(WebControlProGenericEntity, CoverEntity): - """Representation of a WMS based awning.""" +class WebControlProCover(WebControlProGenericEntity, CoverEntity): + """Base representation of a WMS based cover.""" - _attr_device_class = CoverDeviceClass.AWNING + _drive_action_desc: WMS_WebControl_pro_API_actionDescription + _attr_name = None @property def current_cover_position(self) -> int | None: """Return current position of cover.""" - action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + action = self._dest.action(self._drive_action_desc) return 100 - action["percentage"] async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + action = self._dest.action(self._drive_action_desc) await action(percentage=100 - kwargs[ATTR_POSITION]) + await asyncio.sleep(ACTION_DELAY) @property def is_closed(self) -> bool | None: @@ -60,13 +68,15 @@ class WebControlProAwning(WebControlProGenericEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + action = self._dest.action(self._drive_action_desc) await action(percentage=0) + await asyncio.sleep(ACTION_DELAY) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + action = self._dest.action(self._drive_action_desc) await action(percentage=100) + await asyncio.sleep(ACTION_DELAY) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" @@ -75,3 +85,20 @@ class WebControlProAwning(WebControlProGenericEntity, CoverEntity): WMS_WebControl_pro_API_actionType.Stop, ) await action() + await asyncio.sleep(ACTION_DELAY) + + +class WebControlProAwning(WebControlProCover): + """Representation of a WMS based awning.""" + + _attr_device_class = CoverDeviceClass.AWNING + _drive_action_desc = WMS_WebControl_pro_API_actionDescription.AwningDrive + + +class WebControlProRollerShutter(WebControlProCover): + """Representation of a WMS based roller shutter or blind.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _drive_action_desc = ( + WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive + ) diff --git a/homeassistant/components/wmspro/entity.py b/homeassistant/components/wmspro/entity.py index 0bbbc69a294..758a89b7ed8 100644 --- a/homeassistant/components/wmspro/entity.py +++ b/homeassistant/components/wmspro/entity.py @@ -15,7 +15,6 @@ class WebControlProGenericEntity(Entity): _attr_attribution = ATTRIBUTION _attr_has_entity_name = True - _attr_name = None def __init__(self, config_entry_id: str, dest: Destination) -> None: """Initialize the entity with destination channel.""" diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index d181beb1eaa..d828c8a26e8 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import timedelta from typing import Any @@ -16,7 +17,8 @@ from . import WebControlProConfigEntry from .const import BRIGHTNESS_SCALE from .entity import WebControlProGenericEntity -SCAN_INTERVAL = timedelta(seconds=5) +ACTION_DELAY = 0.5 +SCAN_INTERVAL = timedelta(seconds=15) PARALLEL_UPDATES = 1 @@ -42,6 +44,7 @@ class WebControlProLight(WebControlProGenericEntity, LightEntity): """Representation of a WMS based light.""" _attr_color_mode = ColorMode.ONOFF + _attr_name = None _attr_supported_color_modes = {ColorMode.ONOFF} @property @@ -54,11 +57,13 @@ class WebControlProLight(WebControlProGenericEntity, LightEntity): """Turn the light on.""" action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch) await action(onOffState=True) + await asyncio.sleep(ACTION_DELAY) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch) await action(onOffState=False) + await asyncio.sleep(ACTION_DELAY) class WebControlProDimmer(WebControlProLight): @@ -87,3 +92,4 @@ class WebControlProDimmer(WebControlProLight): await action( percentage=brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS]) ) + await asyncio.sleep(ACTION_DELAY) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index dd65be3e7e7..d4eda3a90a6 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.2.1"] + "requirements": ["pywmspro==0.2.2"] } diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index 964d192d279..5f3a6366fe1 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wolflink", "iot_class": "cloud_polling", "loggers": ["wolf_comm"], - "requirements": ["wolf-comm==0.0.19"] + "requirements": ["wolf-comm==0.0.23"] } diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index cf6d712dd0d..9380c28de89 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -2,19 +2,42 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from wolf_comm.models import ( + EnergyParameter, + FlowParameter, + FrequencyParameter, HoursParameter, ListItemParameter, Parameter, PercentageParameter, + PowerParameter, Pressure, + RPMParameter, SimpleParameter, Temperature, ) -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPressure, UnitOfTemperature, UnitOfTime +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,31 +46,106 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STATES +def get_listitem_resolve_state(wolf_object, state): + """Resolve list item state.""" + resolved_state = [item for item in wolf_object.items if item.value == int(state)] + if resolved_state: + resolved_name = resolved_state[0].name + state = STATES.get(resolved_name, resolved_name) + return state + + +@dataclass(kw_only=True, frozen=True) +class WolflinkSensorEntityDescription(SensorEntityDescription): + """Describes Wolflink sensor entity.""" + + value_fn: Callable[[Parameter, str], str | None] = lambda param, value: value + supported_fn: Callable[[Parameter], bool] + + +SENSOR_DESCRIPTIONS = [ + WolflinkSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + supported_fn=lambda param: isinstance(param, Temperature), + ), + WolflinkSensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.BAR, + supported_fn=lambda param: isinstance(param, Pressure), + ), + WolflinkSensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + supported_fn=lambda param: isinstance(param, EnergyParameter), + ), + WolflinkSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + supported_fn=lambda param: isinstance(param, PowerParameter), + ), + WolflinkSensorEntityDescription( + key="percentage", + native_unit_of_measurement=PERCENTAGE, + supported_fn=lambda param: isinstance(param, PercentageParameter), + ), + WolflinkSensorEntityDescription( + key="list_item", + translation_key="state", + supported_fn=lambda param: isinstance(param, ListItemParameter), + value_fn=get_listitem_resolve_state, + ), + WolflinkSensorEntityDescription( + key="hours", + icon="mdi:clock", + native_unit_of_measurement=UnitOfTime.HOURS, + supported_fn=lambda param: isinstance(param, HoursParameter), + ), + WolflinkSensorEntityDescription( + key="flow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + supported_fn=lambda param: isinstance(param, FlowParameter), + ), + WolflinkSensorEntityDescription( + key="frequency", + device_class=SensorDeviceClass.FREQUENCY, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + supported_fn=lambda param: isinstance(param, FrequencyParameter), + ), + WolflinkSensorEntityDescription( + key="rpm", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + supported_fn=lambda param: isinstance(param, RPMParameter), + ), + WolflinkSensorEntityDescription( + key="default", + supported_fn=lambda param: isinstance(param, SimpleParameter), + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all entries for Wolf Platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] parameters = hass.data[DOMAIN][config_entry.entry_id][PARAMETERS] device_id = hass.data[DOMAIN][config_entry.entry_id][DEVICE_ID] - entities: list[WolfLinkSensor] = [] - for parameter in parameters: - if isinstance(parameter, Temperature): - entities.append(WolfLinkTemperature(coordinator, parameter, device_id)) - if isinstance(parameter, Pressure): - entities.append(WolfLinkPressure(coordinator, parameter, device_id)) - if isinstance(parameter, PercentageParameter): - entities.append(WolfLinkPercentage(coordinator, parameter, device_id)) - if isinstance(parameter, ListItemParameter): - entities.append(WolfLinkState(coordinator, parameter, device_id)) - if isinstance(parameter, HoursParameter): - entities.append(WolfLinkHours(coordinator, parameter, device_id)) - if isinstance(parameter, SimpleParameter): - entities.append(WolfLinkSensor(coordinator, parameter, device_id)) + entities: list[WolfLinkSensor] = [ + WolfLinkSensor(coordinator, parameter, device_id, description) + for parameter in parameters + for description in SENSOR_DESCRIPTIONS + if description.supported_fn(parameter) + ] async_add_entities(entities, True) @@ -55,9 +153,18 @@ async def async_setup_entry( class WolfLinkSensor(CoordinatorEntity, SensorEntity): """Base class for all Wolf entities.""" - def __init__(self, coordinator, wolf_object: Parameter, device_id) -> None: + entity_description: WolflinkSensorEntityDescription + + def __init__( + self, + coordinator, + wolf_object: Parameter, + device_id: str, + description: WolflinkSensorEntityDescription, + ) -> None: """Initialize.""" super().__init__(coordinator) + self.entity_description = description self.wolf_object = wolf_object self._attr_name = wolf_object.name self._attr_unique_id = f"{device_id}:{wolf_object.parameter_id}" @@ -69,68 +176,26 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): ) @property - def native_value(self): + def native_value(self) -> str | None: """Return the state. Wolf Client is returning only changed values so we need to store old value here.""" if self.wolf_object.parameter_id in self.coordinator.data: new_state = self.coordinator.data[self.wolf_object.parameter_id] self.wolf_object.value_id = new_state[0] self._state = new_state[1] + if ( + isinstance(self.wolf_object, ListItemParameter) + and self._state is not None + ): + self._state = self.entity_description.value_fn( + self.wolf_object, self._state + ) return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" return { "parameter_id": self.wolf_object.parameter_id, "value_id": self.wolf_object.value_id, "parent": self.wolf_object.parent, } - - -class WolfLinkHours(WolfLinkSensor): - """Class for hour based entities.""" - - _attr_icon = "mdi:clock" - _attr_native_unit_of_measurement = UnitOfTime.HOURS - - -class WolfLinkTemperature(WolfLinkSensor): - """Class for temperature based entities.""" - - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - - -class WolfLinkPressure(WolfLinkSensor): - """Class for pressure based entities.""" - - _attr_device_class = SensorDeviceClass.PRESSURE - _attr_native_unit_of_measurement = UnitOfPressure.BAR - - -class WolfLinkPercentage(WolfLinkSensor): - """Class for percentage based entities.""" - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self.wolf_object.unit - - -class WolfLinkState(WolfLinkSensor): - """Class for entities which has defined list of state.""" - - _attr_translation_key = "state" - - @property - def native_value(self): - """Return the state converting with supported values.""" - state = super().native_value - if state is not None: - resolved_state = [ - item for item in self.wolf_object.items if item.value == int(state) - ] - if resolved_state: - resolved_name = resolved_state[0].name - return STATES.get(resolved_name, resolved_name) - return state diff --git a/homeassistant/components/wolflink/strings.json b/homeassistant/components/wolflink/strings.json index b1c332984a1..ba746a579cd 100644 --- a/homeassistant/components/wolflink/strings.json +++ b/homeassistant/components/wolflink/strings.json @@ -28,14 +28,15 @@ "sensor": { "state": { "state": { - "ein": "[%key:common::state::enabled%]", - "deaktiviert": "Inactive", - "aus": "[%key:common::state::disabled%]", + "ein": "[%key:common::state::on%]", + "aus": "[%key:common::state::off%]", + "deaktiviert": "[%key:common::state::disabled%]", "standby": "[%key:common::state::standby%]", - "auto": "Auto", + "storung": "[%key:common::state::fault%]", + "auto": "[%key:common::state::auto%]", "permanent": "Permanent", "initialisierung": "Initialization", - "antilegionellenfunktion": "Anti-legionella Function", + "antilegionellenfunktion": "Anti-legionella function", "fernschalter_ein": "Remote control enabled", "1_x_warmwasser": "1 x DHW", "bereit_keine_ladung": "Ready, not loading", @@ -53,7 +54,6 @@ "taktsperre": "Anti-cycle", "betrieb_ohne_brenner": "Working without burner", "abgasklappe": "Flue gas damper", - "storung": "Fault", "gradienten_uberwachung": "Gradient monitoring", "gasdruck": "Gas pressure", "spreizung_hoch": "dT too wide", diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 895c7cd50e2..b0b1e9fcc02 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, NumberSelectorMode, + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -79,10 +80,19 @@ def add_province_and_language_to_schema( } if provinces := all_countries.get(country): + if _country.subdivisions_aliases and ( + subdiv_aliases := _country.get_subdivision_aliases() + ): + province_options: list[Any] = [ + SelectOptionDict(value=k, label=", ".join(v)) + for k, v in subdiv_aliases.items() + ] + else: + province_options = provinces province_schema = { vol.Optional(CONF_PROVINCE): SelectSelector( SelectSelectorConfig( - options=provinces, + options=province_options, mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_PROVINCE, ) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index beb828641a4..542b68169a3 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.67"] + "requirements": ["holidays==0.72"] } diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 87fa294dbba..feedc52331b 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -2,13 +2,13 @@ "title": "Workday", "config": { "abort": { - "already_configured": "Workday has already been setup with chosen configuration" + "already_configured": "Workday has already been set up with chosen configuration" }, "step": { "user": { "data": { "name": "[%key:common::config_flow::data::name%]", - "country": "Country" + "country": "[%key:common::config_flow::data::country%]" } }, "options": { @@ -18,7 +18,7 @@ "days_offset": "Offset", "workdays": "Days to include", "add_holidays": "Add holidays", - "remove_holidays": "Remove Holidays", + "remove_holidays": "Remove holidays", "province": "Subdivision of country", "language": "Language for named holidays", "category": "Additional category as holiday" @@ -116,14 +116,14 @@ }, "issues": { "bad_country": { - "title": "Configured Country for {title} does not exist", + "title": "Configured country for {title} does not exist", "fix_flow": { "step": { "country": { "title": "Select country for {title}", "description": "Select a country to use for your Workday sensor.", "data": { - "country": "[%key:component::workday::config::step::user::data::country%]" + "country": "[%key:common::config_flow::data::country%]" } }, "province": { @@ -133,7 +133,7 @@ "province": "[%key:component::workday::config::step::options::data::province%]" }, "data_description": { - "province": "State, Territory, Province, Region of Country" + "province": "[%key:component::workday::config::step::options::data_description::province%]" } } } @@ -150,7 +150,7 @@ "province": "[%key:component::workday::config::step::options::data::province%]" }, "data_description": { - "province": "[%key:component::workday::issues::bad_country::fix_flow::step::province::data_description::province%]" + "province": "[%key:component::workday::config::step::options::data_description::province%]" } } } @@ -217,7 +217,7 @@ "services": { "check_date": { "name": "Check date", - "description": "Check if date is workday.", + "description": "Checks if a given date is a workday.", "fields": { "check_date": { "name": "Date", diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index 8ae93c809f2..b3eb2715562 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -65,7 +65,7 @@ def setup_platform( name = travel_time.get(CONF_NAME) or travel_time.get(CONF_ID) sensors.append( WashingtonStateTravelTimeSensor( - name, config.get(CONF_API_KEY), travel_time.get(CONF_ID) + name, config[CONF_API_KEY], travel_time.get(CONF_ID) ) ) @@ -82,20 +82,20 @@ class WashingtonStateTransportSensor(SensorEntity): _attr_icon = ICON - def __init__(self, name, access_code): + def __init__(self, name: str, access_code: str) -> None: """Initialize the sensor.""" - self._data = {} + self._data: dict[str, str | int | None] = {} self._access_code = access_code self._name = name - self._state = None + self._state: int | None = None @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._state @@ -106,7 +106,7 @@ class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): _attr_attribution = ATTRIBUTION _attr_native_unit_of_measurement = UnitOfTime.MINUTES - def __init__(self, name, access_code, travel_time_id): + def __init__(self, name: str, access_code: str, travel_time_id: str) -> None: """Construct a travel time sensor.""" self._travel_time_id = travel_time_id WashingtonStateTransportSensor.__init__(self, name, access_code) @@ -123,13 +123,17 @@ class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): _LOGGER.warning("Invalid response from WSDOT API") else: self._data = response.json() - self._state = self._data.get(ATTR_CURRENT_TIME) + _state = self._data.get(ATTR_CURRENT_TIME) + if not isinstance(_state, int): + self._state = None + else: + self._state = _state @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return other details about the sensor state.""" if self._data is not None: - attrs = {} + attrs: dict[str, str | int | None | datetime] = {} for key in ( ATTR_AVG_TIME, ATTR_NAME, @@ -144,12 +148,15 @@ class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): return None -def _parse_wsdot_timestamp(timestamp): +def _parse_wsdot_timestamp(timestamp: Any) -> datetime | None: """Convert WSDOT timestamp to datetime.""" - if not timestamp: + if not isinstance(timestamp, str): return None # ex: Date(1485040200000-0800) - milliseconds, tzone = re.search(r"Date\((\d+)([+-]\d\d)\d\d\)", timestamp).groups() + timestamp_parts = re.search(r"Date\((\d+)([+-]\d\d)\d\d\)", timestamp) + if timestamp_parts is None: + return None + milliseconds, tzone = timestamp_parts.groups() return datetime.fromtimestamp( int(milliseconds) / 1000, tz=timezone(timedelta(hours=int(tzone))) ) diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index d639933ece6..4e76287d8e7 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -8,15 +8,19 @@ 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 homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .devices import SatelliteDevice from .models import DomainDataItem +from .websocket_api import async_register_websocket_api _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + SATELLITE_PLATFORMS = [ Platform.ASSIST_SATELLITE, Platform.BINARY_SENSOR, @@ -28,11 +32,19 @@ SATELLITE_PLATFORMS = [ __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup", "async_setup_entry", "async_unload_entry", ] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Wyoming integration.""" + async_register_websocket_api(hass) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load Wyoming.""" service = await WyomingService.create(entry.data["host"], entry.data["port"]) diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 5440b2bebeb..88939f0ba77 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -178,7 +178,11 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._pipeline_ended_event.set() self.device.set_is_active(False) elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: - self.hass.add_job(self._client.write_event(Detect().event())) + self.config_entry.async_create_background_task( + self.hass, + self._client.write_event(Detect().event()), + f"{self.entity_id} {event.type}", + ) elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END: # Wake word detection # Inform client of wake word detection @@ -187,46 +191,59 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): name=wake_word_output["wake_word_id"], timestamp=wake_word_output.get("timestamp"), ) - self.hass.add_job(self._client.write_event(detection.event())) + self.config_entry.async_create_background_task( + self.hass, + self._client.write_event(detection.event()), + f"{self.entity_id} {event.type}", + ) elif event.type == assist_pipeline.PipelineEventType.STT_START: # Speech-to-text self.device.set_is_active(True) if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( Transcribe(language=event.data["metadata"]["language"]).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.STT_VAD_START: # User started speaking if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( VoiceStarted(timestamp=event.data["timestamp"]).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.STT_VAD_END: # User stopped speaking if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( VoiceStopped(timestamp=event.data["timestamp"]).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.STT_END: # Speech-to-text transcript if event.data: # Inform client of transript stt_text = event.data["stt_output"]["text"] - self.hass.add_job( - self._client.write_event(Transcript(text=stt_text).event()) + self.config_entry.async_create_background_task( + self.hass, + self._client.write_event(Transcript(text=stt_text).event()), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.TTS_START: # Text-to-speech text if event.data: # Inform client of text - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( Synthesize( text=event.data["tts_input"], @@ -235,22 +252,32 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): language=event.data.get("language"), ), ).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.TTS_END: # TTS stream - if event.data and (tts_output := event.data["tts_output"]): - media_id = tts_output["media_id"] - self.hass.add_job(self._stream_tts(media_id)) + if ( + event.data + and (tts_output := event.data["tts_output"]) + and (stream := tts.async_get_stream(self.hass, tts_output["token"])) + ): + self.config_entry.async_create_background_task( + self.hass, + self._stream_tts(stream), + f"{self.entity_id} {event.type}", + ) elif event.type == assist_pipeline.PipelineEventType.ERROR: # Pipeline error if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( Error( text=event.data["message"], code=event.data["code"] ).event() - ) + ), + f"{self.entity_id} {event.type}", ) async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: @@ -662,13 +689,16 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): await self._client.disconnect() self._client = None - async def _stream_tts(self, media_id: str) -> None: + async def _stream_tts(self, tts_result: tts.ResultStream) -> None: """Stream TTS WAV audio to satellite in chunks.""" assert self._client is not None - extension, data = await tts.async_get_media_source_audio(self.hass, media_id) - if extension != "wav": - raise ValueError(f"Cannot stream audio format to satellite: {extension}") + if tts_result.extension != "wav": + raise ValueError( + f"Cannot stream audio format to satellite: {tts_result.extension}" + ) + + data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: sample_rate = wav_file.getframerate() diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 4a1a4c3a246..2578b0e5278 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -40,10 +40,10 @@ "noise_suppression_level": { "name": "Noise suppression level", "state": { - "off": "Off", - "low": "Low", - "medium": "Medium", - "high": "High", + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "max": "Max" } }, diff --git a/homeassistant/components/wyoming/websocket_api.py b/homeassistant/components/wyoming/websocket_api.py new file mode 100644 index 00000000000..613238c302a --- /dev/null +++ b/homeassistant/components/wyoming/websocket_api.py @@ -0,0 +1,42 @@ +"""Wyoming Websocket API.""" + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .models import DomainDataItem + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the websocket API.""" + websocket_api.async_register_command(hass, websocket_info) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "wyoming/info"}) +def websocket_info( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List service information for Wyoming all config entries.""" + entry_items: dict[str, DomainDataItem] = hass.data.get(DOMAIN, {}) + + connection.send_result( + msg["id"], + { + "info": { + entry_id: item.service.info.to_dict() + for entry_id, item in entry_items.items() + } + }, + ) diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index 8ea99cf1f84..aab443c67fa 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -37,6 +37,7 @@ LOCK_FINGERPRINT = "lock_fingerprint" MOTION_DEVICE: Final = "motion_device" DOUBLE_BUTTON: Final = "double_button" TRIPPLE_BUTTON: Final = "tripple_button" +QUADRUPLE_BUTTON: Final = "quadruple_button" REMOTE: Final = "remote" REMOTE_FAN: Final = "remote_fan" REMOTE_VENFAN: Final = "remote_ventilator_fan" @@ -48,6 +49,7 @@ BUTTON_PRESS_LONG: Final = "button_press_long" BUTTON_PRESS_DOUBLE_LONG: Final = "button_press_double_long" DOUBLE_BUTTON_PRESS_DOUBLE_LONG: Final = "double_button_press_double_long" TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "tripple_button_press_double_long" +QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "quadruple_button_press_double_long" class XiaomiBleEvent(TypedDict): diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index 119424788db..3c5488a1e74 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -47,6 +47,8 @@ from .const import ( LOCK_FINGERPRINT, MOTION, MOTION_DEVICE, + QUADRUPLE_BUTTON, + QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG, REMOTE, REMOTE_BATHROOM, REMOTE_FAN, @@ -123,6 +125,12 @@ EVENT_TYPES = { DIMMER: ["dimmer"], DOUBLE_BUTTON: ["button_left", "button_right"], TRIPPLE_BUTTON: ["button_left", "button_middle", "button_right"], + QUADRUPLE_BUTTON: [ + "button_left", + "button_mid_left", + "button_mid_right", + "button_right", + ], ERROR: ["error"], FINGERPRINT: ["fingerprint"], LOCK: ["lock"], @@ -205,6 +213,11 @@ TRIGGER_MODEL_DATA = { event_types=EVENT_TYPES[TRIPPLE_BUTTON], triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], ), + QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[QUADRUPLE_BUTTON], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], + ), ERROR: TriggerModelData( event_class=EVENT_CLASS_ERROR, event_types=EVENT_TYPES[ERROR], @@ -261,6 +274,8 @@ MODEL_DATA = { "XMWXKG01YL": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-2BTN": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-3BTN": TRIGGER_MODEL_DATA[TRIPPLE_BUTTON_PRESS_DOUBLE_LONG], + "KS1": TRIGGER_MODEL_DATA[QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG], + "KS1BP": TRIGGER_MODEL_DATA[QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG], "YLYK01YL": TRIGGER_MODEL_DATA[REMOTE], "YLYK01YL-FANRC": TRIGGER_MODEL_DATA[REMOTE_FAN], "YLYK01YL-VENFAN": TRIGGER_MODEL_DATA[REMOTE_VENFAN], diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 26dd82c73bc..3f13c7921a8 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.33.0"] + "requirements": ["xiaomi-ble==0.38.0"] } diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index 01f15ff09b8..0fcae1925bb 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -9,9 +9,11 @@ from xiaomi_ble.parser import ExtendedSensorDeviceClass from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataUpdate, + PassiveBluetoothEntityKey, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( + EntityDescription, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -78,6 +80,7 @@ SENSOR_DESCRIPTIONS = { icon="mdi:omega", native_unit_of_measurement=Units.OHM, state_class=SensorStateClass.MEASUREMENT, + translation_key="impedance", ), # Mass sensor (kg) (DeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription( @@ -93,6 +96,7 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfMass.KILOGRAMS, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + translation_key="weight_non_stabilized", ), (DeviceClass.MOISTURE, Units.PERCENTAGE): SensorEntityDescription( key=f"{DeviceClass.MOISTURE}_{Units.PERCENTAGE}", @@ -173,6 +177,25 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), + # Low frequency impedance sensor (ohm) + (ExtendedSensorDeviceClass.IMPEDANCE_LOW, Units.OHM): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.IMPEDANCE_LOW), + native_unit_of_measurement=Units.OHM, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:omega", + ), + # Heart rate sensor (bpm) + (ExtendedSensorDeviceClass.HEART_RATE, "bpm"): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.HEART_RATE), + native_unit_of_measurement="bpm", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:heart-pulse", + ), + # User profile ID sensor + (ExtendedSensorDeviceClass.PROFILE_ID, None): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.PROFILE_ID), + icon="mdi:identifier", + ), } @@ -180,18 +203,20 @@ def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, ) -> PassiveBluetoothDataUpdate[float | None]: """Convert a sensor update to a bluetooth data update.""" + entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] = { + device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + (description.device_class, description.native_unit_of_measurement) + ] + for device_key, description in sensor_update.entity_descriptions.items() + if description.device_class + } + return PassiveBluetoothDataUpdate( devices={ device_id: sensor_device_info_to_hass_device_info(device_info) for device_id, device_info in sensor_update.devices.items() }, - entity_descriptions={ - device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ - (description.device_class, description.native_unit_of_measurement) - ] - for device_key, description in sensor_update.entity_descriptions.items() - if description.device_class - }, + entity_descriptions=entity_descriptions, entity_data={ device_key_to_bluetooth_entity_key(device_key): cast( float | None, sensor_values.native_value @@ -201,6 +226,17 @@ def sensor_update_to_bluetooth_data_update( entity_names={ device_key_to_bluetooth_entity_key(device_key): sensor_values.name for device_key, sensor_values in sensor_update.entity_values.items() + # Add names where the entity description has neither a translation_key nor + # a device_class + if ( + description := entity_descriptions.get( + device_key_to_bluetooth_entity_key(device_key) + ) + ) + is None + or ( + description.translation_key is None and description.device_class is None + ) }, ) diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 4ea4a47c61e..06b49b8e86f 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -86,6 +86,8 @@ "trigger_type": { "button": "Button \"{subtype}\"", "button_left": "Button Left \"{subtype}\"", + "button_mid_left": "Button Mid Left \"{subtype}\"", + "button_mid_right": "Button Mid Right \"{subtype}\"", "button_middle": "Button Middle \"{subtype}\"", "button_right": "Button Right \"{subtype}\"", "button_on": "Button On \"{subtype}\"", @@ -227,6 +229,14 @@ } } } + }, + "sensor": { + "impedance": { + "name": "Impedance" + }, + "weight_non_stabilized": { + "name": "Weight non stabilized" + } } } } diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index a5d1b4b69c6..a7bcb3a12fe 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -117,7 +117,7 @@ MODEL_TO_BUTTON_MAP: dict[str, tuple[str, ...]] = { ATTR_RESET_DUST_FILTER, ATTR_RESET_UPPER_FILTER, ), - **{model: BUTTONS_FOR_VACUUM for model in MODELS_VACUUM}, + **dict.fromkeys(MODELS_VACUUM, BUTTONS_FOR_VACUUM), } diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 81f68306cbc..781ac0b4acd 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -271,7 +271,7 @@ class XiaomiPhilipsAbstractLight(XiaomiMiioEntity, LightEntity): self._state_attrs = {} @property - def available(self): + def available(self) -> bool: """Return true when state is known.""" return self._available @@ -1027,7 +1027,7 @@ class XiaomiGatewayLight(LightEntity): return self._name @property - def available(self): + def available(self) -> bool: """Return true when state is known.""" return self._available diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 6f623c46af8..e837192ddd7 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -928,7 +928,7 @@ class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): self.entity_description = description @property - def available(self): + def available(self) -> bool: """Return true when state is known.""" return self._available @@ -1001,7 +1001,7 @@ class XiaomiGatewayIlluminanceSensor(SensorEntity): self._state = None @property - def available(self): + def available(self) -> bool: """Return true when state is known.""" return self._available diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index bd3b3499689..a5af3d8bd1f 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -14,7 +14,7 @@ "unknown_device": "The device model is not known, not able to set up the device using config flow.", "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", - "cloud_login_error": "Could not login to Xiaomi Miio Cloud, check the credentials." + "cloud_login_error": "Could not log in to Xiaomi Miio Cloud, check the credentials." }, "flow_title": "{name}", "step": { @@ -82,15 +82,15 @@ "airpurifier_mode": { "state": { "silent": "Silent", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "favorite": "Favorite" } }, "ptc_level": { "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, @@ -100,7 +100,7 @@ "preset_mode": { "state": { "nature": "Nature", - "normal": "Normal" + "normal": "[%key:common::state::normal%]" } } } diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index e4b94aebc20..4469849eae7 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -789,7 +789,7 @@ class XiaomiPlugGenericSwitch(XiaomiMiioEntity, SwitchEntity): return self._icon @property - def available(self): + def available(self) -> bool: """Return true when state is known.""" return self._available diff --git a/homeassistant/components/xs1/climate.py b/homeassistant/components/xs1/climate.py index 3bb80df25b2..3f44cb1504d 100644 --- a/homeassistant/components/xs1/climate.py +++ b/homeassistant/components/xs1/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 ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS +from . import ACTUATORS, DOMAIN, SENSORS from .entity import XS1DeviceEntity MIN_TEMP = 8 @@ -30,8 +30,8 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the XS1 thermostat platform.""" - actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS] - sensors = hass.data[COMPONENT_DOMAIN][SENSORS] + actuators = hass.data[DOMAIN][ACTUATORS] + sensors = hass.data[DOMAIN][SENSORS] thermostat_entities = [] for actuator in actuators: diff --git a/homeassistant/components/xs1/sensor.py b/homeassistant/components/xs1/sensor.py index b3895d67d82..26c009b15ee 100644 --- a/homeassistant/components/xs1/sensor.py +++ b/homeassistant/components/xs1/sensor.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS +from . import ACTUATORS, DOMAIN, SENSORS from .entity import XS1DeviceEntity @@ -20,8 +20,8 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the XS1 sensor platform.""" - sensors = hass.data[COMPONENT_DOMAIN][SENSORS] - actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS] + sensors = hass.data[DOMAIN][SENSORS] + actuators = hass.data[DOMAIN][ACTUATORS] sensor_entities = [] for sensor in sensors: diff --git a/homeassistant/components/xs1/switch.py b/homeassistant/components/xs1/switch.py index a8f66390a6d..5e107099515 100644 --- a/homeassistant/components/xs1/switch.py +++ b/homeassistant/components/xs1/switch.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN +from . import ACTUATORS, DOMAIN from .entity import XS1DeviceEntity @@ -22,7 +22,7 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the XS1 switch platform.""" - actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS] + actuators = hass.data[DOMAIN][ACTUATORS] add_entities( XS1SwitchEntity(actuator) diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 5c8e98b1e6e..4d9ea9ec2c9 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.7"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.6.0"] } diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index ebcf0b3af63..fd8d403da8d 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -71,8 +71,8 @@ "volume": { "name": "Volume", "state": { - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "off": "[%key:common::state::off%]" } } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index c44f0fdd1e9..2387f5dc15f 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.7"] + "requirements": ["yalexs-ble==2.6.0"] } diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index eaa5ac50c80..e38eb5955d9 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -29,29 +29,29 @@ "select": { "dimmer": { "state": { - "auto": "Auto" + "auto": "[%key:common::state::auto%]" } }, "zone_sleep": { "state": { "off": "[%key:common::state::off%]", - "30_min": "30 Minutes", - "60_min": "60 Minutes", - "90_min": "90 Minutes", - "120_min": "120 Minutes" + "30_min": "30 minutes", + "60_min": "60 minutes", + "90_min": "90 minutes", + "120_min": "120 minutes" } }, "zone_tone_control_mode": { "state": { - "manual": "Manual", - "auto": "Auto", + "manual": "[%key:common::state::manual%]", + "auto": "[%key:common::state::auto%]", "bypass": "Bypass" } }, "zone_surr_decoder_type": { "state": { "toggle": "[%key:common::action::toggle%]", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "dolby_pl": "Dolby ProLogic", "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", "dolby_pl2x_music": "Dolby ProLogic 2x Music", @@ -64,8 +64,8 @@ }, "zone_equalizer_mode": { "state": { - "manual": "Manual", - "auto": "Auto", + "manual": "[%key:common::state::manual%]", + "auto": "[%key:common::state::auto%]", "bypass": "[%key:component::yamaha_musiccast::entity::select::zone_tone_control_mode::state::bypass%]" } }, @@ -84,11 +84,11 @@ }, "zone_link_audio_delay": { "state": { - "audio_sync_on": "Audio Synchronization On", - "audio_sync_off": "Audio Synchronization Off", + "audio_sync_on": "Audio synchronization on", + "audio_sync_off": "Audio synchronization off", "balanced": "Balanced", - "lip_sync": "Lip Synchronization", - "audio_sync": "Audio Synchronization" + "lip_sync": "Lip synchronization", + "audio_sync": "Audio synchronization" } } } diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index cf7bc9c9035..07970cb25ca 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.16", "async-upnp-client==0.43.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.44.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index d53c28cb64a..e01a853a360 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -73,7 +73,7 @@ "fields": { "rgb_color": { "name": "RGB color", - "description": "Color for the light in RGB-format." + "description": "Color for the light in RGB format." }, "brightness": { "name": "Brightness", @@ -173,11 +173,11 @@ "selector": { "mode": { "options": { - "color_flow": "Color Flow", + "normal": "[%key:common::state::normal%]", + "color_flow": "Color flow", "hsv": "HSV", "last": "Last", "moonlight": "Moonlight", - "normal": "Normal", "rgb": "RGB" } }, diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7ba7433f53f..3dd5aa7c974 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.typing import ConfigType from . import api -from .const import DOMAIN, YOLINK_EVENT +from .const import ATTR_LORA_INFO, DOMAIN, YOLINK_EVENT from .coordinator import YoLinkCoordinator from .device_trigger import CONF_LONG_PRESS, CONF_SHORT_PRESS from .services import async_register_services @@ -72,6 +72,8 @@ class YoLinkHomeMessageListener(MessageListener): if device_coordinator is None: return device_coordinator.dev_online = True + if (loraInfo := msg_data.get(ATTR_LORA_INFO)) is not None: + device_coordinator.dev_net_type = loraInfo.get("devNetType") device_coordinator.async_set_updated_data(msg_data) # handling events if ( diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 30c04d3a424..7f965650354 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -11,6 +11,7 @@ from yolink.const import ( ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ) @@ -25,7 +26,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .const import ( + DEV_MODEL_WATER_METER_YS5018_EC, + DEV_MODEL_WATER_METER_YS5018_UC, + DOMAIN, +) from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity @@ -37,6 +42,7 @@ class YoLinkBinarySensorEntityDescription(BinarySensorEntityDescription): exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True state_key: str = "state" value: Callable[[Any], bool | None] = lambda _: None + should_update_entity: Callable = lambda state: True SENSOR_DEVICE_TYPE = [ @@ -46,6 +52,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ] @@ -91,8 +98,25 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( state_key="alarm", device_class=BinarySensorDeviceClass.MOISTURE, value=lambda state: state.get("leak") if state is not None else None, + # This property will be lost during valve operation. + should_update_entity=lambda value: value is not None, + exists_fn=lambda device: ( + device.device_type + in [ + ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ] + ), + ), + YoLinkBinarySensorEntityDescription( + key="water_running", + translation_key="water_running", + value=lambda state: state.get("waterFlowing") if state is not None else None, + should_update_entity=lambda value: value is not None, exists_fn=lambda device: ( device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER + and device.device_model_name + in [DEV_MODEL_WATER_METER_YS5018_EC, DEV_MODEL_WATER_METER_YS5018_UC] ), ), ) @@ -141,9 +165,13 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity): @callback def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" - self._attr_is_on = self.entity_description.value( - state.get(self.entity_description.state_key) - ) + if ( + _attr_val := self.entity_description.value( + state.get(self.entity_description.state_key) + ) + ) is None or self.entity_description.should_update_entity(_attr_val) is False: + return + self._attr_is_on = _attr_val self.async_write_ha_state() @property diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 8879ef15125..9556c1bbd82 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -12,6 +12,7 @@ ATTR_VOLUME = "volume" ATTR_TEXT_MESSAGE = "message" ATTR_REPEAT = "repeat" ATTR_TONE = "tone" +ATTR_LORA_INFO = "loraInfo" YOLINK_EVENT = f"{DOMAIN}_event" YOLINK_OFFLINE_TIME = 32400 @@ -37,3 +38,7 @@ DEV_MODEL_SWITCH_YS5708_UC = "YS5708-UC" DEV_MODEL_SWITCH_YS5708_EC = "YS5708-EC" DEV_MODEL_SWITCH_YS5709_UC = "YS5709-UC" DEV_MODEL_SWITCH_YS5709_EC = "YS5709-EC" +DEV_MODEL_LEAK_STOP_YS5009 = "YS5009" +DEV_MODEL_LEAK_STOP_YS5029 = "YS5029" +DEV_MODEL_WATER_METER_YS5018_EC = "YS5018-EC" +DEV_MODEL_WATER_METER_YS5018_UC = "YS5018-UC" diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index d18a37bd276..7d5323663de 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_DEVICE_STATE, DOMAIN, YOLINK_OFFLINE_TIME +from .const import ATTR_DEVICE_STATE, ATTR_LORA_INFO, DOMAIN, YOLINK_OFFLINE_TIME _LOGGER = logging.getLogger(__name__) @@ -47,6 +47,7 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): self.device = device self.paired_device = paired_device self.dev_online = True + self.dev_net_type = None async def _async_update_data(self) -> dict: """Fetch device state.""" @@ -76,7 +77,15 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): except YoLinkAuthFailError as yl_auth_err: raise ConfigEntryAuthFailed from yl_auth_err except YoLinkClientError as yl_client_err: + _LOGGER.error( + "Failed to obtain device status, device: %s, error: %s ", + self.device.device_id, + yl_client_err, + ) raise UpdateFailed from yl_client_err if device_state is not None: + dev_lora_info = device_state.get(ATTR_LORA_INFO) + if dev_lora_info is not None: + self.dev_net_type = dev_lora_info.get("devNetType") return device_state return {} diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py index 0f500b72404..7828bf91541 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -45,7 +45,7 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): def _handle_coordinator_update(self) -> None: """Update state.""" data = self.coordinator.data - if data is not None: + if data is not None and len(data) > 0: self.update_entity_state(data) @property diff --git a/homeassistant/components/yolink/icons.json b/homeassistant/components/yolink/icons.json index c58d219a2e0..6d9062a92b8 100644 --- a/homeassistant/components/yolink/icons.json +++ b/homeassistant/components/yolink/icons.json @@ -1,5 +1,10 @@ { "entity": { + "binary_sensor": { + "water_running": { + "default": "mdi:waves-arrow-right" + } + }, "number": { "config_volume": { "default": "mdi:volume-high" diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 52ae8281f59..74e2259f050 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.4.8"] + "requirements": ["yolink-api==0.5.2"] } diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 511b7718e26..bc32d0eea83 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -12,9 +12,11 @@ from yolink.const import ( ATTR_DEVICE_FINGER, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_LOCK, + ATTR_DEVICE_LOCK_V2, ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_OUTLET, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ATTR_DEVICE_OUTLET, ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SIREN, @@ -95,7 +97,9 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_WATER_DEPTH_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ATTR_DEVICE_LOCK, + ATTR_DEVICE_LOCK_V2, ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_GARAGE_DOOR_CONTROLLER, @@ -112,10 +116,12 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_LOCK, + ATTR_DEVICE_LOCK_V2, ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_WATER_DEPTH_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ] MCU_DEV_TEMPERATURE_SENSOR = [ @@ -211,14 +217,14 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( translation_key="power_failure_alarm", device_class=SensorDeviceClass.ENUM, options=["normal", "alert", "off"], - exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_POWER_FAILURE_ALARM, ), YoLinkSensorEntityDescription( key="mute", translation_key="power_failure_alarm_mute", device_class=SensorDeviceClass.ENUM, options=["muted", "unmuted"], - exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_POWER_FAILURE_ALARM, value=lambda value: "muted" if value is True else "unmuted", ), YoLinkSensorEntityDescription( @@ -226,7 +232,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( translation_key="power_failure_alarm_volume", device_class=SensorDeviceClass.ENUM, options=["low", "medium", "high"], - exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_POWER_FAILURE_ALARM, value=cvt_volume, ), YoLinkSensorEntityDescription( @@ -234,14 +240,14 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( translation_key="power_failure_alarm_beep", device_class=SensorDeviceClass.ENUM, options=["enabled", "disabled"], - exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_POWER_FAILURE_ALARM, value=lambda value: "enabled" if value is True else "disabled", ), YoLinkSensorEntityDescription( key="waterDepth", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.METERS, - exists_fn=lambda device: device.device_type in ATTR_DEVICE_WATER_DEPTH_SENSOR, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_WATER_DEPTH_SENSOR, ), YoLinkSensorEntityDescription( key="meter_reading", @@ -251,7 +257,29 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, should_update_entity=lambda value: value is not None, exists_fn=lambda device: ( - device.device_type in ATTR_DEVICE_WATER_METER_CONTROLLER + device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER + ), + ), + YoLinkSensorEntityDescription( + key="meter_1_reading", + translation_key="water_meter_1_reading", + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL_INCREASING, + should_update_entity=lambda value: value is not None, + exists_fn=lambda device: ( + device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER + ), + ), + YoLinkSensorEntityDescription( + key="meter_2_reading", + translation_key="water_meter_2_reading", + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL_INCREASING, + should_update_entity=lambda value: value is not None, + exists_fn=lambda device: ( + device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER ), ), YoLinkSensorEntityDescription( diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 8ec7612fd73..d38ea248c31 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -44,6 +44,9 @@ } }, "entity": { + "binary_sensor": { + "water_running": { "name": "Water is flowing" } + }, "switch": { "usb_ports": { "name": "USB ports" }, "plug_1": { "name": "Plug 1" }, @@ -61,8 +64,8 @@ "power_failure_alarm": { "name": "Power failure alarm", "state": { - "normal": "Normal", "alert": "Alert", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]" } }, @@ -72,7 +75,11 @@ }, "power_failure_alarm_volume": { "name": "Power failure alarm volume", - "state": { "low": "Low", "medium": "Medium", "high": "High" } + "state": { + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } }, "power_failure_alarm_beep": { "name": "Power failure alarm beep", @@ -83,6 +90,12 @@ }, "water_meter_reading": { "name": "Water meter reading" + }, + "water_meter_1_reading": { + "name": "Water meter 1 reading" + }, + "water_meter_2_reading": { + "name": "Water meter 2 reading" } }, "number": { @@ -93,6 +106,12 @@ "valve": { "meter_valve_state": { "name": "Valve state" + }, + "meter_valve_1_state": { + "name": "Valve 1" + }, + "meter_valve_2_state": { + "name": "Valve 2" } } }, diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index 26ce72a53d1..0e8a5e61855 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -6,7 +6,11 @@ from collections.abc import Callable from dataclasses import dataclass from yolink.client_request import ClientRequest -from yolink.const import ATTR_DEVICE_WATER_METER_CONTROLLER +from yolink.const import ( + ATTR_DEVICE_MODEL_A, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_WATER_METER_CONTROLLER, +) from yolink.device import YoLinkDevice from homeassistant.components.valve import ( @@ -30,6 +34,7 @@ class YoLinkValveEntityDescription(ValveEntityDescription): exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True value: Callable = lambda state: state + channel_index: int | None = None DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( @@ -42,9 +47,32 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( == ATTR_DEVICE_WATER_METER_CONTROLLER and not device.device_model_name.startswith(DEV_MODEL_WATER_METER_YS5007), ), + YoLinkValveEntityDescription( + key="valve_1_state", + translation_key="meter_valve_1_state", + device_class=ValveDeviceClass.WATER, + value=lambda value: value != "open" if value is not None else None, + exists_fn=lambda device: ( + device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER + ), + channel_index=0, + ), + YoLinkValveEntityDescription( + key="valve_2_state", + translation_key="meter_valve_2_state", + device_class=ValveDeviceClass.WATER, + value=lambda value: value != "open" if value is not None else None, + exists_fn=lambda device: ( + device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER + ), + channel_index=1, + ), ) -DEVICE_TYPE = [ATTR_DEVICE_WATER_METER_CONTROLLER] +DEVICE_TYPE = [ + ATTR_DEVICE_WATER_METER_CONTROLLER, + ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, +] async def async_setup_entry( @@ -102,7 +130,17 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity): async def _async_invoke_device(self, state: str) -> None: """Call setState api to change valve state.""" - await self.call_device(ClientRequest("setState", {"valve": state})) + if ( + self.coordinator.device.device_type + == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER + ): + channel_index = self.entity_description.channel_index + if channel_index is not None: + await self.call_device( + ClientRequest("setState", {"valves": {str(channel_index): state}}) + ) + else: + await self.call_device(ClientRequest("setState", {"valve": state})) self._attr_is_closed = state == "close" self.async_write_ha_state() @@ -113,3 +151,11 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity): async def async_close_valve(self) -> None: """Close valve.""" await self._async_invoke_device("close") + + @property + def available(self) -> bool: + """Return true is device is available.""" + if self.coordinator.dev_net_type is not None: + # When the device operates in Class A mode, it cannot be controlled. + return self.coordinator.dev_net_type != ATTR_DEVICE_MODEL_A + return super().available diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 48336422585..76d74965b34 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -7,7 +7,6 @@ import logging from typing import Any import voluptuous as vol -from youtubeaio.helper import first from youtubeaio.types import AuthScope, ForbiddenError from youtubeaio.youtube import YouTube @@ -96,8 +95,12 @@ class OAuth2FlowHandler( """Create an entry for the flow, or update existing entry.""" try: youtube = await self.get_resource(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - own_channel = await first(youtube.get_user_channels()) - if own_channel is None or own_channel.snippet is None: + own_channels = [ + channel + async for channel in youtube.get_user_channels() + if channel.snippet is not None + ] + if not own_channels: return self.async_abort( reason="no_channel", description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, @@ -111,10 +114,10 @@ class OAuth2FlowHandler( except Exception as ex: # noqa: BLE001 LOGGER.error("Unknown error occurred: %s", ex.args) return self.async_abort(reason="unknown") - self._title = own_channel.snippet.title + self._title = own_channels[0].snippet.title self._data = data - await self.async_set_unique_id(own_channel.channel_id) + await self.async_set_unique_id(own_channels[0].channel_id) if self.source != SOURCE_REAUTH: self._abort_if_unique_id_configured() @@ -138,13 +141,39 @@ class OAuth2FlowHandler( options=user_input, ) youtube = await self.get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + + # Get user's own channels + own_channels = [ + channel + async for channel in youtube.get_user_channels() + if channel.snippet is not None + ] + if not own_channels: + return self.async_abort( + reason="no_channel", + description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, + ) + + # Start with user's own channels selectable_channels = [ SelectOptionDict( - value=subscription.snippet.channel_id, - label=subscription.snippet.title, + value=channel.channel_id, + label=f"{channel.snippet.title} (Your Channel)", ) - async for subscription in youtube.get_user_subscriptions() + for channel in own_channels ] + + # Add subscribed channels + selectable_channels.extend( + [ + SelectOptionDict( + value=subscription.snippet.channel_id, + label=subscription.snippet.title, + ) + async for subscription in youtube.get_user_subscriptions() + ] + ) + if not selectable_channels: return self.async_abort(reason="no_subscriptions") return self.async_show_form( @@ -175,13 +204,39 @@ class YouTubeOptionsFlowHandler(OptionsFlow): await youtube.set_user_authentication( self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN], [AuthScope.READ_ONLY] ) + + # Get user's own channels + own_channels = [ + channel + async for channel in youtube.get_user_channels() + if channel.snippet is not None + ] + if not own_channels: + return self.async_abort( + reason="no_channel", + description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, + ) + + # Start with user's own channels selectable_channels = [ SelectOptionDict( - value=subscription.snippet.channel_id, - label=subscription.snippet.title, + value=channel.channel_id, + label=f"{channel.snippet.title} (Your Channel)", ) - async for subscription in youtube.get_user_subscriptions() + for channel in own_channels ] + + # Add subscribed channels + selectable_channels.extend( + [ + SelectOptionDict( + value=subscription.snippet.channel_id, + label=subscription.snippet.title, + ) + async for subscription in youtube.get_user_subscriptions() + ] + ) + return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 5846092e555..fdb9d51185c 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -82,7 +82,8 @@ SENSOR_TYPES: tuple[ZamgSensorEntityDescription, ...] = ( key="wind_bearing", name="Wind Bearing", native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, para_name="DD", ), ZamgSensorEntityDescription( diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py index 2ab46820b56..ccb6733c650 100644 --- a/homeassistant/components/zengge/light.py +++ b/homeassistant/components/zengge/light.py @@ -2,138 +2,38 @@ from __future__ import annotations -import logging -from typing import Any - import voluptuous as vol -from zengge import zengge -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_HS_COLOR, - ATTR_WHITE, - PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, - ColorMode, - LightEntity, -) +from homeassistant.components.light import PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import color as color_util - -_LOGGER = logging.getLogger(__name__) DEVICE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string}) +DOMAIN = "zengge" PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend( {vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}} ) -def setup_platform( +def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Zengge platform.""" - lights = [] - for address, device_config in config[CONF_DEVICES].items(): - light = ZenggeLight(device_config[CONF_NAME], address) - if light.is_valid: - lights.append(light) - - add_entities(lights, True) - - -class ZenggeLight(LightEntity): - """Representation of a Zengge light.""" - - _attr_supported_color_modes = {ColorMode.HS, ColorMode.WHITE} - - def __init__(self, name: str, address: str) -> None: - """Initialize the light.""" - - self._attr_name = name - self._attr_unique_id = address - self.is_valid = True - self._bulb = zengge(address) - self._white = 0 - self._attr_brightness = 0 - self._attr_hs_color = (0, 0) - self._attr_is_on = False - if self._bulb.connect() is False: - self.is_valid = False - _LOGGER.error("Failed to connect to bulb %s, %s", address, name) - return - - @property - def white_value(self) -> int: - """Return the white property.""" - return self._white - - @property - def color_mode(self) -> ColorMode: - """Return the current color mode.""" - if self._white != 0: - return ColorMode.WHITE - return ColorMode.HS - - def _set_rgb(self, red: int, green: int, blue: int) -> None: - """Set the rgb state.""" - self._bulb.set_rgb(red, green, blue) - - def _set_white(self, white): - """Set the white state.""" - return self._bulb.set_white(white) - - def turn_on(self, **kwargs: Any) -> None: - """Turn the specified light on.""" - self._attr_is_on = True - self._bulb.on() - - hs_color = kwargs.get(ATTR_HS_COLOR) - white = kwargs.get(ATTR_WHITE) - brightness = kwargs.get(ATTR_BRIGHTNESS) - - if white is not None: - # Change the bulb to white - self._attr_brightness = white - self._white = white - self._attr_hs_color = (0, 0) - - if hs_color is not None: - # Change the bulb to hs - self._white = 0 - self._attr_hs_color = hs_color - - if brightness is not None: - self._attr_brightness = brightness - - if self._white != 0: - self._set_white(self.brightness) - else: - assert self.hs_color is not None - assert self.brightness is not None - rgb = color_util.color_hsv_to_RGB( - self.hs_color[0], self.hs_color[1], self.brightness / 255 * 100 - ) - self._set_rgb(*rgb) - - def turn_off(self, **kwargs: Any) -> None: - """Turn the specified light off.""" - self._attr_is_on = False - self._bulb.off() - - def update(self) -> None: - """Synchronise internal state with the actual light state.""" - rgb = self._bulb.get_colour() - hsv = color_util.color_RGB_to_hsv(*rgb) - self._attr_hs_color = hsv[:2] - self._attr_brightness = int((hsv[2] / 100) * 255) - self._white = self._bulb.get_white() - if self._white: - self._attr_brightness = self._white - self._attr_is_on = self._bulb.get_on() + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "led_ble_url": "https://www.home-assistant.io/integrations/led_ble/", + }, + ) diff --git a/homeassistant/components/zengge/manifest.json b/homeassistant/components/zengge/manifest.json index 03d989c5f3b..daa63b4de3d 100644 --- a/homeassistant/components/zengge/manifest.json +++ b/homeassistant/components/zengge/manifest.json @@ -5,6 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/zengge", "iot_class": "local_polling", "loggers": ["zengge"], - "quality_scale": "legacy", - "requirements": ["bluepy==1.3.0", "zengge==0.2"] + "quality_scale": "legacy" } diff --git a/homeassistant/components/zengge/strings.json b/homeassistant/components/zengge/strings.json new file mode 100644 index 00000000000..abc3b2450aa --- /dev/null +++ b/homeassistant/components/zengge/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "integration_removed": { + "title": "The Zengge integration has been removed", + "description": "The Zengge integration has been removed from Home Assistant. Support for Zengge lights is provided by the `led_ble` integration.\n\nTo resolve this issue, please remove the (now defunct) `zengge` light configuration from your Home Assistant configuration and [configure the `led_ble` integration]({led_ble_url})." + } + } +} diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index e80b6b8cfdb..311c42ee18e 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -2,26 +2,17 @@ from __future__ import annotations -import contextlib from contextlib import suppress -from fnmatch import translate -from functools import lru_cache, partial +from functools import partial from ipaddress import IPv4Address, IPv6Address import logging -import re import sys -from typing import TYPE_CHECKING, Any, Final, cast +from typing import Any, cast import voluptuous as vol -from zeroconf import ( - BadTypeInNameException, - InterfaceChoice, - IPVersion, - ServiceStateChange, -) -from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo +from zeroconf import InterfaceChoice, IPVersion +from zeroconf.asyncio import AsyncServiceInfo -from homeassistant import config_entries from homeassistant.components import network from homeassistant.const import ( EVENT_HOMEASSISTANT_CLOSE, @@ -29,55 +20,41 @@ from homeassistant.const import ( __version__, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, discovery_flow, instance_id +from homeassistant.helpers import config_validation as cv, 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, - ZeroconfMatcher, - async_get_homekit, - async_get_zeroconf, - bind_hass, -) +from homeassistant.loader import async_get_homekit, async_get_zeroconf, bind_hass from homeassistant.setup import async_when_setup_or_start +from . import websocket_api +from .const import DOMAIN, ZEROCONF_TYPE +from .discovery import ( # noqa: F401 + DATA_DISCOVERY, + ZeroconfDiscovery, + build_homekit_model_lookups, + info_from_service, +) from .models import HaAsyncZeroconf, HaZeroconf from .usage import install_multiple_zeroconf_catcher _LOGGER = logging.getLogger(__name__) -DOMAIN = "zeroconf" - -ZEROCONF_TYPE = "_home-assistant._tcp.local." -HOMEKIT_TYPES = [ - "_hap._tcp.local.", - # Thread based devices - "_hap._udp.local.", -] -_HOMEKIT_MODEL_SPLITS = (None, " ", "-") - CONF_DEFAULT_INTERFACE = "default_interface" CONF_IPV6 = "ipv6" DEFAULT_DEFAULT_INTERFACE = True DEFAULT_IPV6 = True -HOMEKIT_PAIRED_STATUS_FLAG = "sf" -HOMEKIT_MODEL_LOWER = "md" -HOMEKIT_MODEL_UPPER = "MD" - # Property key=value has a max length of 255 # so we use 230 to leave space for key= MAX_PROPERTY_VALUE_LEN = 230 @@ -85,10 +62,6 @@ MAX_PROPERTY_VALUE_LEN = 230 # Dns label max length MAX_NAME_LEN = 63 -ATTR_DOMAIN: Final = "domain" -ATTR_NAME: Final = "name" -ATTR_PROPERTIES: Final = "properties" - # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] _DEPRECATED_ATTR_PROPERTIES_ID = DeprecatedConstant( _ATTR_PROPERTIES_ID, @@ -145,8 +118,6 @@ def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf: if DOMAIN in hass.data: return cast(HaAsyncZeroconf, hass.data[DOMAIN]) - logging.getLogger("zeroconf").setLevel(logging.NOTSET) - zeroconf = HaZeroconf(**_async_get_zc_args(hass)) aio_zc = HaAsyncZeroconf(zc=zeroconf) @@ -216,7 +187,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zeroconf = cast(HaZeroconf, aio_zc.zeroconf) zeroconf_types = await async_get_zeroconf(hass) homekit_models = await async_get_homekit(hass) - homekit_model_lookup, homekit_model_matchers = _build_homekit_model_lookups( + homekit_model_lookup, homekit_model_matchers = build_homekit_model_lookups( homekit_models ) discovery = ZeroconfDiscovery( @@ -227,6 +198,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: homekit_model_matchers, ) await discovery.async_setup() + hass.data[DATA_DISCOVERY] = discovery + websocket_api.async_setup(hass) async def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) -> None: """Expose Home Assistant on zeroconf when it starts. @@ -245,25 +218,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def _build_homekit_model_lookups( - homekit_models: dict[str, HomeKitDiscoveredIntegration], -) -> tuple[ - dict[str, HomeKitDiscoveredIntegration], - dict[re.Pattern, HomeKitDiscoveredIntegration], -]: - """Build lookups for homekit models.""" - homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {} - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {} - - for model, discovery in homekit_models.items(): - if "*" in model or "?" in model or "[" in model: - homekit_model_matchers[_compile_fnmatch(model)] = discovery - else: - homekit_model_lookup[model] = discovery - - return homekit_model_lookup, homekit_model_matchers - - def _filter_disallowed_characters(name: str) -> str: """Filter disallowed characters from a string. @@ -317,299 +271,6 @@ async def _async_register_hass_zc_service( await aio_zc.async_register_service(info, allow_name_change=True) -def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: - """Check a matcher to ensure all values in props.""" - for key, value in matcher.items(): - prop_val = props.get(key) - if prop_val is None or not _memorized_fnmatch(prop_val.lower(), value): - return False - return True - - -def is_homekit_paired(props: dict[str, Any]) -> bool: - """Check properties to see if a device is homekit paired.""" - if HOMEKIT_PAIRED_STATUS_FLAG not in props: - return False - with contextlib.suppress(ValueError): - # 0 means paired and not discoverable by iOS clients) - return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0 - # If we cannot tell, we assume its not paired - return False - - -class ZeroconfDiscovery: - """Discovery via zeroconf.""" - - def __init__( - self, - hass: HomeAssistant, - zeroconf: HaZeroconf, - zeroconf_types: dict[str, list[ZeroconfMatcher]], - homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], - ) -> None: - """Init discovery.""" - self.hass = hass - self.zeroconf = zeroconf - self.zeroconf_types = zeroconf_types - self.homekit_model_lookups = homekit_model_lookups - self.homekit_model_matchers = homekit_model_matchers - self.async_service_browser: AsyncServiceBrowser | None = None - - async def async_setup(self) -> None: - """Start discovery.""" - types = list(self.zeroconf_types) - # We want to make sure we know about other HomeAssistant - # instances as soon as possible to avoid name conflicts - # so we always browse for ZEROCONF_TYPE - types.extend( - hk_type - for hk_type in (ZEROCONF_TYPE, *HOMEKIT_TYPES) - if hk_type not in self.zeroconf_types - ) - _LOGGER.debug("Starting Zeroconf browser for: %s", types) - self.async_service_browser = AsyncServiceBrowser( - self.zeroconf, types, handlers=[self.async_service_update] - ) - - async_dispatcher_connect( - self.hass, - config_entries.signal_discovered_config_entry_removed(DOMAIN), - self._handle_config_entry_removed, - ) - - async def async_stop(self) -> None: - """Cancel the service browser and stop processing the queue.""" - if self.async_service_browser: - await self.async_service_browser.async_cancel() - - @callback - def _handle_config_entry_removed( - self, - entry: config_entries.ConfigEntry, - ) -> None: - """Handle config entry changes.""" - for discovery_key in entry.discovery_keys[DOMAIN]: - if discovery_key.version != 1: - continue - _type = discovery_key.key[0] - name = discovery_key.key[1] - _LOGGER.debug("Rediscover service %s.%s", _type, name) - self._async_service_update(self.zeroconf, _type, name) - - 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, - lambda service_info: bool(service_info.name == name), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) - - @callback - def async_service_update( - self, - zeroconf: HaZeroconf, - service_type: str, - name: str, - state_change: ServiceStateChange, - ) -> None: - """Service state changed.""" - _LOGGER.debug( - "service_update: type=%s name=%s state_change=%s", - service_type, - name, - state_change, - ) - - if state_change is ServiceStateChange.Removed: - self._async_dismiss_discoveries(name) - return - - self._async_service_update(zeroconf, service_type, name) - - @callback - def _async_service_update( - self, - zeroconf: HaZeroconf, - service_type: str, - name: str, - ) -> None: - """Service state added or changed.""" - try: - async_service_info = AsyncServiceInfo(service_type, name) - except BadTypeInNameException as ex: - # Some devices broadcast a name that is not a valid DNS name - # This is a bug in the device firmware and we should ignore it - _LOGGER.debug("Bad name in zeroconf record: %s: %s", name, ex) - return - - if async_service_info.load_from_cache(zeroconf): - self._async_process_service_update(async_service_info, service_type, name) - else: - self.hass.async_create_background_task( - self._async_lookup_and_process_service_update( - zeroconf, async_service_info, service_type, name - ), - name=f"zeroconf lookup {name}.{service_type}", - ) - - async def _async_lookup_and_process_service_update( - self, - zeroconf: HaZeroconf, - async_service_info: AsyncServiceInfo, - service_type: str, - name: str, - ) -> None: - """Update and process a zeroconf update.""" - await async_service_info.async_request(zeroconf, 3000) - self._async_process_service_update(async_service_info, service_type, name) - - @callback - def _async_process_service_update( - self, async_service_info: AsyncServiceInfo, service_type: str, name: str - ) -> None: - """Process a zeroconf update.""" - info = info_from_service(async_service_info) - if not info: - # Prevent the browser thread from collapsing - _LOGGER.debug("Failed to get addresses for device %s", name) - return - _LOGGER.debug("Discovered new device %s %s", name, info) - props: dict[str, str | None] = info.properties - discovery_key = DiscoveryKey( - domain=DOMAIN, - key=(info.type, info.name), - version=1, - ) - domain = None - - # If we can handle it as a HomeKit discovery, we do that here. - if service_type in HOMEKIT_TYPES and ( - homekit_discovery := async_get_homekit_discovery( - self.homekit_model_lookups, self.homekit_model_matchers, props - ) - ): - domain = homekit_discovery.domain - discovery_flow.async_create_flow( - self.hass, - homekit_discovery.domain, - {"source": config_entries.SOURCE_HOMEKIT}, - info, - discovery_key=discovery_key, - ) - # Continue on here as homekit_controller - # still needs to get updates on devices - # so it can see when the 'c#' field is updated. - # - # We only send updates to homekit_controller - # if the device is already paired in order to avoid - # offering a second discovery for the same device - if not is_homekit_paired(props) and not homekit_discovery.always_discover: - # If the device is paired with HomeKit we must send on - # the update to homekit_controller so it can see when - # the 'c#' field is updated. This is used to detect - # when the device has been reset or updated. - # - # If the device is not paired and we should not always - # discover it, we can stop here. - return - - if not (matchers := self.zeroconf_types.get(service_type)): - return - - # Not all homekit types are currently used for discovery - # so not all service type exist in zeroconf_types - for matcher in matchers: - if len(matcher) > 1: - if ATTR_NAME in matcher and not _memorized_fnmatch( - info.name.lower(), matcher[ATTR_NAME] - ): - continue - if ATTR_PROPERTIES in matcher and not _match_against_props( - matcher[ATTR_PROPERTIES], props - ): - continue - - matcher_domain = matcher[ATTR_DOMAIN] - # Create a type annotated regular dict since this is a hot path and creating - # a regular dict is slightly cheaper than calling ConfigFlowContext - context: config_entries.ConfigFlowContext = { - "source": config_entries.SOURCE_ZEROCONF, - } - if domain: - # Domain of integration that offers alternative API to handle - # this device. - context["alternative_domain"] = domain - - discovery_flow.async_create_flow( - self.hass, - matcher_domain, - context, - info, - discovery_key=discovery_key, - ) - - -def async_get_homekit_discovery( - homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], - homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], - props: dict[str, Any], -) -> HomeKitDiscoveredIntegration | None: - """Handle a HomeKit discovery. - - Return the domain to forward the discovery data to - """ - if not ( - model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER) - ) or not isinstance(model, str): - return None - - for split_str in _HOMEKIT_MODEL_SPLITS: - key = (model.split(split_str))[0] if split_str else model - if discovery := homekit_model_lookups.get(key): - return discovery - - for pattern, discovery in homekit_model_matchers.items(): - if pattern.match(model): - return discovery - - return 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 - # for property keys and values - if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)): - return None - if TYPE_CHECKING: - ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses) - else: - ip_addresses = maybe_ip_addresses - ip_address: IPv4Address | IPv6Address | None = None - for ip_addr in ip_addresses: - if not ip_addr.is_link_local and not ip_addr.is_unspecified: - ip_address = ip_addr - break - if not ip_address: - return None - - if TYPE_CHECKING: - 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, - hostname=service.server, - type=service.type, - name=service.name, - properties=service.decoded_properties, - ) - - def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" @@ -646,27 +307,6 @@ def _truncate_location_name_to_valid(location_name: str) -> str: return location_name.encode("utf-8")[:MAX_NAME_LEN].decode("utf-8", "ignore") -@lru_cache(maxsize=4096, typed=True) -def _compile_fnmatch(pattern: str) -> re.Pattern: - """Compile a fnmatch pattern.""" - return re.compile(translate(pattern)) - - -@lru_cache(maxsize=1024, typed=True) -def _memorized_fnmatch(name: str, pattern: str) -> bool: - """Memorized version of fnmatch that has a larger lru_cache. - - The default version of fnmatch only has a lru_cache of 256 entries. - With many devices we quickly reach that limit and end up compiling - the same pattern over and over again. - - Zeroconf has its own memorized fnmatch with its own lru_cache - since the data is going to be relatively the same - 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( diff --git a/homeassistant/components/zeroconf/const.py b/homeassistant/components/zeroconf/const.py new file mode 100644 index 00000000000..6267d18642c --- /dev/null +++ b/homeassistant/components/zeroconf/const.py @@ -0,0 +1,7 @@ +"""Zeroconf constants.""" + +DOMAIN = "zeroconf" + +ZEROCONF_TYPE = "_home-assistant._tcp.local." + +REQUEST_TIMEOUT = 10000 # 10 seconds diff --git a/homeassistant/components/zeroconf/discovery.py b/homeassistant/components/zeroconf/discovery.py new file mode 100644 index 00000000000..e9b4508caee --- /dev/null +++ b/homeassistant/components/zeroconf/discovery.py @@ -0,0 +1,410 @@ +"""Zeroconf discovery for Home Assistant.""" + +from __future__ import annotations + +from collections.abc import Callable +import contextlib +from fnmatch import translate +from functools import lru_cache, partial +from ipaddress import IPv4Address, IPv6Address +import logging +import re +from typing import TYPE_CHECKING, Any, Final, cast + +from zeroconf import BadTypeInNameException, IPVersion, ServiceStateChange +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import discovery_flow +from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service_info.zeroconf import ( + ZeroconfServiceInfo as _ZeroconfServiceInfo, +) +from homeassistant.loader import HomeKitDiscoveredIntegration, ZeroconfMatcher +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN, REQUEST_TIMEOUT + +if TYPE_CHECKING: + from .models import HaZeroconf + +_LOGGER = logging.getLogger(__name__) + +ZEROCONF_TYPE = "_home-assistant._tcp.local." +HOMEKIT_TYPES = [ + "_hap._tcp.local.", + # Thread based devices + "_hap._udp.local.", +] +_HOMEKIT_MODEL_SPLITS = (None, " ", "-") + + +HOMEKIT_PAIRED_STATUS_FLAG = "sf" +HOMEKIT_MODEL_LOWER = "md" +HOMEKIT_MODEL_UPPER = "MD" + +ATTR_DOMAIN: Final = "domain" +ATTR_NAME: Final = "name" +ATTR_PROPERTIES: Final = "properties" + + +DATA_DISCOVERY: HassKey[ZeroconfDiscovery] = HassKey("zeroconf_discovery") + + +def build_homekit_model_lookups( + homekit_models: dict[str, HomeKitDiscoveredIntegration], +) -> tuple[ + dict[str, HomeKitDiscoveredIntegration], + dict[re.Pattern, HomeKitDiscoveredIntegration], +]: + """Build lookups for homekit models.""" + homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {} + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {} + + for model, discovery in homekit_models.items(): + if "*" in model or "?" in model or "[" in model: + homekit_model_matchers[_compile_fnmatch(model)] = discovery + else: + homekit_model_lookup[model] = discovery + + return homekit_model_lookup, homekit_model_matchers + + +@lru_cache(maxsize=4096, typed=True) +def _compile_fnmatch(pattern: str) -> re.Pattern: + """Compile a fnmatch pattern.""" + return re.compile(translate(pattern)) + + +@lru_cache(maxsize=1024, typed=True) +def _memorized_fnmatch(name: str, pattern: str) -> bool: + """Memorized version of fnmatch that has a larger lru_cache. + + The default version of fnmatch only has a lru_cache of 256 entries. + With many devices we quickly reach that limit and end up compiling + the same pattern over and over again. + + Zeroconf has its own memorized fnmatch with its own lru_cache + since the data is going to be relatively the same + since the devices will not change frequently + """ + return bool(_compile_fnmatch(pattern).match(name)) + + +def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: + """Check a matcher to ensure all values in props.""" + for key, value in matcher.items(): + prop_val = props.get(key) + if prop_val is None or not _memorized_fnmatch(prop_val.lower(), value): + return False + return True + + +def is_homekit_paired(props: dict[str, Any]) -> bool: + """Check properties to see if a device is homekit paired.""" + if HOMEKIT_PAIRED_STATUS_FLAG not in props: + return False + with contextlib.suppress(ValueError): + # 0 means paired and not discoverable by iOS clients) + return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0 + # If we cannot tell, we assume its not paired + return False + + +def async_get_homekit_discovery( + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], + props: dict[str, Any], +) -> HomeKitDiscoveredIntegration | None: + """Handle a HomeKit discovery. + + Return the domain to forward the discovery data to + """ + if not ( + model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER) + ) or not isinstance(model, str): + return None + + for split_str in _HOMEKIT_MODEL_SPLITS: + key = (model.split(split_str))[0] if split_str else model + if discovery := homekit_model_lookups.get(key): + return discovery + + for pattern, discovery in homekit_model_matchers.items(): + if pattern.match(model): + return discovery + + return 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 + # for property keys and values + if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)): + return None + if TYPE_CHECKING: + ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses) + else: + ip_addresses = maybe_ip_addresses + ip_address: IPv4Address | IPv6Address | None = None + for ip_addr in ip_addresses: + if not ip_addr.is_link_local and not ip_addr.is_unspecified: + ip_address = ip_addr + break + if not ip_address: + return None + + if TYPE_CHECKING: + 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, + hostname=service.server, + type=service.type, + name=service.name, + properties=service.decoded_properties, + ) + + +class ZeroconfDiscovery: + """Discovery via zeroconf.""" + + def __init__( + self, + hass: HomeAssistant, + zeroconf: HaZeroconf, + zeroconf_types: dict[str, list[ZeroconfMatcher]], + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], + ) -> None: + """Init discovery.""" + self.hass = hass + self.zeroconf = zeroconf + self.zeroconf_types = zeroconf_types + self.homekit_model_lookups = homekit_model_lookups + self.homekit_model_matchers = homekit_model_matchers + self.async_service_browser: AsyncServiceBrowser | None = None + self._service_update_listeners: set[Callable[[AsyncServiceInfo], None]] = set() + self._service_removed_listeners: set[Callable[[str], None]] = set() + + @callback + def async_register_service_update_listener( + self, + listener: Callable[[AsyncServiceInfo], None], + ) -> Callable[[], None]: + """Register a service update listener.""" + self._service_update_listeners.add(listener) + return partial(self._service_update_listeners.remove, listener) + + @callback + def async_register_service_removed_listener( + self, + listener: Callable[[str], None], + ) -> Callable[[], None]: + """Register a service removed listener.""" + self._service_removed_listeners.add(listener) + return partial(self._service_removed_listeners.remove, listener) + + async def async_setup(self) -> None: + """Start discovery.""" + types = list(self.zeroconf_types) + # We want to make sure we know about other HomeAssistant + # instances as soon as possible to avoid name conflicts + # so we always browse for ZEROCONF_TYPE + types.extend( + hk_type + for hk_type in (ZEROCONF_TYPE, *HOMEKIT_TYPES) + if hk_type not in self.zeroconf_types + ) + _LOGGER.debug("Starting Zeroconf browser for: %s", types) + self.async_service_browser = AsyncServiceBrowser( + self.zeroconf, types, handlers=[self.async_service_update] + ) + + async_dispatcher_connect( + self.hass, + config_entries.signal_discovered_config_entry_removed(DOMAIN), + self._handle_config_entry_removed, + ) + + async def async_stop(self) -> None: + """Cancel the service browser and stop processing the queue.""" + if self.async_service_browser: + await self.async_service_browser.async_cancel() + + @callback + def _handle_config_entry_removed( + self, + entry: config_entries.ConfigEntry, + ) -> None: + """Handle config entry changes.""" + for discovery_key in entry.discovery_keys[DOMAIN]: + if discovery_key.version != 1: + continue + _type = discovery_key.key[0] + name = discovery_key.key[1] + _LOGGER.debug("Rediscover service %s.%s", _type, name) + self._async_service_update(self.zeroconf, _type, name) + + 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, + lambda service_info: bool(service_info.name == name), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + + @callback + def async_service_update( + self, + zeroconf: HaZeroconf, + service_type: str, + name: str, + state_change: ServiceStateChange, + ) -> None: + """Service state changed.""" + _LOGGER.debug( + "service_update: type=%s name=%s state_change=%s", + service_type, + name, + state_change, + ) + + if state_change is ServiceStateChange.Removed: + self._async_dismiss_discoveries(name) + for listener in self._service_removed_listeners: + listener(name) + return + + self._async_service_update(zeroconf, service_type, name) + + @callback + def _async_service_update( + self, + zeroconf: HaZeroconf, + service_type: str, + name: str, + ) -> None: + """Service state added or changed.""" + try: + async_service_info = AsyncServiceInfo(service_type, name) + except BadTypeInNameException as ex: + # Some devices broadcast a name that is not a valid DNS name + # This is a bug in the device firmware and we should ignore it + _LOGGER.debug("Bad name in zeroconf record: %s: %s", name, ex) + return + + if async_service_info.load_from_cache(zeroconf): + self._async_process_service_update(async_service_info, service_type, name) + else: + self.hass.async_create_background_task( + self._async_lookup_and_process_service_update( + zeroconf, async_service_info, service_type, name + ), + name=f"zeroconf lookup {name}.{service_type}", + ) + + async def _async_lookup_and_process_service_update( + self, + zeroconf: HaZeroconf, + async_service_info: AsyncServiceInfo, + service_type: str, + name: str, + ) -> None: + """Update and process a zeroconf update.""" + await async_service_info.async_request(zeroconf, REQUEST_TIMEOUT) + self._async_process_service_update(async_service_info, service_type, name) + + @callback + def _async_process_service_update( + self, async_service_info: AsyncServiceInfo, service_type: str, name: str + ) -> None: + """Process a zeroconf update.""" + for listener in self._service_update_listeners: + listener(async_service_info) + info = info_from_service(async_service_info) + if not info: + # Prevent the browser thread from collapsing + _LOGGER.debug("Failed to get addresses for device %s", name) + return + _LOGGER.debug("Discovered new device %s %s", name, info) + props: dict[str, str | None] = info.properties + discovery_key = DiscoveryKey( + domain=DOMAIN, + key=(info.type, info.name), + version=1, + ) + domain = None + + # If we can handle it as a HomeKit discovery, we do that here. + if service_type in HOMEKIT_TYPES and ( + homekit_discovery := async_get_homekit_discovery( + self.homekit_model_lookups, self.homekit_model_matchers, props + ) + ): + domain = homekit_discovery.domain + discovery_flow.async_create_flow( + self.hass, + homekit_discovery.domain, + {"source": config_entries.SOURCE_HOMEKIT}, + info, + discovery_key=discovery_key, + ) + # Continue on here as homekit_controller + # still needs to get updates on devices + # so it can see when the 'c#' field is updated. + # + # We only send updates to homekit_controller + # if the device is already paired in order to avoid + # offering a second discovery for the same device + if not is_homekit_paired(props) and not homekit_discovery.always_discover: + # If the device is paired with HomeKit we must send on + # the update to homekit_controller so it can see when + # the 'c#' field is updated. This is used to detect + # when the device has been reset or updated. + # + # If the device is not paired and we should not always + # discover it, we can stop here. + return + + if not (matchers := self.zeroconf_types.get(service_type)): + return + + # Not all homekit types are currently used for discovery + # so not all service type exist in zeroconf_types + for matcher in matchers: + if len(matcher) > 1: + if ATTR_NAME in matcher and not _memorized_fnmatch( + info.name.lower(), matcher[ATTR_NAME] + ): + continue + if ATTR_PROPERTIES in matcher and not _match_against_props( + matcher[ATTR_PROPERTIES], props + ): + continue + + matcher_domain = matcher[ATTR_DOMAIN] + # Create a type annotated regular dict since this is a hot path and creating + # a regular dict is slightly cheaper than calling ConfigFlowContext + context: config_entries.ConfigFlowContext = { + "source": config_entries.SOURCE_ZEROCONF, + } + if domain: + # Domain of integration that offers alternative API to handle + # this device. + context["alternative_domain"] = domain + + discovery_flow.async_create_flow( + self.hass, + matcher_domain, + context, + info, + discovery_key=discovery_key, + ) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 8abaa4a838e..fe190e78956 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.145.1"] + "requirements": ["zeroconf==0.147.0"] } diff --git a/homeassistant/components/zeroconf/websocket_api.py b/homeassistant/components/zeroconf/websocket_api.py new file mode 100644 index 00000000000..3a1881e6f4e --- /dev/null +++ b/homeassistant/components/zeroconf/websocket_api.py @@ -0,0 +1,163 @@ +"""The zeroconf integration websocket apis.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from functools import partial +from itertools import chain +import logging +from typing import Any, cast + +import voluptuous as vol +from zeroconf import BadTypeInNameException, DNSPointer, Zeroconf, current_time_millis +from zeroconf.asyncio import AsyncServiceInfo, IPVersion + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_bytes + +from .const import DOMAIN, REQUEST_TIMEOUT +from .discovery import DATA_DISCOVERY, ZeroconfDiscovery +from .models import HaAsyncZeroconf + +_LOGGER = logging.getLogger(__name__) +CLASS_IN = 1 +TYPE_PTR = 12 + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the zeroconf websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_discovery) + + +def serialize_service_info(service_info: AsyncServiceInfo) -> dict[str, Any]: + """Serialize an AsyncServiceInfo object.""" + return { + "name": service_info.name, + "type": service_info.type, + "port": service_info.port, + "properties": service_info.decoded_properties, + "ip_addresses": [ + str(ip) for ip in service_info.ip_addresses_by_version(IPVersion.All) + ], + } + + +class _DiscoverySubscription: + """Class to hold and manage the subscription data.""" + + def __init__( + self, + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + ws_msg_id: int, + aiozc: HaAsyncZeroconf, + discovery: ZeroconfDiscovery, + ) -> None: + """Initialize the subscription data.""" + self.hass = hass + self.discovery = discovery + self.aiozc = aiozc + self.ws_msg_id = ws_msg_id + self.connection = connection + + @callback + def _async_unsubscribe( + self, cancel_callbacks: tuple[Callable[[], None], ...] + ) -> None: + """Unsubscribe the callback.""" + for cancel_callback in cancel_callbacks: + cancel_callback() + + async def async_start(self) -> None: + """Start the subscription.""" + connection = self.connection + listeners = ( + self.discovery.async_register_service_update_listener( + self._async_on_update + ), + self.discovery.async_register_service_removed_listener( + self._async_on_remove + ), + ) + connection.subscriptions[self.ws_msg_id] = partial( + self._async_unsubscribe, listeners + ) + self.connection.send_message( + json_bytes(websocket_api.result_message(self.ws_msg_id)) + ) + await self._async_update_from_cache() + + async def _async_update_from_cache(self) -> None: + """Load the records from the cache.""" + tasks: list[asyncio.Task[None]] = [] + now = current_time_millis() + for record in self._async_get_ptr_records(self.aiozc.zeroconf): + try: + info = AsyncServiceInfo(record.name, record.alias) + except BadTypeInNameException as ex: + _LOGGER.debug( + "Ignoring record with bad type in name: %s: %s", record.alias, ex + ) + continue + if info.load_from_cache(self.aiozc.zeroconf, now): + self._async_on_update(info) + else: + tasks.append( + self.hass.async_create_background_task( + self._async_handle_service(info), + f"zeroconf resolve {record.alias}", + ), + ) + + if tasks: + await asyncio.gather(*tasks) + + def _async_get_ptr_records(self, zc: Zeroconf) -> list[DNSPointer]: + """Return all PTR records for the HAP type.""" + return cast( + list[DNSPointer], + list( + chain.from_iterable( + zc.cache.async_all_by_details(zc_type, TYPE_PTR, CLASS_IN) + for zc_type in self.discovery.zeroconf_types + ) + ), + ) + + async def _async_handle_service(self, info: AsyncServiceInfo) -> None: + """Add a device that became visible via zeroconf.""" + await info.async_request(self.aiozc.zeroconf, REQUEST_TIMEOUT) + self._async_on_update(info) + + 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_on_update(self, info: AsyncServiceInfo) -> None: + if info.type in self.discovery.zeroconf_types: + self._async_event_message({"add": [serialize_service_info(info)]}) + + def _async_on_remove(self, name: str) -> None: + self._async_event_message({"remove": [{"name": name}]}) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "zeroconf/subscribe_discovery", + } +) +@websocket_api.async_response +async def ws_subscribe_discovery( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe advertisements websocket command.""" + discovery = hass.data[DATA_DISCOVERY] + aiozc: HaAsyncZeroconf = hass.data[DOMAIN] + await _DiscoverySubscription( + hass, connection, msg["id"], aiozc, discovery + ).async_start() diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index ec8850b187d..6b3b38bdde8 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -107,13 +107,13 @@ class ZestimateDataSensor(SensorEntity): attributes["address"] = self.address return attributes - def update(self): + def update(self) -> None: """Get the latest data and update the states.""" try: response = requests.get(_RESOURCE, params=self.params, timeout=5) data = response.content.decode("utf-8") - data_dict = xmltodict.parse(data).get(ZESTIMATE) + data_dict = xmltodict.parse(data)[ZESTIMATE] error_code = int(data_dict["message"]["code"]) if error_code != 0: _LOGGER.error("The API returned: %s", data_dict["message"]["text"]) diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index 8e8509e62a5..75d22ce28a1 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN as ZHA_DOMAIN +from .const import DOMAIN from .helpers import async_get_zha_device_proxy, get_zha_data CONF_SUBTYPE = "subtype" @@ -104,7 +104,7 @@ async def async_get_triggers( return [ { CONF_DEVICE_ID: device_id, - CONF_DOMAIN: ZHA_DOMAIN, + CONF_DOMAIN: DOMAIN, CONF_PLATFORM: DEVICE, CONF_TYPE: trigger, CONF_SUBTYPE: subtype, diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 234f10d59ae..6c5fcba1f8b 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -6,26 +6,14 @@ import dataclasses from importlib.metadata import version from typing import Any -from zha.application.const import ( - ATTR_ATTRIBUTE, - ATTR_DEVICE_TYPE, - ATTR_IEEE, - ATTR_IN_CLUSTERS, - ATTR_OUT_CLUSTERS, - ATTR_PROFILE_ID, - ATTR_VALUE, - UNKNOWN, -) +from zha.application.const import ATTR_IEEE from zha.application.gateway import Gateway -from zha.zigbee.device import Device from zigpy.config import CONF_NWK_EXTENDED_PAN_ID -from zigpy.profiles import PROFILES from zigpy.types import Channels -from zigpy.zcl import Cluster from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ID, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -44,6 +32,7 @@ KEYS_TO_REDACT = { "network_key", CONF_NWK_EXTENDED_PAN_ID, "partner_ieee", + "device_ieee", } ATTRIBUTES = "attributes" @@ -122,60 +111,5 @@ async def async_get_device_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a device.""" zha_device_proxy: ZHADeviceProxy = async_get_zha_device_proxy(hass, device.id) - device_info: dict[str, Any] = zha_device_proxy.zha_device_info - device_info[CLUSTER_DETAILS] = get_endpoint_cluster_attr_data( - zha_device_proxy.device - ) - return async_redact_data(device_info, KEYS_TO_REDACT) - - -def get_endpoint_cluster_attr_data(zha_device: Device) -> dict: - """Return endpoint cluster attribute data.""" - cluster_details = {} - for ep_id, endpoint in zha_device.device.endpoints.items(): - if ep_id == 0: - continue - endpoint_key = ( - f"{PROFILES.get(endpoint.profile_id).DeviceType(endpoint.device_type).name}" - if PROFILES.get(endpoint.profile_id) is not None - and endpoint.device_type is not None - else UNKNOWN - ) - cluster_details[ep_id] = { - ATTR_DEVICE_TYPE: { - CONF_NAME: endpoint_key, - CONF_ID: endpoint.device_type, - }, - ATTR_PROFILE_ID: endpoint.profile_id, - ATTR_IN_CLUSTERS: { - f"0x{cluster_id:04x}": { - "endpoint_attribute": cluster.ep_attribute, - **get_cluster_attr_data(cluster), - } - for cluster_id, cluster in endpoint.in_clusters.items() - }, - ATTR_OUT_CLUSTERS: { - f"0x{cluster_id:04x}": { - "endpoint_attribute": cluster.ep_attribute, - **get_cluster_attr_data(cluster), - } - for cluster_id, cluster in endpoint.out_clusters.items() - }, - } - return cluster_details - - -def get_cluster_attr_data(cluster: Cluster) -> dict: - """Return cluster attribute data.""" - return { - ATTRIBUTES: { - f"0x{attr_id:04x}": { - ATTR_ATTRIBUTE: repr(attr_def), - ATTR_VALUE: cluster.get(attr_def.name), - } - for attr_id, attr_def in cluster.attributes.items() - }, - UNSUPPORTED_ATTRIBUTES: sorted( - cluster.unsupported_attributes, key=lambda v: (isinstance(v, str), v) - ), - } + diagnostics_json: dict[str, Any] = zha_device_proxy.device.get_diagnostics_json() + return async_redact_data(diagnostics_json, KEYS_TO_REDACT) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 700e2833705..084e1c882ac 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -419,13 +419,26 @@ class ZHADeviceProxy(EventBase): @callback def handle_zha_event(self, zha_event: ZHAEvent) -> None: """Handle a ZHA event.""" + if ATTR_UNIQUE_ID in zha_event.data: + unique_id = zha_event.data[ATTR_UNIQUE_ID] + + # Client cluster handler unique IDs in the ZHA lib were disambiguated by + # adding a suffix of `_CLIENT`. Unfortunately, this breaks existing + # automations that match the `unique_id` key. This can be removed in a + # future release with proper notice of a breaking change. + unique_id = unique_id.removesuffix("_CLIENT") + else: + unique_id = zha_event.unique_id + self.gateway_proxy.hass.bus.async_fire( ZHA_EVENT, { ATTR_DEVICE_IEEE: str(zha_event.device_ieee), - ATTR_UNIQUE_ID: zha_event.unique_id, ATTR_DEVICE_ID: self.device_id, **zha_event.data, + # The order of these keys is intentional, `zha_event.data` can contain + # a `unique_id` key, which we explicitly replace + ATTR_UNIQUE_ID: unique_id, }, ) @@ -514,6 +527,7 @@ class ZHAGatewayProxy(EventBase): self._log_queue_handler.listener = logging.handlers.QueueListener( log_simple_queue, log_relay_handler ) + self._log_queue_handler_count: int = 0 self._unsubs: list[Callable[[], None]] = [] self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol)) @@ -747,7 +761,10 @@ class ZHAGatewayProxy(EventBase): if filterer: self._log_queue_handler.addFilter(filterer) - if self._log_queue_handler.listener: + # Only start a new log queue handler if the old one is no longer running + self._log_queue_handler_count += 1 + + if self._log_queue_handler.listener and self._log_queue_handler_count == 1: self._log_queue_handler.listener.start() for logger_name in DEBUG_RELAY_LOGGERS: @@ -763,7 +780,10 @@ class ZHAGatewayProxy(EventBase): for logger_name in DEBUG_RELAY_LOGGERS: logging.getLogger(logger_name).removeHandler(self._log_queue_handler) - if self._log_queue_handler.listener: + # Only stop the log queue handler if nothing else is using it + self._log_queue_handler_count -= 1 + + if self._log_queue_handler.listener and self._log_queue_handler_count == 0: self._log_queue_handler.listener.stop() if filterer: diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py index 05539a063d2..38fe9f92e64 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -12,7 +12,7 @@ from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from .const import DOMAIN as ZHA_DOMAIN +from .const import DOMAIN from .helpers import async_get_zha_device_proxy if TYPE_CHECKING: @@ -84,4 +84,4 @@ def async_describe_events( LOGBOOK_ENTRY_MESSAGE: message, } - async_describe_event(ZHA_DOMAIN, ZHA_EVENT, async_describe_zha_event) + async_describe_event(DOMAIN, ZHA_EVENT, async_describe_zha_event) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 0cc2524469e..ae337c2a5f5 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.51"], + "requirements": ["zha==0.0.57"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index a8383857e57..73d773b1640 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -138,6 +138,11 @@ class Sensor(ZHAEntity, SensorEntity): entity_description.device_class.value ) + if entity.info_object.suggested_display_precision is not None: + self._attr_suggested_display_precision = ( + entity.info_object.suggested_display_precision + ) + @property def native_value(self) -> StateType: """Return the state of the entity.""" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 38f55fb550d..d6a812569f5 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -176,7 +176,7 @@ }, "config_panel": { "zha_options": { - "title": "Global Options", + "title": "Global options", "enhanced_light_transition": "Enable enhanced light color/temperature transition from an off-state", "light_transitioning_flag": "Enable enhanced brightness slider during light transition", "group_members_assume_state": "Group members assume state of group", @@ -187,7 +187,7 @@ "consider_unavailable_battery": "Consider battery powered devices unavailable after (seconds)" }, "zha_alarm_options": { - "title": "Alarm Control Panel Options", + "title": "Alarm control panel options", "alarm_master_code": "Master code for the alarm control panel(s)", "alarm_failed_tries": "The number of consecutive failed code entries to trigger an alarm", "alarm_arm_requires_code": "Code required for arming actions" @@ -274,15 +274,15 @@ }, "source_ieee": { "name": "Source IEEE", - "description": "IEEE address of the joining device (must be used with the install code)." + "description": "IEEE address of the joining device (must be combined with the 'Install code' field)." }, "install_code": { "name": "Install code", - "description": "Install code of the joining device (must be used with the source_ieee)." + "description": "Install code of the joining device (must be combined with the 'Source IEEE' field)." }, "qr_code": { "name": "QR code", - "description": "Value of the QR install code (different between vendors)." + "description": "Provides both the IEEE address and the install code of the joining device (different between vendors)." } } }, @@ -610,6 +610,12 @@ }, "flow_switch": { "name": "Flow switch" + }, + "water_leak": { + "name": "Water leak" + }, + "water_supply": { + "name": "Water supply" } }, "button": { @@ -1101,6 +1107,36 @@ }, "shutdown_timer": { "name": "Shutdown timer" + }, + "calibration_vertical_run_time_up": { + "name": "Calibration vertical run time up" + }, + "calibration_vertical_run_time_down": { + "name": "Calibration vertical run time down" + }, + "calibration_rotation_run_time_up": { + "name": "Calibration rotation run time up" + }, + "calibration_rotation_run_time_down": { + "name": "Calibration rotation run time down" + }, + "impulse_mode_duration": { + "name": "Impulse mode duration" + }, + "water_duration": { + "name": "Water duration" + }, + "water_interval": { + "name": "Water interval" + }, + "hush_duration": { + "name": "Hush duration" + }, + "temperature_control_accuracy": { + "name": "Temperature control accuracy" + }, + "external_temperature_sensor_value": { + "name": "External temperature sensor value" } }, "select": { @@ -1144,10 +1180,10 @@ "name": "Switch type" }, "led_scaling_mode": { - "name": "Led scaling mode" + "name": "LED scaling mode" }, "smart_fan_led_display_levels": { - "name": "Smart fan led display levels" + "name": "Smart fan LED display levels" }, "increased_non_neutral_output": { "name": "Non neutral output" @@ -1319,6 +1355,18 @@ }, "hysteresis_mode": { "name": "Hysteresis mode" + }, + "speed": { + "name": "Speed" + }, + "led_brightness": { + "name": "LED brightness" + }, + "alarm_sound_level": { + "name": "Alarm sound level" + }, + "alarm_sound_mode": { + "name": "Alarm sound mode" } }, "sensor": { @@ -1457,7 +1505,7 @@ "adaptation_run_status": { "name": "Adaptation run status", "state": { - "nothing": "Idle", + "nothing": "[%key:common::state::idle%]", "something": "State" }, "state_attributes": { @@ -1479,7 +1527,7 @@ "name": "Software error", "state": { "nothing": "Good", - "something": "Error" + "something": "[%key:common::state::error%]" }, "state_attributes": { "top_pcb_sensor_error": { @@ -1560,7 +1608,7 @@ "name": "Floor temperature" }, "self_test": { - "name": "Self test result" + "name": "Self-test result" }, "voc_index": { "name": "VOC index" @@ -1666,6 +1714,12 @@ }, "last_watering_duration": { "name": "Last watering duration" + }, + "device_status": { + "name": "Device status" + }, + "lifetime": { + "name": "Lifetime" } }, "switch": { @@ -1808,7 +1862,7 @@ "name": "Mute siren" }, "self_test_switch": { - "name": "Self test" + "name": "Self-test" }, "output_switch": { "name": "Output switch" @@ -1875,6 +1929,12 @@ }, "auto_clean": { "name": "Auto clean" + }, + "test_mode": { + "name": "Test mode" + }, + "external_temperature_sensor": { + "name": "External temperature sensor" } } } diff --git a/homeassistant/components/zimi/__init__.py b/homeassistant/components/zimi/__init__.py new file mode 100644 index 00000000000..a00dd60ee5f --- /dev/null +++ b/homeassistant/components/zimi/__init__.py @@ -0,0 +1,73 @@ +"""The zcc integration.""" + +from __future__ import annotations + +import logging + +from zcc import ControlPoint, ControlPointError + +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 homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from .const import DOMAIN +from .helpers import async_connect_to_controller + +PLATFORMS = [ + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] + +_LOGGER = logging.getLogger(__name__) + + +type ZimiConfigEntry = ConfigEntry[ControlPoint] + + +async def async_setup_entry(hass: HomeAssistant, entry: ZimiConfigEntry) -> bool: + """Connect to Zimi Controller and register device.""" + + try: + api = await async_connect_to_controller( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + ) + + except ControlPointError as error: + raise ConfigEntryNotReady(f"Zimi setup failed: {error}") from error + + _LOGGER.debug("\n%s", api.describe()) + + entry.runtime_data = api + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, api.mac)}, + manufacturer=api.brand, + name=f"{api.network_name}", + model="Zimi Cloud Connect", + sw_version=api.firmware_version, + connections={(CONNECTION_NETWORK_MAC, api.mac)}, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + _LOGGER.debug("Zimi setup complete") + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ZimiConfigEntry) -> bool: + """Unload a config entry.""" + + api = entry.runtime_data + api.disconnect() + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/zimi/config_flow.py b/homeassistant/components/zimi/config_flow.py new file mode 100644 index 00000000000..1037a05a2ce --- /dev/null +++ b/homeassistant/components/zimi/config_flow.py @@ -0,0 +1,172 @@ +"""Config flow for zcc integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from zcc import ( + ControlPoint, + ControlPointCannotConnectError, + ControlPointConnectionRefusedError, + ControlPointDescription, + ControlPointDiscoveryService, + ControlPointError, + ControlPointInvalidHostError, + ControlPointTimeoutError, +) + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +DEFAULT_PORT = 5003 +STEP_MANUAL_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } +) + +SELECTED_HOST_AND_PORT = "selected_host_and_port" + + +class ZimiConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for zcc.""" + + api: ControlPoint = None + api_descriptions: list[ControlPointDescription] + data: dict[str, Any] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial auto-discovery step.""" + + self.data = {} + + try: + self.api_descriptions = await ControlPointDiscoveryService().discovers() + except ControlPointError: + # ControlPointError is expected if no zcc are found on LAN + return await self.async_step_manual() + + if len(self.api_descriptions) == 1: + self.data[CONF_HOST] = self.api_descriptions[0].host + self.data[CONF_PORT] = self.api_descriptions[0].port + await self.check_connection(self.data[CONF_HOST], self.data[CONF_PORT]) + return await self.create_entry() + + return await self.async_step_selection() + + async def async_step_selection( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle selection of zcc to configure if multiple are discovered.""" + + errors: dict[str, str] | None = {} + + if user_input is not None: + self.data[CONF_HOST] = user_input[SELECTED_HOST_AND_PORT].split(":")[0] + self.data[CONF_PORT] = int(user_input[SELECTED_HOST_AND_PORT].split(":")[1]) + errors = await self.check_connection( + self.data[CONF_HOST], self.data[CONF_PORT] + ) + if not errors: + return await self.create_entry() + + available_options = [ + SelectOptionDict( + label=f"{description.host}:{description.port}", + value=f"{description.host}:{description.port}", + ) + for description in self.api_descriptions + ] + + available_schema = vol.Schema( + { + vol.Required( + SELECTED_HOST_AND_PORT, default=available_options[0]["value"] + ): SelectSelector( + SelectSelectorConfig( + options=available_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ) + + return self.async_show_form( + step_id="selection", data_schema=available_schema, errors=errors + ) + + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle manual configuration step if needed.""" + + errors: dict[str, str] | None = {} + + if user_input is not None: + self.data = {**self.data, **user_input} + + errors = await self.check_connection( + self.data[CONF_HOST], self.data[CONF_PORT] + ) + + if not errors: + return await self.create_entry() + + return self.async_show_form( + step_id="manual", + data_schema=self.add_suggested_values_to_schema( + STEP_MANUAL_DATA_SCHEMA, self.data + ), + errors=errors, + ) + + async def check_connection(self, host: str, port: int) -> dict[str, str] | None: + """Check connection to zcc. + + Stores mac and returns None if successful, otherwise returns error message. + """ + + try: + result = await ControlPointDiscoveryService().validate_connection( + self.data[CONF_HOST], self.data[CONF_PORT] + ) + except ControlPointInvalidHostError: + return {"base": "invalid_host"} + except ControlPointConnectionRefusedError: + return {"base": "connection_refused"} + except ControlPointCannotConnectError: + return {"base": "cannot_connect"} + except ControlPointTimeoutError: + return {"base": "timeout"} + except Exception: + _LOGGER.exception("Unexpected error") + return {"base": "unknown"} + + self.data[CONF_MAC] = format_mac(result.mac) + + return None + + async def create_entry(self) -> ConfigFlowResult: + """Create entry for zcc.""" + + await self.async_set_unique_id(self.data[CONF_MAC]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"ZIMI Controller ({self.data[CONF_HOST]}:{self.data[CONF_PORT]})", + data=self.data, + ) diff --git a/homeassistant/components/zimi/const.py b/homeassistant/components/zimi/const.py new file mode 100644 index 00000000000..1a426875b75 --- /dev/null +++ b/homeassistant/components/zimi/const.py @@ -0,0 +1,3 @@ +"""Constants for the zcc integration.""" + +DOMAIN = "zimi" diff --git a/homeassistant/components/zimi/cover.py b/homeassistant/components/zimi/cover.py new file mode 100644 index 00000000000..8f05e35e263 --- /dev/null +++ b/homeassistant/components/zimi/cover.py @@ -0,0 +1,93 @@ +"""Platform for cover integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ZimiConfigEntry +from .entity import ZimiEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ZimiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Zimi Cover platform.""" + + api = config_entry.runtime_data + + doors = [ZimiCover(device, api) for device in api.doors] + + async_add_entities(doors) + + +class ZimiCover(ZimiEntity, CoverEntity): + """Representation of a Zimi cover.""" + + _attr_device_class = CoverDeviceClass.GARAGE + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover/door.""" + _LOGGER.debug("Sending close_cover() for %s", self.name) + await self._device.close_door() + + @property + def current_cover_position(self) -> int | None: + """Return the current cover/door position.""" + return self._device.percentage + + @property + def is_closed(self) -> bool | None: + """Return true if cover is closed.""" + return self._device.is_closed + + @property + def is_closing(self) -> bool | None: + """Return true if cover is closing.""" + return self._device.is_closing + + @property + def is_opening(self) -> bool | None: + """Return true if cover is opening.""" + return self._device.is_opening + + @property + def is_open(self) -> bool | None: + """Return true if cover is open.""" + return self._device.is_open + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover/door.""" + _LOGGER.debug("Sending open_cover() for %s", self.name) + await self._device.open_door() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Open the cover/door to a specified percentage.""" + if position := kwargs.get("position"): + _LOGGER.debug("Sending set_cover_position(%d) for %s", position, self.name) + await self._device.open_to_percentage(position) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + _LOGGER.debug( + "Stopping open_cover() by setting to current position for %s", self.name + ) + await self.async_set_cover_position(position=self.current_cover_position) diff --git a/homeassistant/components/zimi/entity.py b/homeassistant/components/zimi/entity.py new file mode 100644 index 00000000000..12d8f336bf0 --- /dev/null +++ b/homeassistant/components/zimi/entity.py @@ -0,0 +1,69 @@ +"""Base entity for zimi integrations.""" + +from __future__ import annotations + +import logging + +from zcc import ControlPoint +from zcc.device import ControlPointDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ZimiEntity(Entity): + """Representation of a Zimi API entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, device: ControlPointDevice, api: ControlPoint, use_device_name=True + ) -> None: + """Initialize an HA Entity which is a ZimiDevice.""" + + self._device = device + self._attr_unique_id = device.identifier + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.manufacture_info.identifier)}, + manufacturer=device.manufacture_info.manufacturer, + model=device.manufacture_info.model, + name=device.manufacture_info.name, + hw_version=device.manufacture_info.hwVersion, + sw_version=device.manufacture_info.firmwareVersion, + suggested_area=device.room, + via_device=(DOMAIN, api.mac), + ) + if use_device_name: + self._attr_name = device.name.strip() + self._attr_suggested_area = device.room + + @property + def available(self) -> bool: + """Return True if Home Assistant is able to read the state and control the underlying device.""" + return self._device.is_connected + + async def async_added_to_hass(self) -> None: + """Subscribe to the events.""" + await super().async_added_to_hass() + self._device.subscribe(self) + + async def async_will_remove_from_hass(self) -> None: + """Cleanup ZimiLight with removal of notification prior to removal.""" + self._device.unsubscribe(self) + await super().async_will_remove_from_hass() + + def notify(self, _observable: object) -> None: + """Receive notification from device that state has changed. + + No data is fetched for the notification but schedule_update_ha_state is called. + """ + + _LOGGER.debug( + "Received notification() for %s in %s", self._device.name, self._device.room + ) + self.schedule_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/zimi/fan.py b/homeassistant/components/zimi/fan.py new file mode 100644 index 00000000000..19c51371d1a --- /dev/null +++ b/homeassistant/components/zimi/fan.py @@ -0,0 +1,94 @@ +"""Platform for fan integration.""" + +from __future__ import annotations + +import logging +import math +from typing import Any + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) +from homeassistant.util.scaling import int_states_in_range + +from . import ZimiConfigEntry +from .entity import ZimiEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ZimiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Zimi Fan platform.""" + + api = config_entry.runtime_data + + async_add_entities([ZimiFan(device, api) for device in api.fans]) + + +class ZimiFan(ZimiEntity, FanEntity): + """Representation of a Zimi fan.""" + + _attr_speed_range = (0, 7) + + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the desired speed for the fan.""" + + if percentage == 0: + await self.async_turn_off() + return + + target_speed = math.ceil( + percentage_to_ranged_value(self._attr_speed_range, percentage) + ) + + _LOGGER.debug( + "Sending async_set_percentage() for %s with percentage %s", + self.name, + percentage, + ) + + await self._device.set_fanspeed(target_speed) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Instruct the fan to turn on.""" + + _LOGGER.debug("Sending turn_on() for %s", self.name) + await self._device.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the fan to turn off.""" + + _LOGGER.debug("Sending turn_off() for %s", self.name) + + await self._device.turn_off() + + @property + def percentage(self) -> int: + """Return the current speed percentage for the fan.""" + if not self._device.fanspeed: + return 0 + return ranged_value_to_percentage(self._attr_speed_range, self._device.fanspeed) + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(self._attr_speed_range) diff --git a/homeassistant/components/zimi/helpers.py b/homeassistant/components/zimi/helpers.py new file mode 100644 index 00000000000..81d9a986f46 --- /dev/null +++ b/homeassistant/components/zimi/helpers.py @@ -0,0 +1,38 @@ +"""The zcc integration helpers.""" + +from __future__ import annotations + +import logging + +from zcc import ControlPoint, ControlPointDescription + +from homeassistant.exceptions import ConfigEntryNotReady + +_LOGGER = logging.getLogger(__name__) + + +async def async_connect_to_controller( + host: str, port: int, fast: bool = False +) -> ControlPoint: + """Connect to Zimi Cloud Controller with defined parameters.""" + + _LOGGER.debug("Connecting to %s:%d", host, port) + + api = ControlPoint( + description=ControlPointDescription( + host=host, + port=port, + ) + ) + await api.connect(fast=fast) + + if api.ready: + _LOGGER.debug("Connected") + + if not fast: + api.start_watchdog() + _LOGGER.debug("Started watchdog") + + return api + + raise ConfigEntryNotReady("Connection failed: not ready") diff --git a/homeassistant/components/zimi/light.py b/homeassistant/components/zimi/light.py new file mode 100644 index 00000000000..a93bbb53b3d --- /dev/null +++ b/homeassistant/components/zimi/light.py @@ -0,0 +1,103 @@ +"""Light platform for zcc integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from zcc import ControlPoint +from zcc.device import ControlPointDevice + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ZimiConfigEntry +from .entity import ZimiEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ZimiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Zimi Light platform.""" + + api = config_entry.runtime_data + + lights: list[ZimiLight | ZimiDimmer] = [ + ZimiLight(device, api) for device in api.lights if device.type != "dimmer" + ] + + lights.extend( + [ZimiDimmer(device, api) for device in api.lights if device.type == "dimmer"] + ) + + async_add_entities(lights) + + +class ZimiLight(ZimiEntity, LightEntity): + """Representation of a Zimi Light.""" + + def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None: + """Initialize a ZimiLight.""" + + super().__init__(device, api) + + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {ColorMode.ONOFF} + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on (with optional brightness).""" + + _LOGGER.debug( + "Sending turn_on() for %s in %s", self._device.name, self._device.room + ) + + await self._device.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + + _LOGGER.debug( + "Sending turn_off() for %s in %s", self._device.name, self._device.room + ) + + await self._device.turn_off() + + +class ZimiDimmer(ZimiLight): + """Zimi Light supporting dimming.""" + + def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None: + """Initialize a ZimiDimmer.""" + super().__init__(device, api) + self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + if self._device.type != "dimmer": + raise ValueError("ZimiDimmer needs a dimmable light") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on (with optional brightness).""" + + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) * 100 / 255 + _LOGGER.debug( + "Sending turn_on(brightness=%d) for %s in %s", + brightness, + self._device.name, + self._device.room, + ) + + await self._device.set_brightness(brightness) + + @property + def brightness(self) -> int | None: + """Return the brightness of the light.""" + return round(self._device.brightness * 255 / 100) diff --git a/homeassistant/components/zimi/manifest.json b/homeassistant/components/zimi/manifest.json new file mode 100644 index 00000000000..3e019d2f053 --- /dev/null +++ b/homeassistant/components/zimi/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "zimi", + "name": "zimi", + "codeowners": ["@markhannon"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/zimi", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["zcc-helper==3.5.2"] +} diff --git a/homeassistant/components/zimi/quality_scale.yaml b/homeassistant/components/zimi/quality_scale.yaml new file mode 100644 index 00000000000..98e6c5b627c --- /dev/null +++ b/homeassistant/components/zimi/quality_scale.yaml @@ -0,0 +1,99 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + There are no service actions. + appropriate-polling: + status: done + comment: | + There is no polling of the entities. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: + status: done + comment: | + https://mark_hannon@bitbucket.org/mark_hannon/zcc.git + docs-actions: + status: exempt + comment: | + There are no service 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: todo + entity-unavailable: done + action-exceptions: todo + reauthentication-flow: + status: exempt + comment: | + There is no user authentication needed. + parallel-updates: + status: todo + comment: | + Test of parallel updates will be done before setting. + test-coverage: todo + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + This integration has no options flow + + # Gold + entity-translations: todo + entity-device-class: + status: todo + comment: | + Will set device classes for subsequent entities - not relevant for light. + devices: done + entity-category: todo + entity-disabled-by-default: todo + discovery: + status: todo + comment: > + Discovery is supported for the case where the Zimi Cloud Controller(s) are + connected to a local LAN network. Discover is not supported if the Zimi + Cloud Controller(s) are not connected to the local LAN network. + stale-devices: todo + diagnostics: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: + status: todo + comment: | + New devices will be automatically added - but only when the zcc connection is re-established. + discovery-update-info: + status: todo + comment: > + Discovery is not supported. + repair-issues: todo + docs-use-cases: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-data-update: done + docs-known-limitations: done + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not use web sessions. + strict-typing: + status: todo diff --git a/homeassistant/components/zimi/sensor.py b/homeassistant/components/zimi/sensor.py new file mode 100644 index 00000000000..2c681f8e69e --- /dev/null +++ b/homeassistant/components/zimi/sensor.py @@ -0,0 +1,103 @@ +"""Platform for sensor integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from zcc import ControlPoint +from zcc.device import ControlPointDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import ZimiConfigEntry +from .entity import ZimiEntity + + +@dataclass(frozen=True, kw_only=True) +class ZimiSensorEntityDescription(SensorEntityDescription): + """Class describing Zimi sensor entities.""" + + value_fn: Callable[[ControlPointDevice], StateType] + + +GARAGE_SENSOR_DESCRIPTIONS: tuple[ZimiSensorEntityDescription, ...] = ( + ZimiSensorEntityDescription( + key="door_temperature", + translation_key="door_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda device: device.door_temp, + ), + ZimiSensorEntityDescription( + key="garage_battery", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.BATTERY, + value_fn=lambda device: device.battery_level, + ), + ZimiSensorEntityDescription( + key="garage_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda device: device.garage_temp, + ), + ZimiSensorEntityDescription( + key="garage_humidty", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + value_fn=lambda device: device.garage_humidity, + ), +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ZimiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Zimi Sensor platform.""" + + api = config_entry.runtime_data + + async_add_entities( + ZimiSensor(device, description, api) + for device in api.sensors + for description in GARAGE_SENSOR_DESCRIPTIONS + ) + + +class ZimiSensor(ZimiEntity, SensorEntity): + """Representation of a Zimi sensor.""" + + entity_description: ZimiSensorEntityDescription + + def __init__( + self, + device: ControlPointDevice, + description: ZimiSensorEntityDescription, + api: ControlPoint, + ) -> None: + """Initialize an ZimiSensor with specified type.""" + + super().__init__(device, api, use_device_name=False) + + self.entity_description = description + self._attr_unique_id = device.identifier + "." + self.entity_description.key + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/zimi/strings.json b/homeassistant/components/zimi/strings.json new file mode 100644 index 00000000000..e1c7944b25a --- /dev/null +++ b/homeassistant/components/zimi/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "user": { + "title": "Zimi - Discover device(s)", + "description": "Discover and auto-configure Zimi Cloud Connect device." + }, + "selection": { + "title": "Zimi - Select device", + "description": "Select Zimi Cloud Connect device to configure.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "selected_host_and_port": "Selected ZCC" + }, + "data_description": { + "host": "Mandatory - ZCC IP address.", + "port": "Mandatory - ZCC port number (default=5003).", + "selected_host_and_port": "Selected ZCC IP address and port number" + } + }, + "manual": { + "title": "Zimi - Configure device", + "description": "Enter details of your Zimi Cloud Connect device.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::zimi::config::step::selection::data_description::host%]", + "port": "[%key:component::zimi::config::step::selection::data_description::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "timeout": "[%key:common::config_flow::error::timeout_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "connection_refused": "Connection refused" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "door_temperature": { + "name": "Outside temperature" + } + } + } +} diff --git a/homeassistant/components/zimi/switch.py b/homeassistant/components/zimi/switch.py new file mode 100644 index 00000000000..a5292602a6e --- /dev/null +++ b/homeassistant/components/zimi/switch.py @@ -0,0 +1,56 @@ +"""Switch platform for zcc integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ZimiConfigEntry +from .entity import ZimiEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ZimiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Zimi Switch platform.""" + + api = config_entry.runtime_data + + outlets = [ZimiSwitch(device, api) for device in api.outlets] + + async_add_entities(outlets) + + +class ZimiSwitch(ZimiEntity, SwitchEntity): + """Representation of an Zimi Switch.""" + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the switch to turn on.""" + + _LOGGER.debug( + "Sending turn_on() for %s in %s", self._device.name, self._device.room + ) + + await self._device.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the switch to turn off.""" + + _LOGGER.debug( + "Sending turn_off() for %s in %s", self._device.name, self._device.room + ) + + await self._device.turn_off() diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 1c43a79e10e..6325f830ea0 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable import logging from operator import attrgetter import sys @@ -47,6 +47,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey from homeassistant.util.location import distance from .const import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE @@ -108,10 +109,13 @@ ENTITY_ID_SORTER = attrgetter("entity_id") ZONE_ENTITY_IDS = "zone_entity_ids" +DATA_ZONE_STORAGE_COLLECTION: HassKey[ZoneStorageCollection] = HassKey(DOMAIN) +DATA_ZONE_ENTITY_IDS: HassKey[list[str]] = HassKey(ZONE_ENTITY_IDS) + @bind_hass def async_active_zone( - hass: HomeAssistant, latitude: float, longitude: float, radius: int = 0 + hass: HomeAssistant, latitude: float, longitude: float, radius: float = 0 ) -> State | None: """Find the active zone for given latitude, longitude. @@ -122,7 +126,7 @@ def async_active_zone( closest: State | None = None # This can be called before async_setup by device tracker - zone_entity_ids: Iterable[str] = hass.data.get(ZONE_ENTITY_IDS, ()) + zone_entity_ids = hass.data.get(DATA_ZONE_ENTITY_IDS, ()) for entity_id in zone_entity_ids: if ( @@ -168,8 +172,8 @@ def async_active_zone( @callback def async_setup_track_zone_entity_ids(hass: HomeAssistant) -> None: """Set up track of entity IDs for zones.""" - zone_entity_ids: list[str] = hass.states.async_entity_ids(DOMAIN) - hass.data[ZONE_ENTITY_IDS] = zone_entity_ids + zone_entity_ids = hass.states.async_entity_ids(DOMAIN) + hass.data[DATA_ZONE_ENTITY_IDS] = zone_entity_ids @callback def _async_add_zone_entity_id( @@ -290,7 +294,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, core_config_updated) - hass.data[DOMAIN] = storage_collection + hass.data[DATA_ZONE_STORAGE_COLLECTION] = storage_collection return True @@ -312,13 +316,11 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry ) -> bool: """Set up zone as config entry.""" - storage_collection = cast(ZoneStorageCollection, hass.data[DOMAIN]) - data = dict(config_entry.data) data.setdefault(CONF_PASSIVE, DEFAULT_PASSIVE) data.setdefault(CONF_RADIUS, DEFAULT_RADIUS) - await storage_collection.async_create_item(data) + await hass.data[DATA_ZONE_STORAGE_COLLECTION].async_create_item(data) hass.async_create_task( hass.config_entries.async_remove(config_entry.entry_id), eager_start=True @@ -363,7 +365,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from storage.""" zone = cls(config) zone.editable = True - zone._generate_attrs() # noqa: SLF001 + zone._generate_attrs() return zone @classmethod @@ -371,7 +373,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from yaml.""" zone = cls(config) zone.editable = False - zone._generate_attrs() # noqa: SLF001 + zone._generate_attrs() return zone @property diff --git a/homeassistant/components/zoneminder/binary_sensor.py b/homeassistant/components/zoneminder/binary_sensor.py index 926780fc6da..f26f2351b5a 100644 --- a/homeassistant/components/zoneminder/binary_sensor.py +++ b/homeassistant/components/zoneminder/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as ZONEMINDER_DOMAIN +from . import DOMAIN async def async_setup_platform( @@ -23,7 +23,7 @@ async def async_setup_platform( ) -> None: """Set up the ZoneMinder binary sensor platform.""" sensors = [] - for host_name, zm_client in hass.data[ZONEMINDER_DOMAIN].items(): + for host_name, zm_client in hass.data[DOMAIN].items(): sensors.append(ZMAvailabilitySensor(host_name, zm_client)) add_entities(sensors) diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py index 21513b4bed4..851b7492e06 100644 --- a/homeassistant/components/zoneminder/camera.py +++ b/homeassistant/components/zoneminder/camera.py @@ -13,7 +13,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as ZONEMINDER_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,7 @@ def setup_platform( filter_urllib3_logging() cameras = [] zm_client: ZoneMinder - for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): + for zm_client in hass.data[DOMAIN].values(): if not (monitors := zm_client.get_monitors()): raise PlatformNotReady( "Camera could not fetch any monitors from ZoneMinder" diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 4f79f8876e5..5663da0b308 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as ZONEMINDER_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -77,7 +77,7 @@ def setup_platform( sensors: list[SensorEntity] = [] zm_client: ZoneMinder - for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): + for zm_client in hass.data[DOMAIN].values(): if not (monitors := zm_client.get_monitors()): raise PlatformNotReady( "Sensor could not fetch any monitors from ZoneMinder" diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index 13da0927196..7ab6f786cfb 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -20,7 +20,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as ZONEMINDER_DOMAIN +from . import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -45,7 +45,7 @@ def setup_platform( switches: list[ZMSwitchMonitors] = [] zm_client: ZoneMinder - for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): + for zm_client in hass.data[DOMAIN].values(): if not (monitors := zm_client.get_monitors()): raise PlatformNotReady( "Switch could not fetch any monitors from ZoneMinder" diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index c8503b1f4c6..6e76b2f89cf 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections import defaultdict -from contextlib import suppress +import contextlib import logging from typing import Any @@ -12,7 +12,11 @@ from awesomeversion import AwesomeVersion import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, RemoveNodeReason -from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion +from zwave_js_server.exceptions import ( + BaseZwaveJSServerError, + InvalidServerVersion, + NotConnected, +) from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.notification import ( @@ -25,7 +29,7 @@ from zwave_js_server.model.value import Value, ValueNotification from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.components.persistent_notification import async_create -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_DOMAIN, @@ -36,7 +40,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -101,6 +105,7 @@ from .const import ( CONF_USE_ADDON, DATA_CLIENT, DOMAIN, + DRIVER_READY_TIMEOUT, EVENT_DEVICE_ADDED_TO_REGISTRY, EVENT_VALUE_UPDATED, LIB_LOGGER, @@ -130,9 +135,7 @@ from .migrate import async_migrate_discovered_value from .services import ZWaveServices CONNECT_TIMEOUT = 10 -DATA_CLIENT_LISTEN_TASK = "client_listen_task" DATA_DRIVER_EVENTS = "driver_events" -DATA_START_CLIENT_TASK = "start_client_task" CONFIG_SCHEMA = vol.Schema( { @@ -145,6 +148,24 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.COVER, + Platform.EVENT, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, + Platform.UPDATE, +] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Z-Wave JS component.""" @@ -196,53 +217,132 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(f"Failed to connect: {err}") from err async_delete_issue(hass, DOMAIN, "invalid_server_version") - LOGGER.info("Connected to Zwave JS Server") + LOGGER.debug("Connected to Zwave JS Server") # Set up websocket API async_register_api(hass) - entry.runtime_data = {} - # Create a task to allow the config entry to be unloaded before the driver is ready. - # Unloading the config entry is needed if the client listen task errors. - start_client_task = hass.async_create_task(start_client(hass, entry, client)) - entry.runtime_data[DATA_START_CLIENT_TASK] = start_client_task + driver_ready = asyncio.Event() + listen_task = entry.async_create_background_task( + hass, + client_listen(hass, entry, client, driver_ready), + f"{DOMAIN}_{entry.title}_client_listen", + ) - return True - - -async def start_client( - hass: HomeAssistant, entry: ConfigEntry, client: ZwaveClient -) -> None: - """Start listening with the client.""" - entry.runtime_data[DATA_CLIENT] = client - driver_events = entry.runtime_data[DATA_DRIVER_EVENTS] = DriverEvents(hass, entry) + entry.async_on_unload(client.disconnect) async def handle_ha_shutdown(event: Event) -> None: """Handle HA shutdown.""" - await disconnect_client(hass, entry) + await client.disconnect() - listen_task = asyncio.create_task( - client_listen(hass, entry, client, driver_events.ready) - ) - entry.runtime_data[DATA_CLIENT_LISTEN_TASK] = listen_task entry.async_on_unload( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_ha_shutdown) ) - try: - await driver_events.ready.wait() - except asyncio.CancelledError: - LOGGER.debug("Cancelling start client") - return - - LOGGER.info("Connection to Zwave JS Server initialized") - - assert client.driver - async_dispatcher_send( - hass, f"{DOMAIN}_{client.driver.controller.home_id}_connected_to_server" + driver_ready_task = entry.async_create_task( + hass, + driver_ready.wait(), + f"{DOMAIN}_{entry.title}_driver_ready", + ) + done, pending = await asyncio.wait( + (driver_ready_task, listen_task), + return_when=asyncio.FIRST_COMPLETED, + timeout=DRIVER_READY_TIMEOUT, ) - await driver_events.setup(client.driver) + if driver_ready_task in pending or listen_task in done: + error_message = "Driver ready timed out" + listen_error: BaseException | None = None + if listen_task.done(): + listen_error, error_message = _get_listen_task_error(listen_task) + else: + listen_task.cancel() + driver_ready_task.cancel() + raise ConfigEntryNotReady(error_message) from listen_error + + LOGGER.debug("Connection to Zwave JS Server initialized") + + entry_runtime_data = entry.runtime_data = { + DATA_CLIENT: client, + } + entry_runtime_data[DATA_DRIVER_EVENTS] = driver_events = DriverEvents(hass, entry) + + driver = client.driver + # When the driver is ready we know it's set on the client. + assert driver is not None + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + with contextlib.suppress(NotConnected): + # If the client isn't connected the listen task may have an exception + # and we'll handle the clean up below. + await driver_events.setup(driver) + + if (old_unique_id := entry.unique_id) is not None and old_unique_id != ( + new_unique_id := str(driver.controller.home_id) + ): + device_registry = dr.async_get(hass) + controller_model = "Unknown model" + if ( + (own_node := driver.controller.own_node) + and ( + controller_device_entry := device_registry.async_get_device( + identifiers={get_device_id(driver, own_node)} + ) + ) + and (model := controller_device_entry.model) + ): + controller_model = model + async_create_issue( + hass, + DOMAIN, + f"migrate_unique_id.{entry.entry_id}", + data={ + "config_entry_id": entry.entry_id, + "config_entry_title": entry.title, + "controller_model": controller_model, + "new_unique_id": new_unique_id, + "old_unique_id": old_unique_id, + }, + is_fixable=True, + severity=IssueSeverity.ERROR, + translation_key="migrate_unique_id", + ) + else: + async_delete_issue(hass, DOMAIN, f"migrate_unique_id.{entry.entry_id}") + + # If the listen task is already failed, we need to raise ConfigEntryNotReady + if listen_task.done(): + listen_error, error_message = _get_listen_task_error(listen_task) + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + raise ConfigEntryNotReady(error_message) from listen_error + + # Re-attach trigger listeners. + # Schedule this call to make sure the config entry is loaded first. + + @callback + def on_config_entry_loaded() -> None: + """Signal that server connection and driver are ready.""" + if entry.state is ConfigEntryState.LOADED: + async_dispatcher_send( + hass, + f"{DOMAIN}_{driver.controller.home_id}_connected_to_server", + ) + + entry.async_on_unload(entry.async_on_state_change(on_config_entry_loaded)) + + return True + + +def _get_listen_task_error( + listen_task: asyncio.Task, +) -> tuple[BaseException | None, str]: + """Check the listen task for errors.""" + if listen_error := listen_task.exception(): + error_message = f"Client listen failed: {listen_error}" + else: + error_message = "Client connection was closed" + return listen_error, error_message class DriverEvents: @@ -255,8 +355,6 @@ class DriverEvents: self.config_entry = entry self.dev_reg = dr.async_get(hass) self.hass = hass - self.platform_setup_tasks: dict[str, asyncio.Task] = {} - self.ready = asyncio.Event() # Make sure to not pass self to ControllerEvents until all attributes are set. self.controller_events = ControllerEvents(hass, self) @@ -298,11 +396,17 @@ class DriverEvents: self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) for node in controller.nodes.values() ] + provisioned_devices = [ + self.dev_reg.async_get(entry.additional_properties["device_id"]) + for entry in await controller.async_get_provisioning_entries() + if entry.additional_properties + and "device_id" in entry.additional_properties + ] # Devices that are in the device registry that are not known by the controller # can be removed for device in stored_devices: - if device not in known_devices: + if device not in known_devices and device not in provisioned_devices: self.dev_reg.async_remove_device(device.id) # run discovery on controller node @@ -339,16 +443,6 @@ class DriverEvents: controller.on("identify", self.controller_events.async_on_identify) ) - async def async_setup_platform(self, platform: Platform) -> None: - """Set up platform if needed.""" - if platform not in self.platform_setup_tasks: - self.platform_setup_tasks[platform] = self.hass.async_create_task( - self.hass.config_entries.async_forward_entry_setups( - self.config_entry, [platform] - ) - ) - await self.platform_setup_tasks[platform] - class ControllerEvents: """Represent controller events. @@ -380,9 +474,6 @@ class ControllerEvents: async def async_on_node_added(self, node: ZwaveNode) -> None: """Handle node added event.""" - # Every node including the controller will have at least one sensor - await self.driver_events.async_setup_platform(Platform.SENSOR) - # Remove stale entities that may exist from a previous interview when an # interview is started. base_unique_id = get_valueless_base_unique_id(self.driver_events.driver, node) @@ -396,6 +487,8 @@ class ControllerEvents: ) ) + await self.async_check_preprovisioned_device(node) + if node.is_controller_node: # Create a controller status sensor for each device async_dispatcher_send( @@ -411,7 +504,6 @@ class ControllerEvents: ) # Create a ping button for each device - await self.driver_events.async_setup_platform(Platform.BUTTON) async_dispatcher_send( self.hass, f"{DOMAIN}_{self.config_entry.entry_id}_add_ping_button_entity", @@ -446,7 +538,7 @@ class ControllerEvents: # we do submit the node to device registry so user has # some visual feedback that something is (in the process of) being added - self.register_node_in_dev_reg(node) + await self.async_register_node_in_dev_reg(node) @callback def async_on_node_removed(self, event: dict) -> None: @@ -523,18 +615,52 @@ class ControllerEvents: f"{DOMAIN}.identify_controller.{dev_id[1]}", ) - @callback - def register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: + async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None: + """Check if the node was preprovisioned and update the device registry.""" + provisioning_entry = ( + await self.driver_events.driver.controller.async_get_provisioning_entry( + node.node_id + ) + ) + if ( + provisioning_entry + and provisioning_entry.additional_properties + and "device_id" in provisioning_entry.additional_properties + ): + preprovisioned_device = self.dev_reg.async_get( + provisioning_entry.additional_properties["device_id"] + ) + + if preprovisioned_device: + dsk = provisioning_entry.dsk + dsk_identifier = (DOMAIN, f"provision_{dsk}") + + # If the pre-provisioned device has the DSK identifier, remove it + if dsk_identifier in preprovisioned_device.identifiers: + driver = self.driver_events.driver + device_id = get_device_id(driver, node) + device_id_ext = get_device_id_ext(driver, node) + new_identifiers = preprovisioned_device.identifiers.copy() + new_identifiers.remove(dsk_identifier) + new_identifiers.add(device_id) + if device_id_ext: + new_identifiers.add(device_id_ext) + self.dev_reg.async_update_device( + preprovisioned_device.id, + new_identifiers=new_identifiers, + ) + + async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: """Register node in dev reg.""" driver = self.driver_events.driver device_id = get_device_id(driver, node) device_id_ext = get_device_id_ext(driver, node) node_id_device = self.dev_reg.async_get_device(identifiers={device_id}) - via_device_id = None + via_identifier = None controller = driver.controller # Get the controller node device ID if this node is not the controller if controller.own_node and controller.own_node != node: - via_device_id = get_device_id(driver, controller.own_node) + via_identifier = get_device_id(driver, controller.own_node) if device_id_ext: # If there is a device with this node ID but with a different hardware @@ -581,7 +707,7 @@ class ControllerEvents: model=node.device_config.label, manufacturer=node.device_config.manufacturer, suggested_area=node.location if node.location else UNDEFINED, - via_device=via_device_id, + via_device=via_identifier, ) async_dispatcher_send(self.hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device) @@ -615,7 +741,7 @@ class NodeEvents: """Handle node ready event.""" LOGGER.debug("Processing node %s", node) # register (or update) node in device registry - device = self.controller_events.register_node_in_dev_reg(node) + device = await self.controller_events.async_register_node_in_dev_reg(node) # Remove any old value ids if this is a reinterview. self.controller_events.discovered_value_ids.pop(device.id, None) @@ -668,9 +794,6 @@ class NodeEvents: cc.id == CommandClass.FIRMWARE_UPDATE_MD.value for cc in node.command_classes ): - await self.controller_events.driver_events.async_setup_platform( - Platform.UPDATE - ) async_dispatcher_send( self.hass, f"{DOMAIN}_{self.config_entry.entry_id}_add_firmware_update_entity", @@ -701,21 +824,19 @@ class NodeEvents: value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], ) -> None: """Handle discovery info and all dependent tasks.""" + platform = disc_info.platform # This migration logic was added in 2021.3 to handle a breaking change to # the value_id format. Some time in the future, this call (as well as the # helper functions) can be removed. async_migrate_discovered_value( self.hass, self.ent_reg, - self.controller_events.registered_unique_ids[device.id][disc_info.platform], + self.controller_events.registered_unique_ids[device.id][platform], device, self.controller_events.driver_events.driver, disc_info, ) - platform = disc_info.platform - await self.controller_events.driver_events.async_setup_platform(platform) - LOGGER.debug("Discovered entity: %s", disc_info) async_dispatcher_send( self.hass, @@ -930,63 +1051,37 @@ async def client_listen( driver_ready: asyncio.Event, ) -> None: """Listen with the client.""" - should_reload = True try: await client.listen(driver_ready) - except asyncio.CancelledError: - should_reload = False except BaseZwaveJSServerError as err: - LOGGER.error("Failed to listen: %s", err) - except Exception as err: # noqa: BLE001 + if entry.state is not ConfigEntryState.LOADED: + raise + LOGGER.error("Client listen failed: %s", err) + 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 is not ConfigEntryState.LOADED: + raise # The entry needs to be reloaded since a new driver state # will be acquired on reconnect. # All model instances will be replaced when the new state is acquired. - if should_reload: - LOGGER.info("Disconnected from server. Reloading integration") - hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) - - -async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Disconnect client.""" - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] - listen_task: asyncio.Task = entry.runtime_data[DATA_CLIENT_LISTEN_TASK] - start_client_task: asyncio.Task = entry.runtime_data[DATA_START_CLIENT_TASK] - driver_events: DriverEvents = entry.runtime_data[DATA_DRIVER_EVENTS] - listen_task.cancel() - start_client_task.cancel() - platform_setup_tasks = driver_events.platform_setup_tasks.values() - for task in platform_setup_tasks: - task.cancel() - - tasks = (listen_task, start_client_task, *platform_setup_tasks) - await asyncio.gather(*tasks, return_exceptions=True) - for task in tasks: - with suppress(asyncio.CancelledError): - await task - - if client.connected: - await client.disconnect() - LOGGER.info("Disconnected from Zwave JS Server") + if not hass.is_stopping: + if entry.state is not ConfigEntryState.LOADED: + raise HomeAssistantError("Listen task ended unexpectedly") + LOGGER.debug("Disconnected from server. Reloading integration") + hass.config_entries.async_schedule_reload(entry.entry_id) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] - driver_events: DriverEvents = entry.runtime_data[DATA_DRIVER_EVENTS] - platforms = [ - platform - for platform, task in driver_events.platform_setup_tasks.items() - if not task.cancel() - ] - unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if client.connected and client.driver: - await async_disable_server_logging_if_needed(hass, entry, client.driver) - if DATA_CLIENT_LISTEN_TASK in entry.runtime_data: - await disconnect_client(hass, entry) + entry_runtime_data = entry.runtime_data + client: ZwaveClient = entry_runtime_data[DATA_CLIENT] + + if client.connected and (driver := client.driver): + await async_disable_server_logging_if_needed(hass, entry, driver) if entry.data.get(CONF_USE_ADDON) and entry.disabled_by: addon_manager: AddonManager = get_addon_manager(hass) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index aef23cb73ea..5f6050b88e9 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2,7 +2,9 @@ from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine +from contextlib import suppress import dataclasses from functools import partial, wraps from typing import Any, Concatenate, Literal, cast @@ -69,6 +71,7 @@ from homeassistant.components.websocket_api import ( ActiveConnection, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -85,12 +88,17 @@ from .const import ( CONF_INSTALLER_MODE, DATA_CLIENT, DOMAIN, + DRIVER_READY_TIMEOUT, EVENT_DEVICE_ADDED_TO_REGISTRY, + LOGGER, USER_AGENT, ) from .helpers import ( + CannotConnect, async_enable_statistics, async_get_node_from_device_id, + async_get_provisioning_entry_from_device_id, + async_get_version_info, get_device_id, ) @@ -171,6 +179,10 @@ ADDITIONAL_PROPERTIES = "additional_properties" STATUS = "status" REQUESTED_SECURITY_CLASSES = "requestedSecurityClasses" +PROTOCOL = "protocol" +DEVICE_NAME = "device_name" +AREA_ID = "area_id" + FEATURE = "feature" STRATEGY = "strategy" @@ -398,6 +410,7 @@ def async_register_api(hass: HomeAssistant) -> None: 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_subscribe_new_devices) websocket_api.async_register_command(hass, websocket_provision_smart_start_node) websocket_api.async_register_command(hass, websocket_unprovision_smart_start_node) websocket_api.async_register_command(hass, websocket_get_provisioning_entries) @@ -405,6 +418,7 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command( hass, websocket_try_parse_dsk_from_qr_code_string ) + websocket_api.async_register_command(hass, websocket_lookup_device) websocket_api.async_register_command(hass, websocket_supports_feature) websocket_api.async_register_command(hass, websocket_stop_inclusion) websocket_api.async_register_command(hass, websocket_stop_exclusion) @@ -454,6 +468,8 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_node_capabilities) websocket_api.async_register_command(hass, websocket_invoke_cc_api) websocket_api.async_register_command(hass, websocket_get_integration_settings) + websocket_api.async_register_command(hass, websocket_backup_nvm) + websocket_api.async_register_command(hass, websocket_restore_nvm) hass.http.register_view(FirmwareUploadView(dr.async_get(hass))) @@ -518,6 +534,7 @@ async def websocket_network_status( "supported_function_types": controller.supported_function_types, "suc_node_id": controller.suc_node_id, "supports_timers": controller.supports_timers, + "supports_long_range": controller.supports_long_range, "is_rebuilding_routes": controller.is_rebuilding_routes, "inclusion_state": controller.inclusion_state, "rf_region": controller.rf_region, @@ -627,18 +644,50 @@ async def websocket_node_metadata( } ) @websocket_api.async_response -@async_get_node async def websocket_node_alerts( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - node: Node, ) -> None: """Get the alerts for a Z-Wave JS node.""" + try: + node = async_get_node_from_device_id(hass, msg[DEVICE_ID]) + except ValueError as err: + if "can't be found" in err.args[0]: + provisioning_entry = await async_get_provisioning_entry_from_device_id( + hass, msg[DEVICE_ID] + ) + if provisioning_entry: + connection.send_result( + msg[ID], + { + "comments": [ + { + "level": "info", + "text": "This device has been provisioned but is not yet included in the " + "network.", + } + ], + }, + ) + else: + connection.send_error(msg[ID], ERR_NOT_FOUND, str(err)) + else: + connection.send_error(msg[ID], ERR_NOT_LOADED, str(err)) + return + + comments = node.device_config.metadata.comments + if node.in_interview: + comments.append( + { + "level": "warning", + "text": "This device is currently being interviewed and may not be fully operational.", + } + ) connection.send_result( msg[ID], { - "comments": node.device_config.metadata.comments, + "comments": comments, "is_embedded": node.device_config.is_embedded, }, ) @@ -967,18 +1016,58 @@ async def websocket_validate_dsk_and_enter_pin( connection.send_result(msg[ID]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/subscribe_new_devices", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +async def websocket_subscribe_new_devices( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to new devices.""" + + @callback + def async_cleanup() -> None: + for unsub in unsubs: + unsub() + + @callback + def device_registered(device: dr.DeviceEntry) -> None: + device_details = { + "name": device.name, + "id": device.id, + "manufacturer": device.manufacturer, + "model": device.model, + } + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": "device registered", "device": device_details} + ) + ) + + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [ + async_dispatcher_connect( + hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered + ), + ] + connection.send_result(msg[ID]) + + @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required(TYPE): "zwave_js/provision_smart_start_node", vol.Required(ENTRY_ID): str, - vol.Exclusive( - PLANNED_PROVISIONING_ENTRY, "options" - ): PLANNED_PROVISIONING_ENTRY_SCHEMA, - vol.Exclusive( - QR_PROVISIONING_INFORMATION, "options" - ): QR_PROVISIONING_INFORMATION_SCHEMA, - vol.Exclusive(QR_CODE_STRING, "options"): QR_CODE_STRING_SCHEMA, + vol.Required(QR_PROVISIONING_INFORMATION): QR_PROVISIONING_INFORMATION_SCHEMA, + vol.Optional(PROTOCOL): vol.Coerce(Protocols), + vol.Optional(DEVICE_NAME): str, + vol.Optional(AREA_ID): str, } ) @websocket_api.async_response @@ -993,36 +1082,68 @@ async def websocket_provision_smart_start_node( driver: Driver, ) -> None: """Pre-provision a smart start node.""" - try: - cv.has_at_least_one_key( - PLANNED_PROVISIONING_ENTRY, QR_PROVISIONING_INFORMATION, QR_CODE_STRING - )(msg) - except vol.Invalid as err: - connection.send_error( - msg[ID], - ERR_INVALID_FORMAT, - err.args[0], - ) - return + qr_info = msg[QR_PROVISIONING_INFORMATION] - provisioning_info = ( - msg.get(PLANNED_PROVISIONING_ENTRY) - or msg.get(QR_PROVISIONING_INFORMATION) - or msg[QR_CODE_STRING] - ) - - if ( - QR_PROVISIONING_INFORMATION in msg - and provisioning_info.version == QRCodeVersion.S2 - ): + if qr_info.version == QRCodeVersion.S2: connection.send_error( msg[ID], ERR_INVALID_FORMAT, "QR code version S2 is not supported for this command", ) return + + provisioning_info = ProvisioningEntry( + dsk=qr_info.dsk, + security_classes=qr_info.security_classes, + requested_security_classes=qr_info.requested_security_classes, + protocol=msg.get(PROTOCOL), + additional_properties=qr_info.additional_properties, + ) + + device = None + # Create an empty device if device_name is provided + if device_name := msg.get(DEVICE_NAME): + dev_reg = dr.async_get(hass) + + # Create a unique device identifier using the DSK + device_identifier = (DOMAIN, f"provision_{qr_info.dsk}") + + manufacturer = None + model = None + + device_info = await driver.config_manager.lookup_device( + qr_info.manufacturer_id, + qr_info.product_type, + qr_info.product_id, + ) + if device_info: + manufacturer = device_info.manufacturer + model = device_info.label + + # Create an empty device + device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={device_identifier}, + name=device_name, + manufacturer=manufacturer, + model=model, + via_device=get_device_id(driver, driver.controller.own_node) + if driver.controller.own_node + else None, + ) + dev_reg.async_update_device( + device.id, area_id=msg.get(AREA_ID), name_by_user=device_name + ) + + if provisioning_info.additional_properties is None: + provisioning_info.additional_properties = {} + provisioning_info.additional_properties["device_id"] = device.id + await driver.controller.async_provision_smart_start_node(provisioning_info) - connection.send_result(msg[ID]) + if device: + connection.send_result(msg[ID], device.id) + else: + connection.send_result(msg[ID]) @websocket_api.require_admin @@ -1056,7 +1177,24 @@ async def websocket_unprovision_smart_start_node( ) return dsk_or_node_id = msg.get(DSK) or msg[NODE_ID] + provisioning_entry = await driver.controller.async_get_provisioning_entry( + dsk_or_node_id + ) + if ( + provisioning_entry + and provisioning_entry.additional_properties + and "device_id" in provisioning_entry.additional_properties + ): + device_identifier = (DOMAIN, f"provision_{provisioning_entry.dsk}") + device_id = provisioning_entry.additional_properties["device_id"] + dev_reg = dr.async_get(hass) + device = dev_reg.async_get(device_id) + if device and device.identifiers == {device_identifier}: + # Only remove the device if nothing else has claimed it + dev_reg.async_remove_device(device_id) + await driver.controller.async_unprovision_smart_start_node(dsk_or_node_id) + connection.send_result(msg[ID]) @@ -1135,6 +1273,41 @@ async def websocket_try_parse_dsk_from_qr_code_string( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/lookup_device", + vol.Required(ENTRY_ID): str, + vol.Required(MANUFACTURER_ID): int, + vol.Required(PRODUCT_TYPE): int, + vol.Required(PRODUCT_ID): int, + vol.Optional(APPLICATION_VERSION): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_lookup_device( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Look up the definition of a given device in the configuration DB.""" + device = await driver.config_manager.lookup_device( + msg[MANUFACTURER_ID], + msg[PRODUCT_TYPE], + msg[PRODUCT_ID], + msg.get(APPLICATION_VERSION), + ) + if device is None: + connection.send_error(msg[ID], ERR_NOT_FOUND, "Device not found") + else: + connection.send_result(msg[ID], device.to_dict()) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -2658,6 +2831,7 @@ async def websocket_hard_reset_controller( driver: Driver, ) -> None: """Hard reset controller.""" + unsubs: list[Callable[[], None]] @callback def async_cleanup() -> None: @@ -2673,13 +2847,47 @@ async def websocket_hard_reset_controller( connection.send_result(msg[ID], device.id) async_cleanup() + @callback + def set_driver_ready(event: dict) -> None: + "Set the driver ready event." + wait_driver_ready.set() + + wait_driver_ready = asyncio.Event() + msg[DATA_UNSUBSCRIBE] = unsubs = [ async_dispatcher_connect( hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added - ) + ), + driver.once("driver ready", set_driver_ready), ] + await driver.async_hard_reset() + with suppress(TimeoutError): + async with asyncio.timeout(DRIVER_READY_TIMEOUT): + await wait_driver_ready.wait() + + # When resetting the controller, the controller home id is also changed. + # The controller state in the client is stale after resetting the controller, + # so get the new home id with a new client using the helper function. + # The client state will be refreshed by reloading the config entry, + # after the unique id of the config entry has been updated. + try: + version_info = await async_get_version_info(hass, entry.data[CONF_URL]) + except CannotConnect: + # Just log this error, as there's nothing to do about it here. + # The stale unique id needs to be handled by a repair flow, + # after the config entry has been reloaded. + LOGGER.error( + "Failed to get server version, cannot update config entry" + "unique id with new home id, after controller reset" + ) + else: + hass.config_entries.async_update_entry( + entry, unique_id=str(version_info.home_id) + ) + await hass.config_entries.async_reload(entry.entry_id) + @websocket_api.websocket_command( { @@ -2779,3 +2987,140 @@ def websocket_get_integration_settings( CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False), }, ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/backup_nvm", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_backup_nvm( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Backup NVM data.""" + controller = driver.controller + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "bytesRead": event["bytesRead"], + "total": event["total"], + }, + ) + ) + + # Set up subscription for progress events + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [ + controller.on("nvm backup progress", forward_progress), + ] + + result = await controller.async_backup_nvm_raw_base64() + # Send the finished event with the backup data + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": "finished", + "data": result, + }, + ) + ) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/restore_nvm", + vol.Required(ENTRY_ID): str, + vol.Required("data"): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_restore_nvm( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Restore NVM data.""" + controller = driver.controller + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "bytesRead": event.get("bytesRead"), + "bytesWritten": event.get("bytesWritten"), + "total": event["total"], + }, + ) + ) + + @callback + def set_driver_ready(event: dict) -> None: + "Set the driver ready event." + wait_driver_ready.set() + + wait_driver_ready = asyncio.Event() + + # Set up subscription for progress events + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [ + controller.on("nvm convert progress", forward_progress), + controller.on("nvm restore progress", forward_progress), + driver.once("driver ready", set_driver_ready), + ] + + await controller.async_restore_nvm_base64(msg["data"]) + + with suppress(TimeoutError): + async with asyncio.timeout(DRIVER_READY_TIMEOUT): + await wait_driver_ready.wait() + await hass.config_entries.async_reload(entry.entry_id) + + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": "finished", + }, + ) + ) + connection.send_result(msg[ID]) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index d07846c8dcc..1439aa0ca0f 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -67,7 +67,45 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription): # Mappings for Notification sensors -# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json +# https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx +# +# Mapping rules: +# The catch all description should not have a device class and be marked as diagnostic. +# +# The following notifications have been moved to diagnostic: +# Smoke Alarm +# - Alarm silenced +# - Replacement required +# - Replacement required, End-of-life +# - Maintenance required, planned periodic inspection +# - Maintenance required, dust in device +# CO Alarm +# - Carbon monoxide test +# - Replacement required +# - Replacement required, End-of-life +# - Alarm silenced +# - Maintenance required, planned periodic inspection +# CO2 Alarm +# - Carbon dioxide test +# - Replacement required +# - Replacement required, End-of-life +# - Alarm silenced +# - Maintenance required, planned periodic inspection +# Heat Alarm +# - Rapid temperature rise (location provided) +# - Rapid temperature rise +# - Rapid temperature fall (location provided) +# - Rapid temperature fall +# - Heat alarm test +# - Alarm silenced +# - Replacement required, End-of-life +# - Maintenance required, dust in device +# - Maintenance required, planned periodic inspection + +# Water Alarm +# - Replace water filter +# - Sump pump failure + NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = ( NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected @@ -75,10 +113,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.SMOKE, ), + NotificationZWaveJSEntityDescription( + # NotificationType 1: Smoke Alarm - State Id's 4, 5, 7, 8 + key=NOTIFICATION_SMOKE_ALARM, + states=("4", "5", "7", "8"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - All other State Id's key=NOTIFICATION_SMOKE_ALARM, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - State Id's 1 and 2 @@ -86,10 +131,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.CO, ), + NotificationZWaveJSEntityDescription( + # NotificationType 2: Carbon Monoxide - State Id 4, 5, 7 + key=NOTIFICATION_CARBON_MONOOXIDE, + states=("4", "5", "7"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - All other State Id's key=NOTIFICATION_CARBON_MONOOXIDE, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - State Id's 1 and 2 @@ -97,10 +149,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.GAS, ), + NotificationZWaveJSEntityDescription( + # NotificationType 3: Carbon Dioxide - State Id's 4, 5, 7 + key=NOTIFICATION_CARBON_DIOXIDE, + states=("4", "5", "7"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - All other State Id's key=NOTIFICATION_CARBON_DIOXIDE, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 4: Heat - State Id's 1, 2, 5, 6 (heat/underheat) @@ -109,20 +168,34 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = device_class=BinarySensorDeviceClass.HEAT, ), NotificationZWaveJSEntityDescription( - # NotificationType 4: Heat - All other State Id's + # NotificationType 4: Heat - State ID's 8, A, B key=NOTIFICATION_HEAT, + states=("8", "10", "11"), device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( - # NotificationType 5: Water - State Id's 1, 2, 3, 4 + # NotificationType 4: Heat - All other State Id's + key=NOTIFICATION_HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 5: Water - State Id's 1, 2, 3, 4, 6, 7, 8, 9, 0A key=NOTIFICATION_WATER, - states=("1", "2", "3", "4"), + states=("1", "2", "3", "4", "6", "7", "8", "9", "10"), device_class=BinarySensorDeviceClass.MOISTURE, ), + NotificationZWaveJSEntityDescription( + # NotificationType 5: Water - State Id's B + key=NOTIFICATION_WATER, + states=("11",), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 5: Water - All other State Id's key=NOTIFICATION_WATER, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock) @@ -214,16 +287,22 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = device_class=BinarySensorDeviceClass.SOUND, ), NotificationZWaveJSEntityDescription( - # NotificationType 18: Gas + # NotificationType 18: Gas - State Id's 1, 2, 3, 4 key=NOTIFICATION_GAS, states=("1", "2", "3", "4"), device_class=BinarySensorDeviceClass.GAS, ), NotificationZWaveJSEntityDescription( - # NotificationType 18: Gas + # NotificationType 18: Gas - State Id 6 key=NOTIFICATION_GAS, states=("6",), device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 18: Gas - All other State Id's + key=NOTIFICATION_GAS, + entity_category=EntityCategory.DIAGNOSTIC, ), ) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 44adf6a12ab..e442fb59cfc 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -2,15 +2,20 @@ from __future__ import annotations -from abc import ABC, abstractmethod import asyncio +from contextlib import suppress +from datetime import datetime import logging +from pathlib import Path from typing import Any -import aiohttp +from awesomeversion import AwesomeVersion from serial.tools import list_ports import voluptuous as vol -from zwave_js_server.version import VersionInfo, get_server_version +from zwave_js_server.client import Client +from zwave_js_server.exceptions import FailedCommand +from zwave_js_server.model.driver import Driver +from zwave_js_server.version import VersionInfo from homeassistant.components import usb from homeassistant.components.hassio import ( @@ -21,28 +26,21 @@ from homeassistant.components.hassio import ( ) from homeassistant.config_entries import ( SOURCE_USB, - ConfigEntriesFlowManager, ConfigEntry, - ConfigEntryBaseFlow, ConfigEntryState, ConfigFlow, - ConfigFlowContext, ConfigFlowResult, - OptionsFlow, - OptionsFlowManager, ) from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import AbortFlow, FlowManager +from homeassistant.data_entry_flow import AbortFlow 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 from .addon import get_addon_manager from .const import ( ADDON_SLUG, @@ -65,8 +63,11 @@ from .const import ( CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, CONF_USE_ADDON, + DATA_CLIENT, DOMAIN, + DRIVER_READY_TIMEOUT, ) +from .helpers import CannotConnect, async_get_version_info _LOGGER = logging.getLogger(__name__) @@ -77,7 +78,6 @@ ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 40 CONF_EMULATE_HARDWARE = "emulate_hardware" CONF_LOG_LEVEL = "log_level" -SERVER_VERSION_TIMEOUT = 10 ADDON_LOG_LEVELS = { "error": "Error", @@ -100,6 +100,7 @@ ADDON_USER_INPUT_MAP = { } ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) +MIN_MIGRATION_SDK_VERSION = AwesomeVersion("6.61") def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: @@ -127,22 +128,6 @@ async def validate_input(hass: HomeAssistant, user_input: dict) -> VersionInfo: raise InvalidInput("cannot_connect") from err -async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> VersionInfo: - """Return Z-Wave JS version info.""" - try: - async with asyncio.timeout(SERVER_VERSION_TIMEOUT): - version_info: VersionInfo = await get_server_version( - ws_address, async_get_clientsession(hass) - ) - except (TimeoutError, aiohttp.ClientError) as err: - # We don't want to spam the log if the add-on isn't started - # or takes a long time to start. - _LOGGER.debug("Failed to connect to Z-Wave JS server: %s", err) - raise CannotConnect from err - - return version_info - - def get_usb_ports() -> dict[str, str]: """Return a dict of USB ports and their friendly names.""" ports = list_ports.comports() @@ -172,8 +157,12 @@ async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: return await hass.async_add_executor_job(get_usb_ports) -class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): - """Represent the base config flow for Z-Wave JS.""" +class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Z-Wave JS.""" + + VERSION = 1 + + _title: str def __init__(self) -> None: """Set up flow instance.""" @@ -191,11 +180,16 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None self.version_info: VersionInfo | None = None - - @property - @abstractmethod - def flow_manager(self) -> FlowManager[ConfigFlowContext, ConfigFlowResult]: - """Return the flow manager of the flow.""" + self.original_addon_config: dict[str, Any] | None = None + self.revert_reason: str | None = None + self.backup_task: asyncio.Task | None = None + self.restore_backup_task: asyncio.Task | None = None + self.backup_data: bytes | None = None + self.backup_filepath: str | None = None + self.use_addon = False + self._migrating = False + self._reconfigure_config_entry: ConfigEntry | None = None + self._usb_discovery = False async def async_step_install_addon( self, user_input: dict[str, Any] | None = None @@ -257,6 +251,10 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Add-on start failed.""" + if self._migrating: + return self.async_abort(reason="addon_start_failed") + if self._reconfigure_config_entry: + return await self.async_revert_addon_config(reason="addon_start_failed") return self.async_abort(reason="addon_start_failed") async def _async_start_addon(self) -> None: @@ -290,13 +288,14 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): else: raise CannotConnect("Failed to start Z-Wave JS add-on: timeout") - @abstractmethod async def async_step_configure_addon( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for config for Z-Wave JS add-on.""" + if self._reconfigure_config_entry: + return await self.async_step_configure_addon_reconfigure(user_input) + return await self.async_step_configure_addon_user(user_input) - @abstractmethod async def async_step_finish_addon_setup( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -305,6 +304,11 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): Get add-on discovery info and server version info. Set unique id and abort if already configured. """ + if self._migrating: + return await self.async_step_finish_addon_setup_migrate(user_input) + if self._reconfigure_config_entry: + return await self.async_step_finish_addon_setup_reconfigure(user_input) + return await self.async_step_finish_addon_setup_user(user_input) async def _async_get_addon_info(self) -> AddonInfo: """Return and cache Z-Wave JS add-on info.""" @@ -317,11 +321,25 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): return addon_info - async def _async_set_addon_config(self, config: dict) -> None: + async def _async_set_addon_config(self, config_updates: dict) -> None: """Set Z-Wave JS add-on config.""" + addon_info = await self._async_get_addon_info() + addon_config = addon_info.options + + new_addon_config = addon_config | config_updates + + if new_addon_config == addon_config: + return + + if addon_info.state == AddonState.RUNNING: + self.restart_addon = True + # Copy the add-on config to keep the objects separate. + self.original_addon_config = dict(addon_config) + # Remove legacy network_key + new_addon_config.pop(CONF_ADDON_NETWORK_KEY, None) addon_manager: AddonManager = get_addon_manager(self.hass) try: - await addon_manager.async_set_addon_options(config) + await addon_manager.async_set_addon_options(new_addon_config) except AddonError as err: _LOGGER.error(err) raise AbortFlow("addon_set_config_failed") from err @@ -342,33 +360,6 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): return discovery_info_config - -class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): - """Handle a config flow for Z-Wave JS.""" - - VERSION = 1 - - _title: str - - def __init__(self) -> None: - """Set up flow instance.""" - super().__init__() - self.use_addon = False - self._usb_discovery = False - - @property - def flow_manager(self) -> ConfigEntriesFlowManager: - """Return the correct flow manager.""" - return self.hass.config_entries.flow - - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlowHandler: - """Return the options flow.""" - return OptionsFlowHandler() - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -378,6 +369,19 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): return await self.async_step_manual() + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm if we are migrating adapters or just re-configuring.""" + self._reconfigure_config_entry = self._get_reconfigure_entry() + return self.async_show_menu( + step_id="reconfigure", + menu_options=[ + "intent_reconfigure", + "intent_migrate", + ], + ) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: @@ -410,10 +414,27 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): """Handle USB Discovery.""" if not is_hassio(self.hass): return self.async_abort(reason="discovery_requires_supervisor") - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - if self._async_in_progress(): + if any( + flow + for flow in self._async_in_progress() + if flow["context"].get("source") != SOURCE_USB + ): + # Allow multiple USB discovery flows to be in progress. + # Migration requires more than one USB stick to be connected, + # which can cause more than one discovery flow to be in progress, + # at least for a short time. return self.async_abort(reason="already_in_progress") + if current_config_entries := self._async_current_entries(include_ignore=False): + config_entry = next( + ( + entry + for entry in current_config_entries + if entry.data.get(CONF_USE_ADDON) + ), + None, + ) + if not config_entry: + return self.async_abort(reason="addon_required") vid = discovery_info.vid pid = discovery_info.pid @@ -424,27 +445,44 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): if vid == "10C4" and pid == "EA60" and description and "2652" in description: return self.async_abort(reason="not_zwave_device") + discovery_info.device = await self.hass.async_add_executor_job( + usb.get_serial_by_id, discovery_info.device + ) + addon_info = await self._async_get_addon_info() - if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.NOT_RUNNING): + if ( + addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.INSTALLING) + and (addon_device := addon_info.options.get(CONF_ADDON_DEVICE)) is not None + and await self.hass.async_add_executor_job( + usb.get_serial_by_id, addon_device + ) + == discovery_info.device + ): return self.async_abort(reason="already_configured") await self.async_set_unique_id( f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" ) - self._abort_if_unique_id_configured() + # We don't need to check if the unique_id is already configured + # since we will update the unique_id before finishing the flow. + # The unique_id set above is just a temporary value to avoid + # duplicate discovery flows. dev_path = discovery_info.device self.usb_path = dev_path - self._title = usb.human_readable_device_name( - dev_path, - serial_number, - manufacturer, - description, - vid, - pid, - ) - self.context["title_placeholders"] = { - CONF_NAME: self._title.split(" - ")[0].strip() - } + if manufacturer == "Nabu Casa" and description == "ZWA-2 - Nabu Casa ZWA-2": + title = "Home Assistant Connect ZWA-2" + else: + human_name = usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + title = human_name.split(" - ")[0].strip() + self.context["title_placeholders"] = {CONF_NAME: title} + self._title = title return await self.async_step_usb_confirm() async def async_step_usb_confirm( @@ -458,6 +496,18 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): ) self._usb_discovery = True + if current_config_entries := self._async_current_entries(include_ignore=False): + self._reconfigure_config_entry = next( + ( + entry + for entry in current_config_entries + if entry.data.get(CONF_USE_ADDON) + ), + None, + ) + if not self._reconfigure_config_entry: + return self.async_abort(reason="addon_required") + return await self.async_step_intent_migrate() return await self.async_step_on_supervisor({CONF_USE_ADDON: True}) @@ -570,14 +620,14 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): self.lr_s2_authenticated_key = addon_config.get( CONF_ADDON_LR_S2_AUTHENTICATED_KEY, "" ) - return await self.async_step_finish_addon_setup() + return await self.async_step_finish_addon_setup_user() if addon_info.state == AddonState.NOT_RUNNING: - return await self.async_step_configure_addon() + return await self.async_step_configure_addon_user() return await self.async_step_install_addon() - async def async_step_configure_addon( + async def async_step_configure_addon_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for config for Z-Wave JS add-on.""" @@ -594,8 +644,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): if not self._usb_discovery: self.usb_path = user_input[CONF_USB_PATH] - new_addon_config = { - **addon_config, + addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, @@ -605,8 +654,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, } - if new_addon_config != addon_config: - await self._async_set_addon_config(new_addon_config) + await self._async_set_addon_config(addon_config_updates) return await self.async_step_start_addon() @@ -648,7 +696,12 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): } if not self._usb_discovery: - ports = await async_get_usb_ports(self.hass) + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") + schema = { vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), **schema, @@ -656,9 +709,11 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): data_schema = vol.Schema(schema) - return self.async_show_form(step_id="configure_addon", data_schema=data_schema) + return self.async_show_form( + step_id="configure_addon_user", data_schema=data_schema + ) - async def async_step_finish_addon_setup( + async def async_step_finish_addon_setup_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Prepare info needed to complete the config entry. @@ -720,45 +775,201 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): }, ) - -class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): - """Handle an options flow for Z-Wave JS.""" - - def __init__(self) -> None: - """Set up the options flow.""" - super().__init__() - self.original_addon_config: dict[str, Any] | None = None - self.revert_reason: str | None = None - - @property - def flow_manager(self) -> OptionsFlowManager: - """Return the correct flow manager.""" - return self.hass.config_entries.options - @callback - def _async_update_entry(self, data: dict[str, Any]) -> None: + def _async_update_entry(self, updates: dict[str, Any]) -> None: """Update the config entry with new data.""" - self.hass.config_entries.async_update_entry(self.config_entry, data=data) + config_entry = self._reconfigure_config_entry + assert config_entry is not None + self.hass.config_entries.async_update_entry( + config_entry, data=config_entry.data | updates + ) + self.hass.config_entries.async_schedule_reload(config_entry.entry_id) - async def async_step_init( + async def async_step_intent_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" if is_hassio(self.hass): - return await self.async_step_on_supervisor() + return await self.async_step_on_supervisor_reconfigure() - return await self.async_step_manual() + return await self.async_step_manual_reconfigure() - async def async_step_manual( + async def async_step_intent_migrate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the user wants to reset their current controller.""" + config_entry = self._reconfigure_config_entry + assert config_entry is not None + if not self._usb_discovery and not config_entry.data.get(CONF_USE_ADDON): + return self.async_abort(reason="addon_required") + + try: + driver = self._get_driver() + except AbortFlow: + return self.async_abort(reason="config_entry_not_loaded") + if ( + sdk_version := driver.controller.sdk_version + ) is not None and sdk_version < MIN_MIGRATION_SDK_VERSION: + _LOGGER.warning( + "Migration from this controller that has SDK version %s " + "is not supported. If possible, update the firmware " + "of the controller to a firmware built using SDK version %s or higher", + sdk_version, + MIN_MIGRATION_SDK_VERSION, + ) + return self.async_abort( + reason="migration_low_sdk_version", + description_placeholders={ + "ok_sdk_version": str(MIN_MIGRATION_SDK_VERSION) + }, + ) + + if user_input is not None: + self._migrating = True + return await self.async_step_backup_nvm() + + return self.async_show_form(step_id="intent_migrate") + + async def async_step_backup_nvm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Backup the current network.""" + if self.backup_task is None: + self.backup_task = self.hass.async_create_task(self._async_backup_network()) + + if not self.backup_task.done(): + return self.async_show_progress( + step_id="backup_nvm", + progress_action="backup_nvm", + progress_task=self.backup_task, + ) + + try: + await self.backup_task + except AbortFlow as err: + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="backup_failed") + finally: + self.backup_task = None + + return self.async_show_progress_done(next_step_id="instruct_unplug") + + async def async_step_restore_nvm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Restore the backup.""" + if self.restore_backup_task is None: + self.restore_backup_task = self.hass.async_create_task( + self._async_restore_network_backup() + ) + + if not self.restore_backup_task.done(): + return self.async_show_progress( + step_id="restore_nvm", + progress_action="restore_nvm", + progress_task=self.restore_backup_task, + ) + + try: + await self.restore_backup_task + except AbortFlow as err: + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="restore_failed") + finally: + self.restore_backup_task = None + + return self.async_show_progress_done(next_step_id="migration_done") + + async def async_step_instruct_unplug( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reset the current controller, and instruct the user to unplug it.""" + + if user_input is not None: + if self.usb_path: + # USB discovery was used, so the device is already known. + await self._async_set_addon_config({CONF_ADDON_DEVICE: self.usb_path}) + return await self.async_step_start_addon() + # Now that the old controller is gone, we can scan for serial ports again + return await self.async_step_choose_serial_port() + + try: + driver = self._get_driver() + except AbortFlow: + return self.async_abort(reason="config_entry_not_loaded") + + @callback + def set_driver_ready(event: dict) -> None: + "Set the driver ready event." + wait_driver_ready.set() + + wait_driver_ready = asyncio.Event() + + unsubscribe = driver.once("driver ready", set_driver_ready) + + # reset the old controller + try: + await driver.async_hard_reset() + except FailedCommand as err: + unsubscribe() + _LOGGER.error("Failed to reset controller: %s", err) + return self.async_abort(reason="reset_failed") + + # Update the unique id of the config entry + # to the new home id, which requires waiting for the driver + # to be ready before getting the new home id. + # If the backup restore, done later in the flow, fails, + # the config entry unique id should be the new home id + # after the controller reset. + try: + async with asyncio.timeout(DRIVER_READY_TIMEOUT): + await wait_driver_ready.wait() + except TimeoutError: + pass + finally: + unsubscribe() + + config_entry = self._reconfigure_config_entry + assert config_entry is not None + + try: + version_info = await async_get_version_info( + self.hass, config_entry.data[CONF_URL] + ) + except CannotConnect: + # Just log this error, as there's nothing to do about it here. + # The stale unique id needs to be handled by a repair flow, + # after the config entry has been reloaded, if the backup restore + # also fails. + _LOGGER.debug( + "Failed to get server version, cannot update config entry " + "unique id with new home id, after controller reset" + ) + else: + self.hass.config_entries.async_update_entry( + config_entry, unique_id=str(version_info.home_id) + ) + + # Unload the config entry before asking the user to unplug the controller. + await self.hass.config_entries.async_unload(config_entry.entry_id) + + return self.async_show_form( + step_id="instruct_unplug", + description_placeholders={ + "file_path": str(self.backup_filepath), + }, + ) + + async def async_step_manual_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a manual configuration.""" + config_entry = self._reconfigure_config_entry + assert config_entry is not None if user_input is None: return self.async_show_form( - step_id="manual", - data_schema=get_manual_schema( - {CONF_URL: self.config_entry.data[CONF_URL]} - ), + step_id="manual_reconfigure", + data_schema=get_manual_schema({CONF_URL: config_entry.data[CONF_URL]}), ) errors = {} @@ -771,49 +982,65 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if self.config_entry.unique_id != str(version_info.home_id): + if config_entry.unique_id != str(version_info.home_id): return self.async_abort(reason="different_device") # Make sure we disable any add-on handling # if the controller is reconfigured in a manual step. self._async_update_entry( { - **self.config_entry.data, **user_input, CONF_USE_ADDON: False, CONF_INTEGRATION_CREATED_ADDON: False, } ) - self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) - return self.async_create_entry(title=TITLE, data={}) + return self.async_abort(reason="reconfigure_successful") return self.async_show_form( - step_id="manual", data_schema=get_manual_schema(user_input), errors=errors + step_id="manual_reconfigure", + data_schema=get_manual_schema(user_input), + errors=errors, ) - async def async_step_on_supervisor( + async def async_step_on_supervisor_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle logic when on Supervisor host.""" + config_entry = self._reconfigure_config_entry + assert config_entry is not None if user_input is None: return self.async_show_form( - step_id="on_supervisor", + step_id="on_supervisor_reconfigure", data_schema=get_on_supervisor_schema( - {CONF_USE_ADDON: self.config_entry.data.get(CONF_USE_ADDON, True)} + {CONF_USE_ADDON: config_entry.data.get(CONF_USE_ADDON, True)} ), ) + if not user_input[CONF_USE_ADDON]: - return await self.async_step_manual() + if config_entry.data.get(CONF_USE_ADDON): + # Unload the config entry before stopping the add-on. + await self.hass.config_entries.async_unload(config_entry.entry_id) + addon_manager = get_addon_manager(self.hass) + _LOGGER.debug("Stopping Z-Wave JS add-on") + try: + await addon_manager.async_stop_addon() + except AddonError as err: + _LOGGER.error(err) + self.hass.config_entries.async_schedule_reload( + config_entry.entry_id + ) + raise AbortFlow("addon_stop_failed") from err + return await self.async_step_manual_reconfigure() addon_info = await self._async_get_addon_info() if addon_info.state == AddonState.NOT_INSTALLED: return await self.async_step_install_addon() - return await self.async_step_configure_addon() + return await self.async_step_configure_addon_reconfigure() - async def async_step_configure_addon( + async def async_step_configure_addon_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for config for Z-Wave JS add-on.""" @@ -829,8 +1056,7 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): self.lr_s2_authenticated_key = user_input[CONF_LR_S2_AUTHENTICATED_KEY] self.usb_path = user_input[CONF_USB_PATH] - new_addon_config = { - **addon_config, + addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, @@ -844,24 +1070,16 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): ), } - if new_addon_config != addon_config: - if addon_info.state == AddonState.RUNNING: - self.restart_addon = True - # Copy the add-on config to keep the objects separate. - self.original_addon_config = dict(addon_config) - # Remove legacy network_key - new_addon_config.pop(CONF_ADDON_NETWORK_KEY, None) - await self._async_set_addon_config(new_addon_config) + await self._async_set_addon_config(addon_config_updates) if addon_info.state == AddonState.RUNNING and not self.restart_addon: - return await self.async_step_finish_addon_setup() + return await self.async_step_finish_addon_setup_reconfigure() if ( - self.config_entry.data.get(CONF_USE_ADDON) - and self.config_entry.state == ConfigEntryState.LOADED - ): + config_entry := self._reconfigure_config_entry + ) and config_entry.data.get(CONF_USE_ADDON): # Disconnect integration before restarting add-on. - await disconnect_client(self.hass, self.config_entry) + await self.hass.config_entries.async_unload(config_entry.entry_id) return await self.async_step_start_addon() @@ -887,7 +1105,11 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info") emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False) - ports = await async_get_usb_ports(self.hass) + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") data_schema = vol.Schema( { @@ -915,15 +1137,96 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): } ) - return self.async_show_form(step_id="configure_addon", data_schema=data_schema) + return self.async_show_form( + step_id="configure_addon_reconfigure", data_schema=data_schema + ) - async def async_step_start_failed( + async def async_step_choose_serial_port( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Add-on start failed.""" - return await self.async_revert_addon_config(reason="addon_start_failed") + """Choose a serial port.""" + if user_input is not None: + self.usb_path = user_input[CONF_USB_PATH] + await self._async_set_addon_config({CONF_ADDON_DEVICE: self.usb_path}) + return await self.async_step_start_addon() - async def async_step_finish_addon_setup( + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") + + data_schema = vol.Schema( + { + vol.Required(CONF_USB_PATH): vol.In(ports), + } + ) + return self.async_show_form( + step_id="choose_serial_port", data_schema=data_schema + ) + + async def async_step_backup_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Backup failed.""" + return self.async_abort(reason="backup_failed") + + async def async_step_restore_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Restore failed.""" + if user_input is not None: + return await self.async_step_restore_nvm() + + return self.async_show_form( + step_id="restore_failed", + description_placeholders={ + "file_path": str(self.backup_filepath), + }, + ) + + async def async_step_migration_done( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Migration done.""" + return self.async_abort(reason="migration_successful") + + async def async_step_finish_addon_setup_migrate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare info needed to complete the config entry update.""" + ws_address = self.ws_address + assert ws_address is not None + version_info = self.version_info + assert version_info is not None + config_entry = self._reconfigure_config_entry + assert config_entry is not None + + # We need to wait for the config entry to be reloaded, + # before restoring the backup. + # We will do this in the restore nvm progress task, + # to get a nicer user experience. + self.hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + CONF_URL: ws_address, + CONF_USB_PATH: self.usb_path, + CONF_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + CONF_USE_ADDON: True, + CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, + }, + unique_id=str(version_info.home_id), + ) + + return await self.async_step_restore_nvm() + + async def async_step_finish_addon_setup_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Prepare info needed to complete the config entry update. @@ -931,6 +1234,8 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): Get add-on discovery info and server version info. Check for same unique id and abort if not the same unique id. """ + config_entry = self._reconfigure_config_entry + assert config_entry is not None if self.revert_reason: self.original_addon_config = None reason = self.revert_reason @@ -949,12 +1254,11 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): except CannotConnect: return await self.async_revert_addon_config(reason="cannot_connect") - if self.config_entry.unique_id != str(self.version_info.home_id): + if config_entry.unique_id != str(self.version_info.home_id): return await self.async_revert_addon_config(reason="different_device") self._async_update_entry( { - **self.config_entry.data, CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, CONF_S0_LEGACY_KEY: self.s0_legacy_key, @@ -967,9 +1271,8 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, } ) - # Always reload entry since we may have disconnected the client. - self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) - return self.async_create_entry(title=TITLE, data={}) + + return self.async_abort(reason="reconfigure_successful") async def async_revert_addon_config(self, reason: str) -> ConfigFlowResult: """Abort the options flow. @@ -984,7 +1287,9 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): ) if self.revert_reason or not self.original_addon_config: - self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) + config_entry = self._reconfigure_config_entry + assert config_entry is not None + self.hass.config_entries.async_schedule_reload(config_entry.entry_id) return self.async_abort(reason=reason) self.revert_reason = reason @@ -994,11 +1299,109 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): if addon_key in ADDON_USER_INPUT_MAP } _LOGGER.debug("Reverting add-on options, reason: %s", reason) - return await self.async_step_configure_addon(addon_config_input) + return await self.async_step_configure_addon_reconfigure(addon_config_input) + async def _async_backup_network(self) -> None: + """Backup the current network.""" -class CannotConnect(HomeAssistantError): - """Indicate connection error.""" + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to frontend.""" + self.async_update_progress(event["bytesRead"] / event["total"]) + + controller = self._get_driver().controller + unsub = controller.on("nvm backup progress", forward_progress) + try: + self.backup_data = await controller.async_backup_nvm_raw() + except FailedCommand as err: + raise AbortFlow(f"Failed to backup network: {err}") from err + finally: + unsub() + + # save the backup to a file just in case + self.backup_filepath = self.hass.config.path( + f"zwavejs_nvm_backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.bin" + ) + try: + await self.hass.async_add_executor_job( + Path(self.backup_filepath).write_bytes, + self.backup_data, + ) + except OSError as err: + raise AbortFlow(f"Failed to save backup file: {err}") from err + + async def _async_restore_network_backup(self) -> None: + """Restore the backup.""" + assert self.backup_data is not None + config_entry = self._reconfigure_config_entry + assert config_entry is not None + + # Reload the config entry to reconnect the client after the addon restart + await self.hass.config_entries.async_reload(config_entry.entry_id) + + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to frontend.""" + if event["event"] == "nvm convert progress": + # assume convert is 50% of the total progress + self.async_update_progress(event["bytesRead"] / event["total"] * 0.5) + elif event["event"] == "nvm restore progress": + # assume restore is the rest of the progress + self.async_update_progress( + event["bytesWritten"] / event["total"] * 0.5 + 0.5 + ) + + @callback + def set_driver_ready(event: dict) -> None: + "Set the driver ready event." + wait_driver_ready.set() + + driver = self._get_driver() + controller = driver.controller + wait_driver_ready = asyncio.Event() + unsubs = [ + controller.on("nvm convert progress", forward_progress), + controller.on("nvm restore progress", forward_progress), + driver.once("driver ready", set_driver_ready), + ] + try: + await controller.async_restore_nvm(self.backup_data) + except FailedCommand as err: + raise AbortFlow(f"Failed to restore network: {err}") from err + else: + with suppress(TimeoutError): + async with asyncio.timeout(DRIVER_READY_TIMEOUT): + await wait_driver_ready.wait() + try: + version_info = await async_get_version_info( + self.hass, config_entry.data[CONF_URL] + ) + except CannotConnect: + # Just log this error, as there's nothing to do about it here. + # The stale unique id needs to be handled by a repair flow, + # after the config entry has been reloaded. + _LOGGER.error( + "Failed to get server version, cannot update config entry " + "unique id with new home id, after controller reset" + ) + else: + self.hass.config_entries.async_update_entry( + config_entry, unique_id=str(version_info.home_id) + ) + await self.hass.config_entries.async_reload(config_entry.entry_id) + finally: + for unsub in unsubs: + unsub() + + def _get_driver(self) -> Driver: + """Get the driver from the config entry.""" + config_entry = self._reconfigure_config_entry + assert config_entry is not None + if config_entry.state != ConfigEntryState.LOADED: + raise AbortFlow("Configuration entry is not loaded") + client: Client = config_entry.runtime_data[DATA_CLIENT] + assert client.driver is not None + return client.driver class InvalidInput(HomeAssistantError): diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 16cf6f748bb..31cfb144e2a 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -201,3 +201,7 @@ COVER_TILT_PROPERTY_KEYS: set[str | int | None] = { WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE, WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION, } + +# Other constants + +DRIVER_READY_TIMEOUT = 60 diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 5c79c668afc..b46735e4040 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -1204,7 +1204,7 @@ DISCOVERY_SCHEMAS = [ property={RESET_METER_PROPERTY}, type={ValueType.BOOLEAN}, ), - entity_category=EntityCategory.DIAGNOSTIC, + entity_category=EntityCategory.CONFIG, ), ZWaveDiscoverySchema( platform=Platform.BINARY_SENSOR, diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 904a26acc78..bfa093f7db9 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -2,11 +2,13 @@ from __future__ import annotations +import asyncio from collections.abc import Callable from dataclasses import astuple, dataclass import logging from typing import Any, cast +import aiohttp import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( @@ -15,7 +17,7 @@ from zwave_js_server.const import ( ConfigurationValueType, LogLevel, ) -from zwave_js_server.model.controller import Controller +from zwave_js_server.model.controller import Controller, ProvisioningEntry from zwave_js_server.model.driver import Driver from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.node import Node as ZwaveNode @@ -25,6 +27,7 @@ from zwave_js_server.model.value import ( ValueDataType, get_value_id_str, ) +from zwave_js_server.version import VersionInfo, get_server_version from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -38,6 +41,7 @@ from homeassistant.const import ( 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.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.group import expand_entity_ids from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -54,6 +58,8 @@ from .const import ( LOGGER, ) +SERVER_VERSION_TIMEOUT = 10 + @dataclass class ZwaveValueID: @@ -187,7 +193,7 @@ async def async_disable_server_logging_if_needed( old_server_log_level, ) await driver.async_update_log_config(LogConfig(level=old_server_log_level)) - await driver.client.disable_server_logging() + driver.client.disable_server_logging() LOGGER.info("Zwave-js-server logging is enabled") @@ -233,7 +239,7 @@ def get_home_and_node_id_from_device_entry( ), None, ) - if device_id is None: + if device_id is None or device_id.startswith("provision_"): return None id_ = device_id.split("-") return (id_[0], int(id_[1])) @@ -264,12 +270,12 @@ def async_get_node_from_device_id( ), None, ) - if entry and entry.state != ConfigEntryState.LOADED: - raise ValueError(f"Device {device_id} config entry is not loaded") if entry is None: raise ValueError( f"Device {device_id} is not from an existing zwave_js config entry" ) + if entry.state != ConfigEntryState.LOADED: + raise ValueError(f"Device {device_id} config entry is not loaded") client: ZwaveClient = entry.runtime_data[DATA_CLIENT] driver = client.driver @@ -289,6 +295,53 @@ def async_get_node_from_device_id( return driver.controller.nodes[node_id] +async def async_get_provisioning_entry_from_device_id( + hass: HomeAssistant, device_id: str +) -> ProvisioningEntry | None: + """Get provisioning entry from a device ID. + + Raises ValueError if device is invalid + """ + dev_reg = dr.async_get(hass) + + if not (device_entry := dev_reg.async_get(device_id)): + raise ValueError(f"Device ID {device_id} is not valid") + + # Use device config entry ID's to validate that this is a valid zwave_js device + # and to get the client + config_entry_ids = device_entry.config_entries + entry = next( + ( + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.entry_id in config_entry_ids + ), + None, + ) + if entry is None: + raise ValueError( + f"Device {device_id} is not from an existing zwave_js config entry" + ) + if entry.state != ConfigEntryState.LOADED: + raise ValueError(f"Device {device_id} config entry is not loaded") + + client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + driver = client.driver + + if driver is None: + raise ValueError("Driver is not ready.") + + provisioning_entries = await driver.controller.async_get_provisioning_entries() + for provisioning_entry in provisioning_entries: + if ( + provisioning_entry.additional_properties + and provisioning_entry.additional_properties.get("device_id") == device_id + ): + return provisioning_entry + + return None + + @callback def async_get_node_from_entity_id( hass: HomeAssistant, @@ -521,3 +574,23 @@ def get_network_identifier_for_notification( return f"`{config_entry.title}`, with the home ID `{home_id}`," return f"with the home ID `{home_id}`" return "" + + +async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> VersionInfo: + """Return Z-Wave JS version info.""" + try: + async with asyncio.timeout(SERVER_VERSION_TIMEOUT): + version_info: VersionInfo = await get_server_version( + ws_address, async_get_clientsession(hass) + ) + except (TimeoutError, aiohttp.ClientError) as err: + # We don't want to spam the log if the add-on isn't started + # or takes a long time to start. + LOGGER.debug("Failed to connect to Z-Wave JS server: %s", err) + raise CannotConnect from err + + return version_info + + +class CannotConnect(HomeAssistantError): + """Indicate connection error.""" diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index a610bbcb91e..f60e129cc77 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( @@ -483,7 +483,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): red = multi_color.get(COLOR_SWITCH_COMBINED_RED, red_val.value) green = multi_color.get(COLOR_SWITCH_COMBINED_GREEN, green_val.value) blue = multi_color.get(COLOR_SWITCH_COMBINED_BLUE, blue_val.value) - if None not in (red, green, blue): + if red is not None and green is not None and blue is not None: # convert to HS self._hs_color = color_util.color_RGB_to_hs(red, green, blue) # Light supports color, set color mode to hs @@ -496,7 +496,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # Calculate color temps based on whites if cold_white or warm_white: self._color_temp = color_util.color_temperature_mired_to_kelvin( - MAX_MIREDS - ((cold_white / 255) * (MAX_MIREDS - MIN_MIREDS)) + MAX_MIREDS + - ((cast(int, cold_white) / 255) * (MAX_MIREDS - MIN_MIREDS)) ) # White channels turned on, set color mode to color_temp self._color_mode = ColorMode.COLOR_TEMP @@ -505,6 +506,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # only one white channel (warm white) = rgbw support elif red_val and green_val and blue_val and ww_val: white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value) + if TYPE_CHECKING: + assert ( + red is not None + and green is not None + and blue is not None + and white is not None + ) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw self._color_mode = ColorMode.RGBW @@ -512,6 +520,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): elif cw_val: self._supports_rgbw = True white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value) + if TYPE_CHECKING: + assert ( + red is not None + and green is not None + and blue is not None + and white is not None + ) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw self._color_mode = ColorMode.RGBW diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 3178bdf46ad..8719c333753 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.60.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.63.0"], "usb": [ { "vid": "0658", @@ -21,6 +21,13 @@ "pid": "8A2A", "description": "*z-wave*", "known_devices": ["Nortek HUSBZB-1"] + }, + { + "vid": "303A", + "pid": "4001", + "description": "*nabu casa zwa-2*", + "manufacturer": "nabu casa", + "known_devices": ["Nabu Casa Connect ZWA-2"] } ], "zeroconf": ["_zwave-js-server._tcp.local."] diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py index e515ae10549..f1deb91d869 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -57,6 +57,47 @@ class DeviceConfigFileChangedFlow(RepairsFlow): ) +class MigrateUniqueIDFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize.""" + self.description_placeholders: dict[str, str] = { + "config_entry_title": data["config_entry_title"], + "controller_model": data["controller_model"], + "new_unique_id": data["new_unique_id"], + "old_unique_id": data["old_unique_id"], + } + self._config_entry_id: str = data["config_entry_id"] + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.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 + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + config_entry = self.hass.config_entries.async_get_entry( + self._config_entry_id + ) + # If config entry was removed, we can ignore the issue. + if config_entry is not None: + self.hass.config_entries.async_update_entry( + config_entry, + unique_id=self.description_placeholders["new_unique_id"], + ) + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="confirm", + description_placeholders=self.description_placeholders, + ) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: @@ -65,4 +106,7 @@ async def async_create_fix_flow( if issue_id.split(".")[0] == "device_config_file_changed": assert data return DeviceConfigFileChangedFlow(data) + if issue_id.split(".")[0] == "migrate_unique_id": + assert data + return MigrateUniqueIDFlow(data) return ConfirmRepairFlow() diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 8f23fee4447..2a8e2c6ea2d 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -4,14 +4,24 @@ "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_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave Supervisor add-on. You can still use the Backup and Restore buttons to migrate your network manually.", "addon_set_config_failed": "Failed to set Z-Wave configuration.", "addon_start_failed": "Failed to start the Z-Wave add-on.", + "addon_stop_failed": "Failed to stop 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%]", + "backup_failed": "Failed to back up network.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "config_entry_not_loaded": "The Z-Wave configuration entry is not loaded. Please try again when the configuration entry is loaded.", + "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.", "discovery_requires_supervisor": "Discovery requires the supervisor.", + "migration_low_sdk_version": "The SDK version of the old controller is lower than {ok_sdk_version}. This means it's not possible to migrate the Non Volatile Memory (NVM) of the old controller to another controller.\n\nCheck the documentation on the manufacturer support pages of the old controller, if it's possible to upgrade the firmware of the old controller to a version that is build with SDK version {ok_sdk_version} or higher.", + "migration_successful": "Migration successful.", "not_zwave_device": "Discovered device is not a Z-Wave device.", - "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on." + "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "reset_failed": "Failed to reset controller.", + "usb_ports_failed": "Failed to get USB devices." }, "error": { "addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.", @@ -22,11 +32,15 @@ "flow_title": "{name}", "progress": { "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." + "start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds.", + "backup_nvm": "Please wait while the network backup completes.", + "restore_nvm": "Please wait while the network restore completes." }, "step": { - "configure_addon": { + "configure_addon_user": { "data": { + "lr_s2_access_control_key": "Long Range S2 Access Control Key", + "lr_s2_authenticated_key": "Long Range S2 Authenticated Key", "s0_legacy_key": "S0 Key (Legacy)", "s2_access_control_key": "S2 Access Control Key", "s2_authenticated_key": "S2 Authenticated Key", @@ -36,8 +50,23 @@ "description": "The add-on will generate security keys if those fields are left empty.", "title": "Enter the Z-Wave add-on configuration" }, + "configure_addon_reconfigure": { + "data": { + "emulate_hardware": "Emulate Hardware", + "log_level": "Log level", + "lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_access_control_key%]", + "lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_authenticated_key%]", + "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s0_legacy_key%]", + "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_access_control_key%]", + "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_authenticated_key%]", + "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_unauthenticated_key%]", + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "description": "[%key:component::zwave_js::config::step::configure_addon_user::description%]", + "title": "[%key:component::zwave_js::config::step::configure_addon_user::title%]" + }, "hassio_confirm": { - "title": "Set up Z-Wave integration with the Z-Wave add-on" + "description": "Do you want to set up the Z-Wave integration with the Z-Wave add-on?" }, "install_addon": { "title": "The Z-Wave add-on installation has started" @@ -47,6 +76,11 @@ "url": "[%key:common::config_flow::data::url%]" } }, + "manual_reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + }, "on_supervisor": { "data": { "use_addon": "Use the Z-Wave Supervisor add-on" @@ -54,6 +88,13 @@ "description": "Do you want to use the Z-Wave Supervisor add-on?", "title": "Select connection method" }, + "on_supervisor_reconfigure": { + "data": { + "use_addon": "[%key:component::zwave_js::config::step::on_supervisor::data::use_addon%]" + }, + "description": "[%key:component::zwave_js::config::step::on_supervisor::description%]", + "title": "[%key:component::zwave_js::config::step::on_supervisor::title%]" + }, "start_addon": { "title": "The Z-Wave add-on is starting." }, @@ -63,6 +104,33 @@ "zeroconf_confirm": { "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" + }, + "reconfigure": { + "title": "Migrate or re-configure", + "description": "Are you migrating to a new controller or re-configuring the current controller?", + "menu_options": { + "intent_migrate": "Migrate to a new controller", + "intent_reconfigure": "Re-configure the current controller" + } + }, + "intent_migrate": { + "title": "[%key:component::zwave_js::config::step::reconfigure::menu_options::intent_migrate%]", + "description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?" + }, + "instruct_unplug": { + "title": "Unplug your old controller", + "description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing." + }, + "restore_failed": { + "title": "Restoring unsuccessful", + "description": "Your Z-Wave network could not be restored to the new controller. This means that your Z-Wave devices are not connected to Home Assistant.\n\nThe backup is saved to ”{file_path}”", + "submit": "Try again" + }, + "choose_serial_port": { + "data": { + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "title": "Select your Z-Wave device" } } }, @@ -205,60 +273,17 @@ "invalid_server_version": { "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": { - "abort": { - "addon_get_discovery_info_failed": "[%key:component::zwave_js::config::abort::addon_get_discovery_info_failed%]", - "addon_info_failed": "[%key:component::zwave_js::config::abort::addon_info_failed%]", - "addon_install_failed": "[%key:component::zwave_js::config::abort::addon_install_failed%]", - "addon_set_config_failed": "[%key:component::zwave_js::config::abort::addon_set_config_failed%]", - "addon_start_failed": "[%key:component::zwave_js::config::abort::addon_start_failed%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device." }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_ws_url": "[%key:component::zwave_js::config::error::invalid_ws_url%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "progress": { - "install_addon": "[%key:component::zwave_js::config::progress::install_addon%]", - "start_addon": "[%key:component::zwave_js::config::progress::start_addon%]" - }, - "step": { - "configure_addon": { - "data": { - "emulate_hardware": "Emulate Hardware", - "log_level": "Log level", - "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon::data::s0_legacy_key%]", - "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_access_control_key%]", - "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_authenticated_key%]", - "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_unauthenticated_key%]", - "usb_path": "[%key:common::config_flow::data::usb_path%]" - }, - "description": "[%key:component::zwave_js::config::step::configure_addon::description%]", - "title": "[%key:component::zwave_js::config::step::configure_addon::title%]" - }, - "install_addon": { - "title": "[%key:component::zwave_js::config::step::install_addon::title%]" - }, - "manual": { - "data": { - "url": "[%key:common::config_flow::data::url%]" + "migrate_unique_id": { + "fix_flow": { + "step": { + "confirm": { + "description": "A Z-Wave controller of model {controller_model} with a different ID ({new_unique_id}) than the previously connected controller ({old_unique_id}) was connected to the {config_entry_title} configuration entry.\n\nReasons for a different controller ID could be:\n\n1. The controller was factory reset, with a 3rd party application.\n2. A controller Non Volatile Memory (NVM) backup was restored to the controller, with a 3rd party application.\n3. A different controller was connected to this configuration entry.\n\nIf a different controller was connected, you should instead set up a new configuration entry for the new controller.\n\nIf you are sure that the current controller is the correct controller you can confirm this by pressing Submit, and the configuration entry will remember the new controller ID.", + "title": "An unknown controller was detected" + } } }, - "on_supervisor": { - "data": { - "use_addon": "[%key:component::zwave_js::config::step::on_supervisor::data::use_addon%]" - }, - "description": "[%key:component::zwave_js::config::step::on_supervisor::description%]", - "title": "[%key:component::zwave_js::config::step::on_supervisor::title%]" - }, - "start_addon": { - "title": "[%key:component::zwave_js::config::step::start_addon::title%]" - } + "title": "An unknown controller was detected" } }, "services": { diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index d5c5a69cb96..e687f992afc 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave-me-ws==0.4.3", "url-normalize==1.4.3"], + "requirements": ["zwave-me-ws==0.4.3", "url-normalize==2.2.1"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/homeassistant/components/zwave_me/strings.json b/homeassistant/components/zwave_me/strings.json index 0c5a1d30976..9bc0d2b8ab7 100644 --- a/homeassistant/components/zwave_me/strings.json +++ b/homeassistant/components/zwave_me/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Input IP address with port and access token of Z-Way server. To get the token go to the Z-Way user interface Smart Home UI > Menu > Settings > Users > Administrator > API token.\n\nExample of connecting to Z-Way running as an add-on:\nURL: {add_on_url}\nToken: {local_token}\n\nExample of connecting to Z-Way in the local network:\nURL: {local_url}\nToken: {local_token}\n\nExample of connecting to Z-Way via remote access find.z-wave.me:\nURL: {find_url}\nToken: {find_token}\n\nExample of connecting to Z-Way with a static public IP address:\nURL: {remote_url}\nToken: {local_token}\n\nWhen connecting via find.z-wave.me you need to use a token with a global scope (log-in to Z-Way via find.z-wave.me for this).", + "description": "Input IP address with port and access token of Z-Way server. To get the token go to the Z-Way user interface Smart Home UI > Menu > Settings > Users > Administrator > API token.\n\nExample of connecting to Z-Way running as an add-on:\nURL: {add_on_url}\nToken: {local_token}\n\nExample of connecting to Z-Way in the local network:\nURL: {local_url}\nToken: {local_token}\n\nExample of connecting to Z-Way via remote access find.z-wave.me:\nURL: {find_url}\nToken: {find_token}\n\nExample of connecting to Z-Way with a static public IP address:\nURL: {remote_url}\nToken: {local_token}\n\nWhen connecting via find.z-wave.me you need to use a token with a global scope (log in to Z-Way via find.z-wave.me for this).", "data": { "url": "[%key:common::config_flow::data::url%]", "token": "[%key:common::config_flow::data::api_token%]" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2639c429e71..c2481ae3fa3 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -30,7 +30,6 @@ from propcache.api import cached_property import voluptuous as vol from . import data_entry_flow, loader -from .components import persistent_notification from .const import ( CONF_NAME, EVENT_HOMEASSISTANT_STARTED, @@ -73,12 +72,12 @@ from .helpers.json import json_bytes, json_bytes_sorted, json_fragment from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType from .loader import async_suggest_report_issue from .setup import ( - DATA_SETUP_DONE, SetupPhases, async_pause_setup, async_process_deps_reqs, async_setup_component, async_start_setup, + async_wait_component, ) from .util import ulid as ulid_util from .util.async_ import create_eager_task @@ -178,7 +177,6 @@ class ConfigEntryState(Enum): DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id" -DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" DISCOVERY_SOURCES = { SOURCE_BLUETOOTH, SOURCE_DHCP, @@ -195,8 +193,6 @@ DISCOVERY_SOURCES = { SOURCE_ZEROCONF, } -RECONFIGURE_NOTIFICATION_ID = "config_entry_reconfigure" - EVENT_FLOW_DISCOVERED = "config_entry_discovered" SIGNAL_CONFIG_ENTRY_CHANGED = SignalType["ConfigEntryChange", "ConfigEntry"]( @@ -1371,14 +1367,15 @@ class ConfigEntriesFlowManager( self._initialize_futures: defaultdict[str, set[asyncio.Future[None]]] = ( defaultdict(set) ) - self._discovery_debouncer = Debouncer[None]( + self._discovery_event_debouncer = Debouncer[None]( hass, _LOGGER, cooldown=DISCOVERY_COOLDOWN, immediate=True, - function=self._async_discovery, + function=self._async_fire_discovery_event, background=True, ) + self._flow_subscriptions: list[Callable[[str, str], None]] = [] async def async_wait_import_flow_initialized(self, handler: str) -> None: """Wait till all import flows in progress are initialized.""" @@ -1387,14 +1384,6 @@ class ConfigEntriesFlowManager( await asyncio.wait(current.values()) - @callback - def _async_has_other_discovery_flows(self, flow_id: str) -> bool: - """Check if there are any other discovery flows in progress.""" - for flow in self._progress.values(): - if flow.flow_id != flow_id and flow.context["source"] in DISCOVERY_SOURCES: - return True - return False - async def async_init( self, handler: str, @@ -1466,8 +1455,19 @@ class ConfigEntriesFlowManager( if not self._pending_import_flows[handler]: del self._pending_import_flows[handler] - if result["type"] != data_entry_flow.FlowResultType.ABORT: - await self.async_post_init(flow, result) + if ( + result["type"] != data_entry_flow.FlowResultType.ABORT + and source in DISCOVERY_SOURCES + ): + # Fire discovery event + await self._discovery_event_debouncer.async_call() + + if result["type"] != data_entry_flow.FlowResultType.ABORT and source in ( + DISCOVERY_SOURCES | {SOURCE_REAUTH} + ): + # Notify listeners that a flow is created + for subscription in self._flow_subscriptions: + subscription("added", flow.flow_id) return result @@ -1509,7 +1509,22 @@ class ConfigEntriesFlowManager( for future_list in self._initialize_futures.values(): for future in future_list: future.set_result(None) - self._discovery_debouncer.async_shutdown() + self._discovery_event_debouncer.async_shutdown() + + @callback + def async_flow_removed( + self, + flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], + ) -> None: + """Handle a removed config flow.""" + flow = cast(ConfigFlow, flow) + + # Clean up issue if this is a reauth flow + if flow.context["source"] == SOURCE_REAUTH: + if (entry_id := flow.context.get("entry_id")) is not None: + # The config entry's domain is flow.handler + issue_id = f"config_entry_reauth_{flow.handler}_{entry_id}" + ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) async def async_finish_flow( self, @@ -1523,26 +1538,8 @@ class ConfigEntriesFlowManager( """ flow = cast(ConfigFlow, flow) - # Mark the step as done. - # We do this to avoid a circular dependency where async_finish_flow sets up a - # new entry, which needs the integration to be set up, which is waiting for - # init to be done. - self._set_pending_import_done(flow) - - # Remove notification if no other discovery config entries in progress - if not self._async_has_other_discovery_flows(flow.flow_id): - persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_ID) - - # Clean up issue if this is a reauth flow - if flow.context["source"] == SOURCE_REAUTH: - if (entry_id := flow.context.get("entry_id")) is not None and ( - entry := self.config_entries.async_get_entry(entry_id) - ) is not None: - issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" - ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) - if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: - # If there's an ignored config entry with a matching unique ID, + # If there's a config entry with a matching unique ID, # update the discovery key. if ( (discovery_key := flow.context.get("discovery_key")) @@ -1579,6 +1576,12 @@ class ConfigEntriesFlowManager( ) return result + # Mark the step as done. + # We do this to avoid a circular dependency where async_finish_flow sets up a + # new entry, which needs the integration to be set up, which is waiting for + # init to be done. + self._set_pending_import_done(flow) + # Avoid adding a config entry for a integration # that only supports a single config entry, but already has an entry if ( @@ -1628,6 +1631,27 @@ class ConfigEntriesFlowManager( result["handler"], flow.unique_id ) + if ( + existing_entry is not None + and flow.handler != "mobile_app" + and existing_entry.source != SOURCE_IGNORE + ): + # This causes the old entry to be removed and replaced, when the flow + # should instead be aborted. + # In case of manual flows, integrations should implement options, reauth, + # reconfigure to allow the user to change settings. + # In case of non user visible flows, the integration should optionally + # update the existing entry before aborting. + # see https://developers.home-assistant.io/blog/2025/03/01/config-flow-unique-id/ + report_usage( + "creates a config entry when another entry with the same unique ID " + "exists", + core_behavior=ReportBehavior.LOG, + core_integration_behavior=ReportBehavior.LOG, + custom_integration_behavior=ReportBehavior.LOG, + integration_domain=flow.handler, + ) + # Unload the entry before setting up the new one. if existing_entry is not None and existing_entry.state.recoverable: await self.config_entries.async_unload(existing_entry.entry_id) @@ -1686,43 +1710,12 @@ class ConfigEntriesFlowManager( flow.init_step = context["source"] return flow - async def async_post_init( - self, - flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], - result: ConfigFlowResult, - ) -> None: - """After a flow is initialised trigger new flow notifications.""" - source = flow.context["source"] - - # Create notification. - if source in DISCOVERY_SOURCES: - await self._discovery_debouncer.async_call() - elif source == SOURCE_REAUTH: - persistent_notification.async_create( - self.hass, - title="Integration requires reconfiguration", - message=( - "At least one of your integrations requires reconfiguration to " - "continue functioning. [Check it out](/config/integrations)." - ), - notification_id=RECONFIGURE_NOTIFICATION_ID, - ) - @callback - def _async_discovery(self) -> None: - """Handle discovery.""" + def _async_fire_discovery_event(self) -> None: + """Fire discovery event.""" # async_fire_internal is used here because this is only # called from the Debouncer so we know the usage is safe self.hass.bus.async_fire_internal(EVENT_FLOW_DISCOVERED) - persistent_notification.async_create( - self.hass, - title="New devices discovered", - message=( - "We have discovered new devices on your network. " - "[Check it out](/config/integrations)." - ), - notification_id=DISCOVERY_NOTIFICATION_ID, - ) @callback def async_has_matching_discovery_flow( @@ -1753,6 +1746,29 @@ class ConfigEntriesFlowManager( return True return False + @callback + def async_subscribe_flow( + self, listener: Callable[[str, str], None] + ) -> CALLBACK_TYPE: + """Subscribe to non user initiated flow init or remove.""" + self._flow_subscriptions.append(listener) + return lambda: self._flow_subscriptions.remove(listener) + + @callback + def _async_remove_flow_progress(self, flow_id: str) -> None: + """Remove a flow from in progress.""" + flow = self._progress.get(flow_id) + super()._async_remove_flow_progress(flow_id) + # Fire remove event for initialized non user initiated flows + if ( + not flow + or flow.cur_step is None + or flow.source not in (DISCOVERY_SOURCES | {SOURCE_REAUTH}) + ): + return + for listeners in self._flow_subscriptions: + listeners("removed", flow_id) + class ConfigEntryItems(UserDict[str, ConfigEntry]): """Container for config items, maps config_entry_id -> entry. @@ -2111,13 +2127,7 @@ class ConfigEntries: # If the configuration entry is removed during reauth, it should # abort any reauth flow that is active for the removed entry and # linked issues. - for progress_flow in self.hass.config_entries.flow.async_progress_by_handler( - entry.domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH} - ): - if "flow_id" in progress_flow: - self.hass.config_entries.flow.async_abort(progress_flow["flow_id"]) - issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" - ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) + _abort_reauth_flows(self.hass, entry.domain, entry_id) self._async_dispatch(ConfigEntryChange.REMOVED, entry) @@ -2249,6 +2259,9 @@ class ConfigEntries: # attempts. entry.async_cancel_retry_setup() + # Abort any in-progress reauth flow and linked issues + _abort_reauth_flows(self.hass, entry.domain, entry_id) + if entry.domain not in self.hass.config.components: # If the component is not loaded, just load it as # the config entry will be loaded as well. We need @@ -2371,12 +2384,7 @@ class ConfigEntries: if unique_id is not UNDEFINED and entry.unique_id != unique_id: # Deprecated in 2024.11, should fail in 2025.11 if ( - # flipr creates duplicates during migration, and asks users to - # remove the duplicate. We don't need warn about it here too. - # We should remove the special case for "flipr" in HA Core 2025.4, - # when the flipr migration period ends - entry.domain != "flipr" - and unique_id is not None + unique_id is not None and self.async_entry_for_domain_unique_id(entry.domain, unique_id) is not None ): @@ -2595,46 +2603,6 @@ class ConfigEntries: ) ) - async def async_forward_entry_setup( - self, entry: ConfigEntry, domain: Platform | str - ) -> bool: - """Forward the setup of an entry to a different component. - - By default an entry is setup with the component it belongs to. If that - component also has related platforms, the component will have to - forward the entry to be setup by that component. - - This method is deprecated and will stop working in Home Assistant 2025.6. - - Instead, await async_forward_entry_setups as it can load - multiple platforms at once and is more efficient since it - does not require a separate import executor job for each platform. - """ - report_usage( - "calls async_forward_entry_setup for " - f"integration, {entry.domain} with title: {entry.title} " - f"and entry_id: {entry.entry_id}, which is deprecated, " - "await async_forward_entry_setups instead", - core_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.6", - ) - if not entry.setup_lock.locked(): - async with entry.setup_lock: - if entry.state is not ConfigEntryState.LOADED: - raise OperationNotAllowed( - f"The config entry '{entry.title}' ({entry.domain}) with " - f"entry_id '{entry.entry_id}' cannot forward setup for " - f"{domain} because it is in state {entry.state}, but needs " - f"to be in the {ConfigEntryState.LOADED} state" - ) - return await self._async_forward_entry_setup(entry, domain, True) - result = await self._async_forward_entry_setup(entry, domain, True) - # If the lock was held when we stated, and it was released during - # the platform setup, it means they did not await the setup call. - if not entry.setup_lock.locked(): - _report_non_awaited_platform_forwards(entry, "async_forward_entry_setup") - return result - async def _async_forward_entry_setup( self, entry: ConfigEntry, @@ -2723,11 +2691,7 @@ class ConfigEntries: Config entries which are created after Home Assistant is started can't be waited for, the function will just return if the config entry is loaded or not. """ - setup_done = self.hass.data.get(DATA_SETUP_DONE, {}) - if setup_future := setup_done.get(entry.domain): - await setup_future - # The component was not loaded. - if entry.domain not in self.hass.config.components: + if not await async_wait_component(self.hass, entry.domain): return False return entry.state is ConfigEntryState.LOADED @@ -2747,12 +2711,6 @@ class ConfigEntries: issues.add(issue.issue_id) for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001 - # flipr creates duplicates during migration, and asks users to - # remove the duplicate. We don't need warn about it here too. - # We should remove the special case for "flipr" in HA Core 2025.4, - # when the flipr migration period ends - if domain == "flipr": - continue for unique_id, entries in unique_ids.items(): # We might mutate the list of entries, so we need a copy to not mess up # the index @@ -2881,10 +2839,16 @@ class ConfigFlow(ConfigEntryBaseFlow): ) -> None: """Abort if current entries match all data. + Do not abort for the entry that is being updated by the current flow. Requires `already_configured` in strings.json in user visible flows. """ _async_abort_entries_match( - self._async_current_entries(include_ignore=False), match_dict + [ + entry + for entry in self._async_current_entries(include_ignore=False) + if entry.entry_id != self.context.get("entry_id") + ], + match_dict, ) @callback @@ -2915,6 +2879,7 @@ class ConfigFlow(ConfigEntryBaseFlow): reload_on_update: bool = True, *, error: str = "already_configured", + description_placeholders: Mapping[str, str] | None = None, ) -> None: """Abort if the unique ID is already configured. @@ -2955,7 +2920,7 @@ class ConfigFlow(ConfigEntryBaseFlow): return if should_reload: self.hass.config_entries.async_schedule_reload(entry.entry_id) - raise data_entry_flow.AbortFlow(error) + raise data_entry_flow.AbortFlow(error, description_placeholders) async def async_set_unique_id( self, unique_id: str | None = None, *, raise_on_progress: bool = True @@ -2969,8 +2934,11 @@ class ConfigFlow(ConfigEntryBaseFlow): return None if raise_on_progress: - if self._async_in_progress( - include_uninitialized=True, match_context={"unique_id": unique_id} + if any( + flow["context"]["source"] != SOURCE_REAUTH + for flow in self._async_in_progress( + include_uninitialized=True, match_context={"unique_id": unique_id} + ) ): raise data_entry_flow.AbortFlow("already_in_progress") @@ -3099,29 +3067,6 @@ class ConfigFlow(ConfigEntryBaseFlow): """Handle a flow initialized by discovery.""" return await self._async_step_discovery_without_unique_id() - @callback - def async_abort( - self, - *, - reason: str, - description_placeholders: Mapping[str, str] | None = None, - ) -> ConfigFlowResult: - """Abort the config flow.""" - # Remove reauth notification if no reauth flows are in progress - if self.source == SOURCE_REAUTH and not any( - ent["flow_id"] != self.flow_id - for ent in self.hass.config_entries.flow.async_progress_by_handler( - self.handler, match_context={"source": SOURCE_REAUTH} - ) - ): - persistent_notification.async_dismiss( - self.hass, RECONFIGURE_NOTIFICATION_ID - ) - - return super().async_abort( - reason=reason, description_placeholders=description_placeholders - ) - async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak ) -> ConfigFlowResult: @@ -3471,18 +3416,14 @@ class ConfigSubentryFlow( return self.async_abort(reason="reconfigure_successful") @property - def _reconfigure_entry_id(self) -> str: - """Return reconfigure entry id.""" - if self.source != SOURCE_RECONFIGURE: - raise ValueError(f"Source is {self.source}, expected {SOURCE_RECONFIGURE}") + def _entry_id(self) -> str: + """Return config entry id.""" return self.handler[0] @callback - def _get_reconfigure_entry(self) -> ConfigEntry: - """Return the reconfigure config entry linked to the current context.""" - return self.hass.config_entries.async_get_known_entry( - self._reconfigure_entry_id - ) + def _get_entry(self) -> ConfigEntry: + """Return the config entry linked to the current context.""" + return self.hass.config_entries.async_get_known_entry(self._entry_id) @property def _reconfigure_subentry_id(self) -> str: @@ -3494,9 +3435,7 @@ class ConfigSubentryFlow( @callback def _get_reconfigure_subentry(self) -> ConfigSubentry: """Return the reconfigure config subentry linked to the current context.""" - entry = self.hass.config_entries.async_get_known_entry( - self._reconfigure_entry_id - ) + entry = self.hass.config_entries.async_get_known_entry(self._entry_id) subentry_id = self._reconfigure_subentry_id if subentry_id not in entry.subentries: raise UnknownSubEntry(subentry_id) @@ -3810,3 +3749,13 @@ async def _async_get_flow_handler( return handler raise data_entry_flow.UnknownHandler + + +@callback +def _abort_reauth_flows(hass: HomeAssistant, domain: str, entry_id: str) -> None: + """Abort reauth flows for an entry.""" + for progress_flow in hass.config_entries.flow.async_progress_by_handler( + domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH} + ): + if "flow_id" in progress_flow: + hass.config_entries.flow.async_abort(progress_flow["flow_id"]) diff --git a/homeassistant/const.py b/homeassistant/const.py index b9695c350a7..a3674d6e5d6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,12 +24,12 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 4 +MINOR_VERSION: Final = 6 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, 13, 0) -REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) +REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) +REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) # Truthy date string triggers showing related deprecation warning messages. REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" @@ -115,6 +115,7 @@ SUN_EVENT_SUNRISE: Final = "sunrise" CONF_ABOVE: Final = "above" CONF_ACCESS_TOKEN: Final = "access_token" CONF_ACTION: Final = "action" +CONF_ACTIONS: Final = "actions" CONF_ADDRESS: Final = "address" CONF_AFTER: Final = "after" CONF_ALIAS: Final = "alias" @@ -603,6 +604,7 @@ class UnitOfReactivePower(StrEnum): """Reactive power units.""" VOLT_AMPERE_REACTIVE = "var" + KILO_VOLT_AMPERE_REACTIVE = "kvar" _DEPRECATED_POWER_VOLT_AMPERE_REACTIVE: Final = DeprecatedConstantEnum( @@ -632,6 +634,14 @@ class UnitOfEnergy(StrEnum): GIGA_CALORIE = "Gcal" +# Reactive energy units +class UnitOfReactiveEnergy(StrEnum): + """Reactive energy units.""" + + VOLT_AMPERE_REACTIVE_HOUR = "varh" + KILO_VOLT_AMPERE_REACTIVE_HOUR = "kvarh" + + # Energy Distance units class UnitOfEnergyDistance(StrEnum): """Energy Distance units.""" @@ -765,8 +775,11 @@ class UnitOfVolumeFlowRate(StrEnum): """Volume flow rate units.""" CUBIC_METERS_PER_HOUR = "m³/h" + CUBIC_METERS_PER_SECOND = "m³/s" CUBIC_FEET_PER_MINUTE = "ft³/min" + LITERS_PER_HOUR = "L/h" LITERS_PER_MINUTE = "L/min" + LITERS_PER_SECOND = "L/s" GALLONS_PER_MINUTE = "gal/min" MILLILITERS_PER_SECOND = "mL/s" diff --git a/homeassistant/core.py b/homeassistant/core.py index 46ae499e2ca..d7535907dfc 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -38,6 +38,7 @@ from typing import ( TypedDict, TypeVar, cast, + final, overload, ) @@ -71,6 +72,7 @@ from .const import ( MAX_EXPECTED_ENTITY_IDS, MAX_LENGTH_EVENT_EVENT_TYPE, MAX_LENGTH_STATE_STATE, + STATE_UNKNOWN, __version__, ) from .exceptions import ( @@ -324,6 +326,7 @@ class HassJobType(enum.Enum): Executor = 3 +@final # Final to allow direct checking of the type instead of using isinstance class HassJob[**_P, _R_co]: """Represent a job to be run later. @@ -425,9 +428,6 @@ class HomeAssistant: def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" - # pylint: disable-next=import-outside-toplevel - from . import loader - # pylint: disable-next=import-outside-toplevel from .core_config import Config @@ -441,8 +441,6 @@ class HomeAssistant: self.states = StateMachine(self.bus, self.loop) self.config = Config(self, config_dir) self.config.async_initialize() - self.components = loader.Components(self) - self.helpers = loader.Helpers(self) self.state: CoreState = CoreState.not_running self.exit_code: int = 0 # If not None, use to signal end-of-loop @@ -1317,6 +1315,7 @@ class EventOrigin(enum.Enum): return next((idx for idx, origin in enumerate(EventOrigin) if origin is self)) +@final # Final to allow direct checking of the type instead of using isinstance class Event(Generic[_DataT]): """Representation of an event within the bus.""" @@ -1796,18 +1795,13 @@ class State: ) -> None: """Initialize a new state.""" self._cache: dict[str, Any] = {} - state = str(state) - if validate_entity_id and not valid_entity_id(entity_id): raise InvalidEntityFormatError( f"Invalid entity id encountered: {entity_id}. " "Format should be ." ) - - validate_state(state) - self.entity_id = entity_id - self.state = state + self.state = state if type(state) is str else str(state) # State only creates and expects a ReadOnlyDict so # there is no need to check for subclassing with # isinstance here so we can use the faster type check. @@ -1935,13 +1929,14 @@ class State: # to avoid callers outside of this module # from misusing it by mistake. context = state_context._as_dict # noqa: SLF001 + last_changed_timestamp = self.last_changed_timestamp compressed_state: CompressedState = { COMPRESSED_STATE_STATE: self.state, COMPRESSED_STATE_ATTRIBUTES: self.attributes, COMPRESSED_STATE_CONTEXT: context, - COMPRESSED_STATE_LAST_CHANGED: self.last_changed_timestamp, + COMPRESSED_STATE_LAST_CHANGED: last_changed_timestamp, } - if self.last_changed != self.last_updated: + if last_changed_timestamp != self.last_updated_timestamp: compressed_state[COMPRESSED_STATE_LAST_UPDATED] = ( self.last_updated_timestamp ) @@ -2235,7 +2230,6 @@ class StateMachine: This avoids a race condition where multiple entities with the same entity_id are added. """ - entity_id = entity_id.lower() if entity_id in self._states_data or entity_id in self._reservations: raise HomeAssistantError( "async_reserve must not be called once the state is in the state" @@ -2272,9 +2266,11 @@ class StateMachine: This method must be run in the event loop. """ + state = str(new_state) + validate_state(state) self.async_set_internal( entity_id.lower(), - str(new_state), + state, attributes or {}, force_update, context, @@ -2300,6 +2296,8 @@ class StateMachine: breaking changes to this function in the future and it should not be used in integrations. + Callers are responsible for ensuring the entity_id is lower case. + This method must be run in the event loop. """ # Most cases the key will be in the dict @@ -2358,6 +2356,16 @@ class StateMachine: assert old_state is not None attributes = old_state.attributes + if not same_state and len(new_state) > MAX_LENGTH_STATE_STATE: + _LOGGER.error( + "State %s for %s is longer than %s, falling back to %s", + new_state, + entity_id, + MAX_LENGTH_STATE_STATE, + STATE_UNKNOWN, + ) + new_state = STATE_UNKNOWN + # This is intentionally called with positional only arguments for performance # reasons state = State( diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index f080705fced..f1ba96daae4 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -60,7 +60,6 @@ from .core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from .generated.currencies import HISTORIC_CURRENCIES from .helpers import config_validation as cv, issue_registry as ir from .helpers.entity_values import EntityValues -from .helpers.frame import ReportBehavior, report_usage from .helpers.storage import Store from .helpers.typing import UNDEFINED, UndefinedType from .util import dt as dt_util, location @@ -581,9 +580,7 @@ class Config: self.all_components: set[str] = set() # Set of loaded components - self.components: _ComponentSet = _ComponentSet( - self.top_level_components, self.all_components - ) + self.components = _ComponentSet(self.top_level_components, self.all_components) # API (HTTP) server configuration self.api: ApiConfig | None = None @@ -714,26 +711,6 @@ class Config: else: raise ValueError(f"Received invalid time zone {time_zone_str}") - def set_time_zone(self, time_zone_str: str) -> None: - """Set the time zone. - - This is a legacy method that should not be used in new code. - Use async_set_time_zone instead. - - It will be removed in Home Assistant 2025.6. - """ - report_usage( - "sets the time zone using set_time_zone instead of async_set_time_zone", - core_integration_behavior=ReportBehavior.ERROR, - custom_integration_behavior=ReportBehavior.ERROR, - breaks_in_ha_version="2025.6", - ) - if time_zone := dt_util.get_time_zone(time_zone_str): - self.time_zone = time_zone_str - dt_util.set_default_time_zone(time_zone) - else: - raise ValueError(f"Received invalid time zone {time_zone_str}") - async def _async_update( self, *, @@ -889,17 +866,17 @@ class Config: # pylint: disable-next=import-outside-toplevel from .components.frontend import storage as frontend_store - _, owner_data = await frontend_store.async_user_store( + owner_store = await frontend_store.async_user_store( self.hass, owner.id ) if ( - "language" in owner_data - and "language" in owner_data["language"] + "language" in owner_store.data + and "language" in owner_store.data["language"] ): with suppress(vol.InInvalid): data["language"] = cv.language( - owner_data["language"]["language"] + owner_store.data["language"]["language"] ) # pylint: disable-next=broad-except except Exception: diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 251e22e7990..9286f9c78f5 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -40,6 +40,7 @@ class FlowResultType(StrEnum): # Event that is fired when a flow is progressed via external or progress source. EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" +EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE = "data_entry_flow_progress_update" FLOW_NOT_COMPLETE_STEPS = { FlowResultType.FORM, @@ -207,6 +208,13 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): Handler key is the domain of the component that we want to set up. """ + @callback + def async_flow_removed( + self, + flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT], + ) -> None: + """Handle a removed data entry flow.""" + @abc.abstractmethod async def async_finish_flow( self, @@ -219,13 +227,6 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): FlowResultType.CREATE_ENTRY. """ - async def async_post_init( - self, - flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT], - result: _FlowResultT, - ) -> None: - """Entry has finished executing its first step asynchronously.""" - @callback def async_get(self, flow_id: str) -> _FlowResultT: """Return a flow in progress as a partial FlowResult.""" @@ -312,12 +313,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow.init_data = data self._async_add_flow_progress(flow) - result = await self._async_handle_step(flow, flow.init_step, data) - - if result["type"] != FlowResultType.ABORT: - await self.async_post_init(flow, result) - - return result + return await self._async_handle_step(flow, flow.init_step, data) async def async_configure( self, flow_id: str, user_input: dict | None = None @@ -469,6 +465,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): """Remove a flow from in progress.""" if (flow := self._progress.pop(flow_id, None)) is None: raise UnknownFlow + self.async_flow_removed(flow) self._async_remove_flow_from_index(flow) flow.async_cancel_progress_task() try: @@ -497,6 +494,13 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): description_placeholders=err.description_placeholders, ) + if flow.flow_id not in self._progress: + # The flow was removed during the step, raise UnknownFlow + # unless the result is an abort + if result["type"] != FlowResultType.ABORT: + raise UnknownFlow + return result + # Setup the flow handler's preview if needed if result.get("preview") is not None: await self._async_setup_preview(flow) @@ -547,7 +551,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow.cur_step = result return result - # Abort and Success results both finish the flow + # Abort and Success results both finish the flow. self._async_remove_flow_progress(flow.flow_id) return result @@ -657,6 +661,19 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): ): continue + # Process the section schema options + if ( + suggested_values is not None + and isinstance(val, section) + and key in suggested_values + ): + new_section_key = copy.copy(key) + schema[new_section_key] = val + val.schema = self.add_suggested_values_to_schema( + val.schema, suggested_values[key] + ) + continue + new_key = key if ( suggested_values @@ -813,6 +830,14 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow_result["step_id"] = step_id return flow_result + @callback + def async_update_progress(self, progress: float) -> None: + """Update the progress of a flow. `progress` must be between 0 and 1.""" + self.hass.bus.async_fire_internal( + EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE, + {"handler": self.handler, "flow_id": self.flow_id, "progress": progress}, + ) + @callback def async_show_progress_done(self, *, next_step_id: str) -> _FlowResultT: """Mark the progress done.""" diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index b891e807a7f..2f088716f8c 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -19,12 +19,15 @@ APPLICATION_CREDENTIALS = [ "iotty", "lametric", "lyric", + "mcp", "microbees", + "miele", "monzo", "myuplink", "neato", "nest", "netatmo", + "ondilo_ico", "onedrive", "point", "senz", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 587fea8b941..e796625f81c 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -202,6 +202,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "govee_ble", "local_name": "GVH5130*", }, + { + "connectable": False, + "domain": "govee_ble", + "local_name": "GVH5110*", + }, { "connectable": False, "domain": "govee_ble", @@ -356,6 +361,41 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "inkbird", "local_name": "tps", }, + { + "connectable": False, + "domain": "inkbird", + "local_name": "ITH-11-B", + }, + { + "connectable": False, + "domain": "inkbird", + "local_name": "ITH-13-B", + }, + { + "connectable": False, + "domain": "inkbird", + "local_name": "ITH-21-B", + }, + { + "connectable": False, + "domain": "inkbird", + "local_name": "IBS-P02B", + }, + { + "connectable": True, + "domain": "inkbird", + "local_name": "Ink@IAM-T1", + }, + { + "connectable": True, + "domain": "inkbird", + "manufacturer_data_start": [ + 65, + 67, + 45, + ], + "manufacturer_id": 12628, + }, { "connectable": True, "domain": "iron_os", @@ -374,6 +414,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "keymitt_ble", "local_name": "mib*", }, + { + "domain": "kulersky", + "service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c", + }, { "domain": "lamarzocco", "local_name": "MICRA_*", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8284f77ef94..1b7536ed4b9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -75,6 +75,7 @@ FLOWS = { "aussie_broadband", "autarco", "awair", + "aws_s3", "axis", "azure_data_explorer", "azure_devops", @@ -91,6 +92,7 @@ FLOWS = { "bluetooth", "bmw_connected_drive", "bond", + "bosch_alarm", "bosch_shc", "braviatv", "bring", @@ -284,7 +286,9 @@ FLOWS = { "ifttt", "igloohome", "imap", + "imeon_inverter", "imgw_pib", + "immich", "improv_ble", "incomfort", "inkbird", @@ -376,6 +380,7 @@ FLOWS = { "meteoclimatic", "metoffice", "microbees", + "miele", "mikrotik", "mill", "minecraft_server", @@ -425,6 +430,7 @@ FLOWS = { "nobo_hub", "nordpool", "notion", + "ntfy", "nuheat", "nuki", "nut", @@ -436,7 +442,6 @@ FLOWS = { "ohme", "ollama", "omnilogic", - "oncue", "ondilo_ico", "onedrive", "onewire", @@ -488,6 +493,7 @@ FLOWS = { "proximity", "prusalink", "ps4", + "pterodactyl", "pure_energie", "purpleair", "pushbullet", @@ -513,6 +519,8 @@ FLOWS = { "rdw", "recollect_waste", "refoss", + "rehlko", + "remote_calendar", "renault", "renson", "reolink", @@ -529,7 +537,6 @@ FLOWS = { "roon", "rova", "rpi_power", - "rtsp_to_webrtc", "ruckus_unleashed", "russound_rio", "ruuvi_gateway", @@ -596,6 +603,7 @@ FLOWS = { "starlink", "steam_online", "steamist", + "stiebel_eltron", "stookwijzer", "streamlabswater", "subaru", @@ -728,6 +736,7 @@ FLOWS = { "zerproc", "zeversolar", "zha", + "zimi", "zodiac", "zwave_js", "zwave_me", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 3dba5a98f3c..20b49919ace 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -8,6 +8,20 @@ from __future__ import annotations from typing import Final DHCP: Final[list[dict[str, str | bool]]] = [ + { + "domain": "airthings", + "hostname": "airthings-view", + }, + { + "domain": "airthings", + "hostname": "airthings-hub", + "macaddress": "D0141190*", + }, + { + "domain": "airthings", + "hostname": "airthings-hub", + "macaddress": "70B3D52A0*", + }, { "domain": "airzone", "macaddress": "E84F25*", @@ -84,6 +98,20 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "blink*", "macaddress": "20A171*", }, + { + "domain": "bond", + "hostname": "bond-*", + "macaddress": "3C6A2C1*", + }, + { + "domain": "bond", + "hostname": "bond-*", + "macaddress": "F44E38*", + }, + { + "domain": "bosch_alarm", + "macaddress": "000463*", + }, { "domain": "broadlink", "registered_devices": True, @@ -248,6 +276,21 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "guardian*", "macaddress": "30AEA4*", }, + { + "domain": "home_connect", + "hostname": "balay-*", + "macaddress": "C8D778*", + }, + { + "domain": "home_connect", + "hostname": "(bosch|siemens)-*", + "macaddress": "68A40E*", + }, + { + "domain": "home_connect", + "hostname": "siemens-*", + "macaddress": "38B4D3*", + }, { "domain": "homewizard", "registered_devices": True, @@ -301,6 +344,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "polisy*", "macaddress": "000DB9*", }, + { + "domain": "knocki", + "hostname": "knc*", + }, { "domain": "lamarzocco", "registered_devices": True, @@ -394,11 +441,6 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "obihai", "macaddress": "9CADEF*", }, - { - "domain": "oncue", - "hostname": "kohlergen*", - "macaddress": "00146F*", - }, { "domain": "onvif", "registered_devices": True, @@ -461,6 +503,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "rainforest_eagle", "macaddress": "D8D5B9*", }, + { + "domain": "rehlko", + "hostname": "kohlergen*", + "macaddress": "00146F*", + }, { "domain": "reolink", "hostname": "reolink*", @@ -498,6 +545,18 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "ring*", "macaddress": "341513*", }, + { + "domain": "roborock", + "macaddress": "249E7D*", + }, + { + "domain": "roborock", + "macaddress": "B04A39*", + }, + { + "domain": "roborock", + "hostname": "roborock-*", + }, { "domain": "roomba", "hostname": "irobot-*", @@ -591,6 +650,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "sleepiq", "macaddress": "64DBA0*", }, + { + "domain": "sma", + "hostname": "sma*", + "macaddress": "0015BB*", + }, + { + "domain": "sma", + "registered_devices": True, + }, { "domain": "smartthings", "hostname": "st*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e3185251114..66addc2f5b5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -219,6 +219,12 @@ "iot_class": "cloud_push", "name": "Amazon Web Services (AWS)" }, + "aws_s3": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_push", + "name": "AWS S3" + }, "fire_tv": { "integration_type": "virtual", "config_flow": false, @@ -345,6 +351,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "apollo_automation": { + "name": "Apollo Automation", + "integration_type": "virtual", + "supported_by": "esphome" + }, "appalachianpower": { "name": "Appalachian Power", "integration_type": "virtual", @@ -606,6 +617,13 @@ "config_flow": true, "iot_class": "local_push" }, + "backup": { + "name": "Backup", + "integration_type": "service", + "config_flow": false, + "iot_class": "calculated", + "single_config_entry": true + }, "baf": { "name": "Big Ass Fans", "integration_type": "hub", @@ -618,6 +636,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "balay": { + "name": "Balay", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "balboa": { "name": "Balboa Spa Client", "integration_type": "hub", @@ -747,11 +770,28 @@ "config_flow": true, "iot_class": "local_push" }, - "bosch_shc": { - "name": "Bosch SHC", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "bosch": { + "name": "Bosch", + "integrations": { + "bosch_alarm": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push", + "name": "Bosch Alarm" + }, + "bosch_shc": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Bosch SHC" + }, + "home_connect": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push", + "name": "Home Connect" + } + } }, "brandt": { "name": "Brandt Smart Control", @@ -1037,6 +1077,11 @@ "integration_type": "virtual", "supported_by": "opower" }, + "constructa": { + "name": "Constructa", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "control4": { "name": "Control4", "integration_type": "hub", @@ -1786,6 +1831,12 @@ } } }, + "eve": { + "name": "Eve", + "iot_standards": [ + "matter" + ] + }, "evergy": { "name": "Evergy", "integration_type": "virtual", @@ -2046,6 +2097,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "frankever": { + "name": "FrankEver", + "integration_type": "virtual", + "supported_by": "shelly" + }, "free_mobile": { "name": "Free Mobile", "integration_type": "hub", @@ -2140,6 +2196,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "gaggenau": { + "name": "Gaggenau", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "garadget": { "name": "Garadget", "integration_type": "hub", @@ -2306,6 +2367,12 @@ "iot_class": "cloud_polling", "name": "Google Drive" }, + "google_gemini": { + "integration_type": "virtual", + "config_flow": false, + "supported_by": "google_generative_ai_conversation", + "name": "Google Gemini" + }, "google_generative_ai_conversation": { "integration_type": "service", "config_flow": true, @@ -2479,6 +2546,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "hardkernel": { + "name": "Hardkernel", + "integration_type": "hardware", + "config_flow": false, + "single_config_entry": true + }, "harman_kardon_avr": { "name": "Harman Kardon AVR", "integration_type": "hub", @@ -2610,18 +2683,28 @@ "config_flow": true, "iot_class": "local_polling" }, - "home_connect": { - "name": "Home Connect", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_push", - "single_config_entry": true - }, "home_plus_control": { "name": "Legrand Home+ Control", "integration_type": "virtual", "supported_by": "netatmo" }, + "homeassistant_green": { + "name": "Home Assistant Green", + "integration_type": "hardware", + "config_flow": false, + "single_config_entry": true + }, + "homeassistant_sky_connect": { + "name": "Home Assistant Connect ZBT-1", + "integration_type": "hardware", + "config_flow": true + }, + "homeassistant_yellow": { + "name": "Home Assistant Yellow", + "integration_type": "hardware", + "config_flow": false, + "single_config_entry": true + }, "homee": { "name": "Homee", "integration_type": "hub", @@ -2864,12 +2947,24 @@ "config_flow": true, "iot_class": "cloud_push" }, + "imeon_inverter": { + "name": "Imeon Inverter", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "imgw_pib": { "name": "IMGW-PIB", "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, + "immich": { + "name": "Immich", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "improv_ble": { "name": "Improv via BLE", "integration_type": "device", @@ -3092,6 +3187,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "kaiser_nienhaus": { + "name": "Kaiser Nienhaus", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "kaiterra": { "name": "Kaiterra", "integration_type": "hub", @@ -3252,7 +3352,7 @@ "name": "La Marzocco", "integration_type": "device", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_push" }, "lametric": { "name": "LaMetric", @@ -3408,6 +3508,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "linkedgo": { + "name": "LinkedGo", + "integration_type": "virtual", + "supported_by": "shelly" + }, "linkplay": { "name": "LinkPlay", "integration_type": "hub", @@ -3635,11 +3740,16 @@ "iot_class": "cloud_push" }, "matter": { - "name": "Matter (BETA)", + "name": "Matter", "integration_type": "hub", "config_flow": true, "iot_class": "local_push" }, + "maytag": { + "name": "Maytag", + "integration_type": "virtual", + "supported_by": "whirlpool" + }, "mcp": { "name": "Model Context Protocol", "integration_type": "hub", @@ -3855,6 +3965,13 @@ } } }, + "miele": { + "name": "Miele", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push", + "single_config_entry": true + }, "mijndomein_energie": { "name": "Mijndomein Energie", "integration_type": "virtual", @@ -3985,7 +4102,10 @@ "iot_class": "assumed_state", "name": "Motionblinds Bluetooth" } - } + }, + "iot_standards": [ + "matter" + ] }, "motioneye": { "name": "motionEye", @@ -4131,6 +4251,11 @@ "config_flow": true, "iot_class": "local_push" }, + "national_grid_us": { + "name": "National Grid US", + "integration_type": "virtual", + "supported_by": "opower" + }, "neato": { "name": "Neato Botvac", "integration_type": "hub", @@ -4143,6 +4268,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "neff": { + "name": "Neff", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "ness_alarm": { "name": "Ness Alarm", "integration_type": "hub", @@ -4333,6 +4463,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "ntfy": { + "name": "ntfy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "nuheat": { "name": "NuHeat", "integration_type": "hub", @@ -4341,9 +4477,17 @@ }, "nuki": { "name": "Nuki", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" + "integrations": { + "nuki": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Nuki Bridge" + } + }, + "iot_standards": [ + "matter" + ] }, "numato": { "name": "Numato USB GPIO Expander", @@ -4411,6 +4555,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "ogemray": { + "name": "Ogemray", + "integration_type": "virtual", + "supported_by": "shelly" + }, "ohmconnect": { "name": "OhmConnect", "integration_type": "hub", @@ -4442,12 +4591,6 @@ "iot_class": "cloud_polling", "single_config_entry": true }, - "oncue": { - "name": "Oncue by Kohler", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "ondilo_ico": { "name": "Ondilo ICO", "integration_type": "hub", @@ -4831,6 +4974,11 @@ "integration_type": "virtual", "supported_by": "wyoming" }, + "pitsos": { + "name": "Pitsos", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "pjlink": { "name": "PJLink", "integration_type": "hub", @@ -4906,6 +5054,11 @@ "config_flow": true, "single_config_entry": true }, + "profilo": { + "name": "Profilo", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "progettihwsw": { "name": "ProgettiHWSW Automation", "integration_type": "hub", @@ -4968,6 +5121,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "pterodactyl": { + "name": "Pterodactyl", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "pulseaudio_loopback": { "name": "PulseAudio Loopback", "integration_type": "hub", @@ -5172,6 +5331,11 @@ "raspberry_pi": { "name": "Raspberry Pi", "integrations": { + "raspberry_pi": { + "integration_type": "hardware", + "config_flow": false, + "name": "Raspberry Pi" + }, "rpi_camera": { "integration_type": "hub", "config_flow": false, @@ -5233,6 +5397,12 @@ "iot_class": "local_polling", "single_config_entry": true }, + "rehlko": { + "name": "Rehlko", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "rejseplanen": { "name": "Rejseplanen", "integration_type": "hub", @@ -5245,6 +5415,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "remote_calendar": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "renault": { "name": "Renault", "integration_type": "hub", @@ -5412,12 +5587,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "rtsp_to_webrtc": { - "name": "RTSPtoWebRTC", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" - }, "ruckus_unleashed": { "name": "Ruckus", "integration_type": "hub", @@ -5702,6 +5871,11 @@ "config_flow": true, "iot_class": "local_push" }, + "siemens": { + "name": "Siemens", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "sigfox": { "name": "Sigfox", "integration_type": "hub", @@ -6119,7 +6293,7 @@ "stiebel_eltron": { "name": "STIEBEL ELTRON", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "stookwijzer": { @@ -6454,6 +6628,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "thermador": { + "name": "Thermador", + "integration_type": "virtual", + "supported_by": "home_connect" + }, "thermobeacon": { "name": "ThermoBeacon", "integration_type": "hub", @@ -7456,6 +7635,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "zimi": { + "name": "zimi", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "zodiac": { "integration_type": "hub", "config_flow": true, @@ -7670,6 +7855,7 @@ "plant", "proximity", "random", + "remote_calendar", "rpi_power", "schedule", "season", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 5bbc178ba17..acbb74645a3 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -166,6 +166,13 @@ SSDP = { "st": "urn:hyperion-project.org:device:basic:1", }, ], + "imeon_inverter": [ + { + "deviceType": "urn:schemas-upnp-org:device:Basic:1", + "manufacturer": "IMEON", + "st": "upnp:rootdevice", + }, + ], "isy994": [ { "deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index e66a5861d18..8aea15df283 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -148,4 +148,11 @@ USB = [ "pid": "8A2A", "vid": "10C4", }, + { + "description": "*nabu casa zwa-2*", + "domain": "zwave_js", + "manufacturer": "nabu casa", + "pid": "4001", + "vid": "303A", + }, ] diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index cc1683a3603..38f90663601 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -525,6 +525,11 @@ ZEROCONF = { "domain": "homekit_controller", }, ], + "_homeconnect._tcp.local.": [ + { + "domain": "home_connect", + }, + ], "_homekit._tcp.local.": [ { "domain": "homekit", @@ -710,6 +715,11 @@ ZEROCONF = { "domain": "thread", }, ], + "_mieleathome._tcp.local.": [ + { + "domain": "miele", + }, + ], "_miio._udp.local.": [ { "domain": "xiaomi_aqara", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 3d8dc247857..a9976cf7e32 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -28,6 +28,7 @@ from homeassistant.util.json import json_loads from .frame import warn_use from .json import json_dumps +from .singleton import singleton if TYPE_CHECKING: from aiohttp.typedefs import JSONDecoder @@ -39,6 +40,7 @@ DATA_CONNECTOR: HassKey[dict[tuple[bool, int, str], aiohttp.BaseConnector]] = Ha DATA_CLIENTSESSION: HassKey[dict[tuple[bool, int, str], aiohttp.ClientSession]] = ( HassKey("aiohttp_clientsession") ) +DATA_RESOLVER: HassKey[HassAsyncDNSResolver] = HassKey("aiohttp_resolver") SERVER_SOFTWARE = ( f"{APPLICATION_NAME}/{__version__} " @@ -70,6 +72,21 @@ MAXIMUM_CONNECTIONS = 4096 MAXIMUM_CONNECTIONS_PER_HOST = 100 +class HassAsyncDNSResolver(AsyncDualMDNSResolver): + """Home Assistant AsyncDNSResolver. + + This is a wrapper around the AsyncDualMDNSResolver to only + close the resolver when the Home Assistant instance is closed. + """ + + async def real_close(self) -> None: + """Close the resolver.""" + await super().close() + + async def close(self) -> None: + """Close the resolver.""" + + class HassClientResponse(aiohttp.ClientResponse): """aiohttp.ClientResponse with a json method that uses json_loads by default.""" @@ -363,7 +380,7 @@ def _async_get_connector( ssl=ssl_context, limit=MAXIMUM_CONNECTIONS, limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, - resolver=_async_make_resolver(hass), + resolver=_async_get_or_create_resolver(hass), ) connectors[connector_key] = connector @@ -376,6 +393,19 @@ def _async_get_connector( return connector +@singleton(DATA_RESOLVER) @callback -def _async_make_resolver(hass: HomeAssistant) -> AsyncDualMDNSResolver: - return AsyncDualMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) +def _async_get_or_create_resolver(hass: HomeAssistant) -> HassAsyncDNSResolver: + """Return the HassAsyncDNSResolver.""" + resolver = _async_make_resolver(hass) + + async def _async_close_resolver(event: Event) -> None: + await resolver.real_close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_resolver) + return resolver + + +@callback +def _async_make_resolver(hass: HomeAssistant) -> HassAsyncDNSResolver: + return HassAsyncDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 5601ce4032d..ba02ed51f6b 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -20,6 +20,7 @@ from .json import json_bytes, json_fragment from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, NormalizedNameBaseRegistryItems, + normalize_name, ) from .registry import BaseRegistry, RegistryIndexType from .singleton import singleton @@ -169,6 +170,7 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): super().__init__() self._labels_index: RegistryIndexType = defaultdict(dict) self._floors_index: RegistryIndexType = defaultdict(dict) + self._aliases_index: RegistryIndexType = defaultdict(dict) def _index_entry(self, key: str, entry: AreaEntry) -> None: """Index an entry.""" @@ -177,6 +179,9 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): self._floors_index[entry.floor_id][key] = True for label in entry.labels: self._labels_index[label][key] = True + for alias in entry.aliases: + normalized_alias = normalize_name(alias) + self._aliases_index[normalized_alias][key] = True def _unindex_entry( self, key: str, replacement_entry: AreaEntry | None = None @@ -184,6 +189,10 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): # always call base class before other indices super()._unindex_entry(key, replacement_entry) entry = self.data[key] + if aliases := entry.aliases: + for alias in aliases: + normalized_alias = normalize_name(alias) + self._unindex_entry_value(key, normalized_alias, self._aliases_index) if labels := entry.labels: for label in labels: self._unindex_entry_value(key, label, self._labels_index) @@ -200,6 +209,12 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): data = self.data return [data[key] for key in self._floors_index.get(floor, ())] + def get_areas_for_alias(self, alias: str) -> list[AreaEntry]: + """Get areas for alias.""" + data = self.data + normalized_alias = normalize_name(alias) + return [data[key] for key in self._aliases_index.get(normalized_alias, ())] + class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): """Class to hold a registry of areas.""" @@ -232,6 +247,11 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): """Get area by name.""" return self.areas.get_by_name(name) + @callback + def async_get_areas_by_alias(self, alias: str) -> list[AreaEntry]: + """Get areas by alias.""" + return self.areas.get_areas_for_alias(alias) + @callback def async_list_areas(self) -> Iterable[AreaEntry]: """Get all areas.""" diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py index 4ab302749a1..b3607f6653c 100644 --- a/homeassistant/helpers/backup.py +++ b/homeassistant/helpers/backup.py @@ -12,7 +12,11 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: - from homeassistant.components.backup import BackupManager, ManagerStateEvent + from homeassistant.components.backup import ( + BackupManager, + BackupPlatformEvent, + ManagerStateEvent, + ) DATA_BACKUP: HassKey[BackupData] = HassKey("backup_data") DATA_MANAGER: HassKey[BackupManager] = HassKey("backup") @@ -25,6 +29,9 @@ class BackupData: backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = field( default_factory=list ) + backup_platform_event_subscriptions: list[Callable[[BackupPlatformEvent], None]] = ( + field(default_factory=list) + ) manager_ready: asyncio.Future[None] = field(default_factory=asyncio.Future) @@ -68,3 +75,20 @@ def async_subscribe_events( backup_event_subscriptions.append(on_event) return remove_subscription + + +@callback +def async_subscribe_platform_events( + hass: HomeAssistant, + on_event: Callable[[BackupPlatformEvent], None], +) -> Callable[[], None]: + """Subscribe to backup platform events.""" + backup_platform_event_subscriptions = hass.data[ + DATA_BACKUP + ].backup_platform_event_subscriptions + + def remove_subscription() -> None: + backup_platform_event_subscriptions.remove(on_event) + + backup_platform_event_subscriptions.append(on_event) + return remove_subscription diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 0841585e1a1..836536da9ee 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -8,6 +8,7 @@ import os from pathlib import Path from typing import NamedTuple, Self +from annotatedyaml import loader as yaml_loader import voluptuous as vol from homeassistant import loader @@ -29,7 +30,6 @@ from homeassistant.requirements import ( async_clear_install_history, async_get_integration_with_requirements, ) -from homeassistant.util.yaml import loader as yaml_loader from . import config_validation as cv from .typing import ConfigType diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index fa2dd42589b..fbdf2dce7b1 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -42,8 +42,6 @@ from homeassistant.const import ( ENTITY_MATCH_ANY, STATE_UNAVAILABLE, STATE_UNKNOWN, - SUN_EVENT_SUNRISE, - SUN_EVENT_SUNSET, WEEKDAYS, ) from homeassistant.core import HomeAssistant, State, callback @@ -60,7 +58,6 @@ from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe from . import config_validation as cv, entity_registry as er -from .sun import get_astral_event_date from .template import Template, render_complex from .trace import ( TraceElement, @@ -85,7 +82,6 @@ _PLATFORM_ALIASES = { "numeric_state": None, "or": None, "state": None, - "sun": None, "template": None, "time": None, "trigger": None, @@ -98,12 +94,7 @@ INPUT_ENTITY_ID = re.compile( class ConditionProtocol(Protocol): - """Define the format of device_condition modules. - - Each module must define either CONDITION_SCHEMA or async_validate_condition_config. - """ - - CONDITION_SCHEMA: vol.Schema + """Define the format of condition modules.""" async def async_validate_condition_config( self, hass: HomeAssistant, config: ConfigType @@ -655,105 +646,6 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType: return if_state -def sun( - hass: HomeAssistant, - before: str | None = None, - after: str | None = None, - before_offset: timedelta | None = None, - after_offset: timedelta | None = None, -) -> bool: - """Test if current time matches sun requirements.""" - utcnow = dt_util.utcnow() - today = dt_util.as_local(utcnow).date() - before_offset = before_offset or timedelta(0) - after_offset = after_offset or timedelta(0) - - sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today) - sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) - - has_sunrise_condition = SUN_EVENT_SUNRISE in (before, after) - has_sunset_condition = SUN_EVENT_SUNSET in (before, after) - - after_sunrise = today > dt_util.as_local(cast(datetime, sunrise)).date() - if after_sunrise and has_sunrise_condition: - tomorrow = today + timedelta(days=1) - sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, tomorrow) - - after_sunset = today > dt_util.as_local(cast(datetime, sunset)).date() - if after_sunset and has_sunset_condition: - tomorrow = today + timedelta(days=1) - sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, tomorrow) - - # Special case: before sunrise OR after sunset - # This will handle the very rare case in the polar region when the sun rises/sets - # but does not set/rise. - # However this entire condition does not handle those full days of darkness - # or light, the following should be used instead: - # - # condition: - # condition: state - # entity_id: sun.sun - # state: 'above_horizon' (or 'below_horizon') - # - if before == SUN_EVENT_SUNRISE and after == SUN_EVENT_SUNSET: - wanted_time_before = cast(datetime, sunrise) + before_offset - condition_trace_update_result(wanted_time_before=wanted_time_before) - wanted_time_after = cast(datetime, sunset) + after_offset - condition_trace_update_result(wanted_time_after=wanted_time_after) - return utcnow < wanted_time_before or utcnow > wanted_time_after - - if sunrise is None and has_sunrise_condition: - # There is no sunrise today - condition_trace_set_result(False, message="no sunrise today") - return False - - if sunset is None and has_sunset_condition: - # There is no sunset today - condition_trace_set_result(False, message="no sunset today") - return False - - if before == SUN_EVENT_SUNRISE: - wanted_time_before = cast(datetime, sunrise) + before_offset - condition_trace_update_result(wanted_time_before=wanted_time_before) - if utcnow > wanted_time_before: - return False - - if before == SUN_EVENT_SUNSET: - wanted_time_before = cast(datetime, sunset) + before_offset - condition_trace_update_result(wanted_time_before=wanted_time_before) - if utcnow > wanted_time_before: - return False - - if after == SUN_EVENT_SUNRISE: - wanted_time_after = cast(datetime, sunrise) + after_offset - condition_trace_update_result(wanted_time_after=wanted_time_after) - if utcnow < wanted_time_after: - return False - - if after == SUN_EVENT_SUNSET: - wanted_time_after = cast(datetime, sunset) + after_offset - condition_trace_update_result(wanted_time_after=wanted_time_after) - if utcnow < wanted_time_after: - return False - - return True - - -def sun_from_config(config: ConfigType) -> ConditionCheckerType: - """Wrap action method with sun based condition.""" - before = config.get("before") - after = config.get("after") - before_offset = config.get("before_offset") - after_offset = config.get("after_offset") - - @trace_condition_function - def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - """Validate time based if-condition.""" - return sun(hass, before, after, before_offset, after_offset) - - return sun_if - - def template( hass: HomeAssistant, value_template: Template, variables: TemplateVarsType = None ) -> bool: @@ -1054,7 +946,7 @@ async def async_validate_condition_config( return config platform = await _async_get_condition_platform(hass, config) - if platform is not None and hasattr(platform, "async_validate_condition_config"): + if platform is not None: return await platform.async_validate_condition_config(hass, config) if platform is None and condition in ("numeric_state", "state"): validator = cast( diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 24a9de5b562..1cff90031c2 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -11,7 +11,9 @@ from __future__ import annotations from abc import ABC, ABCMeta, abstractmethod import asyncio from asyncio import Lock +import base64 from collections.abc import Awaitable, Callable +import hashlib from http import HTTPStatus from json import JSONDecodeError import logging @@ -25,11 +27,11 @@ import voluptuous as vol from yarl import URL from homeassistant import config_entries -from homeassistant.components import http from homeassistant.core import HomeAssistant, callback from homeassistant.loader import async_get_application_credentials from homeassistant.util.hass_dict import HassKey +from . import http from .aiohttp_client import async_get_clientsession from .network import NoURLAvailableError @@ -166,6 +168,11 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): """Extra data that needs to be appended to the authorize url.""" return {} + @property + def extra_token_resolve_data(self) -> dict: + """Extra data for the token resolve request.""" + return {} + async def async_generate_authorize_url(self, flow_id: str) -> str: """Generate a url for the user to authorize.""" redirect_uri = self.redirect_uri @@ -186,13 +193,13 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): 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"], - } - ) + request_data: dict = { + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + request_data.update(self.extra_token_resolve_data) + return await self._token_request(request_data) async def _async_refresh_token(self, token: dict) -> dict: """Refresh tokens.""" @@ -211,7 +218,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): data["client_id"] = self.client_id - if self.client_secret is not None: + if self.client_secret: data["client_secret"] = self.client_secret _LOGGER.debug("Sending token request to %s", self.token_url) @@ -233,6 +240,100 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): return cast(dict, await resp.json()) +class LocalOAuth2ImplementationWithPkce(LocalOAuth2Implementation): + """Local OAuth2 implementation with PKCE.""" + + def __init__( + self, + hass: HomeAssistant, + domain: str, + client_id: str, + authorize_url: str, + token_url: str, + client_secret: str = "", + code_verifier_length: int = 128, + ) -> None: + """Initialize local auth implementation.""" + super().__init__( + hass, + domain, + client_id, + client_secret, + authorize_url, + token_url, + ) + + # Generate code verifier + self.code_verifier = LocalOAuth2ImplementationWithPkce.generate_code_verifier( + code_verifier_length + ) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url. + + If you want to override this method, + calling super is mandatory (for adding scopes): + ``` + @def extra_authorize_data(self) -> dict: + data: dict = { + "scope": "openid profile email", + } + data.update(super().extra_authorize_data) + return data + ``` + """ + return { + "code_challenge": LocalOAuth2ImplementationWithPkce.compute_code_challenge( + self.code_verifier + ), + "code_challenge_method": "S256", + } + + @property + def extra_token_resolve_data(self) -> dict: + """Extra data that needs to be included in the token resolve request. + + If you want to override this method, + calling super is mandatory (for adding `someKey`): + ``` + @def extra_token_resolve_data(self) -> dict: + data: dict = { + "someKey": "someValue", + } + data.update(super().extra_token_resolve_data) + return data + ``` + """ + + return {"code_verifier": self.code_verifier} + + @staticmethod + def generate_code_verifier(code_verifier_length: int = 128) -> str: + """Generate a code verifier.""" + if not 43 <= code_verifier_length <= 128: + msg = ( + "Parameter `code_verifier_length` must validate" + "`43 <= code_verifier_length <= 128`." + ) + raise ValueError(msg) + return secrets.token_urlsafe(96)[:code_verifier_length] + + @staticmethod + def compute_code_challenge(code_verifier: str) -> str: + """Compute the code challenge.""" + if not 43 <= len(code_verifier) <= 128: + msg = ( + "Parameter `code_verifier` must validate " + "`43 <= len(code_verifier) <= 128`." + ) + raise ValueError(msg) + + hashed = hashlib.sha256(code_verifier.encode("ascii")).digest() + encoded = base64.urlsafe_b64encode(hashed) + return encoded.decode("ascii").replace("=", "") + + class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): """Handle a config flow.""" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 4978158c0f6..31a3e365071 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -21,7 +21,7 @@ from socket import ( # type: ignore[attr-defined] # private, not in typeshed _GLOBAL_DEFAULT_TIMEOUT, ) import threading -from typing import Any, cast, overload +from typing import TYPE_CHECKING, Any, cast, overload from urllib.parse import urlparse from uuid import UUID @@ -355,7 +355,13 @@ 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] + if isinstance(value, list): + if TYPE_CHECKING: + # https://github.com/home-assistant/core/pull/71960 + # cast with a type variable is still slow. + return cast(list[_T], value) + return value # type: ignore[unreachable] + return [value] def entity_id(value: Any) -> str: @@ -1053,10 +1059,38 @@ def removed( ) +def renamed( + old_key: str, + new_key: str, +) -> Callable[[Any], Any]: + """Replace key with a new key. + + Fails if both the new and old key are present. + """ + + def validator(value: Any) -> Any: + if not isinstance(value, dict): + return value + + if old_key in value: + if new_key in value: + raise vol.Invalid( + f"Cannot specify both '{old_key}' and '{new_key}'. Please use '{new_key}' only." + ) + value[new_key] = value.pop(old_key) + + return value + + return validator + + +type ValueSchemas = dict[Hashable, VolSchemaType | Callable[[Any], dict[str, Any]]] + + def key_value_schemas( key: str, - value_schemas: dict[Hashable, VolSchemaType | Callable[[Any], dict[str, Any]]], - default_schema: VolSchemaType | None = None, + value_schemas: ValueSchemas, + default_schema: VolSchemaType | Callable[[Any], dict[str, Any]] | None = None, default_description: str | None = None, ) -> Callable[[Any], dict[Hashable, Any]]: """Create a validator that validates based on a value for specific key. @@ -1153,41 +1187,6 @@ def _custom_serializer(schema: Any, *, allow_section: bool) -> Any: return voluptuous_serialize.UNSUPPORTED -def expand_condition_shorthand(value: Any | None) -> Any: - """Expand boolean condition shorthand notations.""" - - if not isinstance(value, dict) or CONF_CONDITIONS in value: - return value - - for key, schema in ( - ("and", AND_CONDITION_SHORTHAND_SCHEMA), - ("or", OR_CONDITION_SHORTHAND_SCHEMA), - ("not", NOT_CONDITION_SHORTHAND_SCHEMA), - ): - try: - schema(value) - return { - CONF_CONDITION: key, - CONF_CONDITIONS: value[key], - **{k: value[k] for k in value if k != key}, - } - except vol.MultipleInvalid: - pass - - if isinstance(value.get(CONF_CONDITION), list): - try: - CONDITION_SHORTHAND_SCHEMA(value) - return { - CONF_CONDITION: "and", - CONF_CONDITIONS: value[CONF_CONDITION], - **{k: value[k] for k in value if k != CONF_CONDITION}, - } - except vol.MultipleInvalid: - pass - - return value - - # Schemas def empty_config_schema(domain: str) -> Callable[[dict], dict]: """Return a config schema which logs if there are configuration parameters.""" @@ -1683,7 +1682,43 @@ DEVICE_CONDITION_BASE_SCHEMA = vol.Schema( DEVICE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -dynamic_template_condition_action = vol.All( + +def expand_condition_shorthand(value: Any | None) -> Any: + """Expand boolean condition shorthand notations.""" + + if not isinstance(value, dict) or CONF_CONDITIONS in value: + return value + + for key, schema in ( + ("and", AND_CONDITION_SHORTHAND_SCHEMA), + ("or", OR_CONDITION_SHORTHAND_SCHEMA), + ("not", NOT_CONDITION_SHORTHAND_SCHEMA), + ): + try: + schema(value) + return { + CONF_CONDITION: key, + CONF_CONDITIONS: value[key], + **{k: value[k] for k in value if k != key}, + } + except vol.MultipleInvalid: + pass + + if isinstance(value.get(CONF_CONDITION), list): + try: + CONDITION_SHORTHAND_SCHEMA(value) + return { + CONF_CONDITION: "and", + CONF_CONDITIONS: value[CONF_CONDITION], + **{k: value[k] for k in value if k != CONF_CONDITION}, + } + except vol.MultipleInvalid: + pass + + return value + + +dynamic_template_condition = vol.All( # Wrap a shorthand template condition in a template condition dynamic_template, lambda config: { @@ -1703,28 +1738,44 @@ CONDITION_SHORTHAND_SCHEMA = vol.Schema( } ) +BUILT_IN_CONDITIONS: ValueSchemas = { + "and": AND_CONDITION_SCHEMA, + "device": DEVICE_CONDITION_SCHEMA, + "not": NOT_CONDITION_SCHEMA, + "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, + "or": OR_CONDITION_SCHEMA, + "state": STATE_CONDITION_SCHEMA, + "template": TEMPLATE_CONDITION_SCHEMA, + "time": TIME_CONDITION_SCHEMA, + "trigger": TRIGGER_CONDITION_SCHEMA, + "zone": ZONE_CONDITION_SCHEMA, +} + + +# This is first round of validation, we don't want to mutate the config here already, +# just ensure basics as condition type and alias are there. +def _base_condition_validator(value: Any) -> Any: + vol.Schema( + { + **CONDITION_BASE_SCHEMA, + CONF_CONDITION: vol.NotIn(BUILT_IN_CONDITIONS), + }, + extra=vol.ALLOW_EXTRA, + )(value) + return value + + CONDITION_SCHEMA: vol.Schema = vol.Schema( vol.Any( vol.All( expand_condition_shorthand, key_value_schemas( CONF_CONDITION, - { - "and": AND_CONDITION_SCHEMA, - "device": DEVICE_CONDITION_SCHEMA, - "not": NOT_CONDITION_SCHEMA, - "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, - "or": OR_CONDITION_SCHEMA, - "state": STATE_CONDITION_SCHEMA, - "sun": SUN_CONDITION_SCHEMA, - "template": TEMPLATE_CONDITION_SCHEMA, - "time": TIME_CONDITION_SCHEMA, - "trigger": TRIGGER_CONDITION_SCHEMA, - "zone": ZONE_CONDITION_SCHEMA, - }, + BUILT_IN_CONDITIONS, + _base_condition_validator, ), ), - dynamic_template_condition_action, + dynamic_template_condition, ) ) @@ -1748,20 +1799,11 @@ CONDITION_ACTION_SCHEMA: vol.Schema = vol.Schema( expand_condition_shorthand, key_value_schemas( CONF_CONDITION, - { - "and": AND_CONDITION_SCHEMA, - "device": DEVICE_CONDITION_SCHEMA, - "not": NOT_CONDITION_SCHEMA, - "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, - "or": OR_CONDITION_SCHEMA, - "state": STATE_CONDITION_SCHEMA, - "sun": SUN_CONDITION_SCHEMA, - "template": TEMPLATE_CONDITION_SCHEMA, - "time": TIME_CONDITION_SCHEMA, - "trigger": TRIGGER_CONDITION_SCHEMA, - "zone": ZONE_CONDITION_SCHEMA, - }, - dynamic_template_condition_action, + BUILT_IN_CONDITIONS, + vol.Any( + dynamic_template_condition_action, + _base_condition_validator, + ), "a list of conditions or a valid template", ), ) @@ -1820,7 +1862,7 @@ def _base_trigger_list_flatten(triggers: list[Any]) -> list[Any]: return flatlist -# This is first round of validation, we don't want to process the config here already, +# This is first round of validation, we don't want to mutate the config here already, # just ensure basics as platform and ID are there. def _base_trigger_validator(value: Any) -> Any: _base_trigger_validator_schema(value) @@ -1873,12 +1915,8 @@ _SCRIPT_REPEAT_SCHEMA = vol.Schema( vol.Exclusive(CONF_FOR_EACH, "repeat"): vol.Any( dynamic_template, vol.All(list, template_complex) ), - vol.Exclusive(CONF_WHILE, "repeat"): vol.All( - ensure_list, [CONDITION_SCHEMA] - ), - vol.Exclusive(CONF_UNTIL, "repeat"): vol.All( - ensure_list, [CONDITION_SCHEMA] - ), + vol.Exclusive(CONF_WHILE, "repeat"): CONDITIONS_SCHEMA, + vol.Exclusive(CONF_UNTIL, "repeat"): CONDITIONS_SCHEMA, vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA, }, has_at_least_one_key(CONF_COUNT, CONF_FOR_EACH, CONF_WHILE, CONF_UNTIL), @@ -1894,9 +1932,7 @@ _SCRIPT_CHOOSE_SCHEMA = vol.Schema( [ { vol.Optional(CONF_ALIAS): string, - vol.Required(CONF_CONDITIONS): vol.All( - ensure_list, [CONDITION_SCHEMA] - ), + vol.Required(CONF_CONDITIONS): CONDITIONS_SCHEMA, vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA, } ], @@ -1917,7 +1953,7 @@ _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA = vol.Schema( _SCRIPT_IF_SCHEMA = vol.Schema( { **SCRIPT_ACTION_BASE_SCHEMA, - vol.Required(CONF_IF): vol.All(ensure_list, [CONDITION_SCHEMA]), + vol.Required(CONF_IF): CONDITIONS_SCHEMA, vol.Required(CONF_THEN): SCRIPT_SCHEMA, vol.Optional(CONF_ELSE): SCRIPT_SCHEMA, } diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 375ec58c26f..101b9731caf 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -369,7 +369,7 @@ class EnumWithDeprecatedMembers(EnumType): """Enum with deprecated members.""" def __new__( - mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + mcs, cls: str, bases: tuple[type, ...], classdict: _EnumDict, diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 991a6cf5a57..161e1205d4f 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -397,11 +397,11 @@ class DeletedDeviceEntry: config_entries: set[str] = attr.ib() config_entries_subentries: dict[str, set[str | None]] = attr.ib() connections: set[tuple[str, str]] = attr.ib() - identifiers: set[tuple[str, str]] = attr.ib() + created_at: datetime = attr.ib() id: str = attr.ib() + identifiers: set[tuple[str, str]] = attr.ib() + modified_at: datetime = attr.ib() orphaned_timestamp: float | None = attr.ib() - created_at: datetime = attr.ib(factory=utcnow) - modified_at: datetime = attr.ib(factory=utcnow) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) def to_device_entry( @@ -440,8 +440,8 @@ class DeletedDeviceEntry: "created_at": self.created_at, "identifiers": list(self.identifiers), "id": self.id, - "orphaned_timestamp": self.orphaned_timestamp, "modified_at": self.modified_at, + "orphaned_timestamp": self.orphaned_timestamp, } ) ) @@ -575,14 +575,16 @@ class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( """Unindex an entry.""" old_entry = self.data[key] for connection in old_entry.connections: - del self._connections[connection] + if connection in self._connections: + del self._connections[connection] for identifier in old_entry.identifiers: - del self._identifiers[identifier] + if identifier in self._identifiers: + del self._identifiers[identifier] def get_entry( self, - identifiers: set[tuple[str, str]] | None, - connections: set[tuple[str, str]] | None, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, ) -> _EntryTypeT | None: """Get entry from identifiers or connections.""" if identifiers: @@ -709,22 +711,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Check if device is registered.""" return self.devices.get_entry(identifiers, connections) - def _async_get_deleted_device( - self, - identifiers: set[tuple[str, str]], - connections: set[tuple[str, str]], - ) -> DeletedDeviceEntry | None: - """Check if device is deleted.""" - return self.deleted_devices.get_entry(identifiers, connections) - - def _async_get_deleted_devices( - self, - identifiers: set[tuple[str, str]] | None = None, - connections: set[tuple[str, str]] | None = None, - ) -> Iterable[DeletedDeviceEntry]: - """List devices that are deleted.""" - return self.deleted_devices.get_entries(identifiers, connections) - def _substitute_name_placeholders( self, domain: str, @@ -839,10 +825,12 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): else: connections = _normalize_connections(connections) - device = self.async_get_device(identifiers=identifiers, connections=connections) + device = self.devices.get_entry( + identifiers=identifiers, connections=connections + ) if device is None: - deleted_device = self._async_get_deleted_device(identifiers, connections) + deleted_device = self.deleted_devices.get_entry(identifiers, connections) if deleted_device is None: device = DeviceEntry(is_new=True) else: @@ -869,7 +857,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): name = default_name if via_device is not None and via_device is not UNDEFINED: - if (via := self.async_get_device(identifiers={via_device})) is None: + if (via := self.devices.get_entry(identifiers={via_device})) is None: report_usage( "calls `device_registry.async_get_or_create` referencing a " f"non existing `via_device` {via_device}, " @@ -1172,7 +1160,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # NOTE: Once we solve the broader issue of duplicated devices, we might # want to revisit it. Instead of simply removing the duplicated deleted device, # we might want to merge the information from it into the non-deleted device. - for deleted_device in self._async_get_deleted_devices( + for deleted_device in self.deleted_devices.get_entries( added_identifiers, added_connections ): del self.deleted_devices[deleted_device.id] @@ -1214,7 +1202,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.async_get_device(connections={connection}) + existing_device := self.devices.get_entry(connections={connection}) ) and existing_device.id != device_id: raise DeviceConnectionCollisionError( normalized_connections, existing_device @@ -1238,7 +1226,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.async_get_device(identifiers={identifier}) + existing_device := self.devices.get_entry(identifiers={identifier}) ) and existing_device.id != device_id: raise DeviceIdentifierCollisionError(identifiers, existing_device) @@ -1256,6 +1244,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): created_at=device.created_at, identifiers=device.identifiers, id=device.id, + modified_at=utcnow(), orphaned_timestamp=None, ) for other_device in list(self.devices.values()): diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index bed5ce586c5..a3edf6bb64f 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -49,11 +49,7 @@ from homeassistant.core import ( get_release_channel, ) from homeassistant.core_config import DATA_CUSTOMIZE -from homeassistant.exceptions import ( - HomeAssistantError, - InvalidStateError, - NoEntitySpecifiedError, -) +from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError from homeassistant.loader import async_suggest_report_issue, bind_hass from homeassistant.util import ensure_unique_string, slugify from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed @@ -281,7 +277,7 @@ class CachedProperties(type): """ def __new__( - mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + mcs, name: str, bases: tuple[type, ...], namespace: dict[Any, Any], @@ -1127,9 +1123,6 @@ class Entity( # Polling returned after the entity has already been removed return - hass = self.hass - entity_id = self.entity_id - if (entry := self.registry_entry) and entry.disabled_by: if not self._disabled_reported: self._disabled_reported = True @@ -1138,7 +1131,7 @@ class Entity( "Entity %s is incorrectly being triggered for updates while it" " is disabled. This is a bug in the %s integration" ), - entity_id, + self.entity_id, self.platform.platform_name, ) return @@ -1180,7 +1173,7 @@ class Entity( "Entity %s (%s) is updating its capabilities too often," " please %s" ), - entity_id, + self.entity_id, type(self), report_issue, ) @@ -1197,7 +1190,7 @@ class Entity( report_issue = self._suggest_report_issue() _LOGGER.warning( "Updating state for %s (%s) took %.3f seconds. Please %s", - entity_id, + self.entity_id, type(self), time_now - state_calculate_start, report_issue, @@ -1208,12 +1201,12 @@ class Entity( # set and since try is near zero cost # on py3.11+ its faster to assume it is # set and catch the exception if it is not. - customize = hass.data[DATA_CUSTOMIZE] + custom = self.hass.data[DATA_CUSTOMIZE].get(self.entity_id) except KeyError: pass else: # Overwrite properties that have been set in the config file. - if custom := customize.get(entity_id): + if custom: attr |= custom if ( @@ -1223,23 +1216,16 @@ class Entity( self._context = None self._context_set = None - try: - hass.states.async_set_internal( - entity_id, - state, - attr, - self.force_update, - self._context, - self._state_info, - time_now, - ) - except InvalidStateError: - _LOGGER.exception( - "Failed to set state for %s, fall back to %s", entity_id, STATE_UNKNOWN - ) - hass.states.async_set( - entity_id, STATE_UNKNOWN, {}, self.force_update, self._context - ) + # Intentionally called with positional args for performance reasons + self.hass.states.async_set_internal( + self.entity_id, + state, + attr, + self.force_update, + self._context, + self._state_info, + time_now, + ) def schedule_update_ha_state(self, force_refresh: bool = False) -> None: """Schedule an update ha state change task. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 11a9786f86e..f543891d3f3 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -522,8 +522,14 @@ class EntityPlatform: self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: """Schedule adding entities for a single platform async.""" + entities: list[Entity] = ( + new_entities if type(new_entities) is list else list(new_entities) + ) + # handle empty list from component/platform + if not entities: + return task = self.hass.async_create_task_internal( - self.async_add_entities(new_entities, update_before_add=update_before_add), + self.async_add_entities(entities, update_before_add=update_before_add), f"EntityPlatform async_add_entities {self.domain}.{self.platform_name}", eager_start=True, ) @@ -541,10 +547,16 @@ class EntityPlatform: ) -> None: """Schedule adding entities for a single platform async and track the task.""" assert self.config_entry + entities: list[Entity] = ( + new_entities if type(new_entities) is list else list(new_entities) + ) + # handle empty list from component/platform + if not entities: + return task = self.config_entry.async_create_task( self.hass, self.async_add_entities( - new_entities, + entities, update_before_add=update_before_add, config_subentry_id=config_subentry_id, ), @@ -573,9 +585,9 @@ class EntityPlatform: async def _async_add_and_update_entities( self, - coros: list[Coroutine[Any, Any, None]], entities: list[Entity], timeout: float, + config_subentry_id: str | None, ) -> None: """Add entities for a single platform and update them. @@ -585,10 +597,21 @@ class EntityPlatform: event loop and will finish faster if we run them concurrently. """ results: list[BaseException | None] | None = None - tasks = [create_eager_task(coro, loop=self.hass.loop) for coro in coros] + entity_registry = ent_reg.async_get(self.hass) try: async with self.hass.timeout.async_timeout(timeout, self.domain): - results = await asyncio.gather(*tasks, return_exceptions=True) + results = await asyncio.gather( + *( + create_eager_task( + self._async_add_entity( + entity, True, entity_registry, config_subentry_id + ), + loop=self.hass.loop, + ) + for entity in entities + ), + return_exceptions=True, + ) except TimeoutError: self.logger.warning( "Timed out adding entities for domain %s with platform %s after %ds", @@ -615,9 +638,9 @@ class EntityPlatform: async def _async_add_entities( self, - coros: list[Coroutine[Any, Any, None]], entities: list[Entity], timeout: float, + config_subentry_id: str | None, ) -> None: """Add entities for a single platform without updating. @@ -626,13 +649,15 @@ class EntityPlatform: to the event loop so we can await the coros directly without scheduling them as tasks. """ + entity_registry = ent_reg.async_get(self.hass) try: async with self.hass.timeout.async_timeout(timeout, self.domain): - for idx, coro in enumerate(coros): + for entity in entities: try: - await coro + await self._async_add_entity( + entity, False, entity_registry, config_subentry_id + ) except Exception as ex: - entity = entities[idx] self.logger.exception( "Error adding entity %s for domain %s with platform %s", entity.entity_id, @@ -670,33 +695,16 @@ class EntityPlatform: f"entry {self.config_entry.entry_id if self.config_entry else None}" ) - # handle empty list from component/platform - if not new_entities: # type: ignore[truthy-iterable] - return - - hass = self.hass - entity_registry = ent_reg.async_get(hass) - coros: list[Coroutine[Any, Any, None]] = [] - entities: list[Entity] = [] - for entity in new_entities: - coros.append( - self._async_add_entity( - entity, update_before_add, entity_registry, config_subentry_id - ) - ) - entities.append(entity) - - # No entities for processing - if not coros: - return - - timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(coros), SLOW_ADD_MIN_TIMEOUT) + entities: list[Entity] = ( + new_entities if type(new_entities) is list else list(new_entities) + ) + timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(entities), SLOW_ADD_MIN_TIMEOUT) if update_before_add: - add_func = self._async_add_and_update_entities + await self._async_add_and_update_entities( + entities, timeout, config_subentry_id + ) else: - add_func = self._async_add_entities - - await add_func(coros, entities, timeout) + await self._async_add_entities(entities, timeout, config_subentry_id) if ( (self.config_entry and self.config_entry.pref_disable_polling) @@ -835,24 +843,31 @@ class EntityPlatform: else: device = None - # An entity may suggest the entity_id by setting entity_id itself - suggested_entity_id: str | None = entity.entity_id - if suggested_entity_id is not None: - suggested_object_id = split_entity_id(entity.entity_id)[1] - else: - if device and entity.has_entity_name: - device_name = device.name_by_user or device.name - if entity.use_device_name: - suggested_object_id = device_name - else: - suggested_object_id = ( - f"{device_name} {entity.suggested_object_id}" - ) - if not suggested_object_id: - suggested_object_id = entity.suggested_object_id + if not registered_entity_id: + # Do not bother working out a suggested_object_id + # if the entity is already registered as it will + # be ignored. + # + # An entity may suggest the entity_id by setting entity_id itself + suggested_entity_id: str | None = entity.entity_id + if suggested_entity_id is not None: + suggested_object_id = split_entity_id(entity.entity_id)[1] + else: + if device and entity.has_entity_name: + device_name = device.name_by_user or device.name + if entity.use_device_name: + suggested_object_id = device_name + else: + suggested_object_id = ( + f"{device_name} {entity.suggested_object_id}" + ) + if not suggested_object_id: + suggested_object_id = entity.suggested_object_id - if self.entity_namespace is not None: - suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" + if self.entity_namespace is not None: + suggested_object_id = ( + f"{self.entity_namespace} {suggested_object_id}" + ) disabled_by: RegistryEntryDisabler | None = None if not entity.entity_registry_enabled_default: @@ -875,7 +890,6 @@ class EntityPlatform: get_initial_options=entity.get_initial_entity_options, has_entity_name=entity.has_entity_name, hidden_by=hidden_by, - known_object_ids=self.entities, original_device_class=entity.device_class, original_icon=entity.icon, original_name=entity_name, @@ -919,7 +933,7 @@ class EntityPlatform: f"{self.entity_namespace} {suggested_object_id}" ) entity.entity_id = entity_registry.async_generate_entity_id( - self.domain, suggested_object_id, self.entities + self.domain, suggested_object_id ) # Make sure it is valid in case an entity set the value themselves diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 684d00fe344..abe0468ed17 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -11,7 +11,7 @@ timer. from __future__ import annotations from collections import defaultdict -from collections.abc import Callable, Container, Hashable, KeysView, Mapping +from collections.abc import Callable, Hashable, KeysView, Mapping from datetime import datetime, timedelta from enum import StrEnum import logging @@ -164,7 +164,7 @@ def _protect_entity_options( return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()}) -@attr.s(frozen=True, slots=True) +@attr.s(frozen=True, kw_only=True, slots=True) class RegistryEntry: """Entity Registry Entry.""" @@ -175,35 +175,32 @@ class RegistryEntry: aliases: set[str] = attr.ib(factory=set) area_id: str | None = attr.ib(default=None) categories: dict[str, str] = attr.ib(factory=dict) - capabilities: Mapping[str, Any] | None = attr.ib(default=None) - config_entry_id: str | None = attr.ib(default=None) - config_subentry_id: str | None = attr.ib(default=None) - created_at: datetime = attr.ib(factory=utcnow) + capabilities: Mapping[str, Any] | None = attr.ib() + config_entry_id: str | None = attr.ib() + config_subentry_id: str | None = attr.ib() + created_at: datetime = attr.ib() device_class: str | None = attr.ib(default=None) - device_id: str | None = attr.ib(default=None) + device_id: str | None = attr.ib() domain: str = attr.ib(init=False, repr=False) - disabled_by: RegistryEntryDisabler | None = attr.ib(default=None) - entity_category: EntityCategory | None = attr.ib(default=None) - hidden_by: RegistryEntryHider | None = attr.ib(default=None) + disabled_by: RegistryEntryDisabler | None = attr.ib() + entity_category: EntityCategory | None = attr.ib() + has_entity_name: bool = attr.ib() + hidden_by: RegistryEntryHider | None = attr.ib() icon: str | None = attr.ib(default=None) id: str = attr.ib( - default=None, - converter=attr.converters.default_if_none(factory=uuid_util.random_uuid_hex), # type: ignore[misc] + converter=attr.converters.default_if_none(factory=uuid_util.random_uuid_hex) # type: ignore[misc] ) - has_entity_name: bool = attr.ib(default=False) labels: set[str] = attr.ib(factory=set) modified_at: datetime = attr.ib(factory=utcnow) name: str | None = attr.ib(default=None) - options: ReadOnlyEntityOptionsType = attr.ib( - default=None, converter=_protect_entity_options - ) + options: ReadOnlyEntityOptionsType = attr.ib(converter=_protect_entity_options) # As set by integration - original_device_class: str | None = attr.ib(default=None) - original_icon: str | None = attr.ib(default=None) - original_name: str | None = attr.ib(default=None) - supported_features: int = attr.ib(default=0) - translation_key: str | None = attr.ib(default=None) - unit_of_measurement: str | None = attr.ib(default=None) + original_device_class: str | None = attr.ib() + original_icon: str | None = attr.ib() + original_name: str | None = attr.ib() + supported_features: int = attr.ib() + translation_key: str | None = attr.ib() + unit_of_measurement: str | None = attr.ib() _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @domain.default @@ -409,11 +406,12 @@ class DeletedRegistryEntry: platform: str = attr.ib() config_entry_id: str | None = attr.ib() config_subentry_id: str | None = attr.ib() + created_at: datetime = attr.ib() domain: str = attr.ib(init=False, repr=False) id: str = attr.ib() + modified_at: datetime = attr.ib() orphaned_timestamp: float | None = attr.ib() - created_at: datetime = attr.ib(factory=utcnow) - modified_at: datetime = attr.ib(factory=utcnow) + _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @domain.default @@ -790,26 +788,18 @@ class EntityRegistry(BaseRegistry): """Return known device ids.""" return list(self.entities.get_device_ids()) - def _entity_id_available( - self, entity_id: str, known_object_ids: Container[str] | None - ) -> bool: + def _entity_id_available(self, entity_id: str) -> bool: """Return True if the entity_id is available. An entity_id is available if: - It's not registered - - It's not known by the entity component adding the entity - - It's not in the state machine + - It's available (not in the state machine and not reserved) Note that an entity_id which belongs to a deleted entity is considered available. """ - if known_object_ids is None: - known_object_ids = {} - - return ( - entity_id not in self.entities - and entity_id not in known_object_ids - and self.hass.states.async_available(entity_id) + return entity_id not in self.entities and self.hass.states.async_available( + entity_id ) @callback @@ -817,7 +807,6 @@ class EntityRegistry(BaseRegistry): self, domain: str, suggested_object_id: str, - known_object_ids: Container[str] | None = None, ) -> str: """Generate an entity ID that does not conflict. @@ -829,11 +818,9 @@ class EntityRegistry(BaseRegistry): raise MaxLengthExceeded(domain, "domain", MAX_LENGTH_STATE_DOMAIN) test_string = preferred_string[:MAX_LENGTH_STATE_ENTITY_ID] - if known_object_ids is None: - known_object_ids = set() tries = 1 - while not self._entity_id_available(test_string, known_object_ids): + while not self._entity_id_available(test_string): tries += 1 len_suffix = len(str(tries)) + 1 test_string = ( @@ -850,7 +837,6 @@ class EntityRegistry(BaseRegistry): unique_id: str, *, # To influence entity ID generation - known_object_ids: Container[str] | None = None, suggested_object_id: str | None = None, # To disable or hide an entity if it gets created disabled_by: RegistryEntryDisabler | None = None, @@ -924,7 +910,6 @@ class EntityRegistry(BaseRegistry): entity_id = self.async_generate_entity_id( domain, suggested_object_id or f"{platform}_{unique_id}", - known_object_ids, ) if ( @@ -991,6 +976,7 @@ class EntityRegistry(BaseRegistry): created_at=entity.created_at, entity_id=entity_id, id=entity.id, + modified_at=utcnow(), orphaned_timestamp=orphaned_timestamp, platform=entity.platform, unique_id=entity.unique_id, @@ -1167,7 +1153,7 @@ class EntityRegistry(BaseRegistry): ) if new_entity_id is not UNDEFINED and new_entity_id != old.entity_id: - if not self._entity_id_available(new_entity_id, None): + if not self._entity_id_available(new_entity_id): raise ValueError("Entity with this ID is already registered") if not valid_entity_id(new_entity_id): diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b363bc21e86..baf1f144a3f 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -551,6 +551,12 @@ def async_track_entity_registry_updated_event( ) +@callback +def async_has_entity_registry_updated_listeners(hass: HomeAssistant) -> bool: + """Check if async_track_entity_registry_updated_event has been called yet.""" + return _KEYED_TRACK_ENTITY_REGISTRY_UPDATED.key in hass.data + + @callback def _async_device_registry_updated_filter( hass: HomeAssistant, diff --git a/homeassistant/helpers/floor_registry.py b/homeassistant/helpers/floor_registry.py index fcfca8e3212..186ad2b31f7 100644 --- a/homeassistant/helpers/floor_registry.py +++ b/homeassistant/helpers/floor_registry.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Iterable import dataclasses from dataclasses import dataclass @@ -16,8 +17,9 @@ from homeassistant.util.hass_dict import HassKey from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, NormalizedNameBaseRegistryItems, + normalize_name, ) -from .registry import BaseRegistry +from .registry import BaseRegistry, RegistryIndexType from .singleton import singleton from .storage import Store from .typing import UNDEFINED, UndefinedType @@ -92,10 +94,43 @@ class FloorRegistryStore(Store[FloorRegistryStoreData]): return old_data # type: ignore[return-value] +class FloorRegistryItems(NormalizedNameBaseRegistryItems[FloorEntry]): + """Class to hold floor registry items.""" + + def __init__(self) -> None: + """Initialize the floor registry items.""" + super().__init__() + self._aliases_index: RegistryIndexType = defaultdict(dict) + + def _index_entry(self, key: str, entry: FloorEntry) -> None: + """Index an entry.""" + super()._index_entry(key, entry) + for alias in entry.aliases: + normalized_alias = normalize_name(alias) + self._aliases_index[normalized_alias][key] = True + + def _unindex_entry( + self, key: str, replacement_entry: FloorEntry | None = None + ) -> None: + # always call base class before other indices + super()._unindex_entry(key, replacement_entry) + entry = self.data[key] + if aliases := entry.aliases: + for alias in aliases: + normalized_alias = normalize_name(alias) + self._unindex_entry_value(key, normalized_alias, self._aliases_index) + + def get_floors_for_alias(self, alias: str) -> list[FloorEntry]: + """Get floors for alias.""" + data = self.data + normalized_alias = normalize_name(alias) + return [data[key] for key in self._aliases_index.get(normalized_alias, ())] + + class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): """Class to hold a registry of floors.""" - floors: NormalizedNameBaseRegistryItems[FloorEntry] + floors: FloorRegistryItems _floor_data: dict[str, FloorEntry] def __init__(self, hass: HomeAssistant) -> None: @@ -123,6 +158,11 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): """Get floor by name.""" return self.floors.get_by_name(name) + @callback + def async_get_floors_by_alias(self, alias: str) -> list[FloorEntry]: + """Get floors by alias.""" + return self.floors.get_floors_for_alias(alias) + @callback def async_list_floors(self) -> Iterable[FloorEntry]: """Get all floors.""" @@ -226,7 +266,7 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): async def async_load(self) -> None: """Load the floor registry.""" data = await self._store.async_load() - floors = NormalizedNameBaseRegistryItems[FloorEntry]() + floors = FloorRegistryItems() if data is not None: for floor in data["floors"]: diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index f33f8407e47..ca7b097d90d 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -10,18 +10,20 @@ import functools import linecache import logging import sys +import threading from types import FrameType from typing import Any, cast from propcache.api import cached_property -from homeassistant.core import HomeAssistant, async_get_hass_or_none +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import ( Integration, async_get_issue_integration, async_suggest_report_issue, ) +from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -29,6 +31,21 @@ _LOGGER = logging.getLogger(__name__) _REPORTED_INTEGRATIONS: set[str] = set() +class _Hass: + """Container which makes a HomeAssistant instance available to frame helper.""" + + hass: HomeAssistant | None = None + + +_hass = _Hass() + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the frame helper.""" + _hass.hass = hass + + @dataclass(kw_only=True) class IntegrationFrame: """Integration frame container.""" @@ -133,44 +150,6 @@ class MissingIntegrationFrame(HomeAssistantError): """Raised when no integration is found in the frame.""" -def report( - what: str, - *, - exclude_integrations: set[str] | None = None, - error_if_core: bool = True, - error_if_integration: bool = False, - level: int = logging.WARNING, - log_custom_component_only: bool = False, -) -> None: - """Report incorrect usage. - - If error_if_core is True, raise instead of log if an integration is not found - when unwinding the stack frame. - If error_if_integration is True, raise instead of log if an integration is found - when unwinding the stack frame. - """ - core_behavior = ReportBehavior.ERROR if error_if_core else ReportBehavior.LOG - core_integration_behavior = ( - ReportBehavior.ERROR if error_if_integration else ReportBehavior.LOG - ) - custom_integration_behavior = core_integration_behavior - - if log_custom_component_only: - if core_behavior is ReportBehavior.LOG: - core_behavior = ReportBehavior.IGNORE - if core_integration_behavior is ReportBehavior.LOG: - core_integration_behavior = ReportBehavior.IGNORE - - report_usage( - what, - core_behavior=core_behavior, - core_integration_behavior=core_integration_behavior, - custom_integration_behavior=custom_integration_behavior, - exclude_integrations=exclude_integrations, - level=level, - ) - - class ReportBehavior(enum.Enum): """Enum for behavior on code usage.""" @@ -201,18 +180,49 @@ def report_usage( breaking version :param exclude_integrations: skip specified integration when reviewing the stack. If no integration is found, the core behavior will be applied - :param integration_domain: fallback for identifying the integration if the - frame is not found + :param integration_domain: domain of the integration causing the issue. If None, the + stack frame will be searched to identify the integration causing the issue. """ - try: - integration_frame = get_integration_frame( - exclude_integrations=exclude_integrations - ) - except MissingIntegrationFrame as err: - if integration := async_get_issue_integration( - hass := async_get_hass_or_none(), integration_domain - ): - _report_integration_domain( + if (hass := _hass.hass) is None: + raise RuntimeError("Frame helper not set up") + _report_usage_partial = functools.partial( + _report_usage, + hass, + what, + breaks_in_ha_version=breaks_in_ha_version, + core_behavior=core_behavior, + core_integration_behavior=core_integration_behavior, + custom_integration_behavior=custom_integration_behavior, + exclude_integrations=exclude_integrations, + integration_domain=integration_domain, + level=level, + ) + if hass.loop_thread_id != threading.get_ident(): + future = run_callback_threadsafe(hass.loop, _report_usage_partial) + future.result() + return + _report_usage_partial() + + +def _report_usage( + hass: HomeAssistant, + what: str, + *, + breaks_in_ha_version: str | None, + core_behavior: ReportBehavior, + core_integration_behavior: ReportBehavior, + custom_integration_behavior: ReportBehavior, + exclude_integrations: set[str] | None, + integration_domain: str | None, + level: int, +) -> None: + """Report incorrect code usage. + + Must be called from the event loop. + """ + if integration_domain: + if integration := async_get_issue_integration(hass, integration_domain): + _report_usage_integration_domain( hass, what, breaks_in_ha_version, @@ -222,16 +232,15 @@ def report_usage( level, ) return - msg = f"Detected code that {what}. Please report this issue" - if core_behavior is ReportBehavior.ERROR: - raise RuntimeError(msg) from err - if core_behavior is ReportBehavior.LOG: - if breaks_in_ha_version: - msg = ( - f"Detected code that {what}. This will stop working in Home " - f"Assistant {breaks_in_ha_version}, please report this issue" - ) - _LOGGER.warning(msg, stack_info=True) + _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, None) + return + + try: + integration_frame = get_integration_frame( + exclude_integrations=exclude_integrations + ) + except MissingIntegrationFrame as err: + _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, err) return integration_behavior = core_integration_behavior @@ -239,7 +248,8 @@ def report_usage( integration_behavior = custom_integration_behavior if integration_behavior is not ReportBehavior.IGNORE: - _report_integration_frame( + _report_usage_integration_frame( + hass, what, breaks_in_ha_version, integration_frame, @@ -248,7 +258,7 @@ def report_usage( ) -def _report_integration_domain( +def _report_usage_integration_domain( hass: HomeAssistant | None, what: str, breaks_in_ha_version: str | None, @@ -298,7 +308,8 @@ def _report_integration_domain( ) -def _report_integration_frame( +def _report_usage_integration_frame( + hass: HomeAssistant, what: str, breaks_in_ha_version: str | None, integration_frame: IntegrationFrame, @@ -316,7 +327,7 @@ def _report_integration_frame( _REPORTED_INTEGRATIONS.add(key) report_issue = async_suggest_report_issue( - async_get_hass_or_none(), + hass, integration_domain=integration_frame.integration, module=integration_frame.module, ) @@ -346,31 +357,55 @@ def _report_integration_frame( ) +def _report_usage_no_integration( + what: str, + core_behavior: ReportBehavior, + breaks_in_ha_version: str | None, + err: MissingIntegrationFrame | None, +) -> None: + """Report incorrect usage without an integration. + + This could happen because the offending call happened outside of an integration, + or because the integration could not be identified. + """ + msg = f"Detected code that {what}. Please report this issue" + if core_behavior is ReportBehavior.ERROR: + raise RuntimeError(msg) from err + if core_behavior is ReportBehavior.LOG: + if breaks_in_ha_version: + msg = ( + f"Detected code that {what}. This will stop working in Home " + f"Assistant {breaks_in_ha_version}, please report this issue" + ) + _LOGGER.warning(msg, stack_info=True) + + def warn_use[_CallableT: Callable](func: _CallableT, what: str) -> _CallableT: """Mock a function to warn when it was about to be used.""" if asyncio.iscoroutinefunction(func): @functools.wraps(func) async def report_use(*args: Any, **kwargs: Any) -> None: - report(what) + report_usage(what) else: @functools.wraps(func) def report_use(*args: Any, **kwargs: Any) -> None: - report(what) + report_usage(what) return cast(_CallableT, report_use) def report_non_thread_safe_operation(what: str) -> None: """Report a non-thread safe operation.""" - report( + report_usage( f"calls {what} from a thread other than the event loop, " "which may cause Home Assistant to crash or data to corrupt. " "For more information, see " "https://developers.home-assistant.io/docs/asyncio_thread_safety/" f"#{what.replace('.', '')}", - error_if_core=True, - error_if_integration=True, + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.ERROR, ) diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index ade2ce747d5..49b12e0aa60 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -7,6 +7,9 @@ import sys from types import TracebackType from typing import Any, Self +# httpx dynamically imports httpcore, so we need to import it +# to avoid it being imported later when the event loop is running +import httpcore # noqa: F401 import httpx from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index c93545ed414..75572194bb8 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -38,7 +38,7 @@ from .typing import VolSchemaType _LOGGER = logging.getLogger(__name__) type _SlotsType = dict[str, Any] type _IntentSlotsType = dict[ - str | tuple[str, str], VolSchemaType | Callable[[Any], Any] + str | tuple[str, str], IntentSlotInfo | VolSchemaType | Callable[[Any], Any] ] INTENT_TURN_OFF = "HassTurnOff" @@ -59,6 +59,7 @@ INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" INTENT_RESPOND = "HassRespond" INTENT_BROADCAST = "HassBroadcast" +INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) @@ -506,12 +507,22 @@ def _add_areas( candidate.area = areas.async_get_area(candidate.device.area_id) +def _default_area_candidate_filter( + candidate: MatchTargetsCandidate, possible_area_ids: Collection[str] +) -> bool: + """Keep candidates in the possible areas.""" + return (candidate.area is not None) and (candidate.area.id in possible_area_ids) + + @callback def async_match_targets( # noqa: C901 hass: HomeAssistant, constraints: MatchTargetsConstraints, preferences: MatchTargetsPreferences | None = None, states: list[State] | None = None, + area_candidate_filter: Callable[ + [MatchTargetsCandidate, Collection[str]], bool + ] = _default_area_candidate_filter, ) -> MatchTargetsResult: """Match entities based on constraints in order to handle an intent.""" preferences = preferences or MatchTargetsPreferences() @@ -622,9 +633,7 @@ def async_match_targets( # noqa: C901 } candidates = [ - c - for c in candidates - if (c.area is not None) and (c.area.id in possible_area_ids) + c for c in candidates if area_candidate_filter(c, possible_area_ids) ] if not candidates: return MatchTargetsResult( @@ -648,9 +657,7 @@ def async_match_targets( # noqa: C901 # May be constrained by floors above possible_area_ids.intersection_update(matching_area_ids) candidates = [ - c - for c in candidates - if (c.area is not None) and (c.area.id in possible_area_ids) + c for c in candidates if area_candidate_filter(c, possible_area_ids) ] if not candidates: return MatchTargetsResult( @@ -700,7 +707,7 @@ def async_match_targets( # noqa: C901 group_candidates = [ c for c in group_candidates - if (c.area is not None) and (c.area.id == preferences.area_id) + if area_candidate_filter(c, {preferences.area_id}) ] if len(group_candidates) < 2: # Disambiguated by area @@ -746,7 +753,7 @@ def async_match_targets( # noqa: C901 if preferences.area_id: # Filter by area filtered_candidates = [ - c for c in candidates if c.area and (c.area.id == preferences.area_id) + c for c in candidates if area_candidate_filter(c, {preferences.area_id}) ] if (len(filtered_candidates) > 1) and preferences.floor_id: @@ -867,6 +874,34 @@ def non_empty_string(value: Any) -> str: return value_str +@dataclass(kw_only=True) +class IntentSlotInfo: + """Details about how intent slots are processed and validated.""" + + service_data_name: str | None = None + """Optional name of the service data input to map to this slot.""" + + description: str | None = None + """Human readable description of the slot.""" + + value_schema: VolSchemaType | Callable[[Any], Any] = vol.Any + """Validator for the slot.""" + + +def _convert_slot_info( + key: str | tuple[str, str], + value: IntentSlotInfo | VolSchemaType | Callable[[Any], Any], +) -> tuple[str, IntentSlotInfo]: + """Create an IntentSlotInfo from the various supported input arguments.""" + if isinstance(value, IntentSlotInfo): + if not isinstance(key, str): + raise TypeError("Tuple key and IntentSlotDescription value not supported") + return key, value + if isinstance(key, tuple): + return key[0], IntentSlotInfo(service_data_name=key[1], value_schema=value) + return key, IntentSlotInfo(value_schema=value) + + class DynamicServiceIntentHandler(IntentHandler): """Service Intent handler registration (dynamic). @@ -900,23 +935,14 @@ class DynamicServiceIntentHandler(IntentHandler): self.platforms = platforms self.device_classes = device_classes - self.required_slots: _IntentSlotsType = {} - if required_slots: - for key, value_schema in required_slots.items(): - if isinstance(key, str): - # Slot name/service data key - key = (key, key) - - self.required_slots[key] = value_schema - - self.optional_slots: _IntentSlotsType = {} - if optional_slots: - for key, value_schema in optional_slots.items(): - if isinstance(key, str): - # Slot name/service data key - key = (key, key) - - self.optional_slots[key] = value_schema + self.required_slots: dict[str, IntentSlotInfo] = dict( + _convert_slot_info(key, value) + for key, value in (required_slots or {}).items() + ) + self.optional_slots: dict[str, IntentSlotInfo] = dict( + _convert_slot_info(key, value) + for key, value in (optional_slots or {}).items() + ) @cached_property def slot_schema(self) -> dict: @@ -957,16 +983,20 @@ class DynamicServiceIntentHandler(IntentHandler): if self.required_slots: slot_schema.update( { - vol.Required(key[0]): validator - for key, validator in self.required_slots.items() + vol.Required( + key, description=slot_info.description + ): slot_info.value_schema + for key, slot_info in self.required_slots.items() } ) if self.optional_slots: slot_schema.update( { - vol.Optional(key[0]): validator - for key, validator in self.optional_slots.items() + vol.Optional( + key, description=slot_info.description + ): slot_info.value_schema + for key, slot_info in self.optional_slots.items() } ) @@ -1149,18 +1179,15 @@ class DynamicServiceIntentHandler(IntentHandler): service_data: dict[str, Any] = {ATTR_ENTITY_ID: state.entity_id} if self.required_slots: - service_data.update( - { - key[1]: intent_obj.slots[key[0]]["value"] - for key in self.required_slots - } - ) + for key, slot_info in self.required_slots.items(): + service_data[slot_info.service_data_name or key] = intent_obj.slots[ + key + ]["value"] if self.optional_slots: - for key in self.optional_slots: - value = intent_obj.slots.get(key[0]) - if value: - service_data[key[1]] = value["value"] + for key, slot_info in self.optional_slots.items(): + if value := intent_obj.slots.get(key): + service_data[slot_info.service_data_name or key] = value["value"] await self._run_then_background( hass.async_create_task_internal( diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 2ef785e7f71..adf113e0f30 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -9,6 +9,7 @@ from datetime import timedelta from decimal import Decimal from enum import Enum from functools import cache, partial +from operator import attrgetter from typing import Any, cast import slugify as unicode_slug @@ -19,11 +20,11 @@ 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 DOMAIN as SCRIPT_DOMAIN +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN, TodoServices from homeassistant.components.weather import INTENT_GET_WEATHER from homeassistant.const import ( ATTR_DOMAIN, @@ -67,6 +68,24 @@ Answer questions about the world truthfully. Answer in plain text. Keep it simple and to the point. """ +NO_ENTITIES_PROMPT = ( + "Only if the user wants to control a device, tell them to expose entities " + "to their voice assistant in Home Assistant." +) + +DYNAMIC_CONTEXT_PROMPT = """You ARE equipped to answer questions about the current state of +the home using the `GetLiveContext` tool. This is a primary function. Do not state you lack the +functionality if the question requires live data. +If the user asks about device existence/type (e.g., "Do I have lights in the bedroom?"): Answer +from the static context below. +If the user asks about the CURRENT state, value, or mode (e.g., "Is the lock locked?", +"Is the fan on?", "What mode is the thermostat in?", "What is the temperature outside?"): + 1. Recognize this requires live data. + 2. You MUST call `GetLiveContext`. This tool will provide the needed real-time information (like temperature from the local weather, lock status, etc.). + 3. Use the tool's response** to answer the user accurately (e.g., "The temperature outside is [value from tool]."). +For general knowledge questions not about the home: Answer truthfully from internal knowledge. +""" + @callback def async_render_no_api_prompt(hass: HomeAssistant) -> str: @@ -105,15 +124,29 @@ def async_register_api(hass: HomeAssistant, api: API) -> Callable[[], None]: async def async_get_api( - hass: HomeAssistant, api_id: str, llm_context: LLMContext + hass: HomeAssistant, api_id: str | list[str], llm_context: LLMContext ) -> APIInstance: - """Get an API.""" + """Get an API. + + This returns a single APIInstance for one or more API ids, merging into + a single instance of necessary. + """ apis = _async_get_apis(hass) - if api_id not in apis: - raise HomeAssistantError(f"API {api_id} not found") + if isinstance(api_id, str): + api_id = [api_id] - return await apis[api_id].async_get_api_instance(llm_context) + for key in api_id: + if key not in apis: + raise HomeAssistantError(f"API {key} not found") + + api: API + if len(api_id) == 1: + api = apis[api_id[0]] + else: + api = MergedAPI([apis[key] for key in api_id]) + + return await api.async_get_api_instance(llm_context) @callback @@ -281,11 +314,107 @@ class IntentTool(Tool): return response +class NamespacedTool(Tool): + """A tool that wraps another tool, prepending a namespace. + + This is used to support tools from multiple API. This tool dispatches + the original tool with the original non-namespaced name. + """ + + def __init__(self, namespace: str, tool: Tool) -> None: + """Init the class.""" + self.namespace = namespace + self.name = f"{namespace}.{tool.name}" + self.description = tool.description + self.parameters = tool.parameters + self.tool = tool + + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Handle the intent.""" + return await self.tool.async_call( + hass, + ToolInput( + tool_name=self.tool.name, + tool_args=tool_input.tool_args, + id=tool_input.id, + ), + llm_context, + ) + + +class MergedAPI(API): + """An API that represents a merged view of multiple APIs.""" + + def __init__(self, llm_apis: list[API]) -> None: + """Init the class.""" + if not llm_apis: + raise ValueError("No APIs provided") + hass = llm_apis[0].hass + api_ids = [unicode_slug.slugify(api.id) for api in llm_apis] + if len(set(api_ids)) != len(api_ids): + raise ValueError("API IDs must be unique") + super().__init__( + hass=hass, + id="|".join(unicode_slug.slugify(api.id) for api in llm_apis), + name="Merged LLM API", + ) + self.llm_apis = llm_apis + + async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: + """Return the instance of the API.""" + # These usually don't do I/O and execute right away + llm_apis = [ + await llm_api.async_get_api_instance(llm_context) + for llm_api in self.llm_apis + ] + prompt_parts = [] + tools: list[Tool] = [] + for api_instance in llm_apis: + namespace = unicode_slug.slugify(api_instance.api.name) + prompt_parts.append( + f'Follow these instructions for tools from "{namespace}":\n' + ) + prompt_parts.append(api_instance.api_prompt) + prompt_parts.append("\n\n") + tools.extend( + [NamespacedTool(namespace, tool) for tool in api_instance.tools] + ) + + return APIInstance( + api=self, + api_prompt="".join(prompt_parts), + llm_context=llm_context, + tools=tools, + custom_serializer=self._custom_serializer(llm_apis), + ) + + def _custom_serializer( + self, llm_apis: list[APIInstance] + ) -> Callable[[Any], Any] | None: + serializers = [ + api_instance.custom_serializer + for api_instance in llm_apis + if api_instance.custom_serializer is not None + ] + if not serializers: + return None + + def merged(x: Any) -> Any: + for serializer in serializers: + if (result := serializer(x)) is not None: + return result + return x + + return merged + + class AssistAPI(API): """API exposing Assist API to LLMs.""" IGNORE_INTENTS = { - INTENT_GET_TEMPERATURE, + intent.INTENT_GET_TEMPERATURE, INTENT_GET_WEATHER, INTENT_OPEN_COVER, # deprecated INTENT_CLOSE_COVER, # deprecated @@ -312,7 +441,7 @@ class AssistAPI(API): """Return the instance of the API.""" if llm_context.assistant: exposed_entities: dict | None = _get_exposed_entities( - self.hass, llm_context.assistant + self.hass, llm_context.assistant, include_state=False ) else: exposed_entities = None @@ -330,10 +459,7 @@ class AssistAPI(API): self, llm_context: LLMContext, exposed_entities: dict | None ) -> str: if not exposed_entities or not exposed_entities["entities"]: - return ( - "Only if the user wants to control a device, tell them to expose entities " - "to their voice assistant in Home Assistant." - ) + return NO_ENTITIES_PROMPT return "\n".join( [ *self._async_get_preable(llm_context), @@ -383,6 +509,8 @@ class AssistAPI(API): ): prompt.append("This device is not able to start timers.") + prompt.append(DYNAMIC_CONTEXT_PROMPT) + return prompt @callback @@ -394,7 +522,7 @@ class AssistAPI(API): if exposed_entities and exposed_entities["entities"]: prompt.append( - "An overview of the areas and the devices in this smart home:" + "Static Context: An overview of the areas and the devices in this smart home:" ) prompt.append(yaml_util.dump(list(exposed_entities["entities"].values()))) @@ -450,16 +578,29 @@ class AssistAPI(API): names.extend(info["names"].split(", ")) tools.append(CalendarGetEventsTool(names)) + if exposed_domains is not None and TODO_DOMAIN in exposed_domains: + names = [] + for info in exposed_entities["entities"].values(): + if info["domain"] != TODO_DOMAIN: + continue + names.extend(info["names"].split(", ")) + tools.append(TodoGetItemsTool(names)) + tools.extend( ScriptTool(self.hass, script_entity_id) for script_entity_id in exposed_entities[SCRIPT_DOMAIN] ) + if exposed_domains: + tools.append(GetLiveContextTool()) + return tools def _get_exposed_entities( - hass: HomeAssistant, assistant: str + hass: HomeAssistant, + assistant: str, + include_state: bool = True, ) -> dict[str, dict[str, dict[str, Any]]]: """Get exposed entities. @@ -490,7 +631,7 @@ def _get_exposed_entities( CALENDAR_DOMAIN: {}, } - for state in hass.states.async_all(): + for state in sorted(hass.states.async_all(), key=attrgetter("name")): if not async_should_expose(hass, assistant, state.entity_id): continue @@ -520,22 +661,28 @@ def _get_exposed_entities( info: dict[str, Any] = { "names": ", ".join(names), "domain": state.domain, - "state": state.state, } + if include_state: + info["state"] = state.state + if description: info["description"] = description if area_names: info["areas"] = ", ".join(area_names) - if attributes := { - attr_name: str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value - for attr_name, attr_value in state.attributes.items() - if attr_name in interesting_attributes - }: + if include_state and ( + attributes := { + attr_name: ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) + for attr_name, attr_value in state.attributes.items() + if attr_name in interesting_attributes + } + ): info["attributes"] = attributes if state.domain in data: @@ -884,3 +1031,103 @@ class CalendarGetEventsTool(Tool): ] return {"success": True, "result": events} + + +class TodoGetItemsTool(Tool): + """LLM Tool allowing querying a to-do list.""" + + name = "todo_get_items" + description = ( + "Query a to-do list to find out what items are on it. " + "Use this to answer questions like 'What's on my task list?' or 'Read my grocery list'. " + "Filters items by status (needs_action, completed, all)." + ) + + def __init__(self, todo_lists: list[str]) -> None: + """Init the get items tool.""" + self.parameters = vol.Schema( + { + vol.Required("todo_list"): vol.In(todo_lists), + vol.Optional( + "status", + description="Filter returned items by status, by default returns incomplete items", + default="needs_action", + ): vol.In(["needs_action", "completed", "all"]), + } + ) + + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a to-do list.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["todo_list"], + domains=[TODO_DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "To-do list not found"} + entity_id = result.states[0].entity_id + service_data: dict[str, Any] = {"entity_id": entity_id} + if status := data.get("status"): + if status == "all": + service_data["status"] = ["needs_action", "completed"] + else: + service_data["status"] = [status] + service_result = await hass.services.async_call( + TODO_DOMAIN, + TodoServices.GET_ITEMS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + if not service_result: + return {"success": False, "error": "To-do list not found"} + items = cast(dict, service_result)[entity_id]["items"] + return {"success": True, "result": items} + + +class GetLiveContextTool(Tool): + """Tool for getting the current state of exposed entities. + + This returns state for all entities that have been exposed to + the assistant. This is different than the GetState intent, which + returns state for entities based on intent parameters. + """ + + name = "GetLiveContext" + description = ( + "Provides real-time information about the CURRENT state, value, or mode of devices, sensors, entities, or areas. " + "Use this tool for: " + "1. Answering questions about current conditions (e.g., 'Is the light on?'). " + "2. As the first step in conditional actions (e.g., 'If the weather is rainy, turn off sprinklers' requires checking the weather first)." + ) + + async def async_call( + self, + hass: HomeAssistant, + tool_input: ToolInput, + llm_context: LLMContext, + ) -> JsonObjectType: + """Get the current state of exposed entities.""" + if llm_context.assistant is None: + # Note this doesn't happen in practice since this tool won't be + # exposed if no assistant is configured. + return {"success": False, "error": "No assistant configured"} + + exposed_entities = _get_exposed_entities(hass, llm_context.assistant) + if not exposed_entities["entities"]: + return {"success": False, "error": NO_ENTITIES_PROMPT} + prompt = [ + "Live Context: An overview of the areas and the devices in this smart home:", + yaml_util.dump(list(exposed_entities["entities"].values())), + ] + return { + "success": True, + "result": "\n".join(prompt), + } diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index e39cc2de547..67c4448724e 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -10,12 +10,12 @@ from aiohttp import hdrs from hass_nabucasa import remote import yarl -from homeassistant.components import http from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.util.network import is_ip_address, is_loopback, normalize_url +from . import http from .hassio import is_hassio TYPE_URL_INTERNAL = "internal_url" diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index bf7a4a0971c..2b4da38b15e 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -629,6 +629,10 @@ class _ScriptRun: self, script: Script, *, parallel: bool = False ) -> None: """Execute a script.""" + if not script.enabled: + self._log("Skipping disabled script: %s", script.name) + trace_set_result(enabled=False) + return result = await self._async_run_long_action( self._hass.async_create_task_internal( script.async_run( @@ -966,12 +970,11 @@ class _ScriptRun: ## Variable actions ## async def _async_step_variables(self) -> None: - """Define a local variable.""" - self._step_log("defining local variables") - for key, value in ( - self._action[CONF_VARIABLES].async_simple_render(self._variables).items() - ): - self._variables.define_local(key, value) + """Assign values to variables.""" + self._step_log("assigning variables") + self._variables.update( + self._action[CONF_VARIABLES].async_simple_render(self._variables) + ) ## External actions ## @@ -1312,7 +1315,7 @@ class _QueuedScriptRun(_ScriptRun): lock_acquired = False - async def async_run(self) -> None: + async def async_run(self) -> ScriptRunResult | None: """Run script.""" # Wait for previous run, if any, to finish by attempting to acquire the script's # shared lock. At the same time monitor if we've been told to stop. @@ -1326,7 +1329,7 @@ class _QueuedScriptRun(_ScriptRun): self.lock_acquired = True # We've acquired the lock so we can go ahead and start the run. - await super().async_run() + return await super().async_run() def _finish(self) -> None: if self.lock_acquired: @@ -1443,8 +1446,12 @@ class Script: script_mode: str = DEFAULT_SCRIPT_MODE, top_level: bool = True, variables: ScriptVariables | None = None, + enabled: bool = True, ) -> None: - """Initialize the script.""" + """Initialize the script. + + enabled attribute is only used for non-top-level scripts. + """ if not (all_scripts := hass.data.get(DATA_SCRIPTS)): all_scripts = hass.data[DATA_SCRIPTS] = [] hass.bus.async_listen_once( @@ -1463,6 +1470,7 @@ class Script: self.name = name self.unique_id = f"{domain}.{name}-{id(self)}" self.domain = domain + self.enabled = enabled self.running_description = running_description or f"{domain} script" self._change_listener = change_listener self._change_listener_job = ( @@ -2003,6 +2011,7 @@ class Script: max_runs=self.max_runs, logger=self._logger, top_level=False, + enabled=parallel_script.get(CONF_ENABLED, True), ) parallel_script.change_listener = partial( self._chain_change_listener, parallel_script diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 025b8de8896..f2c76d1d019 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -164,6 +164,8 @@ DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( vol.Optional("manufacturer"): str, # Model of device vol.Optional("model"): str, + # Model ID of device + vol.Optional("model_id"): str, # Device has to contain entities matching this selector vol.Optional("entity"): vol.All( cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] @@ -178,6 +180,7 @@ class DeviceFilterSelectorConfig(TypedDict, total=False): integration: str manufacturer: str model: str + model_id: str class ActionSelectorConfig(TypedDict): @@ -1133,7 +1136,7 @@ class SelectOptionDict(TypedDict): class SelectSelectorMode(StrEnum): - """Possible modes for a number selector.""" + """Possible modes for a select selector.""" LIST = "list" DROPDOWN = "dropdown" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 4873d935537..f157e82bc53 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine, Iterable +from collections.abc import Callable, Coroutine, Iterable import dataclasses from enum import Enum from functools import cache, partial @@ -1094,9 +1094,15 @@ async def _handle_entity_call( async def _async_admin_handler( hass: HomeAssistant, - service_job: HassJob[[ServiceCall], Awaitable[None] | None], + service_job: HassJob[ + [ServiceCall], + Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] + | ServiceResponse + | EntityServiceResponse + | None, + ], call: ServiceCall, -) -> None: +) -> ServiceResponse | EntityServiceResponse | None: """Run an admin service.""" if call.context.user_id: user = await hass.auth.async_get_user(call.context.user_id) @@ -1105,9 +1111,10 @@ async def _async_admin_handler( if not user.is_admin: raise Unauthorized(context=call.context) - result = hass.async_run_hass_job(service_job, call) - if result is not None: - await result + task = hass.async_run_hass_job(service_job, call) + if task is not None: + return await task + return None @bind_hass @@ -1116,8 +1123,15 @@ def async_register_admin_service( hass: HomeAssistant, domain: str, service: str, - service_func: Callable[[ServiceCall], Awaitable[None] | None], + service_func: Callable[ + [ServiceCall], + Coroutine[Any, Any, ServiceResponse | EntityServiceResponse] + | ServiceResponse + | EntityServiceResponse + | None, + ], schema: VolSchemaType = vol.Schema({}, extra=vol.PREVENT_EXTRA), + supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Register a service that requires admin access.""" hass.services.async_register( @@ -1129,6 +1143,7 @@ def async_register_admin_service( HassJob(service_func, f"admin service {domain}.{service}"), ), schema, + supports_response, ) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 7866250d658..cb6d8fe81b8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -6,12 +6,13 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Generator, Iterable +from collections.abc import Callable, Generator, Iterable, MutableSequence from contextlib import AbstractContextManager from contextvars import ContextVar from copy import deepcopy from datetime import date, datetime, time, timedelta from functools import cache, lru_cache, partial, wraps +import hashlib import json import logging import math @@ -1071,7 +1072,7 @@ class TemplateStateBase(State): raise KeyError @under_cached_property - def entity_id(self) -> str: # type: ignore[override] + def entity_id(self) -> str: """Wrap State.entity_id. Intentionally does not collect state @@ -1127,7 +1128,7 @@ class TemplateStateBase(State): return self._state.object_id @property - def name(self) -> str: # type: ignore[override] + def name(self) -> str: """Wrap State.name.""" self._collect_state() return self._state.name @@ -1412,6 +1413,28 @@ def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None: ) +def device_name(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the device name from an device id, or entity id.""" + device_reg = device_registry.async_get(hass) + if device := device_reg.async_get(lookup_value): + return device.name_by_user or device.name + + ent_reg = entity_registry.async_get(hass) + # Import here, not at top-level to avoid circular import + from . import config_validation as cv # pylint: disable=import-outside-toplevel + + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + if entity.device_id and (device := device_reg.async_get(entity.device_id)): + return device.name_by_user or device.name + + return None + + def device_attr(hass: HomeAssistant, device_or_entity_id: str, attr_name: str) -> Any: """Get the device specific attribute.""" device_reg = device_registry.async_get(hass) @@ -1477,10 +1500,14 @@ def floors(hass: HomeAssistant) -> Iterable[str | None]: def floor_id(hass: HomeAssistant, lookup_value: Any) -> str | None: - """Get the floor ID from a floor name.""" + """Get the floor ID from a floor or area name, alias, device id, or entity id.""" floor_registry = fr.async_get(hass) - if floor := floor_registry.async_get_floor_by_name(str(lookup_value)): + lookup_str = str(lookup_value) + if floor := floor_registry.async_get_floor_by_name(lookup_str): return floor.floor_id + floors_list = floor_registry.async_get_floors_by_alias(lookup_str) + if floors_list: + return floors_list[0].floor_id if aid := area_id(hass, lookup_value): area_reg = area_registry.async_get(hass) @@ -1525,16 +1552,29 @@ def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: return [entry.id for entry in entries if entry.id] +def floor_entities(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: + """Return entity_ids for a given floor ID or name.""" + return [ + entity_id + for area_id in floor_areas(hass, floor_id_or_name) + for entity_id in area_entities(hass, area_id) + ] + + def areas(hass: HomeAssistant) -> Iterable[str | None]: """Return all areas.""" return list(area_registry.async_get(hass).areas) def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: - """Get the area ID from an area name, device id, or entity id.""" + """Get the area ID from an area name, alias, device id, or entity id.""" area_reg = area_registry.async_get(hass) - if area := area_reg.async_get_area_by_name(str(lookup_value)): + lookup_str = str(lookup_value) + if area := area_reg.async_get_area_by_name(lookup_str): return area.id + areas_list = area_reg.async_get_areas_by_alias(lookup_str) + if areas_list: + return areas_list[0].id ent_reg = entity_registry.async_get(hass) dev_reg = device_registry.async_get(hass) @@ -2727,6 +2767,144 @@ def iif( return if_false +def shuffle(*args: Any, seed: Any = None) -> MutableSequence[Any]: + """Shuffle a list, either with a seed or without.""" + if not args: + raise TypeError("shuffle expected at least 1 argument, got 0") + + # If first argument is iterable and more than 1 argument provided + # but not a named seed, then use 2nd argument as seed. + if isinstance(args[0], Iterable): + items = list(args[0]) + if len(args) > 1 and seed is None: + seed = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + items = list(args) + + if seed: + r = random.Random(seed) + r.shuffle(items) + else: + random.shuffle(items) + return items + + +def typeof(value: Any) -> Any: + """Return the type of value passed to debug types.""" + return value.__class__.__name__ + + +def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]: + """Flattens list of lists.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"flatten expected a list, got {type(value).__name__}") + + flattened: list[Any] = [] + for item in value: + if isinstance(item, Iterable) and not isinstance(item, str): + if levels is None: + flattened.extend(flatten(item)) + elif levels >= 1: + flattened.extend(flatten(item, levels=(levels - 1))) + else: + flattened.append(item) + else: + flattened.append(item) + return flattened + + +def intersect(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return the common elements between two lists.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"intersect expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"intersect expected a list, got {type(other).__name__}") + + return list(set(value) & set(other)) + + +def difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return elements in first list that are not in second list.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"difference expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"difference expected a list, got {type(other).__name__}") + + return list(set(value) - set(other)) + + +def union(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return all unique elements from both lists combined.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"union expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"union expected a list, got {type(other).__name__}") + + return list(set(value) | set(other)) + + +def symmetric_difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return elements that are in either list but not in both.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError( + f"symmetric_difference expected a list, got {type(value).__name__}" + ) + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError( + f"symmetric_difference expected a list, got {type(other).__name__}" + ) + + return list(set(value) ^ set(other)) + + +def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: + """Combine multiple dictionaries into one.""" + if not args: + raise TypeError("combine expected at least 1 argument, got 0") + + result: dict[Any, Any] = {} + for arg in args: + if not isinstance(arg, dict): + raise TypeError(f"combine expected a dict, got {type(arg).__name__}") + + if recursive: + for key, value in arg.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = combine(result[key], value, recursive=True) + else: + result[key] = value + else: + result |= arg + + return result + + +def md5(value: str) -> str: + """Generate md5 hash from a string.""" + return hashlib.md5(value.encode()).hexdigest() + + +def sha1(value: str) -> str: + """Generate sha1 hash from a string.""" + return hashlib.sha1(value.encode()).hexdigest() + + +def sha256(value: str) -> str: + """Generate sha256 hash from a string.""" + return hashlib.sha256(value.encode()).hexdigest() + + +def sha512(value: str) -> str: + """Generate sha512 hash from a string.""" + return hashlib.sha512(value.encode()).hexdigest() + + class TemplateContextManager(AbstractContextManager): """Context manager to store template being parsed or rendered in a ContextVar.""" @@ -2879,100 +3057,127 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): str | jinja2.nodes.Template, CodeType | None ] = weakref.WeakValueDictionary() self.add_extension("jinja2.ext.loopcontrols") - self.filters["round"] = forgiving_round - self.filters["multiply"] = multiply - self.filters["add"] = add - self.filters["log"] = logarithm - self.filters["sin"] = sine - self.filters["cos"] = cosine - self.filters["tan"] = tangent - self.filters["asin"] = arc_sine - self.filters["acos"] = arc_cosine - self.filters["atan"] = arc_tangent - self.filters["atan2"] = arc_tangent2 - self.filters["sqrt"] = square_root - self.filters["as_datetime"] = as_datetime - self.filters["as_timedelta"] = as_timedelta - self.filters["as_timestamp"] = forgiving_as_timestamp - self.filters["as_local"] = dt_util.as_local - self.filters["timestamp_custom"] = timestamp_custom - self.filters["timestamp_local"] = timestamp_local - self.filters["timestamp_utc"] = timestamp_utc - self.filters["to_json"] = to_json - self.filters["from_json"] = from_json - self.filters["is_defined"] = fail_when_undefined - self.filters["average"] = average - self.filters["median"] = median - self.filters["statistical_mode"] = statistical_mode - self.filters["random"] = random_every_time - self.filters["base64_encode"] = base64_encode - self.filters["base64_decode"] = base64_decode - self.filters["ordinal"] = ordinal - self.filters["regex_match"] = regex_match - self.filters["regex_replace"] = regex_replace - self.filters["regex_search"] = regex_search - self.filters["regex_findall"] = regex_findall - self.filters["regex_findall_index"] = regex_findall_index - self.filters["bitwise_and"] = bitwise_and - self.filters["bitwise_or"] = bitwise_or - self.filters["bitwise_xor"] = bitwise_xor - self.filters["pack"] = struct_pack - self.filters["unpack"] = struct_unpack - self.filters["ord"] = ord - self.filters["is_number"] = is_number - self.filters["float"] = forgiving_float_filter - self.filters["int"] = forgiving_int_filter - self.filters["slugify"] = slugify - self.filters["iif"] = iif - self.filters["bool"] = forgiving_boolean - self.filters["version"] = version - self.filters["contains"] = contains - self.globals["log"] = logarithm - self.globals["sin"] = sine - self.globals["cos"] = cosine - self.globals["tan"] = tangent - self.globals["sqrt"] = square_root - self.globals["pi"] = math.pi - self.globals["tau"] = math.pi * 2 - self.globals["e"] = math.e - self.globals["asin"] = arc_sine + self.globals["acos"] = arc_cosine - self.globals["atan"] = arc_tangent - self.globals["atan2"] = arc_tangent2 - self.globals["float"] = forgiving_float self.globals["as_datetime"] = as_datetime self.globals["as_local"] = dt_util.as_local self.globals["as_timedelta"] = as_timedelta self.globals["as_timestamp"] = forgiving_as_timestamp - self.globals["timedelta"] = timedelta - self.globals["merge_response"] = merge_response - self.globals["strptime"] = strptime - self.globals["urlencode"] = urlencode + self.globals["asin"] = arc_sine + self.globals["atan"] = arc_tangent + self.globals["atan2"] = arc_tangent2 self.globals["average"] = average - self.globals["median"] = median - self.globals["statistical_mode"] = statistical_mode - self.globals["max"] = min_max_from_filter(self.filters["max"], "max") - self.globals["min"] = min_max_from_filter(self.filters["min"], "min") - self.globals["is_number"] = is_number - self.globals["set"] = _to_set - self.globals["tuple"] = _to_tuple - self.globals["int"] = forgiving_int - self.globals["pack"] = struct_pack - self.globals["unpack"] = struct_unpack - self.globals["slugify"] = slugify - self.globals["iif"] = iif self.globals["bool"] = forgiving_boolean + self.globals["combine"] = combine + self.globals["cos"] = cosine + self.globals["difference"] = difference + self.globals["e"] = math.e + self.globals["flatten"] = flatten + self.globals["float"] = forgiving_float + self.globals["iif"] = iif + self.globals["int"] = forgiving_int + self.globals["intersect"] = intersect + self.globals["is_number"] = is_number + self.globals["log"] = logarithm + self.globals["max"] = min_max_from_filter(self.filters["max"], "max") + self.globals["md5"] = md5 + self.globals["median"] = median + self.globals["merge_response"] = merge_response + self.globals["min"] = min_max_from_filter(self.filters["min"], "min") + self.globals["pack"] = struct_pack + self.globals["pi"] = math.pi + self.globals["set"] = _to_set + self.globals["sha1"] = sha1 + self.globals["sha256"] = sha256 + self.globals["sha512"] = sha512 + self.globals["shuffle"] = shuffle + self.globals["sin"] = sine + self.globals["slugify"] = slugify + self.globals["sqrt"] = square_root + self.globals["statistical_mode"] = statistical_mode + self.globals["strptime"] = strptime + self.globals["symmetric_difference"] = symmetric_difference + self.globals["tan"] = tangent + self.globals["tau"] = math.pi * 2 + self.globals["timedelta"] = timedelta + self.globals["tuple"] = _to_tuple + self.globals["typeof"] = typeof + self.globals["union"] = union + self.globals["unpack"] = struct_unpack + self.globals["urlencode"] = urlencode self.globals["version"] = version self.globals["zip"] = zip + + self.filters["acos"] = arc_cosine + self.filters["add"] = add + self.filters["as_datetime"] = as_datetime + self.filters["as_local"] = dt_util.as_local + self.filters["as_timedelta"] = as_timedelta + self.filters["as_timestamp"] = forgiving_as_timestamp + self.filters["asin"] = arc_sine + self.filters["atan"] = arc_tangent + self.filters["atan2"] = arc_tangent2 + self.filters["average"] = average + self.filters["base64_decode"] = base64_decode + self.filters["base64_encode"] = base64_encode + self.filters["bitwise_and"] = bitwise_and + self.filters["bitwise_or"] = bitwise_or + self.filters["bitwise_xor"] = bitwise_xor + self.filters["bool"] = forgiving_boolean + self.filters["combine"] = combine + self.filters["contains"] = contains + self.filters["cos"] = cosine + self.filters["difference"] = difference + self.filters["flatten"] = flatten + self.filters["float"] = forgiving_float_filter + self.filters["from_json"] = from_json + self.filters["iif"] = iif + self.filters["int"] = forgiving_int_filter + self.filters["intersect"] = intersect + self.filters["is_defined"] = fail_when_undefined + self.filters["is_number"] = is_number + self.filters["log"] = logarithm + self.filters["md5"] = md5 + self.filters["median"] = median + self.filters["multiply"] = multiply + self.filters["ord"] = ord + self.filters["ordinal"] = ordinal + self.filters["pack"] = struct_pack + self.filters["random"] = random_every_time + self.filters["regex_findall_index"] = regex_findall_index + self.filters["regex_findall"] = regex_findall + self.filters["regex_match"] = regex_match + self.filters["regex_replace"] = regex_replace + self.filters["regex_search"] = regex_search + self.filters["round"] = forgiving_round + self.filters["sha1"] = sha1 + self.filters["sha256"] = sha256 + self.filters["sha512"] = sha512 + self.filters["shuffle"] = shuffle + self.filters["sin"] = sine + self.filters["slugify"] = slugify + self.filters["sqrt"] = square_root + self.filters["statistical_mode"] = statistical_mode + self.filters["symmetric_difference"] = symmetric_difference + self.filters["tan"] = tangent + self.filters["timestamp_custom"] = timestamp_custom + self.filters["timestamp_local"] = timestamp_local + self.filters["timestamp_utc"] = timestamp_utc + self.filters["to_json"] = to_json + self.filters["typeof"] = typeof + self.filters["union"] = union + self.filters["unpack"] = struct_unpack + self.filters["version"] = version + + self.tests["contains"] = contains + self.tests["datetime"] = _is_datetime self.tests["is_number"] = is_number self.tests["list"] = _is_list - self.tests["set"] = _is_set - self.tests["tuple"] = _is_tuple - self.tests["datetime"] = _is_datetime - self.tests["string_like"] = _is_string_like self.tests["match"] = regex_match self.tests["search"] = regex_search - self.tests["contains"] = contains + self.tests["set"] = _is_set + self.tests["string_like"] = _is_string_like + self.tests["tuple"] = _is_tuple if hass is None: return @@ -2999,28 +3204,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return jinja_context(wrapper) - self.globals["device_entities"] = hassfunction(device_entities) - self.filters["device_entities"] = self.globals["device_entities"] - - self.globals["device_attr"] = hassfunction(device_attr) - self.filters["device_attr"] = self.globals["device_attr"] - - self.globals["config_entry_attr"] = hassfunction(config_entry_attr) - self.filters["config_entry_attr"] = self.globals["config_entry_attr"] - - self.globals["is_device_attr"] = hassfunction(is_device_attr) - self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context) - - self.globals["config_entry_id"] = hassfunction(config_entry_id) - self.filters["config_entry_id"] = self.globals["config_entry_id"] - - self.globals["device_id"] = hassfunction(device_id) - self.filters["device_id"] = self.globals["device_id"] - - self.globals["issues"] = hassfunction(issues) - - self.globals["issue"] = hassfunction(issue) - self.filters["issue"] = self.globals["issue"] + # Area extensions self.globals["areas"] = hassfunction(areas) @@ -3036,6 +3220,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = self.globals["area_devices"] + # Floor extensions + self.globals["floors"] = hassfunction(floors) self.filters["floors"] = self.globals["floors"] @@ -3048,9 +3234,41 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["floor_areas"] = hassfunction(floor_areas) self.filters["floor_areas"] = self.globals["floor_areas"] + self.globals["floor_entities"] = hassfunction(floor_entities) + self.filters["floor_entities"] = self.globals["floor_entities"] + + # Integration extensions + self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = self.globals["integration_entities"] + # Config entry extensions + + self.globals["config_entry_attr"] = hassfunction(config_entry_attr) + self.filters["config_entry_attr"] = self.globals["config_entry_attr"] + + self.globals["config_entry_id"] = hassfunction(config_entry_id) + self.filters["config_entry_id"] = self.globals["config_entry_id"] + + # Device extensions + + self.globals["device_name"] = hassfunction(device_name) + self.filters["device_name"] = self.globals["device_name"] + + self.globals["device_attr"] = hassfunction(device_attr) + self.filters["device_attr"] = self.globals["device_attr"] + + self.globals["device_entities"] = hassfunction(device_entities) + self.filters["device_entities"] = self.globals["device_entities"] + + self.globals["is_device_attr"] = hassfunction(is_device_attr) + self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context) + + self.globals["device_id"] = hassfunction(device_id) + self.filters["device_id"] = self.globals["device_id"] + + # Label extensions + self.globals["labels"] = hassfunction(labels) self.filters["labels"] = self.globals["labels"] @@ -3069,6 +3287,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["label_entities"] = hassfunction(label_entities) self.filters["label_entities"] = self.globals["label_entities"] + # Issue extensions + + self.globals["issues"] = hassfunction(issues) + self.globals["issue"] = hassfunction(issue) + self.filters["issue"] = self.globals["issue"] + if limited: # Only device_entities is available to limited templates, mark other # functions and filters as unsupported. @@ -3081,38 +3305,38 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return warn_unsupported hass_globals = [ - "closest", - "distance", - "expand", - "is_hidden_entity", - "is_state", - "is_state_attr", - "state_attr", - "states", - "state_translated", - "has_value", - "utcnow", - "now", - "device_attr", - "is_device_attr", - "device_id", "area_id", "area_name", + "closest", + "device_attr", + "device_id", + "distance", + "expand", "floor_id", "floor_name", + "has_value", + "is_device_attr", + "is_hidden_entity", + "is_state_attr", + "is_state", + "label_id", + "label_name", + "now", "relative_time", + "state_attr", + "state_translated", + "states", "time_since", "time_until", "today_at", - "label_id", - "label_name", + "utcnow", ] hass_filters = [ - "closest", - "expand", - "device_id", "area_id", "area_name", + "closest", + "device_id", + "expand", "floor_id", "floor_name", "has_value", @@ -3122,8 +3346,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): hass_tests = [ "has_value", "is_hidden_entity", - "is_state", "is_state_attr", + "is_state", ] for glob in hass_globals: self.globals[glob] = unsupported(glob) @@ -3133,38 +3357,46 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters[test] = unsupported(test) return - self.globals["expand"] = hassfunction(expand) - self.filters["expand"] = self.globals["expand"] self.globals["closest"] = hassfunction(closest) - self.filters["closest"] = hassfunction(closest_filter) self.globals["distance"] = hassfunction(distance) + self.globals["expand"] = hassfunction(expand) + self.globals["has_value"] = hassfunction(has_value) + self.globals["now"] = hassfunction(now) + self.globals["relative_time"] = hassfunction(relative_time) + self.globals["time_since"] = hassfunction(time_since) + self.globals["time_until"] = hassfunction(time_until) + self.globals["today_at"] = hassfunction(today_at) + self.globals["utcnow"] = hassfunction(utcnow) + + self.filters["closest"] = hassfunction(closest_filter) + self.filters["expand"] = self.globals["expand"] + self.filters["has_value"] = self.globals["has_value"] + self.filters["relative_time"] = self.globals["relative_time"] + self.filters["time_since"] = self.globals["time_since"] + self.filters["time_until"] = self.globals["time_until"] + self.filters["today_at"] = self.globals["today_at"] + + self.tests["has_value"] = hassfunction(has_value, pass_eval_context) + + # Entity extensions + self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) self.tests["is_hidden_entity"] = hassfunction( is_hidden_entity, pass_eval_context ) - self.globals["is_state"] = hassfunction(is_state) - self.tests["is_state"] = hassfunction(is_state, pass_eval_context) + + # State extensions + self.globals["is_state_attr"] = hassfunction(is_state_attr) - self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context) + self.globals["is_state"] = hassfunction(is_state) self.globals["state_attr"] = hassfunction(state_attr) - self.filters["state_attr"] = self.globals["state_attr"] - self.globals["states"] = AllStates(hass) - self.filters["states"] = self.globals["states"] self.globals["state_translated"] = StateTranslated(hass) + self.globals["states"] = AllStates(hass) + self.filters["state_attr"] = self.globals["state_attr"] self.filters["state_translated"] = self.globals["state_translated"] - self.globals["has_value"] = hassfunction(has_value) - self.filters["has_value"] = self.globals["has_value"] - self.tests["has_value"] = hassfunction(has_value, pass_eval_context) - self.globals["utcnow"] = hassfunction(utcnow) - self.globals["now"] = hassfunction(now) - self.globals["relative_time"] = hassfunction(relative_time) - self.filters["relative_time"] = self.globals["relative_time"] - self.globals["time_since"] = hassfunction(time_since) - self.filters["time_since"] = self.globals["time_since"] - self.globals["time_until"] = hassfunction(time_until) - self.filters["time_until"] = self.globals["time_until"] - self.globals["today_at"] = hassfunction(today_at) - self.filters["today_at"] = self.globals["today_at"] + self.filters["states"] = self.globals["states"] + self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context) + self.tests["is_state"] = hassfunction(is_state, pass_eval_context) def is_safe_callable(self, obj): """Test if callback is safe.""" diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 67e9010df79..a27c85a5c58 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -265,18 +265,18 @@ def _trigger_action_wrapper( while isinstance(check_func, functools.partial): check_func = check_func.func - wrapper_func: Callable[..., None] | Callable[..., Coroutine[Any, Any, None]] + wrapper_func: Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]] if asyncio.iscoroutinefunction(check_func): - async_action = cast(Callable[..., Coroutine[Any, Any, None]], action) + async_action = cast(Callable[..., Coroutine[Any, Any, Any]], action) @functools.wraps(async_action) async def async_with_vars( run_variables: dict[str, Any], context: Context | None = None - ) -> None: + ) -> Any: """Wrap action with extra vars.""" trigger_variables = conf[CONF_VARIABLES] run_variables.update(trigger_variables.async_render(hass, run_variables)) - await action(run_variables, context) + return await action(run_variables, context) wrapper_func = async_with_vars @@ -285,11 +285,11 @@ def _trigger_action_wrapper( @functools.wraps(action) async def with_vars( run_variables: dict[str, Any], context: Context | None = None - ) -> None: + ) -> Any: """Wrap action with extra vars.""" trigger_variables = conf[CONF_VARIABLES] run_variables.update(trigger_variables.async_render(hass, run_variables)) - action(run_variables, context) + return action(run_variables, context) if is_callback(check_func): with_vars = callback(with_vars) diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index 1486e33d6fa..bf7598eb024 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -2,10 +2,11 @@ from __future__ import annotations -import contextlib +import itertools import logging from typing import Any +import jinja2 import voluptuous as vol from homeassistant.components.sensor import ( @@ -30,7 +31,14 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import config_validation as cv from .entity import Entity -from .template import TemplateStateFromEntityId, render_complex +from .template import ( + _SENTINEL, + Template, + TemplateStateFromEntityId, + _render_with_context, + render_complex, + result_as_boolean, +) from .typing import ConfigType CONF_AVAILABILITY = "availability" @@ -65,6 +73,27 @@ def make_template_entity_base_schema(default_name: str) -> vol.Schema: ) +def log_triggered_template_error( + entity_id: str, + err: TemplateError, + key: str | None = None, + attribute: str | None = None, +) -> None: + """Log a trigger entity template error.""" + target = "" + if key: + target = f" {key}" + elif attribute: + target = f" {CONF_ATTRIBUTES}.{attribute}" + + logging.getLogger(f"{__package__}.{entity_id.split('.')[0]}").error( + "Error rendering%s template for %s: %s", + target, + entity_id, + err, + ) + + TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( { vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, @@ -74,6 +103,44 @@ TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( ).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) +class ValueTemplate(Template): + """Class to hold a value_template and manage caching and rendering it with 'value' in variables.""" + + @classmethod + def from_template(cls, template: Template) -> ValueTemplate: + """Create a ValueTemplate object from a Template object.""" + return cls(template.template, template.hass) + + @callback + def async_render_as_value_template( + self, entity_id: str, variables: dict[str, Any], error_value: Any + ) -> Any: + """Render template that requires 'value' and optionally 'value_json'. + + Template errors will be suppressed when an error_value is supplied. + + This method must be run in the event loop. + """ + self._renders += 1 + + if self.is_static: + return self.template + + compiled = self._compiled or self._ensure_compiled() + + try: + render_result = _render_with_context( + self.template, compiled, **variables + ).strip() + except jinja2.TemplateError as ex: + message = f"Error parsing value for {entity_id}: {ex} (value: {variables['value']}, template: {self.template})" + logger = logging.getLogger(f"{__package__}.{entity_id.split('.')[0]}") + logger.debug(message) + return error_value + + return render_result + + class TriggerBaseEntity(Entity): """Template Base entity based on trigger data.""" @@ -122,6 +189,9 @@ class TriggerBaseEntity(Entity): self._parse_result = {CONF_AVAILABILITY} self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._availability_template = config.get(CONF_AVAILABILITY) + self._available = True + @property def name(self) -> str | None: """Name of the entity.""" @@ -145,12 +215,10 @@ class TriggerBaseEntity(Entity): @property def available(self) -> bool: """Return availability of the entity.""" - return ( - self._rendered is not self._static_rendered - and - # Check against False so `None` is ok - self._rendered.get(CONF_AVAILABILITY) is not False - ) + if self._availability_template is None: + return True + + return self._available @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -176,35 +244,93 @@ class TriggerBaseEntity(Entity): extra_state_attributes[attr] = last_state.attributes[attr] self._rendered[CONF_ATTRIBUTES] = extra_state_attributes + def _template_variables(self, run_variables: dict[str, Any] | None = None) -> dict: + """Render template variables.""" + return { + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **(run_variables or {}), + } + + def _render_single_template( + self, + key: str, + variables: dict[str, Any], + strict: bool = False, + ) -> Any: + """Render a single template.""" + try: + if key in self._to_render_complex: + return render_complex(self._config[key], variables) + + return self._config[key].async_render( + variables, parse_result=key in self._parse_result, strict=strict + ) + except TemplateError as err: + log_triggered_template_error(self.entity_id, err, key=key) + + return _SENTINEL + + def _render_availability_template(self, variables: dict[str, Any]) -> bool: + """Render availability template.""" + if not self._availability_template: + return True + + try: + if ( + available := self._availability_template.async_render( + variables, parse_result=True, strict=True + ) + ) is False: + self._rendered = dict(self._static_rendered) + + self._available = result_as_boolean(available) + + except TemplateError as err: + # The entity will be available when an error is rendered. This + # ensures functionality is consistent between template and trigger template + # entities. + self._available = True + log_triggered_template_error(self.entity_id, err, key=CONF_AVAILABILITY) + + return self._available + + def _render_attributes(self, rendered: dict, variables: dict[str, Any]) -> None: + """Render template attributes.""" + if CONF_ATTRIBUTES in self._config: + attributes = {} + for attribute, attribute_template in self._config[CONF_ATTRIBUTES].items(): + try: + value = render_complex(attribute_template, variables) + attributes[attribute] = value + variables.update({attribute: value}) + except TemplateError as err: + log_triggered_template_error( + self.entity_id, err, attribute=attribute + ) + rendered[CONF_ATTRIBUTES] = attributes + + def _render_single_templates( + self, + rendered: dict, + variables: dict[str, Any], + filtered: list[str] | None = None, + ) -> None: + """Render all single templates.""" + for key in itertools.chain(self._to_render_simple, self._to_render_complex): + if filtered and key in filtered: + continue + + if ( + result := self._render_single_template(key, variables) + ) is not _SENTINEL: + rendered[key] = result + def _render_templates(self, variables: dict[str, Any]) -> None: """Render templates.""" - try: - rendered = dict(self._static_rendered) - - for key in self._to_render_simple: - rendered[key] = self._config[key].async_render( - variables, - parse_result=key in self._parse_result, - ) - - for key in self._to_render_complex: - rendered[key] = render_complex( - self._config[key], - variables, - ) - - if CONF_ATTRIBUTES in self._config: - rendered[CONF_ATTRIBUTES] = render_complex( - self._config[CONF_ATTRIBUTES], - variables, - ) - - self._rendered = rendered - except TemplateError as err: - logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( - "Error rendering %s template for %s: %s", key, self.entity_id, err - ) - self._rendered = self._static_rendered + rendered = dict(self._static_rendered) + self._render_single_templates(rendered, variables) + self._render_attributes(rendered, variables) + self._rendered = rendered class ManualTriggerEntity(TriggerBaseEntity): @@ -223,23 +349,31 @@ class ManualTriggerEntity(TriggerBaseEntity): parse_result=CONF_NAME in self._parse_result, ) + def _template_variables_with_value( + self, value: str | None = None + ) -> dict[str, Any]: + """Render template variables. + + Implementing class should call this first in update method to render variables for templates. + Ex: variables = self._render_template_variables_with_value(payload) + """ + run_variables: dict[str, Any] = {"value": value} + + # Silently try if variable is a json and store result in `value_json` if it is. + try: # noqa: SIM105 - suppress is much slower + run_variables["value_json"] = json_loads(value) # type: ignore[arg-type] + except JSON_DECODE_EXCEPTIONS: + pass + + return self._template_variables(run_variables) + @callback - def _process_manual_data(self, value: Any | None = None) -> None: + def _process_manual_data(self, variables: dict[str, Any]) -> None: """Process new data manually. Implementing class should call this last in update method to render templates. - Ex: self._process_manual_data(payload) + Ex: self._process_manual_data(variables) """ - - run_variables: dict[str, Any] = {"value": value} - # Silently try if variable is a json and store result in `value_json` if it is. - with contextlib.suppress(*JSON_DECODE_EXCEPTIONS): - run_variables["value_json"] = json_loads(run_variables["value"]) - variables = { - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **(run_variables or {}), - } - self._render_templates(variables) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 3bc33f8374c..0980a6f2ba9 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -18,7 +18,7 @@ import pathlib import sys import time from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast, final from awesomeversion import ( AwesomeVersion, @@ -40,6 +40,8 @@ from .generated.ssdp import SSDP from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF from .helpers.json import json_bytes, json_fragment +from .helpers.typing import UNDEFINED, UndefinedType +from .util.async_ import create_eager_task from .util.hass_dict import HassKey from .util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -644,6 +646,7 @@ def async_register_preload_platform(hass: HomeAssistant, platform_name: str) -> preload_platforms.append(platform_name) +@final # Final to allow direct checking of the type instead of using isinstance class Integration: """An integration in Home Assistant.""" @@ -758,10 +761,8 @@ class Integration: manifest["overwrites_built_in"] = self.overwrites_built_in if self.dependencies: - self._all_dependencies_resolved: bool | None = None - self._all_dependencies: set[str] | None = None + self._all_dependencies: set[str] | Exception | None = None else: - self._all_dependencies_resolved = True self._all_dependencies = set() self._platforms_to_preload = hass.data[DATA_PRELOAD_PLATFORMS] @@ -933,47 +934,25 @@ class Integration: """Return all dependencies including sub-dependencies.""" if self._all_dependencies is None: raise RuntimeError("Dependencies not resolved!") + if isinstance(self._all_dependencies, Exception): + raise self._all_dependencies return self._all_dependencies @property def all_dependencies_resolved(self) -> bool: """Return if all dependencies have been resolved.""" - return self._all_dependencies_resolved is not None + return self._all_dependencies is not None - async def resolve_dependencies(self) -> bool: + async def resolve_dependencies(self) -> set[str] | None: """Resolve all dependencies.""" - if self._all_dependencies_resolved is not None: - return self._all_dependencies_resolved + if self._all_dependencies is not None: + if isinstance(self._all_dependencies, Exception): + return None + return self._all_dependencies - self._all_dependencies_resolved = False - try: - dependencies = await _async_component_dependencies(self.hass, self) - except IntegrationNotFound as err: - _LOGGER.error( - ( - "Unable to resolve dependencies for %s: unable to resolve" - " (sub)dependency %s" - ), - self.domain, - err.domain, - ) - except CircularDependency as err: - _LOGGER.error( - ( - "Unable to resolve dependencies for %s: it contains a circular" - " dependency: %s -> %s" - ), - self.domain, - err.from_domain, - err.to_domain, - ) - else: - dependencies.discard(self.domain) - self._all_dependencies = dependencies - self._all_dependencies_resolved = True - - return self._all_dependencies_resolved + result = await resolve_integrations_dependencies(self.hass, (self,)) + return result.get(self.domain) async def async_get_component(self) -> ComponentProtocol: """Return the component. @@ -1441,6 +1420,191 @@ async def async_get_integrations( return results +class _ResolveDependenciesCacheProtocol(Protocol): + def get(self, itg: Integration) -> set[str] | Exception | None: ... + + def __setitem__( + self, itg: Integration, all_dependencies: set[str] | Exception + ) -> None: ... + + +class _ResolveDependenciesCache(_ResolveDependenciesCacheProtocol): + """Cache for resolve_integrations_dependencies.""" + + def get(self, itg: Integration) -> set[str] | Exception | None: + return itg._all_dependencies # noqa: SLF001 + + def __setitem__( + self, itg: Integration, all_dependencies: set[str] | Exception + ) -> None: + itg._all_dependencies = all_dependencies # noqa: SLF001 + + +async def resolve_integrations_dependencies( + hass: HomeAssistant, integrations: Iterable[Integration] +) -> dict[str, set[str]]: + """Resolve all dependencies for integrations. + + Detects circular dependencies and missing integrations. + """ + return await _resolve_integrations_dependencies( + hass, + "resolve dependencies", + integrations, + cache=_ResolveDependenciesCache(), + ignore_exceptions=False, + ) + + +async def resolve_integrations_after_dependencies( + hass: HomeAssistant, + integrations: Iterable[Integration], + possible_after_dependencies: set[str] | None = None, + *, + ignore_exceptions: bool = False, +) -> dict[str, set[str]]: + """Resolve all dependencies, including after_dependencies, for integrations. + + Detects circular dependencies and missing integrations. + """ + return await _resolve_integrations_dependencies( + hass, + "resolve (after) dependencies", + integrations, + cache={}, + possible_after_dependencies=possible_after_dependencies, + ignore_exceptions=ignore_exceptions, + ) + + +async def _resolve_integrations_dependencies( + hass: HomeAssistant, + name: str, + integrations: Iterable[Integration], + *, + cache: _ResolveDependenciesCacheProtocol, + possible_after_dependencies: set[str] | None | UndefinedType = UNDEFINED, + ignore_exceptions: bool, +) -> dict[str, set[str]]: + """Resolve all dependencies, possibly including after_dependencies, for integrations. + + Detects circular dependencies and missing integrations. + """ + + async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None: + try: + return await _resolve_integration_dependencies( + itg, + cache=cache, + possible_after_dependencies=possible_after_dependencies, + ignore_exceptions=ignore_exceptions, + ) + except Exception as exc: # noqa: BLE001 + _LOGGER.error("Unable to %s for %s: %s", name, itg.domain, exc) + return None + + resolve_dependencies_tasks = { + itg.domain: create_eager_task( + _resolve_deps_catch_exceptions(itg), + name=f"{name} {itg.domain}", + loop=hass.loop, + ) + for itg in integrations + } + + result = await asyncio.gather(*resolve_dependencies_tasks.values()) + + return { + domain: deps + for domain, deps in zip(resolve_dependencies_tasks, result, strict=True) + if deps is not None + } + + +async def _resolve_integration_dependencies( + itg: Integration, + *, + cache: _ResolveDependenciesCacheProtocol, + possible_after_dependencies: set[str] | None | UndefinedType = UNDEFINED, + ignore_exceptions: bool = False, +) -> set[str]: + """Recursively resolve all dependencies. + + Uses `cache` to cache the results. + + If `possible_after_dependencies` is not UNDEFINED, + listed after dependencies are also considered. + If `possible_after_dependencies` is None, + all the possible after dependencies are considered. + + If `ignore_exceptions` is True, exceptions are caught and ignored + and the normal resolution algorithm continues. + Otherwise, exceptions are raised. + """ + resolved = cache + resolving: set[str] = set() + + async def resolve_dependencies_impl(itg: Integration) -> set[str]: + domain = itg.domain + + # If it's already resolved, no point doing it again. + if (result := resolved.get(itg)) is not None: + if isinstance(result, Exception): + raise result + return result + + # If we are already resolving it, we have a circular dependency. + if domain in resolving: + if ignore_exceptions: + resolved[itg] = set() + return set() + exc = CircularDependency([domain]) + resolved[itg] = exc + raise exc + + resolving.add(domain) + + dependencies_domains = set(itg.dependencies) + if possible_after_dependencies is not UNDEFINED: + if possible_after_dependencies is None: + after_dependencies: Iterable[str] = itg.after_dependencies + else: + after_dependencies = ( + set(itg.after_dependencies) & possible_after_dependencies + ) + dependencies_domains.update(after_dependencies) + dependencies = await async_get_integrations(itg.hass, dependencies_domains) + + all_dependencies: set[str] = set() + for dep_domain, dep_integration in dependencies.items(): + if isinstance(dep_integration, Exception): + if ignore_exceptions: + continue + resolved[itg] = dep_integration + raise dep_integration + + all_dependencies.add(dep_domain) + + try: + dep_dependencies = await resolve_dependencies_impl(dep_integration) + except CircularDependency as exc: + exc.extend_cycle(domain) + resolved[itg] = exc + raise + except Exception as exc: + resolved[itg] = exc + raise + + all_dependencies.update(dep_dependencies) + + resolving.remove(domain) + + resolved[itg] = all_dependencies + return all_dependencies + + return await resolve_dependencies_impl(itg) + + class LoaderError(Exception): """Loader base error.""" @@ -1466,11 +1630,13 @@ class IntegrationNotLoaded(LoaderError): class CircularDependency(LoaderError): """Raised when a circular dependency is found when resolving components.""" - def __init__(self, from_domain: str | set[str], to_domain: str) -> None: + def __init__(self, domain_cycle: list[str]) -> None: """Initialize circular dependency error.""" - super().__init__(f"Circular dependency detected: {from_domain} -> {to_domain}.") - self.from_domain = from_domain - self.to_domain = to_domain + super().__init__("Circular dependency detected", domain_cycle) + + def extend_cycle(self, domain: str) -> None: + """Extend the cycle with the domain.""" + self.args[1].insert(0, domain) def _load_file( @@ -1544,76 +1710,6 @@ class ModuleWrapper: return value -class Components: - """Helper to load components.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the Components class.""" - self._hass = hass - - def __getattr__(self, comp_name: str) -> ModuleWrapper: - """Fetch a component.""" - # Test integration cache - integration = self._hass.data[DATA_INTEGRATIONS].get(comp_name) - - if isinstance(integration, Integration): - component: ComponentProtocol | None = integration.get_component() - else: - # Fallback to importing old-school - component = _load_file(self._hass, comp_name, _lookup_path(self._hass)) - - if component is None: - raise ImportError(f"Unable to load {comp_name}") - - # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from .helpers.frame import ReportBehavior, report_usage - - report_usage( - f"accesses hass.components.{comp_name}, which" - f" should be updated to import functions used from {comp_name} directly", - core_behavior=ReportBehavior.IGNORE, - core_integration_behavior=ReportBehavior.IGNORE, - custom_integration_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.3", - ) - - wrapped = ModuleWrapper(self._hass, component) - setattr(self, comp_name, wrapped) - return wrapped - - -class Helpers: - """Helper to load helpers.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the Helpers class.""" - self._hass = hass - - def __getattr__(self, helper_name: str) -> ModuleWrapper: - """Fetch a helper.""" - helper = importlib.import_module(f"homeassistant.helpers.{helper_name}") - - # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from .helpers.frame import ReportBehavior, report_usage - - report_usage( - ( - f"accesses hass.helpers.{helper_name}, which" - f" should be updated to import functions used from {helper_name} directly" - ), - core_behavior=ReportBehavior.IGNORE, - core_integration_behavior=ReportBehavior.IGNORE, - custom_integration_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2025.5", - ) - - wrapped = ModuleWrapper(self._hass, helper) - setattr(self, helper_name, wrapped) - return wrapped - - def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: """Decorate function to indicate that first argument is hass. @@ -1624,50 +1720,6 @@ def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: return func -async def _async_component_dependencies( - hass: HomeAssistant, - integration: Integration, -) -> set[str]: - """Get component dependencies.""" - loading: set[str] = set() - loaded: set[str] = set() - - async def component_dependencies_impl(integration: Integration) -> None: - """Recursively get component dependencies.""" - domain = integration.domain - if not (dependencies := integration.dependencies): - loaded.add(domain) - return - - loading.add(domain) - dep_integrations = await async_get_integrations(hass, dependencies) - for dependency_domain, dep_integration in dep_integrations.items(): - if isinstance(dep_integration, Exception): - raise dep_integration - - # If we are already loading it, we have a circular dependency. - # We have to check it here to make sure that every integration that - # depends on us, does not appear in our own after_dependencies. - if conflict := loading.intersection(dep_integration.after_dependencies): - raise CircularDependency(conflict, dependency_domain) - - # If we have already loaded it, no point doing it again. - if dependency_domain in loaded: - continue - - # If we are already loading it, we have a circular dependency. - if dependency_domain in loading: - raise CircularDependency(dependency_domain, domain) - - await component_dependencies_impl(dep_integration) - loading.remove(domain) - loaded.add(domain) - - await component_dependencies_impl(integration) - - return loaded - - def _async_mount_config_dir(hass: HomeAssistant) -> None: """Mount config dir in order to load custom_component. diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c49580ae47b..fcb23c346a2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,18 +1,19 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodhcpwatcher==1.1.1 -aiodiscover==2.6.1 -aiodns==3.2.0 -aiohasupervisor==0.3.0 +aiodhcpwatcher==1.2.0 +aiodiscover==2.7.0 +aiodns==3.4.0 +aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.13 +aiohttp==3.11.18 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 +annotatedyaml==0.4.5 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 atomicwrites-homeassistant==1.4.1 attrs==25.1.0 audioop-lts==0.2.1 @@ -22,38 +23,39 @@ bcrypt==4.2.0 bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 -bluetooth-auto-recovery==1.4.4 -bluetooth-data-tools==1.23.4 -cached-ipaddress==0.9.2 +bluetooth-auto-recovery==1.5.1 +bluetooth-data-tools==1.28.1 +cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -cryptography==44.0.1 -dbus-fast==2.33.0 -fnv-hash-fast==1.2.6 -go2rtc-client==0.1.2 +cryptography==45.0.1 +dbus-fast==2.43.0 +fnv-hash-fast==1.5.0 +go2rtc-client==0.1.3b0 ha-ffmpeg==3.2.2 -habluetooth==3.24.0 -hass-nabucasa==0.92.0 +habluetooth==3.48.2 +hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250226.0 -home-assistant-intents==2025.2.26 +home-assistant-frontend==20250516.0 +home-assistant-intents==2025.5.7 httpx==0.28.1 ifaddr==0.2.0 -Jinja2==3.1.5 +Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.12 +numpy==2.2.2 +orjson==3.10.18 packaging>=23.1 paho-mqtt==2.1.0 -Pillow==11.1.0 -propcache==0.3.0 +Pillow==11.2.1 +propcache==0.3.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 PyNaCl==1.5.0 -pyOpenSSL==25.0.0 +pyOpenSSL==25.1.0 pyserial==3.5 pyspeex-noise==1.0.2 python-slugify==8.0.4 @@ -61,19 +63,19 @@ PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 -SQLAlchemy==2.0.38 +SQLAlchemy==2.0.40 standard-aifc==3.13.0 standard-telnetlib==3.13.0 -typing-extensions>=4.12.2,<5.0 -ulid-transform==1.2.1 +typing-extensions>=4.13.0,<5.0 +ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.1 -voluptuous-openapi==0.0.6 +uv==0.7.1 +voluptuous-openapi==0.1.0 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.18.3 -zeroconf==0.145.1 +yarl==1.20.0 +zeroconf==0.147.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -86,9 +88,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.67.1 -grpcio-status==1.67.1 -grpcio-reflection==1.67.1 +grpcio==1.72.0 +grpcio-status==1.72.0 +grpcio-reflection==1.72.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -108,7 +110,7 @@ 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.8.0 +anyio==4.9.0 h11==0.14.0 httpcore==1.0.7 @@ -128,7 +130,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.6 +pydantic==2.11.3 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -141,13 +143,9 @@ pubnub!=6.4.0 # https://github.com/dahlia/iso4217/issues/16 iso4217!=1.10.20220401 -# pyOpenSSL 24.0.0 or later required to avoid import errors when -# cryptography 42.0.0 is installed with botocore -pyOpenSSL>=24.0.0 - # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==5.29.2 +protobuf==6.30.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 @@ -210,3 +208,13 @@ async-timeout==4.0.3 # https://github.com/home-assistant/core/issues/122508 # https://github.com/home-assistant/core/issues/118004 aiofiles>=24.1.0 + +# multidict < 6.4.0 has memory leaks +# https://github.com/aio-libs/multidict/issues/1134 +# https://github.com/aio-libs/multidict/issues/1131 +multidict>=6.4.2 + +# rpds-py > 0.25.0 requires cargo 1.84.0 +# Stable Alpine current only ships cargo 1.83.0 +# No wheels upstream available for armhf & armv7 +rpds-py==0.24.0 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index a24568e9a6f..981f0a26926 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -12,6 +12,9 @@ import os from typing import Any from unittest.mock import patch +from annotatedyaml import loader as yaml_loader +from annotatedyaml.loader import Secrets + from homeassistant import core, loader from homeassistant.config import get_default_config_dir from homeassistant.config_entries import ConfigEntries @@ -23,17 +26,16 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.check_config import async_check_ha_config_file -from homeassistant.util.yaml import Secrets, loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs -REQUIREMENTS = ("colorlog==6.8.2",) +REQUIREMENTS = ("colorlog==6.9.0",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { - "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml), + "load": ("annotatedyaml.loader.load_yaml", yaml_loader.load_yaml), "load*": ("homeassistant.config.load_yaml_dict", yaml_loader.load_yaml_dict), - "secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml), + "secrets": ("annotatedyaml.loader.secret_yaml", yaml_loader.secret_yaml), } PATCHES: dict[str, Any] = {} diff --git a/homeassistant/setup.py b/homeassistant/setup.py index dc4d0988b91..39f0a7656f3 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -45,36 +45,36 @@ _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT: Final = "component" -# DATA_SETUP is a dict, indicating domains which are currently +# _DATA_SETUP is a dict, indicating domains which are currently # being setup or which failed to setup: -# - Tasks are added to DATA_SETUP by `async_setup_component`, the key is the domain +# - Tasks are added to _DATA_SETUP by `async_setup_component`, the key is the domain # being setup and the Task is the `_async_setup_component` helper. -# - Tasks are removed from DATA_SETUP if setup was successful, that is, +# - Tasks are removed from _DATA_SETUP if setup was successful, that is, # the task returned True. -DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks") +_DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks") -# DATA_SETUP_DONE is a dict, indicating components which will be setup: -# - Events are added to DATA_SETUP_DONE during bootstrap by +# _DATA_SETUP_DONE is a dict, indicating components which will be setup: +# - Events are added to _DATA_SETUP_DONE during bootstrap by # async_set_domains_to_be_loaded, the key is the domain which will be loaded. -# - Events are set and removed from DATA_SETUP_DONE when async_setup_component +# - Events are set and removed from _DATA_SETUP_DONE when async_setup_component # is finished, regardless of if the setup was successful or not. -DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done") +_DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done") -# DATA_SETUP_STARTED is a dict, indicating when an attempt +# _DATA_SETUP_STARTED is a dict, indicating when an attempt # to setup a component started. -DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey( +_DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey( "setup_started" ) -# DATA_SETUP_TIME is a defaultdict, indicating how time was spent +# _DATA_SETUP_TIME is a defaultdict, indicating how time was spent # setting up a component. -DATA_SETUP_TIME: HassKey[ +_DATA_SETUP_TIME: HassKey[ defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]] ] = HassKey("setup_time") -DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed") +_DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed") -DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey( +_DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey( "bootstrap_persistent_errors" ) @@ -104,8 +104,8 @@ def async_notify_setup_error( # pylint: disable-next=import-outside-toplevel from .components import persistent_notification - if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None: - errors = hass.data[DATA_PERSISTENT_ERRORS] = {} + if (errors := hass.data.get(_DATA_PERSISTENT_ERRORS)) is None: + errors = hass.data[_DATA_PERSISTENT_ERRORS] = {} errors[component] = errors.get(component) or display_link @@ -131,8 +131,8 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) - Properly handle after_dependencies. - Keep track of domains which will load but have not yet finished loading """ - setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) - setup_futures = hass.data.setdefault(DATA_SETUP, {}) + setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) old_domains = set(setup_futures) | set(setup_done_futures) | hass.config.components if overlap := old_domains & domains: _LOGGER.debug("Domains to be loaded %s already loaded or pending", overlap) @@ -158,8 +158,8 @@ async def async_setup_component( if domain in hass.config.components: return True - setup_futures = hass.data.setdefault(DATA_SETUP, {}) - setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) + setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {}) if existing_setup_future := setup_futures.get(domain): return await existing_setup_future @@ -200,30 +200,42 @@ async def _async_process_dependencies( Returns a list of dependencies which failed to set up. """ - setup_futures = hass.data.setdefault(DATA_SETUP, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) - dependencies_tasks = { - dep: setup_futures.get(dep) - or create_eager_task( - async_setup_component(hass, dep, config), - name=f"setup {dep} as dependency of {integration.domain}", - loop=hass.loop, - ) - for dep in integration.dependencies - if dep not in hass.config.components - } + dependencies_tasks: dict[str, asyncio.Future[bool]] = {} - after_dependencies_tasks: dict[str, asyncio.Future[bool]] = {} - to_be_loaded = hass.data.get(DATA_SETUP_DONE, {}) + for dep in integration.dependencies: + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( + async_setup_component(hass, dep, config), + name=f"setup {dep} as dependency of {integration.domain}", + loop=hass.loop, + ) + dependencies_tasks[dep] = fut + + to_be_loaded = hass.data.get(_DATA_SETUP_DONE, {}) + # We don't want to just wait for the futures from `to_be_loaded` here. + # We want to ensure that our after_dependencies are always actually + # scheduled to be set up, as if for whatever reason they had not been, + # we would deadlock waiting for them here. for dep in integration.after_dependencies: - if ( - dep not in dependencies_tasks - and dep in to_be_loaded - and dep not in hass.config.components - ): - after_dependencies_tasks[dep] = to_be_loaded[dep] + if dep not in to_be_loaded or dep in dependencies_tasks: + continue + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( + async_setup_component(hass, dep, config), + name=f"setup {dep} as after dependency of {integration.domain}", + loop=hass.loop, + ) + dependencies_tasks[dep] = fut - if not dependencies_tasks and not after_dependencies_tasks: + if not dependencies_tasks: return [] if dependencies_tasks: @@ -232,17 +244,9 @@ async def _async_process_dependencies( integration.domain, dependencies_tasks.keys(), ) - if after_dependencies_tasks: - _LOGGER.debug( - "Dependency %s will wait for after dependencies %s", - integration.domain, - after_dependencies_tasks.keys(), - ) async with hass.timeout.async_freeze(integration.domain): - results = await asyncio.gather( - *dependencies_tasks.values(), *after_dependencies_tasks.values() - ) + results = await asyncio.gather(*dependencies_tasks.values()) failed = [ domain for idx, domain in enumerate(dependencies_tasks) if not results[idx] @@ -323,7 +327,7 @@ async def _async_setup_component( translation.async_load_integrations(hass, integration_set), loop=hass.loop ) # Validate all dependencies exist and there are no circular dependencies - if not await integration.resolve_dependencies(): + if await integration.resolve_dependencies() is None: return False # Process requirements as soon as possible, so we can import the component @@ -479,7 +483,7 @@ async def _async_setup_component( ) # Cleanup - hass.data[DATA_SETUP].pop(domain, None) + hass.data[_DATA_SETUP].pop(domain, None) hass.bus.async_fire_internal( EVENT_COMPONENT_LOADED, EventComponentLoaded(component=domain) @@ -569,8 +573,8 @@ async def async_process_deps_reqs( Module is a Python module of either a component or platform. """ - if (processed := hass.data.get(DATA_DEPS_REQS)) is None: - processed = hass.data[DATA_DEPS_REQS] = set() + if (processed := hass.data.get(_DATA_DEPS_REQS)) is None: + processed = hass.data[_DATA_DEPS_REQS] = set() elif integration.domain in processed: return @@ -685,7 +689,7 @@ class SetupPhases(StrEnum): """Wait time for the packages to import.""" -@singleton.singleton(DATA_SETUP_STARTED) +@singleton.singleton(_DATA_SETUP_STARTED) def _setup_started( hass: core.HomeAssistant, ) -> dict[tuple[str, str | None], float]: @@ -728,7 +732,7 @@ def async_pause_setup(hass: core.HomeAssistant, phase: SetupPhases) -> Generator ) -@singleton.singleton(DATA_SETUP_TIME) +@singleton.singleton(_DATA_SETUP_TIME) def _setup_times( hass: core.HomeAssistant, ) -> defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]: @@ -828,3 +832,11 @@ def async_get_domain_setup_times( ) -> Mapping[str | None, dict[SetupPhases, float]]: """Return timing data for each integration.""" return _setup_times(hass).get(domain, {}) + + +async def async_wait_component(hass: HomeAssistant, domain: str) -> bool: + """Wait until a component is set up if pending, then return if it is set up.""" + setup_done = hass.data.get(_DATA_SETUP_DONE, {}) + if setup_future := setup_done.get(domain): + await setup_future + return domain in hass.config.components diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 2fe46ad6994..ba33b17cae1 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -47,6 +47,7 @@ "access_token": "Access token", "api_key": "API key", "api_token": "API token", + "country": "Country", "device": "Device", "elevation": "Elevation", "email": "Email", @@ -121,24 +122,37 @@ }, "state": { "active": "Active", + "auto": "Auto", "charging": "Charging", "closed": "Closed", + "closing": "Closing", "connected": "Connected", "disabled": "Disabled", "discharging": "Discharging", "disconnected": "Disconnected", "enabled": "Enabled", + "error": "Error", + "fault": "Fault", + "high": "High", "home": "Home", "idle": "Idle", "locked": "Locked", + "low": "Low", + "manual": "Manual", + "medium": "Medium", "no": "No", + "normal": "Normal", "not_home": "Away", "off": "Off", "on": "On", "open": "Open", + "opening": "Opening", "paused": "Paused", "standby": "Standby", + "stopped": "Stopped", "unlocked": "Unlocked", + "very_high": "Very high", + "very_low": "Very low", "yes": "Yes" }, "time": { diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index eb898e4b544..ce30e9d6414 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -390,7 +390,9 @@ def parse_time_expression(parameter: Any, min_value: int, max_value: int) -> lis elif isinstance(parameter, str): if parameter.startswith("/"): parameter = int(parameter[1:]) - res = [x for x in range(min_value, max_value + 1) if x % parameter == 0] + res = list( + range(min_value + (-min_value % parameter), max_value + 1, parameter) + ) else: res = [int(parameter)] diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py index 81ce9961a0b..518515d4f85 100644 --- a/homeassistant/util/frozen_dataclass_compat.py +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -63,7 +63,7 @@ class FrozenOrThawed(type): ) def __new__( - mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + mcs, name: str, bases: tuple[type, ...], namespace: dict[Any, Any], diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 2c4eb744614..d5dfab7da6c 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -2,14 +2,16 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Callable, Coroutine from functools import partial, wraps import inspect import logging import logging.handlers -import queue +from queue import SimpleQueue +import time import traceback -from typing import Any, cast, overload +from typing import Any, cast, overload, override from homeassistant.core import ( HassJobType, @@ -18,6 +20,76 @@ from homeassistant.core import ( get_hassjob_callable_job_type, ) +_LOGGER = logging.getLogger(__name__) + + +class HomeAssistantQueueListener(logging.handlers.QueueListener): + """Custom QueueListener to watch for noisy loggers.""" + + LOG_COUNTS_RESET_INTERVAL = 300 + MAX_LOGS_COUNT = 200 + + EXCLUDED_LOG_COUNT_MODULES = [ + "homeassistant.components.automation", + "homeassistant.components.script", + "homeassistant.setup", + "homeassistant.util.logging", + ] + + _last_reset: float + _log_counts: dict[str, int] + + def __init__( + self, queue: SimpleQueue[logging.Handler], *handlers: logging.Handler + ) -> None: + """Initialize the handler.""" + super().__init__(queue, *handlers) + self._module_log_count_skip_flags: dict[str, bool] = {} + self._reset_counters(time.time()) + + @override + def handle(self, record: logging.LogRecord) -> None: + """Handle the record.""" + super().handle(record) + + if record.levelno < logging.INFO: + return + + if (record.created - self._last_reset) > self.LOG_COUNTS_RESET_INTERVAL: + self._reset_counters(record.created) + + module_name = record.name + + if skip_flag := self._module_log_count_skip_flags.get(module_name): + return + + if skip_flag is None and self._update_skip_flags(module_name): + return + + self._log_counts[module_name] += 1 + module_count = self._log_counts[module_name] + if module_count < self.MAX_LOGS_COUNT: + return + + _LOGGER.warning( + "Module %s is logging too frequently. %d messages since last count", + module_name, + module_count, + ) + self._module_log_count_skip_flags[module_name] = True + + def _reset_counters(self, time_sec: float) -> None: + _LOGGER.debug("Resetting log counters") + self._last_reset = time_sec + self._log_counts = defaultdict(int) + + def _update_skip_flags(self, module_name: str) -> bool: + excluded = any( + module_name.startswith(prefix) for prefix in self.EXCLUDED_LOG_COUNT_MODULES + ) + self._module_log_count_skip_flags[module_name] = excluded + return excluded + class HomeAssistantQueueHandler(logging.handlers.QueueHandler): """Process the log in another thread.""" @@ -60,7 +132,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None: This allows us to avoid blocking I/O and formatting messages in the event loop as log messages are written in another thread. """ - simple_queue: queue.SimpleQueue[logging.Handler] = queue.SimpleQueue() + simple_queue: SimpleQueue[logging.Handler] = SimpleQueue() queue_handler = HomeAssistantQueueHandler(simple_queue) logging.root.addHandler(queue_handler) @@ -71,7 +143,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None: logging.root.removeHandler(handler) migrated_handlers.append(handler) - listener = logging.handlers.QueueListener(simple_queue, *migrated_handlers) + listener = HomeAssistantQueueListener(simple_queue, *migrated_handlers) queue_handler.listener = listener listener.start() diff --git a/homeassistant/util/read_only_dict.py b/homeassistant/util/read_only_dict.py index 02befa78f60..3e4710cf220 100644 --- a/homeassistant/util/read_only_dict.py +++ b/homeassistant/util/read_only_dict.py @@ -1,7 +1,7 @@ """Read only dictionary.""" from copy import deepcopy -from typing import Any +from typing import Any, final def _readonly(*args: Any, **kwargs: Any) -> Any: @@ -9,6 +9,7 @@ def _readonly(*args: Any, **kwargs: Any) -> Any: raise RuntimeError("Cannot modify ReadOnlyDict") +@final # Final to allow direct checking of the type instead of using isinstance class ReadOnlyDict[_KT, _VT](dict[_KT, _VT]): """Read only version of dict that is compatible with dict types.""" diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index a22fd0c8fb4..4e26a126f39 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -82,10 +82,10 @@ def _client_context_no_verify(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: return sslcontext -@cache -def _client_context( +def _create_client_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: + """Return an independent SSL context for making requests.""" # Reuse environment variable definition from requests, since it's already a # requirement. If the environment variable has no value, fall back to using # certs from certifi package. @@ -100,6 +100,14 @@ def _client_context( return sslcontext +@cache +def _client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + # Cached version of _create_client_context + return _create_client_context(ssl_cipher_list) + + # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = _client_context(SSLCipherList.PYTHON_DEFAULT) _DEFAULT_NO_VERIFY_SSL_CONTEXT = _client_context_no_verify(SSLCipherList.PYTHON_DEFAULT) @@ -139,6 +147,14 @@ def client_context( return _SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_SSL_CONTEXT) +def create_client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return an independent SSL context for making requests.""" + # This explicitly uses the non-cached version to create a client context + return _create_client_context(ssl_cipher_list) + + def create_no_verify_ssl_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 67258c9cd09..0355aa96aca 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from functools import lru_cache +from math import floor, log10 from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, @@ -23,6 +24,7 @@ from homeassistant.const import ( UnitOfMass, UnitOfPower, UnitOfPressure, + UnitOfReactiveEnergy, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -144,6 +146,15 @@ class BaseUnitConverter: from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) return from_ratio / to_ratio + @classmethod + @lru_cache + def get_unit_floored_log_ratio( + cls, from_unit: str | None, to_unit: str | None + ) -> float: + """Get floored base10 log ratio between units of measurement.""" + ratio = cls.get_unit_ratio(from_unit, to_unit) + return floor(max(0, log10(ratio))) + @classmethod @lru_cache def _are_unit_inverses(cls, from_unit: str | None, to_unit: str | None) -> bool: @@ -419,6 +430,17 @@ class PressureConverter(BaseUnitConverter): } +class ReactiveEnergyConverter(BaseUnitConverter): + """Utility to convert reactive energy values.""" + + UNIT_CLASS = "reactive_energy" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR: 1, + UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR: 1 / 1e3, + } + VALID_UNITS = set(UnitOfReactiveEnergy) + + class SpeedConverter(BaseUnitConverter): """Utility to convert speed values.""" @@ -695,10 +717,13 @@ class VolumeFlowRateConverter(BaseUnitConverter): # Units in terms of m³/h _UNIT_CONVERSION: dict[str | None, float] = { UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_SECOND: 1 / _HRS_TO_SECS, UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE: 1 / (_HRS_TO_MINUTES * _CUBIC_FOOT_TO_CUBIC_METER), + UnitOfVolumeFlowRate.LITERS_PER_HOUR: 1 / _L_TO_CUBIC_METER, UnitOfVolumeFlowRate.LITERS_PER_MINUTE: 1 / (_HRS_TO_MINUTES * _L_TO_CUBIC_METER), + UnitOfVolumeFlowRate.LITERS_PER_SECOND: 1 / (_HRS_TO_SECS * _L_TO_CUBIC_METER), UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 1 / (_HRS_TO_MINUTES * _GALLON_TO_CUBIC_METER), UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND: 1 @@ -707,7 +732,10 @@ class VolumeFlowRateConverter(BaseUnitConverter): VALID_UNITS = { UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_SECOND, + UnitOfVolumeFlowRate.LITERS_PER_HOUR, UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.LITERS_PER_SECOND, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, } diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 15993cbae47..31f74377a16 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from numbers import Number from typing import TYPE_CHECKING, Final @@ -82,9 +83,21 @@ def _is_valid_unit(unit: str, unit_type: str) -> bool: return False +@dataclass(frozen=True, kw_only=True) class UnitSystem: """A container for units of measure.""" + _name: str + accumulated_precipitation_unit: UnitOfPrecipitationDepth + area_unit: UnitOfArea + length_unit: UnitOfLength + mass_unit: UnitOfMass + pressure_unit: UnitOfPressure + temperature_unit: UnitOfTemperature + volume_unit: UnitOfVolume + wind_speed_unit: UnitOfSpeed + _conversions: dict[tuple[SensorDeviceClass | str | None, str | None], str] + def __init__( self, name: str, @@ -118,16 +131,16 @@ class UnitSystem: if errors: raise ValueError(errors) - self._name = name - self.accumulated_precipitation_unit = accumulated_precipitation - self.area_unit = area - self.length_unit = length - self.mass_unit = mass - self.pressure_unit = pressure - self.temperature_unit = temperature - self.volume_unit = volume - self.wind_speed_unit = wind_speed - self._conversions = conversions + super().__setattr__("_name", name) + super().__setattr__("accumulated_precipitation_unit", accumulated_precipitation) + super().__setattr__("area_unit", area) + super().__setattr__("length_unit", length) + super().__setattr__("mass_unit", mass) + super().__setattr__("pressure_unit", pressure) + super().__setattr__("temperature_unit", temperature) + super().__setattr__("volume_unit", volume) + super().__setattr__("wind_speed_unit", wind_speed) + super().__setattr__("_conversions", conversions) def temperature(self, temperature: float, from_unit: str) -> float: """Convert the given temperature to this unit system.""" @@ -342,6 +355,7 @@ US_CUSTOMARY_SYSTEM = UnitSystem( ("distance", UnitOfLength.MILLIMETERS): UnitOfLength.INCHES, # Convert non-USCS volumes of gas meters ("gas", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, + ("gas", UnitOfVolume.LITERS): UnitOfVolume.CUBIC_FEET, # Convert non-USCS precipitation ("precipitation", UnitOfLength.CENTIMETERS): UnitOfLength.INCHES, ("precipitation", UnitOfLength.MILLIMETERS): UnitOfLength.INCHES, diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index 3b1f5c4cc0a..323383ef53f 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -1,17 +1,10 @@ """YAML utility functions.""" -from .const import SECRET_YAML +from annotatedyaml import SECRET_YAML, Input, YamlTypeError +from annotatedyaml.input import UndefinedSubstitution, extract_inputs, substitute + from .dumper import dump, save_yaml -from .input import UndefinedSubstitution, extract_inputs, substitute -from .loader import ( - Secrets, - YamlTypeError, - load_yaml, - load_yaml_dict, - parse_yaml, - secret_yaml, -) -from .objects import Input +from .loader import Secrets, load_yaml, load_yaml_dict, parse_yaml, secret_yaml __all__ = [ "SECRET_YAML", diff --git a/homeassistant/util/yaml/const.py b/homeassistant/util/yaml/const.py deleted file mode 100644 index 811c7d149f7..00000000000 --- a/homeassistant/util/yaml/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants.""" - -SECRET_YAML = "secrets.yaml" diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index 61772b6989d..059be2c1c5b 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -1,96 +1,5 @@ """Custom dumper and representers.""" -from collections import OrderedDict -from typing import Any +from annotatedyaml.dumper import add_representer, dump, represent_odict, save_yaml -import yaml - -from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass - -# mypy: allow-untyped-calls, no-warn-return-any - - -try: - from yaml import CSafeDumper as FastestAvailableSafeDumper -except ImportError: - from yaml import ( # type: ignore[assignment] - SafeDumper as FastestAvailableSafeDumper, - ) - - -def dump(_dict: dict | list) -> str: - """Dump YAML to a string and remove null.""" - return yaml.dump( - _dict, - default_flow_style=False, - allow_unicode=True, - sort_keys=False, - Dumper=FastestAvailableSafeDumper, - ).replace(": null\n", ":\n") - - -def save_yaml(path: str, data: dict) -> None: - """Save YAML to a file.""" - # Dump before writing to not truncate the file if dumping fails - str_data = dump(data) - with open(path, "w", encoding="utf-8") as outfile: - outfile.write(str_data) - - -# From: https://gist.github.com/miracle2k/3184458 -def represent_odict( # type: ignore[no-untyped-def] - dumper, tag, mapping, flow_style=None -) -> yaml.MappingNode: - """Like BaseRepresenter.represent_mapping but does not issue the sort().""" - value: list = [] - node = yaml.MappingNode(tag, value, flow_style=flow_style) - if dumper.alias_key is not None: - dumper.represented_objects[dumper.alias_key] = node - best_style = True - if hasattr(mapping, "items"): - mapping = mapping.items() - for item_key, item_value in mapping: - node_key = dumper.represent_data(item_key) - node_value = dumper.represent_data(item_value) - if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style): - best_style = False - if not (isinstance(node_value, yaml.ScalarNode) and not node_value.style): - best_style = False - value.append((node_key, node_value)) - if flow_style is None: - if dumper.default_flow_style is not None: - node.flow_style = dumper.default_flow_style - else: - node.flow_style = best_style - return node - - -def add_representer(klass: Any, representer: Any) -> None: - """Add to representer to the dumper.""" - FastestAvailableSafeDumper.add_representer(klass, representer) - - -add_representer( - OrderedDict, - lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), -) - -add_representer( - NodeDictClass, - lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), -) - -add_representer( - NodeListClass, - lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value), -) - -add_representer( - NodeStrClass, - lambda dumper, value: dumper.represent_scalar("tag:yaml.org,2002:str", str(value)), -) - -add_representer( - Input, - lambda dumper, value: dumper.represent_scalar("!input", value.name), -) +__all__ = ["add_representer", "dump", "represent_odict", "save_yaml"] diff --git a/homeassistant/util/yaml/input.py b/homeassistant/util/yaml/input.py index ff9b37f18f1..5dad8a63ae5 100644 --- a/homeassistant/util/yaml/input.py +++ b/homeassistant/util/yaml/input.py @@ -2,55 +2,8 @@ from __future__ import annotations -from typing import Any +from annotatedyaml.input import UndefinedSubstitution, extract_inputs, substitute from .objects import Input - -class UndefinedSubstitution(Exception): - """Error raised when we find a substitution that is not defined.""" - - def __init__(self, input_name: str) -> None: - """Initialize the undefined substitution exception.""" - super().__init__(f"No substitution found for input {input_name}") - self.input = input - - -def extract_inputs(obj: Any) -> set[str]: - """Extract input from a structure.""" - found: set[str] = set() - _extract_inputs(obj, found) - return found - - -def _extract_inputs(obj: Any, found: set[str]) -> None: - """Extract input from a structure.""" - if isinstance(obj, Input): - found.add(obj.name) - return - - if isinstance(obj, list): - for val in obj: - _extract_inputs(val, found) - return - - if isinstance(obj, dict): - for val in obj.values(): - _extract_inputs(val, found) - return - - -def substitute(obj: Any, substitutions: dict[str, Any]) -> Any: - """Substitute values.""" - if isinstance(obj, Input): - if obj.name not in substitutions: - raise UndefinedSubstitution(obj.name) - return substitutions[obj.name] - - if isinstance(obj, list): - return [substitute(val, substitutions) for val in obj] - - if isinstance(obj, dict): - return {key: substitute(val, substitutions) for key, val in obj.items()} - - return obj +__all__ = ["Input", "UndefinedSubstitution", "extract_inputs", "substitute"] diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 3911d62040b..1f8338a1ff7 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -2,157 +2,37 @@ from __future__ import annotations -from collections.abc import Callable, Iterator -import fnmatch -from io import StringIO, TextIOWrapper -import logging +from io import StringIO import os -from pathlib import Path -from typing import Any, TextIO, overload +from typing import TextIO +from annotatedyaml import YAMLException, YamlTypeError +from annotatedyaml.loader import ( + HAS_C_LOADER, + JSON_TYPE, + LoaderType, + Secrets, + add_constructor, + load_yaml as load_annotated_yaml, + load_yaml_dict as load_annotated_yaml_dict, + parse_yaml as parse_annotated_yaml, + secret_yaml as annotated_secret_yaml, +) import yaml -try: - from yaml import CSafeLoader as FastestAvailableSafeLoader - - HAS_C_LOADER = True -except ImportError: - HAS_C_LOADER = False - from yaml import ( # type: ignore[assignment] - SafeLoader as FastestAvailableSafeLoader, - ) - -from propcache.api import cached_property - from homeassistant.exceptions import HomeAssistantError -from .const import SECRET_YAML -from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass - -# mypy: allow-untyped-calls, no-warn-return-any - -JSON_TYPE = list | dict | str - -_LOGGER = logging.getLogger(__name__) - - -class YamlTypeError(HomeAssistantError): - """Raised by load_yaml_dict if top level data is not a dict.""" - - -class Secrets: - """Store secrets while loading YAML.""" - - def __init__(self, config_dir: Path) -> None: - """Initialize secrets.""" - self.config_dir = config_dir - self._cache: dict[Path, dict[str, str]] = {} - - def get(self, requester_path: str, secret: str) -> str: - """Return the value of a secret.""" - current_path = Path(requester_path) - - secret_dir = current_path - while True: - secret_dir = secret_dir.parent - - try: - secret_dir.relative_to(self.config_dir) - except ValueError: - # We went above the config dir - break - - secrets = self._load_secret_yaml(secret_dir) - - if secret in secrets: - _LOGGER.debug( - "Secret %s retrieved from secrets.yaml in folder %s", - secret, - secret_dir, - ) - return secrets[secret] - - raise HomeAssistantError(f"Secret {secret} not defined") - - def _load_secret_yaml(self, secret_dir: Path) -> dict[str, str]: - """Load the secrets yaml from path.""" - if (secret_path := secret_dir / SECRET_YAML) in self._cache: - return self._cache[secret_path] - - _LOGGER.debug("Loading %s", secret_path) - try: - secrets = load_yaml(str(secret_path)) - - if not isinstance(secrets, dict): - raise HomeAssistantError("Secrets is not a dictionary") - - if "logger" in secrets: - logger = str(secrets["logger"]).lower() - if logger == "debug": - _LOGGER.setLevel(logging.DEBUG) - else: - _LOGGER.error( - ( - "Error in secrets.yaml: 'logger: debug' expected, but" - " 'logger: %s' found" - ), - logger, - ) - del secrets["logger"] - except FileNotFoundError: - secrets = {} - - self._cache[secret_path] = secrets - - return secrets - - -class _LoaderMixin: - """Mixin class with extensions for YAML loader.""" - - name: str - stream: Any - - @cached_property - def get_name(self) -> str: - """Get the name of the loader.""" - return self.name - - @cached_property - def get_stream_name(self) -> str: - """Get the name of the stream.""" - return getattr(self.stream, "name", "") - - -class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): - """The fastest available safe loader, either C or Python.""" - - def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: - """Initialize a safe line loader.""" - self.stream = stream - - # Set name in same way as the Python loader does in yaml.reader.__init__ - if isinstance(stream, str): - self.name = "" - elif isinstance(stream, bytes): - self.name = "" - else: - self.name = getattr(stream, "name", "") - - super().__init__(stream) - self.secrets = secrets - - -class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): - """Python safe loader.""" - - def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: - """Initialize a safe line loader.""" - super().__init__(stream) - self.secrets = secrets - - -type LoaderType = FastSafeLoader | PythonSafeLoader +__all__ = [ + "HAS_C_LOADER", + "JSON_TYPE", + "Secrets", + "YamlTypeError", + "add_constructor", + "load_yaml", + "load_yaml_dict", + "parse_yaml", + "secret_yaml", +] def load_yaml( @@ -164,15 +44,9 @@ def load_yaml( except for FileNotFoundError which will be re-raised. """ try: - with open(fname, encoding="utf-8") as conf_file: - return parse_yaml(conf_file, secrets) - except UnicodeDecodeError as exc: - _LOGGER.error("Unable to read file %s: %s", fname, exc) - raise HomeAssistantError(exc) from exc - except FileNotFoundError: - raise - except OSError as exc: - raise HomeAssistantError(exc) from exc + return load_annotated_yaml(fname, secrets) + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc def load_yaml_dict( @@ -183,320 +57,27 @@ def load_yaml_dict( Raise if the top level is not a dict. Return an empty dict if the file is empty. """ - loaded_yaml = load_yaml(fname, secrets) - if loaded_yaml is None: - loaded_yaml = {} - if not isinstance(loaded_yaml, dict): - raise YamlTypeError(f"YAML file {fname} does not contain a dict") - return loaded_yaml + try: + return load_annotated_yaml_dict(fname, secrets) + except YamlTypeError: + raise + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc def parse_yaml( content: str | TextIO | StringIO, secrets: Secrets | None = None ) -> JSON_TYPE: """Parse YAML with the fastest available loader.""" - if not HAS_C_LOADER: - return _parse_yaml_python(content, secrets) try: - return _parse_yaml(FastSafeLoader, content, secrets) - except yaml.YAMLError: - # Loading failed, so we now load with the Python loader which has more - # readable exceptions - if isinstance(content, (StringIO, TextIO, TextIOWrapper)): - # Rewind the stream so we can try again - content.seek(0, 0) - return _parse_yaml_python(content, secrets) - - -def _parse_yaml_python( - content: str | TextIO | StringIO, secrets: Secrets | None = None -) -> JSON_TYPE: - """Parse YAML with the python loader (this is very slow).""" - try: - return _parse_yaml(PythonSafeLoader, content, secrets) - except yaml.YAMLError as exc: - _LOGGER.error(str(exc)) - raise HomeAssistantError(exc) from exc - - -def _parse_yaml( - loader: type[FastSafeLoader | PythonSafeLoader], - content: str | TextIO, - secrets: Secrets | None = None, -) -> JSON_TYPE: - """Load a YAML file.""" - return yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] - - -@overload -def _add_reference( - obj: list | NodeListClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeListClass: ... - - -@overload -def _add_reference( - obj: str | NodeStrClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeStrClass: ... - - -@overload -def _add_reference( - obj: dict | NodeDictClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeDictClass: ... - - -def _add_reference( - obj: dict | list | str | NodeDictClass | NodeListClass | NodeStrClass, - loader: LoaderType, - node: yaml.nodes.Node, -) -> NodeDictClass | NodeListClass | NodeStrClass: - """Add file reference information to an object.""" - if isinstance(obj, list): - obj = NodeListClass(obj) - elif isinstance(obj, str): - obj = NodeStrClass(obj) - elif isinstance(obj, dict): - obj = NodeDictClass(obj) - return _add_reference_to_node_class(obj, loader, node) - - -@overload -def _add_reference_to_node_class( - obj: NodeListClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeListClass: ... - - -@overload -def _add_reference_to_node_class( - obj: NodeStrClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeStrClass: ... - - -@overload -def _add_reference_to_node_class( - obj: NodeDictClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeDictClass: ... - - -def _add_reference_to_node_class( - obj: NodeDictClass | NodeListClass | NodeStrClass, - loader: LoaderType, - node: yaml.nodes.Node, -) -> NodeDictClass | NodeListClass | NodeStrClass: - """Add file reference information to a node class object.""" - try: # suppress is much slower - obj.__config_file__ = loader.get_name - obj.__line__ = node.start_mark.line + 1 - except AttributeError: - pass - return obj - - -def _raise_if_no_value[NodeT: yaml.nodes.Node, _R]( - func: Callable[[LoaderType, NodeT], _R], -) -> Callable[[LoaderType, NodeT], _R]: - def wrapper(loader: LoaderType, node: NodeT) -> _R: - if not node.value: - raise HomeAssistantError( - f"{node.start_mark}: {node.tag} needs an argument." - ) - return func(loader, node) - - return wrapper - - -@_raise_if_no_value -def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: - """Load another YAML file and embed it using the !include tag. - - Example: - device_tracker: !include device_tracker.yaml - - """ - fname = os.path.join(os.path.dirname(loader.get_name), node.value) - try: - loaded_yaml = load_yaml(fname, loader.secrets) - if loaded_yaml is None: - loaded_yaml = NodeDictClass() - return _add_reference(loaded_yaml, loader, node) - except FileNotFoundError as exc: - raise HomeAssistantError( - f"{node.start_mark}: Unable to read file {fname}" - ) from exc - - -def _is_file_valid(name: str) -> bool: - """Decide if a file is valid.""" - return not name.startswith(".") - - -def _find_files(directory: str, pattern: str) -> Iterator[str]: - """Recursively load files in a directory.""" - for root, dirs, files in os.walk(directory, topdown=True): - dirs[:] = [d for d in dirs if _is_file_valid(d)] - for basename in sorted(files): - if _is_file_valid(basename) and fnmatch.fnmatch(basename, pattern): - filename = os.path.join(root, basename) - yield filename - - -@_raise_if_no_value -def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDictClass: - """Load multiple files from directory as a dictionary.""" - mapping = NodeDictClass() - loc = os.path.join(os.path.dirname(loader.get_name), node.value) - for fname in _find_files(loc, "*.yaml"): - filename = os.path.splitext(os.path.basename(fname))[0] - if os.path.basename(fname) == SECRET_YAML: - continue - loaded_yaml = load_yaml(fname, loader.secrets) - if loaded_yaml is None: - # Special case, an empty file included by !include_dir_named is treated - # as an empty dictionary - loaded_yaml = NodeDictClass() - mapping[filename] = loaded_yaml - return _add_reference_to_node_class(mapping, loader, node) - - -@_raise_if_no_value -def _include_dir_merge_named_yaml( - loader: LoaderType, node: yaml.nodes.Node -) -> NodeDictClass: - """Load multiple files from directory as a merged dictionary.""" - mapping = NodeDictClass() - loc = os.path.join(os.path.dirname(loader.get_name), node.value) - for fname in _find_files(loc, "*.yaml"): - if os.path.basename(fname) == SECRET_YAML: - continue - loaded_yaml = load_yaml(fname, loader.secrets) - if isinstance(loaded_yaml, dict): - mapping.update(loaded_yaml) - return _add_reference_to_node_class(mapping, loader, node) - - -@_raise_if_no_value -def _include_dir_list_yaml( - loader: LoaderType, node: yaml.nodes.Node -) -> list[JSON_TYPE]: - """Load multiple files from directory as a list.""" - loc = os.path.join(os.path.dirname(loader.get_name), node.value) - return [ - loaded_yaml - for f in _find_files(loc, "*.yaml") - if os.path.basename(f) != SECRET_YAML - and (loaded_yaml := load_yaml(f, loader.secrets)) is not None - ] - - -@_raise_if_no_value -def _include_dir_merge_list_yaml( - loader: LoaderType, node: yaml.nodes.Node -) -> JSON_TYPE: - """Load multiple files from directory as a merged list.""" - loc: str = os.path.join(os.path.dirname(loader.get_name), node.value) - merged_list: list[JSON_TYPE] = [] - for fname in _find_files(loc, "*.yaml"): - if os.path.basename(fname) == SECRET_YAML: - continue - loaded_yaml = load_yaml(fname, loader.secrets) - if isinstance(loaded_yaml, list): - merged_list.extend(loaded_yaml) - return _add_reference(merged_list, loader, node) - - -def _handle_mapping_tag( - loader: LoaderType, node: yaml.nodes.MappingNode -) -> NodeDictClass: - """Load YAML mappings into an ordered dictionary to preserve key order.""" - loader.flatten_mapping(node) - nodes = loader.construct_pairs(node) - - seen: dict = {} - for (key, _), (child_node, _) in zip(nodes, node.value, strict=False): - line = child_node.start_mark.line - - try: - hash(key) - except TypeError as exc: - fname = loader.get_stream_name - raise yaml.MarkedYAMLError( - context=f'invalid key: "{key}"', - context_mark=yaml.Mark( - fname, - 0, - line, - -1, - None, - None, # type: ignore[arg-type] - ), - ) from exc - - if key in seen: - fname = loader.get_stream_name - _LOGGER.warning( - 'YAML file %s contains duplicate key "%s". Check lines %d and %d', - fname, - key, - seen[key], - line, - ) - seen[key] = line - - return _add_reference_to_node_class(NodeDictClass(nodes), loader, node) - - -def _construct_seq(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: - """Add line number and file name to Load YAML sequence.""" - (obj,) = loader.construct_yaml_seq(node) - return _add_reference(obj, loader, node) - - -def _handle_scalar_tag( - loader: LoaderType, node: yaml.nodes.ScalarNode -) -> str | int | float | None: - """Add line number and file name to Load YAML sequence.""" - obj = node.value - if not isinstance(obj, str): - return obj - return _add_reference_to_node_class(NodeStrClass(obj), loader, node) - - -def _env_var_yaml(loader: LoaderType, node: yaml.nodes.Node) -> str: - """Load environment variables and embed it into the configuration YAML.""" - args = node.value.split() - - # Check for a default value - if len(args) > 1: - return os.getenv(args[0], " ".join(args[1:])) - if args[0] in os.environ: - return os.environ[args[0]] - _LOGGER.error("Environment variable %s not defined", node.value) - raise HomeAssistantError(node.value) + return parse_annotated_yaml(content, secrets) + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc def secret_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """Load secrets and embed it into the configuration YAML.""" - if loader.secrets is None: - raise HomeAssistantError("Secrets not supported in this YAML file") - - return loader.secrets.get(loader.get_name, node.value) - - -def add_constructor(tag: Any, constructor: Any) -> None: - """Add to constructor to all loaders.""" - for yaml_loader in (FastSafeLoader, PythonSafeLoader): - yaml_loader.add_constructor(tag, constructor) - - -add_constructor("!include", _include_yaml) -add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _handle_mapping_tag) -add_constructor(yaml.resolver.BaseResolver.DEFAULT_SCALAR_TAG, _handle_scalar_tag) -add_constructor(yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq) -add_constructor("!env_var", _env_var_yaml) -add_constructor("!secret", secret_yaml) -add_constructor("!include_dir_list", _include_dir_list_yaml) -add_constructor("!include_dir_merge_list", _include_dir_merge_list_yaml) -add_constructor("!include_dir_named", _include_dir_named_yaml) -add_constructor("!include_dir_merge_named", _include_dir_merge_named_yaml) -add_constructor("!input", Input.from_node) + try: + return annotated_secret_yaml(loader, node) + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index 7e4019331c6..26714b0fdd4 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -2,52 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any +from annotatedyaml import Input, NodeDictClass, NodeListClass, NodeStrClass -import voluptuous as vol -from voluptuous.schema_builder import _compile_scalar -import yaml - - -class NodeListClass(list): - """Wrapper class to be able to add attributes on a list.""" - - __slots__ = ("__config_file__", "__line__") - - __config_file__: str - __line__: int | str - - -class NodeStrClass(str): - """Wrapper class to be able to add attributes on a string.""" - - __slots__ = ("__config_file__", "__line__") - - __config_file__: str - __line__: int | str - - def __voluptuous_compile__(self, schema: vol.Schema) -> Any: - """Needed because vol.Schema.compile does not handle str subclasses.""" - return _compile_scalar(self) # type: ignore[no-untyped-call] - - -class NodeDictClass(dict): - """Wrapper class to be able to add attributes on a dict.""" - - __slots__ = ("__config_file__", "__line__") - - __config_file__: str - __line__: int | str - - -@dataclass(slots=True, frozen=True) -class Input: - """Input that should be substituted.""" - - name: str - - @classmethod - def from_node(cls, loader: yaml.Loader, node: yaml.nodes.Node) -> Input: - """Create a new placeholder from a node.""" - return cls(node.value) +__all__ = ["Input", "NodeDictClass", "NodeListClass", "NodeStrClass"] diff --git a/mypy.ini b/mypy.ini index a6203993c87..cf3314f515c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -945,6 +945,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bosch_alarm.*] +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.braviatv.*] 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.comelit.*] +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.command_line.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2436,6 +2456,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.immich.*] +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.incomfort.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2646,6 +2676,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.kulersky.*] +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.lacrosse.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3046,6 +3086,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.miele.*] +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.mikrotik.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3356,6 +3406,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ntfy.*] +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.number.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3376,6 +3436,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ohme.*] +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.onboarding.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3566,6 +3636,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pegel_online.*] +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.persistent_notification.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3716,6 +3796,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pyload.*] +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.python_script.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3856,6 +3946,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.remote_calendar.*] +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.renault.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -4006,16 +4106,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.rtsp_to_webrtc.*] -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.russound_rio.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -4306,6 +4396,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.smtp.*] +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.snooz.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -5039,6 +5139,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.vodafone_station.*] +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.wake_on_lan.*] 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 a4590207294..27ea23b0df3 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -50,6 +50,9 @@ class TypeHintMatch: kwargs_type: str | None = None """kwargs_type is for the special case `**kwargs`""" has_async_counterpart: bool = False + """`function_name` and `async_function_name` share arguments and return type""" + mandatory: bool = False + """bypass ignore_missing_annotations""" def need_to_check_function(self, node: nodes.FunctionDef) -> bool: """Confirm if function should be checked.""" @@ -184,6 +187,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type="bool", has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="async_setup_entry", @@ -192,6 +196,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigEntry", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="async_remove_entry", @@ -200,6 +205,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigEntry", }, return_type=None, + mandatory=True, ), TypeHintMatch( function_name="async_unload_entry", @@ -208,6 +214,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigEntry", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="async_migrate_entry", @@ -216,6 +223,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigEntry", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="async_remove_config_entry_device", @@ -225,6 +233,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 2: "DeviceEntry", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="async_reset_platform", @@ -233,6 +242,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "str", }, return_type=None, + mandatory=True, ), ], "__any_platform__": [ @@ -246,6 +256,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type=None, has_async_counterpart=True, + mandatory=True, ), TypeHintMatch( function_name="async_setup_entry", @@ -255,6 +266,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 2: "AddConfigEntryEntitiesCallback", }, return_type=None, + mandatory=True, ), ], "application_credentials": [ @@ -266,6 +278,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 2: "ClientCredential", }, return_type="AbstractOAuth2Implementation", + mandatory=True, ), TypeHintMatch( function_name="async_get_authorization_server", @@ -273,6 +286,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 0: "HomeAssistant", }, return_type="AuthorizationServer", + mandatory=True, ), ], "backup": [ @@ -282,6 +296,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 0: "HomeAssistant", }, return_type=None, + mandatory=True, ), TypeHintMatch( function_name="async_post_backup", @@ -289,6 +304,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 0: "HomeAssistant", }, return_type=None, + mandatory=True, ), ], "cast": [ @@ -299,6 +315,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "str", }, return_type="list[BrowseMedia]", + mandatory=True, ), TypeHintMatch( function_name="async_browse_media", @@ -309,6 +326,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 3: "str", }, return_type=["BrowseMedia", "BrowseMedia | None"], + mandatory=True, ), TypeHintMatch( function_name="async_play_media", @@ -320,6 +338,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 4: "str", }, return_type="bool", + mandatory=True, ), ], "config_flow": [ @@ -329,6 +348,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 0: "HomeAssistant", }, return_type="bool", + mandatory=True, ), ], "device_action": [ @@ -339,6 +359,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="ConfigType", + mandatory=True, ), TypeHintMatch( function_name="async_call_action_from_config", @@ -349,6 +370,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 3: "Context | None", }, return_type=None, + mandatory=True, ), TypeHintMatch( function_name="async_get_action_capabilities", @@ -357,6 +379,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="dict[str, Schema]", + mandatory=True, ), TypeHintMatch( function_name="async_get_actions", @@ -365,6 +388,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "str", }, return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + mandatory=True, ), ], "device_condition": [ @@ -375,6 +399,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="ConfigType", + mandatory=True, ), TypeHintMatch( function_name="async_condition_from_config", @@ -383,6 +408,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="ConditionCheckerType", + mandatory=True, ), TypeHintMatch( function_name="async_get_condition_capabilities", @@ -391,6 +417,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="dict[str, Schema]", + mandatory=True, ), TypeHintMatch( function_name="async_get_conditions", @@ -399,6 +426,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "str", }, return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + mandatory=True, ), ], "device_tracker": [ @@ -411,6 +439,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 3: "DiscoveryInfoType | None", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="async_setup_scanner", @@ -421,6 +450,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 3: "DiscoveryInfoType | None", }, return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="get_scanner", @@ -430,6 +460,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type=["DeviceScanner", None], has_async_counterpart=True, + mandatory=True, ), ], "device_trigger": [ @@ -440,6 +471,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="ConfigType", + mandatory=True, ), TypeHintMatch( function_name="async_attach_trigger", @@ -450,6 +482,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 3: "TriggerInfo", }, return_type="CALLBACK_TYPE", + mandatory=True, ), TypeHintMatch( function_name="async_get_trigger_capabilities", @@ -458,6 +491,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="dict[str, Schema]", + mandatory=True, ), TypeHintMatch( function_name="async_get_triggers", @@ -466,6 +500,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "str", }, return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + mandatory=True, ), ], "diagnostics": [ @@ -476,6 +511,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigEntry", }, return_type="Mapping[str, Any]", + mandatory=True, ), TypeHintMatch( function_name="async_get_device_diagnostics", @@ -485,6 +521,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 2: "DeviceEntry", }, return_type="Mapping[str, Any]", + mandatory=True, ), ], "notify": [ @@ -497,6 +534,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type=["BaseNotificationService", None], has_async_counterpart=True, + mandatory=True, ), ], } @@ -511,6 +549,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="async_step_*", arg_types={}, return_type="FlowResult", + mandatory=True, ), ], ), @@ -523,6 +562,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 0: "ConfigEntry", }, return_type="OptionsFlow", + mandatory=True, ), TypeHintMatch( function_name="async_step_dhcp", @@ -530,6 +570,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "DhcpServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_hassio", @@ -537,6 +578,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "HassioServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_homekit", @@ -544,6 +586,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "ZeroconfServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_mqtt", @@ -551,6 +594,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "MqttServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_reauth", @@ -558,6 +602,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "Mapping[str, Any]", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_ssdp", @@ -565,6 +610,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "SsdpServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_usb", @@ -572,6 +618,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "UsbServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_zeroconf", @@ -579,11 +626,13 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 1: "ZeroconfServiceInfo", }, return_type="ConfigFlowResult", + mandatory=True, ), TypeHintMatch( function_name="async_step_*", arg_types={}, return_type="ConfigFlowResult", + mandatory=True, ), ], ), @@ -594,6 +643,18 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="async_step_*", arg_types={}, return_type="ConfigFlowResult", + mandatory=True, + ), + ], + ), + ClassTypeHintMatch( + base_class="ConfigSubentryFlow", + matches=[ + TypeHintMatch( + function_name="async_step_*", + arg_types={}, + return_type="SubentryFlowResult", + mandatory=True, ), ], ), @@ -654,10 +715,12 @@ _ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( function_name="available", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="assumed_state", return_type="bool", + mandatory=True, ), TypeHintMatch( function_name="force_update", @@ -1364,7 +1427,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="location_accuracy", - return_type="int", + return_type="float", ), TypeHintMatch( function_name="location_name", @@ -2568,7 +2631,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="in_progress", - return_type=["bool", "int", None], + return_type=["bool", None], ), TypeHintMatch( function_name="latest_version", @@ -2590,6 +2653,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="title", return_type=["str", None], ), + TypeHintMatch( + function_name="update_percentage", + return_type=["int", "float", None], + ), TypeHintMatch( function_name="install", arg_types={1: "str | None", 2: "bool"}, @@ -3181,8 +3248,11 @@ class HassTypeHintChecker(BaseChecker): self._class_matchers.reverse() - def _ignore_function( - self, node: nodes.FunctionDef, annotations: list[nodes.NodeNG | None] + def _ignore_function_match( + self, + node: nodes.FunctionDef, + annotations: list[nodes.NodeNG | None], + match: TypeHintMatch, ) -> bool: """Check if we can skip the function validation.""" return ( @@ -3190,6 +3260,8 @@ class HassTypeHintChecker(BaseChecker): not self._in_test_module # some modules have checks forced and self._module_platform not in _FORCE_ANNOTATION_PLATFORMS + # some matches have checks forced + and not match.mandatory # other modules are only checked ignore_missing_annotations and self.linter.config.ignore_missing_annotations and node.returns is None @@ -3232,7 +3304,7 @@ class HassTypeHintChecker(BaseChecker): continue annotations = _get_all_annotations(function_node) - if self._ignore_function(function_node, annotations): + if self._ignore_function_match(function_node, annotations, match): continue self._check_function(function_node, match, annotations) @@ -3241,8 +3313,6 @@ class HassTypeHintChecker(BaseChecker): def visit_functiondef(self, node: nodes.FunctionDef) -> None: """Apply relevant type hint checks on a FunctionDef node.""" annotations = _get_all_annotations(node) - if self._ignore_function(node, annotations): - return # Check method or function matchers. if node.is_method(): @@ -3263,14 +3333,15 @@ class HassTypeHintChecker(BaseChecker): matchers = self._function_matchers # Check that common arguments are correctly typed. - for arg_name, expected_type in _COMMON_ARGUMENTS.items(): - arg_node, annotation = _get_named_annotation(node, arg_name) - if arg_node and not _is_valid_type(expected_type, annotation): - self.add_message( - "hass-argument-type", - node=arg_node, - args=(arg_name, expected_type, node.name), - ) + if not self.linter.config.ignore_missing_annotations: + for arg_name, expected_type in _COMMON_ARGUMENTS.items(): + arg_node, annotation = _get_named_annotation(node, arg_name) + if arg_node and not _is_valid_type(expected_type, annotation): + self.add_message( + "hass-argument-type", + node=arg_node, + args=(arg_name, expected_type, node.name), + ) for match in matchers: if not match.need_to_check_function(node): @@ -3285,6 +3356,8 @@ class HassTypeHintChecker(BaseChecker): match: TypeHintMatch, annotations: list[nodes.NodeNG | None], ) -> None: + if self._ignore_function_match(node, annotations, match): + return # Check that all positional arguments are correctly annotated. if match.arg_types: for key, expected_type in match.arg_types.items(): diff --git a/pyproject.toml b/pyproject.toml index eda2a495726..183ef236ef1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,97 +1,138 @@ [build-system] -requires = ["setuptools==75.1.0"] +requires = ["setuptools==78.1.1"] build-backend = "setuptools.build_meta" [project] -name = "homeassistant" -version = "2025.4.0.dev0" -license = {text = "Apache-2.0"} +name = "homeassistant" +version = "2025.6.0.dev0" +license = "Apache-2.0" +license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." -readme = "README.rst" -authors = [ - {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} +readme = "README.rst" +authors = [ + { name = "The Home Assistant Authors", email = "hello@home-assistant.io" }, ] -keywords = ["home", "automation"] +keywords = ["home", "automation"] classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: End Users/Desktop", - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.13", - "Topic :: Home Automation", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.13", + "Topic :: Home Automation", ] -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.3.0", - "aiohttp==3.11.13", - "aiohttp_cors==0.7.0", - "aiohttp-fast-zlib==0.2.3", - "aiohttp-asyncmdnsresolver==0.1.1", - "aiozoneinfo==0.2.3", - "astral==2.2", - "async-interrupt==1.2.2", - "attrs==25.1.0", - "atomicwrites-homeassistant==1.4.1", - "audioop-lts==0.2.1", - "awesomeversion==24.6.0", - "bcrypt==4.2.0", - "certifi>=2021.5.30", - "ciso8601==2.3.2", - "cronsim==2.6", - "fnv-hash-fast==1.2.6", - # hass-nabucasa is imported by helpers which don't depend on the cloud - # integration - "hass-nabucasa==0.92.0", - # When bumping httpx, please check the version pins of - # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.28.1", - "home-assistant-bluetooth==1.13.1", - "ifaddr==0.2.0", - "Jinja2==3.1.5", - "lru-dict==1.3.0", - "PyJWT==2.10.1", - # PyJWT has loose dependency. We want the latest one. - "cryptography==44.0.1", - "Pillow==11.1.0", - "propcache==0.3.0", - "pyOpenSSL==25.0.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==2025.2.1", - "SQLAlchemy==2.0.38", - "standard-aifc==3.13.0", - "standard-telnetlib==3.13.0", - "typing-extensions>=4.12.2,<5.0", - "ulid-transform==1.2.1", - # 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.6.1", - "voluptuous==0.15.2", - "voluptuous-serialize==2.6.0", - "voluptuous-openapi==0.0.6", - "yarl==1.18.3", - "webrtc-models==0.3.0", - "zeroconf==0.145.1" +requires-python = ">=3.13.2" +dependencies = [ + "aiodns==3.4.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.3.1", + "aiohttp==3.11.18", + "aiohttp_cors==0.7.0", + "aiohttp-fast-zlib==0.2.3", + "aiohttp-asyncmdnsresolver==0.1.1", + "aiozoneinfo==0.2.3", + "annotatedyaml==0.4.5", + "astral==2.2", + "async-interrupt==1.2.2", + "attrs==25.1.0", + "atomicwrites-homeassistant==1.4.1", + "audioop-lts==0.2.1", + "awesomeversion==24.6.0", + "bcrypt==4.2.0", + "certifi>=2021.5.30", + "ciso8601==2.3.2", + "cronsim==2.6", + "fnv-hash-fast==1.5.0", + # ha-ffmpeg is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->tts->ffmpeg. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "ha-ffmpeg==3.2.2", + # hass-nabucasa is imported by helpers which don't depend on the cloud + # integration + "hass-nabucasa==0.101.0", + # hassil is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "hassil==2.2.3", + # When bumping httpx, please check the version pins of + # httpcore, anyio, and h11 in gen_requirements_all + "httpx==0.28.1", + "home-assistant-bluetooth==1.13.1", + # home_assistant_intents is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->conversation->home_assistant_intents. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "home-assistant-intents==2025.5.7", + "ifaddr==0.2.0", + "Jinja2==3.1.6", + "lru-dict==1.3.0", + # mutagen is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->tts->mutagen. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "mutagen==1.47.0", + # numpy is indirectly imported from onboarding via the import chain + # onboarding->cloud->alexa->camera->stream->numpy. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "numpy==2.2.2", + "PyJWT==2.10.1", + # PyJWT has loose dependency. We want the latest one. + "cryptography==45.0.1", + "Pillow==11.2.1", + "propcache==0.3.1", + "pyOpenSSL==25.1.0", + "orjson==3.10.18", + "packaging>=23.1", + "psutil-home-assistant==0.0.1", + # pymicro_vad is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->pymicro_vad. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "pymicro-vad==1.0.1", + # pyspeex-noise is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->pyspeex_noise. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "pyspeex-noise==1.0.2", + "python-slugify==8.0.4", + # PyTurboJPEG is indirectly imported from onboarding via the import chain + # onboarding->cloud->camera->pyturbojpeg. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "PyTurboJPEG==1.7.5", + "PyYAML==6.0.2", + "requests==2.32.3", + "securetar==2025.2.1", + "SQLAlchemy==2.0.40", + "standard-aifc==3.13.0", + "standard-telnetlib==3.13.0", + "typing-extensions>=4.13.0,<5.0", + "ulid-transform==1.4.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.7.1", + "voluptuous==0.15.2", + "voluptuous-serialize==2.6.0", + "voluptuous-openapi==0.1.0", + "yarl==1.20.0", + "webrtc-models==0.3.0", + "zeroconf==0.147.0", ] [project.urls] -"Homepage" = "https://www.home-assistant.io/" +"Homepage" = "https://www.home-assistant.io/" "Source Code" = "https://github.com/home-assistant/core" "Bug Reports" = "https://github.com/home-assistant/core/issues" -"Docs: Dev" = "https://developers.home-assistant.io/" -"Discord" = "https://www.home-assistant.io/join-chat/" -"Forum" = "https://community.home-assistant.io/" +"Docs: Dev" = "https://developers.home-assistant.io/" +"Discord" = "https://www.home-assistant.io/join-chat/" +"Forum" = "https://community.home-assistant.io/" [project.scripts] hass = "homeassistant.__main__:main" @@ -118,30 +159,28 @@ init-hook = """\ ) \ """ load-plugins = [ - "pylint.extensions.code_style", - "pylint.extensions.typing", - "hass_decorator", - "hass_enforce_class_module", - "hass_enforce_sorted_platforms", - "hass_enforce_super_call", - "hass_enforce_type_hints", - "hass_inheritance", - "hass_imports", - "hass_logger", - "pylint_per_file_ignores", + "pylint.extensions.code_style", + "pylint.extensions.typing", + "hass_decorator", + "hass_enforce_class_module", + "hass_enforce_sorted_platforms", + "hass_enforce_super_call", + "hass_enforce_type_hints", + "hass_inheritance", + "hass_imports", + "hass_logger", + "pylint_per_file_ignores", ] persistent = false extension-pkg-allow-list = [ - "av.audio.stream", - "av.logging", - "av.stream", - "ciso8601", - "orjson", - "cv2", -] -fail-on = [ - "I", + "av.audio.stream", + "av.logging", + "av.stream", + "ciso8601", + "orjson", + "cv2", ] +fail-on = ["I"] [tool.pylint.BASIC] class-const-naming-style = "any" @@ -166,257 +205,257 @@ class-const-naming-style = "any" # consider-using-namedtuple-or-dataclass - too opinionated # consider-using-assignment-expr - decision to use := better left to devs disable = [ - "format", - "abstract-method", - "cyclic-import", - "duplicate-code", - "inconsistent-return-statements", - "locally-disabled", - "not-context-manager", - "too-few-public-methods", - "too-many-ancestors", - "too-many-arguments", - "too-many-instance-attributes", - "too-many-lines", - "too-many-locals", - "too-many-public-methods", - "too-many-boolean-expressions", - "too-many-positional-arguments", - "wrong-import-order", - "consider-using-namedtuple-or-dataclass", - "consider-using-assignment-expr", - "possibly-used-before-assignment", + "format", + "abstract-method", + "cyclic-import", + "duplicate-code", + "inconsistent-return-statements", + "locally-disabled", + "not-context-manager", + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-public-methods", + "too-many-boolean-expressions", + "too-many-positional-arguments", + "wrong-import-order", + "consider-using-namedtuple-or-dataclass", + "consider-using-assignment-expr", + "possibly-used-before-assignment", - # Handled by ruff - # Ref: - "await-outside-async", # PLE1142 - "bad-str-strip-call", # PLE1310 - "bad-string-format-type", # PLE1307 - "bidirectional-unicode", # PLE2502 - "continue-in-finally", # PLE0116 - "duplicate-bases", # PLE0241 - "misplaced-bare-raise", # PLE0704 - "format-needs-mapping", # F502 - "function-redefined", # F811 - # Needed because ruff does not understand type of __all__ generated by a function - # "invalid-all-format", # PLE0605 - "invalid-all-object", # PLE0604 - "invalid-character-backspace", # PLE2510 - "invalid-character-esc", # PLE2513 - "invalid-character-nul", # PLE2514 - "invalid-character-sub", # PLE2512 - "invalid-character-zero-width-space", # PLE2515 - "logging-too-few-args", # PLE1206 - "logging-too-many-args", # PLE1205 - "missing-format-string-key", # F524 - "mixed-format-string", # F506 - "no-method-argument", # N805 - "no-self-argument", # N805 - "nonexistent-operator", # B002 - "nonlocal-without-binding", # PLE0117 - "not-in-loop", # F701, F702 - "notimplemented-raised", # F901 - "return-in-init", # PLE0101 - "return-outside-function", # F706 - "syntax-error", # E999 - "too-few-format-args", # F524 - "too-many-format-args", # F522 - "too-many-star-expressions", # F622 - "truncated-format-string", # F501 - "undefined-all-variable", # F822 - "undefined-variable", # F821 - "used-prior-global-declaration", # PLE0118 - "yield-inside-async-function", # PLE1700 - "yield-outside-function", # F704 - "anomalous-backslash-in-string", # W605 - "assert-on-string-literal", # PLW0129 - "assert-on-tuple", # F631 - "bad-format-string", # W1302, F - "bad-format-string-key", # W1300, F - "bare-except", # E722 - "binary-op-exception", # PLW0711 - "cell-var-from-loop", # B023 - # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work - "duplicate-except", # B014 - "duplicate-key", # F601 - "duplicate-string-formatting-argument", # F - "duplicate-value", # F - "eval-used", # S307 - "exec-used", # S102 - "expression-not-assigned", # B018 - "f-string-without-interpolation", # F541 - "forgotten-debug-statement", # T100 - "format-string-without-interpolation", # F - # "global-statement", # PLW0603, ruff catches new occurrences, needs more work - "global-variable-not-assigned", # PLW0602 - "implicit-str-concat", # ISC001 - "import-self", # PLW0406 - "inconsistent-quotes", # Q000 - "invalid-envvar-default", # PLW1508 - "keyword-arg-before-vararg", # B026 - "logging-format-interpolation", # G - "logging-fstring-interpolation", # G - "logging-not-lazy", # G - "misplaced-future", # F404 - "named-expr-without-context", # PLW0131 - "nested-min-max", # PLW3301 - "pointless-statement", # B018 - "raise-missing-from", # B904 - "redefined-builtin", # A001 - "try-except-raise", # TRY302 - "unused-argument", # ARG001, we don't use it - "unused-format-string-argument", #F507 - "unused-format-string-key", # F504 - "unused-import", # F401 - "unused-variable", # F841 - "useless-else-on-loop", # PLW0120 - "wildcard-import", # F403 - "bad-classmethod-argument", # N804 - "consider-iterating-dictionary", # SIM118 - "empty-docstring", # D419 - "invalid-name", # N815 - "line-too-long", # E501, disabled globally - "missing-class-docstring", # D101 - "missing-final-newline", # W292 - "missing-function-docstring", # D103 - "missing-module-docstring", # D100 - "multiple-imports", #E401 - "singleton-comparison", # E711, E712 - "subprocess-run-check", # PLW1510 - "superfluous-parens", # UP034 - "ungrouped-imports", # I001 - "unidiomatic-typecheck", # E721 - "unnecessary-direct-lambda-call", # PLC3002 - "unnecessary-lambda-assignment", # PLC3001 - "unnecessary-pass", # PIE790 - "unneeded-not", # SIM208 - "useless-import-alias", # PLC0414 - "wrong-import-order", # I001 - "wrong-import-position", # E402 - "comparison-of-constants", # PLR0133 - "comparison-with-itself", # PLR0124 - "consider-alternative-union-syntax", # UP007 - "consider-merging-isinstance", # PLR1701 - "consider-using-alias", # UP006 - "consider-using-dict-comprehension", # C402 - "consider-using-generator", # C417 - "consider-using-get", # SIM401 - "consider-using-set-comprehension", # C401 - "consider-using-sys-exit", # PLR1722 - "consider-using-ternary", # SIM108 - "literal-comparison", # F632 - "property-with-parameters", # PLR0206 - "super-with-arguments", # UP008 - "too-many-branches", # PLR0912 - "too-many-return-statements", # PLR0911 - "too-many-statements", # PLR0915 - "trailing-comma-tuple", # COM818 - "unnecessary-comprehension", # C416 - "use-a-generator", # C417 - "use-dict-literal", # C406 - "use-list-literal", # C405 - "useless-object-inheritance", # UP004 - "useless-return", # PLR1711 - "no-else-break", # RET508 - "no-else-continue", # RET507 - "no-else-raise", # RET506 - "no-else-return", # RET505 - "broad-except", # BLE001 - "protected-access", # SLF001 - "broad-exception-raised", # TRY002 - "consider-using-f-string", # PLC0209 - # "no-self-use", # PLR6301 # Optional plugin, not enabled + # Handled by ruff + # Ref: + "await-outside-async", # PLE1142 + "bad-str-strip-call", # PLE1310 + "bad-string-format-type", # PLE1307 + "bidirectional-unicode", # PLE2502 + "continue-in-finally", # PLE0116 + "duplicate-bases", # PLE0241 + "misplaced-bare-raise", # PLE0704 + "format-needs-mapping", # F502 + "function-redefined", # F811 + # Needed because ruff does not understand type of __all__ generated by a function + # "invalid-all-format", # PLE0605 + "invalid-all-object", # PLE0604 + "invalid-character-backspace", # PLE2510 + "invalid-character-esc", # PLE2513 + "invalid-character-nul", # PLE2514 + "invalid-character-sub", # PLE2512 + "invalid-character-zero-width-space", # PLE2515 + "logging-too-few-args", # PLE1206 + "logging-too-many-args", # PLE1205 + "missing-format-string-key", # F524 + "mixed-format-string", # F506 + "no-method-argument", # N805 + "no-self-argument", # N805 + "nonexistent-operator", # B002 + "nonlocal-without-binding", # PLE0117 + "not-in-loop", # F701, F702 + "notimplemented-raised", # F901 + "return-in-init", # PLE0101 + "return-outside-function", # F706 + "syntax-error", # E999 + "too-few-format-args", # F524 + "too-many-format-args", # F522 + "too-many-star-expressions", # F622 + "truncated-format-string", # F501 + "undefined-all-variable", # F822 + "undefined-variable", # F821 + "used-prior-global-declaration", # PLE0118 + "yield-inside-async-function", # PLE1700 + "yield-outside-function", # F704 + "anomalous-backslash-in-string", # W605 + "assert-on-string-literal", # PLW0129 + "assert-on-tuple", # F631 + "bad-format-string", # W1302, F + "bad-format-string-key", # W1300, F + "bare-except", # E722 + "binary-op-exception", # PLW0711 + "cell-var-from-loop", # B023 + # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work + "duplicate-except", # B014 + "duplicate-key", # F601 + "duplicate-string-formatting-argument", # F + "duplicate-value", # F + "eval-used", # S307 + "exec-used", # S102 + "expression-not-assigned", # B018 + "f-string-without-interpolation", # F541 + "forgotten-debug-statement", # T100 + "format-string-without-interpolation", # F + # "global-statement", # PLW0603, ruff catches new occurrences, needs more work + "global-variable-not-assigned", # PLW0602 + "implicit-str-concat", # ISC001 + "import-self", # PLW0406 + "inconsistent-quotes", # Q000 + "invalid-envvar-default", # PLW1508 + "keyword-arg-before-vararg", # B026 + "logging-format-interpolation", # G + "logging-fstring-interpolation", # G + "logging-not-lazy", # G + "misplaced-future", # F404 + "named-expr-without-context", # PLW0131 + "nested-min-max", # PLW3301 + "pointless-statement", # B018 + "raise-missing-from", # B904 + "redefined-builtin", # A001 + "try-except-raise", # TRY302 + "unused-argument", # ARG001, we don't use it + "unused-format-string-argument", #F507 + "unused-format-string-key", # F504 + "unused-import", # F401 + "unused-variable", # F841 + "useless-else-on-loop", # PLW0120 + "wildcard-import", # F403 + "bad-classmethod-argument", # N804 + "consider-iterating-dictionary", # SIM118 + "empty-docstring", # D419 + "invalid-name", # N815 + "line-too-long", # E501, disabled globally + "missing-class-docstring", # D101 + "missing-final-newline", # W292 + "missing-function-docstring", # D103 + "missing-module-docstring", # D100 + "multiple-imports", #E401 + "singleton-comparison", # E711, E712 + "subprocess-run-check", # PLW1510 + "superfluous-parens", # UP034 + "ungrouped-imports", # I001 + "unidiomatic-typecheck", # E721 + "unnecessary-direct-lambda-call", # PLC3002 + "unnecessary-lambda-assignment", # PLC3001 + "unnecessary-pass", # PIE790 + "unneeded-not", # SIM208 + "useless-import-alias", # PLC0414 + "wrong-import-order", # I001 + "wrong-import-position", # E402 + "comparison-of-constants", # PLR0133 + "comparison-with-itself", # PLR0124 + "consider-alternative-union-syntax", # UP007 + "consider-merging-isinstance", # PLR1701 + "consider-using-alias", # UP006 + "consider-using-dict-comprehension", # C402 + "consider-using-generator", # C417 + "consider-using-get", # SIM401 + "consider-using-set-comprehension", # C401 + "consider-using-sys-exit", # PLR1722 + "consider-using-ternary", # SIM108 + "literal-comparison", # F632 + "property-with-parameters", # PLR0206 + "super-with-arguments", # UP008 + "too-many-branches", # PLR0912 + "too-many-return-statements", # PLR0911 + "too-many-statements", # PLR0915 + "trailing-comma-tuple", # COM818 + "unnecessary-comprehension", # C416 + "use-a-generator", # C417 + "use-dict-literal", # C406 + "use-list-literal", # C405 + "useless-object-inheritance", # UP004 + "useless-return", # PLR1711 + "no-else-break", # RET508 + "no-else-continue", # RET507 + "no-else-raise", # RET506 + "no-else-return", # RET505 + "broad-except", # BLE001 + "protected-access", # SLF001 + "broad-exception-raised", # TRY002 + "consider-using-f-string", # PLC0209 + # "no-self-use", # PLR6301 # Optional plugin, not enabled - # Handled by mypy - # Ref: - "abstract-class-instantiated", - "arguments-differ", - "assigning-non-slot", - "assignment-from-no-return", - "assignment-from-none", - "bad-exception-cause", - "bad-format-character", - "bad-reversed-sequence", - "bad-super-call", - "bad-thread-instantiation", - "catching-non-exception", - "comparison-with-callable", - "deprecated-class", - "dict-iter-missing-items", - "format-combined-specification", - "global-variable-undefined", - "import-error", - "inconsistent-mro", - "inherit-non-class", - "init-is-generator", - "invalid-class-object", - "invalid-enum-extension", - "invalid-envvar-value", - "invalid-format-returned", - "invalid-hash-returned", - "invalid-metaclass", - "invalid-overridden-method", - "invalid-repr-returned", - "invalid-sequence-index", - "invalid-slice-index", - "invalid-slots-object", - "invalid-slots", - "invalid-star-assignment-target", - "invalid-str-returned", - "invalid-unary-operand-type", - "invalid-unicode-codec", - "isinstance-second-argument-not-valid-type", - "method-hidden", - "misplaced-format-function", - "missing-format-argument-key", - "missing-format-attribute", - "missing-kwoa", - "no-member", - "no-value-for-parameter", - "non-iterator-returned", - "non-str-assignment-to-dunder-name", - "nonlocal-and-global", - "not-a-mapping", - "not-an-iterable", - "not-async-context-manager", - "not-callable", - "not-context-manager", - "overridden-final-method", - "raising-bad-type", - "raising-non-exception", - "redundant-keyword-arg", - "relative-beyond-top-level", - "self-cls-assignment", - "signature-differs", - "star-needs-assignment-target", - "subclassed-final-class", - "super-without-brackets", - "too-many-function-args", - "typevar-double-variance", - "typevar-name-mismatch", - "unbalanced-dict-unpacking", - "unbalanced-tuple-unpacking", - "unexpected-keyword-arg", - "unhashable-member", - "unpacking-non-sequence", - "unsubscriptable-object", - "unsupported-assignment-operation", - "unsupported-binary-operation", - "unsupported-delete-operation", - "unsupported-membership-test", - "used-before-assignment", - "using-final-decorator-in-unsupported-version", - "wrong-exception-operation", + # Handled by mypy + # Ref: + "abstract-class-instantiated", + "arguments-differ", + "assigning-non-slot", + "assignment-from-no-return", + "assignment-from-none", + "bad-exception-cause", + "bad-format-character", + "bad-reversed-sequence", + "bad-super-call", + "bad-thread-instantiation", + "catching-non-exception", + "comparison-with-callable", + "deprecated-class", + "dict-iter-missing-items", + "format-combined-specification", + "global-variable-undefined", + "import-error", + "inconsistent-mro", + "inherit-non-class", + "init-is-generator", + "invalid-class-object", + "invalid-enum-extension", + "invalid-envvar-value", + "invalid-format-returned", + "invalid-hash-returned", + "invalid-metaclass", + "invalid-overridden-method", + "invalid-repr-returned", + "invalid-sequence-index", + "invalid-slice-index", + "invalid-slots-object", + "invalid-slots", + "invalid-star-assignment-target", + "invalid-str-returned", + "invalid-unary-operand-type", + "invalid-unicode-codec", + "isinstance-second-argument-not-valid-type", + "method-hidden", + "misplaced-format-function", + "missing-format-argument-key", + "missing-format-attribute", + "missing-kwoa", + "no-member", + "no-value-for-parameter", + "non-iterator-returned", + "non-str-assignment-to-dunder-name", + "nonlocal-and-global", + "not-a-mapping", + "not-an-iterable", + "not-async-context-manager", + "not-callable", + "not-context-manager", + "overridden-final-method", + "raising-bad-type", + "raising-non-exception", + "redundant-keyword-arg", + "relative-beyond-top-level", + "self-cls-assignment", + "signature-differs", + "star-needs-assignment-target", + "subclassed-final-class", + "super-without-brackets", + "too-many-function-args", + "typevar-double-variance", + "typevar-name-mismatch", + "unbalanced-dict-unpacking", + "unbalanced-tuple-unpacking", + "unexpected-keyword-arg", + "unhashable-member", + "unpacking-non-sequence", + "unsubscriptable-object", + "unsupported-assignment-operation", + "unsupported-binary-operation", + "unsupported-delete-operation", + "unsupported-membership-test", + "used-before-assignment", + "using-final-decorator-in-unsupported-version", + "wrong-exception-operation", ] enable = [ - #"useless-suppression", # temporarily every now and then to clean them up - "use-symbolic-message-instead", + #"useless-suppression", # temporarily every now and then to clean them up + "use-symbolic-message-instead", ] per-file-ignores = [ - # redefined-outer-name: Tests reference fixtures in the test function - # use-implicit-booleaness-not-comparison: Tests need to validate that a list - # or a dict is returned - "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", + # redefined-outer-name: Tests reference fixtures in the test function + # use-implicit-booleaness-not-comparison: Tests need to validate that a list + # or a dict is returned + "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", ] [tool.pylint.REPORTS] @@ -424,7 +463,7 @@ score = false [tool.pylint.TYPECHECK] ignored-classes = [ - "_CountingAttr", # for attrs + "_CountingAttr", # for attrs ] mixin-class-rgx = ".*[Mm]ix[Ii]n" @@ -433,9 +472,9 @@ expected-line-ending-format = "LF" [tool.pylint.EXCEPTIONS] overgeneral-exceptions = [ - "builtins.BaseException", - "builtins.Exception", - # "homeassistant.exceptions.HomeAssistantError", # too many issues + "builtins.BaseException", + "builtins.Exception", + # "homeassistant.exceptions.HomeAssistantError", # too many issues ] [tool.pylint.TYPING] @@ -445,241 +484,200 @@ runtime-typing = false max-line-length-suggestions = 72 [tool.pytest.ini_options] -testpaths = [ - "tests", -] -norecursedirs = [ - ".git", - "testing_config", -] +testpaths = ["tests"] +norecursedirs = [".git", "testing_config"] log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [ - "error::sqlalchemy.exc.SAWarning", + "error::sqlalchemy.exc.SAWarning", - # -- HomeAssistant - aiohttp - # Overwrite web.Application to pass a custom default argument to _make_request - "ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning", - # Hass wraps `ClientSession.close` to emit a warning if the session is closed accidentally - "ignore:Setting custom ClientSession.close attribute is discouraged:DeprecationWarning:homeassistant.helpers.aiohttp_client", - # Modify app state for testing - "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", + # -- HomeAssistant - aiohttp + # Overwrite web.Application to pass a custom default argument to _make_request + "ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning", + # Hass wraps `ClientSession.close` to emit a warning if the session is closed accidentally + "ignore:Setting custom ClientSession.close attribute is discouraged:DeprecationWarning:homeassistant.helpers.aiohttp_client", + # Modify app state for testing + "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", - # -- Tests - # Ignore custom pytest marks - "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", - "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", - # https://github.com/rokam/sunweg/blob/3.1.0/sunweg/plant.py#L96 - v3.1.0 - 2024-10-02 - "ignore:The '(kwh_per_kwp|performance_rate)' property is deprecated and will return 0:DeprecationWarning:tests.components.sunweg.test_init", + # -- Tests + # Ignore custom pytest marks + "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", + "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", - # -- design choice 3rd party - # https://github.com/gwww/elkm1/blob/2.2.10/elkm1_lib/util.py#L8-L19 - "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", - # https://github.com/allenporter/ical/pull/215 - # https://github.com/allenporter/ical/blob/8.2.0/ical/util.py#L21-L23 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", - # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 - "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", + # -- DeprecationWarning already fixed in our codebase + # https://github.com/kurtmckee/feedparser/pull/389 - 6.0.11 + "ignore:.*a temporary mapping .* from `updated_parsed` to `published_parsed` if `updated_parsed` doesn't exist:DeprecationWarning:feedparser.util", - # -- Setuptools DeprecationWarnings - # https://github.com/googleapis/google-cloud-python/issues/11184 - # https://github.com/zopefoundation/meta/issues/194 - # https://github.com/Azure/azure-sdk-for-python - "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", + # -- design choice 3rd party + # https://github.com/gwww/elkm1/blob/2.2.11/elkm1_lib/util.py#L8-L19 + "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", + # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 + "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", - # -- tracked upstream / open PRs - # - pyOpenSSL v24.2.1 - # https://github.com/certbot/certbot/issues/9828 - v2.11.0 - # https://github.com/certbot/certbot/issues/9992 - "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", - # - other - # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 - # https://github.com/foxel/python_ndms2_client/pull/8 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", + # -- Setuptools DeprecationWarnings + # https://github.com/googleapis/google-cloud-python/issues/11184 + # https://github.com/zopefoundation/meta/issues/194 + # https://github.com/Azure/azure-sdk-for-python + "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", - # -- fixed, waiting for release / update - # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", - # https://bugs.launchpad.net/beautifulsoup/+bug/2076897 - >4.12.3 - "ignore:The 'strip_cdata' option of HTMLParser\\(\\) has never done anything and will eventually be removed:DeprecationWarning:bs4.builder._lxml", - # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 - "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", - # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", - # https://github.com/fwestenberg/devialet/pull/6 - >1.4.5 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api", - # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", - # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0 - # https://github.com/influxdata/influxdb-client-python/pull/652 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", - # https://github.com/majuss/lupupy/pull/15 - >0.3.2 - "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", - # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", - # https://github.com/eclipse/paho.mqtt.python/issues/653 - >=2.0.0 - # https://github.com/eclipse/paho.mqtt.python/pull/665 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", - # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 - "ignore::DeprecationWarning:holidays", - # 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/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 - "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", + # -- tracked upstream / open PRs + # https://github.com/hacf-fr/meteofrance-api/pull/688 - v1.4.0 - 2025-03-26 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteofrance_api.model.forecast", - # -- fixed for Python 3.13 - # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:wyoming.audio", + # -- fixed, waiting for release / update + # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 + "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", + # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", + # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", + # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0 + # https://github.com/influxdata/influxdb-client-python/pull/652 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", + # https://github.com/majuss/lupupy/pull/15 - >0.3.2 + "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", + # https://github.com/nextcord/nextcord/pull/1095 - >=3.0.0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", + # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 + "ignore::DeprecationWarning:holidays", + # 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/rytilahti/python-miio/pull/1993 - >0.6.0.dev0 + "ignore:functools.partial will be a method descriptor in future Python versions; wrap it in enum.member\\(\\) if you want to preserve the old behavior:FutureWarning:miio.miot_device", + # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 + "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", - # -- other - # Locale changes might take some time to resolve upstream - # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08 - "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", - # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", - # https://github.com/lidatong/dataclasses-json/issues/328 - # https://github.com/lidatong/dataclasses-json/pull/351 - "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm", - # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 - # https://github.com/martonperei/emulated_roku - "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", - # https://github.com/w1ll1am23/pyeconet/blob/v0.1.23/src/pyeconet/api.py#L38 - v0.1.23 - 2024-10-08 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", - # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 - "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", - # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", - # https://github.com/lextudio/pysnmp/blob/v7.1.10/pysnmp/smi/compiler.py#L23-L31 - v7.1.10 - 2024-11-04 - "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", - "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysmi.reader.url", # wrong stacklevel - # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 - "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", - # Wrong stacklevel - # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 fixed in >4.12.3 - "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:html.parser", - # New in aiohttp - v3.9.0 - "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", - # - SyntaxWarnings - # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 - "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", - # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24 - # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789 - "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera", - # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 - # https://github.com/koolsb/pyblackbird/pull/9 -> closed - "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", - # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", - # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 - # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42 - "ignore:invalid escape sequence:SyntaxWarning:.*sanix", - # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 - "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty - # - pkg_resources - # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", - # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", - # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", - # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", - # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", + # -- other + # Locale changes might take some time to resolve upstream + # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08 + "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", + # https://pypi.org/project/agent-py/ - v0.0.24 - 2024-11-07 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", + # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", + # https://github.com/lidatong/dataclasses-json/issues/328 + # https://github.com/lidatong/dataclasses-json/pull/351 + "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm", + # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 + # https://github.com/martonperei/emulated_roku + "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", + # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/pyeconet/ - v0.1.28 - 2025-02-15 + # https://github.com/w1ll1am23/pyeconet/blob/v0.1.28/src/pyeconet/api.py#L38 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", + # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 + "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", + # https://pypi.org/project/PyMetEireann/ - v2024.11.0 - 2024-11-23 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", + # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", + # https://github.com/lextudio/pysnmp/blob/v7.1.17/pysnmp/smi/compiler.py#L23-L31 - v7.1.17 - 2025-03-19 + "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", + "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysnmp.smi.compiler", + # https://github.com/Python-roborock/python-roborock/issues/305 - 2.18.0 - 2025-04-06 + "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api", + # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 + "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", + # New in aiohttp - v3.9.0 + "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", + # - SyntaxWarnings + # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 + "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", + # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24 + # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789 + "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera", + # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 + # https://github.com/koolsb/pyblackbird/pull/9 -> closed + "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", + # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 + "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", + # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 + # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42 + "ignore:invalid escape sequence:SyntaxWarning:.*sanix", + # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 + "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty + # - pkg_resources + # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", + # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", + # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", + # https://pypi.org/project/pybotvac/ - v0.0.26 - 2025-02-26 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", + # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", - # -- Python 3.13 - # HomeAssistant - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api", - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.hddtemp.sensor", - # https://pypi.org/project/nextcord/ - v2.6.0 - 2023-09-23 - # https://github.com/nextcord/nextcord/issues/1174 - # https://github.com/nextcord/nextcord/blob/v2.6.1/nextcord/player.py#L5 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nextcord.player", - # https://pypi.org/project/SpeechRecognition/ - v3.11.0 - 2024-05-05 - # https://github.com/Uberi/speech_recognition/blob/3.11.0/speech_recognition/__init__.py#L7 - "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", - # https://pypi.org/project/voip-utils/ - v0.2.0 - 2024-09-06 - # https://github.com/home-assistant-libs/voip-utils/blob/0.2.0/voip_utils/rtp_audio.py#L3 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio", + # -- New in Python 3.13 + # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 + # https://github.com/kurtmckee/feedparser/issues/481 + "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html", + # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib + "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", - # -- Python 3.13 - unmaintained projects, last release about 2+ years - # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pydub.utils", - # https://github.com/heathbar/plum-lightpad-python/issues/7 - v0.0.11 - 2018-10-16 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:plumlightpad.lightpad", - # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i", + # -- Websockets 14.1 + # https://websockets.readthedocs.io/en/stable/howto/upgrade.html + "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", + # https://github.com/bluecurrent/HomeAssistantAPI + "ignore:websockets.client.connect is deprecated:DeprecationWarning:bluecurrent_api.websocket", + "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:bluecurrent_api.websocket", + "ignore:websockets.exceptions.InvalidStatusCode is deprecated:DeprecationWarning:bluecurrent_api.websocket", + # https://github.com/graphql-python/gql + "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", - # -- New in Python 3.13 - # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 - # https://github.com/kurtmckee/feedparser/issues/481 - "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html", - # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib - "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", - - # -- unmaintained projects, last release about 2+ years - # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", - # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", - # https://pypi.org/project/alarmdecoder/ - v1.13.11 - 2021-06-01 - "ignore:invalid escape sequence:SyntaxWarning:.*alarmdecoder", - # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", - # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", - # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", - # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", - # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` - # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 - # https://github.com/vaidik/commentjson/issues/51 - # https://github.com/vaidik/commentjson/pull/52 - # Fixed upstream, commentjson depends on old version and seems to be unmaintained - "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", - # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", - # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", - # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 - "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", - # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 - "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", - # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight", - # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 - "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)", - # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05 - "ignore:invalid escape sequence:SyntaxWarning:.*ppadb", - # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", - # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", - # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", - # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 - "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", - # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 - "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", - # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants", - # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 - "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", + # -- unmaintained projects, last release about 2+ years + # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", + # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", + # https://pypi.org/project/enocean/ - v0.50.1 (installed) -> v0.60.1 - 2021-06-18 + "ignore:It looks like you're using an HTML parser to parse an XML document:UserWarning:enocean.protocol.eep", + # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", + # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", + # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` + # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 + # https://github.com/vaidik/commentjson/issues/51 + # https://github.com/vaidik/commentjson/pull/52 + # Fixed upstream, commentjson depends on old version and seems to be unmaintained + "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", + # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", + # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", + # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 + "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", + # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight", + # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 + "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", + "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)", + # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05 + "ignore:invalid escape sequence:SyntaxWarning:.*ppadb", + # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 + "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", + # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 + "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", + # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 + "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", + # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 + "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", + # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants", + # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 + "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", ] [tool.coverage.run] @@ -687,177 +685,175 @@ source = ["homeassistant"] [tool.coverage.report] exclude_lines = [ - # Have to re-enable the standard pragma - "pragma: no cover", - # Don't complain about missing debug-only code: - "def __repr__", - # Don't complain if tests don't hit defensive assertion code: - "raise AssertionError", - "raise NotImplementedError", - # TYPE_CHECKING and @overload blocks are never executed during pytest run - "if TYPE_CHECKING:", - "@overload", + # Have to re-enable the standard pragma + "pragma: no cover", + # Don't complain about missing debug-only code: + "def __repr__", + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + # TYPE_CHECKING and @overload blocks are never executed during pytest run + "if TYPE_CHECKING:", + "@overload", ] [tool.ruff] -required-version = ">=0.9.1" +required-version = ">=0.11.0" [tool.ruff.lint] select = [ - "A001", # Variable {name} is shadowing a Python builtin - "ASYNC210", # Async functions should not call blocking HTTP methods - "ASYNC220", # Async functions should not create subprocesses with blocking methods - "ASYNC221", # Async functions should not run processes with blocking methods - "ASYNC222", # Async functions should not wait on processes with blocking methods - "ASYNC230", # Async functions should not open files with blocking methods like open - "ASYNC251", # Async functions should not call time.sleep - "B002", # Python does not support the unary prefix increment - "B005", # Using .strip() with multi-character strings is misleading - "B007", # Loop control variable {name} not used within loop body - "B014", # Exception handler with duplicate exception - "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. - "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", - "C", # complexity - "COM818", # Trailing comma on bare tuple prohibited - "D", # docstrings - "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() - "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) - "E", # pycodestyle - "F", # pyflakes/autoflake - "F541", # f-string without any placeholders - "FLY", # flynt - "FURB", # refurb - "G", # flake8-logging-format - "I", # isort - "INP", # flake8-no-pep420 - "ISC", # flake8-implicit-str-concat - "ICN001", # import concentions; {name} should be imported as {asname} - "LOG", # flake8-logging - "N804", # First argument of a class method should be named cls - "N805", # First argument of a method should be named self - "N815", # Variable {name} in class scope should not be mixedCase - "PERF", # Perflint - "PGH", # pygrep-hooks - "PIE", # flake8-pie - "PL", # pylint - "PT", # flake8-pytest-style - "PTH", # flake8-pathlib - "PYI", # flake8-pyi - "RET", # flake8-return - "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 - "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 - "S306", # suspicious-mktemp-usage - "S307", # suspicious-eval-usage - "S313", # suspicious-xmlc-element-tree-usage - "S314", # suspicious-xml-element-tree-usage - "S315", # suspicious-xml-expat-reader-usage - "S316", # suspicious-xml-expat-builder-usage - "S317", # suspicious-xml-sax-usage - "S318", # suspicious-xml-mini-dom-usage - "S319", # suspicious-xml-pull-dom-usage - "S320", # suspicious-xmle-tree-usage - "S601", # paramiko-call - "S602", # subprocess-popen-with-shell-equals-true - "S604", # call-with-shell-equals-true - "S608", # hardcoded-sql-expression - "S609", # unix-command-wildcard-injection - "SIM", # flake8-simplify - "SLF", # flake8-self - "SLOT", # flake8-slots - "T100", # Trace found: {name} used - "T20", # flake8-print - "TC", # flake8-type-checking - "TID", # Tidy imports - "TRY", # tryceratops - "UP", # pyupgrade - "UP031", # Use format specifiers instead of percent format - "UP032", # Use f-string instead of `format` call - "W", # pycodestyle + "A001", # Variable {name} is shadowing a Python builtin + "ASYNC", # flake8-async + "B002", # Python does not support the unary prefix increment + "B005", # Using .strip() with multi-character strings is misleading + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. + "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", + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "E", # pycodestyle + "F", # pyflakes/autoflake + "F541", # f-string without any placeholders + "FLY", # flynt + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat + "ICN001", # import concentions; {name} should be imported as {asname} + "LOG", # flake8-logging + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N815", # Variable {name} in class scope should not be mixedCase + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-pathlib + "PYI", # flake8-pyi + "RET", # flake8-return + "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 + "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 + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T100", # Trace found: {name} used + "T20", # flake8-print + "TC", # flake8-type-checking + "TID", # Tidy imports + "TRY", # tryceratops + "UP", # pyupgrade + "UP031", # Use format specifiers instead of percent format + "UP032", # Use f-string instead of `format` call + "W", # pycodestyle ] ignore = [ - "D202", # No blank lines allowed after function docstring - "D203", # 1 blank line required before class docstring - "D213", # Multi-line docstring summary should start at the second line - "D406", # Section name should end with a newline - "D407", # Section name underlining - "E501", # line too long + "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead + "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long - "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives - "PLR0911", # Too many return statements ({returns} > {max_returns}) - "PLR0912", # Too many branches ({branches} > {max_branches}) - "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) - "PLR0915", # Too many statements ({statements} > {max_statements}) - "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable - "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target - "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception - "PT018", # Assertion should be broken down into multiple parts - "RUF001", # String contains ambiguous unicode character. - "RUF002", # Docstring contains ambiguous unicode character. - "RUF003", # Comment contains ambiguous unicode character. - "RUF015", # Prefer next(...) over single element slice - "SIM102", # Use a single if statement instead of nested if statements - "SIM103", # Return the condition {condition} directly - "SIM108", # Use ternary operator {contents} instead of if-else-block - "SIM115", # Use context handler for opening files + "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives + "PLR0911", # Too many return statements ({returns} > {max_returns}) + "PLR0912", # Too many branches ({branches} > {max_branches}) + "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) + "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target + "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception + "PT018", # Assertion should be broken down into multiple parts + "RUF001", # String contains ambiguous unicode character. + "RUF002", # Docstring contains ambiguous unicode character. + "RUF003", # Comment contains ambiguous unicode character. + "RUF015", # Prefer next(...) over single element slice + "SIM102", # Use a single if statement instead of nested if statements + "SIM103", # Return the condition {condition} directly + "SIM108", # Use ternary operator {contents} instead of if-else-block + "SIM115", # Use context handler for opening files - # Moving imports into type-checking blocks can mess with pytest.patch() - "TC001", # Move application import {} into a type-checking block - "TC002", # Move third-party import {} into a type-checking block - "TC003", # Move standard library import {} into a type-checking block + # Moving imports into type-checking blocks can mess with pytest.patch() + "TC001", # Move application import {} into a type-checking block + "TC002", # Move third-party import {} into a type-checking block + "TC003", # Move standard library import {} into a type-checking block + # Quotes for typing.cast generally not necessary, only for performance critical paths + "TC006", # Add quotes to type expression in typing.cast() - "TRY003", # Avoid specifying long messages outside the exception class - "TRY400", # Use `logging.exception` instead of `logging.error` - # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 - "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + "TRY003", # Avoid specifying long messages outside the exception class + "TRY400", # Use `logging.exception` instead of `logging.error` + # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` - # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules - "W191", - "E111", - "E114", - "E117", - "D206", - "D300", - "Q", - "COM812", - "COM819", + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q", + "COM812", + "COM819", - # Disabled because ruff does not understand type of __all__ generated by a function - "PLE0605" + # Disabled because ruff does not understand type of __all__ generated by a function + "PLE0605", ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] @@ -933,9 +929,7 @@ mark-parentheses = false [tool.ruff.lint.isort] force-sort-within-sections = true -known-first-party = [ - "homeassistant", -] +known-first-party = ["homeassistant"] combine-as-imports = true split-on-trailing-comma = false diff --git a/requirements.txt b/requirements.txt index b378688106d..7d15999bb38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,13 +3,14 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiodns==3.2.0 -aiohasupervisor==0.3.0 -aiohttp==3.11.13 +aiodns==3.4.0 +aiohasupervisor==0.3.1 +aiohttp==3.11.18 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 +annotatedyaml==0.4.5 astral==2.2 async-interrupt==1.2.2 attrs==25.1.0 @@ -20,35 +21,43 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -fnv-hash-fast==1.2.6 -hass-nabucasa==0.92.0 +fnv-hash-fast==1.5.0 +ha-ffmpeg==3.2.2 +hass-nabucasa==0.101.0 +hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 +home-assistant-intents==2025.5.7 ifaddr==0.2.0 -Jinja2==3.1.5 +Jinja2==3.1.6 lru-dict==1.3.0 +mutagen==1.47.0 +numpy==2.2.2 PyJWT==2.10.1 -cryptography==44.0.1 -Pillow==11.1.0 -propcache==0.3.0 -pyOpenSSL==25.0.0 -orjson==3.10.12 +cryptography==45.0.1 +Pillow==11.2.1 +propcache==0.3.1 +pyOpenSSL==25.1.0 +orjson==3.10.18 packaging>=23.1 psutil-home-assistant==0.0.1 +pymicro-vad==1.0.1 +pyspeex-noise==1.0.2 python-slugify==8.0.4 +PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 -SQLAlchemy==2.0.38 +SQLAlchemy==2.0.40 standard-aifc==3.13.0 standard-telnetlib==3.13.0 -typing-extensions>=4.12.2,<5.0 -ulid-transform==1.2.1 +typing-extensions>=4.13.0,<5.0 +ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.1 +uv==0.7.1 voluptuous==0.15.2 voluptuous-serialize==2.6.0 -voluptuous-openapi==0.0.6 -yarl==1.18.3 +voluptuous-openapi==0.1.0 +yarl==1.20.0 webrtc-models==0.3.0 -zeroconf==0.145.1 +zeroconf==0.147.0 diff --git a/requirements_all.txt b/requirements_all.txt index d1186a9d1a7..b5c2036ba13 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==1.8.1 +Mastodon.py==2.0.1 # homeassistant.components.doods # homeassistant.components.generic @@ -33,7 +33,7 @@ Mastodon.py==1.8.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.1.0 +Pillow==11.2.1 # homeassistant.components.plex PlexAPI==4.15.16 @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==14.0.5 +PyChromecast==14.0.7 # homeassistant.components.flick_electric PyFlick==1.1.3 @@ -54,7 +54,7 @@ PyFlick==1.1.3 PyFlume==0.6.5 # homeassistant.components.fronius -PyFronius==0.7.3 +PyFronius==0.7.7 # homeassistant.components.pyload PyLoadAPI==1.4.2 @@ -69,9 +69,6 @@ PyMetno==0.13.0 # homeassistant.components.keymitt_ble PyMicroBot==0.0.17 -# homeassistant.components.nina -PyNINA==0.3.4 - # homeassistant.components.mobile_app # homeassistant.components.owntracks PyNaCl==1.5.0 @@ -84,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.0 +PySwitchbot==0.62.2 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -100,7 +97,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.43.1 +PyViCare==2.44.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -116,7 +113,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.38 +SQLAlchemy==2.0.40 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 @@ -131,7 +128,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.1.0 +accuweather==4.2.0 # homeassistant.components.adax adax==0.4.0 @@ -179,10 +176,10 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.10 +aioairzone-cloud==0.6.12 # homeassistant.components.airzone -aioairzone==0.9.9 +aioairzone==1.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -201,7 +198,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.1.1 +aioautomower==2025.5.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -210,22 +207,23 @@ aioazuredevops==2.2.1 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.13.1 +# homeassistant.components.aws_s3 +aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==0.10.1 +aiocomelit==0.12.3 # homeassistant.components.dhcp -aiodhcpwatcher==1.1.1 +aiodhcpwatcher==1.2.0 # homeassistant.components.dhcp -aiodiscover==2.6.1 +aiodiscover==2.7.0 # homeassistant.components.dnsip -aiodns==3.2.0 +aiodns==3.4.0 # homeassistant.components.duke_energy -aiodukeenergy==0.2.2 +aiodukeenergy==0.3.0 # homeassistant.components.eafm aioeafm==0.1.2 @@ -234,7 +232,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.1 +aioecowitt==2025.3.1 # homeassistant.components.co2signal aioelectricitymaps==0.4.0 @@ -243,7 +241,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.2.0 +aioesphomeapi==31.1.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -258,16 +256,16 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.4.1 +aioharmony==0.5.2 # homeassistant.components.hassio -aiohasupervisor==0.3.0 +aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.15.0 +aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.14 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 @@ -278,12 +276,18 @@ aiohue==4.7.4 # homeassistant.components.imap aioimaplib==2.0.1 +# homeassistant.components.immich +aioimmich==0.5.0 + # homeassistant.components.apache_kafka aiokafka==0.10.0 # homeassistant.components.kef aiokef==0.2.16 +# homeassistant.components.rehlko +aiokem==0.5.10 + # homeassistant.components.lifx aiolifx-effects==0.3.2 @@ -291,7 +295,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.2 +aiolifx==1.1.4 # homeassistant.components.lookin aiolookin==1.0.0 @@ -314,12 +318,12 @@ aionanoleaf==0.2.1 # homeassistant.components.notion aionotion==2024.03.0 +# homeassistant.components.ntfy +aiontfy==0.5.2 + # homeassistant.components.nut aionut==4.3.4 -# homeassistant.components.oncue -aiooncue==0.3.9 - # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -362,7 +366,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.4.0 +aiorussound==4.5.2 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -371,7 +375,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.0.0 +aioshelly==13.6.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -404,7 +408,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==82 +aiounifi==83 # homeassistant.components.usb aiousbwatcher==1.1.1 @@ -413,7 +417,7 @@ aiousbwatcher==1.1.1 aiovlc==0.5.1 # homeassistant.components.vodafone_station -aiovodafone==0.6.1 +aiovodafone==0.10.0 # homeassistant.components.waqi aiowaqi==3.1.0 @@ -422,10 +426,10 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.0 +aiowebdav2==0.4.5 # homeassistant.components.webostv -aiowebostv==0.7.1 +aiowebostv==0.7.3 # homeassistant.components.withings aiowithings==3.1.6 @@ -464,7 +468,7 @@ amcrest==1.9.8 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.0 +androidtvremote2==0.2.1 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 @@ -479,7 +483,7 @@ anthemav==1.4.1 anthropic==0.47.2 # homeassistant.components.mcp_server -anyio==4.8.0 +anyio==4.9.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 @@ -491,7 +495,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.4.0 +apsystems-ez1==2.6.0 # homeassistant.components.aqualogic aqualogic==2.6 @@ -514,7 +518,7 @@ asmog==0.0.6 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -541,7 +545,7 @@ aurorapy==0.2.7 autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble -automower-ble==0.2.0 +automower-ble==0.2.1 # homeassistant.components.generic # homeassistant.components.stream @@ -557,7 +561,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.5 +ayla-iot-unofficial==1.4.7 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -590,7 +594,7 @@ batinfo==0.4.2 # beacontools[scan]==2.1.0 # homeassistant.components.scrape -beautifulsoup4==4.12.3 +beautifulsoup4==4.13.3 # homeassistant.components.beewi_smartclim # beewi-smartclim==0.0.10 @@ -603,7 +607,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.1 +bleak-esphome==2.15.1 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 @@ -624,39 +628,41 @@ blockchain==1.4.4 bluecurrent-api==1.2.3 # homeassistant.components.bluemaestro -bluemaestro-ble==0.2.3 +bluemaestro-ble==0.4.1 # homeassistant.components.decora -# homeassistant.components.zengge # bluepy==1.3.0 # homeassistant.components.bluetooth bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.4 +bluetooth-auto-recovery==1.5.1 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.4 +bluetooth-data-tools==1.28.1 # homeassistant.components.bond bond-async==0.2.1 +# homeassistant.components.bosch_alarm +bosch-alarm-mode2==0.4.6 + # homeassistant.components.bosch_shc boschshcpy==0.2.91 # homeassistant.components.amazon_polly # homeassistant.components.route53 -boto3==1.34.131 +boto3==1.37.1 # homeassistant.components.aws -botocore==1.34.131 +botocore==1.37.1 # homeassistant.components.bring -bring-api==1.0.2 +bring-api==1.1.0 # homeassistant.components.broadlink broadlink==0.19.0 @@ -686,7 +692,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.9.2 +cached-ipaddress==0.10.0 # homeassistant.components.caldav caldav==1.3.9 @@ -707,7 +713,7 @@ coinbase-advanced-py==1.2.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.8.2 +colorlog==6.9.0 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -744,10 +750,10 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.33.0 +dbus-fast==2.43.0 # homeassistant.components.debugpy -debugpy==1.8.11 +debugpy==1.8.14 # homeassistant.components.decora_wifi # decora-wifi==1.4 @@ -756,7 +762,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.2.0 +deebot-client==13.2.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -770,19 +776,19 @@ deluge-client==1.10.2 demetriek==1.2.0 # homeassistant.components.denonavr -denonavr==1.0.1 +denonavr==1.1.1 # homeassistant.components.devialet devialet==1.5.7 # homeassistant.components.devolo_home_control -devolo-home-control-api==0.18.3 +devolo-home-control-api==0.19.0 # homeassistant.components.devolo_home_network -devolo-plc-api==1.4.1 +devolo-plc-api==1.5.1 # homeassistant.components.chacon_dio -dio-chacon-wifi-api==1.2.1 +dio-chacon-wifi-api==1.2.2 # homeassistant.components.directv directv==0.4.0 @@ -800,7 +806,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.4.2 +dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 @@ -827,7 +833,7 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.0.6 +eheimdigital==1.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -869,7 +875,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.10.2 # homeassistant.components.season ephem==4.1.6 @@ -887,7 +893,7 @@ epson-projector==0.5.1 eq3btsmart==1.4.1 # homeassistant.components.esphome -esphome-dashboard-api==1.2.3 +esphome-dashboard-api==1.3.0 # homeassistant.components.netgear_lte eternalegypt==0.0.16 @@ -899,7 +905,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==1.0.2 +evohome-async==1.0.5 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 @@ -942,17 +948,17 @@ flexit_bacnet==2.2.3 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.3 +flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.6 +fnv-hash-fast==1.5.0 # homeassistant.components.foobot foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==4.0.0 +forecast-solar==4.2.0 # homeassistant.components.fortios fortiosapi==1.0.5 @@ -968,25 +974,25 @@ freesms==0.2.0 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.7.0 +fyta_cli==0.7.2 # homeassistant.components.google_translate gTTS==2.5.3 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.5.0 +gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.11 +gassist-text==0.0.12 # homeassistant.components.google -gcal-sync==7.0.0 +gcal-sync==7.0.1 # homeassistant.components.geniushub geniushub-client==0.7.1 # homeassistant.components.geocaching -geocachingapi==0.2.1 +geocachingapi==0.3.0 # homeassistant.components.aprs geopy==2.3.0 @@ -1008,7 +1014,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==5.0.0 +gios==6.0.0 # homeassistant.components.gitter gitterpy==0.1.7 @@ -1017,7 +1023,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.2 +go2rtc-client==0.1.3b0 # homeassistant.components.goalzero goalzero==0.2.2 @@ -1030,26 +1036,26 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.28.0 +google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud -google-cloud-speech==2.27.0 +google-cloud-speech==2.31.1 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.17.2 +google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation -google-genai==1.1.0 +google-genai==1.7.0 + +# homeassistant.components.google_travel_time +google-maps-routing==0.6.15 # homeassistant.components.nest -google-nest-sdm==7.1.3 +google-nest-sdm==7.1.4 # homeassistant.components.google_photos 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 @@ -1058,10 +1064,10 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.43.0 +govee-ble==0.44.0 # homeassistant.components.govee_light_local -govee-local-api==2.0.1 +govee-local-api==2.1.0 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 @@ -1082,7 +1088,7 @@ greenwavereality==0.5.1 gridnet==5.0.1 # homeassistant.components.growatt_server -growattServer==1.5.0 +growattServer==1.6.0 # homeassistant.components.google_sheets gspread==5.5.0 @@ -1094,7 +1100,7 @@ gstreamer-player==1.1.2 guppy3==3.1.5 # homeassistant.components.iaqualink -h2==4.1.0 +h2==4.2.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.2.2 @@ -1105,14 +1111,17 @@ ha-iotawattpy==0.1.2 # homeassistant.components.philips_js ha-philipsjs==3.2.2 +# homeassistant.components.homeassistant_hardware +ha-silabs-firmware-client==0.2.0 + # homeassistant.components.habitica habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.0 +habluetooth==3.48.2 # homeassistant.components.cloud -hass-nabucasa==0.92.0 +hass-nabucasa==0.101.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1121,7 +1130,7 @@ hass-splunk==0.1.1 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate==0.11.1 +hdate[astral]==1.1.0 # homeassistant.components.heatmiser heatmiserV3==2.0.3 @@ -1149,16 +1158,16 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.67 +holidays==0.72 # homeassistant.components.frontend -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250516.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.26 +home-assistant-intents==2025.5.7 # homeassistant.components.homematicip_cloud -homematicip==1.1.7 +homematicip==2.0.1.1 # homeassistant.components.horizon horimote==0.4.1 @@ -1167,7 +1176,7 @@ horimote==0.4.1 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.10.0 +huawei-lte-api==1.11.0 # homeassistant.components.huum huum==0.7.12 @@ -1179,7 +1188,7 @@ hyperion-py==0.7.5 iammeter==0.2.1 # homeassistant.components.iaqualink -iaqualink==0.5.0 +iaqualink==0.5.3 # homeassistant.components.ibeacon ibeacon-ble==1.2.0 @@ -1190,7 +1199,8 @@ ibmiotf==0.3.4 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==8.3.0 +# homeassistant.components.remote_calendar +ical==9.2.4 # homeassistant.components.caldav icalendar==6.1.0 @@ -1213,20 +1223,23 @@ igloohome-api==0.1.0 # homeassistant.components.ihc ihcsdk==2.8.5 +# homeassistant.components.imeon_inverter +imeon_inverter_api==0.3.12 + # homeassistant.components.imgw_pib -imgw_pib==1.0.9 +imgw_pib==1.0.10 # homeassistant.components.incomfort -incomfort-client==0.6.7 +incomfort-client==0.6.8 # homeassistant.components.influxdb -influxdb-client==1.24.0 +influxdb-client==1.48.0 # homeassistant.components.influxdb influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.0 +inkbird-ble==0.16.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1281,7 +1294,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.30.194235 +knx-frontend==2025.4.1.91934 # homeassistant.components.konnected konnected==1.2.0 @@ -1299,16 +1312,16 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.3 +lcn-frontend==0.2.4 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 # homeassistant.components.leaone -leaone-ble==0.1.0 +leaone-ble==0.3.0 # homeassistant.components.led_ble -led-ble==1.1.6 +led-ble==1.1.7 # homeassistant.components.lektrico lektricowifi==0.0.43 @@ -1344,7 +1357,7 @@ linear-garage-door==0.2.9 linode-api==4.1.9b1 # homeassistant.components.livisi -livisi==0.0.24 +livisi==0.0.25 # homeassistant.components.google_maps locationsharinglib==5.0.1 @@ -1378,7 +1391,7 @@ mbddns==0.1.2 # homeassistant.components.mcp # homeassistant.components.mcp_server -mcp==1.1.2 +mcp==1.5.0 # homeassistant.components.minecraft_server mcstatus==11.1.1 @@ -1399,7 +1412,7 @@ messagebird==1.2.0 meteoalertapi==0.3.1 # homeassistant.components.meteo_france -meteofrance-api==1.3.0 +meteofrance-api==1.4.0 # homeassistant.components.mfi mficlient==0.5.0 @@ -1414,7 +1427,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.12.3 +millheater==0.12.5 # homeassistant.components.minio minio==7.1.12 @@ -1423,7 +1436,7 @@ minio==7.1.12 moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 -moehlenhoff-alpha2==1.3.1 +moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo monzopy==1.4.2 @@ -1432,7 +1445,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.25 +motionblinds==0.6.27 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1447,7 +1460,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.1.1 +music-assistant-client==1.2.0 # homeassistant.components.tts mutagen==1.47.0 @@ -1477,13 +1490,13 @@ netdata==1.3.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.0.0 +nettigo-air-monitor==4.1.0 # homeassistant.components.neurio_energy neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.9 +nexia==2.10.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1495,10 +1508,10 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.4.10 +nhc==0.4.12 # homeassistant.components.nibe_heatpump -nibe==2.14.0 +nibe==2.17.0 # homeassistant.components.nice_go nice-go==1.0.1 @@ -1547,13 +1560,13 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==6.0.2 +odp-amsterdam==6.1.1 # homeassistant.components.oem oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.3.2 +ohme==1.5.1 # homeassistant.components.ollama ollama==0.4.7 @@ -1565,7 +1578,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.12 +onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif onvif-zeep-async==3.2.5 @@ -1577,7 +1590,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.61.0 +openai==1.76.2 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1601,7 +1614,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.9.0 +opower==0.12.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1669,7 +1682,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.2 +plugwise==1.7.4 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1704,7 +1717,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.1.1 +psutil==7.0.0 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 @@ -1713,10 +1726,10 @@ pulsectl==23.5.2 pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover_complete==1.1.1 +pushover_complete==1.2.0 # homeassistant.components.pvoutput -pvo==2.2.0 +pvo==2.2.1 # homeassistant.components.aosmith py-aosmith==1.0.12 @@ -1730,6 +1743,9 @@ py-ccm15==0.0.9 # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 +# homeassistant.components.pterodactyl +py-dactyl==2.0.4 + # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 @@ -1743,7 +1759,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.0.5 +py-nextbusnext==2.1.2 # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1755,7 +1771,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.0 +py-synologydsm-api==2.7.1 # homeassistant.components.atome pyAtome==0.1.1 @@ -1779,7 +1795,7 @@ pyEmby==1.10 pyHik==0.3.2 # homeassistant.components.homee -pyHomee==1.2.7 +pyHomee==1.2.8 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 @@ -1788,7 +1804,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.30.8 +pyTibber==0.31.2 # homeassistant.components.dlink pyW215==0.7.0 @@ -1813,7 +1829,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.7 +pyaprilaire==0.8.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 @@ -1822,7 +1838,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.1.0 +pyatmo==9.2.0 # homeassistant.components.apple_tv pyatv==0.16.0 @@ -1840,7 +1856,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.0 +pyblu==2.0.1 # homeassistant.components.neato pybotvac==0.0.26 @@ -1882,7 +1898,7 @@ pycsspeechtts==1.0.8 # pycups==2.0.4 # homeassistant.components.daikin -pydaikin==2.13.8 +pydaikin==2.15.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 @@ -1891,7 +1907,7 @@ pydanfossair==0.1.0 pydeako==0.6.0 # homeassistant.components.deconz -pydeconz==118 +pydeconz==120 # homeassistant.components.delijn pydelijn==1.1.0 @@ -1906,10 +1922,10 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.2.0 +pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam -pydroid-ipcam==2.0.0 +pydroid-ipcam==3.0.0 # homeassistant.components.ebox pyebox==1.1.4 @@ -1939,13 +1955,13 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.25.1 +pyenphase==1.26.1 # homeassistant.components.envisalink pyenvisalink==4.7 # homeassistant.components.ephember -pyephember==0.3.1 +pyephember2==0.4.12 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1954,10 +1970,10 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.1.2 +pyezvizapi==1.0.0.7 # homeassistant.components.fibaro -pyfibaro==0.8.0 +pyfibaro==0.8.2 # homeassistant.components.fido pyfido==2.1.2 @@ -1996,7 +2012,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.2 +pyheos==1.0.5 # homeassistant.components.hive pyhive-integration==1.0.2 @@ -2038,7 +2054,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.14 +pyisy==3.4.1 # homeassistant.components.itach pyitachip2ir==0.0.7 @@ -2068,7 +2084,7 @@ pykoplenti==1.3.0 pykrakenapi==0.1.8 # homeassistant.components.kulersky -pykulersky==0.5.2 +pykulersky==0.5.8 # homeassistant.components.kwb pykwb==0.0.8 @@ -2077,7 +2093,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.7 +pylamarzocco==2.0.3 # homeassistant.components.lastfm pylast==5.1.0 @@ -2098,10 +2114,10 @@ pylitejet==0.6.3 pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.23.0 +pylutron-caseta==0.24.0 # homeassistant.components.lutron -pylutron==0.2.16 +pylutron==0.2.18 # homeassistant.components.mailgun pymailgunner==1.4 @@ -2112,15 +2128,15 @@ pymata-express==1.19 # homeassistant.components.mediaroom pymediaroom==0.6.5.4 -# homeassistant.components.melcloud -pymelcloud==2.5.9 - # homeassistant.components.meteoclimatic pymeteoclimatic==0.1.0 # homeassistant.components.assist_pipeline pymicro-vad==1.0.1 +# homeassistant.components.miele +pymiele==0.5.2 + # homeassistant.components.xiaomi_tv pymitv==1.4.3 @@ -2140,7 +2156,7 @@ pymsteams==0.1.12 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==4.0.1 +pynecil==4.1.0 # homeassistant.components.netgear pynetgear==0.10.10 @@ -2148,6 +2164,9 @@ pynetgear==0.10.10 # homeassistant.components.netio pynetio==0.1.9.1 +# homeassistant.components.nina +pynina==0.3.6 + # homeassistant.components.nobo_hub pynobo==1.8.1 @@ -2179,7 +2198,7 @@ pyombi==0.1.10 pyopenuv==2023.02.0 # homeassistant.components.openweathermap -pyopenweathermap==0.2.1 +pyopenweathermap==0.2.2 # homeassistant.components.opnsense pyopnsense==0.4.0 @@ -2199,7 +2218,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.0 +pyoverkiz==1.17.1 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -2211,10 +2230,10 @@ pypalazzetti==0.1.19 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.5 +pypck==0.8.6 # homeassistant.components.pglab -pypglab==0.0.3 +pypglab==0.0.5 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -2244,7 +2263,7 @@ pyqvrpro==0.52 pyqwikswitch==0.93 # homeassistant.components.nmbs -pyrail==0.0.3 +pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 @@ -2274,13 +2293,13 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2024.11.0 +pyschlage==2025.4.0 # homeassistant.components.sensibo -pysensibo==1.1.0 +pysensibo==1.2.1 # homeassistant.components.serial -pyserial-asyncio-fast==0.14 +pyserial-asyncio-fast==0.16 # homeassistant.components.acer_projector # homeassistant.components.crownstone @@ -2310,19 +2329,19 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==1.2.0 +pysmartthings==3.2.2 # homeassistant.components.smarty pysmarty2==0.10.2 # homeassistant.components.smhi -pysmhi==1.0.0 +pysmhi==1.0.2 # homeassistant.components.edl21 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.3 +pysmlight==0.2.4 # homeassistant.components.snmp pysnmp==6.2.6 @@ -2343,10 +2362,10 @@ pyspeex-noise==1.0.2 pysqueezebox==0.12.0 # homeassistant.components.stiebel_eltron -pystiebeleltron==0.0.1.dev2 +pystiebeleltron==0.1.0 # homeassistant.components.suez_water -pysuezV2==2.0.3 +pysuezV2==2.0.5 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -2421,7 +2440,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.1.3 +python-linkplay==0.2.5 # homeassistant.components.lirc # python-lirc==1.2.3 @@ -2429,6 +2448,9 @@ python-linkplay==0.1.3 # homeassistant.components.matter python-matter-server==7.0.0 +# homeassistant.components.melcloud +python-melcloud==0.1.0 + # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2452,7 +2474,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api2==1.2.2 +python-picnic-api2==1.2.4 # homeassistant.components.rabbitair python-rabbitair==0.0.8 @@ -2461,19 +2483,19 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.11.1 +python-roborock==2.18.2 # homeassistant.components.smarttub python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.0 +python-snoo==0.6.6 # homeassistant.components.songpal python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.6 +python-tado==0.18.14 # homeassistant.components.technove python-technove==2.0.0 @@ -2555,10 +2577,10 @@ pywemo==1.4.0 pywilight==0.0.74 # homeassistant.components.wiz -pywizlight==0.5.14 +pywizlight==0.6.2 # homeassistant.components.wmspro -pywmspro==0.2.1 +pywmspro==0.2.2 # homeassistant.components.ws66i pyws66i==1.1 @@ -2576,7 +2598,7 @@ pyzbar==0.1.7 pyzerproc==0.4.8 # homeassistant.components.qbittorrent -qbittorrent-api==2024.2.59 +qbittorrent-api==2024.9.67 # homeassistant.components.qbus qbusmqttapi==1.3.0 @@ -2612,13 +2634,13 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.9 +renault-api==0.3.1 # homeassistant.components.renson renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.0 +reolink-aio==0.13.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2656,9 +2678,6 @@ rova==0.4.1 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 -# homeassistant.components.rtsp_to_webrtc -rtsp-to-webrtc==0.5.1 - # homeassistant.components.russound_rnet russound==0.2.0 @@ -2694,19 +2713,19 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.5 +sense-energy==0.13.8 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 # homeassistant.components.sensorpro -sensorpro-ble==0.5.3 +sensorpro-ble==0.7.0 # homeassistant.components.sensorpush_cloud -sensorpush-api==2.1.1 +sensorpush-api==2.1.2 # homeassistant.components.sensorpush -sensorpush-ble==1.7.1 +sensorpush-ble==1.9.0 # homeassistant.components.sensorpush_cloud sensorpush-ha==1.3.2 @@ -2721,7 +2740,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.11 # homeassistant.components.sharkiq -sharkiq==1.0.2 +sharkiq==1.1.0 # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 @@ -2821,9 +2840,6 @@ stringcase==1.2.0 # homeassistant.components.subaru subarulink==0.7.13 -# homeassistant.components.sunweg -sunweg==3.0.2 - # homeassistant.components.surepetcare surepy==0.9.0 @@ -2843,7 +2859,7 @@ systembridgeconnector==4.1.5 systembridgemodels==4.2.4 # homeassistant.components.tailscale -tailscale==0.6.1 +tailscale==0.6.2 # homeassistant.components.tank_utility tank-utility==1.5.0 @@ -2872,7 +2888,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.10 +tesla-fleet-api==1.0.17 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2881,7 +2897,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.10 +teslemetry-stream==0.7.7 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2890,16 +2906,16 @@ tessie-api==0.1.1 # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.8.0 +thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.11.0 +thermopro-ble==0.13.0 # homeassistant.components.thingspeak thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.4 +thinqconnect==1.0.5 # homeassistant.components.tikteck tikteck==0.4 @@ -2926,7 +2942,7 @@ total-connect-client==2025.1.4 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.3 +tplink-omada-client==1.4.4 # homeassistant.components.transmission transmission-rpc==7.0.3 @@ -2959,7 +2975,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.1 +uiprotect==7.6.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2974,10 +2990,10 @@ unifi_ap==0.0.2 unifiled==0.11 # homeassistant.components.homeassistant_hardware -universal-silabs-flasher==0.0.29 +universal-silabs-flasher==0.0.30 # homeassistant.components.upb -upb-lib==0.6.0 +upb-lib==0.6.1 # homeassistant.components.upcloud upcloud-api==2.6.0 @@ -2985,13 +3001,13 @@ upcloud-api==2.6.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru # homeassistant.components.zwave_me -url-normalize==1.4.3 +url-normalize==2.2.1 # homeassistant.components.uvc uvcclient==0.12.1 # homeassistant.components.roborock -vacuum-map-parser-roborock==0.1.2 +vacuum-map-parser-roborock==0.1.4 # homeassistant.components.vallox vallox-websocket-api==5.3.0 @@ -3000,7 +3016,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.1.1 +velbus-aio==2025.5.0 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -3009,7 +3025,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.1 +voip-utils==0.3.2 # homeassistant.components.volkszaehler volkszaehler==0.4.0 @@ -3034,7 +3050,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.7.0 +wallbox==0.9.0 # homeassistant.components.folder_watcher watchdog==6.0.0 @@ -3046,7 +3062,7 @@ waterfurnace==1.1.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.cisco_webex_teams webexpythonsdk==2.0.1 @@ -3058,10 +3074,10 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.22 +weheat==2025.4.29 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.12 +whirlpool-sixth-sense==0.20.0 # homeassistant.components.whois whois==0.9.27 @@ -3076,7 +3092,7 @@ wirelesstagpy==0.8.1 wled==0.21.0 # homeassistant.components.wolflink -wolf-comm==0.0.19 +wolf-comm==0.0.23 # homeassistant.components.wyoming wyoming==1.5.4 @@ -3085,13 +3101,13 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.33.0 +xiaomi-ble==0.38.0 # homeassistant.components.knx -xknx==3.6.0 +xknx==3.8.0 # homeassistant.components.knx -xknxproject==3.8.1 +xknxproject==3.8.2 # homeassistant.components.fritz # homeassistant.components.rest @@ -3109,7 +3125,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.7 +yalexs-ble==2.6.0 # homeassistant.components.august # homeassistant.components.yale @@ -3122,7 +3138,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.4.8 +yolink-api==0.5.2 # homeassistant.components.youless youless-api==2.2.0 @@ -3131,7 +3147,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.26 +yt-dlp[default]==2025.03.31 # homeassistant.components.zabbix zabbix-utils==2.0.2 @@ -3139,17 +3155,17 @@ zabbix-utils==2.0.2 # homeassistant.components.zamg zamg==0.3.6 -# homeassistant.components.zengge -zengge==0.2 +# homeassistant.components.zimi +zcc-helper==3.5.2 # homeassistant.components.zeroconf -zeroconf==0.145.1 +zeroconf==0.147.0 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.51 +zha==0.0.57 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 @@ -3161,7 +3177,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.1 +zwave-js-server-python==0.63.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index 0a7a3bb18e5..40349402c4d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,18 +7,19 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.3.8 -coverage==7.6.10 +astroid==3.3.10 +coverage==7.6.12 freezegun==1.5.1 +go2rtc-client==0.1.3b0 license-expression==30.4.1 mock-open==1.4.0 -mypy-dev==1.16.0a3 +mypy-dev==1.16.0a8 pre-commit==4.0.0 -pydantic==2.10.6 -pylint==3.3.4 +pydantic==2.11.3 +pylint==3.3.7 pylint-per-file-ignores==1.4.0 -pipdeptree==2.25.0 -pytest-asyncio==0.25.3 +pipdeptree==2.26.1 +pytest-asyncio==0.26.0 pytest-aiohttp==1.1.0 pytest-cov==6.0.0 pytest-freezer==0.4.9 @@ -29,26 +30,24 @@ pytest-timeout==2.3.1 pytest-unordered==0.6.1 pytest-picked==0.5.1 pytest-xdist==3.6.1 -pytest==8.3.4 +pytest==8.3.5 requests-mock==1.12.1 respx==0.22.0 syrupy==4.8.1 tqdm==4.67.1 -types-aiofiles==24.1.0.20241221 +types-aiofiles==24.1.0.20250326 types-atomicwrites==1.4.5.1 -types-croniter==5.0.1.20241205 -types-beautifulsoup4==4.12.0.20250204 +types-croniter==6.0.0.20250411 types-caldav==1.3.0.20241107 types-chardet==0.1.5 -types-decorator==5.1.8.20250121 +types-decorator==5.2.0.20250324 types-pexpect==4.9.0.20241208 -types-pillow==10.2.0.20240822 -types-protobuf==5.29.1.20241207 -types-psutil==6.1.0.20241221 -types-pyserial==3.5.0.20250130 +types-protobuf==5.29.1.20250403 +types-psutil==7.0.0.20250401 +types-pyserial==3.5.0.20250326 types-python-dateutil==2.9.0.20241206 types-python-slugify==8.0.2.20240310 -types-pytz==2025.1.0.20250204 -types-PyYAML==6.0.12.20241230 +types-pytz==2025.2.0.20250326 +types-PyYAML==6.0.12.20250402 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 2ee967f69ac..f4c96646188 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==1.8.1 +Mastodon.py==2.0.1 # homeassistant.components.doods # homeassistant.components.generic @@ -33,7 +33,7 @@ Mastodon.py==1.8.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.1.0 +Pillow==11.2.1 # homeassistant.components.plex PlexAPI==4.15.16 @@ -42,7 +42,7 @@ PlexAPI==4.15.16 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==14.0.5 +PyChromecast==14.0.7 # homeassistant.components.flick_electric PyFlick==1.1.3 @@ -51,7 +51,7 @@ PyFlick==1.1.3 PyFlume==0.6.5 # homeassistant.components.fronius -PyFronius==0.7.3 +PyFronius==0.7.7 # homeassistant.components.pyload PyLoadAPI==1.4.2 @@ -66,9 +66,6 @@ PyMetno==0.13.0 # homeassistant.components.keymitt_ble PyMicroBot==0.0.17 -# homeassistant.components.nina -PyNINA==0.3.4 - # homeassistant.components.mobile_app # homeassistant.components.owntracks PyNaCl==1.5.0 @@ -81,7 +78,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.0 +PySwitchbot==0.62.2 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -94,7 +91,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.43.1 +PyViCare==2.44.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -110,7 +107,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.38 +SQLAlchemy==2.0.40 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 @@ -119,7 +116,7 @@ Tami4EdgeAPI==3.0 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.1.0 +accuweather==4.2.0 # homeassistant.components.adax adax==0.4.0 @@ -167,10 +164,10 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.10 +aioairzone-cloud==0.6.12 # homeassistant.components.airzone -aioairzone==0.9.9 +aioairzone==1.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -189,7 +186,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.1.1 +aioautomower==2025.5.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -198,22 +195,23 @@ aioazuredevops==2.2.1 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.13.1 +# homeassistant.components.aws_s3 +aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==0.10.1 +aiocomelit==0.12.3 # homeassistant.components.dhcp -aiodhcpwatcher==1.1.1 +aiodhcpwatcher==1.2.0 # homeassistant.components.dhcp -aiodiscover==2.6.1 +aiodiscover==2.7.0 # homeassistant.components.dnsip -aiodns==3.2.0 +aiodns==3.4.0 # homeassistant.components.duke_energy -aiodukeenergy==0.2.2 +aiodukeenergy==0.3.0 # homeassistant.components.eafm aioeafm==0.1.2 @@ -222,7 +220,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.1 +aioecowitt==2025.3.1 # homeassistant.components.co2signal aioelectricitymaps==0.4.0 @@ -231,7 +229,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.2.0 +aioesphomeapi==31.1.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -243,16 +241,16 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.4.1 +aioharmony==0.5.2 # homeassistant.components.hassio -aiohasupervisor==0.3.0 +aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.15.0 +aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.14 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 @@ -263,9 +261,15 @@ aiohue==4.7.4 # homeassistant.components.imap aioimaplib==2.0.1 +# homeassistant.components.immich +aioimmich==0.5.0 + # homeassistant.components.apache_kafka aiokafka==0.10.0 +# homeassistant.components.rehlko +aiokem==0.5.10 + # homeassistant.components.lifx aiolifx-effects==0.3.2 @@ -273,7 +277,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.2 +aiolifx==1.1.4 # homeassistant.components.lookin aiolookin==1.0.0 @@ -296,12 +300,12 @@ aionanoleaf==0.2.1 # homeassistant.components.notion aionotion==2024.03.0 +# homeassistant.components.ntfy +aiontfy==0.5.2 + # homeassistant.components.nut aionut==4.3.4 -# homeassistant.components.oncue -aiooncue==0.3.9 - # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -344,7 +348,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.4.0 +aiorussound==4.5.2 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -353,7 +357,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.0.0 +aioshelly==13.6.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -386,7 +390,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==82 +aiounifi==83 # homeassistant.components.usb aiousbwatcher==1.1.1 @@ -395,7 +399,7 @@ aiousbwatcher==1.1.1 aiovlc==0.5.1 # homeassistant.components.vodafone_station -aiovodafone==0.6.1 +aiovodafone==0.10.0 # homeassistant.components.waqi aiowaqi==3.1.0 @@ -404,10 +408,10 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.0 +aiowebdav2==0.4.5 # homeassistant.components.webostv -aiowebostv==0.7.1 +aiowebostv==0.7.3 # homeassistant.components.withings aiowithings==3.1.6 @@ -440,7 +444,7 @@ amberelectric==2.0.12 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.0 +androidtvremote2==0.2.1 # homeassistant.components.anova anova-wifi==0.17.0 @@ -452,7 +456,7 @@ anthemav==1.4.1 anthropic==0.47.2 # homeassistant.components.mcp_server -anyio==4.8.0 +anyio==4.9.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 @@ -464,7 +468,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.4.0 +apsystems-ez1==2.6.0 # homeassistant.components.aranet aranet4==2.5.1 @@ -478,7 +482,7 @@ arcam-fmj==1.8.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -496,7 +500,7 @@ aurorapy==0.2.7 autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble -automower-ble==0.2.0 +automower-ble==0.2.1 # homeassistant.components.generic # homeassistant.components.stream @@ -506,7 +510,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.5 +ayla-iot-unofficial==1.4.7 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -527,14 +531,14 @@ babel==2.15.0 base36==0.1.1 # homeassistant.components.scrape -beautifulsoup4==4.12.3 +beautifulsoup4==4.13.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.1 +bleak-esphome==2.15.1 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 @@ -552,31 +556,34 @@ blinkpy==0.23.0 bluecurrent-api==1.2.3 # homeassistant.components.bluemaestro -bluemaestro-ble==0.2.3 +bluemaestro-ble==0.4.1 # homeassistant.components.bluetooth bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.4 +bluetooth-auto-recovery==1.5.1 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.4 +bluetooth-data-tools==1.28.1 # homeassistant.components.bond bond-async==0.2.1 +# homeassistant.components.bosch_alarm +bosch-alarm-mode2==0.4.6 + # homeassistant.components.bosch_shc boschshcpy==0.2.91 # homeassistant.components.aws -botocore==1.34.131 +botocore==1.37.1 # homeassistant.components.bring -bring-api==1.0.2 +bring-api==1.1.0 # homeassistant.components.broadlink broadlink==0.19.0 @@ -597,7 +604,7 @@ bthome-ble==3.12.4 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.9.2 +cached-ipaddress==0.10.0 # homeassistant.components.caldav caldav==1.3.9 @@ -609,7 +616,7 @@ coinbase-advanced-py==1.2.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.8.2 +colorlog==6.9.0 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -640,13 +647,13 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.33.0 +dbus-fast==2.43.0 # homeassistant.components.debugpy -debugpy==1.8.11 +debugpy==1.8.14 # homeassistant.components.ecovacs -deebot-client==12.2.0 +deebot-client==13.2.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -660,19 +667,19 @@ deluge-client==1.10.2 demetriek==1.2.0 # homeassistant.components.denonavr -denonavr==1.0.1 +denonavr==1.1.1 # homeassistant.components.devialet devialet==1.5.7 # homeassistant.components.devolo_home_control -devolo-home-control-api==0.18.3 +devolo-home-control-api==0.19.0 # homeassistant.components.devolo_home_network -devolo-plc-api==1.4.1 +devolo-plc-api==1.5.1 # homeassistant.components.chacon_dio -dio-chacon-wifi-api==1.2.1 +dio-chacon-wifi-api==1.2.2 # homeassistant.components.directv directv==0.4.0 @@ -687,7 +694,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.4.2 +dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 @@ -705,7 +712,7 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.0.6 +eheimdigital==1.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -738,7 +745,7 @@ energyzero==2.1.1 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.10.2 # homeassistant.components.season ephem==4.1.6 @@ -756,7 +763,7 @@ epson-projector==0.5.1 eq3btsmart==1.4.1 # homeassistant.components.esphome -esphome-dashboard-api==1.2.3 +esphome-dashboard-api==1.3.0 # homeassistant.components.netgear_lte eternalegypt==0.0.16 @@ -765,7 +772,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==1.0.2 +evohome-async==1.0.5 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 @@ -801,17 +808,17 @@ flexit_bacnet==2.2.3 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.3 +flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.6 +fnv-hash-fast==1.5.0 # homeassistant.components.foobot foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==4.0.0 +forecast-solar==4.2.0 # homeassistant.components.freebox freebox-api==1.2.2 @@ -821,25 +828,25 @@ freebox-api==1.2.2 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.7.0 +fyta_cli==0.7.2 # homeassistant.components.google_translate gTTS==2.5.3 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.5.0 +gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.11 +gassist-text==0.0.12 # homeassistant.components.google -gcal-sync==7.0.0 +gcal-sync==7.0.1 # homeassistant.components.geniushub geniushub-client==0.7.1 # homeassistant.components.geocaching -geocachingapi==0.2.1 +geocachingapi==0.3.0 # homeassistant.components.aprs geopy==2.3.0 @@ -861,13 +868,13 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==5.0.0 +gios==6.0.0 # homeassistant.components.glances glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.2 +go2rtc-client==0.1.3b0 # homeassistant.components.goalzero goalzero==0.2.2 @@ -880,26 +887,26 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.28.0 +google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud -google-cloud-speech==2.27.0 +google-cloud-speech==2.31.1 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.17.2 +google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation -google-genai==1.1.0 +google-genai==1.7.0 + +# homeassistant.components.google_travel_time +google-maps-routing==0.6.15 # homeassistant.components.nest -google-nest-sdm==7.1.3 +google-nest-sdm==7.1.4 # homeassistant.components.google_photos 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 @@ -908,10 +915,10 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.43.0 +govee-ble==0.44.0 # homeassistant.components.govee_light_local -govee-local-api==2.0.1 +govee-local-api==2.1.0 # homeassistant.components.gpsd gps3==0.33.3 @@ -926,7 +933,7 @@ greeneye_monitor==3.0.3 gridnet==5.0.1 # homeassistant.components.growatt_server -growattServer==1.5.0 +growattServer==1.6.0 # homeassistant.components.google_sheets gspread==5.5.0 @@ -935,7 +942,7 @@ gspread==5.5.0 guppy3==3.1.5 # homeassistant.components.iaqualink -h2==4.1.0 +h2==4.2.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.2.2 @@ -946,20 +953,23 @@ ha-iotawattpy==0.1.2 # homeassistant.components.philips_js ha-philipsjs==3.2.2 +# homeassistant.components.homeassistant_hardware +ha-silabs-firmware-client==0.2.0 + # homeassistant.components.habitica habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.0 +habluetooth==3.48.2 # homeassistant.components.cloud -hass-nabucasa==0.92.0 +hass-nabucasa==0.101.0 # homeassistant.components.conversation hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate==0.11.1 +hdate[astral]==1.1.0 # homeassistant.components.here_travel_time here-routing==1.0.1 @@ -978,22 +988,22 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.67 +holidays==0.72 # homeassistant.components.frontend -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250516.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.26 +home-assistant-intents==2025.5.7 # homeassistant.components.homematicip_cloud -homematicip==1.1.7 +homematicip==2.0.1.1 # homeassistant.components.remember_the_milk httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.10.0 +huawei-lte-api==1.11.0 # homeassistant.components.huum huum==0.7.12 @@ -1002,7 +1012,7 @@ huum==0.7.12 hyperion-py==0.7.5 # homeassistant.components.iaqualink -iaqualink==0.5.0 +iaqualink==0.5.3 # homeassistant.components.ibeacon ibeacon-ble==1.2.0 @@ -1010,7 +1020,8 @@ ibeacon-ble==1.2.0 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==8.3.0 +# homeassistant.components.remote_calendar +ical==9.2.4 # homeassistant.components.caldav icalendar==6.1.0 @@ -1027,20 +1038,23 @@ ifaddr==0.2.0 # homeassistant.components.igloohome igloohome-api==0.1.0 +# homeassistant.components.imeon_inverter +imeon_inverter_api==0.3.12 + # homeassistant.components.imgw_pib -imgw_pib==1.0.9 +imgw_pib==1.0.10 # homeassistant.components.incomfort -incomfort-client==0.6.7 +incomfort-client==0.6.8 # homeassistant.components.influxdb -influxdb-client==1.24.0 +influxdb-client==1.48.0 # homeassistant.components.influxdb influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.0 +inkbird-ble==0.16.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1083,7 +1097,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.30.194235 +knx-frontend==2025.4.1.91934 # homeassistant.components.konnected konnected==1.2.0 @@ -1098,16 +1112,16 @@ lacrosse-view==1.1.1 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.3 +lcn-frontend==0.2.4 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 # homeassistant.components.leaone -leaone-ble==0.1.0 +leaone-ble==0.3.0 # homeassistant.components.led_ble -led-ble==1.1.6 +led-ble==1.1.7 # homeassistant.components.lektrico lektricowifi==0.0.43 @@ -1128,7 +1142,7 @@ libsoundtouch==0.8 linear-garage-door==0.2.9 # homeassistant.components.livisi -livisi==0.0.24 +livisi==0.0.25 # homeassistant.components.london_underground london-tube-status==0.5 @@ -1156,7 +1170,7 @@ mbddns==0.1.2 # homeassistant.components.mcp # homeassistant.components.mcp_server -mcp==1.1.2 +mcp==1.5.0 # homeassistant.components.minecraft_server mcstatus==11.1.1 @@ -1171,7 +1185,7 @@ medcom-ble==0.1.1 melnor-bluetooth==0.0.25 # homeassistant.components.meteo_france -meteofrance-api==1.3.0 +meteofrance-api==1.4.0 # homeassistant.components.mfi mficlient==0.5.0 @@ -1186,7 +1200,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.12.3 +millheater==0.12.5 # homeassistant.components.minio minio==7.1.12 @@ -1195,7 +1209,7 @@ minio==7.1.12 moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 -moehlenhoff-alpha2==1.3.1 +moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo monzopy==1.4.2 @@ -1204,7 +1218,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.25 +motionblinds==0.6.27 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1219,7 +1233,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.1.1 +music-assistant-client==1.2.0 # homeassistant.components.tts mutagen==1.47.0 @@ -1243,10 +1257,10 @@ nessclient==1.1.2 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.0.0 +nettigo-air-monitor==4.1.0 # homeassistant.components.nexia -nexia==2.0.9 +nexia==2.10.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1258,10 +1272,10 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.4.10 +nhc==0.4.12 # homeassistant.components.nibe_heatpump -nibe==2.14.0 +nibe==2.17.0 # homeassistant.components.nice_go nice-go==1.0.1 @@ -1298,10 +1312,10 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==6.0.2 +odp-amsterdam==6.1.1 # homeassistant.components.ohme -ohme==1.3.2 +ohme==1.5.1 # homeassistant.components.ollama ollama==0.4.7 @@ -1313,7 +1327,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.12 +onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif onvif-zeep-async==3.2.5 @@ -1325,7 +1339,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.61.0 +openai==1.76.2 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1337,7 +1351,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.9.0 +opower==0.12.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1382,7 +1396,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.2 +plugwise==1.7.4 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1408,16 +1422,16 @@ prometheus-client==0.21.0 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.1.1 +psutil==7.0.0 # homeassistant.components.pushbullet pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover_complete==1.1.1 +pushover_complete==1.2.0 # homeassistant.components.pvoutput -pvo==2.2.0 +pvo==2.2.1 # homeassistant.components.aosmith py-aosmith==1.0.12 @@ -1431,6 +1445,9 @@ py-ccm15==0.0.9 # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 +# homeassistant.components.pterodactyl +py-dactyl==2.0.4 + # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 @@ -1444,7 +1461,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.0.5 +py-nextbusnext==2.1.2 # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1453,7 +1470,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.0 +py-synologydsm-api==2.7.1 # homeassistant.components.hdmi_cec pyCEC==0.5.2 @@ -1468,13 +1485,13 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.homee -pyHomee==1.2.7 +pyHomee==1.2.8 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.30.8 +pyTibber==0.31.2 # homeassistant.components.dlink pyW215==0.7.0 @@ -1493,7 +1510,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.7 +pyaprilaire==0.8.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 @@ -1502,7 +1519,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.1.0 +pyatmo==9.2.0 # homeassistant.components.apple_tv pyatv==0.16.0 @@ -1517,7 +1534,7 @@ pybalboa==1.1.3 pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.0 +pyblu==2.0.1 # homeassistant.components.neato pybotvac==0.0.26 @@ -1541,13 +1558,13 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.13.8 +pydaikin==2.15.0 # homeassistant.components.deako pydeako==0.6.0 # homeassistant.components.deconz -pydeconz==118 +pydeconz==120 # homeassistant.components.dexcom pydexcom==0.2.3 @@ -1556,10 +1573,10 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.2.0 +pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam -pydroid-ipcam==2.0.0 +pydroid-ipcam==3.0.0 # homeassistant.components.ecoforest pyecoforest==0.4.0 @@ -1583,7 +1600,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.25.1 +pyenphase==1.26.1 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1592,10 +1609,10 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.1.2 +pyezvizapi==1.0.0.7 # homeassistant.components.fibaro -pyfibaro==0.8.0 +pyfibaro==0.8.2 # homeassistant.components.fido pyfido==2.1.2 @@ -1625,7 +1642,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.2 +pyheos==1.0.5 # homeassistant.components.hive pyhive-integration==1.0.2 @@ -1661,7 +1678,7 @@ pyiskra==0.1.15 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.14 +pyisy==3.4.1 # homeassistant.components.ituran pyituran==0.1.4 @@ -1688,10 +1705,10 @@ pykoplenti==1.3.0 pykrakenapi==0.1.8 # homeassistant.components.kulersky -pykulersky==0.5.2 +pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==1.4.7 +pylamarzocco==2.0.3 # homeassistant.components.lastfm pylast==5.1.0 @@ -1712,10 +1729,10 @@ pylitejet==0.6.3 pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.23.0 +pylutron-caseta==0.24.0 # homeassistant.components.lutron -pylutron==0.2.16 +pylutron==0.2.18 # homeassistant.components.mailgun pymailgunner==1.4 @@ -1723,15 +1740,15 @@ pymailgunner==1.4 # homeassistant.components.firmata pymata-express==1.19 -# homeassistant.components.melcloud -pymelcloud==2.5.9 - # homeassistant.components.meteoclimatic pymeteoclimatic==0.1.0 # homeassistant.components.assist_pipeline pymicro-vad==1.0.1 +# homeassistant.components.miele +pymiele==0.5.2 + # homeassistant.components.mochad pymochad==0.2.0 @@ -1745,11 +1762,14 @@ pymonoprice==0.4 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==4.0.1 +pynecil==4.1.0 # homeassistant.components.netgear pynetgear==0.10.10 +# homeassistant.components.nina +pynina==0.3.6 + # homeassistant.components.nobo_hub pynobo==1.8.1 @@ -1778,7 +1798,7 @@ pyoctoprintapi==0.1.12 pyopenuv==2023.02.0 # homeassistant.components.openweathermap -pyopenweathermap==0.2.1 +pyopenweathermap==0.2.2 # homeassistant.components.opnsense pyopnsense==0.4.0 @@ -1795,7 +1815,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.0 +pyoverkiz==1.17.1 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -1804,10 +1824,10 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.lcn -pypck==0.8.5 +pypck==0.8.6 # homeassistant.components.pglab -pypglab==0.0.3 +pypglab==0.0.5 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -1834,7 +1854,7 @@ pyps4-2ndscreen==1.3.1 pyqwikswitch==0.93 # homeassistant.components.nmbs -pyrail==0.0.3 +pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 @@ -1855,10 +1875,10 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2024.11.0 +pyschlage==2025.4.0 # homeassistant.components.sensibo -pysensibo==1.1.0 +pysensibo==1.2.1 # homeassistant.components.acer_projector # homeassistant.components.crownstone @@ -1882,19 +1902,19 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==1.2.0 +pysmartthings==3.2.2 # homeassistant.components.smarty pysmarty2==0.10.2 # homeassistant.components.smhi -pysmhi==1.0.0 +pysmhi==1.0.2 # homeassistant.components.edl21 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.3 +pysmlight==0.2.4 # homeassistant.components.snmp pysnmp==6.2.6 @@ -1914,8 +1934,11 @@ pyspeex-noise==1.0.2 # homeassistant.components.squeezebox pysqueezebox==0.12.0 +# homeassistant.components.stiebel_eltron +pystiebeleltron==0.1.0 + # homeassistant.components.suez_water -pysuezV2==2.0.3 +pysuezV2==2.0.5 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -1960,11 +1983,14 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.1.3 +python-linkplay==0.2.5 # homeassistant.components.matter python-matter-server==7.0.0 +# homeassistant.components.melcloud +python-melcloud==0.1.0 + # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -1988,25 +2014,25 @@ python-otbr-api==2.7.0 python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api2==1.2.2 +python-picnic-api2==1.2.4 # homeassistant.components.rabbitair python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.11.1 +python-roborock==2.18.2 # homeassistant.components.smarttub python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.0 +python-snoo==0.6.6 # homeassistant.components.songpal python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.6 +python-tado==0.18.14 # homeassistant.components.technove python-technove==2.0.0 @@ -2073,10 +2099,10 @@ pywemo==1.4.0 pywilight==0.0.74 # homeassistant.components.wiz -pywizlight==0.5.14 +pywizlight==0.6.2 # homeassistant.components.wmspro -pywmspro==0.2.1 +pywmspro==0.2.2 # homeassistant.components.ws66i pyws66i==1.1 @@ -2088,7 +2114,7 @@ pyyardian==1.1.1 pyzerproc==0.4.8 # homeassistant.components.qbittorrent -qbittorrent-api==2024.2.59 +qbittorrent-api==2024.9.67 # homeassistant.components.qbus qbusmqttapi==1.3.0 @@ -2115,13 +2141,13 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.9 +renault-api==0.3.1 # homeassistant.components.renson renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.0 +reolink-aio==0.13.3 # homeassistant.components.rflink rflink==0.0.66 @@ -2147,9 +2173,6 @@ rova==0.4.1 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 -# homeassistant.components.rtsp_to_webrtc -rtsp-to-webrtc==0.5.1 - # homeassistant.components.ruuvitag_ble ruuvitag-ble==0.1.2 @@ -2173,19 +2196,19 @@ securetar==2025.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.5 +sense-energy==0.13.8 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 # homeassistant.components.sensorpro -sensorpro-ble==0.5.3 +sensorpro-ble==0.7.0 # homeassistant.components.sensorpush_cloud -sensorpush-api==2.1.1 +sensorpush-api==2.1.2 # homeassistant.components.sensorpush -sensorpush-ble==1.7.1 +sensorpush-ble==1.9.0 # homeassistant.components.sensorpush_cloud sensorpush-ha==1.3.2 @@ -2200,7 +2223,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.11 # homeassistant.components.sharkiq -sharkiq==1.0.2 +sharkiq==1.1.0 # homeassistant.components.simplefin simplefin4py==0.0.18 @@ -2282,9 +2305,6 @@ stringcase==1.2.0 # homeassistant.components.subaru subarulink==0.7.13 -# homeassistant.components.sunweg -sunweg==3.0.2 - # homeassistant.components.surepetcare surepy==0.9.0 @@ -2298,7 +2318,7 @@ systembridgeconnector==4.1.5 systembridgemodels==4.2.4 # homeassistant.components.tailscale -tailscale==0.6.1 +tailscale==0.6.2 # homeassistant.components.tellduslive tellduslive==0.10.12 @@ -2312,7 +2332,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.10 +tesla-fleet-api==1.0.17 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2321,19 +2341,19 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.10 +teslemetry-stream==0.7.7 # homeassistant.components.tessie tessie-api==0.1.1 # homeassistant.components.thermobeacon -thermobeacon-ble==0.8.0 +thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.11.0 +thermopro-ble==0.13.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.4 +thinqconnect==1.0.5 # homeassistant.components.tilt_ble tilt-ble==0.2.3 @@ -2351,7 +2371,7 @@ toonapi==0.3.0 total-connect-client==2025.1.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.3 +tplink-omada-client==1.4.4 # homeassistant.components.transmission transmission-rpc==7.0.3 @@ -2384,7 +2404,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.1 +uiprotect==7.6.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2392,8 +2412,11 @@ ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.2.0 +# homeassistant.components.homeassistant_hardware +universal-silabs-flasher==0.0.30 + # homeassistant.components.upb -upb-lib==0.6.0 +upb-lib==0.6.1 # homeassistant.components.upcloud upcloud-api==2.6.0 @@ -2401,13 +2424,13 @@ upcloud-api==2.6.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru # homeassistant.components.zwave_me -url-normalize==1.4.3 +url-normalize==2.2.1 # homeassistant.components.uvc uvcclient==0.12.1 # homeassistant.components.roborock -vacuum-map-parser-roborock==0.1.2 +vacuum-map-parser-roborock==0.1.4 # homeassistant.components.vallox vallox-websocket-api==5.3.0 @@ -2416,7 +2439,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.1.1 +velbus-aio==2025.5.0 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2425,7 +2448,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.1 +voip-utils==0.3.2 # homeassistant.components.volvooncall volvooncall==0.10.3 @@ -2444,7 +2467,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.7.0 +wallbox==0.9.0 # homeassistant.components.folder_watcher watchdog==6.0.0 @@ -2453,7 +2476,7 @@ watchdog==6.0.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.nasweb webio-api==0.1.11 @@ -2462,10 +2485,10 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.22 +weheat==2025.4.29 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.12 +whirlpool-sixth-sense==0.20.0 # homeassistant.components.whois whois==0.9.27 @@ -2477,7 +2500,7 @@ wiffi==1.1.2 wled==0.21.0 # homeassistant.components.wolflink -wolf-comm==0.0.19 +wolf-comm==0.0.23 # homeassistant.components.wyoming wyoming==1.5.4 @@ -2486,13 +2509,13 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.33.0 +xiaomi-ble==0.38.0 # homeassistant.components.knx -xknx==3.6.0 +xknx==3.8.0 # homeassistant.components.knx -xknxproject==3.8.1 +xknxproject==3.8.2 # homeassistant.components.fritz # homeassistant.components.rest @@ -2507,7 +2530,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.7 +yalexs-ble==2.6.0 # homeassistant.components.august # homeassistant.components.yale @@ -2517,7 +2540,7 @@ yalexs==8.10.0 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.4.8 +yolink-api==0.5.2 # homeassistant.components.youless youless-api==2.2.0 @@ -2526,22 +2549,25 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.26 +yt-dlp[default]==2025.03.31 # homeassistant.components.zamg zamg==0.3.6 +# homeassistant.components.zimi +zcc-helper==3.5.2 + # homeassistant.components.zeroconf -zeroconf==0.145.1 +zeroconf==0.147.0 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.51 +zha==0.0.57 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.1 +zwave-js-server-python==0.63.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 8c9308e739b..ff86915bbf3 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.4.1 -ruff==0.9.7 +ruff==0.11.0 yamllint==1.35.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fa823fa4834..87f7edaa892 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -117,9 +117,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.67.1 -grpcio-status==1.67.1 -grpcio-reflection==1.67.1 +grpcio==1.72.0 +grpcio-status==1.72.0 +grpcio-reflection==1.72.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -139,7 +139,7 @@ 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.8.0 +anyio==4.9.0 h11==0.14.0 httpcore==1.0.7 @@ -159,7 +159,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.6 +pydantic==2.11.3 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -172,13 +172,9 @@ pubnub!=6.4.0 # https://github.com/dahlia/iso4217/issues/16 iso4217!=1.10.20220401 -# pyOpenSSL 24.0.0 or later required to avoid import errors when -# cryptography 42.0.0 is installed with botocore -pyOpenSSL>=24.0.0 - # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==5.29.2 +protobuf==6.30.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 @@ -241,6 +237,16 @@ async-timeout==4.0.3 # https://github.com/home-assistant/core/issues/122508 # https://github.com/home-assistant/core/issues/118004 aiofiles>=24.1.0 + +# multidict < 6.4.0 has memory leaks +# https://github.com/aio-libs/multidict/issues/1134 +# https://github.com/aio-libs/multidict/issues/1131 +multidict>=6.4.2 + +# rpds-py > 0.25.0 requires cargo 1.84.0 +# Stable Alpine current only ships cargo 1.83.0 +# No wheels upstream available for armhf & armv7 +rpds-py==0.24.0 """ GENERATED_MESSAGE = ( @@ -266,7 +272,8 @@ def has_tests(module: str) -> bool: Test if exists: tests/components/hue/__init__.py """ path = ( - Path(module.replace(".", "/").replace("homeassistant", "tests")) / "__init__.py" + Path(module.replace(".", "/").replace("homeassistant", "tests", 1)) + / "__init__.py" ) return path.exists() diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index f842ec61b97..1f8b7d1139b 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -95,7 +95,6 @@ def _populate_brand_integrations( integration = integrations.get(domain) if not integration or integration.integration_type in ( "entity", - "hardware", "system", ): continue @@ -171,7 +170,7 @@ def _generate_integrations( result["integration"][domain] = metadata else: # integration integration = integrations[domain] - if integration.integration_type in ("entity", "system", "hardware"): + if integration.integration_type in ("entity", "system"): continue if integration.translated_name: diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 368c2f762b8..ee932280201 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -84,37 +84,6 @@ class ImportCollector(ast.NodeVisitor): if name_node.name.startswith("homeassistant.components."): self._add_reference(name_node.name.split(".")[2]) - def visit_Attribute(self, node: ast.Attribute) -> None: - """Visit Attribute node.""" - # hass.components.hue.async_create() - # Name(id=hass) - # .Attribute(attr=hue) - # .Attribute(attr=async_create) - - # self.hass.components.hue.async_create() - # Name(id=self) - # .Attribute(attr=hass) or .Attribute(attr=_hass) - # .Attribute(attr=hue) - # .Attribute(attr=async_create) - if ( - isinstance(node.value, ast.Attribute) - and node.value.attr == "components" - and ( - ( - isinstance(node.value.value, ast.Name) - and node.value.value.id == "hass" - ) - or ( - isinstance(node.value.value, ast.Attribute) - and node.value.value.attr in ("hass", "_hass") - ) - ) - ): - self._add_reference(node.attr) - else: - # Have it visit other kids - self.generic_visit(node) - ALLOWED_USED_COMPONENTS = { *{platform.value for platform in Platform}, @@ -153,8 +122,6 @@ ALLOWED_USED_COMPONENTS = { } IGNORE_VIOLATIONS = { - # Has same requirement, gets defaults. - ("sql", "recorder"), # Sharing a base class ("lutron_caseta", "lutron"), ("ffmpeg_noise", "ffmpeg_motion"), @@ -175,10 +142,6 @@ IGNORE_VIOLATIONS = { "logbook", # Temporary needed for migration until 2024.10 ("conversation", "assist_pipeline"), - # The onboarding integration provides a limited backup API used during - # onboarding. The onboarding integration waits for the backup manager - # to be ready before calling any backup functionality. - ("onboarding", "backup"), } diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 1f177643bd5..5ca638ef487 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG @@ -24,8 +24,8 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,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.25.0 tqdm==4.67.1 ruff==0.9.7 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + stdlib-list==0.10.0 pipdeptree==2.26.1 tqdm==4.67.1 ruff==0.11.0 \ + PyTurboJPEG==1.7.5 go2rtc-client==0.1.3b0 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.5.7 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/quality_scale.py b/script/hassfest/quality_scale.py index 48b0c600371..6bfd2764204 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -256,7 +256,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "coinbase", "color_extractor", "comed_hourly_pricing", - "comelit", "comfoconnect", "command_line", "compensation", @@ -360,7 +359,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "epson", "eq3btsmart", "escea", - "esphome", "etherscan", "eufy", "eufylife_ble", @@ -439,7 +437,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "goalzero", "gogogate2", "goodwe", - "google", "google_assistant", "google_assistant_sdk", "google_cloud", @@ -512,9 +509,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "iglo", "ign_sismologia", "ihc", - "imgw_pib", "improv_ble", - "incomfort", "influxdb", "inkbird", "insteon", @@ -704,7 +699,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "nibe_heatpump", "nice_go", "nightscout", - "niko_home_control", "nilu", "nina", "nissan_leaf", @@ -811,7 +805,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "pushsafer", "pvoutput", "pvpc_hourly_pricing", - "pyload", "qbittorrent", "qingping", "qld_bushfire", @@ -855,7 +848,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "risco", "rituals_perfume_genie", "rmvtransport", - "roborock", "rocketchat", "roku", "romy", @@ -898,7 +890,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "sfr_box", "sharkiq", "shell_command", - "shelly", "shodan", "shopping_list", "sia", @@ -922,11 +913,9 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "sma", "smappee", "smart_meter_texas", - "smartthings", "smarttub", "smarty", "smhi", - "smlight", "sms", "smtp", "snapcast", @@ -975,10 +964,8 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "switch_as_x", "switchbee", "switchbot_cloud", - "switcher_kis", "switchmate", "syncthing", - "syncthru", "synology_chat", "synology_dsm", "synology_srm", @@ -1068,7 +1055,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "upcloud", "upnp", "uptime", - "uptimerobot", "usb", "usgs_earthquakes_feed", "utility_meter", @@ -1089,7 +1075,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "vizio", "vlc", "vlc_telnet", - "vodafone_station", "voicerss", "voip", "volkszaehler", @@ -1111,7 +1096,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "weatherkit", "webmin", "wemo", - "whirlpool", "whois", "wiffi", "wilight", @@ -1313,7 +1297,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "coinbase", "color_extractor", "comed_hourly_pricing", - "comelit", "comfoconnect", "command_line", "compensation", @@ -1410,7 +1393,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "energy", "energyzero", "enigma2", - "enphase_envoy", "enocean", "entur_public_transport", "environment_canada", @@ -1421,7 +1403,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "epson", "eq3btsmart", "escea", - "esphome", "etherscan", "eufy", "eufylife_ble", @@ -1578,9 +1559,7 @@ INTEGRATIONS_WITHOUT_SCALE = [ "ign_sismologia", "ihc", "imap", - "imgw_pib", "improv_ble", - "incomfort", "influxdb", "inkbird", "insteon", @@ -1588,7 +1567,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "intellifire", "intesishome", "ios", - "iron_os", "iotawatt", "iotty", "iperf3", @@ -1889,7 +1867,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "pushsafer", "pvoutput", "pvpc_hourly_pricing", - "pyload", "qbittorrent", "qingping", "qld_bushfire", @@ -1934,7 +1911,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "risco", "rituals_perfume_genie", "rmvtransport", - "roborock", "rocketchat", "roku", "romy", @@ -1964,7 +1940,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "season", "sendgrid", "sense", - "sensibo", "sensirion_ble", "sensorpro", "sensorpush", @@ -2003,11 +1978,9 @@ INTEGRATIONS_WITHOUT_SCALE = [ "sma", "smappee", "smart_meter_texas", - "smartthings", "smarttub", "smarty", "smhi", - "smlight", "sms", "smtp", "snapcast", @@ -2154,7 +2127,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "upcloud", "upnp", "uptime", - "uptimerobot", "usb", "usgs_earthquakes_feed", "utility_meter", @@ -2176,7 +2148,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "vizio", "vlc", "vlc_telnet", - "vodafone_station", "voicerss", "voip", "volkszaehler", @@ -2200,7 +2171,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "webmin", "weheat", "wemo", - "whirlpool", "whois", "wiffi", "wilight", diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 3a0ebed76fe..70f0a63ca76 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -233,7 +233,7 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa ) if service_schema is None: continue - if "name" not in service_schema: + if "name" not in service_schema and integration.core: try: strings["services"][service_name]["name"] except KeyError: @@ -242,7 +242,7 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa f"Service {service_name} has no name {error_msg_suffix}", ) - if "description" not in service_schema: + if "description" not in service_schema and integration.core: try: strings["services"][service_name]["description"] except KeyError: @@ -257,7 +257,7 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa if "fields" in field_schema: # This is a section continue - if "name" not in field_schema: + if "name" not in field_schema and integration.core: try: strings["services"][service_name]["fields"][field_name]["name"] except KeyError: @@ -266,7 +266,7 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa f"Service {service_name} has a field {field_name} with no name {error_msg_suffix}", ) - if "description" not in field_schema: + if "description" not in field_schema and integration.core: try: strings["services"][service_name]["fields"][field_name][ "description" @@ -296,13 +296,14 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa if "fields" not in section_schema: # This is not a section continue - try: - strings["services"][service_name]["sections"][section_name]["name"] - except KeyError: - integration.add_error( - "services", - f"Service {service_name} has a section {section_name} with no name {error_msg_suffix}", - ) + if "name" not in section_schema and integration.core: + try: + strings["services"][service_name]["sections"][section_name]["name"] + except KeyError: + integration.add_error( + "services", + f"Service {service_name} has a section {section_name} with no name {error_msg_suffix}", + ) def validate(integrations: dict[str, Integration], config: Config) -> None: diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index c257f185f51..f4c05f504ca 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -29,6 +29,7 @@ ALLOW_NAME_TRANSLATION = { "cert_expiry", "cpuspeed", "emulated_roku", + "energenie_power_sockets", "faa_delays", "garages_amsterdam", "generic", @@ -40,6 +41,7 @@ ALLOW_NAME_TRANSLATION = { "local_ip", "local_todo", "nmap_tracker", + "remote_calendar", "rpi_power", "swiss_public_transport", "waze_travel_time", diff --git a/script/licenses.py b/script/licenses.py index aa15a58f3bd..44a046a099b 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -88,6 +88,7 @@ OSI_APPROVED_LICENSES_SPDX = { "MPL-1.1", "MPL-2.0", "PSF-2.0", + "Python-2.0", "Unlicense", "Zlib", "ZPL-2.1", @@ -180,7 +181,6 @@ EXCEPTIONS = { "PyMicroBot", # https://github.com/spycle/pyMicroBot/pull/3 "PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16 "PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201 - "aioecowitt", # https://github.com/home-assistant-libs/aioecowitt/pull/180 "chacha20poly1305", # LGPL "commentjson", # https://github.com/vaidik/commentjson/pull/55 "crownstone-cloud", # https://github.com/crownstone/crownstone-lib-python-cloud/pull/5 diff --git a/script/quality_scale_summary.py b/script/quality_scale_summary.py new file mode 100644 index 00000000000..b93eab81451 --- /dev/null +++ b/script/quality_scale_summary.py @@ -0,0 +1,89 @@ +"""Generate a summary of integration quality scales. + +Run with python3 -m script.quality_scale_summary +Data collected at https://docs.google.com/spreadsheets/d/1xEiwovRJyPohAv8S4ad2LAB-0A38s1HWmzHng8v-4NI +""" + +import csv +from pathlib import Path +import sys + +from homeassistant.const import __version__ as current_version +from homeassistant.util.json import load_json + +COMPONENTS_DIR = Path("homeassistant/components") + + +def generate_quality_scale_summary() -> list[str, int]: + """Generate a summary of integration quality scales.""" + quality_scales = { + "virtual": 0, + "unknown": 0, + "legacy": 0, + "internal": 0, + "bronze": 0, + "silver": 0, + "gold": 0, + "platinum": 0, + } + + for manifest_path in COMPONENTS_DIR.glob("*/manifest.json"): + manifest = load_json(manifest_path) + + if manifest.get("integration_type") == "virtual": + quality_scales["virtual"] += 1 + elif quality_scale := manifest.get("quality_scale"): + quality_scales[quality_scale] += 1 + else: + quality_scales["unknown"] += 1 + + return quality_scales + + +def output_csv(quality_scales: dict[str, int], print_header: bool) -> None: + """Output the quality scale summary as CSV.""" + writer = csv.writer(sys.stdout) + if print_header: + writer.writerow( + [ + "Version", + "Total", + "Virtual", + "Unknown", + "Legacy", + "Internal", + "Bronze", + "Silver", + "Gold", + "Platinum", + ] + ) + + # Calculate total + total = sum(quality_scales.values()) + + # Write the summary + writer.writerow( + [ + current_version, + total, + quality_scales["virtual"], + quality_scales["unknown"], + quality_scales["legacy"], + quality_scales["internal"], + quality_scales["bronze"], + quality_scales["silver"], + quality_scales["gold"], + quality_scales["platinum"], + ] + ) + + +def main() -> None: + """Run the script.""" + quality_scales = generate_quality_scale_summary() + output_csv(quality_scales, "--header" in sys.argv) + + +if __name__ == "__main__": + main() diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 7f5355b3cc0..cb96c9396c2 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -10,9 +10,8 @@ from homeassistant.auth.permissions.entities import ( from homeassistant.auth.permissions.models import PermissionLookup from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.entity_registry import RegistryEntry -from tests.common import mock_device_registry, mock_registry +from tests.common import RegistryEntryWithDefaults, mock_device_registry, mock_registry def test_entities_none() -> None: @@ -156,13 +155,13 @@ def test_entities_device_id_boolean(hass: HomeAssistant) -> None: entity_registry = mock_registry( hass, { - "test_domain.allowed": RegistryEntry( + "test_domain.allowed": RegistryEntryWithDefaults( entity_id="test_domain.allowed", unique_id="1234", platform="test_platform", device_id="mock-allowed-dev-id", ), - "test_domain.not_allowed": RegistryEntry( + "test_domain.not_allowed": RegistryEntryWithDefaults( entity_id="test_domain.not_allowed", unique_id="5678", platform="test_platform", @@ -196,7 +195,7 @@ def test_entities_areas_area_true(hass: HomeAssistant) -> None: entity_registry = mock_registry( hass, { - "light.kitchen": RegistryEntry( + "light.kitchen": RegistryEntryWithDefaults( entity_id="light.kitchen", unique_id="1234", platform="test_platform", diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index dd2ce65b480..42a5ba80643 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -19,18 +19,18 @@ from homeassistant.setup import async_setup_component @pytest.fixture -def data(hass: HomeAssistant) -> hass_auth.Data: +async def data(hass: HomeAssistant) -> hass_auth.Data: """Create a loaded data class.""" data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) + await data.async_load() return data @pytest.fixture -def legacy_data(hass: HomeAssistant) -> hass_auth.Data: +async def legacy_data(hass: HomeAssistant) -> hass_auth.Data: """Create a loaded legacy data class.""" data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) + await data.async_load() data.is_legacy = True return data diff --git a/tests/common.py b/tests/common.py index df674d1824c..a80027b2b7e 100644 --- a/tests/common.py +++ b/tests/common.py @@ -29,8 +29,10 @@ from typing import Any, Literal, NoReturn from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 +from annotatedyaml import load_yaml_dict, loader as yaml_loader +import attr import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant import auth, bootstrap, config_entries, loader @@ -45,6 +47,11 @@ from homeassistant.components import device_automation, persistent_notification from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) +from homeassistant.components.logger import ( + DOMAIN as LOGGER_DOMAIN, + SERVICE_SET_LEVEL, + _clear_logger_overwrites, +) from homeassistant.config import IntegrationConfigInfo, async_process_component_config from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import ( @@ -92,7 +99,7 @@ from homeassistant.helpers.entity_platform import ( ) 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 import dt as dt_util, ulid as ulid_util, uuid as uuid_util from homeassistant.util.async_ import ( _SHUTDOWN_RUN_CALLBACK_THREADSAFE, get_scheduled_timer_handles, @@ -109,7 +116,6 @@ from homeassistant.util.json import ( ) from homeassistant.util.signal_type import SignalType from homeassistant.util.unit_system import METRIC_SYSTEM -from homeassistant.util.yaml import load_yaml_dict, loader as yaml_loader from .testing_config.custom_components.test_constant_deprecation import ( import_deprecated_constant, @@ -640,6 +646,34 @@ def mock_registry( return registry +@attr.s(frozen=True, kw_only=True, slots=True) +class RegistryEntryWithDefaults(er.RegistryEntry): + """Helper to create a registry entry with defaults.""" + + capabilities: Mapping[str, Any] | None = attr.ib(default=None) + config_entry_id: str | None = attr.ib(default=None) + config_subentry_id: str | None = attr.ib(default=None) + created_at: datetime = attr.ib(factory=dt_util.utcnow) + device_id: str | None = attr.ib(default=None) + disabled_by: er.RegistryEntryDisabler | None = attr.ib(default=None) + entity_category: er.EntityCategory | None = attr.ib(default=None) + hidden_by: er.RegistryEntryHider | None = attr.ib(default=None) + id: str = attr.ib( + default=None, + converter=attr.converters.default_if_none(factory=uuid_util.random_uuid_hex), # type: ignore[misc] + ) + has_entity_name: bool = attr.ib(default=False) + options: er.ReadOnlyEntityOptionsType = attr.ib( + default=None, converter=er._protect_entity_options + ) + original_device_class: str | None = attr.ib(default=None) + original_icon: str | None = attr.ib(default=None) + original_name: str | None = attr.ib(default=None) + supported_features: int = attr.ib(default=0) + translation_key: str | None = attr.ib(default=None) + unit_of_measurement: str | None = attr.ib(default=None) + + def mock_area_registry( hass: HomeAssistant, mock_entries: dict[str, ar.AreaEntry] | None = None ) -> ar.AreaRegistry: @@ -1688,6 +1722,28 @@ def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state) +@asynccontextmanager +async def async_call_logger_set_level( + logger: str, + level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "FATAL", "CRITICAL"], + *, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> AsyncGenerator[None]: + """Context manager to reset loggers after logger.set_level call.""" + assert LOGGER_DOMAIN in hass.data, "'logger' integration not setup" + with caplog.at_level(logging.NOTSET, logger): + await hass.services.async_call( + LOGGER_DOMAIN, + SERVICE_SET_LEVEL, + {logger: level}, + blocking=True, + ) + await hass.async_block_till_done() + yield + _clear_logger_overwrites(hass) + + def import_and_test_deprecated_constant_enum( caplog: pytest.LogCaptureFixture, module: ModuleType, @@ -1884,3 +1940,16 @@ def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]: ) for rule, details in raw["rules"].items() } + + +def get_schema_suggested_value(schema: vol.Schema, key: str) -> Any | None: + """Get suggested value for key in voluptuous schema.""" + for schema_key in schema: + if schema_key == key: + if ( + schema_key.description is None + or "suggested_value" not in schema_key.description + ): + return None + return schema_key.description["suggested_value"] + return None diff --git a/tests/components/acaia/test_binary_sensor.py b/tests/components/acaia/test_binary_sensor.py index a7aa7034d8d..a03e18b40bc 100644 --- a/tests/components/acaia/test_binary_sensor.py +++ b/tests/components/acaia/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/acaia/test_button.py b/tests/components/acaia/test_button.py index f68f85e253d..171db32913d 100644 --- a/tests/components/acaia/test_button.py +++ b/tests/components/acaia/test_button.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ( diff --git a/tests/components/acaia/test_diagnostics.py b/tests/components/acaia/test_diagnostics.py index 77f6306b068..c628729ec66 100644 --- a/tests/components/acaia/test_diagnostics.py +++ b/tests/components/acaia/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Acaia integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/acaia/test_init.py b/tests/components/acaia/test_init.py index 8ad988d3b9b..d035630af56 100644 --- a/tests/components/acaia/test_init.py +++ b/tests/components/acaia/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.acaia.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/acaia/test_sensor.py b/tests/components/acaia/test_sensor.py index 2f5a851121c..79073937511 100644 --- a/tests/components/acaia/test_sensor.py +++ b/tests/components/acaia/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import PERCENTAGE, Platform from homeassistant.core import HomeAssistant, State diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr index 257d29ae844..cbd2e14207e 100644 --- a/tests/components/accuweather/snapshots/test_sensor.ambr +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -7,11 +7,10 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'very_unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -50,11 +49,10 @@ 'friendly_name': 'Home Air quality day 0', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'very_unhealthy', + 'hazardous', ]), }), 'context': , @@ -73,11 +71,10 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'very_unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -116,11 +113,10 @@ 'friendly_name': 'Home Air quality day 1', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'very_unhealthy', + 'hazardous', ]), }), 'context': , @@ -139,11 +135,10 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'very_unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -182,11 +177,10 @@ 'friendly_name': 'Home Air quality day 2', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'very_unhealthy', + 'hazardous', ]), }), 'context': , @@ -205,11 +199,10 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'very_unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -248,11 +241,10 @@ 'friendly_name': 'Home Air quality day 3', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'very_unhealthy', + 'hazardous', ]), }), 'context': , @@ -271,11 +263,10 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'very_unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -314,11 +305,10 @@ 'friendly_name': 'Home Air quality day 4', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'very_unhealthy', + 'hazardous', ]), }), 'context': , diff --git a/tests/components/accuweather/test_diagnostics.py b/tests/components/accuweather/test_diagnostics.py index bc97ae1fe14..3f8b54c1a10 100644 --- a/tests/components/accuweather/test_diagnostics.py +++ b/tests/components/accuweather/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 37ebe260f39..87737c2f40c 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -6,7 +6,7 @@ from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError from aiohttp.client_exceptions import ClientConnectorError from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.accuweather.const import ( UPDATE_INTERVAL_DAILY_FORECAST, diff --git a/tests/components/adax/__init__.py b/tests/components/adax/__init__.py index 54a72856a85..60cc24b6dd0 100644 --- a/tests/components/adax/__init__.py +++ b/tests/components/adax/__init__.py @@ -1 +1,12 @@ """Tests for the Adax integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up the Adax integration in Home Assistant.""" + 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/adax/conftest.py b/tests/components/adax/conftest.py new file mode 100644 index 00000000000..64cbf96e9c4 --- /dev/null +++ b/tests/components/adax/conftest.py @@ -0,0 +1,89 @@ +"""Fixtures for Adax testing.""" + +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.adax.const import ( + ACCOUNT_ID, + CLOUD, + CONNECTION_TYPE, + DOMAIN, + LOCAL, +) +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_TOKEN, + CONF_UNIQUE_ID, +) + +from tests.common import AsyncMock, MockConfigEntry + +CLOUD_CONFIG = { + ACCOUNT_ID: 12345, + CONF_PASSWORD: "pswd", + CONNECTION_TYPE: CLOUD, +} + +LOCAL_CONFIG = { + CONF_IP_ADDRESS: "192.168.1.12", + CONF_TOKEN: "TOKEN-123", + CONF_UNIQUE_ID: "11:22:33:44:55:66", + CONNECTION_TYPE: LOCAL, +} + + +CLOUD_DEVICE_DATA: dict[str, Any] = [ + { + "id": "1", + "homeId": "1", + "name": "Room 1", + "temperature": 15, + "targetTemperature": 20, + "heatingEnabled": True, + } +] + +LOCAL_DEVICE_DATA: dict[str, Any] = { + "current_temperature": 15, + "target_temperature": 20, +} + + +@pytest.fixture +def mock_cloud_config_entry(request: pytest.FixtureRequest) -> MockConfigEntry: + """Mock a "CLOUD" config entry.""" + return MockConfigEntry(domain=DOMAIN, data=CLOUD_CONFIG) + + +@pytest.fixture +def mock_local_config_entry(request: pytest.FixtureRequest) -> MockConfigEntry: + """Mock a "LOCAL" config entry.""" + return MockConfigEntry(domain=DOMAIN, data=LOCAL_CONFIG) + + +@pytest.fixture +def mock_adax_cloud(): + """Mock climate data.""" + with patch("homeassistant.components.adax.coordinator.Adax") as mock_adax: + mock_adax_class = mock_adax.return_value + + mock_adax_class.get_rooms = AsyncMock() + mock_adax_class.get_rooms.return_value = CLOUD_DEVICE_DATA + + mock_adax_class.update = AsyncMock() + mock_adax_class.update.return_value = None + yield mock_adax_class + + +@pytest.fixture +def mock_adax_local(): + """Mock climate data.""" + with patch("homeassistant.components.adax.coordinator.AdaxLocal") as mock_adax: + mock_adax_class = mock_adax.return_value + + mock_adax_class.get_status = AsyncMock() + mock_adax_class.get_status.return_value = LOCAL_DEVICE_DATA + yield mock_adax_class diff --git a/tests/components/adax/test_climate.py b/tests/components/adax/test_climate.py new file mode 100644 index 00000000000..dd5cc3ff387 --- /dev/null +++ b/tests/components/adax/test_climate.py @@ -0,0 +1,85 @@ +"""Test Adax climate entity.""" + +from homeassistant.components.adax.const import SCAN_INTERVAL +from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode +from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import CLOUD_DEVICE_DATA, LOCAL_DEVICE_DATA + +from tests.common import AsyncMock, MockConfigEntry, async_fire_time_changed +from tests.test_setup import FrozenDateTimeFactory + + +async def test_climate_cloud( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_cloud_config_entry: MockConfigEntry, + mock_adax_cloud: AsyncMock, +) -> None: + """Test states of the (cloud) Climate entity.""" + await setup_integration(hass, mock_cloud_config_entry) + mock_adax_cloud.get_rooms.assert_called_once() + + assert len(hass.states.async_entity_ids(Platform.CLIMATE)) == 1 + entity_id = hass.states.async_entity_ids(Platform.CLIMATE)[0] + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.HEAT + assert ( + state.attributes[ATTR_TEMPERATURE] == CLOUD_DEVICE_DATA[0]["targetTemperature"] + ) + assert ( + state.attributes[ATTR_CURRENT_TEMPERATURE] + == CLOUD_DEVICE_DATA[0]["temperature"] + ) + + mock_adax_cloud.get_rooms.side_effect = Exception() + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_climate_local( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_local_config_entry: MockConfigEntry, + mock_adax_local: AsyncMock, +) -> None: + """Test states of the (local) Climate entity.""" + await setup_integration(hass, mock_local_config_entry) + mock_adax_local.get_status.assert_called_once() + + assert len(hass.states.async_entity_ids(Platform.CLIMATE)) == 1 + entity_id = hass.states.async_entity_ids(Platform.CLIMATE)[0] + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == HVACMode.HEAT + assert ( + state.attributes[ATTR_TEMPERATURE] == (LOCAL_DEVICE_DATA["target_temperature"]) + ) + assert ( + state.attributes[ATTR_CURRENT_TEMPERATURE] + == (LOCAL_DEVICE_DATA["current_temperature"]) + ) + + mock_adax_local.get_status.side_effect = Exception() + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/advantage_air/snapshots/test_climate.ambr b/tests/components/advantage_air/snapshots/test_climate.ambr index bd1fb431ae1..b2559b5bdfd 100644 --- a/tests/components/advantage_air/snapshots/test_climate.ambr +++ b/tests/components/advantage_air/snapshots/test_climate.ambr @@ -30,6 +30,7 @@ 'auto', ]), 'friendly_name': 'myauto', + 'hvac_action': , 'hvac_modes': list([ , , diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index fc9aaade634..69094a80d30 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from advantage_air import ApiError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.advantage_air.climate import ADVANTAGE_AIR_MYAUTO from homeassistant.components.climate import ( diff --git a/tests/components/advantage_air/test_switch.py b/tests/components/advantage_air/test_switch.py index ecc652b3d9e..ea0bd558c8f 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/aemet/test_diagnostics.py b/tests/components/aemet/test_diagnostics.py index 6d007dd0465..a51d95f446e 100644 --- a/tests/components/aemet/test_diagnostics.py +++ b/tests/components/aemet/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.aemet.const import DOMAIN diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr index 4e0c8027b43..b3181fddfeb 100644 --- a/tests/components/airgradient/snapshots/test_init.ambr +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -6,6 +6,10 @@ 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ + tuple( + 'mac', + '84:fc:e6:12:f5:b8', + ), }), 'disabled_by': None, 'entry_type': None, @@ -39,6 +43,10 @@ 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ + tuple( + 'mac', + '84:fc:e6:12:f5:b8', + ), }), 'disabled_by': None, 'entry_type': None, diff --git a/tests/components/airgradient/test_button.py b/tests/components/airgradient/test_button.py index 2440669b6e8..51fbd87ba67 100644 --- a/tests/components/airgradient/test_button.py +++ b/tests/components/airgradient/test_button.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS diff --git a/tests/components/airgradient/test_diagnostics.py b/tests/components/airgradient/test_diagnostics.py index 34a9bb7aab2..e8fb2581a99 100644 --- a/tests/components/airgradient/test_diagnostics.py +++ b/tests/components/airgradient/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/airgradient/test_init.py b/tests/components/airgradient/test_init.py index a121940f2bc..a253cb2888a 100644 --- a/tests/components/airgradient/test_init.py +++ b/tests/components/airgradient/test_init.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/airgradient/test_number.py b/tests/components/airgradient/test_number.py index 2cbd72d033a..6fa1a7d3e07 100644 --- a/tests/components/airgradient/test_number.py +++ b/tests/components/airgradient/test_number.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.number import ( diff --git a/tests/components/airgradient/test_select.py b/tests/components/airgradient/test_select.py index b8ae2cefa4e..8782af4e46a 100644 --- a/tests/components/airgradient/test_select.py +++ b/tests/components/airgradient/test_select.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.select import ( diff --git a/tests/components/airgradient/test_sensor.py b/tests/components/airgradient/test_sensor.py index e3fed70839a..7679ba48546 100644 --- a/tests/components/airgradient/test_sensor.py +++ b/tests/components/airgradient/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from airgradient import AirGradientError, Measures from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform diff --git a/tests/components/airgradient/test_switch.py b/tests/components/airgradient/test_switch.py index 475f38f554c..12b319379f6 100644 --- a/tests/components/airgradient/test_switch.py +++ b/tests/components/airgradient/test_switch.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN diff --git a/tests/components/airgradient/test_update.py b/tests/components/airgradient/test_update.py index 020a9a82a71..65614312b46 100644 --- a/tests/components/airgradient/test_update.py +++ b/tests/components/airgradient/test_update.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/airly/test_diagnostics.py b/tests/components/airly/test_diagnostics.py index 9a61bf5abee..13656f90a68 100644 --- a/tests/components/airly/test_diagnostics.py +++ b/tests/components/airly/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Airly diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index 19f073496db..f45bbb65f6f 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -5,7 +5,7 @@ from http import HTTPStatus from unittest.mock import patch from airly.exceptions import AirlyError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/airnow/test_diagnostics.py b/tests/components/airnow/test_diagnostics.py index eb79dabe51a..5f3ccf5fbe0 100644 --- a/tests/components/airnow/test_diagnostics.py +++ b/tests/components/airnow/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py index 081e1bfd86d..a96fe33c9d0 100644 --- a/tests/components/airthings/test_config_flow.py +++ b/tests/components/airthings/test_config_flow.py @@ -3,12 +3,14 @@ from unittest.mock import patch import airthings +import pytest from homeassistant import config_entries from homeassistant.components.airthings.const import CONF_SECRET, DOMAIN from homeassistant.const import CONF_ID 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 @@ -17,6 +19,24 @@ TEST_DATA = { CONF_SECRET: "secret", } +DHCP_SERVICE_INFO = [ + DhcpServiceInfo( + hostname="airthings-view", + ip="192.168.1.100", + macaddress="00:00:00:00:00:00", + ), + DhcpServiceInfo( + hostname="airthings-hub", + ip="192.168.1.101", + macaddress="D0:14:11:90:00:00", + ), + DhcpServiceInfo( + hostname="airthings-hub", + ip="192.168.1.102", + macaddress="70:B3:D5:2A:00:00", + ), +] + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -37,15 +57,15 @@ async def test_form(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_DATA, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Airthings" - assert result2["data"] == TEST_DATA + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings" + assert result["data"] == TEST_DATA assert len(mock_setup_entry.mock_calls) == 1 @@ -59,13 +79,13 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "airthings.get_token", side_effect=airthings.AirthingsAuthError, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_DATA, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -78,13 +98,13 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "airthings.get_token", side_effect=airthings.AirthingsConnectionError, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_DATA, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} async def test_form_unknown_error(hass: HomeAssistant) -> None: @@ -97,13 +117,13 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None: "airthings.get_token", side_effect=Exception, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_DATA, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: @@ -123,3 +143,59 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("dhcp_service_info", DHCP_SERVICE_INFO) +async def test_dhcp_flow( + hass: HomeAssistant, dhcp_service_info: DhcpServiceInfo +) -> None: + """Test the DHCP discovery flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=dhcp_service_info, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with ( + patch( + "homeassistant.components.airthings.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "airthings.get_token", + return_value="test_token", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings" + assert result["data"] == TEST_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_flow_hub_already_configured(hass: HomeAssistant) -> None: + """Test that DHCP discovery fails when already configured.""" + + first_entry = MockConfigEntry( + domain="airthings", + data=TEST_DATA, + unique_id=TEST_DATA[CONF_ID], + ) + first_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO[0], + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index 314594c612f..2adc5498e7b 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -159,7 +159,6 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: domain=DOMAIN, unique_id="cc:cc:cc:cc:cc:cc", source=SOURCE_IGNORE, - data={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"}, ) entry.add_to_hass(hass) with ( diff --git a/tests/components/airtouch5/test_cover.py b/tests/components/airtouch5/test_cover.py index 57a344e8018..8c76ec4fb38 100644 --- a/tests/components/airtouch5/test_cover.py +++ b/tests/components/airtouch5/test_cover.py @@ -8,7 +8,7 @@ from airtouch5py.packets.zone_status import ( ZonePowerState, ZoneStatusZone, ) -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, diff --git a/tests/components/airvisual/test_diagnostics.py b/tests/components/airvisual/test_diagnostics.py index 0253f102c59..f5239ea7658 100644 --- a/tests/components/airvisual/test_diagnostics.py +++ b/tests/components/airvisual/test_diagnostics.py @@ -1,6 +1,6 @@ """Test AirVisual diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/airvisual_pro/test_diagnostics.py b/tests/components/airvisual_pro/test_diagnostics.py index 372b62eaf38..73893eb4bd2 100644 --- a/tests/components/airvisual_pro/test_diagnostics.py +++ b/tests/components/airvisual_pro/test_diagnostics.py @@ -1,6 +1,6 @@ """Test AirVisual Pro diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index b4976c07e1b..09dea8c354c 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -44,9 +44,11 @@ }), dict({ 'air_demand': 1, + 'battery': 99, 'coldStage': 1, 'coldStages': 1, 'coldangle': 2, + 'coverage': 72, 'errors': list([ ]), 'floor_demand': 1, @@ -73,9 +75,11 @@ }), dict({ 'air_demand': 0, + 'battery': 35, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 60, 'errors': list([ ]), 'floor_demand': 0, @@ -100,9 +104,11 @@ }), dict({ 'air_demand': 0, + 'battery': 25, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 88, 'errors': list([ dict({ 'Zone': 'Low battery', @@ -130,9 +136,11 @@ }), dict({ 'air_demand': 0, + 'battery': 80, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 66, 'errors': list([ ]), 'floor_demand': 0, @@ -497,9 +505,11 @@ 'temp-set': 19.2, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 99, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 72, }), '1:3': dict({ 'absolute-temp-max': 30.0, @@ -546,9 +556,11 @@ 'temp-set': 19.3, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 35, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 60, }), '1:4': dict({ 'absolute-temp-max': 86.0, @@ -597,9 +609,11 @@ 'temp-set': 66.9, 'temp-step': 1.0, 'temp-unit': 1, + 'thermostat-battery': 25, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 88, }), '1:5': dict({ 'absolute-temp-max': 30.0, @@ -645,9 +659,11 @@ 'temp-set': 19.5, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 80, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 66, }), '2:1': dict({ 'absolute-temp-max': 30.0, diff --git a/tests/components/airzone/snapshots/test_sensor.ambr b/tests/components/airzone/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..01ebf35b282 --- /dev/null +++ b/tests/components/airzone/snapshots/test_sensor.ambr @@ -0,0 +1,1245 @@ +# serializer version: 1 +# name: test_airzone_create_sensors[sensor.airzone_2_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_2_1_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_2:1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Airzone 2:1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.airzone_2_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_2_1_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_2:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airzone 2:1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airzone_2_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.3', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_dhw_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airzone_dhw_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_dhw_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_dhw_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airzone DHW Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airzone_dhw_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_webserver_rssi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airzone_webserver_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rssi', + 'unique_id': 'airzone_unique_id_ws_wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_webserver_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Airzone WebServer RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.airzone_webserver_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-42', + }) +# --- +# name: test_airzone_create_sensors[sensor.aux_heat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aux_heat_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_4:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.aux_heat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Aux Heat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aux_heat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Despacho Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Despacho Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.despacho_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:4_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Despacho Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.despacho_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Despacho Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.despacho_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.20', + }) +# --- +# name: test_airzone_create_sensors[sensor.dkn_plus_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dkn_plus_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_3:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dkn_plus_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'DKN Plus Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dkn_plus_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.7', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm #1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm #1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_1_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:3_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm #1 Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_1_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm #1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.8', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm #2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm #2 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_2_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:5_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm #2 Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_2_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm #2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.5', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm Ppal Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm Ppal Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dorm_ppal_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:2_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm Ppal Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dorm_ppal_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm Ppal Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.1', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.salon_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Salon Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.salon_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.salon_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': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Salon Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.salon_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.6', + }) +# --- diff --git a/tests/components/airzone/test_diagnostics.py b/tests/components/airzone/test_diagnostics.py index bca75bca778..bd7bea13a48 100644 --- a/tests/components/airzone/test_diagnostics.py +++ b/tests/components/airzone/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import patch from aioairzone.const import RAW_HVAC, RAW_VERSION, RAW_WEBSERVER -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.airzone.const import DOMAIN diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 352994d6313..b226be8ac78 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -1,14 +1,17 @@ """The sensor tests for the Airzone platform.""" +from collections.abc import Generator import copy from unittest.mock import patch from aioairzone.const import API_DATA, API_SYSTEMS import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airzone.coordinator import SCAN_INTERVAL -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 homeassistant.util.dt import utcnow from .util import ( @@ -20,62 +23,27 @@ from .util import ( async_init_integration, ) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.airzone.PLATFORMS", [Platform.SENSOR]): + yield @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_airzone_create_sensors(hass: HomeAssistant) -> None: +async def test_airzone_create_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: """Test creation of sensors.""" - await async_init_integration(hass) + config_entry = await async_init_integration(hass) - # Hot Water - state = hass.states.get("sensor.airzone_dhw_temperature") - assert state.state == "43" - - # WebServer - state = hass.states.get("sensor.airzone_webserver_rssi") - assert state.state == "-42" - - # Zones - state = hass.states.get("sensor.despacho_temperature") - assert state.state == "21.20" - - state = hass.states.get("sensor.despacho_humidity") - assert state.state == "36" - - state = hass.states.get("sensor.dorm_1_temperature") - assert state.state == "20.8" - - state = hass.states.get("sensor.dorm_1_humidity") - assert state.state == "35" - - state = hass.states.get("sensor.dorm_2_temperature") - assert state.state == "20.5" - - state = hass.states.get("sensor.dorm_2_humidity") - assert state.state == "40" - - state = hass.states.get("sensor.dorm_ppal_temperature") - assert state.state == "21.1" - - state = hass.states.get("sensor.dorm_ppal_humidity") - assert state.state == "39" - - state = hass.states.get("sensor.salon_temperature") - assert state.state == "19.6" - - state = hass.states.get("sensor.salon_humidity") - assert state.state == "34" - - state = hass.states.get("sensor.airzone_2_1_temperature") - assert state.state == "22.3" - - state = hass.states.get("sensor.airzone_2_1_humidity") - assert state.state == "62" - - state = hass.states.get("sensor.dkn_plus_temperature") - assert state.state == "21.7" + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) state = hass.states.get("sensor.dkn_plus_humidity") assert state is None diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 50d1964924d..55cb32b67a5 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -11,12 +11,14 @@ from aioairzone.const import ( API_ACS_SET_POINT, API_ACS_TEMP, API_AIR_DEMAND, + API_BATTERY, API_COLD_ANGLE, API_COLD_STAGE, API_COLD_STAGES, API_COOL_MAX_TEMP, API_COOL_MIN_TEMP, API_COOL_SET_POINT, + API_COVERAGE, API_DATA, API_ERRORS, API_FLOOR_DEMAND, @@ -119,6 +121,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 99, + API_COVERAGE: 72, API_ON: 1, API_MAX_TEMP: 30, API_MIN_TEMP: 15, @@ -147,6 +151,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 35, + API_COVERAGE: 60, API_ON: 1, API_MAX_TEMP: 30, API_MIN_TEMP: 15, @@ -173,6 +179,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 25, + API_COVERAGE: 88, API_ON: 0, API_MAX_TEMP: 86, API_MIN_TEMP: 59, @@ -203,6 +211,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 80, + API_COVERAGE: 66, API_ON: 0, API_MAX_TEMP: 30, API_MIN_TEMP: 15, @@ -361,7 +371,7 @@ HVAC_WEBSERVER_MOCK = { async def async_init_integration( hass: HomeAssistant, -) -> None: +) -> MockConfigEntry: """Set up the Airzone integration in Home Assistant.""" config_entry = MockConfigEntry( @@ -397,3 +407,5 @@ async def async_init_integration( ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py index d3e23fc7f4b..eb997ab1b73 100644 --- a/tests/components/airzone_cloud/test_diagnostics.py +++ b/tests/components/airzone_cloud/test_diagnostics.py @@ -14,7 +14,7 @@ from aioairzone_cloud.const import ( RAW_INSTALLATIONS_LIST, RAW_WEBSERVERS, ) -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.airzone_cloud.const import DOMAIN diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 168d7ecc269..01d103d01aa 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -1,7 +1,6 @@ """Test for the alarm control panel const module.""" from typing import Any -from unittest.mock import patch import pytest @@ -23,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_registry as er, frame +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import UNDEFINED, UndefinedType from . import help_async_setup_entry_init, help_async_unload_entry @@ -222,7 +221,6 @@ 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, @@ -238,7 +236,6 @@ async def test_alarm_control_panel_not_log_deprecated_state_warning( @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, @@ -292,18 +289,17 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop assert state is not None 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 + " enum. This will stop working in Home Assistant 2025.11, please report it to" + " the author of the 'test' custom integration" 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, @@ -345,6 +341,7 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform( hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True @@ -355,7 +352,7 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state assert state is not None assert ( - "Detected that custom integration 'alarm_control_panel' is setting state directly." + "Detected that custom integration 'test' is setting state directly." not in caplog.text ) @@ -364,14 +361,14 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state ) assert ( - "Detected that custom integration 'alarm_control_panel' is setting state directly." + "Detected that custom integration 'test' 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 + " and return its state using the AlarmControlPanelState enum. " + "This will stop working in Home Assistant 2025.11, please report " + "it to the author of the 'test' custom integration" in caplog.text ) caplog.clear() await help_test_async_alarm_control_panel_service( @@ -379,13 +376,12 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state ) # Test we only log once assert ( - "Detected that custom integration 'alarm_control_panel' is setting state directly." + "Detected that custom integration 'test' 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, @@ -428,6 +424,7 @@ async def test_alarm_control_panel_deprecated_state_does_not_break_state( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform( hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 27997a093e5..4407775a582 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -28,6 +28,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import MockEntityPlatform, async_mock_service @@ -116,6 +117,35 @@ async def test_silence(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> assert hass.states.get(ENTITY_ID).state == STATE_ON +async def test_silence_can_acknowledge_false(hass: HomeAssistant) -> None: + """Test that attempting to silence an alert with can_acknowledge=False will not silence.""" + # Create copy of config where can_acknowledge is False + config = deepcopy(TEST_CONFIG) + config[DOMAIN][NAME]["can_acknowledge"] = False + + # Setup the alert component + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Ensure the alert is currently on + hass.states.async_set(ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_ON + + # Attempt to acknowledge + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + # The state should still be ON because can_acknowledge=False + assert hass.states.get(ENTITY_ID).state == STATE_ON + + async def test_reset(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> None: """Test resetting the alert.""" assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index e76ed4ba6d0..c0f206ee4e2 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -1,6 +1,5 @@ """The tests for the Alexa component.""" -from asyncio import AbstractEventLoop import datetime from http import HTTPStatus @@ -24,13 +23,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client( - event_loop: AbstractEventLoop, +async def alexa_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Initialize a Home Assistant server for testing this module.""" - loop = event_loop @callback def mock_service(call): @@ -38,38 +35,36 @@ def alexa_client( hass.services.async_register("test", "alexa", mock_service) - assert loop.run_until_complete( - async_setup_component( - hass, - alexa.DOMAIN, - { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "alexa": { - "flash_briefings": { - "password": "pass/abc", - "weather": [ - { - "title": "Weekly forecast", - "text": "This week it will be sunny.", - }, - { - "title": "Current conditions", - "text": "Currently it is 80 degrees fahrenheit.", - }, - ], - "news_audio": { - "title": "NPR", - "audio": NPR_NEWS_MP3_URL, - "display_url": "https://npr.org", - "uid": "uuid", + assert await async_setup_component( + hass, + alexa.DOMAIN, + { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": { + "flash_briefings": { + "password": "pass/abc", + "weather": [ + { + "title": "Weekly forecast", + "text": "This week it will be sunny.", }, - } - }, + { + "title": "Current conditions", + "text": "Currently it is 80 degrees fahrenheit.", + }, + ], + "news_audio": { + "title": "NPR", + "audio": NPR_NEWS_MP3_URL, + "display_url": "https://npr.org", + "uid": "uuid", + }, + } }, - ) + }, ) - return loop.run_until_complete(hass_client()) + return await hass_client() def _flash_briefing_req(client, briefing_id, password="pass%2Fabc"): diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index b82048dca9b..9c9a292c456 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -1,6 +1,5 @@ """The tests for the Alexa component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json @@ -30,13 +29,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client( - event_loop: AbstractEventLoop, +async def alexa_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Initialize a Home Assistant server for testing this module.""" - loop = event_loop @callback def mock_service(call): @@ -44,96 +41,92 @@ def alexa_client( hass.services.async_register("test", "alexa", mock_service) - assert loop.run_until_complete( - async_setup_component( - hass, - alexa.DOMAIN, - { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "alexa": {}, - }, - ) + assert await async_setup_component( + hass, + alexa.DOMAIN, + { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": {}, + }, ) - assert loop.run_until_complete( - async_setup_component( - hass, - "intent_script", - { - "intent_script": { - "WhereAreWeIntent": { - "speech": { - "type": "plain", - "text": """ - {%- if is_state("device_tracker.paulus", "home") - and is_state("device_tracker.anne_therese", - "home") -%} - You are both home, you silly - {%- else -%} - Anne Therese is at {{ - states("device_tracker.anne_therese") - }} and Paulus is at {{ - states("device_tracker.paulus") - }} - {% endif %} - """, - } + assert await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "WhereAreWeIntent": { + "speech": { + "type": "plain", + "text": """ + {%- if is_state("device_tracker.paulus", "home") + and is_state("device_tracker.anne_therese", + "home") -%} + You are both home, you silly + {%- else -%} + Anne Therese is at {{ + states("device_tracker.anne_therese") + }} and Paulus is at {{ + states("device_tracker.paulus") + }} + {% endif %} + """, + } + }, + "GetZodiacHoroscopeIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign }}.", + } + }, + "GetZodiacHoroscopeIDIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign_Id }}.", + } + }, + "AMAZON.PlaybackAction": { + "speech": { + "type": "plain", + "text": "Playing {{ object_byArtist_name }}.", + } + }, + "CallServiceIntent": { + "speech": { + "type": "plain", + "text": "Service called for {{ ZodiacSign }}", }, - "GetZodiacHoroscopeIntent": { - "speech": { - "type": "plain", - "text": "You told us your sign is {{ ZodiacSign }}.", - } + "card": { + "type": "simple", + "title": "Card title for {{ ZodiacSign }}", + "content": "Card content: {{ ZodiacSign }}", }, - "GetZodiacHoroscopeIDIntent": { - "speech": { - "type": "plain", - "text": "You told us your sign is {{ ZodiacSign_Id }}.", - } + "action": { + "service": "test.alexa", + "data_template": {"hello": "{{ ZodiacSign }}"}, + "entity_id": "switch.test", }, - "AMAZON.PlaybackAction": { - "speech": { - "type": "plain", - "text": "Playing {{ object_byArtist_name }}.", - } + }, + APPLICATION_ID: { + "speech": { + "type": "plain", + "text": "LaunchRequest has been received.", + } + }, + APPLICATION_ID_SESSION_OPEN: { + "speech": { + "type": "plain", + "text": "LaunchRequest has been received.", }, - "CallServiceIntent": { - "speech": { - "type": "plain", - "text": "Service called for {{ ZodiacSign }}", - }, - "card": { - "type": "simple", - "title": "Card title for {{ ZodiacSign }}", - "content": "Card content: {{ ZodiacSign }}", - }, - "action": { - "service": "test.alexa", - "data_template": {"hello": "{{ ZodiacSign }}"}, - "entity_id": "switch.test", - }, + "reprompt": { + "type": "plain", + "text": "LaunchRequest has been received.", }, - APPLICATION_ID: { - "speech": { - "type": "plain", - "text": "LaunchRequest has been received.", - } - }, - APPLICATION_ID_SESSION_OPEN: { - "speech": { - "type": "plain", - "text": "LaunchRequest has been received.", - }, - "reprompt": { - "type": "plain", - "text": "LaunchRequest has been received.", - }, - }, - } - }, - ) + }, + } + }, ) - return loop.run_until_complete(hass_client()) + return await hass_client() def _intent_req(client, data=None): diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr index 8637471cc60..ddf05c99b88 100644 --- a/tests/components/ambient_network/snapshots/test_sensor.ambr +++ b/tests/components/ambient_network/snapshots/test_sensor.ambr @@ -815,7 +815,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -854,6 +856,7 @@ 'device_class': 'wind_direction', 'friendly_name': 'Station A Wind direction', 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -1800,7 +1803,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1839,6 +1844,7 @@ 'device_class': 'wind_direction', 'friendly_name': 'Station C Wind direction', 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -2722,7 +2728,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2760,6 +2768,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'wind_direction', 'friendly_name': 'Station D Wind direction', + 'state_class': , 'unit_of_measurement': '°', }), 'context': , diff --git a/tests/components/ambient_station/test_diagnostics.py b/tests/components/ambient_station/test_diagnostics.py index 82db72eb9ca..14e4dd55f73 100644 --- a/tests/components/ambient_station/test_diagnostics.py +++ b/tests/components/ambient_station/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Ambient PWS diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.ambient_station import AmbientStationConfigEntry diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index ba7e46bdde7..e56df37fe44 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock, PropertyMock, patch import aiohttp from awesomeversion import AwesomeVersion import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.matchers import path_type from homeassistant.components.analytics.analytics import Analytics diff --git a/tests/components/analytics_insights/fixtures/current_data.json b/tests/components/analytics_insights/fixtures/current_data.json index c652a8c0154..ff1baca49ed 100644 --- a/tests/components/analytics_insights/fixtures/current_data.json +++ b/tests/components/analytics_insights/fixtures/current_data.json @@ -1050,7 +1050,6 @@ "melnor": 42, "plaato": 45, "freedompro": 26, - "sunweg": 3, "logi_circle": 18, "proxy": 16, "statsd": 4, diff --git a/tests/components/analytics_insights/test_sensor.py b/tests/components/analytics_insights/test_sensor.py index bf82e0c2d65..ce41afeb272 100644 --- a/tests/components/analytics_insights/test_sensor.py +++ b/tests/components/analytics_insights/test_sensor.py @@ -9,7 +9,7 @@ from python_homeassistant_analytics import ( HomeassistantAnalyticsConnectionError, HomeassistantAnalyticsNotModifiedError, ) -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/androidtv_remote/test_media_player.py b/tests/components/androidtv_remote/test_media_player.py index e292a5b273f..2af8aeb2f56 100644 --- a/tests/components/androidtv_remote/test_media_player.py +++ b/tests/components/androidtv_remote/test_media_player.py @@ -355,6 +355,7 @@ async def test_browse_media( "children_media_class": "app", "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "not_shown": 0, "children": [ @@ -366,6 +367,7 @@ async def test_browse_media( "children_media_class": None, "can_play": False, "can_expand": False, + "can_search": False, "thumbnail": "https://www.youtube.com/icon.png", }, { @@ -376,6 +378,7 @@ async def test_browse_media( "children_media_class": None, "can_play": False, "can_expand": False, + "can_search": False, "thumbnail": "", }, ], @@ -391,7 +394,9 @@ async def test_media_player_connection_closed( assert mock_config_entry.state is ConfigEntryState.LOADED mock_api.send_key_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "media_player", "media_pause", @@ -400,7 +405,9 @@ async def test_media_player_connection_closed( ) mock_api.send_launch_app_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "media_player", "play_media", diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index b3c3ce1c283..9bd86bb3d85 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -183,7 +183,9 @@ async def test_remote_connection_closed( assert mock_config_entry.state is ConfigEntryState.LOADED mock_api.send_key_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "remote", "send_command", @@ -197,7 +199,9 @@ async def test_remote_connection_closed( assert mock_api.send_key_command.mock_calls == [call("DPAD_LEFT", "SHORT")] mock_api.send_launch_app_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "remote", "turn_on", diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index ce6b98c480c..7419ea6c28f 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -1,10 +1,11 @@ """Tests helpers.""" from collections.abc import AsyncGenerator -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest +from homeassistant.components.anthropic import CONF_CHAT_MODEL from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -38,14 +39,27 @@ def mock_config_entry_with_assist( return mock_config_entry +@pytest.fixture +def mock_config_entry_with_extended_thinking( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Mock a config entry with assist.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_CHAT_MODEL: "claude-3-7-sonnet-latest", + }, + ) + return mock_config_entry + + @pytest.fixture async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> AsyncGenerator[None]: """Initialize integration.""" - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock - ): + with patch("anthropic.resources.models.AsyncModels.retrieve"): assert await async_setup_component(hass, "anthropic", {}) await hass.async_block_till_done() yield diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 93f3b03d9af..ea4ce5a980d 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -1,6 +1,324 @@ # serializer version: 1 +# name: test_extended_thinking_tool_call + list([ + dict({ + 'content': ''' + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + Current time is 16:00:00. Today's date is 2024-06-03. + ''', + 'role': 'system', + }), + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude', + 'content': 'Certainly, calling it now!', + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'toolu_0123456789AbCdEfGhIjKlM', + 'tool_args': dict({ + 'param1': 'test_value', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.claude', + 'role': 'tool_result', + 'tool_call_id': 'toolu_0123456789AbCdEfGhIjKlM', + 'tool_name': 'test_tool', + 'tool_result': 'Test response', + }), + dict({ + 'agent_id': 'conversation.claude', + 'content': 'I have successfully called the function', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- +# name: test_extended_thinking_tool_call.1 + list([ + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': 'The user asked me to call a test function.Is it a test? What would the function do? Would it violate any privacy or security policies?', + 'type': 'thinking', + }), + dict({ + 'data': 'EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', + 'type': 'redacted_thinking', + }), + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': "Okay, let's give it a shot. Will I pass the test?", + 'type': 'thinking', + }), + dict({ + 'text': 'Certainly, calling it now!', + 'type': 'text', + }), + dict({ + 'id': 'toolu_0123456789AbCdEfGhIjKlM', + 'input': dict({ + 'param1': 'test_value', + }), + 'name': 'test_tool', + 'type': 'tool_use', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': list([ + dict({ + 'content': '"Test response"', + 'tool_use_id': 'toolu_0123456789AbCdEfGhIjKlM', + 'type': 'tool_result', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'I have successfully called the function', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content0] + list([ + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content1] + list([ + dict({ + 'content': 'What shape is a donut?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'A donut is a torus.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content2] + list([ + dict({ + 'content': list([ + dict({ + 'text': 'What shape is a donut?', + 'type': 'text', + }), + dict({ + 'text': 'Can you tell me?', + 'type': 'text', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'A donut is a torus.', + 'type': 'text', + }), + dict({ + 'text': 'Hope this helps.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content3] + list([ + dict({ + 'content': list([ + dict({ + 'text': 'What shape is a donut?', + 'type': 'text', + }), + dict({ + 'text': 'Can you tell me?', + 'type': 'text', + }), + dict({ + 'text': 'Please?', + 'type': 'text', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'A donut is a torus.', + 'type': 'text', + }), + dict({ + 'text': 'Hope this helps.', + 'type': 'text', + }), + dict({ + 'text': 'You are welcome.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content4] + list([ + dict({ + 'content': 'Turn off the lights and make me coffee', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Sure.', + 'type': 'text', + }), + dict({ + 'id': 'mock-tool-call-id', + 'input': dict({ + 'domain': 'light', + }), + 'name': 'HassTurnOff', + 'type': 'tool_use', + }), + dict({ + 'id': 'mock-tool-call-id-2', + 'input': dict({ + }), + 'name': 'MakeCoffee', + 'type': 'tool_use', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Thank you', + 'type': 'text', + }), + dict({ + 'content': '{"success": true, "response": "Lights are off."}', + 'tool_use_id': 'mock-tool-call-id', + 'type': 'tool_result', + }), + dict({ + 'content': '{"success": false, "response": "Not enough milk."}', + 'tool_use_id': 'mock-tool-call-id-2', + 'type': 'tool_result', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Should I add milk to the shopping list?', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- # name: test_unknown_hass_api dict({ + 'continue_conversation': False, 'conversation_id': '1234', 'response': IntentResponse( card=dict({ diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index a5a025b00d0..1f41b7df2c7 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -21,9 +21,11 @@ from homeassistant.components.anthropic.const import ( CONF_PROMPT, CONF_RECOMMENDED, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_THINKING_BUDGET, ) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant @@ -49,7 +51,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create", + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock, ), patch( @@ -94,6 +96,28 @@ async def test_options( assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL +async def test_options_thinking_budget_more_than_max( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test error about thinking budget being more than max tokens.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + "prompt": "Speak like a pirate", + "max_tokens": 8192, + "chat_model": "claude-3-7-sonnet-latest", + "temperature": 1, + "thinking_budget": 16384, + }, + ) + await hass.async_block_till_done() + assert options["type"] is FlowResultType.FORM + assert options["errors"] == {"thinking_budget": "thinking_budget_too_large"} + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -151,7 +175,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ) with patch( - "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create", + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock, side_effect=side_effect, ): @@ -172,13 +196,13 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ( { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "none", CONF_PROMPT: "bla", }, { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 0.3, + CONF_LLM_HASS_API: [], }, { CONF_RECOMMENDED: False, @@ -186,6 +210,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, }, ), ( @@ -195,18 +220,36 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, ), + ( + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: "assist", + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + ), ], ) async def test_options_switching( diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index a35df281fb6..8706abf36c0 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -8,19 +8,27 @@ from anthropic import RateLimitError from anthropic.types import ( InputJSONDelta, Message, + MessageDeltaUsage, RawContentBlockDeltaEvent, RawContentBlockStartEvent, RawContentBlockStopEvent, + RawMessageDeltaEvent, RawMessageStartEvent, RawMessageStopEvent, RawMessageStreamEvent, + RedactedThinkingBlock, + SignatureDelta, TextBlock, TextDelta, + ThinkingBlock, + ThinkingDelta, ToolUseBlock, Usage, ) +from anthropic.types.raw_message_delta_event import Delta from freezegun import freeze_time from httpx import URL, Request, Response +import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -28,7 +36,7 @@ from homeassistant.components import conversation from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import chat_session, intent, llm from homeassistant.setup import async_setup_component from homeassistant.util import ulid as ulid_util @@ -60,6 +68,11 @@ def create_messages( type="message_start", ), *content_blocks, + RawMessageDeltaEvent( + type="message_delta", + delta=Delta(stop_reason="end_turn", stop_sequence=""), + usage=MessageDeltaUsage(output_tokens=0), + ), RawMessageStopEvent(type="message_stop"), ] @@ -86,6 +99,57 @@ def create_content_block( ] +def create_thinking_block( + index: int, thinking_parts: list[str] +) -> list[RawMessageStreamEvent]: + """Create a thinking block with the specified deltas.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=ThinkingBlock(signature="", thinking="", type="thinking"), + index=index, + ), + *[ + RawContentBlockDeltaEvent( + delta=ThinkingDelta(thinking=thinking_part, type="thinking_delta"), + index=index, + type="content_block_delta", + ) + for thinking_part in thinking_parts + ], + RawContentBlockDeltaEvent( + delta=SignatureDelta( + signature="ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/N" + "oB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ" + "4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo" + "21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==", + type="signature_delta", + ), + index=index, + type="content_block_delta", + ), + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + +def create_redacted_thinking_block(index: int) -> list[RawMessageStreamEvent]: + """Create a redacted thinking block.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=RedactedThinkingBlock( + data="EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9K" + "WPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeV" + "sJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOK" + "iKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny", + type="redacted_thinking", + ), + index=index, + ), + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + def create_tool_use_block( index: int, tool_id: str, tool_name: str, json_parts: list[str] ) -> list[RawMessageStreamEvent]: @@ -127,9 +191,7 @@ async def test_entity( CONF_LLM_HASS_API: "assist", }, ) - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock - ): + with patch("anthropic.resources.models.AsyncModels.retrieve"): await hass.config_entries.async_reload(mock_config_entry.entry_id) state = hass.states.get("conversation.claude") @@ -173,8 +235,11 @@ async def test_template_error( "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock + with ( + patch("anthropic.resources.models.AsyncModels.retrieve"), + patch( + "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock + ), ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -205,6 +270,7 @@ async def test_template_variables( }, ) with ( + patch("anthropic.resources.models.AsyncModels.retrieve"), patch( "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock ) as mock_create, @@ -230,8 +296,8 @@ async def test_template_variables( result.response.speech["plain"]["speech"] == "Okay, let me take care of that for you." ) - 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"] + assert "The user name is Test User." in mock_create.call_args.kwargs["system"] + assert "The user id is 12345." in mock_create.call_args.kwargs["system"] async def test_conversation_agent( @@ -245,11 +311,27 @@ async def test_conversation_agent( @patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@pytest.mark.parametrize( + ("tool_call_json_parts", "expected_call_tool_args"), + [ + ( + ['{"param1": "test_value"}'], + {"param1": "test_value"}, + ), + ( + ['{"para', 'm1": "test_valu', 'e"}'], + {"param1": "test_value"}, + ), + ([""], {}), + ], +) async def test_function_call( mock_get_tools, hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, mock_init_component, + tool_call_json_parts: list[str], + expected_call_tool_args: dict[str, Any], ) -> None: """Test function call from the assistant.""" agent_id = "conversation.claude" @@ -285,7 +367,7 @@ async def test_function_call( 1, "toolu_0123456789AbCdEfGhIjKlM", "test_tool", - ['{"para', 'm1": "test_valu', 'e"}'], + tool_call_json_parts, ), ] ) @@ -329,7 +411,7 @@ async def test_function_call( llm.ToolInput( id="toolu_0123456789AbCdEfGhIjKlM", tool_name="test_tool", - tool_args={"param1": "test_value"}, + tool_args=expected_call_tool_args, ), llm.LLMContext( platform="anthropic", @@ -379,7 +461,7 @@ async def test_function_exception( return stream_generator( create_messages( [ - *create_content_block(0, "Certainly, calling it now!"), + *create_content_block(0, ["Certainly, calling it now!"]), *create_tool_use_block( 1, "toolu_0123456789AbCdEfGhIjKlM", @@ -462,7 +544,7 @@ async def test_assist_api_tools_conversion( new_callable=AsyncMock, return_value=stream_generator( create_messages( - create_content_block(0, "Hello, how can I help you?"), + create_content_block(0, ["Hello, how can I help you?"]), ), ), ) as mock_create: @@ -497,9 +579,7 @@ async def test_unknown_hass_api( assert result == snapshot -@patch("anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock) async def test_conversation_id( - mock_create, hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, @@ -509,7 +589,7 @@ async def test_conversation_id( def create_stream_generator(*args, **kwargs) -> Any: return stream_generator( create_messages( - create_content_block(0, "Hello, how can I help you?"), + create_content_block(0, ["Hello, how can I help you?"]), ), ) @@ -547,3 +627,283 @@ async def test_conversation_id( ) assert result.conversation_id == "koala" + + +async def test_extended_thinking( + hass: HomeAssistant, + mock_config_entry_with_extended_thinking: MockConfigEntry, + mock_init_component, +) -> None: + """Test extended thinking support.""" + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=stream_generator( + create_messages( + [ + *create_thinking_block( + 0, + [ + "The user has just", + ' greeted me with "Hi".', + " This is a simple greeting an", + "d doesn't require any Home Assistant function", + " calls. I should respond with", + " a friendly greeting and let them know I'm available", + " to help with their smart home.", + ], + ), + *create_content_block(1, ["Hello, how can I help you today?"]), + ] + ), + ), + ): + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id="conversation.claude" + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + assert len(chat_log.content) == 3 + assert chat_log.content[1].content == "hello" + assert chat_log.content[2].content == "Hello, how can I help you today?" + + +async def test_redacted_thinking( + hass: HomeAssistant, + mock_config_entry_with_extended_thinking: MockConfigEntry, + mock_init_component, +) -> None: + """Test extended thinking with redacted thinking blocks.""" + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=stream_generator( + create_messages( + [ + *create_redacted_thinking_block(0), + *create_redacted_thinking_block(1), + *create_redacted_thinking_block(2), + *create_content_block(3, ["How can I help you today?"]), + ] + ), + ), + ): + result = await conversation.async_converse( + hass, + "ANTHROPIC_MAGIC_STRING_TRIGGER_REDACTED_THINKING_46C9A13E193C177646C7398A9" + "8432ECCCE4C1253D5E2D82641AC0E52CC2876CB", + None, + Context(), + agent_id="conversation.claude", + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + assert len(chat_log.content) == 3 + assert chat_log.content[2].content == "How can I help you today?" + + +@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +async def test_extended_thinking_tool_call( + mock_get_tools, + hass: HomeAssistant, + mock_config_entry_with_extended_thinking: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test that thinking blocks and their order are preserved in with tool calls.""" + agent_id = "conversation.claude" + context = Context() + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.return_value = "Test response" + + mock_get_tools.return_value = [mock_tool] + + def completion_result(*args, messages, **kwargs): + for message in messages: + for content in message["content"]: + if not isinstance(content, str) and content["type"] == "tool_use": + return stream_generator( + create_messages( + create_content_block( + 0, ["I have ", "successfully called ", "the function"] + ), + ) + ) + + return stream_generator( + create_messages( + [ + *create_thinking_block( + 0, + [ + "The user asked me to", + " call a test function.", + "Is it a test? What", + " would the function", + " do? Would it violate", + " any privacy or security", + " policies?", + ], + ), + *create_redacted_thinking_block(1), + *create_thinking_block( + 2, ["Okay, let's give it a shot.", " Will I pass the test?"] + ), + *create_content_block(3, ["Certainly, calling it now!"]), + *create_tool_use_block( + 1, + "toolu_0123456789AbCdEfGhIjKlM", + "test_tool", + ['{"para', 'm1": "test_valu', 'e"}'], + ), + ] + ) + ) + + with ( + patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + side_effect=completion_result, + ) as mock_create, + freeze_time("2024-06-03 23:00:00"), + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + + assert chat_log.content == snapshot + assert mock_create.mock_calls[1][2]["messages"] == snapshot + + +@pytest.mark.parametrize( + "content", + [ + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="A donut is a torus." + ), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.UserContent("Can you tell me?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="A donut is a torus." + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="Hope this helps." + ), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.UserContent("Can you tell me?"), + conversation.chat_log.UserContent("Please?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="A donut is a torus." + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="Hope this helps." + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="You are welcome." + ), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("Turn off the lights and make me coffee"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", + content="Sure.", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="HassTurnOff", + tool_args={"domain": "light"}, + ), + llm.ToolInput( + id="mock-tool-call-id-2", + tool_name="MakeCoffee", + tool_args={}, + ), + ], + ), + conversation.chat_log.UserContent("Thank you"), + conversation.chat_log.ToolResultContent( + agent_id="conversation.claude", + tool_call_id="mock-tool-call-id", + tool_name="HassTurnOff", + tool_result={"success": True, "response": "Lights are off."}, + ), + conversation.chat_log.ToolResultContent( + agent_id="conversation.claude", + tool_call_id="mock-tool-call-id-2", + tool_name="MakeCoffee", + tool_result={"success": False, "response": "Not enough milk."}, + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", + content="Should I add milk to the shopping list?", + ), + ], + ], +) +async def test_history_conversion( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, + content: list[conversation.chat_log.Content], +) -> None: + """Test conversion of chat_log entries into API parameters.""" + conversation_id = "conversation_id" + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + conversation.async_get_chat_log(hass, session) as chat_log, + patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=stream_generator( + create_messages( + [ + *create_content_block(0, ["Yes, I am sure!"]), + ] + ), + ), + ) as mock_create, + ): + chat_log.content = content + + await conversation.async_converse( + hass, + "Are you sure?", + conversation_id, + Context(), + agent_id="conversation.claude", + ) + + assert mock_create.mock_calls[0][2]["messages"] == snapshot diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index ee87bb708d0..305e442f52d 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -1,6 +1,6 @@ """Tests for the Anthropic integration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from anthropic import ( APIConnectionError, @@ -55,8 +55,7 @@ async def test_init_error( ) -> None: """Test initialization errors.""" with patch( - "anthropic.resources.messages.AsyncMessages.create", - new_callable=AsyncMock, + "anthropic.resources.models.AsyncModels.retrieve", side_effect=side_effect, ): assert await async_setup_component(hass, "anthropic", {}) diff --git a/tests/components/aosmith/test_diagnostics.py b/tests/components/aosmith/test_diagnostics.py index 9090ef5e7b7..d9fbed513bb 100644 --- a/tests/components/aosmith/test_diagnostics.py +++ b/tests/components/aosmith/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the A. O. Smith integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index eb8cd594ad7..2a786925e70 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -1,10 +1,13 @@ """Tests for the APCUPSd component.""" +from __future__ import annotations + from collections import OrderedDict from typing import Final from unittest.mock import patch from homeassistant.components.apcupsd.const import DOMAIN +from homeassistant.components.apcupsd.coordinator import APCUPSdData from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -79,18 +82,23 @@ MOCK_MINIMAL_STATUS: Final = OrderedDict( async def async_init_integration( - hass: HomeAssistant, host: str = "test", status=None + hass: HomeAssistant, + *, + host: str = "test", + status: dict[str, str] | None = None, + entry_id: str = "mocked-config-entry-id", ) -> MockConfigEntry: """Set up the APC UPS Daemon integration in HomeAssistant.""" if status is None: status = MOCK_STATUS entry = MockConfigEntry( + entry_id=entry_id, version=1, domain=DOMAIN, title="APCUPSd", data=CONF_DATA | {CONF_HOST: host}, - unique_id=status.get("SERIALNO", None), + unique_id=APCUPSdData(status).serial_no, source=SOURCE_USER, ) diff --git a/tests/components/apcupsd/snapshots/test_binary_sensor.ambr b/tests/components/apcupsd/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..0ab9dfb047e --- /dev/null +++ b/tests/components/apcupsd/snapshots/test_binary_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.myups_online_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.myups_online_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Online status', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'online_status', + 'unique_id': 'XXXXXXXXXXXX_statflag', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.myups_online_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Online status', + }), + 'context': , + 'entity_id': 'binary_sensor.myups_online_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/apcupsd/snapshots/test_init.ambr b/tests/components/apcupsd/snapshots/test_init.ambr new file mode 100644 index 00000000000..39f28b528fc --- /dev/null +++ b/tests/components/apcupsd/snapshots/test_init.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_async_setup_entry[status0][device_MyUPS_XXXXXXXXXXXX] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '928.a8 .D USB FW:a8', + 'id': , + 'identifiers': set({ + tuple( + 'apcupsd', + 'XXXXXXXXXXXX', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'APC', + 'model': 'Back-UPS ES 600', + 'model_id': None, + 'name': 'MyUPS', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.14.14 (31 May 2016) unknown', + 'via_device_id': None, + }) +# --- +# name: test_async_setup_entry[status1][device_APC UPS_XXXX] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'apcupsd', + 'XXXX', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'APC', + 'model': None, + 'model_id': None, + 'name': 'APC UPS', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_async_setup_entry[status2][device_APC UPS_] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'apcupsd', + 'mocked-config-entry-id', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'APC', + 'model': None, + 'model_id': None, + 'name': 'APC UPS', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_async_setup_entry[status3][device_APC UPS_Blank] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'apcupsd', + 'mocked-config-entry-id', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'APC', + 'model': None, + 'model_id': None, + 'name': 'APC UPS', + '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/apcupsd/snapshots/test_sensor.ambr b/tests/components/apcupsd/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..1be83198dcc --- /dev/null +++ b/tests/components/apcupsd/snapshots/test_sensor.ambr @@ -0,0 +1,1992 @@ +# serializer version: 1 +# name: test_sensor[sensor.myups_alarm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_alarm_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': 'Alarm delay', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_delay', + 'unique_id': 'XXXXXXXXXXXX_alarmdel', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_alarm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Alarm delay', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_alarm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_sensor[sensor.myups_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_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': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'XXXXXXXXXXXX_bcharge', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.myups_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'MyUPS Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.myups_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensor[sensor.myups_battery_nominal_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_battery_nominal_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': 'Battery nominal voltage', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_nominal_voltage', + 'unique_id': 'XXXXXXXXXXXX_nombattv', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_battery_nominal_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Battery nominal voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_battery_nominal_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_sensor[sensor.myups_battery_replaced-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_battery_replaced', + 'has_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 replaced', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_replacement_date', + 'unique_id': 'XXXXXXXXXXXX_battdate', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_battery_replaced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Battery replaced', + }), + 'context': , + 'entity_id': 'sensor.myups_battery_replaced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01', + }) +# --- +# name: test_sensor[sensor.myups_battery_shutdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_battery_shutdown', + 'has_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 shutdown', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_battery_charge', + 'unique_id': 'XXXXXXXXXXXX_mbattchg', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.myups_battery_shutdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Battery shutdown', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.myups_battery_shutdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor[sensor.myups_battery_timeout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_battery_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': 'Battery timeout', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_time', + 'unique_id': 'XXXXXXXXXXXX_maxtime', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_battery_timeout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Battery timeout', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_battery_timeout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.myups_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_battery_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': 'Battery voltage', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'XXXXXXXXXXXX_battv', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.7', + }) +# --- +# name: test_sensor[sensor.myups_cable_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_cable_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': 'Cable type', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cable_type', + 'unique_id': 'XXXXXXXXXXXX_cable', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_cable_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Cable type', + }), + 'context': , + 'entity_id': 'sensor.myups_cable_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'USB Cable', + }) +# --- +# name: test_sensor[sensor.myups_daemon_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_daemon_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Daemon version', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'version', + 'unique_id': 'XXXXXXXXXXXX_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_daemon_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Daemon version', + }), + 'context': , + 'entity_id': 'sensor.myups_daemon_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.14.14 (31 May 2016) unknown', + }) +# --- +# name: test_sensor[sensor.myups_date_and_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_date_and_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': 'Date and time', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'date_and_time', + 'unique_id': 'XXXXXXXXXXXX_end apc', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_date_and_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Date and time', + }), + 'context': , + 'entity_id': 'sensor.myups_date_and_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01 00:00:00 0000', + }) +# --- +# name: test_sensor[sensor.myups_driver-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_driver', + 'has_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', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'driver', + 'unique_id': 'XXXXXXXXXXXX_driver', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_driver-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Driver', + }), + 'context': , + 'entity_id': 'sensor.myups_driver', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'USB UPS Driver', + }) +# --- +# name: test_sensor[sensor.myups_firmware_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_firmware_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Firmware version', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'firmware_version', + 'unique_id': 'XXXXXXXXXXXX_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_firmware_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Firmware version', + }), + 'context': , + 'entity_id': 'sensor.myups_firmware_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '928.a8 .D USB FW:a8', + }) +# --- +# name: test_sensor[sensor.myups_input_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_input_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': 'Input voltage', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'line_voltage', + 'unique_id': 'XXXXXXXXXXXX_linev', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_input_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Input voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_input_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '124.0', + }) +# --- +# name: test_sensor[sensor.myups_internal_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_internal_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': 'Internal temperature', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'internal_temperature', + 'unique_id': 'XXXXXXXXXXXX_itemp', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_internal_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'MyUPS Internal temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_internal_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.6', + }) +# --- +# name: test_sensor[sensor.myups_last_self_test-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_last_self_test', + 'has_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 self-test', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_self_test', + 'unique_id': 'XXXXXXXXXXXX_laststest', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_last_self_test-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Last self-test', + }), + 'context': , + 'entity_id': 'sensor.myups_last_self_test', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01 00:00:00 0000', + }) +# --- +# name: test_sensor[sensor.myups_last_transfer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_last_transfer', + 'has_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 transfer', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_transfer', + 'unique_id': 'XXXXXXXXXXXX_lastxfer', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_last_transfer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Last transfer', + }), + 'context': , + 'entity_id': 'sensor.myups_last_transfer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Automatic or explicit self test', + }) +# --- +# name: test_sensor[sensor.myups_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Load', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'load_capacity', + 'unique_id': 'XXXXXXXXXXXX_loadpct', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.myups_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Load', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.myups_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.0', + }) +# --- +# name: test_sensor[sensor.myups_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_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': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ups_mode', + 'unique_id': 'XXXXXXXXXXXX_upsmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Mode', + }), + 'context': , + 'entity_id': 'sensor.myups_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Stand Alone', + }) +# --- +# name: test_sensor[sensor.myups_model-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_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': 'Model', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'model', + 'unique_id': 'XXXXXXXXXXXX_model', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_model-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Model', + }), + 'context': , + 'entity_id': 'sensor.myups_model', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Back-UPS ES 600', + }) +# --- +# name: test_sensor[sensor.myups_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Name', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ups_name', + 'unique_id': 'XXXXXXXXXXXX_upsname', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Name', + }), + 'context': , + 'entity_id': 'sensor.myups_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'MyUPS', + }) +# --- +# name: test_sensor[sensor.myups_nominal_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_nominal_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': 'Nominal apparent power', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nominal_apparent_power', + 'unique_id': 'XXXXXXXXXXXX_nomapnt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_nominal_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'MyUPS Nominal apparent power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_nominal_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_sensor[sensor.myups_nominal_input_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_nominal_input_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': 'Nominal input voltage', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nominal_input_voltage', + 'unique_id': 'XXXXXXXXXXXX_nominv', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_nominal_input_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Nominal input voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_nominal_input_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_sensor[sensor.myups_nominal_output_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_nominal_output_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': 'Nominal output power', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nominal_output_power', + 'unique_id': 'XXXXXXXXXXXX_nompower', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_nominal_output_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'MyUPS Nominal output power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_nominal_output_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '330', + }) +# --- +# name: test_sensor[sensor.myups_output_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_output_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': 'Output current', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_current', + 'unique_id': 'XXXXXXXXXXXX_outcurnt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_output_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'MyUPS Output current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_output_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.88', + }) +# --- +# name: test_sensor[sensor.myups_self_test_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_self_test_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Self-test interval', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'self_test_interval', + 'unique_id': 'XXXXXXXXXXXX_stesti', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_self_test_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Self-test interval', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_self_test_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_sensor[sensor.myups_self_test_result-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_self_test_result', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Self-test result', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'self_test_result', + 'unique_id': 'XXXXXXXXXXXX_selftest', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_self_test_result-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Self-test result', + }), + 'context': , + 'entity_id': 'sensor.myups_self_test_result', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'NO', + }) +# --- +# name: test_sensor[sensor.myups_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_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': 'Sensitivity', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity', + 'unique_id': 'XXXXXXXXXXXX_sense', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Sensitivity', + }), + 'context': , + 'entity_id': 'sensor.myups_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Medium', + }) +# --- +# name: test_sensor[sensor.myups_serial_number-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_serial_number', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Serial number', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'serial_number', + 'unique_id': 'XXXXXXXXXXXX_serialno', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_serial_number-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Serial number', + }), + 'context': , + 'entity_id': 'sensor.myups_serial_number', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'XXXXXXXXXXXX', + }) +# --- +# name: test_sensor[sensor.myups_shutdown_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_shutdown_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': 'Shutdown time', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'min_time', + 'unique_id': 'XXXXXXXXXXXX_mintimel', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_shutdown_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Shutdown time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_shutdown_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensor[sensor.myups_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_status', + 'has_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', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'XXXXXXXXXXXX_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Status', + }), + 'context': , + 'entity_id': 'sensor.myups_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ONLINE', + }) +# --- +# name: test_sensor[sensor.myups_status_data-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_status_data', + 'has_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 data', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'apc_status', + 'unique_id': 'XXXXXXXXXXXX_apc', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_status_data-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Status data', + }), + 'context': , + 'entity_id': 'sensor.myups_status_data', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '001,038,0985', + }) +# --- +# name: test_sensor[sensor.myups_status_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_status_date', + 'has_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 date', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'date', + 'unique_id': 'XXXXXXXXXXXX_date', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_status_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Status date', + }), + 'context': , + 'entity_id': 'sensor.myups_status_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01 00:00:00 0000', + }) +# --- +# name: test_sensor[sensor.myups_status_flag-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_status_flag', + 'has_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 flag', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'online_status', + 'unique_id': 'XXXXXXXXXXXX_statflag', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_status_flag-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Status flag', + }), + 'context': , + 'entity_id': 'sensor.myups_status_flag', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0x05000008', + }) +# --- +# name: test_sensor[sensor.myups_time_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_time_left', + '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 left', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'time_left', + 'unique_id': 'XXXXXXXXXXXX_timeleft', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_time_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'MyUPS Time left', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_time_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.0', + }) +# --- +# name: test_sensor[sensor.myups_time_on_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_time_on_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': 'Time on battery', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'time_on_battery', + 'unique_id': 'XXXXXXXXXXXX_tonbatt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_time_on_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'MyUPS Time on battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_time_on_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.myups_total_time_on_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_total_time_on_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': 'Total time on battery', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_time_on_battery', + 'unique_id': 'XXXXXXXXXXXX_cumonbatt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_total_time_on_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'MyUPS Total time on battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_total_time_on_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_sensor[sensor.myups_transfer_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_transfer_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Transfer count', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'transfer_count', + 'unique_id': 'XXXXXXXXXXXX_numxfers', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_transfer_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Transfer count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.myups_transfer_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.myups_transfer_from_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_transfer_from_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Transfer from battery', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'transfer_from_battery', + 'unique_id': 'XXXXXXXXXXXX_xoffbatt', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_transfer_from_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Transfer from battery', + }), + 'context': , + 'entity_id': 'sensor.myups_transfer_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01 00:00:00 0000', + }) +# --- +# name: test_sensor[sensor.myups_transfer_high-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_transfer_high', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Transfer high', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'transfer_high', + 'unique_id': 'XXXXXXXXXXXX_hitrans', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_transfer_high-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Transfer high', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_transfer_high', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '139.0', + }) +# --- +# name: test_sensor[sensor.myups_transfer_low-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_transfer_low', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Transfer low', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'transfer_low', + 'unique_id': 'XXXXXXXXXXXX_lotrans', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.myups_transfer_low-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'MyUPS Transfer low', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.myups_transfer_low', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '92.0', + }) +# --- +# name: test_sensor[sensor.myups_transfer_to_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.myups_transfer_to_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Transfer to battery', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'transfer_to_battery', + 'unique_id': 'XXXXXXXXXXXX_xonbatt', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_transfer_to_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Transfer to battery', + }), + 'context': , + 'entity_id': 'sensor.myups_transfer_to_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01 00:00:00 0000', + }) +# --- diff --git a/tests/components/apcupsd/test_binary_sensor.py b/tests/components/apcupsd/test_binary_sensor.py index 02351109603..0bf1c00d2f3 100644 --- a/tests/components/apcupsd/test_binary_sensor.py +++ b/tests/components/apcupsd/test_binary_sensor.py @@ -1,27 +1,29 @@ """Test binary sensors of APCUPSd integration.""" -import pytest +from unittest.mock import patch +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify from . import MOCK_STATUS, async_init_integration +from tests.common import snapshot_platform + async def test_binary_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test states of binary sensor.""" - await async_init_integration(hass, status=MOCK_STATUS) - - device_slug, serialno = slugify(MOCK_STATUS["UPSNAME"]), MOCK_STATUS["SERIALNO"] - state = hass.states.get(f"binary_sensor.{device_slug}_online_status") - assert state - assert state.state == "on" - entry = entity_registry.async_get(f"binary_sensor.{device_slug}_online_status") - assert entry - assert entry.unique_id == f"{serialno}_statflag" + """Test states of binary sensors.""" + with patch("homeassistant.components.apcupsd.PLATFORMS", [Platform.BINARY_SENSOR]): + config_entry = await async_init_integration(hass, status=MOCK_STATUS) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_no_binary_sensor(hass: HomeAssistant) -> None: diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 6bb94ca2948..e7328603a59 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -5,6 +5,7 @@ from collections import OrderedDict from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.apcupsd.const import DOMAIN from homeassistant.components.apcupsd.coordinator import UPDATE_INTERVAL @@ -12,6 +13,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.util import slugify, utcnow from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS, async_init_integration @@ -28,78 +30,31 @@ from tests.common import MockConfigEntry, async_fire_time_changed # Contains "SERIALNO" but no "UPSNAME" field. # We should create devices for the entities and prefix their IDs with default "APC UPS". MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX"}, - # Does not contain either "SERIALNO" field. - # We should _not_ create devices for the entities and their IDs will not have prefixes. + # Does not contain either "SERIALNO" field or "UPSNAME" field. + # Our integration should work fine without it by falling back to config entry ID as unique + # ID and "APC UPS" as default name. 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: - """Test a successful setup entry.""" - # Minimal status does not contain "SERIALNO" field, which is used to determine the - # unique ID of this integration. But, the integration should work fine without it. - # In such a case, the device will not be added either - await async_init_integration(hass, status=status) - - prefix = "" - if "SERIALNO" in status and status["SERIALNO"] != "Blank": - prefix = slugify(status.get("UPSNAME", "APC UPS")) + "_" - - # Verify successful setup by querying the status sensor. - state = hass.states.get(f"binary_sensor.{prefix}online_status") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "on" - - -@pytest.mark.parametrize( - "status", - [ - # 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". - MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX"}, - # We should create all fields of the device entry if they are available. - MOCK_STATUS, - ], -) -async def test_device_entry( - hass: HomeAssistant, status: OrderedDict, device_registry: dr.DeviceRegistry +async def test_async_setup_entry( + hass: HomeAssistant, + status: OrderedDict, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test successful setup of device entries.""" - await async_init_integration(hass, status=status) + """Test a successful setup entry.""" + config_entry = await async_init_integration(hass, status=status) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, config_entry.unique_id or config_entry.entry_id)} + ) + name = f"device_{device_entry.name}_{status.get('SERIALNO', '')}" + assert device_entry == snapshot(name=name) - # Verify device info is properly set up. - if "SERIALNO" not in status or status["SERIALNO"] == "Blank": - assert len(device_registry.devices) == 0 - return - - assert len(device_registry.devices) == 1 - entry = device_registry.async_get_device({(DOMAIN, status["SERIALNO"])}) - assert entry is not None - # Specify the mapping between field name and the expected fields in device entry. - fields = { - "UPSNAME": entry.name, - "MODEL": entry.model, - "VERSION": entry.sw_version, - "FIRMWARE": entry.hw_version, - } - - for field, entry_value in fields.items(): - if field in status: - assert entry_value == status[field] - # Even if UPSNAME is not available, we must fall back to default "APC UPS". - elif field == "UPSNAME": - assert entry_value == "APC UPS" - else: - assert not entry_value - - assert entry.manufacturer == "APC" + platforms = async_get_platforms(hass, DOMAIN) + assert len(platforms) > 0 + assert all(len(p.entities) > 0 for p in platforms) async def test_multiple_integrations(hass: HomeAssistant) -> None: @@ -108,8 +63,12 @@ async def test_multiple_integrations(hass: HomeAssistant) -> None: status1 = MOCK_STATUS | {"LOADPCT": "15.0 Percent", "SERIALNO": "XXXXX1"} status2 = MOCK_STATUS | {"LOADPCT": "16.0 Percent", "SERIALNO": "XXXXX2"} entries = ( - await async_init_integration(hass, host="test1", status=status1), - await async_init_integration(hass, host="test2", status=status2), + await async_init_integration( + hass, host="test1", status=status1, entry_id="entry-id-1" + ), + await async_init_integration( + hass, host="test2", status=status2, entry_id="entry-id-2" + ), ) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 @@ -128,8 +87,12 @@ async def test_multiple_integrations_different_devices(hass: HomeAssistant) -> N status1 = MOCK_STATUS | {"SERIALNO": "XXXXX1", "UPSNAME": "MyUPS1"} status2 = MOCK_STATUS | {"SERIALNO": "XXXXX2", "UPSNAME": "MyUPS2"} entries = ( - await async_init_integration(hass, host="test1", status=status1), - await async_init_integration(hass, host="test2", status=status2), + await async_init_integration( + hass, host="test1", status=status1, entry_id="entry-id-1" + ), + await async_init_integration( + hass, host="test2", status=status2, entry_id="entry-id-2" + ), ) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 @@ -166,8 +129,12 @@ async def test_unload_remove_entry(hass: HomeAssistant) -> None: """Test successful unload and removal of an entry.""" # Load two integrations from two mock hosts. entries = ( - await async_init_integration(hass, host="test1", status=MOCK_STATUS), - await async_init_integration(hass, host="test2", status=MOCK_MINIMAL_STATUS), + await async_init_integration( + hass, host="test1", status=MOCK_STATUS, entry_id="entry-id-1" + ), + await async_init_integration( + hass, host="test2", status=MOCK_MINIMAL_STATUS, entry_id="entry-id-2" + ), ) # Assert they are loaded. diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index 0fe7f12ad27..4da17b1c128 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -3,22 +3,15 @@ from datetime import timedelta from unittest.mock import patch +import pytest +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.apcupsd.coordinator import REQUEST_REFRESH_COOLDOWN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) from homeassistant.const import ( - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN, - UnitOfElectricPotential, - UnitOfPower, - UnitOfTime, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -28,118 +21,19 @@ from homeassistant.util.dt import utcnow from . import MOCK_MINIMAL_STATUS, MOCK_STATUS, async_init_integration -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform -async def test_sensor(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: - """Test states of sensor.""" - await async_init_integration(hass, status=MOCK_STATUS) - device_slug, serialno = slugify(MOCK_STATUS["UPSNAME"]), MOCK_STATUS["SERIALNO"] - - # Test a representative string sensor. - state = hass.states.get(f"sensor.{device_slug}_mode") - assert state - assert state.state == "Stand Alone" - entry = entity_registry.async_get(f"sensor.{device_slug}_mode") - assert entry - assert entry.unique_id == f"{serialno}_upsmode" - - # Test two representative voltage sensors. - state = hass.states.get(f"sensor.{device_slug}_input_voltage") - assert state - assert state.state == "124.0" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - entry = entity_registry.async_get(f"sensor.{device_slug}_input_voltage") - assert entry - assert entry.unique_id == f"{serialno}_linev" - - state = hass.states.get(f"sensor.{device_slug}_battery_voltage") - assert state - assert state.state == "13.7" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - entry = entity_registry.async_get(f"sensor.{device_slug}_battery_voltage") - assert entry - assert entry.unique_id == f"{serialno}_battv" - - # Test a representative time sensor. - state = hass.states.get(f"sensor.{device_slug}_self_test_interval") - assert state - assert state.state == "7" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.DAYS - entry = entity_registry.async_get(f"sensor.{device_slug}_self_test_interval") - assert entry - assert entry.unique_id == f"{serialno}_stesti" - - # Test a representative percentage sensor. - state = hass.states.get(f"sensor.{device_slug}_load") - assert state - assert state.state == "14.0" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = entity_registry.async_get(f"sensor.{device_slug}_load") - assert entry - assert entry.unique_id == f"{serialno}_loadpct" - - # Test a representative wattage sensor. - state = hass.states.get(f"sensor.{device_slug}_nominal_output_power") - assert state - assert state.state == "330" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - entry = entity_registry.async_get(f"sensor.{device_slug}_nominal_output_power") - assert entry - assert entry.unique_id == f"{serialno}_nompower" - - -async def test_sensor_name(hass: HomeAssistant) -> None: - """Test if sensor name follows the recommended entity naming scheme. - - See https://developers.home-assistant.io/docs/core/entity/#entity-naming for more details. - """ - await async_init_integration(hass, status=MOCK_STATUS) - - all_states = hass.states.async_all() - assert len(all_states) != 0 - - device_name = MOCK_STATUS["UPSNAME"] - for state in all_states: - # Friendly name must start with the device name. - friendly_name = state.name - assert friendly_name.startswith(device_name) - - # Entity names should start with a capital letter, the rest of the words are lower case. - entity_name = friendly_name.removeprefix(device_name).strip() - assert entity_name == entity_name.capitalize() - - -async def test_sensor_disabled( - hass: HomeAssistant, entity_registry: er.EntityRegistry +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test sensor disabled by default.""" - await async_init_integration(hass) - - device_slug, serialno = slugify(MOCK_STATUS["UPSNAME"]), MOCK_STATUS["SERIALNO"] - # Test a representative integration-disabled sensor. - entry = entity_registry.async_get(f"sensor.{device_slug}_model") - assert entry.disabled - assert entry.unique_id == f"{serialno}_model" - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Test enabling entity. - updated_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - - assert updated_entry != entry - assert updated_entry.disabled is False + """Test states of sensor.""" + with patch("homeassistant.components.apcupsd.PLATFORMS", [Platform.SENSOR]): + config_entry = await async_init_integration(hass, status=MOCK_STATUS) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_state_update(hass: HomeAssistant) -> None: @@ -241,14 +135,17 @@ async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: async def test_sensor_unknown(hass: HomeAssistant) -> None: - """Test if our integration can properly certain sensors as unknown when it becomes so.""" + """Test if our integration can properly mark certain sensors as unknown when it becomes so.""" await async_init_integration(hass, status=MOCK_MINIMAL_STATUS) - assert hass.states.get("sensor.mode").state == MOCK_MINIMAL_STATUS["UPSMODE"] + ups_mode_id = "sensor.apc_ups_mode" + last_self_test_id = "sensor.apc_ups_last_self_test" + + assert hass.states.get(ups_mode_id).state == MOCK_MINIMAL_STATUS["UPSMODE"] # Last self test sensor should be added even if our status does not report it initially (it is # a sensor that appears only after a periodical or manual self test is performed). - assert hass.states.get("sensor.last_self_test") is not None - assert hass.states.get("sensor.last_self_test").state == STATE_UNKNOWN + assert hass.states.get(last_self_test_id) is not None + assert hass.states.get(last_self_test_id).state == STATE_UNKNOWN # Simulate an event (a self test) such that "LASTSTEST" field is being reported, the state of # the sensor should be properly updated with the corresponding value. @@ -259,7 +156,7 @@ async def test_sensor_unknown(hass: HomeAssistant) -> None: future = utcnow() + timedelta(minutes=2) async_fire_time_changed(hass, future) await hass.async_block_till_done() - assert hass.states.get("sensor.last_self_test").state == "1970-01-01 00:00:00 0000" + assert hass.states.get(last_self_test_id).state == "1970-01-01 00:00:00 0000" # Simulate another event (e.g., daemon restart) such that "LASTSTEST" is no longer reported. with patch("aioapcaccess.request_status") as mock_request_status: @@ -268,4 +165,4 @@ async def test_sensor_unknown(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() # The state should become unknown again. - assert hass.states.get("sensor.last_self_test").state == STATE_UNKNOWN + assert hass.states.get(last_self_test_id).state == STATE_UNKNOWN diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 6363304effc..26a3d7c7a8c 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -22,12 +22,12 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def mock_api_client( +async def mock_api_client( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> TestClient: """Start the Home Assistant HTTP component and return admin API client.""" - hass.loop.run_until_complete(async_setup_component(hass, "api", {})) - return hass.loop.run_until_complete(hass_client()) + await async_setup_component(hass, "api", {}) + return await hass_client() async def test_api_list_state_entities( diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index a13eb3c605b..c9d698e068b 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -59,12 +59,12 @@ def use_mocked_zeroconf(mock_async_zeroconf: MagicMock) -> None: @pytest.fixture(autouse=True) -def mock_setup_entry() -> Generator[None]: +def mock_setup_entry() -> Generator[Mock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.apple_tv.async_setup_entry", return_value=True - ): - yield + ) as setup_entry: + yield setup_entry # User Flows @@ -1183,7 +1183,9 @@ async def test_zeroconf_mismatch(hass: HomeAssistant, mock_scan: AsyncMock) -> N @pytest.mark.usefixtures("mrp_device", "pairing") -async def test_reconfigure_update_credentials(hass: HomeAssistant) -> None: +async def test_reconfigure_update_credentials( + hass: HomeAssistant, mock_setup_entry: Mock +) -> None: """Test that reconfigure flow updates config entry.""" config_entry = MockConfigEntry( domain="apple_tv", unique_id="mrpid", data={"identifiers": ["mrpid"]} @@ -1215,6 +1217,9 @@ async def test_reconfigure_update_credentials(hass: HomeAssistant) -> None: "identifiers": ["mrpid"], } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + # Options diff --git a/tests/components/apsystems/conftest.py b/tests/components/apsystems/conftest.py index 92af6885c0b..d1c97e991a8 100644 --- a/tests/components/apsystems/conftest.py +++ b/tests/components/apsystems/conftest.py @@ -43,6 +43,7 @@ def mock_apsystems() -> Generator[MagicMock]: ipAddr="127.0.01", minPower=0, maxPower=1000, + isBatterySystem=False, ) mock_api.get_output_data.return_value = ReturnOutputData( p1=2.0, diff --git a/tests/components/apsystems/snapshots/test_binary_sensor.ambr b/tests/components/apsystems/snapshots/test_binary_sensor.ambr index 381fc1864fc..d2e73347c83 100644 --- a/tests/components/apsystems/snapshots/test_binary_sensor.ambr +++ b/tests/components/apsystems/snapshots/test_binary_sensor.ambr @@ -120,7 +120,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Off grid status', + 'original_name': 'Off-grid status', 'platform': 'apsystems', 'previous_unique_id': None, 'supported_features': 0, @@ -133,7 +133,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Mock Title Off grid status', + 'friendly_name': 'Mock Title Off-grid status', }), 'context': , 'entity_id': 'binary_sensor.mock_title_off_grid_status', diff --git a/tests/components/apsystems/test_binary_sensor.py b/tests/components/apsystems/test_binary_sensor.py index 0c6fbffc93c..88e482e3eaa 100644 --- a/tests/components/apsystems/test_binary_sensor.py +++ b/tests/components/apsystems/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/apsystems/test_number.py b/tests/components/apsystems/test_number.py index 912759b4a17..6cf054148bf 100644 --- a/tests/components/apsystems/test_number.py +++ b/tests/components/apsystems/test_number.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/apsystems/test_sensor.py b/tests/components/apsystems/test_sensor.py index 810ad3e7bdf..9a87e7ecf18 100644 --- a/tests/components/apsystems/test_sensor.py +++ b/tests/components/apsystems/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/apsystems/test_switch.py b/tests/components/apsystems/test_switch.py index afd889fe958..290cece126d 100644 --- a/tests/components/apsystems/test_switch.py +++ b/tests/components/apsystems/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/aquacell/test_sensor.py b/tests/components/aquacell/test_sensor.py index 0c59dcc40e9..007040d9c79 100644 --- a/tests/components/aquacell/test_sensor.py +++ b/tests/components/aquacell/test_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/arve/test_sensor.py b/tests/components/arve/test_sensor.py index 541820fd7b6..77711632c56 100644 --- a/tests/components/arve/test_sensor.py +++ b/tests/components/arve/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/assist_pipeline/__init__.py b/tests/components/assist_pipeline/__init__.py index dd0f80e52ad..cc11fcc6c82 100644 --- a/tests/components/assist_pipeline/__init__.py +++ b/tests/components/assist_pipeline/__init__.py @@ -1,5 +1,10 @@ """Tests for the Voice Assistant integration.""" +from dataclasses import asdict +from unittest.mock import ANY + +from homeassistant.components import assist_pipeline + MANY_LANGUAGES = [ "ar", "bg", @@ -54,3 +59,16 @@ MANY_LANGUAGES = [ "zh-hk", "zh-tw", ] + + +def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: + """Process events to remove dynamic values.""" + processed = [] + for event in events: + as_dict = asdict(event) + as_dict.pop("timestamp") + if as_dict["type"] == assist_pipeline.PipelineEventType.RUN_START: + as_dict["data"]["pipeline"] = ANY + processed.append(as_dict) + + return processed diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 02ec7c04607..e20452a1f93 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import AsyncIterable, Generator from pathlib import Path from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -24,7 +24,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import chat_session, device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component @@ -37,7 +37,7 @@ from tests.common import ( mock_platform, ) from tests.components.stt.common import MockSTTProvider, MockSTTProviderEntity -from tests.components.tts.common import MockTTSProvider +from tests.components.tts.common import MockTTSEntity, MockTTSProvider _TRANSCRIPT = "test transcript" @@ -68,6 +68,15 @@ async def mock_tts_provider() -> MockTTSProvider: return provider +@pytest.fixture +def mock_tts_entity() -> MockTTSEntity: + """Test TTS entity.""" + entity = MockTTSEntity("en") + entity._attr_unique_id = "test_tts" + entity._attr_supported_languages = ["en-US"] + return entity + + @pytest.fixture async def mock_stt_provider() -> MockSTTProvider: """Mock STT provider.""" @@ -198,6 +207,7 @@ async def init_supporting_components( mock_stt_provider: MockSTTProvider, mock_stt_provider_entity: MockSTTProviderEntity, mock_tts_provider: MockTTSProvider, + mock_tts_entity: MockTTSEntity, mock_wake_word_provider_entity: MockWakeWordEntity, mock_wake_word_provider_entity2: MockWakeWordEntity2, config_flow_fixture, @@ -209,7 +219,7 @@ async def init_supporting_components( ) -> bool: """Set up test config entry.""" await hass.config_entries.async_forward_entry_setups( - config_entry, [Platform.STT, Platform.WAKE_WORD] + config_entry, [Platform.STT, Platform.TTS, Platform.WAKE_WORD] ) return True @@ -230,6 +240,14 @@ async def init_supporting_components( """Set up test stt platform via config entry.""" async_add_entities([mock_stt_provider_entity]) + async def async_setup_entry_tts_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test tts platform via config entry.""" + async_add_entities([mock_tts_entity]) + async def async_setup_entry_wake_word_platform( hass: HomeAssistant, config_entry: ConfigEntry, @@ -253,6 +271,7 @@ async def init_supporting_components( "test.tts", MockTTSPlatform( async_get_engine=AsyncMock(return_value=mock_tts_provider), + async_setup_entry=async_setup_entry_tts_platform, ), ) mock_platform( @@ -379,3 +398,14 @@ def pipeline_storage(pipeline_data) -> PipelineStorageCollection: def make_10ms_chunk(header: bytes) -> bytes: """Return 10ms of zeros with the given header.""" return header + bytes(BYTES_PER_CHUNK - len(header)) + + +@pytest.fixture +def mock_chat_session(hass: HomeAssistant) -> Generator[chat_session.ChatSession]: + """Mock the ulid of chat sessions.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + patch("homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid"), + chat_session.async_get_chat_session(hass) as session, + ): + yield session diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 11e6bc2339a..816430f58d0 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -6,6 +6,11 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -45,6 +50,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -68,18 +74,19 @@ }), dict({ 'data': dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }), 'type': , }), dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': "media-source://tts/tts.test?message=Sorry,+I+couldn't+understand+that&language=en_US&tts_options=%7B%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -98,6 +105,11 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -137,6 +149,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -172,6 +185,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -190,6 +204,11 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -229,6 +248,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -264,6 +284,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -282,6 +303,11 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -345,6 +371,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -368,18 +395,19 @@ }), dict({ 'data': dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }), 'type': , }), dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': "media-source://tts/tts.test?message=Sorry,+I+couldn't+understand+that&language=en_US&tts_options=%7B%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -398,6 +426,11 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }), 'type': , }), @@ -428,196 +461,3 @@ }), ]) # --- -# name: test_pipeline_language_used_instead_of_conversation_language - list([ - dict({ - 'data': dict({ - 'conversation_id': 'mock-conversation-id', - 'language': 'en', - 'pipeline': , - }), - 'type': , - }), - dict({ - 'data': dict({ - 'conversation_id': 'mock-conversation-id', - '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({ - 'conversation_id': 'mock-conversation-id', - 'language': 'en', - 'pipeline': , - }), - 'type': , - }), - dict({ - 'data': dict({ - 'conversation_id': 'mock-conversation-id', - '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({ - 'conversation_id': 'mock-conversation-id', - 'language': 'en', - 'pipeline': , - }), - 'type': , - }), - dict({ - 'data': dict({ - 'conversation_id': 'mock-conversation-id', - '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({ - 'data': dict({ - 'conversation_id': 'mock-conversation-id', - 'language': 'en', - 'pipeline': , - }), - 'type': , - }), - dict({ - 'data': dict({ - 'entity_id': 'wake_word.test', - 'metadata': dict({ - 'bit_rate': , - 'channel': , - 'codec': , - 'format': , - 'sample_rate': , - }), - 'timeout': 0, - }), - 'type': , - }), - dict({ - 'data': dict({ - 'code': 'wake_word_detection_aborted', - 'message': '', - }), - 'type': , - }), - dict({ - 'data': None, - 'type': , - }), - ]) -# --- diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr new file mode 100644 index 00000000000..bbe08a2adbe --- /dev/null +++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr @@ -0,0 +1,356 @@ +# serializer version: 1 +# name: test_chat_log_tts_streaming[to_stream_tts0-1] + list([ + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'device_id': None, + 'engine': 'test-agent', + 'intent_input': 'Set a timer', + 'language': 'en', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'role': 'assistant', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'hello,', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': ' ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'how', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': ' ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'are', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': ' ', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': 'you', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'chat_log_delta': dict({ + 'content': '?', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'continue_conversation': True, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'hello, how are you?', + }), + }), + }), + }), + 'processed_locally': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'tts.test', + 'language': 'en_US', + 'tts_input': 'hello, how are you?', + 'voice': None, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'tts_output': dict({ + 'media_id': 'media-source://tts/tts.test?message=hello,+how+are+you?&language=en_US&tts_options=%7B%7D', + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_pipeline_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'continue_conversation': False, + '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({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + '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({ + 'continue_conversation': False, + '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({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + '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({ + 'continue_conversation': False, + '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({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'sample_rate': , + }), + 'timeout': 0, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'code': 'wake_word_detection_aborted', + 'message': '', + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index f677fa6d8cf..41bdba9f3cd 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -8,6 +8,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline.1 @@ -43,6 +48,7 @@ # name: test_audio_pipeline.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -65,17 +71,18 @@ # --- # name: test_audio_pipeline.5 dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }) # --- # name: test_audio_pipeline.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': "media-source://tts/tts.test?message=Sorry,+I+couldn't+understand+that&language=en_US&tts_options=%7B%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -92,6 +99,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_debug.1 @@ -127,6 +139,7 @@ # name: test_audio_pipeline_debug.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -149,17 +162,18 @@ # --- # name: test_audio_pipeline_debug.5 dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }) # --- # name: test_audio_pipeline_debug.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': "media-source://tts/tts.test?message=Sorry,+I+couldn't+understand+that&language=en_US&tts_options=%7B%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -188,6 +202,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_enhancements.1 @@ -223,6 +242,7 @@ # name: test_audio_pipeline_with_enhancements.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -245,17 +265,18 @@ # --- # name: test_audio_pipeline_with_enhancements.5 dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }) # --- # name: test_audio_pipeline_with_enhancements.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': "media-source://tts/tts.test?message=Sorry,+I+couldn't+understand+that&language=en_US&tts_options=%7B%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -272,6 +293,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.1 @@ -329,6 +355,7 @@ # name: test_audio_pipeline_with_wake_word_no_timeout.6 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -351,17 +378,18 @@ # --- # name: test_audio_pipeline_with_wake_word_no_timeout.7 dict({ - 'engine': 'test', - 'language': 'en-US', + 'engine': 'tts.test', + 'language': 'en_US', 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', + 'voice': None, }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.8 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", + 'media_id': "media-source://tts/tts.test?message=Sorry,+I+couldn't+understand+that&language=en_US&tts_options=%7B%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -378,6 +406,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_wake_word_timeout.1 @@ -581,6 +614,11 @@ 'stt_binary_handler_id': None, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_pipeline_empty_tts_output.1 @@ -596,6 +634,7 @@ # name: test_pipeline_empty_tts_output.2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -629,6 +668,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_different_ids.1 @@ -640,6 +684,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_same_id @@ -651,6 +700,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_same_id.1 @@ -662,6 +716,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_stream_failed @@ -673,6 +732,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_stream_failed.1 @@ -715,6 +779,7 @@ # name: test_text_only_pipeline[extra_msg0].2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -762,6 +827,7 @@ # name: test_text_only_pipeline[extra_msg1].2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -791,28 +857,6 @@ 'message': 'Timeout running pipeline', }) # --- -# name: test_tts_failed - dict({ - 'conversation_id': 'mock-ulid', - 'language': 'en', - 'pipeline': , - 'runner_data': dict({ - 'stt_binary_handler_id': None, - 'timeout': 300, - }), - }) -# --- -# name: test_tts_failed.1 - dict({ - 'engine': 'test', - 'language': 'en-US', - 'tts_input': 'Lights are on.', - 'voice': 'james_earl_jones', - }) -# --- -# name: test_tts_failed.2 - None -# --- # name: test_wake_word_cooldown_different_entities dict({ 'conversation_id': 'mock-ulid', @@ -822,6 +866,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_entities.1 @@ -833,6 +882,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_entities.2 @@ -885,6 +939,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_ids.1 @@ -896,6 +955,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_ids.2 @@ -951,6 +1015,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_same_id.1 @@ -962,6 +1031,11 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_same_id.2 diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 1651950c173..0294f9953db 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -2,65 +2,51 @@ import asyncio from collections.abc import Generator -from dataclasses import asdict import itertools as it from pathlib import Path import tempfile -from unittest.mock import ANY, Mock, patch +from unittest.mock import Mock, patch import wave import hass_nabucasa import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import ( - assist_pipeline, - conversation, - media_source, - stt, - tts, -) +from homeassistant.components import assist_pipeline, stt from homeassistant.components.assist_pipeline.const import ( BYTES_PER_CHUNK, 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 +from . import process_events from .conftest import ( BYTES_ONE_SECOND, MockSTTProvider, MockSTTProviderEntity, - MockTTSProvider, MockWakeWordEntity, make_10ms_chunk, ) -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def mock_ulid() -> Generator[Mock]: - """Mock the ulid of chat sessions.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: yield mock_ulid_now -def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: - """Process events to remove dynamic values.""" - processed = [] - for event in events: - as_dict = asdict(event) - as_dict.pop("timestamp") - if as_dict["type"] == assist_pipeline.PipelineEventType.RUN_START: - as_dict["data"]["pipeline"] = ANY - processed.append(as_dict) - - return processed +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield async def test_pipeline_from_audio_stream_auto( @@ -669,675 +655,6 @@ async def test_pipeline_saved_audio_empty_queue( ) -async def test_wake_word_detection_aborted( - hass: HomeAssistant, - mock_stt_provider: MockSTTProvider, - mock_wake_word_provider_entity: MockWakeWordEntity, - init_components, - pipeline_data: assist_pipeline.pipeline.PipelineData, - snapshot: SnapshotAssertion, -) -> None: - """Test creating a pipeline from an audio stream with wake word.""" - - events: list[assist_pipeline.PipelineEvent] = [] - - async def audio_data(): - yield make_10ms_chunk(b"silence!") - yield make_10ms_chunk(b"wake word!") - yield make_10ms_chunk(b"part1") - yield make_10ms_chunk(b"part2") - yield b"" - - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - conversation_id="mock-conversation-id", - device_id=None, - 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(), - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.WAKE_WORD, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=events.append, - tts_audio_output=None, - wake_word_settings=assist_pipeline.WakeWordSettings( - audio_seconds_to_buffer=1.5 - ), - audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), - ), - ) - await pipeline_input.validate() - - updates = pipeline.to_json() - updates.pop("id") - await pipeline_store.async_update_item( - pipeline_id, - updates, - ) - await pipeline_input.execute() - - assert process_events(events) == snapshot - - -def test_pipeline_run_equality(hass: HomeAssistant, init_components) -> None: - """Test that pipeline run equality uses unique id.""" - - def event_callback(event): - pass - - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass) - run_1 = assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.STT, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=event_callback, - ) - run_2 = assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.STT, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=event_callback, - ) - - assert run_1 == run_1 # noqa: PLR0124 - assert run_1 != run_2 - assert run_1 != 1234 - - -async def test_tts_audio_output( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_tts_provider: MockTTSProvider, - init_components, - pipeline_data: assist_pipeline.pipeline.PipelineData, - snapshot: SnapshotAssertion, -) -> None: - """Test using tts_audio_output with wav sets options correctly.""" - client = await hass_client() - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - events: list[assist_pipeline.PipelineEvent] = [] - - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - tts_input="This is a test.", - conversation_id="mock-conversation-id", - device_id=None, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.TTS, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=events.append, - tts_audio_output="wav", - ), - ) - await pipeline_input.validate() - - # Verify TTS audio settings - assert pipeline_input.run.tts_options is not None - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) == 16000 - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) == 1 - - with patch.object(mock_tts_provider, "get_tts_audio") as mock_get_tts_audio: - await pipeline_input.execute() - - for event in events: - if event.type == assist_pipeline.PipelineEventType.TTS_END: - # We must fetch the media URL to trigger the TTS - assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) - - # Ensure that no unsupported options were passed in - assert mock_get_tts_audio.called - options = mock_get_tts_audio.call_args_list[0].kwargs["options"] - extra_options = set(options).difference(mock_tts_provider.supported_options) - assert len(extra_options) == 0, extra_options - - -async def test_tts_wav_preferred_format( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_tts_provider: MockTTSProvider, - init_components, - pipeline_data: assist_pipeline.pipeline.PipelineData, -) -> None: - """Test that preferred format options are given to the TTS system if supported.""" - client = await hass_client() - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - events: list[assist_pipeline.PipelineEvent] = [] - - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - tts_input="This is a test.", - conversation_id="mock-conversation-id", - device_id=None, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.TTS, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=events.append, - tts_audio_output="wav", - ), - ) - await pipeline_input.validate() - - # Make the TTS provider support preferred format options - supported_options = list(mock_tts_provider.supported_options or []) - supported_options.extend( - [ - tts.ATTR_PREFERRED_FORMAT, - tts.ATTR_PREFERRED_SAMPLE_RATE, - tts.ATTR_PREFERRED_SAMPLE_CHANNELS, - tts.ATTR_PREFERRED_SAMPLE_BYTES, - ] - ) - - with ( - patch.object(mock_tts_provider, "_supported_options", supported_options), - patch.object(mock_tts_provider, "get_tts_audio") as mock_get_tts_audio, - ): - await pipeline_input.execute() - - for event in events: - if event.type == assist_pipeline.PipelineEventType.TTS_END: - # We must fetch the media URL to trigger the TTS - assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) - - assert mock_get_tts_audio.called - options = mock_get_tts_audio.call_args_list[0].kwargs["options"] - - # We should have received preferred format options in get_tts_audio - assert options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 16000 - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 1 - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 - - -async def test_tts_dict_preferred_format( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_tts_provider: MockTTSProvider, - init_components, - pipeline_data: assist_pipeline.pipeline.PipelineData, -) -> None: - """Test that preferred format options are given to the TTS system if supported.""" - client = await hass_client() - assert await async_setup_component(hass, media_source.DOMAIN, {}) - - events: list[assist_pipeline.PipelineEvent] = [] - - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - tts_input="This is a test.", - conversation_id="mock-conversation-id", - device_id=None, - run=assist_pipeline.pipeline.PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - start_stage=assist_pipeline.PipelineStage.TTS, - end_stage=assist_pipeline.PipelineStage.TTS, - event_callback=events.append, - tts_audio_output={ - tts.ATTR_PREFERRED_FORMAT: "flac", - tts.ATTR_PREFERRED_SAMPLE_RATE: 48000, - tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 2, - tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, - }, - ), - ) - await pipeline_input.validate() - - # Make the TTS provider support preferred format options - supported_options = list(mock_tts_provider.supported_options or []) - supported_options.extend( - [ - tts.ATTR_PREFERRED_FORMAT, - tts.ATTR_PREFERRED_SAMPLE_RATE, - tts.ATTR_PREFERRED_SAMPLE_CHANNELS, - tts.ATTR_PREFERRED_SAMPLE_BYTES, - ] - ) - - with ( - patch.object(mock_tts_provider, "_supported_options", supported_options), - patch.object(mock_tts_provider, "get_tts_audio") as mock_get_tts_audio, - ): - await pipeline_input.execute() - - for event in events: - if event.type == assist_pipeline.PipelineEventType.TTS_END: - # We must fetch the media URL to trigger the TTS - assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) - - assert mock_get_tts_audio.called - options = mock_get_tts_audio.call_args_list[0].kwargs["options"] - - # We should have received preferred format options in get_tts_audio - assert options.get(tts.ATTR_PREFERRED_FORMAT) == "flac" - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 48000 - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 2 - assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 - - -async def test_sentence_trigger_overrides_conversation_agent( - hass: HomeAssistant, - init_components, - pipeline_data: assist_pipeline.pipeline.PipelineData, -) -> None: - """Test that sentence triggers are checked before a non-default conversation agent.""" - assert await async_setup_component( - hass, - "automation", - { - "automation": { - "trigger": { - "platform": "conversation", - "command": [ - "test trigger sentence", - ], - }, - "action": { - "set_conversation_response": "test trigger response", - }, - } - }, - ) - - events: list[assist_pipeline.PipelineEvent] = [] - - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - intent_input="test trigger sentence", - conversation_id="mock-conversation-id", - 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, - intent_agent="test-agent", # not the default agent - ), - ) - - # Ensure prepare succeeds - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", - return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), - ): - await pipeline_input.validate() - - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" - ) as mock_async_converse: - await pipeline_input.execute() - - # Sentence trigger should have been handled - mock_async_converse.assert_not_called() - - # Verify sentence trigger response - intent_end_event = next( - ( - e - for e in events - if e.type == assist_pipeline.PipelineEventType.INTENT_END - ), - None, - ) - assert (intent_end_event is not None) and intent_end_event.data - assert ( - intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ - "speech" - ] - == "test trigger response" - ) - - -async def test_prefer_local_intents( - hass: HomeAssistant, - init_components, - pipeline_data: assist_pipeline.pipeline.PipelineData, -) -> None: - """Test that the default agent is checked first when local intents are preferred.""" - events: list[assist_pipeline.PipelineEvent] = [] - - # Reuse custom sentences in test config - class OrderBeerIntentHandler(intent.IntentHandler): - intent_type = "OrderBeer" - - async def async_handle( - self, intent_obj: intent.Intent - ) -> intent.IntentResponse: - response = intent_obj.create_response() - response.async_set_speech("Order confirmed") - return response - - handler = OrderBeerIntentHandler() - intent.async_register(hass, handler) - - # Fake a test agent and prefer local intents - pipeline_store = pipeline_data.pipeline_store - pipeline_id = pipeline_store.async_get_preferred_item() - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - await assist_pipeline.pipeline.async_update_pipeline( - hass, pipeline, conversation_engine="test-agent", prefer_local_intents=True - ) - pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) - - pipeline_input = assist_pipeline.pipeline.PipelineInput( - intent_input="I'd like to order a stout please", - conversation_id="mock-conversation-id", - 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, - ), - ) - - # Ensure prepare succeeds - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", - return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), - ): - await pipeline_input.validate() - - with patch( - "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" - ) as mock_async_converse: - await pipeline_input.execute() - - # Test agent should not have been called - mock_async_converse.assert_not_called() - - # Verify local intent response - intent_end_event = next( - ( - e - for e in events - if e.type == assist_pipeline.PipelineEventType.INTENT_END - ), - None, - ) - assert (intent_end_event is not None) and intent_end_event.data - assert ( - intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ - "speech" - ] - == "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", - conversation_id="mock-conversation-id", - 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", - conversation_id="mock-conversation-id", - 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", - conversation_id="mock-conversation-id", - 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, diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index a7f6fbf7553..abf6572afc9 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1,13 +1,20 @@ """Websocket tests for Voice Assistant integration.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator from typing import Any -from unittest.mock import ANY, patch +from unittest.mock import ANY, Mock, patch from hassil.recognize import Intent, IntentData, RecognizeResult import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components import conversation +from homeassistant.components import ( + assist_pipeline, + conversation, + media_source, + stt, + tts, +) from homeassistant.components.assist_pipeline.const import DOMAIN from homeassistant.components.assist_pipeline.pipeline import ( STORAGE_KEY, @@ -24,14 +31,23 @@ from homeassistant.components.assist_pipeline.pipeline import ( async_migrate_engine, async_update_pipeline, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.const import MATCH_ALL +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import chat_session, intent from homeassistant.setup import async_setup_component -from . import MANY_LANGUAGES -from .conftest import MockSTTProviderEntity, MockTTSProvider +from . import MANY_LANGUAGES, process_events +from .conftest import ( + MockSTTProvider, + MockSTTProviderEntity, + MockTTSEntity, + MockTTSProvider, + MockWakeWordEntity, + make_10ms_chunk, +) from tests.common import flush_store +from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture(autouse=True) @@ -47,6 +63,12 @@ async def load_homeassistant(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "homeassistant", {}) +@pytest.fixture +async def disable_tts_entity(mock_tts_entity: tts.TextToSpeechEntity) -> None: + """Disable the TTS entity.""" + mock_tts_entity._attr_entity_registry_enabled_default = False + + @pytest.mark.usefixtures("init_components") async def test_load_pipelines(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" @@ -119,6 +141,22 @@ async def test_load_pipelines(hass: HomeAssistant) -> None: assert store1.async_get_preferred_item() == store2.async_get_preferred_item() +@pytest.fixture(autouse=True) +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: + yield mock_ulid_now + + +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield + + async def test_loading_pipelines_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -252,6 +290,7 @@ async def test_migrate_pipeline_store( @pytest.mark.usefixtures("init_supporting_components") +@pytest.mark.usefixtures("disable_tts_entity") async def test_create_default_pipeline(hass: HomeAssistant) -> None: """Test async_create_default_pipeline.""" assert await async_setup_component(hass, "assist_pipeline", {}) @@ -399,6 +438,7 @@ async def test_default_pipeline_no_stt_tts( ], ) @pytest.mark.usefixtures("init_supporting_components") +@pytest.mark.usefixtures("disable_tts_entity") async def test_default_pipeline( hass: HomeAssistant, mock_stt_provider_entity: MockSTTProviderEntity, @@ -443,6 +483,7 @@ async def test_default_pipeline( @pytest.mark.usefixtures("init_supporting_components") +@pytest.mark.usefixtures("disable_tts_entity") async def test_default_pipeline_unsupported_stt_language( hass: HomeAssistant, mock_stt_provider_entity: MockSTTProviderEntity ) -> None: @@ -473,6 +514,7 @@ async def test_default_pipeline_unsupported_stt_language( @pytest.mark.usefixtures("init_supporting_components") +@pytest.mark.usefixtures("disable_tts_entity") async def test_default_pipeline_unsupported_tts_language( hass: HomeAssistant, mock_tts_provider: MockTTSProvider ) -> None: @@ -684,7 +726,7 @@ def test_fallback_intent_filter() -> None: entities_list=[], ) ) - is True + is False ) assert ( _async_local_fallback_intent_filter( @@ -697,3 +739,965 @@ def test_fallback_intent_filter() -> None: ) is False ) + + +async def test_wake_word_detection_aborted( + hass: HomeAssistant, + mock_stt_provider: MockSTTProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, + snapshot: SnapshotAssertion, +) -> None: + """Test wake word stream is first detected, then aborted.""" + + events: list[assist_pipeline.PipelineEvent] = [] + + async def audio_data(): + yield make_10ms_chunk(b"silence!") + yield make_10ms_chunk(b"wake word!") + yield make_10ms_chunk(b"part1") + yield make_10ms_chunk(b"part2") + yield b"" + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + session=mock_chat_session, + device_id=None, + 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(), + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output=None, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False), + ), + ) + await pipeline_input.validate() + + updates = pipeline.to_json() + updates.pop("id") + await pipeline_store.async_update_item( + pipeline_id, + updates, + ) + await pipeline_input.execute() + + assert process_events(events) == snapshot + + +def test_pipeline_run_equality(hass: HomeAssistant, init_components) -> None: + """Test that pipeline run equality uses unique id.""" + + def event_callback(event): + pass + + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass) + run_1 = assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.STT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + ) + run_2 = assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.STT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + ) + + assert run_1 == run_1 # noqa: PLR0124 + assert run_1 != run_2 + assert run_1 != 1234 + + +async def test_tts_audio_output( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts_entity: MockTTSProvider, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, + snapshot: SnapshotAssertion, +) -> None: + """Test using tts_audio_output with wav sets options correctly.""" + client = await hass_client() + assert await async_setup_component(hass, media_source.DOMAIN, {}) + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + tts_input="This is a test.", + session=mock_chat_session, + device_id=None, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.TTS, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output="wav", + ), + ) + await pipeline_input.validate() + + # Verify TTS audio settings + assert pipeline_input.run.tts_stream.options is not None + assert pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) + == 16000 + ) + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) + == 1 + ) + + with patch.object(mock_tts_entity, "get_tts_audio") as mock_get_tts_audio: + await pipeline_input.execute() + + for event in events: + if event.type == assist_pipeline.PipelineEventType.TTS_END: + # We must fetch the media URL to trigger the TTS + assert event.data + await client.get(event.data["tts_output"]["url"]) + + # Ensure that no unsupported options were passed in + assert mock_get_tts_audio.called + options = mock_get_tts_audio.call_args_list[0].kwargs["options"] + extra_options = set(options).difference(mock_tts_entity.supported_options) + assert len(extra_options) == 0, extra_options + + +async def test_tts_wav_preferred_format( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts_entity: MockTTSEntity, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that preferred format options are given to the TTS system if supported.""" + client = await hass_client() + assert await async_setup_component(hass, media_source.DOMAIN, {}) + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + tts_input="This is a test.", + session=mock_chat_session, + device_id=None, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.TTS, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output="wav", + ), + ) + await pipeline_input.validate() + + # Make the TTS provider support preferred format options + supported_options = list(mock_tts_entity.supported_options or []) + supported_options.extend( + [ + tts.ATTR_PREFERRED_FORMAT, + tts.ATTR_PREFERRED_SAMPLE_RATE, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS, + tts.ATTR_PREFERRED_SAMPLE_BYTES, + ] + ) + + with ( + patch.object(mock_tts_entity, "_supported_options", supported_options), + patch.object(mock_tts_entity, "get_tts_audio") as mock_get_tts_audio, + ): + await pipeline_input.execute() + + for event in events: + if event.type == assist_pipeline.PipelineEventType.TTS_END: + # We must fetch the media URL to trigger the TTS + assert event.data + await client.get(event.data["tts_output"]["url"]) + + assert mock_get_tts_audio.called + options = mock_get_tts_audio.call_args_list[0].kwargs["options"] + + # We should have received preferred format options in get_tts_audio + assert options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 16000 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 1 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 + + +async def test_tts_dict_preferred_format( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts_entity: MockTTSEntity, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that preferred format options are given to the TTS system if supported.""" + client = await hass_client() + assert await async_setup_component(hass, media_source.DOMAIN, {}) + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + tts_input="This is a test.", + session=mock_chat_session, + device_id=None, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.TTS, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output={ + tts.ATTR_PREFERRED_FORMAT: "flac", + tts.ATTR_PREFERRED_SAMPLE_RATE: 48000, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 2, + tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + }, + ), + ) + await pipeline_input.validate() + + # Make the TTS provider support preferred format options + supported_options = list(mock_tts_entity.supported_options or []) + supported_options.extend( + [ + tts.ATTR_PREFERRED_FORMAT, + tts.ATTR_PREFERRED_SAMPLE_RATE, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS, + tts.ATTR_PREFERRED_SAMPLE_BYTES, + ] + ) + + with ( + patch.object(mock_tts_entity, "_supported_options", supported_options), + patch.object(mock_tts_entity, "get_tts_audio") as mock_get_tts_audio, + ): + await pipeline_input.execute() + + for event in events: + if event.type == assist_pipeline.PipelineEventType.TTS_END: + # We must fetch the media URL to trigger the TTS + assert event.data + await client.get(event.data["tts_output"]["url"]) + + assert mock_get_tts_audio.called + options = mock_get_tts_audio.call_args_list[0].kwargs["options"] + + # We should have received preferred format options in get_tts_audio + assert options.get(tts.ATTR_PREFERRED_FORMAT) == "flac" + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 48000 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 2 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 + + +async def test_sentence_trigger_overrides_conversation_agent( + hass: HomeAssistant, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that sentence triggers are checked before a non-default conversation agent.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": [ + "test trigger sentence", + ], + }, + "action": { + "set_conversation_response": "test trigger response", + }, + } + }, + ) + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test trigger sentence", + session=mock_chat_session, + 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, + intent_agent="test-agent", # not the default agent + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ): + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" + ) as mock_async_converse: + await pipeline_input.execute() + + # Sentence trigger should have been handled + mock_async_converse.assert_not_called() + + # Verify sentence trigger response + intent_end_event = next( + ( + e + for e in events + if e.type == assist_pipeline.PipelineEventType.INTENT_END + ), + None, + ) + assert (intent_end_event is not None) and intent_end_event.data + assert ( + intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ + "speech" + ] + == "test trigger response" + ) + + +async def test_prefer_local_intents( + hass: HomeAssistant, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that the default agent is checked first when local intents are preferred.""" + events: list[assist_pipeline.PipelineEvent] = [] + + # Reuse custom sentences in test config + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + response = intent_obj.create_response() + response.async_set_speech("Order confirmed") + return response + + handler = OrderBeerIntentHandler() + intent.async_register(hass, handler) + + # Fake a test agent and prefer local intents + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent", prefer_local_intents=True + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="I'd like to order a stout please", + session=mock_chat_session, + 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, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ): + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" + ) as mock_async_converse: + await pipeline_input.execute() + + # Test agent should not have been called + mock_async_converse.assert_not_called() + + # Verify local intent response + intent_end_event = next( + ( + e + for e in events + if e.type == assist_pipeline.PipelineEventType.INTENT_END + ), + None, + ) + assert (intent_end_event is not None) and intent_end_event.data + assert ( + intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ + "speech" + ] + == "Order confirmed" + ) + + +async def test_intent_continue_conversation( + hass: HomeAssistant, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that a conversation agent flagging continue conversation gets response.""" + events: list[assist_pipeline.PipelineEvent] = [] + + # Fake a test agent and prefer local intents + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent" + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Set a timer", + session=mock_chat_session, + 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, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ): + await pipeline_input.validate() + + response = intent.IntentResponse("en") + response.async_set_speech("For how long?") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + continue_conversation=True, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[1]["intent_output"]["continue_conversation"] is True + + # Change conversation agent to default one and register sentence trigger that should not be called + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine=None + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["Hello"], + }, + "action": { + "set_conversation_response": "test trigger response", + }, + } + }, + ) + + # Because we did continue conversation, it should respond to the test agent again. + events.clear() + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Hello", + session=mock_chat_session, + 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, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ) as mock_prepare: + await pipeline_input.validate() + + # It requested test agent even if that was not default agent. + assert mock_prepare.mock_calls[0][1][1] == "test-agent" + + response = intent.IntentResponse("en") + response.async_set_speech("Timer set for 20 minutes") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + # Snapshot will show it was still handled by the test agent and not default agent + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[0]["engine"] == "test-agent" + assert results[1]["intent_output"]["continue_conversation"] is False + + +async def test_stt_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_chat_session: chat_session.ChatSession, + 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", + session=mock_chat_session, + 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, + mock_chat_session: chat_session.ChatSession, + 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", + session=mock_chat_session, + 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, + mock_chat_session: chat_session.ChatSession, + 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", + session=mock_chat_session, + 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 + ) + + +@pytest.mark.parametrize( + ("to_stream_tts", "expected_chunks"), + [ + ( + [ + "hello,", + " ", + "how", + " ", + "are", + " ", + "you", + "?", + ], + 1, + ), + ], +) +async def test_chat_log_tts_streaming( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_chat_session: chat_session.ChatSession, + snapshot: SnapshotAssertion, + mock_tts_entity: MockTTSEntity, + pipeline_data: assist_pipeline.pipeline.PipelineData, + to_stream_tts: list[str], + expected_chunks: int, +) -> None: + """Test that chat log events are streamed to the TTS entity.""" + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent" + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Set a timer", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + ), + ) + + received_tts = [] + + async def async_stream_tts_audio( + request: tts.TTSAudioRequest, + ) -> tts.TTSAudioResponse: + """Mock stream TTS audio.""" + + async def gen_data(): + async for msg in request.message_gen: + received_tts.append(msg) + yield msg.encode() + + return tts.TTSAudioResponse( + extension="mp3", + data_gen=gen_data(), + ) + + mock_tts_entity.async_stream_tts_audio = async_stream_tts_audio + mock_tts_entity.async_supports_streaming_input = Mock(return_value=True) + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ): + await pipeline_input.validate() + + async def mock_converse( + hass: HomeAssistant, + text: str, + conversation_id: str | None, + context: Context, + language: str | None = None, + agent_id: str | None = None, + device_id: str | None = None, + extra_system_prompt: str | None = None, + ): + """Mock converse.""" + conversation_input = conversation.ConversationInput( + text=text, + context=context, + conversation_id=conversation_id, + device_id=device_id, + language=language, + agent_id=agent_id, + extra_system_prompt=extra_system_prompt, + ) + + async def stream_llm_response(): + yield {"role": "assistant"} + for chunk in to_stream_tts: + yield {"content": chunk} + + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + conversation.async_get_chat_log( + hass, + session, + conversation_input, + ) as chat_log, + ): + async for _content in chat_log.async_add_delta_content_stream( + agent_id, stream_llm_response() + ): + pass + intent_response = intent.IntentResponse(language) + intent_response.async_set_speech("".join(to_stream_tts)) + return conversation.ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + mock_converse, + ): + await pipeline_input.execute() + + stream = tts.async_get_stream(hass, events[0].data["tts_output"]["token"]) + assert stream is not None + tts_result = "".join( + [chunk.decode() async for chunk in stream.async_stream_result()] + ) + + streamed_text = "".join(to_stream_tts) + assert tts_result == streamed_text + assert len(received_tts) == expected_chunks + assert "".join(received_tts) == streamed_text + + assert process_events(events) == snapshot diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index f856bbe7f61..bf9818f2a5f 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -20,6 +20,8 @@ from homeassistant.components.assist_pipeline.pipeline import ( DeviceAudioQueue, Pipeline, PipelineData, + async_get_pipelines, + async_update_pipeline, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -38,13 +40,21 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def mock_ulid() -> Generator[Mock]: - """Mock the ulid of chat sessions.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: yield mock_ulid_now +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield + + @pytest.mark.parametrize( "extra_msg", [ @@ -825,74 +835,6 @@ async def test_stt_stream_failed( assert msg["result"] == {"events": events} -async def test_tts_failed( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - snapshot: SnapshotAssertion, -) -> None: - """Test pipeline run with text-to-speech error.""" - events = [] - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.media_source.async_resolve_media", - side_effect=RuntimeError, - ): - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "tts", - "end_stage": "tts", - "input": {"text": "Lights are on."}, - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - msg["event"]["data"]["pipeline"] = ANY - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - # tts start - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - # tts error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "tts-failed" - events.append(msg["event"]) - - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_debug)[0] - pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] - - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline_debug/get", - "pipeline_id": pipeline_id, - "pipeline_run_id": pipeline_run_id, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == {"events": events} - - async def test_tts_provider_missing( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -903,23 +845,22 @@ async def test_tts_provider_missing( """Test pipeline run with text-to-speech error.""" client = await hass_ws_client(hass) - with patch( - "homeassistant.components.tts.async_support_options", - side_effect=HomeAssistantError, - ): - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "tts", - "end_stage": "tts", - "input": {"text": "Lights are on."}, - } - ) + pipelines = async_get_pipelines(hass) + await async_update_pipeline(hass, pipelines[0], tts_engine="unavailable") - # result - msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "tts-not-supported" + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "tts-not-supported" async def test_tts_provider_bad_options( @@ -933,8 +874,8 @@ async def test_tts_provider_bad_options( client = await hass_ws_client(hass) with patch( - "homeassistant.components.tts.async_support_options", - return_value=False, + "homeassistant.components.tts.SpeechManager.process_options", + side_effect=HomeAssistantError("Language not supported"), ): await client.send_json_auto_id( { @@ -1212,9 +1153,9 @@ async def test_get_pipeline( "name": "Home Assistant", "stt_engine": "stt.mock_stt", "stt_language": "en-US", - "tts_engine": "test", - "tts_language": "en-US", - "tts_voice": "james_earl_jones", + "tts_engine": "tts.test", + "tts_language": "en_US", + "tts_voice": None, "wake_word_entity": None, "wake_word_id": None, "prefer_local_intents": False, @@ -1238,9 +1179,9 @@ async def test_get_pipeline( # It found these defaults "stt_engine": "stt.mock_stt", "stt_language": "en-US", - "tts_engine": "test", - "tts_language": "en-US", - "tts_voice": "james_earl_jones", + "tts_engine": "tts.test", + "tts_language": "en_US", + "tts_voice": None, "wake_word_entity": None, "wake_word_id": None, "prefer_local_intents": False, @@ -1325,9 +1266,9 @@ async def test_list_pipelines( "name": "Home Assistant", "stt_engine": "stt.mock_stt", "stt_language": "en-US", - "tts_engine": "test", - "tts_language": "en-US", - "tts_voice": "james_earl_jones", + "tts_engine": "tts.test", + "tts_language": "en_US", + "tts_voice": None, "wake_word_entity": None, "wake_word_id": None, "prefer_local_intents": False, diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 42b4adf742c..8050b23f5ff 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -22,6 +22,7 @@ from homeassistant.components.assist_satellite import ( AssistSatelliteAnnouncement, SatelliteBusyError, ) +from homeassistant.components.assist_satellite.const import PREANNOUNCE_URL from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry @@ -31,6 +32,8 @@ from homeassistant.exceptions import HomeAssistantError from . import ENTITY_ID from .conftest import MockAssistSatellite +from tests.components.tts.common import MockResultStream + @pytest.fixture def mock_chat_session_conversation_id() -> Generator[Mock]: @@ -183,11 +186,12 @@ async def test_new_pipeline_cancels_pipeline( ("service_data", "expected_params"), [ ( - {"message": "Hello"}, + {"message": "Hello", "preannounce": False}, AssistSatelliteAnnouncement( message="Hello", - media_id="https://www.home-assistant.io/resolved.mp3", + media_id="http://10.10.10.10:8123/api/tts_proxy/test-token", original_media_id="media-source://bla", + tts_token="test-token", media_id_source="tts", ), ), @@ -195,23 +199,40 @@ async def test_new_pipeline_cancels_pipeline( { "message": "Hello", "media_id": "media-source://given", + "preannounce": False, }, AssistSatelliteAnnouncement( message="Hello", media_id="https://www.home-assistant.io/resolved.mp3", original_media_id="media-source://given", + tts_token=None, media_id_source="media_id", ), ), ( - {"media_id": "http://example.com/bla.mp3"}, + {"media_id": "http://example.com/bla.mp3", "preannounce": False}, AssistSatelliteAnnouncement( message="", media_id="http://example.com/bla.mp3", original_media_id="http://example.com/bla.mp3", + tts_token=None, media_id_source="url", ), ), + ( + { + "media_id": "http://example.com/bla.mp3", + "preannounce_media_id": "http://example.com/preannounce.mp3", + }, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/bla.mp3", + original_media_id="http://example.com/bla.mp3", + tts_token=None, + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), ], ) async def test_announce( @@ -243,9 +264,17 @@ async def test_announce( with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.tts.generate_media_source_id", new=tts_generate_media_source_id, ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), patch( "homeassistant.components.media_source.async_resolve_media", return_value=PlayMedia( @@ -341,6 +370,24 @@ async def test_announce_cancels_pipeline( mock_async_announce.assert_called_once() +async def test_announce_default_preannounce( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test announcing on a device with the default preannouncement sound.""" + + async def async_announce(announcement): + assert announcement.preannounce_media_id.endswith(PREANNOUNCE_URL) + + with patch.object(entity, "async_announce", new=async_announce): + await hass.services.async_call( + "assist_satellite", + "announce", + {"media_id": "test-media-id"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + async def test_context_refresh( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: @@ -494,13 +541,15 @@ async def test_vad_sensitivity_entity_not_found( { "start_message": "Hello", "extra_system_prompt": "Better system prompt", + "preannounce": False, }, ( "mock-conversation-id", "Better system prompt", AssistSatelliteAnnouncement( message="Hello", - media_id="https://www.home-assistant.io/resolved.mp3", + media_id="http://10.10.10.10:8123/api/tts_proxy/test-token", + tts_token="test-token", original_media_id="media-source://generated", media_id_source="tts", ), @@ -510,6 +559,7 @@ async def test_vad_sensitivity_entity_not_found( { "start_message": "Hello", "start_media_id": "media-source://given", + "preannounce": False, }, ( "mock-conversation-id", @@ -517,24 +567,47 @@ async def test_vad_sensitivity_entity_not_found( AssistSatelliteAnnouncement( message="Hello", media_id="https://www.home-assistant.io/resolved.mp3", + tts_token=None, original_media_id="media-source://given", media_id_source="media_id", ), ), ), ( - {"start_media_id": "http://example.com/given.mp3"}, + { + "start_media_id": "http://example.com/given.mp3", + "preannounce": False, + }, ( "mock-conversation-id", None, AssistSatelliteAnnouncement( message="", media_id="http://example.com/given.mp3", + tts_token=None, original_media_id="http://example.com/given.mp3", media_id_source="url", ), ), ), + ( + { + "start_media_id": "http://example.com/given.mp3", + "preannounce_media_id": "http://example.com/preannounce.mp3", + }, + ( + "mock-conversation-id", + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + tts_token=None, + original_media_id="http://example.com/given.mp3", + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), + ), ], ) @pytest.mark.usefixtures("mock_chat_session_conversation_id") @@ -546,6 +619,13 @@ async def test_start_conversation( expected_params: tuple[str, str], ) -> None: """Test starting a conversation on a device.""" + original_start_conversation = entity.async_start_conversation + + async def async_start_conversation(start_announcement): + # Verify state change + assert entity.state == AssistSatelliteState.RESPONDING + await original_start_conversation(start_announcement) + await async_update_pipeline( hass, async_get_pipeline(hass), @@ -554,9 +634,17 @@ async def test_start_conversation( with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.tts.generate_media_source_id", return_value="media-source://generated", ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), patch( "homeassistant.components.media_source.async_resolve_media", return_value=PlayMedia( @@ -564,6 +652,7 @@ async def test_start_conversation( mime_type="audio/mp3", ), ), + patch.object(entity, "async_start_conversation", new=async_start_conversation), ): await hass.services.async_call( "assist_satellite", @@ -572,6 +661,7 @@ async def test_start_conversation( target={"entity_id": "assist_satellite.test_entity"}, blocking=True, ) + assert entity.state == AssistSatelliteState.IDLE assert entity.start_conversations[0] == expected_params @@ -592,6 +682,32 @@ async def test_start_conversation_reject_builtin_agent( ) +async def test_start_conversation_default_preannounce( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test starting a conversation on a device with the default preannouncement sound.""" + + async def async_start_conversation(start_announcement): + assert PREANNOUNCE_URL in start_announcement.preannounce_media_id + + await async_update_pipeline( + hass, + async_get_pipeline(hass), + conversation_engine="conversation.some_llm", + ) + + with ( + patch.object(entity, "async_start_conversation", new=async_start_conversation), + ): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + {"start_media_id": "test-media-id"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + async def test_wake_word_start_keeps_responding( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py index 9304229dbe3..0e531811adc 100644 --- a/tests/components/assist_satellite/test_intent.py +++ b/tests/components/assist_satellite/test_intent.py @@ -4,28 +4,28 @@ 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 homeassistant.setup import async_setup_component from .conftest import TEST_DOMAIN, MockAssistSatellite +from tests.components.tts.common import MockResultStream + @pytest.fixture -def mock_tts(): +async def mock_tts(hass: HomeAssistant): """Mock TTS service.""" + assert await async_setup_component(hass, "tts", {}) with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.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", - ), + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), ), ): yield @@ -41,9 +41,13 @@ async def test_broadcast_intent( ) -> None: """Test we can invoke a broadcast intent.""" - result = await intent.async_handle( - hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} - ) + with patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ): + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) assert result.as_dict() == { "card": {}, @@ -71,13 +75,17 @@ async def test_broadcast_intent( 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, - ) + with patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ): + 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": {}, diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py index f0a8f02fc50..23eec7e8461 100644 --- a/tests/components/assist_satellite/test_websocket_api.py +++ b/tests/components/assist_satellite/test_websocket_api.py @@ -445,6 +445,7 @@ async def test_connection_test( assert len(entity.announcements) == 1 assert entity.announcements[0].message == "" + assert entity.announcements[0].preannounce_media_id is None announcement_media_id = entity.announcements[0].media_id hass_url = "http://10.10.10.10:8123" assert announcement_media_id.startswith( diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index bcdd4d55330..563221635f8 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -4,7 +4,7 @@ import datetime from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from yalexs.pubnub_async import AugustPubNub from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN diff --git a/tests/components/august/test_diagnostics.py b/tests/components/august/test_diagnostics.py index 0b00bde7b23..cdc538ca6bd 100644 --- a/tests/components/august/test_diagnostics.py +++ b/tests/components/august/test_diagnostics.py @@ -1,6 +1,6 @@ """Test august diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 065ffef91ff..a1ba83ecb01 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -6,7 +6,7 @@ from unittest.mock import Mock from aiohttp import ClientResponseError from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from yalexs.manager.activity import INITIAL_LOCK_RESYNC_TIME from yalexs.pubnub_async import AugustPubNub diff --git a/tests/components/autarco/test_diagnostics.py b/tests/components/autarco/test_diagnostics.py index 1d12a2c1894..461f65becdb 100644 --- a/tests/components/autarco/test_diagnostics.py +++ b/tests/components/autarco/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/autarco/test_sensor.py b/tests/components/autarco/test_sensor.py index c7e65baba70..9cdc93e98b0 100644 --- a/tests/components/autarco/test_sensor.py +++ b/tests/components/autarco/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from autarco import AutarcoConnectionError from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, Platform diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index 8c9cd6e3a24..040deaf8f80 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -127,7 +127,7 @@ async def test_awair_gen1_sensors( assert_expected_properties( hass, entity_registry, - "sensor.living_room_vocs", + "sensor.living_room_volatile_organic_compounds_parts", f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_VOC].unique_id_tag}", "366", { diff --git a/tests/components/aws_s3/__init__.py b/tests/components/aws_s3/__init__.py new file mode 100644 index 00000000000..90e4652bb2b --- /dev/null +++ b/tests/components/aws_s3/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the AWS S3 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 S3 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/aws_s3/conftest.py b/tests/components/aws_s3/conftest.py new file mode 100644 index 00000000000..8f12ee17661 --- /dev/null +++ b/tests/components/aws_s3/conftest.py @@ -0,0 +1,82 @@ +"""Common fixtures for the AWS S3 tests.""" + +from collections.abc import AsyncIterator, Generator +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.aws_s3.backup import ( + MULTIPART_MIN_PART_SIZE_BYTES, + suggested_filenames, +) +from homeassistant.components.aws_s3.const import DOMAIN +from homeassistant.components.backup import AgentBackup + +from .const import USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture( + params=[2**20, MULTIPART_MIN_PART_SIZE_BYTES], + ids=["small", "large"], +) +def test_backup(request: pytest.FixtureRequest) -> None: + """Test backup fixture.""" + return AgentBackup( + 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=request.param, + ) + + +@pytest.fixture(autouse=True) +def mock_client(test_backup: AgentBackup) -> Generator[AsyncMock]: + """Mock the S3 client.""" + with patch( + "aiobotocore.session.AioSession.create_client", + autospec=True, + return_value=AsyncMock(), + ) as create_client: + client = create_client.return_value + + tar_file, metadata_file = suggested_filenames(test_backup) + client.list_objects_v2.return_value = { + "Contents": [{"Key": tar_file}, {"Key": metadata_file}] + } + client.create_multipart_upload.return_value = {"UploadId": "upload_id"} + client.upload_part.return_value = {"ETag": "etag"} + + # to simplify this mock, we assume that backup is always "iterated" over, while metadata is always "read" as a whole + class MockStream: + async def iter_chunks(self) -> AsyncIterator[bytes]: + yield b"backup data" + + async def read(self) -> bytes: + return json.dumps(test_backup.as_dict()).encode() + + client.get_object.return_value = {"Body": MockStream()} + client.head_bucket.return_value = {} + + create_client.return_value.__aenter__.return_value = client + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + entry_id="test", + title="test", + domain=DOMAIN, + data=USER_INPUT, + ) diff --git a/tests/components/aws_s3/const.py b/tests/components/aws_s3/const.py new file mode 100644 index 00000000000..ebffa11d956 --- /dev/null +++ b/tests/components/aws_s3/const.py @@ -0,0 +1,15 @@ +"""Consts for AWS S3 tests.""" + +from homeassistant.components.aws_s3.const import ( + CONF_ACCESS_KEY_ID, + CONF_BUCKET, + CONF_ENDPOINT_URL, + CONF_SECRET_ACCESS_KEY, +) + +USER_INPUT = { + CONF_ACCESS_KEY_ID: "TestTestTestTestTest", + CONF_SECRET_ACCESS_KEY: "TestTestTestTestTestTestTestTestTestTest", + CONF_ENDPOINT_URL: "https://s3.eu-south-1.amazonaws.com", + CONF_BUCKET: "test", +} diff --git a/tests/components/aws_s3/test_backup.py b/tests/components/aws_s3/test_backup.py new file mode 100644 index 00000000000..a8b24ec1ab4 --- /dev/null +++ b/tests/components/aws_s3/test_backup.py @@ -0,0 +1,470 @@ +"""Test the AWS S3 backup platform.""" + +from collections.abc import AsyncGenerator +from io import StringIO +import json +from time import time +from unittest.mock import AsyncMock, Mock, patch + +from botocore.exceptions import ConnectTimeoutError +import pytest + +from homeassistant.components.aws_s3.backup import ( + MULTIPART_MIN_PART_SIZE_BYTES, + BotoCoreError, + S3BackupAgent, + async_register_backup_agents_listener, + suggested_filenames, +) +from homeassistant.components.aws_s3.const import ( + CONF_ENDPOINT_URL, + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .const import USER_INPUT + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up S3 integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + async_initialize_backup(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_suggested_filenames() -> None: + """Test the suggested_filenames function.""" + backup = AgentBackup( + backup_id="a1b2c3", + date="2021-01-01T01:02:03+00:00", + addons=[], + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=False, + homeassistant_version=None, + name="my_pretty_backup", + protected=False, + size=0, + ) + tar_filename, metadata_filename = suggested_filenames(backup) + + assert tar_filename == "my_pretty_backup_2021-01-01_01.02_03000000.tar" + assert ( + metadata_filename == "my_pretty_backup_2021-01-01_01.02_03000000.metadata.json" + ) + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> 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", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.entry_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + test_backup: AgentBackup, +) -> 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"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": test_backup.addons, + "backup_id": test_backup.backup_id, + "date": test_backup.date, + "database_included": test_backup.database_included, + "folders": test_backup.folders, + "homeassistant_included": test_backup.homeassistant_included, + "homeassistant_version": test_backup.homeassistant_version, + "name": test_backup.name, + "extra_metadata": test_backup.extra_metadata, + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": test_backup.protected, + "size": test_backup.size, + } + }, + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + test_backup: AgentBackup, +) -> None: + """Test agent get backup.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "backup/details", "backup_id": test_backup.backup_id} + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": test_backup.addons, + "backup_id": test_backup.backup_id, + "date": test_backup.date, + "database_included": test_backup.database_included, + "folders": test_backup.folders, + "homeassistant_included": test_backup.homeassistant_included, + "homeassistant_version": test_backup.homeassistant_version, + "name": test_backup.name, + "extra_metadata": test_backup.extra_metadata, + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": test_backup.protected, + "size": test_backup.size, + } + }, + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_get_backup_does_not_throw_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent get backup does not throw on a backup not found.""" + mock_client.list_objects_v2.return_value = {"Contents": []} + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": "random"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] is None + + +async def test_agents_list_backups_with_corrupted_metadata( + hass: HomeAssistant, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + test_backup: AgentBackup, +) -> None: + """Test listing backups when one metadata file is corrupted.""" + # Create agent + agent = S3BackupAgent(hass, mock_config_entry) + + # Set up mock responses for both valid and corrupted metadata files + mock_client.list_objects_v2.return_value = { + "Contents": [ + { + "Key": "valid_backup.metadata.json", + "LastModified": "2023-01-01T00:00:00+00:00", + }, + { + "Key": "corrupted_backup.metadata.json", + "LastModified": "2023-01-01T00:00:00+00:00", + }, + ] + } + + # Mock responses for get_object calls + valid_metadata = json.dumps(test_backup.as_dict()) + corrupted_metadata = "{invalid json content" + + async def mock_get_object(**kwargs): + """Mock get_object with different responses based on the key.""" + key = kwargs.get("Key", "") + if "valid_backup" in key: + mock_body = AsyncMock() + mock_body.read.return_value = valid_metadata.encode() + return {"Body": mock_body} + # Corrupted metadata + mock_body = AsyncMock() + mock_body.read.return_value = corrupted_metadata.encode() + return {"Body": mock_body} + + mock_client.get_object.side_effect = mock_get_object + + backups = await agent.async_list_backups() + assert len(backups) == 1 + assert backups[0].backup_id == test_backup.backup_id + assert "Failed to process metadata file" in caplog.text + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "23e64aec", + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + # Should delete both the tar and the metadata file + assert mock_client.delete_object.call_count == 2 + + +async def test_agents_delete_not_throwing_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup does not throw on a backup not found.""" + mock_client.list_objects_v2.return_value = {"Contents": []} + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "random", + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + assert mock_client.delete_object.call_count == 0 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, + test_backup: AgentBackup, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=test_backup, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + # we must emit at least two chunks + # the "appendix" chunk triggers the upload of the final buffer part + mocked_open.return_value.read = Mock( + side_effect=[ + b"a" * test_backup.size, + b"appendix", + b"", + ] + ) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + if test_backup.size < MULTIPART_MIN_PART_SIZE_BYTES: + # single part + metadata both as regular upload (no multiparts) + assert mock_client.create_multipart_upload.await_count == 0 + assert mock_client.put_object.await_count == 2 + else: + assert "Uploading final part" in caplog.text + # 2 parts as multipart + metadata as regular upload + assert mock_client.create_multipart_upload.await_count == 1 + assert mock_client.upload_part.await_count == 2 + assert mock_client.complete_multipart_upload.await_count == 1 + assert mock_client.put_object.await_count == 1 + + +async def test_agents_upload_network_failure( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, + test_backup: AgentBackup, +) -> None: + """Test agent upload backup with network failure.""" + client = await hass_client() + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=test_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""]) + # simulate network failure + mock_client.put_object.side_effect = mock_client.upload_part.side_effect = ( + mock_client.abort_multipart_upload.side_effect + ) = ConnectTimeoutError(endpoint_url=USER_INPUT[CONF_ENDPOINT_URL]) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert "Upload failed for aws_s3" in caplog.text + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = "23e64aec" + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + assert mock_client.get_object.call_count == 2 # One for metadata, one for tar file + + +async def test_error_during_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, + test_backup: AgentBackup, +) -> None: + """Test the error wrapper.""" + mock_client.delete_object.side_effect = BotoCoreError + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": test_backup.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": { + f"{DOMAIN}.{mock_config_entry.entry_id}": "Failed during async_delete_backup" + } + } + + +async def test_cache_expiration( + hass: HomeAssistant, + mock_client: MagicMock, + test_backup: AgentBackup, +) -> None: + """Test that the cache expires correctly.""" + # Mock the entry + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"bucket": "test-bucket"}, + unique_id="test-unique-id", + title="Test S3", + ) + mock_entry.runtime_data = mock_client + + # Create agent + agent = S3BackupAgent(hass, mock_entry) + + # Mock metadata response + metadata_content = json.dumps(test_backup.as_dict()) + mock_body = AsyncMock() + mock_body.read.return_value = metadata_content.encode() + mock_client.list_objects_v2.return_value = { + "Contents": [ + {"Key": "test.metadata.json", "LastModified": "2023-01-01T00:00:00+00:00"} + ] + } + + # First call should query S3 + await agent.async_list_backups() + assert mock_client.list_objects_v2.call_count == 1 + assert mock_client.get_object.call_count == 1 + + # Second call should use cache + await agent.async_list_backups() + assert mock_client.list_objects_v2.call_count == 1 + assert mock_client.get_object.call_count == 1 + + # Set cache to expire + agent._cache_expiration = time() - 1 + + # Third call should query S3 again + await agent.async_list_backups() + assert mock_client.list_objects_v2.call_count == 2 + assert mock_client.get_object.call_count == 2 + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = MagicMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [ + listener + ] # make sure it's the last listener + remove_listener() + + assert DATA_BACKUP_AGENT_LISTENERS not in hass.data diff --git a/tests/components/aws_s3/test_config_flow.py b/tests/components/aws_s3/test_config_flow.py new file mode 100644 index 00000000000..593eea5cdb9 --- /dev/null +++ b/tests/components/aws_s3/test_config_flow.py @@ -0,0 +1,143 @@ +"""Test the AWS S3 config flow.""" + +from unittest.mock import AsyncMock, patch + +from botocore.exceptions import ( + ClientError, + EndpointConnectionError, + ParamValidationError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.aws_s3.const import CONF_BUCKET, CONF_ENDPOINT_URL, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import USER_INPUT + +from tests.common import MockConfigEntry + + +async def _async_start_flow( + hass: HomeAssistant, + user_input: dict[str, str] | None = None, +) -> FlowResultType: + """Initialize the config flow.""" + if user_input is None: + user_input = USER_INPUT + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + + +async def test_flow(hass: HomeAssistant) -> None: + """Test config flow.""" + result = await _async_start_flow(hass) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test" + assert result["data"] == USER_INPUT + + +@pytest.mark.parametrize( + ("exception", "errors"), + [ + ( + ParamValidationError(report="Invalid bucket name"), + {CONF_BUCKET: "invalid_bucket_name"}, + ), + (ValueError(), {CONF_ENDPOINT_URL: "invalid_endpoint_url"}), + ( + EndpointConnectionError(endpoint_url="http://example.com"), + {CONF_ENDPOINT_URL: "cannot_connect"}, + ), + ], +) +async def test_flow_create_client_errors( + hass: HomeAssistant, + exception: Exception, + errors: dict[str, str], +) -> None: + """Test config flow errors.""" + with patch( + "aiobotocore.session.AioSession.create_client", + side_effect=exception, + ): + result = await _async_start_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == errors + + # Fix and finish the test + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test" + assert result["data"] == USER_INPUT + + +async def test_flow_head_bucket_error( + hass: HomeAssistant, + mock_client: AsyncMock, +) -> None: + """Test setup_entry error when calling head_bucket.""" + mock_client.head_bucket.side_effect = ClientError( + error_response={"Error": {"Code": "InvalidAccessKeyId"}}, + operation_name="head_bucket", + ) + result = await _async_start_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_credentials"} + + # Fix and finish the test + mock_client.head_bucket.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"] == "test" + assert result["data"] == USER_INPUT + + +async def test_abort_if_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if the account is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await _async_start_flow(hass) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_create_not_aws_endpoint( + hass: HomeAssistant, +) -> None: + """Test config flow with a not aws endpoint should raise an error.""" + result = await _async_start_flow( + hass, USER_INPUT | {CONF_ENDPOINT_URL: "http://example.com"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_ENDPOINT_URL: "invalid_endpoint_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test" + assert result["data"] == USER_INPUT diff --git a/tests/components/aws_s3/test_init.py b/tests/components/aws_s3/test_init.py new file mode 100644 index 00000000000..ee247bfce1d --- /dev/null +++ b/tests/components/aws_s3/test_init.py @@ -0,0 +1,75 @@ +"""Test the AWS S3 storage integration.""" + +from unittest.mock import AsyncMock, patch + +from botocore.exceptions import ( + ClientError, + EndpointConnectionError, + ParamValidationError, +) +import pytest + +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_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + 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.parametrize( + ("exception", "state"), + [ + ( + ParamValidationError(report="Invalid bucket name"), + ConfigEntryState.SETUP_ERROR, + ), + (ValueError(), ConfigEntryState.SETUP_ERROR), + ( + EndpointConnectionError(endpoint_url="https://example.com"), + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_setup_entry_create_client_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test various setup errors.""" + with patch( + "aiobotocore.session.AioSession.create_client", + side_effect=exception, + ): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is state + + +async def test_setup_entry_head_bucket_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, +) -> None: + """Test setup_entry error when calling head_bucket.""" + mock_client.head_bucket.side_effect = ClientError( + error_response={"Error": {"Code": "InvalidAccessKeyId"}}, + operation_name="head_bucket", + ) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/axis/snapshots/test_camera.ambr b/tests/components/axis/snapshots/test_camera.ambr index 1e70e2a799f..d323a209dc8 100644 --- a/tests/components/axis/snapshots/test_camera.ambr +++ b/tests/components/axis/snapshots/test_camera.ambr @@ -39,7 +39,6 @@ 'access_token': '1', 'entity_picture': '/api/camera_proxy/camera.home?token=1', 'friendly_name': 'home', - 'frontend_stream_type': , 'supported_features': , }), 'context': , @@ -90,7 +89,6 @@ 'access_token': '1', 'entity_picture': '/api/camera_proxy/camera.home?token=1', 'friendly_name': 'home', - 'frontend_stream_type': , 'supported_features': , }), 'context': , diff --git a/tests/components/axis/test_binary_sensor.py b/tests/components/axis/test_binary_sensor.py index 766a51463a4..e13d77c73c8 100644 --- a/tests/components/axis/test_binary_sensor.py +++ b/tests/components/axis/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.const import Platform diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py index 9dcfbac4e7b..1f6f1bf44f8 100644 --- a/tests/components/axis/test_camera.py +++ b/tests/components/axis/test_camera.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import camera from homeassistant.components.axis.const import CONF_STREAM_PROFILE diff --git a/tests/components/axis/test_diagnostics.py b/tests/components/axis/test_diagnostics.py index e96ba88c2cd..9107ef2e8a3 100644 --- a/tests/components/axis/test_diagnostics.py +++ b/tests/components/axis/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Axis diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py index b2f2d15d989..a7da7891d50 100644 --- a/tests/components/axis/test_hub.py +++ b/tests/components/axis/test_hub.py @@ -9,7 +9,7 @@ from unittest.mock import ANY, Mock, call, patch import axis as axislib import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import axis from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN diff --git a/tests/components/axis/test_light.py b/tests/components/axis/test_light.py index c33af5ec3a4..ccff3d06e2d 100644 --- a/tests/components/axis/test_light.py +++ b/tests/components/axis/test_light.py @@ -6,7 +6,7 @@ from unittest.mock import patch from axis.models.api import CONTEXT import pytest import respx -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN from homeassistant.const import ( diff --git a/tests/components/axis/test_switch.py b/tests/components/axis/test_switch.py index 964cfdae64c..c0203bc3d4c 100644 --- a/tests/components/axis/test_switch.py +++ b/tests/components/axis/test_switch.py @@ -4,7 +4,7 @@ from unittest.mock import patch from axis.models.api import CONTEXT import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( diff --git a/tests/components/azure_devops/snapshots/test_sensor.ambr b/tests/components/azure_devops/snapshots/test_sensor.ambr index 0b8f35497c6..3fe4d470a63 100644 --- a/tests/components/azure_devops/snapshots/test_sensor.ambr +++ b/tests/components/azure_devops/snapshots/test_sensor.ambr @@ -131,7 +131,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CI latest build id', + 'original_name': 'CI latest build ID', 'platform': 'azure_devops', 'previous_unique_id': None, 'supported_features': 0, @@ -143,7 +143,7 @@ # name: test_sensors[sensor.testproject_ci_latest_build_id-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build id', + 'friendly_name': 'testproject CI latest build ID', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_id', @@ -462,7 +462,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CI latest build url', + 'original_name': 'CI latest build URL', 'platform': 'azure_devops', 'previous_unique_id': None, 'supported_features': 0, @@ -474,7 +474,7 @@ # name: test_sensors[sensor.testproject_ci_latest_build_url-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build url', + 'friendly_name': 'testproject CI latest build URL', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_url', @@ -526,7 +526,7 @@ # name: test_sensors_missing_data[sensor.testproject_ci_latest_build_id-state-missing-data] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build id', + 'friendly_name': 'testproject CI latest build ID', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_id', @@ -619,7 +619,7 @@ # name: test_sensors_missing_data[sensor.testproject_ci_latest_build_url-state-missing-data] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build url', + 'friendly_name': 'testproject CI latest build URL', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_url', diff --git a/tests/components/azure_storage/test_config_flow.py b/tests/components/azure_storage/test_config_flow.py index d5c0726e94a..67dc44f9f2c 100644 --- a/tests/components/azure_storage/test_config_flow.py +++ b/tests/components/azure_storage/test_config_flow.py @@ -172,3 +172,27 @@ async def test_reauth_flow_errors( **USER_INPUT, CONF_STORAGE_ACCOUNT_KEY: "new_key", } + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reconfigure flow works.""" + + 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_CONTAINER_NAME: "new_container"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_CONTAINER_NAME: "new_container", + } diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index e41da5c1bad..3197cbfadeb 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Coroutine, Iterable +from collections.abc import AsyncIterator, Buffer, Callable, Coroutine, Iterable from pathlib import Path -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, Mock, patch from homeassistant.components.backup import ( @@ -16,6 +16,7 @@ from homeassistant.components.backup import ( BackupNotFound, Folder, ) +from homeassistant.components.backup.backup import CoreLocalBackupAgent from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup @@ -67,15 +68,20 @@ async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mock: """Create a mock backup agent.""" + async def delete_backup(backup_id: str, **kwargs: Any) -> None: + """Mock delete.""" + await get_backup(backup_id) + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: """Mock download.""" - if not await get_backup(backup_id): - raise BackupNotFound return aiter_from_iter((backups_data.get(backup_id, b"backup data"),)) - async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup | None: + async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup: """Get a backup.""" - return next((b for b in backups if b.backup_id == backup_id), None) + backup = next((b for b in _backups if b.backup_id == backup_id), None) + if backup is None: + raise BackupNotFound + return backup async def upload_backup( *, @@ -84,22 +90,22 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo **kwargs: Any, ) -> None: """Upload a backup.""" - backups.append(backup) + _backups.append(backup) backup_stream = await open_stream() backup_data = bytearray() async for chunk in backup_stream: backup_data += chunk backups_data[backup.backup_id] = backup_data - backups = backups or [] - backups_data: dict[str, bytes] = {} + _backups = backups or [] + backups_data: dict[str, Buffer] = {} mock_agent = Mock(spec=BackupAgent) mock_agent.domain = TEST_DOMAIN mock_agent.name = name mock_agent.unique_id = name type(mock_agent).agent_id = BackupAgent.agent_id mock_agent.async_delete_backup = AsyncMock( - spec_set=[BackupAgent.async_delete_backup] + side_effect=delete_backup, spec_set=[BackupAgent.async_delete_backup] ) mock_agent.async_download_backup = AsyncMock( side_effect=download_backup, spec_set=[BackupAgent.async_download_backup] @@ -108,7 +114,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo side_effect=get_backup, spec_set=[BackupAgent.async_get_backup] ) mock_agent.async_list_backups = AsyncMock( - return_value=backups, spec_set=[BackupAgent.async_list_backups] + return_value=_backups, spec_set=[BackupAgent.async_list_backups] ) mock_agent.async_upload_backup = AsyncMock( side_effect=upload_backup, @@ -155,11 +161,18 @@ async def setup_backup_integration( if LOCAL_AGENT_ID not in backups or with_hassio: return remote_agents_dict - agent = hass.data[DATA_MANAGER].backup_agents[LOCAL_AGENT_ID] + local_agent = cast( + CoreLocalBackupAgent, hass.data[DATA_MANAGER].backup_agents[LOCAL_AGENT_ID] + ) for backup in backups[LOCAL_AGENT_ID]: - await agent.async_upload_backup(open_stream=None, backup=backup) - agent._loaded_backups = True + await local_agent.async_upload_backup( + open_stream=AsyncMock( + side_effect=RuntimeError("Local agent does not open stream") + ), + backup=backup, + ) + local_agent._loaded_backups = True return remote_agents_dict diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index eb38399eb79..d391df44475 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -61,24 +61,49 @@ def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]: CONFIG_DIR = { - "testing_config": [ + "tests/testing_config": [ Path("test.txt"), Path(".DS_Store"), Path(".storage"), + Path("another_subdir"), Path("backups"), Path("tmp_backups"), + Path("tts"), Path("home-assistant_v2.db"), ], - "backups": [ + "/backups": [ Path("backups/backup.tar"), Path("backups/not_backup"), ], - "tmp_backups": [ + "/another_subdir": [ + Path("another_subdir/.DS_Store"), + Path("another_subdir/backups"), + Path("another_subdir/tts"), + ], + "another_subdir/backups": [ + Path("another_subdir/backups/backup.tar"), + Path("another_subdir/backups/not_backup"), + ], + "another_subdir/tts": [ + Path("another_subdir/tts/voice.mp3"), + ], + "/tmp_backups": [ # noqa: S108 Path("tmp_backups/forgotten_backup.tar"), Path("tmp_backups/not_backup"), ], + "/tts": [ + Path("tts/voice.mp3"), + ], +} +CONFIG_DIR_DIRS = { + Path(".storage"), + Path("another_subdir"), + Path("another_subdir/backups"), + Path("another_subdir/tts"), + Path("backups"), + Path("tmp_backups"), + Path("tts"), } -CONFIG_DIR_DIRS = {Path(".storage"), Path("backups"), Path("tmp_backups")} @pytest.fixture(name="create_backup") @@ -105,7 +130,10 @@ def mock_backup_generation_fixture( """Mock backup generator.""" with ( - patch("pathlib.Path.iterdir", lambda x: CONFIG_DIR.get(x.name, [])), + patch( + "pathlib.Path.iterdir", + lambda x: CONFIG_DIR.get(f"{x.parent.name}/{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), diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar index f3b2845d5eb..29e61d5e4c1 100644 Binary files a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar differ diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted index c97533fc1af..386ea021247 100644 Binary files a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted differ diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted_skip_core2 b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted_skip_core2 new file mode 100644 index 00000000000..ba53b103b03 Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted_skip_core2 differ diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_skip_core2 b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_skip_core2 new file mode 100644 index 00000000000..40216194671 Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_skip_core2 differ diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 28ee9b834c1..7cbbb9ddbce 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -114,9 +114,9 @@ 'with_automatic_settings': None, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -148,9 +148,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -182,9 +182,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -216,9 +216,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -250,9 +250,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', diff --git a/tests/components/backup/snapshots/test_diagnostics.ambr b/tests/components/backup/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..cf412970204 --- /dev/null +++ b/tests/components/backup/snapshots/test_diagnostics.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'backup_agents': list([ + dict({ + 'agent_id': 'backup.local', + 'name': 'local', + }), + ]), + 'backup_config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + '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, + }), + }), + }) +# --- diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/backup/snapshots/test_onboarding.ambr similarity index 98% rename from tests/components/onboarding/snapshots/test_views.ambr rename to tests/components/backup/snapshots/test_onboarding.ambr index 2d084bd9ade..48ddf30d1f2 100644 --- a/tests/components/onboarding/snapshots/test_views.ambr +++ b/tests/components/backup/snapshots/test_onboarding.ambr @@ -62,7 +62,7 @@ 'with_automatic_settings': None, }), ]), - 'last_non_idle_event': None, + 'last_action_event': None, 'state': 'idle', }) # --- diff --git a/tests/components/backup/snapshots/test_sensors.ambr b/tests/components/backup/snapshots/test_sensors.ambr new file mode 100644 index 00000000000..b68d706dfb3 --- /dev/null +++ b/tests/components/backup/snapshots/test_sensors.ambr @@ -0,0 +1,208 @@ +# serializer version: 1 +# name: test_sensors[sensor.backup_backup_manager_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'idle', + 'create_backup', + 'blocked', + 'receive_backup', + 'restore_backup', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.backup_backup_manager_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': 'Backup Manager state', + 'platform': 'backup', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'backup_manager_state', + 'unique_id': 'backup_manager_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.backup_backup_manager_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Backup Backup Manager state', + 'options': list([ + 'idle', + 'create_backup', + 'blocked', + 'receive_backup', + 'restore_backup', + ]), + }), + 'context': , + 'entity_id': 'sensor.backup_backup_manager_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensors[sensor.backup_last_attempted_automatic_backup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.backup_last_attempted_automatic_backup', + '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 attempted automatic backup', + 'platform': 'backup', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_attempted_automatic_backup', + 'unique_id': 'last_attempted_automatic_backup', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.backup_last_attempted_automatic_backup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Backup Last attempted automatic backup', + }), + 'context': , + 'entity_id': 'sensor.backup_last_attempted_automatic_backup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.backup_last_successful_automatic_backup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.backup_last_successful_automatic_backup', + '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 successful automatic backup', + 'platform': 'backup', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_successful_automatic_backup', + 'unique_id': 'last_successful_automatic_backup', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.backup_last_successful_automatic_backup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Backup Last successful automatic backup', + }), + 'context': , + 'entity_id': 'sensor.backup_last_successful_automatic_backup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.backup_next_scheduled_automatic_backup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.backup_next_scheduled_automatic_backup', + '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 scheduled automatic backup', + 'platform': 'backup', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_scheduled_automatic_backup', + 'unique_id': 'next_scheduled_automatic_backup', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.backup_next_scheduled_automatic_backup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Backup Next scheduled automatic backup', + }), + 'context': , + 'entity_id': 'sensor.backup_next_scheduled_automatic_backup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 41778322825..6f1bce8d5e4 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -40,7 +40,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -86,7 +86,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -131,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -177,7 +177,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -196,6 +196,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -225,7 +226,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -244,6 +245,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -274,7 +276,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -293,6 +295,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -322,7 +325,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -341,6 +344,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -371,7 +375,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -390,6 +394,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': True, @@ -419,7 +424,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -438,6 +443,7 @@ 'agents': dict({ 'test.remote': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': True, @@ -468,7 +474,112 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data5] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + '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': 6, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data5].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + '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': 6, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 17e3ca8b176..7528785ab0d 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -229,6 +229,17 @@ 'type': 'result', }) # --- +# name: test_can_decrypt_on_download_get_backup_returns_none + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent test.remote', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_can_decrypt_on_download_with_agent_error[BackupAgentError] dict({ 'error': dict({ @@ -289,6 +300,61 @@ 'type': 'result', }) # --- +# name: test_config_load_config_info[with_hassio-storage_data10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + }), + 'test-agent2': dict({ + 'protected': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + }), + }), + 'automatic_backups_configured': False, + '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_load_config_info[with_hassio-storage_data1] dict({ 'id': 1, @@ -545,9 +611,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -703,6 +771,61 @@ 'type': 'result', }) # --- +# name: test_config_load_config_info[without_hassio-storage_data10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + }), + 'test-agent2': dict({ + 'protected': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + }), + }), + 'automatic_backups_configured': False, + '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_load_config_info[without_hassio-storage_data1] dict({ 'id': 1, @@ -955,9 +1078,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1187,7 +1312,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1304,7 +1429,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1421,7 +1546,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1471,9 +1596,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1516,9 +1643,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1548,7 +1677,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1598,9 +1727,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': True, + 'retention': None, }), 'test-agent2': dict({ 'protected': False, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1642,9 +1773,11 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': False, + 'retention': None, }), 'test-agent2': dict({ 'protected': True, + 'retention': None, }), }), 'automatic_backups_configured': False, @@ -1679,6 +1812,104 @@ }) # --- # name: test_config_update[commands13].3 + dict({ + 'id': 7, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + }), + 'test-agent2': dict({ + 'protected': True, + 'retention': None, + }), + }), + 'automatic_backups_configured': False, + '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[commands13].4 + dict({ + 'id': 9, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + 'retention': None, + }), + 'test-agent2': dict({ + 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + }), + }), + 'automatic_backups_configured': False, + '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[commands13].5 dict({ 'data': dict({ 'backups': list([ @@ -1687,9 +1918,14 @@ 'agents': dict({ 'test-agent1': dict({ 'protected': False, + 'retention': None, }), 'test-agent2': dict({ 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), }), }), 'automatic_backups_configured': False, @@ -1719,7 +1955,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1834,7 +2070,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -1949,7 +2185,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2066,7 +2302,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2185,7 +2421,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2302,7 +2538,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2423,7 +2659,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2548,7 +2784,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2665,7 +2901,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2782,7 +3018,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -2899,7 +3135,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -3016,7 +3252,7 @@ }), }), 'key': 'backup', - 'minor_version': 5, + 'minor_version': 6, 'version': 1, }) # --- @@ -3248,6 +3484,158 @@ 'type': 'result', }) # --- +# name: test_config_update_errors[command12] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + '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[command12].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + '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[command13] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + '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[command13].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + '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, @@ -3940,9 +4328,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -3970,9 +4358,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4021,9 +4409,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4051,9 +4439,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4102,9 +4490,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4164,9 +4552,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4210,9 +4598,9 @@ 'with_automatic_settings': None, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4267,9 +4655,9 @@ 'with_automatic_settings': None, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4322,9 +4710,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4384,9 +4772,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4447,9 +4835,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4511,9 +4899,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4573,9 +4961,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4635,9 +5023,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4698,9 +5086,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4762,9 +5150,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4930,6 +5318,18 @@ 'type': 'result', }) # --- +# name: test_details_get_backup_returns_none + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_details_with_errors[BackupAgentUnreachableError] dict({ 'id': 1, @@ -5327,9 +5727,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5378,9 +5778,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5433,9 +5833,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5511,9 +5911,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5563,9 +5963,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5615,9 +6015,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5667,9 +6067,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5728,6 +6128,17 @@ # name: test_restore_remote_agent[remote_agents1-backups1].1 1 # --- +# name: test_restore_remote_agent_get_backup_returns_none + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent test.remote', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_restore_wrong_password dict({ 'error': dict({ diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index c9d797f4e30..5a33bf39390 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -10,7 +10,7 @@ from tarfile import TarError from unittest.mock import MagicMock, mock_open, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant diff --git a/tests/components/backup/test_diagnostics.py b/tests/components/backup/test_diagnostics.py new file mode 100644 index 00000000000..8f6c501ca86 --- /dev/null +++ b/tests/components/backup/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests the diagnostics for Home Assistant Backup integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.backup.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .common import setup_backup_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_backup_integration(hass, with_hassio=False) + await hass.async_block_till_done(wait_background_tasks=True) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + diag_data = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert diag_data == snapshot diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index a03217beac2..b3845b1209a 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -177,7 +177,7 @@ async def _test_downloading_encrypted_backup( 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, + outer_tar.extractfile("homeassistant.tar.gz") as inner_tar_file, pytest.raises(tarfile.ReadError, match="file could not be opened"), ): # pylint: disable-next=consider-using-with @@ -209,7 +209,7 @@ async def _test_downloading_encrypted_backup( 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, + outer_tar.extractfile("homeassistant.tar.gz") as inner_tar_file, tarfile.open(fileobj=inner_tar_file, mode="r") as inner_tar, ): assert inner_tar.getnames() == [ @@ -234,6 +234,26 @@ async def test_downloading_backup_not_found( assert resp.status == 404 +async def test_downloading_backup_not_found_get_backup_returns_none( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test downloading a backup file that does not exist.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.test"]) + mock_agents["test.test"].async_get_backup.return_value = None + mock_agents["test.test"].async_get_backup.side_effect = None + + client = await hass_client() + + resp = await client.get("/api/backup/download/abc123?agent_id=test.test") + assert resp.status == 404 + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + async def test_downloading_as_non_admin( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index 8a0cc2b97c0..10bd2d8b97a 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -6,11 +6,13 @@ from unittest.mock import patch import pytest from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN +from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotFound from .common import setup_backup_integration +from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -141,3 +143,17 @@ async def test_create_automatic_service( ) generate_backup.assert_called_once_with(**expected_kwargs) + + +async def test_setup_entry( + hass: HomeAssistant, +) -> None: + """Test setup backup config entry.""" + await setup_backup_integration(hass, with_hassio=False) + entry = MockConfigEntry(domain=DOMAIN, source=SOURCE_SYSTEM) + entry.add_to_hass(hass) + + with patch("homeassistant.components.backup.PLATFORMS", return_value=[]): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 6e626e63748..04072dae864 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -8,6 +8,7 @@ from dataclasses import replace from io import StringIO import json from pathlib import Path +import re import tarfile from typing import Any from unittest.mock import ( @@ -35,6 +36,7 @@ from homeassistant.components.backup.agent import BackupAgentError from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.manager import ( BackupManagerError, + BackupManagerExceptionGroup, BackupManagerState, CreateBackupStage, CreateBackupState, @@ -45,7 +47,8 @@ from homeassistant.components.backup.manager import ( WrittenBackup, ) from homeassistant.components.backup.util import password_to_key -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -65,10 +68,17 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator _EXPECTED_FILES = [ "test.txt", ".storage", + "another_subdir", + "another_subdir/backups", + "another_subdir/backups/backup.tar", + "another_subdir/backups/not_backup", + "another_subdir/tts", + "another_subdir/tts/voice.mp3", "backups", "backups/not_backup", "tmp_backups", "tmp_backups/not_backup", + "tts", ] _EXPECTED_FILES_WITH_DATABASE = { True: [*_EXPECTED_FILES, "home-assistant_v2.db"], @@ -535,7 +545,7 @@ async def test_initiate_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -768,7 +778,7 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -860,7 +870,7 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": { + "last_action_event": { "manager_state": "create_backup", "reason": "upload_failed", "stage": None, @@ -1150,7 +1160,7 @@ async def test_initiate_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1247,7 +1257,7 @@ async def test_initiate_backup_with_task_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1343,7 +1353,7 @@ async def test_initiate_backup_file_error_upload_to_agents( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1467,7 +1477,7 @@ async def test_initiate_backup_file_error_create_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1646,34 +1656,60 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: assert str(err.value) == "Error during pre-backup: Test exception" +@pytest.mark.parametrize( + ("unhandled_error", "expected_exception", "expected_msg"), + [ + (None, BackupManagerError, "Error during post-backup: Test exception"), + ( + HomeAssistantError("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ( + Exception("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ], +) @pytest.mark.usefixtures("mock_backup_generation") -async def test_exception_platform_post(hass: HomeAssistant) -> None: +async def test_exception_platform_post( + hass: HomeAssistant, + unhandled_error: Exception | None, + expected_exception: type[Exception], + expected_msg: str, +) -> None: """Test exception in post step.""" - async def _mock_step(hass: HomeAssistant) -> None: - raise HomeAssistantError("Test exception") - remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", platform=Mock( - async_pre_backup=AsyncMock(), - async_post_backup=_mock_step, + # We let the pre_backup fail to test that unhandled errors are not discarded + # when post backup fails + async_pre_backup=AsyncMock(side_effect=unhandled_error), + async_post_backup=AsyncMock( + side_effect=HomeAssistantError("Test exception") + ), async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) await setup_backup_integration(hass) - with pytest.raises(BackupManagerError) as err: + with pytest.raises(expected_exception, match=re.escape(expected_msg)): await hass.services.async_call( DOMAIN, "create", blocking=True, ) - assert str(err.value) == "Error during post-backup: Test exception" - @pytest.mark.parametrize( ( @@ -1938,7 +1974,7 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2021,7 +2057,7 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": { + "last_action_event": { "manager_state": "receive_backup", "reason": None, "stage": None, @@ -2074,7 +2110,7 @@ async def test_receive_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2186,7 +2222,7 @@ async def test_receive_backup_file_write_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2282,7 +2318,7 @@ async def test_receive_backup_read_tar_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2447,7 +2483,7 @@ async def test_receive_backup_file_read_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3258,7 +3294,7 @@ async def test_initiate_backup_per_agent_encryption( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3361,7 +3397,7 @@ async def test_initiate_backup_per_agent_encryption( @pytest.mark.parametrize( - ("restore_result", "last_non_idle_event"), + ("restore_result", "last_action_event"), [ ( {"error": None, "error_type": None, "success": True}, @@ -3387,7 +3423,7 @@ async def test_restore_progress_after_restart( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, restore_result: dict[str, Any], - last_non_idle_event: dict[str, Any], + last_action_event: dict[str, Any], ) -> None: """Test restore backup progress after restart.""" @@ -3405,7 +3441,7 @@ async def test_restore_progress_after_restart( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": last_non_idle_event, + "last_action_event": last_action_event, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3431,7 +3467,7 @@ async def test_restore_progress_after_restart_fail_to_remove( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3441,3 +3477,66 @@ async def test_restore_progress_after_restart_fail_to_remove( "Unexpected error deleting backup restore result file: Boom!" in caplog.text ) + + +async def test_manager_blocked_until_home_assistant_started( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test backup manager's state is blocked until Home Assistant has started.""" + + hass.set_state(CoreState.not_running) + + await setup_backup_integration(hass) + manager = hass.data[DATA_MANAGER] + + assert manager.state == BackupManagerState.BLOCKED + assert manager.last_action_event is None + + # Fired when Home Assistant changes to starting state + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert manager.state == BackupManagerState.BLOCKED + assert manager.last_action_event is None + + # Fired when Home Assistant changes to running state + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert manager.state == BackupManagerState.IDLE + assert manager.last_action_event is None + + +async def test_manager_not_blocked_after_restore( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test restore backup progress after restart.""" + restore_result = {"error": None, "error_type": None, "success": True} + + hass.set_state(CoreState.not_running) + with patch( + "pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode() + ): + await setup_backup_integration(hass) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_action_event": { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } diff --git a/tests/components/backup/test_onboarding.py b/tests/components/backup/test_onboarding.py new file mode 100644 index 00000000000..48e7252289a --- /dev/null +++ b/tests/components/backup/test_onboarding.py @@ -0,0 +1,414 @@ +"""Test the onboarding views.""" + +from io import StringIO +from typing import Any +from unittest.mock import ANY, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import backup, onboarding +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component + +from tests.common import register_auth_provider +from tests.typing import ClientSessionGenerator + + +def mock_onboarding_storage(hass_storage, data): + """Mock the onboarding storage.""" + hass_storage[onboarding.STORAGE_KEY] = { + "version": onboarding.STORAGE_VERSION, + "data": data, + } + + +@pytest.fixture(autouse=True) +def auth_active(hass: HomeAssistant) -> None: + """Ensure auth is always active.""" + hass.loop.run_until_complete( + register_auth_provider(hass, {"type": "homeassistant"}) + ) + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_view_after_done( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test raising after onboarding.""" + mock_onboarding_storage(hass_storage, {"done": [onboarding.const.STEP_USER]}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 401 + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_backup_view_without_backup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test interacting with backup wievs when backup integration is missing.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 404 + + +async def test_onboarding_backup_info( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test backup info.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + backups = { + "abc123": backup.ManagerBackup( + addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={ + "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="abc123", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + failed_agent_ids=[], + with_automatic_settings=True, + ), + "def456": backup.ManagerBackup( + addons=[], + agents={ + "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="def456", + date="1980-01-01T00:00:00.000Z", + database_included=False, + extra_metadata={ + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test 2", + failed_agent_ids=[], + with_automatic_settings=None, + ), + } + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ): + resp = await client.get("/api/onboarding/backup/info") + + assert resp.status == 200 + assert await resp.json() == snapshot + + +@pytest.mark.parametrize( + ("params", "expected_kwargs"), + [ + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + { + "agent_id": "backup.local", + "password": None, + "restore_addons": None, + "restore_database": True, + "restore_folders": None, + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": ["media"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": [backup.Folder.MEDIA], + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": ["media", "share"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], + "restore_homeassistant": True, + }, + ), + ], +) +async def test_onboarding_backup_restore( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + expected_kwargs: dict[str, Any], +) -> None: + """Test restore backup.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + assert resp.status == 200 + mock_restore.assert_called_once_with("abc123", **expected_kwargs) + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_json", "restore_calls"), + [ + # Missing agent_id + ( + {"backup_id": "abc123"}, + None, + 400, + { + "message": "Message format incorrect: required key not provided @ data['agent_id']" + }, + 0, + ), + # Missing backup_id + ( + {"agent_id": "backup.local"}, + None, + 400, + { + "message": "Message format incorrect: required key not provided @ data['backup_id']" + }, + 0, + ), + # Invalid restore_database + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_database": "yes_please", + }, + None, + 400, + { + "message": "Message format incorrect: expected bool for dictionary value @ data['restore_database']" + }, + 0, + ), + # Invalid folder + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_folders": ["invalid"], + }, + None, + 400, + { + "message": "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]" + }, + 0, + ), + # Wrong password + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + backup.IncorrectPasswordError, + 400, + {"code": "incorrect_password"}, + 1, + ), + # Home Assistant error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + HomeAssistantError("Boom!"), + 400, + {"code": "restore_failed", "message": "Boom!"}, + 1, + ), + ], +) +async def test_onboarding_backup_restore_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_json: str, + restore_calls: int, +) -> None: + """Test restore backup fails.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert await resp.json() == expected_json + assert len(mock_restore.mock_calls) == restore_calls + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), + [ + # Unexpected error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + Exception("Boom!"), + 500, + "500 Internal Server Error", + 1, + ), + ], +) +async def test_onboarding_backup_restore_unexpected_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_message: str, + restore_calls: int, +) -> None: + """Test restore backup fails.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert (await resp.content.read()).decode().startswith(expected_message) + assert len(mock_restore.mock_calls) == restore_calls + + +async def test_onboarding_backup_upload( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, +) -> None: + """Test upload backup.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value="abc123", + ) as mock_receive: + resp = await client.post( + "/api/onboarding/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + assert resp.status == 201 + assert await resp.json() == {"backup_id": "abc123"} + mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) diff --git a/tests/components/backup/test_sensors.py b/tests/components/backup/test_sensors.py new file mode 100644 index 00000000000..7320c037b21 --- /dev/null +++ b/tests/components/backup/test_sensors.py @@ -0,0 +1,123 @@ +"""Tests for the sensors of the Backup integration.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.backup import store +from homeassistant.components.backup.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import setup_backup_integration + +from tests.common import async_fire_time_changed, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_sensors( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of backup sensors.""" + with patch("homeassistant.components.backup.PLATFORMS", [Platform.SENSOR]): + await setup_backup_integration(hass, with_hassio=False) + await hass.async_block_till_done(wait_background_tasks=True) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + # start backup and check sensor states again + client = await hass_ws_client(hass) + await hass.async_block_till_done() + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["backup.local"]} + ) + + assert await client.receive_json() + state = hass.states.get("sensor.backup_backup_manager_state") + assert state.state == "create_backup" + + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("sensor.backup_backup_manager_state") + assert state.state == "idle" + + +async def test_sensor_updates( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], + create_backup: AsyncMock, +) -> None: + """Test update of backup sensors.""" + # Ensure created backup is already protected, + # to avoid manager creating a new EncryptedBackupStreamer + # instead of using the already mocked stream writer. + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-12T12:00:00+01:00") + storage_data = { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["test.remote"], + "include_addons": [], + "include_all_addons": False, + "include_database": True, + "include_folders": [], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": "2024-11-11T04:45:00+01:00", + "last_completed_automatic_backup": "2024-11-11T04:45:00+01:00", + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": "06:00", + }, + }, + } + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + } + + with patch("homeassistant.components.backup.PLATFORMS", [Platform.SENSOR]): + await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] + ) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.backup_last_attempted_automatic_backup") + assert state.state == "2024-11-11T03:45:00+00:00" + state = hass.states.get("sensor.backup_last_successful_automatic_backup") + assert state.state == "2024-11-11T03:45:00+00:00" + state = hass.states.get("sensor.backup_next_scheduled_automatic_backup") + assert state.state == "2024-11-13T05:00:00+00:00" + + freezer.move_to("2024-11-13T12:00:00+01:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.backup_last_attempted_automatic_backup") + assert state.state == "2024-11-13T11:00:00+00:00" + state = hass.states.get("sensor.backup_last_successful_automatic_backup") + assert state.state == "2024-11-13T11:00:00+00:00" + state = hass.states.get("sensor.backup_next_scheduled_automatic_backup") + assert state.state == "2024-11-14T05:00:00+00:00" diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index 0d29bb2006a..97f6a4102f7 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup.const import DOMAIN from homeassistant.core import HomeAssistant @@ -98,7 +98,7 @@ def mock_delay_save() -> Generator[None]: } ], "config": { - "agents": {"test.remote": {"protected": True}}, + "agents": {"test.remote": {"protected": True, "retention": None}}, "automatic_backups_configured": False, "create_backup": { "agent_ids": [], @@ -200,6 +200,49 @@ def mock_delay_save() -> Generator[None]: "minor_version": 4, "version": 1, }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": { + "test.remote": { + "protected": True, + "retention": {"copies": None, "days": None}, + } + }, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": "hunter2", + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 6, + "version": 1, + }, ], ) async def test_store_migration( diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 97e94eafb73..af37a3b88a6 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -112,6 +112,11 @@ from tests.common import get_fixture_path ), ), ], + ids=[ + "no addons and no metadata", + "with addons and metadata", + "only metadata", + ], ) def test_read_backup(backup_json_content: bytes, expected_backup: AgentBackup) -> None: """Test reading a backup.""" @@ -167,14 +172,37 @@ def test_validate_password_no_homeassistant() -> None: assert validate_password(mock_path, "hunter2") is False -async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("addons", "padding_size", "decrypted_backup"), + [ + ( + [ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], + 40960, # 4 x 10240 byte of padding + "test_backups/c0cb53bd.tar.decrypted", + ), + ( + [ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + ], + 30720, # 3 x 10240 byte of padding + "test_backups/c0cb53bd.tar.decrypted_skip_core2", + ), + ], +) +async def test_decrypted_backup_streamer( + hass: HomeAssistant, + addons: list[AddonInfo], + padding_size: int, + decrypted_backup: str, +) -> None: """Test the decrypted backup streamer.""" - decrypted_backup_path = get_fixture_path( - "test_backups/c0cb53bd.tar.decrypted", DOMAIN - ) + decrypted_backup_path = get_fixture_path(decrypted_backup, DOMAIN) encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=addons, backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -186,7 +214,7 @@ async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None: protected=True, size=encrypted_backup_path.stat().st_size, ) - expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + expected_padding = b"\0" * padding_size async def send_backup() -> AsyncIterator[bytes]: f = encrypted_backup_path.open("rb") @@ -218,7 +246,10 @@ async def test_decrypted_backup_streamer_interrupt_stuck_reader( """Test the decrypted backup streamer.""" encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -253,7 +284,10 @@ async def test_decrypted_backup_streamer_interrupt_stuck_writer( """Test the decrypted backup streamer.""" encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -283,7 +317,10 @@ async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> """Test the decrypted backup streamer with wrong password.""" encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -313,14 +350,39 @@ async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> assert isinstance(decryptor._workers[0].error, securetar.SecureTarReadError) -async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("addons", "padding_size", "encrypted_backup"), + [ + ( + [ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], + 40960, # 4 x 10240 byte of padding + "test_backups/c0cb53bd.tar", + ), + ( + [ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + ], + 30720, # 3 x 10240 byte of padding + "test_backups/c0cb53bd.tar.encrypted_skip_core2", + ), + ], +) +async def test_encrypted_backup_streamer( + hass: HomeAssistant, + addons: list[AddonInfo], + padding_size: int, + encrypted_backup: str, +) -> None: """Test the encrypted backup streamer.""" decrypted_backup_path = get_fixture_path( "test_backups/c0cb53bd.tar.decrypted", DOMAIN ) - encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + encrypted_backup_path = get_fixture_path(encrypted_backup, DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=addons, backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -332,7 +394,7 @@ async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: protected=False, size=decrypted_backup_path.stat().st_size, ) - expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + expected_padding = b"\0" * padding_size async def send_backup() -> AsyncIterator[bytes]: f = decrypted_backup_path.open("rb") @@ -353,15 +415,16 @@ async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: bytes.fromhex("00000000000000000000000000000000"), ) encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") - assert encryptor.backup() == dataclasses.replace( - backup, protected=True, size=backup.size + len(expected_padding) - ) - encrypted_stream = await encryptor.open_stream() - encrypted_output = b"" - async for chunk in encrypted_stream: - encrypted_output += chunk - await encryptor.wait() + assert encryptor.backup() == dataclasses.replace( + backup, protected=True, size=backup.size + len(expected_padding) + ) + + encrypted_stream = await encryptor.open_stream() + encrypted_output = b"" + async for chunk in encrypted_stream: + encrypted_output += chunk + await encryptor.wait() # Expect the output to match the stored encrypted backup file, with additional # padding. @@ -377,7 +440,10 @@ async def test_encrypted_backup_streamer_interrupt_stuck_reader( "test_backups/c0cb53bd.tar.decrypted", DOMAIN ) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -414,7 +480,10 @@ async def test_encrypted_backup_streamer_interrupt_stuck_writer( "test_backups/c0cb53bd.tar.decrypted", DOMAIN ) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -447,7 +516,10 @@ async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> No ) encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, @@ -490,7 +562,7 @@ async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> No await encryptor1.wait() await encryptor2.wait() - # Output from the two streames should differ but have the same length. + # Output from the two streams should differ but have the same length. assert encrypted_output1 != encrypted_output3 assert len(encrypted_output1) == len(encrypted_output3) @@ -508,7 +580,10 @@ async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: "test_backups/c0cb53bd.tar.decrypted", DOMAIN ) backup = AgentBackup( - addons=["addon_1", "addon_2"], + addons=[ + AddonInfo(name="Core 1", slug="core1", version="1.0.0"), + AddonInfo(name="Core 2", slug="core2", version="1.0.0"), + ], backup_id="1234", date="2024-12-02T07:23:58.261875-05:00", database_included=False, diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 404ba52de4b..2115533452e 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1,14 +1,16 @@ """Tests for the Backup integration.""" from collections.abc import Generator +from dataclasses import replace 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 syrupy.assertion import SnapshotAssertion from homeassistant.components.backup import ( + AddonInfo, AgentBackup, BackupAgentError, BackupNotFound, @@ -81,6 +83,21 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { } DAILY = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +TEST_MANAGER_BACKUP = ManagerBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={"test.test-agent": AgentBackupStatus(protected=True, size=0)}, + backup_id="backup-1", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + failed_agent_ids=[], + with_automatic_settings=True, +) + @pytest.fixture def sync_access_token_proxy( @@ -234,6 +251,31 @@ async def test_details_with_errors( assert await client.receive_json() == snapshot +async def test_details_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test getting backup info when the agent returns None from get_backup.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + 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 + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + @pytest.mark.parametrize( ("remote_agents", "backups"), [ @@ -724,6 +766,36 @@ async def test_restore_remote_agent( assert len(restart_calls) == snapshot +async def test_restore_remote_agent_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test calling the restore command when the agent returns None from get_backup.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + 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) == 0 + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + async def test_restore_wrong_password( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1105,8 +1177,8 @@ async def test_agents_info( "backups": [], "config": { "agents": { - "test-agent1": {"protected": True}, - "test-agent2": {"protected": False}, + "test-agent1": {"protected": True, "retention": None}, + "test-agent2": {"protected": False, "retention": None}, }, "automatic_backups_configured": False, "create_backup": { @@ -1198,6 +1270,47 @@ async def test_agents_info( "minor_version": store.STORAGE_VERSION_MINOR, }, }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": { + "test-agent1": { + "protected": True, + "retention": {"copies": 3, "days": None}, + }, + "test-agent2": { + "protected": False, + "retention": {"copies": None, "days": 7}, + }, + }, + "automatic_backups_configured": False, + "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, + }, + }, ], ) @pytest.mark.parametrize( @@ -1216,7 +1329,7 @@ async def test_config_load_config_info( snapshot: SnapshotAssertion, hass_storage: dict[str, Any], with_hassio: bool, - storage_data: dict[str, Any] | None, + storage_data: dict[str, Any], ) -> None: """Test loading stored backup config and reading it via config/info.""" client = await hass_ws_client(hass) @@ -1357,6 +1470,20 @@ async def test_config_load_config_info( "test-agent2": {"protected": True}, }, }, + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"retention": {"copies": 3}}, + "test-agent2": {"retention": None}, + }, + }, + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"retention": None}, + "test-agent2": {"retention": {"days": 7}}, + }, + }, ], [ { @@ -1378,7 +1505,7 @@ async def test_config_update( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, - commands: dict[str, Any], + commands: list[dict[str, Any]], hass_storage: dict[str, Any], ) -> None: """Test updating the backup config.""" @@ -1467,6 +1594,14 @@ async def test_config_update( "type": "backup/config/update", "retention": {"days": 0}, }, + { + "type": "backup/config/update", + "agents": {"test-agent1": {"retention": {"copies": 0}}}, + }, + { + "type": "backup/config/update", + "agents": {"test-agent1": {"retention": {"days": 0}}}, + }, ], ) async def test_config_update_errors( @@ -2434,6 +2569,253 @@ async def test_config_schedule_logic( 1, {}, ), + ( + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": None, + }, + "test.test-agent2": { + "protected": True, + "retention": { + "copies": 1, + "days": None, + }, + }, + }, + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-5": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-5", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, + ), + ( + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": None, + }, + "test.test-agent2": { + "protected": True, + "retention": { + "copies": 1, + "days": None, + }, + }, + }, + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-5": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-5", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + { + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, + ), + ( + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": { + "copies": None, + "days": None, + }, + }, + "test.test-agent2": { + "protected": True, + "retention": None, + }, + }, + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 2, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-5": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-5", + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + { + "test.test-agent2": [call("backup-1")], + }, + ), ], ) @patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) @@ -3166,6 +3548,223 @@ async def test_config_retention_copies_logic_manual_backup( 1, {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), + ( + None, + [ + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": {"days": 3}, + }, + "test.test-agent2": { + "protected": True, + "retention": None, + }, + }, + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, + ), + ( + None, + [ + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": {"days": 3}, + }, + "test.test-agent2": { + "protected": True, + "retention": None, + }, + }, + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + { + "test.test-agent": [call("backup-1")], + }, + ), + ( + None, + [ + { + "type": "backup/config/update", + "agents": { + "test.test-agent": { + "protected": True, + "retention": None, + }, + "test.test-agent2": { + "protected": True, + "retention": {"copies": None, "days": None}, + }, + }, + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 2}, + "schedule": {"recurrence": "never"}, + } + ], + { + "backup-1": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-1", + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-2": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-2", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-3": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-3", + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + ), + "backup-4": replace( + TEST_MANAGER_BACKUP, + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + backup_id="backup-4", + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=False, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-11T12:00:00+01:00", + "2024-11-12T12:00:00+01:00", + 1, + { + "test.test-agent": [call("backup-1"), call("backup-2")], + }, + ), ], ) async def test_config_retention_days_logic( @@ -3223,7 +3822,7 @@ async def test_config_retention_days_logic( freezer.move_to(start_time) mock_agents = await setup_backup_integration( - hass, remote_agents=["test.test-agent"] + hass, remote_agents=["test.test-agent", "test.test-agent2"] ) await hass.async_block_till_done() @@ -3543,3 +4142,32 @@ async def test_can_decrypt_on_download_with_agent_error( } ) assert await client.receive_json() == snapshot + + +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test can decrypt on download when the agent returns None from get_backup.""" + + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + "password": "hunter2", + } + ) + assert await client.receive_json() == snapshot + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 90f8fdc3d6e..7678a97305e 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -66,6 +66,9 @@ def client_fixture() -> Generator[MagicMock]: client.heat_state = 2 client.lights = [] client.pumps = [] + client.temperature_range.client = client client.temperature_range.state = LowHighRange.LOW + client.fault = None + yield client diff --git a/tests/components/balboa/snapshots/test_event.ambr b/tests/components/balboa/snapshots/test_event.ambr new file mode 100644 index 00000000000..fc8f591a9fc --- /dev/null +++ b/tests/components/balboa/snapshots/test_event.ambr @@ -0,0 +1,90 @@ +# serializer version: 1 +# name: test_events[event.fakespa_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'clock_failed', + 'flow_failed', + 'gfci_test_failed', + 'heater_dry', + 'heater_may_be_dry', + 'heater_too_hot', + 'hot_fault', + 'low_flow', + 'memory_failure', + 'priming_mode', + 'pump_stuck', + 'sensor_a_fault', + 'sensor_b_fault', + 'sensor_out_of_sync', + 'service_sensor_sync', + 'settings_reset', + 'standby_mode', + 'water_too_hot', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.fakespa_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fault', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fault', + 'unique_id': 'FakeSpa-fault-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[event.fakespa_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'clock_failed', + 'flow_failed', + 'gfci_test_failed', + 'heater_dry', + 'heater_may_be_dry', + 'heater_too_hot', + 'hot_fault', + 'low_flow', + 'memory_failure', + 'priming_mode', + 'pump_stuck', + 'sensor_a_fault', + 'sensor_b_fault', + 'sensor_out_of_sync', + 'service_sensor_sync', + 'settings_reset', + 'standby_mode', + 'water_too_hot', + ]), + 'friendly_name': 'FakeSpa Fault', + }), + 'context': , + 'entity_id': 'event.fakespa_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/balboa/test_binary_sensor.py b/tests/components/balboa/test_binary_sensor.py index 5990c73bb68..8f3c7a4b21c 100644 --- a/tests/components/balboa/test_binary_sensor.py +++ b/tests/components/balboa/test_binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 850184a7d71..5cd5bc9091a 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch from pybalboa import SpaControl from pybalboa.enums import HeatMode, OffLowMediumHighState import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_FAN_MODE, @@ -26,10 +26,11 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import client_update, init_integration @@ -97,11 +98,10 @@ async def test_spa_temperature_unit( hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry ) -> None: """Test temperature unit conversions.""" - with patch.object( - hass.config.units, "temperature_unit", UnitOfTemperature.FAHRENHEIT - ): - state = await _patch_spa_settemp(hass, client, 0, 15.4) - assert state.attributes.get(ATTR_TEMPERATURE) == 15.0 + hass.config.units = US_CUSTOMARY_SYSTEM + + state = await _patch_spa_settemp(hass, client, 0, 15.4) + assert state.attributes.get(ATTR_TEMPERATURE) == 15.0 async def test_spa_hvac_modes( diff --git a/tests/components/balboa/test_event.py b/tests/components/balboa/test_event.py new file mode 100644 index 00000000000..b5a10192c5c --- /dev/null +++ b/tests/components/balboa/test_event.py @@ -0,0 +1,82 @@ +"""Tests of the events of the balboa integration.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + +ENTITY_EVENT = "event.fakespa_fault" +FAULT_DATE = "fault_date" + + +async def test_events( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa events.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.EVENT]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_event(hass: HomeAssistant, client: MagicMock) -> None: + """Test spa fault event.""" + await init_integration(hass) + + # check the state is unknown + state = hass.states.get(ENTITY_EVENT) + assert state.state == STATE_UNKNOWN + + # set a fault + client.fault = MagicMock( + fault_datetime=datetime(2025, 2, 15, 13, 0), message_code=16 + ) + client.emit("") + await hass.async_block_till_done() + + # check new state is what we expect + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 + + # set fault to None + client.fault = None + client.emit("") + await hass.async_block_till_done() + + # validate state remains unchanged + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 + + # set fault to an unknown one + client.fault = MagicMock( + fault_datetime=datetime(2025, 2, 15, 14, 0), message_code=-1 + ) + # validate a ValueError is raises + with pytest.raises(ValueError): + client.emit("") + await hass.async_block_till_done() + + # validate state remains unchanged + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 diff --git a/tests/components/balboa/test_fan.py b/tests/components/balboa/test_fan.py index 3eacb0d08c0..f9ab201b925 100644 --- a/tests/components/balboa/test_fan.py +++ b/tests/components/balboa/test_fan.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch from pybalboa import SpaControl from pybalboa.enums import OffLowHighState, UnknownState import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ATTR_PERCENTAGE from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform diff --git a/tests/components/balboa/test_light.py b/tests/components/balboa/test_light.py index 01469416da5..5eb802f6fc9 100644 --- a/tests/components/balboa/test_light.py +++ b/tests/components/balboa/test_light.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch from pybalboa import SpaControl from pybalboa.enums import OffOnState, UnknownState import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/balboa/test_select.py b/tests/components/balboa/test_select.py index da57ee8f22e..e44962b43b9 100644 --- a/tests/components/balboa/test_select.py +++ b/tests/components/balboa/test_select.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, call, patch from pybalboa import SpaControl from pybalboa.enums import LowHighRange import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, diff --git a/tests/components/balboa/test_switch.py b/tests/components/balboa/test_switch.py index 4b6bae172f4..ed031bebe05 100644 --- a/tests/components/balboa/test_switch.py +++ b/tests/components/balboa/test_switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/balboa/test_time.py b/tests/components/balboa/test_time.py index 21778d08e2d..093e741bbf4 100644 --- a/tests/components/balboa/test_time.py +++ b/tests/components/balboa/test_time.py @@ -6,7 +6,7 @@ from datetime import time from unittest.mock import MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.time import ( ATTR_TIME, diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py index a9415a222a8..fdc22390e64 100644 --- a/tests/components/bang_olufsen/test_diagnostics.py +++ b/tests/components/bang_olufsen/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 70b826f0b92..a389f9fa818 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -1323,6 +1323,7 @@ async def test_async_play_media_url_m3u( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, }, @@ -1337,6 +1338,7 @@ async def test_async_play_media_url_m3u( "media_content_id": ("media-source://media_source/local/test.mp4"), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, }, diff --git a/tests/components/blink/test_diagnostics.py b/tests/components/blink/test_diagnostics.py index d527633d4c9..334ecfaa50c 100644 --- a/tests/components/blink/test_diagnostics.py +++ b/tests/components/blink/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/blue_current/snapshots/test_button.ambr b/tests/components/blue_current/snapshots/test_button.ambr new file mode 100644 index 00000000000..0dc27892ceb --- /dev/null +++ b/tests/components/blue_current/snapshots/test_button.ambr @@ -0,0 +1,144 @@ +# serializer version: 1 +# name: test_buttons_created[button.101_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.101_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'blue_current', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reboot', + 'unique_id': 'reboot_101', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons_created[button.101_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': '101 Reboot', + }), + 'context': , + 'entity_id': 'button.101_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons_created[button.101_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.101_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reset', + 'platform': 'blue_current', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset', + 'unique_id': 'reset_101', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons_created[button.101_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': '101 Reset', + }), + 'context': , + 'entity_id': 'button.101_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons_created[button.101_stop_charge_session-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.101_stop_charge_session', + 'has_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 charge session', + 'platform': 'blue_current', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge_session', + 'unique_id': 'stop_charge_session_101', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons_created[button.101_stop_charge_session-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '101 Stop charge session', + }), + 'context': , + 'entity_id': 'button.101_stop_charge_session', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/blue_current/test_button.py b/tests/components/blue_current/test_button.py new file mode 100644 index 00000000000..7b9e7a7e7ce --- /dev/null +++ b/tests/components/blue_current/test_button.py @@ -0,0 +1,51 @@ +"""The tests for Blue Current buttons.""" + +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, STATE_UNKNOWN, 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 + +charge_point_buttons = ["stop_charge_session", "reset", "reboot"] + + +async def test_buttons_created( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test if all buttons are created.""" + await init_integration(hass, config_entry, Platform.BUTTON) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_charge_point_buttons( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test the underlying charge point buttons.""" + await init_integration(hass, config_entry, Platform.BUTTON) + + for button in charge_point_buttons: + state = hass.states.get(f"button.101_{button}") + assert state is not None + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.101_{button}"}, + blocking=True, + ) + + state = hass.states.get(f"button.101_{button}") + assert state + assert state.state == "2023-01-13T12:00:00+00:00" diff --git a/tests/components/bluemaestro/__init__.py b/tests/components/bluemaestro/__init__.py index 412bc3cb7b3..e598eb34597 100644 --- a/tests/components/bluemaestro/__init__.py +++ b/tests/components/bluemaestro/__init__.py @@ -1,8 +1,48 @@ """Tests for the BlueMaestro integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_BLUEMAESTRO_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +NOT_BLUEMAESTRO_SERVICE_INFO = make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -12,7 +52,7 @@ NOT_BLUEMAESTRO_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -BLUEMAESTRO_SERVICE_INFO = BluetoothServiceInfo( +BLUEMAESTRO_SERVICE_INFO = make_bluetooth_service_info( name="FA17B62C", manufacturer_data={ 307: b"\x17d\x0e\x10\x00\x02\x00\xf2\x01\xf2\x00\x83\x01\x00\x01\r\x02\xab\x00\xf2\x01\xf2\x01\r\x02\xab\x00\xf2\x01\xf2\x00\xff\x02N\x00\x00\x00\x00\x00" diff --git a/tests/components/bluemaestro/test_sensor.py b/tests/components/bluemaestro/test_sensor.py index a75e390c781..40e8550cc9e 100644 --- a/tests/components/bluemaestro/test_sensor.py +++ b/tests/components/bluemaestro/test_sensor.py @@ -1,7 +1,7 @@ """Test the BlueMaestro sensors.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.bluemaestro.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/blueprint/test_importer.py b/tests/components/blueprint/test_importer.py index 94036d208ab..c61be9e2b32 100644 --- a/tests/components/blueprint/test_importer.py +++ b/tests/components/blueprint/test_importer.py @@ -4,7 +4,7 @@ import json from pathlib import Path import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.blueprint import importer from homeassistant.core import HomeAssistant diff --git a/tests/components/bluesound/conftest.py b/tests/components/bluesound/conftest.py index 717c9f61850..63597ed0532 100644 --- a/tests/components/bluesound/conftest.py +++ b/tests/components/bluesound/conftest.py @@ -102,8 +102,8 @@ class PlayerMockData: ) player.presets = AsyncMock( return_value=[ - Preset("preset1", "1", "url1", "image1", None), - Preset("preset2", "2", "url2", "image2", None), + Preset("preset1", 1, "url1", "image1", None), + Preset("preset2", 2, "url2", "image2", None), ] ) diff --git a/tests/components/bluesound/test_button.py b/tests/components/bluesound/test_button.py new file mode 100644 index 00000000000..0cb40f53d27 --- /dev/null +++ b/tests/components/bluesound/test_button.py @@ -0,0 +1,47 @@ +"""Test for bluesound buttons.""" + +from unittest.mock import call + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .conftest import PlayerMocks + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_sleep_timer( + hass: HomeAssistant, + player_mocks: PlayerMocks, + setup_config_entry: None, +) -> None: + """Test the media player volume set.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.player_name1111_set_sleep_timer"}, + blocking=True, + ) + + player_mocks.player_data.player.sleep_timer.assert_called_once() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_clear_sleep_timer( + hass: HomeAssistant, + player_mocks: PlayerMocks, + setup_config_entry: None, +) -> None: + """Test the media player volume set.""" + player_mocks.player_data.player.sleep_timer.side_effect = [15, 30, 45, 60, 90, 0] + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.player_name1111_clear_sleep_timer"}, + blocking=True, + ) + + player_mocks.player_data.player.sleep_timer.assert_has_calls([call()] * 6) diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py index d0e0f75991b..a4d5eecd744 100644 --- a/tests/components/bluesound/test_config_flow.py +++ b/tests/components/bluesound/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Bluesound config flow.""" +from ipaddress import IPv4Address, IPv6Address from unittest.mock import AsyncMock from pyblu.errors import PlayerUnreachableError @@ -121,8 +122,8 @@ async def test_zeroconf_flow_success( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.1", - ip_addresses=["1.1.1.1"], + ip_address=IPv4Address("1.1.1.1"), + ip_addresses=[IPv4Address("1.1.1.1")], port=11000, hostname="player-name1111", type="_musc._tcp.local.", @@ -160,8 +161,8 @@ async def test_zeroconf_flow_cannot_connect( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.1", - ip_addresses=["1.1.1.1"], + ip_address=IPv4Address("1.1.1.1"), + ip_addresses=[IPv4Address("1.1.1.1")], port=11000, hostname="player-name1111", type="_musc._tcp.local.", @@ -187,8 +188,8 @@ async def test_zeroconf_flow_already_configured( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.2", - ip_addresses=["1.1.1.2"], + ip_address=IPv4Address("1.1.1.2"), + ip_addresses=[IPv4Address("1.1.1.2")], port=11000, hostname="player-name1112", type="_musc._tcp.local.", @@ -203,3 +204,23 @@ async def test_zeroconf_flow_already_configured( assert config_entry.data[CONF_HOST] == "1.1.1.2" player_mocks.player_data_for_already_configured.player.sync_status.assert_called_once() + + +async def test_zeroconf_flow_no_ipv4_address(hass: HomeAssistant) -> None: + """Test abort flow when no ipv4 address is found in zeroconf data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=IPv6Address("2001:db8::1"), + ip_addresses=[IPv6Address("2001:db8::1")], + port=11000, + hostname="player-name1112", + type="_musc._tcp.local.", + name="player-name._musc._tcp.local.", + properties={}, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_ipv4_address" diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index ed537d0bc57..dcff33399f5 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -17,12 +17,14 @@ from homeassistant.components.bluesound.media_player import ( SERVICE_SET_TIMER, ) from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_SELECT_SOURCE, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, @@ -119,6 +121,32 @@ async def test_volume_down( player_mocks.player_data.player.volume.assert_called_once_with(level=9) +async def test_select_input_source( + hass: HomeAssistant, setup_config_entry: None, player_mocks: PlayerMocks +) -> None: + """Test the media player select input source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.player_name1111", ATTR_INPUT_SOURCE: "input1"}, + ) + + player_mocks.player_data.player.play_url.assert_called_once_with("url1") + + +async def test_select_preset_source( + hass: HomeAssistant, setup_config_entry: None, player_mocks: PlayerMocks +) -> None: + """Test the media player select preset source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.player_name1111", ATTR_INPUT_SOURCE: "preset1"}, + ) + + player_mocks.player_data.player.load_preset.assert_called_once_with(1) + + async def test_attributes_set( hass: HomeAssistant, setup_config_entry: None, diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index e07b580acb2..e0b491e8f66 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -23,8 +23,7 @@ from . import ( @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") def disable_bluez_manager_socket(): """Mock the bluez manager socket.""" - with patch.object(bleak_manager, "get_global_bluez_manager_with_timeout"): - yield + bleak_manager.get_global_bluez_manager_with_timeout._has_dbus_socket = False @pytest.fixture(name="disable_dbus_socket", autouse=True, scope="package") diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index f0136396c22..4561bcfb802 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -56,6 +56,7 @@ async def test_options_flow_disabled_not_setup( response = await ws_client.receive_json() assert response["result"][0]["supports_options"] is False await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures("macos_adapter") @@ -396,6 +397,7 @@ async def test_options_flow_linux(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_PASSIVE] is False await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -425,6 +427,7 @@ async def test_options_flow_disabled_macos( response = await ws_client.receive_json() assert response["result"][0]["supports_options"] is False await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -457,6 +460,7 @@ async def test_options_flow_enabled_linux( response = await ws_client.receive_json() assert response["result"][0]["supports_options"] is True await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -487,6 +491,8 @@ async def test_options_flow_remote_adapter(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "remote_adapters_not_supported" + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -514,6 +520,8 @@ async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> Non result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "local_adapters_no_passive_support" + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures("one_adapter") @@ -608,3 +616,40 @@ async def test_async_step_integration_discovery_remote_adapter( await hass.async_block_till_done() cancel_scanner() await hass.async_block_till_done() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_step_integration_discovery_remote_adapter_mac_fix( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test remote adapter corrects mac address via integration discovery.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + bluetooth_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: "AA:BB:CC:DD:EE:FF", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: None, + }, + ) + bluetooth_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: "AA:AA:AA:AA:AA:AA", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: None, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert bluetooth_entry.unique_id == "AA:AA:AA:AA:AA:AA" + assert bluetooth_entry.data[CONF_SOURCE] == "AA:AA:AA:AA:AA:AA" diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index e38ae19ce52..80fca88b2de 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -353,6 +353,7 @@ async def test_diagnostics_macos( "1": {"__type": "", "repr": "b'\\x01'"} }, "name": "wohand", + "raw": None, "rssi": -127, "service_data": {}, "service_uuids": [], @@ -382,6 +383,7 @@ async def test_diagnostics_macos( "1": {"__type": "", "repr": "b'\\x01'"} }, "name": "wohand", + "raw": None, "rssi": -127, "service_data": {}, "service_uuids": [], @@ -556,6 +558,7 @@ async def test_diagnostics_remote_adapter( "1": {"__type": "", "repr": "b'\\x01'"} }, "name": "wohand", + "raw": None, "rssi": -127, "service_data": {}, "service_uuids": [], @@ -585,6 +588,7 @@ async def test_diagnostics_remote_adapter( "1": {"__type": "", "repr": "b'\\x01'"} }, "name": "wohand", + "raw": None, "rssi": -127, "service_data": {}, "service_uuids": [], diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 2c8c9e70e7f..de299c58b93 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -3300,3 +3300,52 @@ async def test_cleanup_orphened_remote_scanner_config_entry( assert not hass.config_entries.async_entry_for_domain_unique_id( "bluetooth", scanner.source ) + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_fix_incorrect_mac_remote_scanner_config_entry( + hass: HomeAssistant, +) -> None: + """Test the remote scanner config entries can replace a incorrect mac.""" + source_entry = MockConfigEntry(domain="test") + source_entry.add_to_hass(hass) + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("AA:BB:CC:DD:EE:FF", "esp32", connector, True) + assert scanner.source == "AA:BB:CC:DD:EE:FF" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: source_entry.entry_id, + }, + unique_id=scanner.source, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) + await hass.config_entries.async_unload(entry.entry_id) + + new_scanner = FakeRemoteScanner("AA:BB:CC:DD:EE:AA", "esp32", connector, True) + assert new_scanner.source == "AA:BB:CC:DD:EE:AA" + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_SOURCE: new_scanner.source}, + unique_id=new_scanner.source, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", new_scanner.source + ) + # Incorrect connection should be removed + 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 be23a536f49..bf773b69a99 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -61,6 +61,7 @@ from . import ( from tests.common import ( MockConfigEntry, MockModule, + async_call_logger_set_level, async_fire_time_changed, load_fixture, mock_integration, @@ -1019,8 +1020,6 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( def clear_all_devices(self) -> None: """Clear all devices.""" - self._discovered_device_advertisement_datas.clear() - self._discovered_device_timestamps.clear() self._previous_service_info.clear() connector = ( @@ -1146,54 +1145,45 @@ async def test_debug_logging( ) -> None: """Test debug logging.""" assert await async_setup_component(hass, "logger", {"logger": {}}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "DEBUG"}, - blocking=True, - ) - await hass.async_block_till_done() + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog + ): + address = "44:44:33:11:23:41" + start_time_monotonic = 50.0 - address = "44:44:33:11:23:41" - start_time_monotonic = 50.0 + switchbot_device_poor_signal_hci0 = generate_ble_device( + address, "wohand_poor_signal_hci0" + ) + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci0, + switchbot_adv_poor_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_poor_signal_hci0" in caplog.text + caplog.clear() - switchbot_device_poor_signal_hci0 = generate_ble_device( - address, "wohand_poor_signal_hci0" - ) - switchbot_adv_poor_signal_hci0 = generate_advertisement_data( - local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 - ) - inject_advertisement_with_time_and_source( - hass, - switchbot_device_poor_signal_hci0, - switchbot_adv_poor_signal_hci0, - start_time_monotonic, - "hci0", - ) - assert "wohand_poor_signal_hci0" in caplog.text - caplog.clear() - - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "WARNING"}, - blocking=True, - ) - - switchbot_device_good_signal_hci0 = generate_ble_device( - address, "wohand_good_signal_hci0" - ) - switchbot_adv_good_signal_hci0 = generate_advertisement_data( - local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33 - ) - inject_advertisement_with_time_and_source( - hass, - switchbot_device_good_signal_hci0, - switchbot_adv_good_signal_hci0, - start_time_monotonic, - "hci0", - ) - assert "wohand_good_signal_hci0" not in caplog.text + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "WARNING", hass=hass, caplog=caplog + ): + switchbot_device_good_signal_hci0 = generate_ble_device( + address, "wohand_good_signal_hci0" + ) + switchbot_adv_good_signal_hci0 = generate_advertisement_data( + local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_good_signal_hci0, + switchbot_adv_good_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_good_signal_hci0" not in caplog.text @pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") @@ -1446,8 +1436,6 @@ async def test_bluetooth_rediscover( def clear_all_devices(self) -> None: """Clear all devices.""" - self._discovered_device_advertisement_datas.clear() - self._discovered_device_timestamps.clear() self._previous_service_info.clear() connector = ( @@ -1625,8 +1613,6 @@ async def test_bluetooth_rediscover_no_match( def clear_all_devices(self) -> None: """Clear all devices.""" - self._discovered_device_advertisement_datas.clear() - self._discovered_device_timestamps.clear() self._previous_service_info.clear() connector = ( diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index e9274965e3c..5d4dfcf103f 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -273,6 +273,111 @@ async def test_basic_usage(hass: HomeAssistant) -> None: cancel_coordinator() +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_async_set_updated_data_usage(hass: HomeAssistant) -> None: + """Test async_set_updated_data of the PassiveBluetoothProcessorCoordinator.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + @callback + def _mock_update_method( + service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + assert data == {"test": "data"} + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + assert coordinator.available is False # no data yet + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + + unregister_processor = coordinator.async_register_processor(processor) + cancel_coordinator = coordinator.async_start() + + entity_key = PassiveBluetoothEntityKey("temperature", None) + entity_key_events = [] + all_events = [] + mock_entity = MagicMock() + mock_add_entities = MagicMock() + + def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock entity key listener.""" + entity_key_events.append(data) + + cancel_async_add_entity_key_listener = processor.async_add_entity_key_listener( + _async_entity_key_listener, + entity_key, + ) + + def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock an all listener.""" + all_events.append(data) + + cancel_listener = processor.async_add_listener( + _all_listener, + ) + + cancel_async_add_entities_listener = processor.async_add_entities_listener( + mock_entity, + mock_add_entities, + ) + + assert coordinator.available is False + coordinator.async_set_updated_data({"test": "data"}) + assert coordinator.available is True + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + + # Each listener should receive the same data + # since both match, and an additional all_events + # for the async_set_updated_data call + assert len(entity_key_events) == 1 + assert len(all_events) == 2 + + # There should be 4 calls to create entities + assert len(mock_entity.mock_calls) == 2 + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) + + # Only the all listener should receive the new data + # since temperature is not in the new data, and an additional all_events + # for the async_set_updated_data call + assert len(entity_key_events) == 1 + assert len(all_events) == 3 + + # On the second, the entities should already be created + # so the mock should not be called again + assert len(mock_entity.mock_calls) == 2 + + cancel_async_add_entity_key_listener() + cancel_listener() + cancel_async_add_entities_listener() + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + + # Each listener should not trigger any more now + # that they were cancelled + assert len(entity_key_events) == 1 + assert len(all_events) == 3 + assert len(mock_entity.mock_calls) == 2 + assert coordinator.available is True + + unregister_processor() + cancel_coordinator() + + @pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") async def test_entity_key_is_dispatched_on_entity_key_change( hass: HomeAssistant, diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 6acb86476e7..142438fbb95 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -29,7 +29,11 @@ from . import ( patch_bluetooth_time, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_call_logger_set_level, + async_fire_time_changed, +) # If the adapter is in a stuck state the following errors are raised: NEED_RESET_ERRORS = [ @@ -482,70 +486,67 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( ) -> None: """Test we can recover the adapter at startup and we wait for Dbus to init.""" assert await async_setup_component(hass, "logger", {}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.bluetooth": "DEBUG"}, - blocking=True, - ) - called_start = 0 - called_stop = 0 - _callback = None - mock_discovered = [] - - class MockBleakScanner: - async def start(self, *args, **kwargs): - """Mock Start.""" - nonlocal called_start - called_start += 1 - if called_start == 1: - raise BleakError("org.freedesktop.DBus.Error.UnknownObject") - if called_start == 2: - raise BleakError("org.bluez.Error.InProgress") - if called_start == 3: - raise BleakError("org.bluez.Error.InProgress") - - async def stop(self, *args, **kwargs): - """Mock Start.""" - nonlocal called_stop - called_stop += 1 - - @property - def discovered_devices(self): - """Mock discovered_devices.""" - nonlocal mock_discovered - return mock_discovered - - def register_detection_callback(self, callback: AdvertisementDataCallback): - """Mock Register Detection Callback.""" - nonlocal _callback - _callback = callback - - scanner = MockBleakScanner() - start_time_monotonic = time.monotonic() - - with ( - patch( - "habluetooth.scanner.ADAPTER_INIT_TIME", - 0, - ), - patch_bluetooth_time( - start_time_monotonic, - ), - patch( - "habluetooth.scanner.OriginalBleakScanner", - return_value=scanner, - ), - patch( - "habluetooth.util.recover_adapter", return_value=True - ) as mock_recover_adapter, + async with async_call_logger_set_level( + "homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog ): - await async_setup_with_one_adapter(hass) + called_start = 0 + called_stop = 0 + _callback = None + mock_discovered = [] - assert called_start == 4 + class MockBleakScanner: + async def start(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_start + called_start += 1 + if called_start == 1: + raise BleakError("org.freedesktop.DBus.Error.UnknownObject") + if called_start == 2: + raise BleakError("org.bluez.Error.InProgress") + if called_start == 3: + raise BleakError("org.bluez.Error.InProgress") - assert len(mock_recover_adapter.mock_calls) == 1 - assert "Waiting for adapter to initialize" in caplog.text + async def stop(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_stop + called_stop += 1 + + @property + def discovered_devices(self): + """Mock discovered_devices.""" + nonlocal mock_discovered + return mock_discovered + + def register_detection_callback(self, callback: AdvertisementDataCallback): + """Mock Register Detection Callback.""" + nonlocal _callback + _callback = callback + + scanner = MockBleakScanner() + start_time_monotonic = time.monotonic() + + with ( + patch( + "habluetooth.scanner.ADAPTER_INIT_TIME", + 0, + ), + patch_bluetooth_time( + start_time_monotonic, + ), + patch( + "habluetooth.scanner.OriginalBleakScanner", + return_value=scanner, + ), + patch( + "habluetooth.util.recover_adapter", return_value=True + ) as mock_recover_adapter, + ): + await async_setup_with_one_adapter(hass) + + assert called_start == 4 + + assert len(mock_recover_adapter.mock_calls) == 1 + assert "Waiting for adapter to initialize" in caplog.text @pytest.mark.usefixtures("one_adapter") diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index c5908776882..bfe7445f614 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -316,65 +316,6 @@ async def test_release_slot_on_connect_exception( cancel_hci1() -@pytest.mark.usefixtures("enable_bluetooth", "two_adapters") -async def test_we_switch_adapters_on_failure( - hass: HomeAssistant, - install_bleak_catcher, -) -> None: - """Ensure we try the next best adapter after a failure.""" - hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices( - hass - ) - ble_device = hci0_device_advs["00:00:00:00:00:01"][0] - client = bleak.BleakClient(ble_device) - - class FakeBleakClientFailsHCI0Only(BaseFakeBleakClient): - """Fake bleak client that fails to connect.""" - - async def connect(self, *args, **kwargs): - """Connect.""" - if "/hci0/" in self._device.details["path"]: - return False - return True - - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsHCI0Only, - ): - assert await client.connect() is False - - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsHCI0Only, - ): - assert await client.connect() is False - - # After two tries we should switch to hci1 - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsHCI0Only, - ): - assert await client.connect() is True - - # ..and we remember that hci1 works as long as the client doesn't change - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsHCI0Only, - ): - assert await client.connect() is True - - # If we replace the client, we should try hci0 again - client = bleak.BleakClient(ble_device) - - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsHCI0Only, - ): - assert await client.connect() is False - cancel_hci0() - cancel_hci1() - - @pytest.mark.usefixtures("enable_bluetooth", "two_adapters") async def test_passing_subclassed_str_as_address( hass: HomeAssistant, diff --git a/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr index 569d39c1a5a..0e5a1a7622a 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr @@ -120,7 +120,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Condition based services', + 'original_name': 'Condition-based services', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -135,7 +135,7 @@ 'brake_fluid': 'OK', 'brake_fluid_date': '2022-10-01', 'device_class': 'problem', - 'friendly_name': 'i3 (+ REX) Condition based services', + 'friendly_name': 'i3 (+ REX) Condition-based services', 'vehicle_check': 'OK', 'vehicle_check_date': '2023-05-01', 'vehicle_tuv': 'OK', @@ -326,7 +326,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Pre entry climatization', + 'original_name': 'Pre-entry climatization', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -338,7 +338,7 @@ # name: test_entity_state_attrs[binary_sensor.i3_rex_pre_entry_climatization-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i3 (+ REX) Pre entry climatization', + 'friendly_name': 'i3 (+ REX) Pre-entry climatization', }), 'context': , 'entity_id': 'binary_sensor.i3_rex_pre_entry_climatization', @@ -520,7 +520,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Condition based services', + 'original_name': 'Condition-based services', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -536,7 +536,7 @@ 'brake_fluid_date': '2024-12-01', 'brake_fluid_distance': '50000 km', 'device_class': 'problem', - 'friendly_name': 'i4 eDrive40 Condition based services', + 'friendly_name': 'i4 eDrive40 Condition-based services', 'tire_wear_front': 'OK', 'tire_wear_rear': 'OK', 'vehicle_check': 'OK', @@ -730,7 +730,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Pre entry climatization', + 'original_name': 'Pre-entry climatization', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -742,7 +742,7 @@ # name: test_entity_state_attrs[binary_sensor.i4_edrive40_pre_entry_climatization-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i4 eDrive40 Pre entry climatization', + 'friendly_name': 'i4 eDrive40 Pre-entry climatization', }), 'context': , 'entity_id': 'binary_sensor.i4_edrive40_pre_entry_climatization', @@ -927,7 +927,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Condition based services', + 'original_name': 'Condition-based services', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -943,7 +943,7 @@ 'brake_fluid_date': '2024-12-01', 'brake_fluid_distance': '50000 km', 'device_class': 'problem', - 'friendly_name': 'iX xDrive50 Condition based services', + 'friendly_name': 'iX xDrive50 Condition-based services', 'tire_wear_front': 'OK', 'tire_wear_rear': 'OK', 'vehicle_check': 'OK', @@ -1138,7 +1138,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Pre entry climatization', + 'original_name': 'Pre-entry climatization', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -1150,7 +1150,7 @@ # name: test_entity_state_attrs[binary_sensor.ix_xdrive50_pre_entry_climatization-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'iX xDrive50 Pre entry climatization', + 'friendly_name': 'iX xDrive50 Pre-entry climatization', }), 'context': , 'entity_id': 'binary_sensor.ix_xdrive50_pre_entry_climatization', @@ -1288,7 +1288,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Condition based services', + 'original_name': 'Condition-based services', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -1304,7 +1304,7 @@ 'brake_fluid_date': '2024-12-01', 'brake_fluid_distance': '50000 km', 'device_class': 'problem', - 'friendly_name': 'M340i xDrive Condition based services', + 'friendly_name': 'M340i xDrive Condition-based services', 'oil': 'OK', 'oil_date': '2024-12-01', 'oil_distance': '50000 km', diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index de76b07057e..0edead03f26 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -30,7 +30,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -42,7 +42,7 @@ # name: test_entity_state_attrs[select.i3_rex_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i3 (+ REX) Charging Mode', + 'friendly_name': 'i3 (+ REX) Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', @@ -98,7 +98,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Charging Limit', + 'original_name': 'AC charging limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -110,7 +110,7 @@ # name: test_entity_state_attrs[select.i4_edrive40_ac_charging_limit-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i4 eDrive40 AC Charging Limit', + 'friendly_name': 'i4 eDrive40 AC charging limit', 'options': list([ '6', '7', @@ -167,7 +167,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -179,7 +179,7 @@ # name: test_entity_state_attrs[select.i4_edrive40_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'i4 eDrive40 Charging Mode', + 'friendly_name': 'i4 eDrive40 Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', @@ -235,7 +235,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Charging Limit', + 'original_name': 'AC charging limit', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -247,7 +247,7 @@ # name: test_entity_state_attrs[select.ix_xdrive50_ac_charging_limit-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'iX xDrive50 AC Charging Limit', + 'friendly_name': 'iX xDrive50 AC charging limit', 'options': list([ '6', '7', @@ -304,7 +304,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Mode', + 'original_name': 'Charging mode', 'platform': 'bmw_connected_drive', 'previous_unique_id': None, 'supported_features': 0, @@ -316,7 +316,7 @@ # name: test_entity_state_attrs[select.ix_xdrive50_charging_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'iX xDrive50 Charging Mode', + 'friendly_name': 'iX xDrive50 Charging mode', 'options': list([ 'immediate_charging', 'delayed_charging', diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 73aece4af6b..e5139b253aa 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -15,6 +15,8 @@ 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.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( @@ -63,6 +65,59 @@ async def test_user_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_form_can_create_when_already_discovered( + hass: HomeAssistant, +) -> None: + """Test we get the user initiated form can create when already discovered.""" + + with patch_bond_version(), patch_bond_token(): + zc_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname="mock_hostname", + name="ZXXX12345.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), + ) + assert zc_result["type"] is FlowResultType.FORM + assert zc_result["errors"] == {} + + 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_bond_version(return_value={"bondid": "ZXXX12345"}), + patch_bond_device_ids(return_value=["f6776c11", "f6776c12"]), + patch_bond_bridge(), + patch_bond_device_properties(), + patch_bond_device(), + patch_bond_device_state(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "some host", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "ZXXX12345" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_form_with_non_bridge(hass: HomeAssistant) -> None: """Test setup a smart by bond fan.""" @@ -97,6 +152,7 @@ async def test_user_form_with_non_bridge(hass: HomeAssistant) -> None: CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token", } + assert result2["result"].unique_id == "KXXX12345" assert len(mock_setup_entry.mock_calls) == 1 @@ -253,6 +309,107 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_dhcp_discovery(hass: HomeAssistant) -> None: + """Test DHCP discovery.""" + + with patch_bond_version(), patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ45842", + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_bridge(), + patch_bond_device_ids(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "KVPRBDJ45842" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_discovery_already_exists(hass: HomeAssistant) -> None: + """Test DHCP discovery for an already existing entry.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="KVPRBDJ45842", + ) + entry.add_to_hass(hass) + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_token(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ45842".lower(), + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_short_name(hass: HomeAssistant) -> None: + """Test DHCP discovery with the name cut off.""" + + with patch_bond_version(), patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ", + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_bridge(), + patch_bond_device_ids(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "KVPRBDJ45842" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: """Test we get the discovery form and we handle the token being unavailable.""" diff --git a/tests/components/bosch_alarm/__init__.py b/tests/components/bosch_alarm/__init__.py new file mode 100644 index 00000000000..2b2d94cf1e5 --- /dev/null +++ b/tests/components/bosch_alarm/__init__.py @@ -0,0 +1,22 @@ +"""Tests for the Bosch Alarm component.""" + +from unittest.mock import AsyncMock + +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() + + +async def call_observable(hass: HomeAssistant, observable: AsyncMock) -> None: + """Call the observable with the given event.""" + for callback in observable.attach.call_args_list: + callback[0][0]() + await hass.async_block_till_done() diff --git a/tests/components/bosch_alarm/conftest.py b/tests/components/bosch_alarm/conftest.py new file mode 100644 index 00000000000..283eb158d5c --- /dev/null +++ b/tests/components/bosch_alarm/conftest.py @@ -0,0 +1,215 @@ +"""Define fixtures for Bosch Alarm tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from bosch_alarm_mode2.panel import Area, Door, Output, Point +from bosch_alarm_mode2.utils import Observable +import pytest + +from homeassistant.components.bosch_alarm.const import ( + CONF_INSTALLER_CODE, + CONF_USER_CODE, + DOMAIN, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_PASSWORD, + CONF_PORT, +) +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + + +@pytest.fixture( + params=[ + "solution_3000", + "amax_3000", + "b5512", + ] +) +def model(request: pytest.FixtureRequest) -> Generator[str]: + """Return every device.""" + return request.param + + +@pytest.fixture +def extra_config_entry_data( + model: str, model_name: str, config_flow_data: dict[str, Any] +) -> dict[str, Any]: + """Return extra config entry data.""" + return {CONF_MODEL: model_name} | config_flow_data + + +@pytest.fixture(params=[None]) +def mac_address(request: pytest.FixtureRequest) -> str | None: + """Return entity mac address.""" + return request.param + + +@pytest.fixture +def config_flow_data(model: str) -> dict[str, Any]: + """Return extra config entry data.""" + if model == "solution_3000": + return {CONF_USER_CODE: "1234"} + if model == "amax_3000": + return {CONF_INSTALLER_CODE: "1234", CONF_PASSWORD: "1234567890"} + if model == "b5512": + return {CONF_PASSWORD: "1234567890"} + pytest.fail("Invalid model") + + +@pytest.fixture +def model_name(model: str) -> str | None: + """Return extra config entry data.""" + return { + "solution_3000": "Solution 3000", + "amax_3000": "AMAX 3000", + "b5512": "B5512 (US1B)", + }.get(model) + + +@pytest.fixture +def serial_number(model: str) -> str | None: + """Return extra config entry data.""" + if model == "b5512": + return "1234567890" + return None + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.bosch_alarm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def points() -> Generator[dict[int, Point]]: + """Define a mocked door.""" + names = [ + "Window", + "Door", + "Motion Detector", + "CO Detector", + "Smoke Detector", + "Glassbreak Sensor", + "Bedroom", + ] + points = {} + for i, name in enumerate(names): + mock = AsyncMock(spec=Point) + mock.name = name + mock.status_observer = AsyncMock(spec=Observable) + mock.is_open.return_value = False + mock.is_normal.return_value = True + points[i] = mock + return points + + +@pytest.fixture +def output() -> Generator[Output]: + """Define a mocked output.""" + mock = AsyncMock(spec=Output) + mock.name = "Output A" + mock.status_observer = AsyncMock(spec=Observable) + mock.is_active.return_value = False + return mock + + +@pytest.fixture +def door() -> Generator[Door]: + """Define a mocked door.""" + mock = AsyncMock(spec=Door) + mock.name = "Main Door" + mock.status_observer = AsyncMock(spec=Observable) + mock.is_open.return_value = False + mock.is_cycling.return_value = False + mock.is_secured.return_value = False + mock.is_locked.return_value = True + return mock + + +@pytest.fixture +def area() -> Generator[Area]: + """Define a mocked area.""" + mock = AsyncMock(spec=Area) + mock.name = "Area1" + mock.status_observer = AsyncMock(spec=Observable) + mock.alarm_observer = AsyncMock(spec=Observable) + mock.ready_observer = AsyncMock(spec=Observable) + mock.alarms = [] + mock.alarms_ids = [] + mock.faults = 0 + mock.all_ready = True + mock.part_ready = True + mock.is_triggered.return_value = False + mock.is_disarmed.return_value = True + mock.is_armed.return_value = False + mock.is_arming.return_value = False + mock.is_pending.return_value = False + mock.is_part_armed.return_value = False + mock.is_all_armed.return_value = False + return mock + + +@pytest.fixture +def mock_panel( + area: AsyncMock, + door: AsyncMock, + output: AsyncMock, + points: dict[int, AsyncMock], + model_name: str, + serial_number: str | None, +) -> Generator[AsyncMock]: + """Define a fixture to set up Bosch Alarm.""" + with ( + patch( + "homeassistant.components.bosch_alarm.Panel", autospec=True + ) as mock_panel, + patch("homeassistant.components.bosch_alarm.config_flow.Panel", new=mock_panel), + ): + client = mock_panel.return_value + client.areas = {1: area} + client.doors = {1: door} + client.outputs = {1: output} + client.points = points + client.model = model_name + client.faults = [] + client.events = [] + client.panel_faults_ids = [] + client.firmware_version = "1.0.0" + client.protocol_version = "1.0.0" + client.serial_number = serial_number + client.connection_status_observer = AsyncMock(spec=Observable) + client.faults_observer = AsyncMock(spec=Observable) + client.history_observer = AsyncMock(spec=Observable) + yield client + + +@pytest.fixture +def mock_config_entry( + extra_config_entry_data: dict[str, Any], + serial_number: str | None, + mac_address: str | None, +) -> MockConfigEntry: + """Mock config entry for bosch alarm.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=serial_number, + entry_id="01JQ917ACKQ33HHM7YCFXYZX51", + data={ + CONF_HOST: "0.0.0.0", + CONF_PORT: 7700, + CONF_MODEL: "bosch_alarm_test_data.model", + CONF_MAC: mac_address and format_mac(mac_address), + } + | extra_config_entry_data, + ) diff --git a/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr b/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..26c67879f7c --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,154 @@ +# serializer version: 1 +# name: test_alarm_control_panel[None-amax_3000][alarm_control_panel.area1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.area1', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[None-amax_3000][alarm_control_panel.area1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Area1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.area1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- +# name: test_alarm_control_panel[None-b5512][alarm_control_panel.area1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.area1', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1234567890_area_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[None-b5512][alarm_control_panel.area1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Area1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.area1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- +# name: test_alarm_control_panel[None-solution_3000][alarm_control_panel.area1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.area1', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[None-solution_3000][alarm_control_panel.area1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Area1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.area1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- diff --git a/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..377a9e23426 --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr @@ -0,0 +1,2995 @@ +# serializer version: 1 +# name: test_binary_sensor[None-amax_3000][binary_sensor.area1_area_ready_to_arm_away-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_away', + 'has_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 ready to arm away', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_away', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_ready_to_arm_away', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.area1_area_ready_to_arm_away-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm away', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_away', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.area1_area_ready_to_arm_home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_home', + 'has_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 ready to arm home', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_home', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_ready_to_arm_home', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.area1_area_ready_to_arm_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm home', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bedroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.bedroom', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_ac_failure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_ac_failure', + '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 Failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_ac_fail', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_ac_fail', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_ac_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 AC Failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_ac_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_battery_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bosch AMAX 3000 Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_battery_missing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_battery_missing', + '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 missing', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_battery_mising', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_battery_mising', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_battery_missing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 Battery missing', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_battery_missing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_crc_failure_in_panel_configuration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_crc_failure_in_panel_configuration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CRC failure in panel configuration', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_parameter_crc_fail_in_pif', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_parameter_crc_fail_in_pif', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_crc_failure_in_panel_configuration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 CRC failure in panel configuration', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_crc_failure_in_panel_configuration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_failure_to_call_rps_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_failure_to_call_rps_since_rps_hang_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': 'Failure to call RPS since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_failure_to_call_rps_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bosch AMAX 3000 Failure to call RPS since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_failure_to_call_rps_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_log_overflow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_log_overflow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log overflow', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_overflow', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_log_overflow', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_log_overflow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 Log overflow', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_log_overflow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_log_threshold_reached-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_log_threshold_reached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log threshold reached', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_threshold', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_log_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_log_threshold_reached-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 Log threshold reached', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_log_threshold_reached', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_phone_line_failure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_phone_line_failure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phone line failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_phone_line_failure', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_phone_line_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_phone_line_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Bosch AMAX 3000 Phone line failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_phone_line_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_point_bus_failure_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_point_bus_failure_since_rps_hang_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Point bus failure since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_point_bus_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_point_bus_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_point_bus_failure_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 Point bus failure since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_point_bus_failure_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_communication_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_communication_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_sdi_failure_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_sdi_failure_since_rps_hang_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SDI failure since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_sdi_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_sdi_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_sdi_failure_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 SDI failure since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_sdi_failure_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_user_code_tamper_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_amax_3000_user_code_tamper_since_rps_hang_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User code tamper since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_user_code_tamper_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_user_code_tamper_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.bosch_amax_3000_user_code_tamper_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch AMAX 3000 User code tamper since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_amax_3000_user_code_tamper_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.co_detector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.co_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.co_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CO Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.co_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.door', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door', + }), + 'context': , + 'entity_id': 'binary_sensor.door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.glassbreak_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.glassbreak_sensor', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.glassbreak_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Glassbreak Sensor', + }), + 'context': , + 'entity_id': 'binary_sensor.glassbreak_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.motion_detector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.motion_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.motion_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Motion Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.smoke_detector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.smoke_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.smoke_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.window', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-amax_3000][binary_sensor.window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Window', + }), + 'context': , + 'entity_id': 'binary_sensor.window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.area1_area_ready_to_arm_away-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_away', + 'has_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 ready to arm away', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_away', + 'unique_id': '1234567890_area_1_ready_to_arm_away', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.area1_area_ready_to_arm_away-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm away', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_away', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.area1_area_ready_to_arm_home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_home', + 'has_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 ready to arm home', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_home', + 'unique_id': '1234567890_area_1_ready_to_arm_home', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.area1_area_ready_to_arm_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm home', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bedroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.bedroom', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_ac_failure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_ac_failure', + '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 Failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_ac_fail', + 'unique_id': '1234567890_fault_panel_fault_ac_fail', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_ac_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) AC Failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_ac_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_fault_panel_fault_battery_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bosch B5512 (US1B) Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_battery_missing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_battery_missing', + '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 missing', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_battery_mising', + 'unique_id': '1234567890_fault_panel_fault_battery_mising', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_battery_missing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) Battery missing', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_battery_missing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_crc_failure_in_panel_configuration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_crc_failure_in_panel_configuration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CRC failure in panel configuration', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_parameter_crc_fail_in_pif', + 'unique_id': '1234567890_fault_panel_fault_parameter_crc_fail_in_pif', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_crc_failure_in_panel_configuration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) CRC failure in panel configuration', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_crc_failure_in_panel_configuration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_failure_to_call_rps_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_failure_to_call_rps_since_rps_hang_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': 'Failure to call RPS since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unique_id': '1234567890_fault_panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_failure_to_call_rps_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bosch B5512 (US1B) Failure to call RPS since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_failure_to_call_rps_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_log_overflow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_log_overflow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log overflow', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_overflow', + 'unique_id': '1234567890_fault_panel_fault_log_overflow', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_log_overflow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) Log overflow', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_log_overflow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_log_threshold_reached-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_log_threshold_reached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log threshold reached', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_threshold', + 'unique_id': '1234567890_fault_panel_fault_log_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_log_threshold_reached-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) Log threshold reached', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_log_threshold_reached', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_phone_line_failure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_phone_line_failure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phone line failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_phone_line_failure', + 'unique_id': '1234567890_fault_panel_fault_phone_line_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_phone_line_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Bosch B5512 (US1B) Phone line failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_phone_line_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_point_bus_failure_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_point_bus_failure_since_rps_hang_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Point bus failure since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_point_bus_fail_since_rps_hang_up', + 'unique_id': '1234567890_fault_panel_fault_point_bus_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_point_bus_failure_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) Point bus failure since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_point_bus_failure_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_communication_fail_since_rps_hang_up', + 'unique_id': '1234567890_fault_panel_fault_communication_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_sdi_failure_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_sdi_failure_since_rps_hang_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SDI failure since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_sdi_fail_since_rps_hang_up', + 'unique_id': '1234567890_fault_panel_fault_sdi_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_sdi_failure_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) SDI failure since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_sdi_failure_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_user_code_tamper_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_user_code_tamper_since_rps_hang_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User code tamper since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_user_code_tamper_since_rps_hang_up', + 'unique_id': '1234567890_fault_panel_fault_user_code_tamper_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.bosch_b5512_us1b_user_code_tamper_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch B5512 (US1B) User code tamper since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_b5512_us1b_user_code_tamper_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.co_detector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.co_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.co_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CO Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.co_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.door', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door', + }), + 'context': , + 'entity_id': 'binary_sensor.door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.glassbreak_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.glassbreak_sensor', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.glassbreak_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Glassbreak Sensor', + }), + 'context': , + 'entity_id': 'binary_sensor.glassbreak_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.motion_detector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.motion_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.motion_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Motion Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.smoke_detector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.smoke_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.smoke_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.window', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_point_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-b5512][binary_sensor.window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Window', + }), + 'context': , + 'entity_id': 'binary_sensor.window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.area1_area_ready_to_arm_away-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_away', + 'has_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 ready to arm away', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_away', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_ready_to_arm_away', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.area1_area_ready_to_arm_away-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm away', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_away', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.area1_area_ready_to_arm_home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_home', + 'has_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 ready to arm home', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'area_ready_to_arm_home', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_ready_to_arm_home', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.area1_area_ready_to_arm_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Area ready to arm home', + }), + 'context': , + 'entity_id': 'binary_sensor.area1_area_ready_to_arm_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bedroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.bedroom', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_ac_failure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_ac_failure', + '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 Failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_ac_fail', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_ac_fail', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_ac_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 AC Failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_ac_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_battery_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bosch Solution 3000 Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_battery_missing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_battery_missing', + '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 missing', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_battery_mising', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_battery_mising', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_battery_missing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 Battery missing', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_battery_missing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_crc_failure_in_panel_configuration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_crc_failure_in_panel_configuration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CRC failure in panel configuration', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_parameter_crc_fail_in_pif', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_parameter_crc_fail_in_pif', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_crc_failure_in_panel_configuration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 CRC failure in panel configuration', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_crc_failure_in_panel_configuration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_failure_to_call_rps_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_failure_to_call_rps_since_rps_hang_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': 'Failure to call RPS since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_fail_to_call_rps_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_failure_to_call_rps_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bosch Solution 3000 Failure to call RPS since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_failure_to_call_rps_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_log_overflow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_log_overflow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log overflow', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_overflow', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_log_overflow', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_log_overflow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 Log overflow', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_log_overflow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_log_threshold_reached-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_log_threshold_reached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Log threshold reached', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_log_threshold', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_log_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_log_threshold_reached-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 Log threshold reached', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_log_threshold_reached', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_phone_line_failure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_phone_line_failure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phone line failure', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_phone_line_failure', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_phone_line_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_phone_line_failure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Bosch Solution 3000 Phone line failure', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_phone_line_failure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_point_bus_failure_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_point_bus_failure_since_rps_hang_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Point bus failure since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_point_bus_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_point_bus_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_point_bus_failure_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 Point bus failure since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_point_bus_failure_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_communication_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_communication_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_sdi_failure_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_sdi_failure_since_rps_hang_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SDI failure since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_sdi_fail_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_sdi_fail_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_sdi_failure_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 SDI failure since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_sdi_failure_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_user_code_tamper_since_rps_hang_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bosch_solution_3000_user_code_tamper_since_rps_hang_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User code tamper since RPS hang up', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panel_fault_user_code_tamper_since_rps_hang_up', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_fault_panel_fault_user_code_tamper_since_rps_hang_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.bosch_solution_3000_user_code_tamper_since_rps_hang_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Bosch Solution 3000 User code tamper since RPS hang up', + }), + 'context': , + 'entity_id': 'binary_sensor.bosch_solution_3000_user_code_tamper_since_rps_hang_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.co_detector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.co_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.co_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CO Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.co_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.door', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door', + }), + 'context': , + 'entity_id': 'binary_sensor.door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.glassbreak_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.glassbreak_sensor', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.glassbreak_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Glassbreak Sensor', + }), + 'context': , + 'entity_id': 'binary_sensor.glassbreak_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.motion_detector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.motion_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.motion_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Motion Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.smoke_detector-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.smoke_detector', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.smoke_detector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke Detector', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_detector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.window', + 'has_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': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_point_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[None-solution_3000][binary_sensor.window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Window', + }), + 'context': , + 'entity_id': 'binary_sensor.window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..670db709a1a --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr @@ -0,0 +1,290 @@ +# serializer version: 1 +# name: test_diagnostics[amax_3000-None] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': 0, + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'AMAX 3000', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': None, + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'installer_code': '**REDACTED**', + 'mac': None, + 'model': 'AMAX 3000', + 'password': '**REDACTED**', + 'port': 7700, + }), + }) +# --- +# name: test_diagnostics[b5512-None] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': 0, + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'B5512 (US1B)', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': '1234567890', + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'mac': None, + 'model': 'B5512 (US1B)', + 'password': '**REDACTED**', + 'port': 7700, + }), + }) +# --- +# name: test_diagnostics[solution_3000-None] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': 0, + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'Solution 3000', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': None, + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'mac': None, + 'model': 'Solution 3000', + 'port': 7700, + 'user_code': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/bosch_alarm/snapshots/test_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..4f4c55dd845 --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_sensor.ambr @@ -0,0 +1,568 @@ +# serializer version: 1 +# name: test_sensor[None-amax_3000][sensor.area1_burglary_alarm_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_burglary_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Burglary alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_burglary', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_burglary', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-amax_3000][sensor.area1_burglary_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Burglary alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_burglary_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-amax_3000][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[None-amax_3000][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[None-amax_3000][sensor.area1_fire_alarm_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_fire_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fire alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_fire', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_fire', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-amax_3000][sensor.area1_fire_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Fire alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_fire_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-amax_3000][sensor.area1_gas_alarm_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_gas_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_gas', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_gas', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-amax_3000][sensor.area1_gas_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Gas alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_gas_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_burglary_alarm_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_burglary_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Burglary alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_burglary', + 'unique_id': '1234567890_area_1_alarms_burglary', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_burglary_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Burglary alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_burglary_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '1234567890_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_fire_alarm_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_fire_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fire alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_fire', + 'unique_id': '1234567890_area_1_alarms_fire', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_fire_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Fire alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_fire_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_gas_alarm_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_gas_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_gas', + 'unique_id': '1234567890_area_1_alarms_gas', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-b5512][sensor.area1_gas_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Gas alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_gas_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_burglary_alarm_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_burglary_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Burglary alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_burglary', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_burglary', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_burglary_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Burglary alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_burglary_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_fire_alarm_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_fire_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fire alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_fire', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_fire', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_fire_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Fire alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_fire_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_gas_alarm_issues-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_gas_alarm_issues', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas alarm issues', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarms_gas', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_alarms_gas', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[None-solution_3000][sensor.area1_gas_alarm_issues-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Gas alarm issues', + }), + 'context': , + 'entity_id': 'sensor.area1_gas_alarm_issues', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_issues', + }) +# --- diff --git a/tests/components/bosch_alarm/snapshots/test_switch.ambr b/tests/components/bosch_alarm/snapshots/test_switch.ambr new file mode 100644 index 00000000000..0604787924f --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_switch.ambr @@ -0,0 +1,565 @@ +# serializer version: 1 +# name: test_switch[None-amax_3000][switch.main_door_locked-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.main_door_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': 'Locked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'locked', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-amax_3000][switch.main_door_locked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Locked', + }), + 'context': , + 'entity_id': 'switch.main_door_locked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[None-amax_3000][switch.main_door_momentarily_unlocked-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.main_door_momentarily_unlocked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Momentarily unlocked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cycling', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_cycling', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-amax_3000][switch.main_door_momentarily_unlocked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Momentarily unlocked', + }), + 'context': , + 'entity_id': 'switch.main_door_momentarily_unlocked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-amax_3000][switch.main_door_secured-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.main_door_secured', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Secured', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'secured', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_secured', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-amax_3000][switch.main_door_secured-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Secured', + }), + 'context': , + 'entity_id': 'switch.main_door_secured', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-amax_3000][switch.output_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.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': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_output_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-amax_3000][switch.output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Output A', + }), + 'context': , + 'entity_id': 'switch.output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-b5512][switch.main_door_locked-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.main_door_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': 'Locked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'locked', + 'unique_id': '1234567890_door_1_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-b5512][switch.main_door_locked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Locked', + }), + 'context': , + 'entity_id': 'switch.main_door_locked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[None-b5512][switch.main_door_momentarily_unlocked-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.main_door_momentarily_unlocked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Momentarily unlocked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cycling', + 'unique_id': '1234567890_door_1_cycling', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-b5512][switch.main_door_momentarily_unlocked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Momentarily unlocked', + }), + 'context': , + 'entity_id': 'switch.main_door_momentarily_unlocked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-b5512][switch.main_door_secured-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.main_door_secured', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Secured', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'secured', + 'unique_id': '1234567890_door_1_secured', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-b5512][switch.main_door_secured-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Secured', + }), + 'context': , + 'entity_id': 'switch.main_door_secured', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-b5512][switch.output_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.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': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_output_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-b5512][switch.output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Output A', + }), + 'context': , + 'entity_id': 'switch.output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_locked-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.main_door_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': 'Locked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'locked', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_locked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Locked', + }), + 'context': , + 'entity_id': 'switch.main_door_locked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_momentarily_unlocked-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.main_door_momentarily_unlocked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Momentarily unlocked', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cycling', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_cycling', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_momentarily_unlocked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Momentarily unlocked', + }), + 'context': , + 'entity_id': 'switch.main_door_momentarily_unlocked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_secured-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.main_door_secured', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Secured', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'secured', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_door_1_secured', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-solution_3000][switch.main_door_secured-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Main Door Secured', + }), + 'context': , + 'entity_id': 'switch.main_door_secured', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[None-solution_3000][switch.output_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.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': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_output_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[None-solution_3000][switch.output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Output A', + }), + 'context': , + 'entity_id': 'switch.output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/bosch_alarm/test_alarm_control_panel.py b/tests/components/bosch_alarm/test_alarm_control_panel.py new file mode 100644 index 00000000000..31d2f928ec5 --- /dev/null +++ b/tests/components/bosch_alarm/test_alarm_control_panel.py @@ -0,0 +1,145 @@ +"""Tests for Bosch Alarm component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, + AlarmControlPanelState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_DISARM, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_observable, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch( + "homeassistant.components.bosch_alarm.PLATFORMS", [Platform.ALARM_CONTROL_PANEL] + ): + yield + + +async def test_update_alarm_device( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that alarm panel state changes after arming the panel.""" + await setup_integration(hass, mock_config_entry) + entity_id = "alarm_control_panel.area1" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + + area.is_arming.return_value = True + area.is_disarmed.return_value = False + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING + + area.is_arming.return_value = False + area.is_all_armed.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_AWAY + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + area.is_all_armed.return_value = False + area.is_disarmed.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_HOME, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + area.is_disarmed.return_value = False + area.is_arming.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING + + area.is_arming.return_value = False + area.is_part_armed.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_HOME + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + area.is_part_armed.return_value = False + area.is_disarmed.return_value = True + + await call_observable(hass, area.status_observer) + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + + +async def test_alarm_control_panel( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the alarm_control_panel state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_alarm_control_panel_availability( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the alarm_control_panel availability.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("alarm_control_panel.area1").state + == AlarmControlPanelState.DISARMED + ) + + mock_panel.connection_status.return_value = False + + await call_observable(hass, mock_panel.connection_status_observer) + + assert hass.states.get("alarm_control_panel.area1").state == STATE_UNAVAILABLE diff --git a/tests/components/bosch_alarm/test_binary_sensor.py b/tests/components/bosch_alarm/test_binary_sensor.py new file mode 100644 index 00000000000..e788d7c5eda --- /dev/null +++ b/tests/components/bosch_alarm/test_binary_sensor.py @@ -0,0 +1,78 @@ +"""Tests for Bosch Alarm component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from bosch_alarm_mode2.const import ALARM_PANEL_FAULTS +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_observable, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch( + "homeassistant.components.bosch_alarm.PLATFORMS", [Platform.BINARY_SENSOR] + ): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the binary sensor state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("model", ["b5512"]) +async def test_panel_faults( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that fault sensor state changes after inducing a fault.""" + await setup_integration(hass, mock_config_entry) + entity_id = "binary_sensor.bosch_b5512_us1b_battery" + assert hass.states.get(entity_id).state == STATE_OFF + mock_panel.panel_faults_ids = [ALARM_PANEL_FAULTS.BATTERY_LOW] + await call_observable(hass, mock_panel.faults_observer) + assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize("model", ["b5512"]) +async def test_area_ready_to_arm( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that fault sensor state changes after inducing a fault.""" + await setup_integration(hass, mock_config_entry) + entity_id = "binary_sensor.area1_area_ready_to_arm_away" + entity_id_2 = "binary_sensor.area1_area_ready_to_arm_home" + assert hass.states.get(entity_id).state == STATE_ON + assert hass.states.get(entity_id_2).state == STATE_ON + area.all_ready = False + await call_observable(hass, area.status_observer) + assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(entity_id_2).state == STATE_ON + area.part_ready = False + await call_observable(hass, area.status_observer) + assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(entity_id_2).state == STATE_OFF diff --git a/tests/components/bosch_alarm/test_config_flow.py b/tests/components/bosch_alarm/test_config_flow.py new file mode 100644 index 00000000000..afdd98bb1c0 --- /dev/null +++ b/tests/components/bosch_alarm/test_config_flow.py @@ -0,0 +1,493 @@ +"""Tests for the bosch_alarm config flow.""" + +import asyncio +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.bosch_alarm.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_RECONFIGURE, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_form_user( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test the config flow for bosch_alarm.""" + 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: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config_flow_data, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Bosch {model_name}" + assert ( + result["data"] + == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 7700, + CONF_MODEL: model_name, + } + | config_flow_data + ) + assert result["result"].unique_id == serial_number + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (asyncio.TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test we handle exceptions correctly.""" + + 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_panel.connect.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": message} + + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config_flow_data, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (PermissionError, "invalid_auth"), + (asyncio.TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions_user( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test we handle exceptions correctly.""" + + 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: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + mock_panel.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config_flow_data + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": message} + + mock_panel.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config_flow_data + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize("model", ["solution_3000", "amax_3000"]) +async def test_entry_already_configured_host( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], +) -> None: + """Test if configuring an entity twice results in an error.""" + mock_config_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_configure( + result["flow_id"], {CONF_HOST: "0.0.0.0"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("model", ["b5512"]) +async def test_entry_already_configured_serial( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], +) -> None: + """Test if configuring an entity twice results in an error.""" + mock_config_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_configure( + result["flow_id"], {CONF_HOST: "1.1.1.1"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config_flow_data + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_can_finish( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test DHCP discovery flow can finish right away.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="test", + ip="1.1.1.1", + macaddress="34ea34b43b5a", + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config_flow_data, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Bosch {model_name}" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_MAC: "34:ea:34:b4:3b:5a", + CONF_PORT: 7700, + CONF_MODEL: model_name, + **config_flow_data, + } + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (asyncio.exceptions.TimeoutError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_dhcp_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test DHCP discovery flow that fails to connect.""" + mock_panel.connect.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="test", + ip="1.1.1.1", + macaddress="34ea34b43b5a", + ), + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == message + + +@pytest.mark.parametrize("mac_address", ["34ea34b43b5a"]) +async def test_dhcp_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + mac_address: str | None, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test DHCP updates host.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="test", + ip="4.5.6.7", + macaddress=mac_address, + ), + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "4.5.6.7" + + +@pytest.mark.parametrize("model", ["solution_3000", "amax_3000"]) +async def test_dhcp_abort_ongoing_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], +) -> None: + """Test if a dhcp flow is aborted if there is already an ongoing flow.""" + + 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_HOST: "0.0.0.0"} + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="test", + ip="0.0.0.0", + macaddress="34ea34b43b5a", + ), + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + # Now check it works when there are no errors + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (OSError(), "cannot_connect"), + (PermissionError(), "invalid_auth"), + (Exception(), "unknown"), + ], +) +async def test_reauth_flow_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + mock_panel.connect.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == message + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data + + +async def test_reconfig_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reconfig auth.""" + await setup_integration(hass, mock_config_entry) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + 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: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config_flow_data, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 7700, + CONF_MODEL: model_name, + **config_flow_data, + } + + +@pytest.mark.parametrize("model", ["b5512"]) +async def test_reconfig_flow_incorrect_model( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reconfig fails with a different device.""" + await setup_integration(hass, mock_config_entry) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + mock_panel.model = "Solution 3000" + + 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: "0.0.0.0", CONF_PORT: 7700}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "device_mismatch" diff --git a/tests/components/bosch_alarm/test_diagnostics.py b/tests/components/bosch_alarm/test_diagnostics.py new file mode 100644 index 00000000000..3e10878bd07 --- /dev/null +++ b/tests/components/bosch_alarm/test_diagnostics.py @@ -0,0 +1,32 @@ +"""Test the Bosch Alarm diagnostics.""" + +from typing import Any +from unittest.mock import AsyncMock + +from syrupy.assertion 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( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_panel: AsyncMock, + area: AsyncMock, + model_name: str, + serial_number: str, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + config_flow_data: dict[str, Any], +) -> None: + """Test generating diagnostics for bosch alarm.""" + await setup_integration(hass, mock_config_entry) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + assert diag == snapshot diff --git a/tests/components/bosch_alarm/test_init.py b/tests/components/bosch_alarm/test_init.py new file mode 100644 index 00000000000..13e938bd711 --- /dev/null +++ b/tests/components/bosch_alarm/test_init.py @@ -0,0 +1,47 @@ +"""Tests for bosch alarm integration init.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def disable_platform_only(): + """Disable platforms to speed up tests.""" + with patch("homeassistant.components.bosch_alarm.PLATFORMS", []): + yield + + +@pytest.mark.parametrize("model", ["solution_3000"]) +@pytest.mark.parametrize("exception", [PermissionError()]) +async def test_incorrect_auth( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test errors with incorrect auth.""" + mock_panel.connect.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize("model", ["solution_3000"]) +@pytest.mark.parametrize("exception", [TimeoutError()]) +async def test_connection_error( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test errors with incorrect auth.""" + mock_panel.connect.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/bosch_alarm/test_sensor.py b/tests/components/bosch_alarm/test_sensor.py new file mode 100644 index 00000000000..c986fdab733 --- /dev/null +++ b/tests/components/bosch_alarm/test_sensor.py @@ -0,0 +1,69 @@ +"""Tests for Bosch Alarm component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from bosch_alarm_mode2.const import ALARM_MEMORY_PRIORITIES +import pytest +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 call_observable, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.bosch_alarm.PLATFORMS", [Platform.SENSOR]): + yield + + +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_faulting_points( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that area faulting point count changes after arming the panel.""" + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.area1_faulting_points" + assert hass.states.get(entity_id).state == "0" + + area.faults = 1 + await call_observable(hass, area.ready_observer) + assert hass.states.get(entity_id).state == "1" + + +async def test_alarm_faults( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that alarm state changes after arming the panel.""" + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.area1_fire_alarm_issues" + assert hass.states.get(entity_id).state == "no_issues" + + area.alarms_ids = [ALARM_MEMORY_PRIORITIES.FIRE_TROUBLE] + await call_observable(hass, area.alarm_observer) + + assert hass.states.get(entity_id).state == "trouble" diff --git a/tests/components/bosch_alarm/test_switch.py b/tests/components/bosch_alarm/test_switch.py new file mode 100644 index 00000000000..2c52c21099a --- /dev/null +++ b/tests/components/bosch_alarm/test_switch.py @@ -0,0 +1,147 @@ +"""Tests for Bosch Alarm component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +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, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_observable, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.bosch_alarm.PLATFORMS", [Platform.SWITCH]): + yield + + +async def test_update_switch_device( + hass: HomeAssistant, + mock_panel: AsyncMock, + output: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that output state changes after turning on the output.""" + await setup_integration(hass, mock_config_entry) + entity_id = "switch.output_a" + assert hass.states.get(entity_id).state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + output.is_active.return_value = True + await call_observable(hass, output.status_observer) + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_unlock_door( + hass: HomeAssistant, + mock_panel: AsyncMock, + door: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that door state changes after unlocking the door.""" + await setup_integration(hass, mock_config_entry) + entity_id = "switch.main_door_locked" + assert hass.states.get(entity_id).state == STATE_ON + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + door.is_locked.return_value = False + door.is_open.return_value = True + await call_observable(hass, door.status_observer) + assert hass.states.get(entity_id).state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + door.is_locked.return_value = True + door.is_open.return_value = False + await call_observable(hass, door.status_observer) + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_secure_door( + hass: HomeAssistant, + mock_panel: AsyncMock, + door: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that door state changes after unlocking the door.""" + await setup_integration(hass, mock_config_entry) + entity_id = "switch.main_door_secured" + assert hass.states.get(entity_id).state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + door.is_secured.return_value = True + await call_observable(hass, door.status_observer) + assert hass.states.get(entity_id).state == STATE_ON + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + door.is_secured.return_value = False + await call_observable(hass, door.status_observer) + assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_cycle_door( + hass: HomeAssistant, + mock_panel: AsyncMock, + door: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that door state changes after unlocking the door.""" + await setup_integration(hass, mock_config_entry) + entity_id = "switch.main_door_momentarily_unlocked" + assert hass.states.get(entity_id).state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + door.is_cycling.return_value = True + await call_observable(hass, door.status_observer) + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_switch( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the switch state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/braviatv/test_diagnostics.py b/tests/components/braviatv/test_diagnostics.py index a7bd1631788..2f6df722909 100644 --- a/tests/components/braviatv/test_diagnostics.py +++ b/tests/components/braviatv/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.braviatv.const import CONF_USE_PSK, DOMAIN diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 951c3d3f808..4c8475428e9 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'data': dict({ + 'activity': dict({ 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ 'activity': dict({ 'timeline': list([ @@ -79,76 +79,26 @@ 'timestamp': '2025-01-01T03:09:33.036000+00:00', 'totalEvents': 3, }), - 'content': dict({ - 'items': dict({ - 'purchase': list([ - dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', - }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', - }), - ]), - }), - 'status': 'REGISTERED', - 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - }), - 'lst': dict({ - 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - 'name': 'Baumarkt', - 'theme': 'ch.publisheria.bring.theme.home', - }), 'users': dict({ 'users': list([ dict({ 'country': 'DE', - 'email': 'test-email', + 'email': '**REDACTED**', 'language': 'de', - 'name': 'Bring', + 'name': '**REDACTED**', 'photoPath': '', + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', 'pushEnabled': True, }), dict({ 'country': 'US', - 'email': 'EMAIL', + 'email': '**REDACTED**', 'language': 'en', - 'name': 'NAME', + 'name': '**REDACTED**', 'photoPath': '', + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', 'pushEnabled': True, @@ -159,6 +109,7 @@ 'language': 'en', 'name': None, 'photoPath': None, + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', 'pushEnabled': True, @@ -243,6 +194,101 @@ 'timestamp': '2025-01-01T03:09:33.036000+00:00', 'totalEvents': 3, }), + 'users': dict({ + 'users': list([ + dict({ + 'country': 'DE', + 'email': '**REDACTED**', + 'language': 'de', + 'name': '**REDACTED**', + 'photoPath': '', + 'plusExpiry': None, + 'plusTryOut': False, + 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': '**REDACTED**', + 'language': 'en', + 'name': '**REDACTED**', + 'photoPath': '', + 'plusExpiry': None, + 'plusTryOut': False, + 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': None, + 'language': 'en', + 'name': None, + 'photoPath': None, + 'plusExpiry': None, + 'plusTryOut': False, + 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'pushEnabled': True, + }), + ]), + }), + }), + }), + 'data': dict({ + 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ + 'content': dict({ + 'items': dict({ + 'purchase': list([ + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + }), + ]), + }), + 'status': 'REGISTERED', + 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + }), + 'lst': dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), + }), + 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ 'content': dict({ 'items': dict({ 'purchase': list([ @@ -295,40 +341,6 @@ 'name': 'Einkauf', 'theme': 'ch.publisheria.bring.theme.home', }), - 'users': dict({ - 'users': list([ - dict({ - 'country': 'DE', - 'email': 'test-email', - 'language': 'de', - 'name': 'Bring', - 'photoPath': '', - 'plusTryOut': False, - 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', - 'pushEnabled': True, - }), - dict({ - 'country': 'US', - 'email': 'EMAIL', - 'language': 'en', - 'name': 'NAME', - 'photoPath': '', - 'plusTryOut': False, - 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', - 'pushEnabled': True, - }), - dict({ - 'country': 'US', - 'email': None, - 'language': 'en', - 'name': None, - 'photoPath': None, - 'plusTryOut': False, - 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', - 'pushEnabled': True, - }), - ]), - }), }), }), 'lists': list([ diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index f053f294ef1..7f235ea505c 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -139,6 +139,31 @@ async def test_config_entry_not_ready_udpdate_failed( assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize( + ("exception", "state"), + [ + (BringRequestException, ConfigEntryState.SETUP_RETRY), + (BringParseException, ConfigEntryState.SETUP_RETRY), + (BringAuthException, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_activity_coordinator_errors( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test config entry not ready from update failed in _async_update_data.""" + mock_bring_client.get_activity.side_effect = exception + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is state + + @pytest.mark.parametrize( ("exception", "state"), [ @@ -263,3 +288,44 @@ async def test_create_devices( assert device_registry.async_get_device( {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} ) + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_coordinator_update_intervals( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_bring_client: AsyncMock, +) -> None: + """Test the coordinator updates at the specified intervals.""" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + # fetch 2 lists on first refresh + assert mock_bring_client.load_lists.await_count == 2 + assert mock_bring_client.get_activity.await_count == 2 + + mock_bring_client.load_lists.reset_mock() + mock_bring_client.get_activity.reset_mock() + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # main coordinator refreshes, activity does not + assert mock_bring_client.load_lists.await_count == 1 + assert mock_bring_client.get_activity.await_count == 0 + + mock_bring_client.load_lists.reset_mock() + mock_bring_client.get_activity.reset_mock() + + freezer.tick(timedelta(seconds=510)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # assert activity refreshes after 10min and has up-to-date lists data + assert mock_bring_client.get_activity.await_count == 1 diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 673c4e68a4d..a1d7de2b553 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -1,12 +1,6 @@ """Test for utility functions of the Bring! integration.""" -from bring_api import ( - BringActivityResponse, - BringItemsResponse, - BringListResponse, - BringUserSettingsResponse, -) -from bring_api.types import BringUsersResponse +from bring_api import BringItemsResponse, BringListResponse, BringUserSettingsResponse import pytest from homeassistant.components.bring.const import DOMAIN @@ -47,10 +41,8 @@ def test_sum_attributes(attribute: str, expected: int) -> None: """Test function sum_attributes.""" items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)) lst = BringListResponse.from_json(load_fixture("lists.json", DOMAIN)) - activity = BringActivityResponse.from_json(load_fixture("activity.json", DOMAIN)) - users = BringUsersResponse.from_json(load_fixture("users.json", DOMAIN)) result = sum_attributes( - BringData(lst.lists[0], items, activity, users), + BringData(lst.lists[0], items), attribute, ) diff --git a/tests/components/brother/test_diagnostics.py b/tests/components/brother/test_diagnostics.py index 117990b6470..493f2993555 100644 --- a/tests/components/brother/test_diagnostics.py +++ b/tests/components/brother/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 8069b27e307..28d08cd6b2f 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.brother.const import DOMAIN, UPDATE_INTERVAL from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN diff --git a/tests/components/bsblan/test_diagnostics.py b/tests/components/bsblan/test_diagnostics.py index aea53f8a1a2..c6b6c92e718 100644 --- a/tests/components/bsblan/test_diagnostics.py +++ b/tests/components/bsblan/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 2d712f408c2..6de0a7ef936 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Generator from datetime import timedelta from http import HTTPStatus +import re from typing import Any from freezegun import freeze_time @@ -448,7 +449,7 @@ async def test_list_events_service( service: str, expected: dict[str, Any], ) -> None: - """Test listing events from the service call using exlplicit start and end time. + """Test listing events from the service call using explicit start and end time. This test uses a fixed date/time so that it can deterministically test the string output values. @@ -553,3 +554,53 @@ async def test_list_events_missing_fields(hass: HomeAssistant) -> None: blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + "frozen_time", ["2023-06-22 10:30:00+00:00"], ids=["frozen_time"] +) +@pytest.mark.parametrize( + ("service_data", "error_msg"), + [ + ( + { + "start_date_time": "2023-06-22T04:30:00-06:00", + "end_date_time": "2023-06-22T04:30:00-06:00", + }, + "Expected end time to be after start time (2023-06-22 04:30:00-06:00, 2023-06-22 04:30:00-06:00)", + ), + ( + { + "start_date_time": "2023-06-22T04:30:00", + "end_date_time": "2023-06-22T04:30:00", + }, + "Expected end time to be after start time (2023-06-22 04:30:00, 2023-06-22 04:30:00)", + ), + ( + {"start_date_time": "2023-06-22", "end_date_time": "2023-06-22"}, + "Expected end time to be after start time (2023-06-22 00:00:00, 2023-06-22 00:00:00)", + ), + ( + {"start_date_time": "2023-06-22 10:00:00", "duration": "0"}, + "Expected positive duration (0:00:00)", + ), + ], +) +async def test_list_events_service_same_dates( + hass: HomeAssistant, + service_data: dict[str, str], + error_msg: str, +) -> None: + """Test listing events from the service call using the same start and end time.""" + + with pytest.raises(vol.error.MultipleInvalid, match=re.escape(error_msg)): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_EVENTS, + service_data={ + "entity_id": "calendar.calendar_1", + **service_data, + }, + blocking=True, + return_response=True, + ) diff --git a/tests/components/cambridge_audio/snapshots/test_media_browser.ambr b/tests/components/cambridge_audio/snapshots/test_media_browser.ambr index 180d5ed1bb0..9f0fffdac49 100644 --- a/tests/components/cambridge_audio/snapshots/test_media_browser.ambr +++ b/tests/components/cambridge_audio/snapshots/test_media_browser.ambr @@ -4,6 +4,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'directory', 'media_content_id': '', @@ -18,6 +19,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'music', 'media_content_id': '1', @@ -28,6 +30,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'music', 'media_content_id': '2', diff --git a/tests/components/cambridge_audio/snapshots/test_select.ambr b/tests/components/cambridge_audio/snapshots/test_select.ambr index 8c9801b101b..c83e101f363 100644 --- a/tests/components/cambridge_audio/snapshots/test_select.ambr +++ b/tests/components/cambridge_audio/snapshots/test_select.ambr @@ -57,6 +57,64 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[select.cambridge_audio_cxnv2_control_bus_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'amplifier', + 'receiver', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cambridge_audio_cxnv2_control_bus_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': 'Control Bus mode', + 'platform': 'cambridge_audio', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'control_bus_mode', + 'unique_id': '0020c2d8-control_bus_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.cambridge_audio_cxnv2_control_bus_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cambridge Audio CXNv2 Control Bus mode', + 'options': list([ + 'amplifier', + 'receiver', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.cambridge_audio_cxnv2_control_bus_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[select.cambridge_audio_cxnv2_display_brightness-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/cambridge_audio/test_diagnostics.py b/tests/components/cambridge_audio/test_diagnostics.py index 9c1a09c6318..42367a67876 100644 --- a/tests/components/cambridge_audio/test_diagnostics.py +++ b/tests/components/cambridge_audio/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/cambridge_audio/test_init.py b/tests/components/cambridge_audio/test_init.py index a058f7c8b6c..507a942c30f 100644 --- a/tests/components/cambridge_audio/test_init.py +++ b/tests/components/cambridge_audio/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock from aiostreammagic import StreamMagicError from aiostreammagic.models import CallbackType import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cambridge_audio.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/cambridge_audio/test_media_browser.py b/tests/components/cambridge_audio/test_media_browser.py index da72cfab534..1e374566611 100644 --- a/tests/components/cambridge_audio/test_media_browser.py +++ b/tests/components/cambridge_audio/test_media_browser.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py index bb2ccd1aec4..10e9311c4b0 100644 --- a/tests/components/cambridge_audio/test_media_player.py +++ b/tests/components/cambridge_audio/test_media_player.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from aiostreammagic import ( + ControlBusMode, RepeatMode as CambridgeRepeatMode, ShuffleMode, TransportControl, @@ -10,6 +11,7 @@ from aiostreammagic import ( import pytest from homeassistant.components.media_player import ( + ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_REPEAT, @@ -128,6 +130,29 @@ async def test_entity_supported_features( ) +async def test_entity_supported_features_with_control_bus( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entity attributes with control bus state.""" + await setup_integration(hass, mock_config_entry) + + mock_stream_magic_client.state.pre_amp_mode = False + mock_stream_magic_client.state.control_bus = ControlBusMode.AMPLIFIER + + await mock_state_update(mock_stream_magic_client) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + attrs = state.attributes + assert MediaPlayerEntityFeature.VOLUME_STEP in attrs[ATTR_SUPPORTED_FEATURES] + assert ( + MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE + not in attrs[ATTR_SUPPORTED_FEATURES] + ) + + @pytest.mark.parametrize( ("power_state", "play_state", "media_player_state"), [ @@ -489,3 +514,41 @@ async def test_play_media_unknown_type( }, blocking=True, ) + + +@pytest.mark.parametrize( + ("source_id", "artist", "station", "display"), + [ + ("MEDIA_PLAYER", "Metallica", None, "Metallica"), + ("USB_AUDIO", "Iron Maiden", "Radio BOB!", "Iron Maiden"), + ("IR", "In Flames", "Radio BOB!", "In Flames"), + ("IR", None, "Radio BOB!", "Radio BOB!"), + ("IR", None, None, None), + ("MEDIA_PLAYER", None, "Radio BOB!", None), + ], +) +async def test_media_artist( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_config_entry: MockConfigEntry, + source_id: str, + artist: str, + station: str, + display: str, +) -> None: + """Test media player state.""" + await setup_integration(hass, mock_config_entry) + mock_stream_magic_client.play_state.metadata.artist = artist + mock_stream_magic_client.play_state.metadata.station = station + mock_stream_magic_client.state.source = source_id + + await mock_state_update(mock_stream_magic_client) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + if (artist is None and source_id != "IR") or ( + source_id == "IR" and station is None + ): + assert ATTR_MEDIA_ARTIST not in state.attributes + else: + assert state.attributes[ATTR_MEDIA_ARTIST] == display diff --git a/tests/components/cambridge_audio/test_select.py b/tests/components/cambridge_audio/test_select.py index 473c4027163..73359aaa2b7 100644 --- a/tests/components/cambridge_audio/test_select.py +++ b/tests/components/cambridge_audio/test_select.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, diff --git a/tests/components/cambridge_audio/test_switch.py b/tests/components/cambridge_audio/test_switch.py index 3192f198d1f..44f7379f22f 100644 --- a/tests/components/cambridge_audio/test_switch.py +++ b/tests/components/cambridge_audio/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, Platform diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index b529ee3e9b9..dcc02cf99fe 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -165,13 +165,15 @@ async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None: async def stream_source(self) -> str | None: return STREAM_SOURCE - class SyncCamera(BaseCamera): - """Mock Camera with native sync WebRTC support.""" + class AsyncNoCandidateCamera(BaseCamera): + """Mock Camera with native async WebRTC support but not implemented candidate support.""" - _attr_name = "Sync" + _attr_name = "Async No Candidate" - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: - return WEBRTC_ANSWER + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: + send_message(WebRTCAnswer(WEBRTC_ANSWER)) class AsyncCamera(BaseCamera): """Mock Camera with native async WebRTC support.""" @@ -221,7 +223,10 @@ async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None: ), ) setup_test_component_platform( - hass, camera.DOMAIN, [SyncCamera(), AsyncCamera()], from_config_entry=True + hass, + camera.DOMAIN, + [AsyncNoCandidateCamera(), AsyncCamera()], + from_config_entry=True, ) mock_platform(hass, f"{domain}.config_flow", Mock()) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 7fd469fa51a..2348ca58673 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -27,7 +27,6 @@ from homeassistant.components.camera.helper import get_camera_from_entity_id from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ( ATTR_ENTITY_ID, - CONF_PLATFORM, EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, ) @@ -969,24 +968,19 @@ async def test_camera_capabilities_webrtc( """Test WebRTC camera capabilities.""" await _test_capabilities( - hass, hass_ws_client, "camera.sync", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} + hass, hass_ws_client, "camera.async", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} ) -@pytest.mark.parametrize( - ("entity_id", "expect_native_async_webrtc"), - [("camera.sync", False), ("camera.async", True)], -) @pytest.mark.usefixtures("mock_test_webrtc_cameras", "register_test_provider") async def test_webrtc_provider_not_added_for_native_webrtc( - hass: HomeAssistant, entity_id: str, expect_native_async_webrtc: bool + hass: HomeAssistant, ) -> None: """Test that a WebRTC provider is not added to a camera when the camera has native WebRTC support.""" - camera_obj = get_camera_from_entity_id(hass, entity_id) + camera_obj = get_camera_from_entity_id(hass, "camera.async") assert camera_obj assert camera_obj._webrtc_provider is None - assert camera_obj._supports_native_sync_webrtc is not expect_native_async_webrtc - assert camera_obj._supports_native_async_webrtc is expect_native_async_webrtc + assert camera_obj._supports_native_async_webrtc is True @pytest.mark.usefixtures("mock_camera", "mock_stream_source") @@ -1017,14 +1011,12 @@ async def test_camera_capabilities_changing_non_native_support( @pytest.mark.usefixtures("mock_test_webrtc_cameras") -@pytest.mark.parametrize(("entity_id"), ["camera.sync", "camera.async"]) async def test_camera_capabilities_changing_native_support( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - entity_id: str, ) -> None: """Test WebRTC camera capabilities.""" - cam = get_camera_from_entity_id(hass, entity_id) + cam = get_camera_from_entity_id(hass, "camera.async") assert cam.supported_features == camera.CameraEntityFeature.STREAM await _test_capabilities( @@ -1036,27 +1028,3 @@ async def test_camera_capabilities_changing_native_support( await hass.async_block_till_done() await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set()) - - -@pytest.mark.usefixtures("enable_custom_integrations") -async def test_deprecated_frontend_stream_type_logs( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test using (_attr_)frontend_stream_type will log.""" - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - - for entity_id in ( - "camera.property_frontend_stream_type", - "camera.attr_frontend_stream_type", - ): - camera_obj = get_camera_from_entity_id(hass, entity_id) - assert camera_obj.frontend_stream_type == StreamType.WEB_RTC - - assert ( - "Detected that custom integration 'test' is overwriting the 'frontend_stream_type' property in the PropertyFrontendStreamTypeCamera class, which is deprecated and will be removed in Home Assistant 2025.6," - ) in caplog.text - assert ( - "Detected that custom integration 'test' is setting the '_attr_frontend_stream_type' attribute in the AttrFrontendStreamTypeCamera class, which is deprecated and will be removed in Home Assistant 2025.6," - ) in caplog.text diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index a7c6d889409..e6b13afc171 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -1,7 +1,6 @@ """Test camera WebRTC.""" -from collections.abc import AsyncGenerator, Generator -import logging +from collections.abc import AsyncGenerator from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -10,9 +9,7 @@ from webrtc_models import RTCIceCandidate, RTCIceCandidateInit, RTCIceServer from homeassistant.components.camera import ( DATA_ICE_SERVERS, - DOMAIN as CAMERA_DOMAIN, Camera, - CameraEntityFeature, CameraWebRTCProvider, StreamType, WebRTCAnswer, @@ -20,30 +17,17 @@ from homeassistant.components.camera import ( WebRTCError, WebRTCMessage, WebRTCSendMessage, - async_get_supported_legacy_provider, async_register_ice_servers, - async_register_rtsp_to_web_rtc_provider, async_register_webrtc_provider, get_camera_from_entity_id, ) from homeassistant.components.websocket_api import TYPE_RESULT -from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant, callback from homeassistant.core_config import async_process_ha_core_config -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider -from tests.common import ( - MockConfigEntry, - MockModule, - mock_config_flow, - mock_integration, - mock_platform, - setup_test_component_platform, -) from tests.typing import WebSocketGenerator WEBRTC_OFFER = "v=0\r\n" @@ -60,84 +44,6 @@ class Go2RTCProvider(SomeTestProvider): return "go2rtc" -class MockCamera(Camera): - """Mock Camera Entity.""" - - _attr_name = "Test" - _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - - def __init__(self) -> None: - """Initialize the mock entity.""" - super().__init__() - self._sync_answer: str | None | Exception = WEBRTC_ANSWER - - def set_sync_answer(self, value: str | None | Exception) -> None: - """Set sync offer answer.""" - self._sync_answer = value - - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: - """Handle the WebRTC offer and return the answer.""" - if isinstance(self._sync_answer, Exception): - raise self._sync_answer - return self._sync_answer - - async def stream_source(self) -> str | None: - """Return the source of the stream. - - This is used by cameras with CameraEntityFeature.STREAM - and StreamType.HLS. - """ - return "rtsp://stream" - - -@pytest.fixture -async def init_test_integration( - hass: HomeAssistant, -) -> MockCamera: - """Initialize components.""" - - entry = MockConfigEntry(domain=TEST_INTEGRATION_DOMAIN) - entry.add_to_hass(hass) - - 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, [CAMERA_DOMAIN] - ) - return True - - async def async_unload_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Unload test config entry.""" - await hass.config_entries.async_forward_entry_unload( - config_entry, CAMERA_DOMAIN - ) - return True - - mock_integration( - hass, - MockModule( - TEST_INTEGRATION_DOMAIN, - async_setup_entry=async_setup_entry_init, - async_unload_entry=async_unload_entry_init, - ), - ) - test_camera = MockCamera() - setup_test_component_platform( - hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True - ) - mock_platform(hass, f"{TEST_INTEGRATION_DOMAIN}.config_flow", Mock()) - - with mock_config_flow(TEST_INTEGRATION_DOMAIN, ConfigFlow): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return test_camera - - @pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_async_register_webrtc_provider( hass: HomeAssistant, @@ -305,7 +211,6 @@ async def test_ws_get_client_config( }, ], }, - "getCandidatesUpfront": False, } @callback @@ -344,30 +249,6 @@ async def test_ws_get_client_config( }, ], }, - "getCandidatesUpfront": False, - } - - -@pytest.mark.usefixtures("mock_test_webrtc_cameras") -async def test_ws_get_client_config_sync_offer( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test get WebRTC client config, when camera is supporting sync offer.""" - await async_setup_component(hass, "camera", {}) - await hass.async_block_till_done() - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - {"type": "camera/webrtc/get_client_config", "entity_id": "camera.sync"} - ) - msg = await client.receive_json() - - # Assert WebSocket response - assert msg["type"] == TYPE_RESULT - assert msg["success"] - assert msg["result"] == { - "configuration": {}, - "getCandidatesUpfront": True, } @@ -394,7 +275,6 @@ async def test_ws_get_client_config_custom_config( assert msg["success"] assert msg["result"] == { "configuration": {"iceServers": [{"urls": ["stun:custom_stun_server:3478"]}]}, - "getCandidatesUpfront": False, } @@ -427,21 +307,6 @@ async def provide_webrtc_answer(stream_source: str, offer: str, stream_id: str) return WEBRTC_ANSWER -@pytest.fixture(name="mock_rtsp_to_webrtc") -def mock_rtsp_to_webrtc_fixture( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> Generator[Mock]: - """Fixture that registers a mock rtsp to webrtc provider.""" - mock_provider = Mock(side_effect=provide_webrtc_answer) - unsub = async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", mock_provider) - assert ( - "async_register_rtsp_to_web_rtc_provider is a deprecated function which will" - " be removed in HA Core 2025.6. Use async_register_webrtc_provider instead" - ) in caplog.text - yield mock_provider - unsub() - - @pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_websocket_webrtc_offer( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -643,144 +508,6 @@ async def test_websocket_webrtc_offer_missing_offer( assert response["error"]["code"] == "invalid_format" -@pytest.mark.parametrize( - ("error", "expected_message"), - [ - (ValueError("value error"), "value error"), - (HomeAssistantError("offer failed"), "offer failed"), - (TimeoutError(), "Timeout handling WebRTC offer"), - ], -) -async def test_websocket_webrtc_offer_failure( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_test_integration: MockCamera, - error: Exception, - expected_message: str, -) -> None: - """Test WebRTC stream that fails handling the offer.""" - client = await hass_ws_client(hass) - init_test_integration.set_sync_answer(error) - - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.test", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Error - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "error", - "code": "webrtc_offer_failed", - "message": expected_message, - } - - -@pytest.mark.usefixtures("mock_test_webrtc_cameras") -async def test_websocket_webrtc_offer_sync( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test sync WebRTC stream offer.""" - client = await hass_ws_client(hass) - - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.sync", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - - assert ( - "tests.components.camera.conftest", - logging.WARNING, - ( - "async_handle_web_rtc_offer was called from camera, this is a deprecated " - "function which will be removed in HA Core 2025.6. Use " - "async_handle_async_webrtc_offer instead" - ), - ) in caplog.record_tuples - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == {"type": "answer", "answer": WEBRTC_ANSWER} - - -async def test_websocket_webrtc_offer_sync_no_answer( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - caplog: pytest.LogCaptureFixture, - init_test_integration: MockCamera, -) -> None: - """Test sync WebRTC stream offer with no answer.""" - client = await hass_ws_client(hass) - init_test_integration.set_sync_answer(None) - - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.test", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "error", - "code": "webrtc_offer_failed", - "message": "No answer on WebRTC offer", - } - assert ( - "homeassistant.components.camera", - logging.ERROR, - "Error handling WebRTC offer: No answer", - ) in caplog.record_tuples - - @pytest.mark.usefixtures("mock_camera") async def test_websocket_webrtc_offer_invalid_stream_type( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -804,45 +531,6 @@ async def test_websocket_webrtc_offer_invalid_stream_type( } -@pytest.mark.usefixtures("mock_camera", "mock_stream_source") -async def test_rtsp_to_webrtc_offer( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_rtsp_to_webrtc: Mock, -) -> None: - """Test creating a webrtc offer from an rstp provider.""" - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "answer", - "answer": WEBRTC_ANSWER, - } - - assert mock_rtsp_to_webrtc.called - - @pytest.fixture(name="mock_hls_stream_source") async def mock_hls_stream_source_fixture() -> AsyncGenerator[AsyncMock]: """Fixture to create an HLS stream source.""" @@ -853,117 +541,6 @@ async def mock_hls_stream_source_fixture() -> AsyncGenerator[AsyncMock]: yield mock_hls_stream_source -@pytest.mark.usefixtures("mock_camera", "mock_stream_source") -async def test_rtsp_to_webrtc_provider_unregistered( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test creating a webrtc offer from an rstp provider.""" - mock_provider = Mock(side_effect=provide_webrtc_answer) - unsub = async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", mock_provider) - - client = await hass_ws_client(hass) - - # Registered provider can handle the WebRTC offer - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "answer", - "answer": WEBRTC_ANSWER, - } - - assert mock_provider.called - mock_provider.reset_mock() - - # Unregister provider, then verify the WebRTC offer cannot be handled - unsub() - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - assert response.get("type") == TYPE_RESULT - assert not response["success"] - assert response["error"] == { - "code": "webrtc_offer_failed", - "message": "Camera does not support WebRTC, frontend_stream_types={}", - } - - assert not mock_provider.called - - -@pytest.mark.usefixtures("mock_camera", "mock_stream_source") -async def test_rtsp_to_webrtc_offer_not_accepted( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test a provider that can't satisfy the rtsp to webrtc offer.""" - - async def provide_none( - stream_source: str, offer: str, stream_id: str - ) -> str | None: - """Simulate a provider that can't accept the offer.""" - return None - - mock_provider = Mock(side_effect=provide_none) - unsub = async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", mock_provider) - client = await hass_ws_client(hass) - - # Registered provider can handle the WebRTC offer - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": WEBRTC_OFFER, - } - ) - response = await client.receive_json() - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "error", - "code": "webrtc_offer_failed", - "message": "Camera does not support WebRTC", - } - - assert mock_provider.called - - unsub() - - @pytest.mark.parametrize( ("frontend_candidate", "expected_candidate"), [ @@ -1069,7 +646,7 @@ async def test_ws_webrtc_candidate_not_supported( await client.send_json_auto_id( { "type": "camera/webrtc/candidate", - "entity_id": "camera.sync", + "entity_id": "camera.async_no_candidate", "session_id": "session_id", "candidate": {"candidate": "candidate"}, } @@ -1224,79 +801,3 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None: "session_id", RTCIceCandidateInit("candidate") ) provider.async_close_session("session_id") - - -@pytest.mark.usefixtures("mock_camera") -async def test_repair_issue_legacy_provider( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test repair issue created for legacy provider.""" - # Ensure no issue if no provider is registered - assert not issue_registry.async_get_issue( - "camera", "legacy_webrtc_provider_mock_domain" - ) - - # Register a legacy provider - legacy_provider = Mock(side_effect=provide_webrtc_answer) - unsub_legacy_provider = async_register_rtsp_to_web_rtc_provider( - hass, "mock_domain", legacy_provider - ) - await hass.async_block_till_done() - - # Ensure no issue if only legacy provider is registered - assert not issue_registry.async_get_issue( - "camera", "legacy_webrtc_provider_mock_domain" - ) - - provider = Go2RTCProvider() - unsub_go2rtc_provider = async_register_webrtc_provider(hass, provider) - await hass.async_block_till_done() - - # Ensure issue when legacy and builtin provider are registered - issue = issue_registry.async_get_issue( - "camera", "legacy_webrtc_provider_mock_domain" - ) - assert issue - assert issue.is_fixable is False - assert issue.is_persistent is False - assert issue.issue_domain == "mock_domain" - assert issue.learn_more_url == "https://www.home-assistant.io/integrations/go2rtc/" - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.issue_id == "legacy_webrtc_provider_mock_domain" - assert issue.translation_key == "legacy_webrtc_provider" - assert issue.translation_placeholders == { - "legacy_integration": "mock_domain", - "builtin_integration": "go2rtc", - } - - unsub_legacy_provider() - unsub_go2rtc_provider() - - -@pytest.mark.usefixtures("mock_camera", "register_test_provider", "mock_rtsp_to_webrtc") -async def test_no_repair_issue_without_new_provider( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test repair issue not created if no go2rtc provider exists.""" - assert not issue_registry.async_get_issue( - "camera", "legacy_webrtc_provider_mock_domain" - ) - - -@pytest.mark.usefixtures("mock_camera", "mock_rtsp_to_webrtc") -async def test_registering_same_legacy_provider( - hass: HomeAssistant, -) -> None: - """Test registering the same legacy provider twice.""" - legacy_provider = Mock(side_effect=provide_webrtc_answer) - with pytest.raises(ValueError, match="Provider already registered"): - async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", legacy_provider) - - -@pytest.mark.usefixtures("mock_hls_stream_source", "mock_camera", "mock_rtsp_to_webrtc") -async def test_get_not_supported_legacy_provider(hass: HomeAssistant) -> None: - """Test getting a not supported legacy provider.""" - camera = get_camera_from_entity_id(hass, "camera.demo_camera") - assert await async_get_supported_legacy_provider(hass, camera) is None diff --git a/tests/components/canary/__init__.py b/tests/components/canary/__init__.py index 13c4b84ab94..b247bfc35d6 100644 --- a/tests/components/canary/__init__.py +++ b/tests/components/canary/__init__.py @@ -37,13 +37,6 @@ YAML_CONFIG = { } -def _patch_async_setup(return_value=True): - return patch( - "homeassistant.components.canary.async_setup", - return_value=return_value, - ) - - def _patch_async_setup_entry(return_value=True): return patch( "homeassistant.components.canary.async_setup_entry", diff --git a/tests/components/canary/test_alarm_control_panel.py b/tests/components/canary/test_alarm_control_panel.py index a194621b0d9..2df75ad5c59 100644 --- a/tests/components/canary/test_alarm_control_panel.py +++ b/tests/components/canary/test_alarm_control_panel.py @@ -8,7 +8,6 @@ from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_DOMAIN, AlarmControlPanelState, ) -from homeassistant.components.canary import DOMAIN from homeassistant.const import ( SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, @@ -19,9 +18,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.setup import async_setup_component -from . import mock_device, mock_location, mock_mode +from . import init_integration, mock_device, mock_location, mock_mode async def test_alarm_control_panel( @@ -43,10 +41,8 @@ async def test_alarm_control_panel( instance = canary.return_value instance.get_locations.return_value = [mocked_location] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["alarm_control_panel"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) entity_id = "alarm_control_panel.home" entity_entry = entity_registry.async_get(entity_id) @@ -124,10 +120,8 @@ async def test_alarm_control_panel_services(hass: HomeAssistant, canary) -> None instance = canary.return_value instance.get_locations.return_value = [mocked_location] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["alarm_control_panel"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) entity_id = "alarm_control_panel.home" diff --git a/tests/components/canary/test_config_flow.py b/tests/components/canary/test_config_flow.py index 552aa9089ce..06aadc8297c 100644 --- a/tests/components/canary/test_config_flow.py +++ b/tests/components/canary/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import USER_INPUT, _patch_async_setup, _patch_async_setup_entry, init_integration +from . import USER_INPUT, _patch_async_setup_entry, init_integration async def test_user_form(hass: HomeAssistant, canary_config_flow) -> None: @@ -27,10 +27,7 @@ async def test_user_form(hass: HomeAssistant, canary_config_flow) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - _patch_async_setup() as mock_setup, - _patch_async_setup_entry() as mock_setup_entry, - ): + with _patch_async_setup_entry() as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -41,7 +38,6 @@ async def test_user_form(hass: HomeAssistant, canary_config_flow) -> None: assert result["title"] == "test-username" assert result["data"] == {**USER_INPUT, CONF_TIMEOUT: DEFAULT_TIMEOUT} - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -120,7 +116,7 @@ async def test_options_flow(hass: HomeAssistant, canary) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with _patch_async_setup(), _patch_async_setup_entry(): + with _patch_async_setup_entry(): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_FFMPEG_ARGUMENTS: "-v", CONF_TIMEOUT: 7}, diff --git a/tests/components/canary/test_init.py b/tests/components/canary/test_init.py index e0d1c532efc..67cb11207df 100644 --- a/tests/components/canary/test_init.py +++ b/tests/components/canary/test_init.py @@ -1,59 +1,12 @@ """The tests for the Canary component.""" -from unittest.mock import patch - from requests import ConnectTimeout -from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.components.canary.const import CONF_FFMPEG_ARGUMENTS, DOMAIN +from homeassistant.components.canary.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from . import YAML_CONFIG, init_integration - - -async def test_import_from_yaml(hass: HomeAssistant, canary) -> None: - """Test import from YAML.""" - with patch( - "homeassistant.components.canary.async_setup_entry", - return_value=True, - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: YAML_CONFIG}) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - assert entries[0].data[CONF_USERNAME] == "test-username" - assert entries[0].data[CONF_PASSWORD] == "test-password" - assert entries[0].data[CONF_TIMEOUT] == 5 - - -async def test_import_from_yaml_ffmpeg(hass: HomeAssistant, canary) -> None: - """Test import from YAML with ffmpeg arguments.""" - with patch( - "homeassistant.components.canary.async_setup_entry", - return_value=True, - ): - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: YAML_CONFIG, - CAMERA_DOMAIN: [{"platform": DOMAIN, CONF_FFMPEG_ARGUMENTS: "-v"}], - }, - ) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - assert entries[0].data[CONF_USERNAME] == "test-username" - assert entries[0].data[CONF_PASSWORD] == "test-password" - assert entries[0].data[CONF_TIMEOUT] == 5 - assert entries[0].data.get(CONF_FFMPEG_ARGUMENTS) == "-v" +from . import init_integration async def test_unload_entry(hass: HomeAssistant, canary) -> None: diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index afcf9f16db4..b5a79724ddb 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -20,10 +20,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from . import mock_device, mock_location, mock_reading +from . import init_integration, mock_device, mock_location, mock_reading from tests.common import async_fire_time_changed @@ -48,10 +47,8 @@ async def test_sensors_pro( mock_reading("air_quality", "0.59"), ] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["sensor"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) sensors = { "home_dining_room_temperature": ( @@ -112,10 +109,8 @@ async def test_sensors_attributes_pro(hass: HomeAssistant, canary) -> None: mock_reading("air_quality", "0.59"), ] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["sensor"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) entity_id = "sensor.home_dining_room_air_quality" state1 = hass.states.get(entity_id) @@ -175,10 +170,8 @@ async def test_sensors_flex( mock_reading("wifi", "-57"), ] - config = {DOMAIN: {"username": "test-username", "password": "test-password"}} with patch("homeassistant.components.canary.PLATFORMS", ["sensor"]): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + await init_integration(hass) sensors = { "home_dining_room_battery": ( diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index 2dcf007c6d4..99f3113a10b 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.cast.home_assistant_cast import CAST_USER_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value async def test_creating_entry_sets_up_media_player(hass: HomeAssistant) -> None: @@ -87,7 +87,7 @@ async def test_user_setup_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"known_hosts": "192.168.0.1, , 192.168.0.2 "} + result["flow_id"], {"known_hosts": ["192.168.0.1", "", " ", "192.168.0.2 "]} ) users = await hass.auth.async_get_users() @@ -141,24 +141,14 @@ async def test_zeroconf_setup_onboarding(hass: HomeAssistant) -> None: } -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - return None - - @pytest.mark.parametrize( - "parameter_data", + ("parameter", "initial", "suggested", "user_input", "updated"), [ ( "known_hosts", ["192.168.0.10", "192.168.0.11"], - "192.168.0.10,192.168.0.11", - "192.168.0.1, , 192.168.0.2 ", + ["192.168.0.10", "192.168.0.11"], + ["192.168.0.1", " ", " 192.168.0.2 "], ["192.168.0.1", "192.168.0.2"], ), ( @@ -177,11 +167,17 @@ def get_suggested(schema, key): ), ], ) -async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: +async def test_option_flow( + hass: HomeAssistant, + parameter: str, + initial: list[str], + suggested: str | list[str], + user_input: str | list[str], + updated: list[str], +) -> None: """Test config flow options.""" basic_parameters = ["known_hosts"] advanced_parameters = ["ignore_cec", "uuid"] - parameter, initial, suggested, user_input, updated = parameter_data data = { "ignore_cec": [], @@ -213,9 +209,9 @@ async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: for other_param in basic_parameters: if other_param == parameter: continue - assert get_suggested(data_schema, other_param) == "" + assert get_schema_suggested_value(data_schema, other_param) == [] if parameter in basic_parameters: - assert get_suggested(data_schema, parameter) == suggested + assert get_schema_suggested_value(data_schema, parameter) == suggested user_input_dict = {} if parameter in basic_parameters: @@ -238,9 +234,9 @@ async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: for other_param in advanced_parameters: if other_param == parameter: continue - assert get_suggested(data_schema, other_param) == "" + assert get_schema_suggested_value(data_schema, other_param) == "" if parameter in advanced_parameters: - assert get_suggested(data_schema, parameter) == suggested + assert get_schema_suggested_value(data_schema, parameter) == suggested user_input_dict = {} if parameter in advanced_parameters: @@ -261,7 +257,7 @@ async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"known_hosts": ""}, + user_input={}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {} @@ -277,7 +273,7 @@ async def test_known_hosts(hass: HomeAssistant, castbrowser_mock) -> None: "cast", context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"known_hosts": "192.168.0.1, 192.168.0.2"} + result["flow_id"], {"known_hosts": ["192.168.0.1", "192.168.0.2"]} ) assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done(wait_background_tasks=True) @@ -290,7 +286,7 @@ async def test_known_hosts(hass: HomeAssistant, castbrowser_mock) -> None: result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"known_hosts": "192.168.0.11, 192.168.0.12"}, + user_input={"known_hosts": ["192.168.0.11", "192.168.0.12"]}, ) await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index b2ce60e9393..386b9270571 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -1037,6 +1037,7 @@ async def test_entity_browse_media( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -1049,6 +1050,7 @@ async def test_entity_browse_media( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -1107,6 +1109,7 @@ async def test_entity_browse_media_audio_only( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -1909,6 +1912,7 @@ async def test_group_media_control( ) +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_on_idle( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1939,6 +1943,7 @@ async def test_failed_cast_on_idle( assert "Failed to cast media http://example.com:8123/tts.mp3." in caplog.text +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_other_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1963,6 +1968,7 @@ async def test_failed_cast_other_url( assert "Failed to cast media http://example.com:8123/tts.mp3." in caplog.text +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_internal_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1992,6 +1998,7 @@ async def test_failed_cast_internal_url( ) +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_external_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -2204,6 +2211,7 @@ async def test_cast_platform_browse_media( "media_content_id": "", "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": "https://brands.home-assistant.io/_/spotify/logo.png", "children_media_class": None, } @@ -2228,6 +2236,7 @@ async def test_cast_platform_browse_media( "media_content_id": "", "can_play": True, "can_expand": False, + "can_search": False, "children_media_class": None, "thumbnail": None, "children": [], diff --git a/tests/components/ccm15/test_diagnostics.py b/tests/components/ccm15/test_diagnostics.py index f6f0d75c4e3..ae876694c0c 100644 --- a/tests/components/ccm15/test_diagnostics.py +++ b/tests/components/ccm15/test_diagnostics.py @@ -1,7 +1,7 @@ """Test CCM15 diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ccm15.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 65d607e618b..4ce06199eb8 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -14,7 +14,6 @@ from homeassistant.components.climate import ( HVACMode, intent as climate_intent, ) -from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant @@ -131,335 +130,6 @@ class MockClimateEntityNoSetTemperature(ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] -async def test_get_temperature( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test HassClimateGetTemperature intent.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - climate_1 = MockClimateEntity() - climate_1._attr_name = "Climate 1" - climate_1._attr_unique_id = "1234" - climate_1._attr_current_temperature = 10.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "1234", suggested_object_id="climate_1" - ) - - climate_2 = MockClimateEntity() - climate_2._attr_name = "Climate 2" - climate_2._attr_unique_id = "5678" - climate_2._attr_current_temperature = 22.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "5678", suggested_object_id="climate_2" - ) - - await create_mock_platform(hass, [climate_1, climate_2]) - - # Add climate entities to different areas: - # climate_1 => living room - # climate_2 => bedroom - # nothing in office - living_room_area = area_registry.async_create(name="Living Room") - bedroom_area = area_registry.async_create(name="Bedroom") - office_area = area_registry.async_create(name="Office") - - entity_registry.async_update_entity( - climate_1.entity_id, area_id=living_room_area.id - ) - entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) - - # First climate entity will be selected (no area) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert response.matched_states - assert response.matched_states[0].entity_id == climate_1.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 10.0 - - # Select by area (climate_2) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 - - # Select by name (climate_2) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 2"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 - - # Check area with no climate entities - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": office_area.name}}, - assistant=conversation.DOMAIN, - ) - - # Exception should contain details of what we tried to match - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name is None - assert constraints.area_name == office_area.name - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - # Check wrong name - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Does not exist"}}, - ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME - constraints = error.value.constraints - assert constraints.name == "Does not exist" - assert constraints.area_name is None - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - # Check wrong name with area - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, - ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name == "Climate 1" - assert constraints.area_name == bedroom_area.name - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - -async def test_get_temperature_no_entities( - hass: HomeAssistant, -) -> None: - """Test HassClimateGetTemperature intent with no climate entities.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - await create_mock_platform(hass, []) - - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN - - -async def test_not_exposed( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test HassClimateGetTemperature intent when entities aren't exposed.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - climate_1 = MockClimateEntity() - climate_1._attr_name = "Climate 1" - climate_1._attr_unique_id = "1234" - climate_1._attr_current_temperature = 10.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "1234", suggested_object_id="climate_1" - ) - - climate_2 = MockClimateEntity() - climate_2._attr_name = "Climate 2" - climate_2._attr_unique_id = "5678" - climate_2._attr_current_temperature = 22.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "5678", suggested_object_id="climate_2" - ) - - await create_mock_platform(hass, [climate_1, climate_2]) - - # Add climate entities to same area - living_room_area = area_registry.async_create(name="Living Room") - bedroom_area = area_registry.async_create(name="Bedroom") - entity_registry.async_update_entity( - climate_1.entity_id, area_id=living_room_area.id - ) - entity_registry.async_update_entity( - climate_2.entity_id, area_id=living_room_area.id - ) - - # Should fail with empty name - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - # Should fail with empty area - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - # Expose second, hide first - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the area should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the exposed entity should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_2.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the *unexposed* entity should fail - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_1.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Expose first, hide second - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_1.entity_id - - # Wrong area name - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA - - # Neither are exposed - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with area - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with both names - for name in (climate_1.name, climate_2.name): - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - async def test_set_temperature( hass: HomeAssistant, area_registry: ar.AreaRegistry, diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 2d594fd9345..0e118f251de 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -218,9 +218,9 @@ def mock_user_data() -> Generator[MagicMock]: @pytest.fixture -def mock_cloud_fixture(hass: HomeAssistant) -> CloudPreferences: +async def mock_cloud_fixture(hass: HomeAssistant) -> CloudPreferences: """Fixture for cloud component.""" - hass.loop.run_until_complete(mock_cloud(hass)) + await mock_cloud(hass) return mock_cloud_prefs(hass, {}) diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 5220d3eccd5..8399e69ab09 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -5,9 +5,9 @@ from io import StringIO from typing import Any from unittest.mock import ANY, Mock, PropertyMock, patch -from aiohttp import ClientError +from aiohttp import ClientError, ClientResponseError from hass_nabucasa import CloudError -from hass_nabucasa.api import CloudApiNonRetryableError +from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError from hass_nabucasa.files import FilesError, StorageType import pytest @@ -208,7 +208,7 @@ async def test_agents_list_backups_fail_cloud( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -547,6 +547,120 @@ async def test_agents_upload_not_protected( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_not_subscribed( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + cloud: Mock, +) -> None: + """Test upload backup when cloud user is not subscribed.""" + cloud.subscription_expired = True + client = await hass_client() + backup_data = "test" + 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=len(backup_data), + ) + + 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=[backup_data.encode(), b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO(backup_data)}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert cloud.files.upload.call_count == 0 + 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_upload_not_subscribed_midway( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + cloud: Mock, +) -> None: + """Test upload backup when cloud subscription expires during the call.""" + client = await hass_client() + backup_data = "test" + 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=len(backup_data), + ) + + async def mock_upload(*args: Any, **kwargs: Any) -> None: + """Mock file upload.""" + cloud.subscription_expired = True + raise CloudApiError( + "Boom!", orig_exc=ClientResponseError(Mock(), Mock(), status=403) + ) + + cloud.files.upload.side_effect = mock_upload + + 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=[backup_data.encode(), b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO(backup_data)}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert cloud.files.upload.call_count == 1 + 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_upload_wrong_size( hass: HomeAssistant, diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 52457fe558c..283e2ff39f1 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -468,7 +468,10 @@ async def test_async_create_repair_issue_known( await cloud.client.async_create_repair_issue( identifier=identifier, translation_key=translation_key, - placeholders={"custom_domains": "example.com"}, + placeholders={ + "account_url": "http://example.org", + "custom_domains": "example.com", + }, severity="warning", ) issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) @@ -479,19 +482,53 @@ async def test_async_create_repair_issue_unknown( cloud: MagicMock, mock_cloud_setup: None, issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test not creating repair issue for unknown repairs.""" identifier = "abc123" - with pytest.raises( - ValueError, - match="Invalid translation key unknown_translation_key", - ): - await cloud.client.async_create_repair_issue( - identifier=identifier, - translation_key="unknown_translation_key", - placeholders={"custom_domains": "example.com"}, - severity="error", - ) + await cloud.client.async_create_repair_issue( + identifier=identifier, + translation_key="unknown_translation_key", + placeholders={"custom_domains": "example.com"}, + severity="error", + ) + assert ( + "Invalid translation key unknown_translation_key for repair issue abc123" + in caplog.text + ) + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) + assert issue is None + + +async def test_async_delete_repair_issue( + cloud: MagicMock, + mock_cloud_setup: None, + issue_registry: ir.IssueRegistry, +) -> None: + """Test delete repair issue.""" + identifier = "test_identifier" + issue_registry.issues[(DOMAIN, identifier)] = ir.IssueEntry( + active=True, + breaks_in_ha_version=None, + created=dt_util.utcnow(), + data={}, + dismissed_version=None, + domain=DOMAIN, + is_fixable=False, + is_persistent=True, + issue_domain=None, + issue_id=identifier, + learn_more_url=None, + severity="warning", + translation_key="test_translation_key", + translation_placeholders=None, + ) + + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) + assert issue is not None + + await cloud.client.async_delete_repair_issue(identifier=identifier) + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) assert issue is None diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 81e8554ebf2..b5cce286ba2 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -4,14 +4,13 @@ from collections.abc import Callable, Coroutine from copy import deepcopy import datetime from http import HTTPStatus -import json import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp from freezegun.api import FrozenDateTimeFactory -from hass_nabucasa import AlreadyConnectedError, thingtalk +from hass_nabucasa import AlreadyConnectedError from hass_nabucasa.auth import ( InvalidTotpCode, MFARequired, @@ -20,7 +19,6 @@ from hass_nabucasa.auth import ( ) from hass_nabucasa.const import STATE_CONNECTED from hass_nabucasa.remote import CertificateStatus -from hass_nabucasa.voice import TTS_VOICES import pytest from syrupy.assertion import SnapshotAssertion @@ -31,6 +29,7 @@ from homeassistant.components.alexa import errors as alexa_errors from homeassistant.components.alexa.entities import LightCapabilities from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY from homeassistant.components.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN +from homeassistant.components.cloud.http_api import validate_language_voice from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.websocket_api import ERR_INVALID_FORMAT @@ -1746,70 +1745,6 @@ async def test_enable_alexa_state_report_fail( assert response["error"]["code"] == "alexa_relink" -async def test_thingtalk_convert( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - setup_cloud: None, -) -> None: - """Test that we can convert a query.""" - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.cloud.http_api.thingtalk.async_convert", - return_value={"hello": "world"}, - ): - await client.send_json( - {"id": 5, "type": "cloud/thingtalk/convert", "query": "some-data"} - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == {"hello": "world"} - - -async def test_thingtalk_convert_timeout( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - setup_cloud: None, -) -> None: - """Test that we can convert a query.""" - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.cloud.http_api.thingtalk.async_convert", - side_effect=TimeoutError, - ): - await client.send_json( - {"id": 5, "type": "cloud/thingtalk/convert", "query": "some-data"} - ) - response = await client.receive_json() - - assert not response["success"] - assert response["error"]["code"] == "timeout" - - -async def test_thingtalk_convert_internal( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - setup_cloud: None, -) -> None: - """Test that we can convert a query.""" - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.cloud.http_api.thingtalk.async_convert", - side_effect=thingtalk.ThingTalkConversionError("Did not understand"), - ): - await client.send_json( - {"id": 5, "type": "cloud/thingtalk/convert", "query": "some-data"} - ) - response = await client.receive_json() - - assert not response["success"] - assert response["error"]["code"] == "unknown_error" - assert response["error"]["message"] == "Did not understand" - - async def test_tts_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1822,17 +1757,14 @@ async def test_tts_info( response = await client.receive_json() assert response["success"] - assert response["result"] == { - "languages": json.loads( - json.dumps( - [ - (language, voice) - for language, voices in TTS_VOICES.items() - for voice in voices - ] - ) - ) - } + assert "languages" in response["result"] + assert all(len(lang) for lang in response["result"]["languages"]) + assert len(response["result"]["languages"]) > 300 + assert ( + len([lang for lang in response["result"]["languages"] if "||" in lang[1]]) > 100 + ) + for lang in response["result"]["languages"]: + assert validate_language_voice(lang[:2]) @pytest.mark.parametrize( diff --git a/tests/components/cloud/test_onboarding.py b/tests/components/cloud/test_onboarding.py new file mode 100644 index 00000000000..142cd90a59c --- /dev/null +++ b/tests/components/cloud/test_onboarding.py @@ -0,0 +1,165 @@ +"""Test the onboarding views.""" + +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components import onboarding +from homeassistant.components.cloud import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import register_auth_provider +from tests.typing import ClientSessionGenerator + + +def mock_onboarding_storage(hass_storage, data): + """Mock the onboarding storage.""" + hass_storage[onboarding.STORAGE_KEY] = { + "version": onboarding.STORAGE_VERSION, + "data": data, + } + + +@pytest.fixture(autouse=True) +async def auth_active(hass: HomeAssistant) -> None: + """Ensure auth is always active.""" + await register_auth_provider(hass, {"type": "homeassistant"}) + + +@pytest.fixture(name="setup_cloud", autouse=True) +async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: + """Fixture that sets up cloud.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ( + "post", + "cloud/forgot_password", + {"json": {"email": "hello@bla.com"}}, + ), + ( + "post", + "cloud/login", + {"json": {"email": "my_username", "password": "my_password"}}, + ), + ("post", "cloud/logout", {}), + ("get", "cloud/status", {}), + ], +) +async def test_onboarding_view_after_done( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test raising after onboarding.""" + mock_onboarding_storage(hass_storage, {"done": [onboarding.const.STEP_USER]}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 401 + + +async def test_onboarding_cloud_forgot_password( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test cloud forgot password.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + mock_cognito = cloud.auth + + req = await client.post( + "/api/onboarding/cloud/forgot_password", json={"email": "hello@bla.com"} + ) + + assert req.status == HTTPStatus.OK + assert mock_cognito.async_forgot_password.call_count == 1 + + +async def test_onboarding_cloud_login( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post( + "/api/onboarding/cloud/login", + json={"email": "my_username", "password": "my_password"}, + ) + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"cloud_pipeline": None, "success": True} + assert cloud.login.call_count == 1 + + +async def test_onboarding_cloud_logout( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post("/api/onboarding/cloud/logout") + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"message": "ok"} + assert cloud.logout.call_count == 1 + + +async def test_onboarding_cloud_status( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.get("/api/onboarding/cloud/status") + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"logged_in": False} diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index 81b10866dff..c920fdac264 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -6,7 +6,8 @@ from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from hass_nabucasa.voice import TTS_VOICES, VoiceError, VoiceTokenError +from hass_nabucasa.voice import VoiceError, VoiceTokenError +from hass_nabucasa.voice_data import TTS_VOICES import pytest import voluptuous as vol @@ -203,7 +204,7 @@ async def test_provider_properties( assert "nl-NL" in engine.supported_languages supported_voices = engine.async_get_supported_voices("nl-NL") assert supported_voices is not None - assert Voice("ColetteNeural", "ColetteNeural") in supported_voices + assert Voice("ColetteNeural", "Colette") in supported_voices supported_voices = engine.async_get_supported_voices("missing_language") assert supported_voices is None diff --git a/tests/components/co2signal/test_diagnostics.py b/tests/components/co2signal/test_diagnostics.py index 3d5e1a0580b..3ede845f01f 100644 --- a/tests/components/co2signal/test_diagnostics.py +++ b/tests/components/co2signal/test_diagnostics.py @@ -1,7 +1,7 @@ """Test the CO2Signal diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/co2signal/test_sensor.py b/tests/components/co2signal/test_sensor.py index fddda17f3ed..2154782f62d 100644 --- a/tests/components/co2signal/test_sensor.py +++ b/tests/components/co2signal/test_sensor.py @@ -11,7 +11,7 @@ from aioelectricitymaps import ( ) from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/coinbase/test_diagnostics.py b/tests/components/coinbase/test_diagnostics.py index 0e06c172c37..98936f47e48 100644 --- a/tests/components/coinbase/test_diagnostics.py +++ b/tests/components/coinbase/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/comelit/__init__.py b/tests/components/comelit/__init__.py index 916a684de4b..6475f500f01 100644 --- a/tests/components/comelit/__init__.py +++ b/tests/components/comelit/__init__.py @@ -1 +1,13 @@ """Tests for the Comelit SimpleHome 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/comelit/conftest.py b/tests/components/comelit/conftest.py new file mode 100644 index 00000000000..1e5e85cd26e --- /dev/null +++ b/tests/components/comelit/conftest.py @@ -0,0 +1,108 @@ +"""Configure tests for Comelit SimpleHome.""" + +from copy import deepcopy + +import pytest + +from homeassistant.components.comelit.const import ( + BRIDGE, + DOMAIN as COMELIT_DOMAIN, + VEDO, +) +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE + +from .const import ( + BRIDGE_DEVICE_QUERY, + BRIDGE_HOST, + BRIDGE_PIN, + BRIDGE_PORT, + VEDO_DEVICE_QUERY, + VEDO_HOST, + VEDO_PIN, + VEDO_PORT, +) + +from tests.common import AsyncMock, Generator, MockConfigEntry, patch + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.comelit.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_serial_bridge() -> Generator[AsyncMock]: + """Mock a Comelit serial bridge.""" + with ( + patch( + "homeassistant.components.comelit.coordinator.ComeliteSerialBridgeApi", + autospec=True, + ) as mock_comelit_serial_bridge, + patch( + "homeassistant.components.comelit.config_flow.ComeliteSerialBridgeApi", + new=mock_comelit_serial_bridge, + ), + ): + bridge = mock_comelit_serial_bridge.return_value + bridge.get_all_devices.return_value = deepcopy(BRIDGE_DEVICE_QUERY) + bridge.host = BRIDGE_HOST + bridge.port = BRIDGE_PORT + bridge.device_pin = BRIDGE_PIN + yield bridge + + +@pytest.fixture +def mock_serial_bridge_config_entry() -> Generator[MockConfigEntry]: + """Mock a Comelit config entry for Comelit bridge.""" + return MockConfigEntry( + domain=COMELIT_DOMAIN, + data={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + }, + entry_id="serial_bridge_config_entry_id", + ) + + +@pytest.fixture +def mock_vedo() -> Generator[AsyncMock]: + """Mock a Comelit vedo.""" + with ( + patch( + "homeassistant.components.comelit.coordinator.ComelitVedoApi", + autospec=True, + ) as mock_comelit_vedo, + patch( + "homeassistant.components.comelit.config_flow.ComelitVedoApi", + new=mock_comelit_vedo, + ), + ): + vedo = mock_comelit_vedo.return_value + vedo.get_all_areas_and_zones.return_value = deepcopy(VEDO_DEVICE_QUERY) + vedo.host = VEDO_HOST + vedo.port = VEDO_PORT + vedo.device_pin = VEDO_PIN + vedo.type = VEDO + yield vedo + + +@pytest.fixture +def mock_vedo_config_entry() -> Generator[MockConfigEntry]: + """Mock a Comelit config entry for Comelit vedo.""" + return MockConfigEntry( + domain=COMELIT_DOMAIN, + data={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + entry_id="vedo_config_entry_id", + ) diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 92fdfebfa1d..0cbdaf56bbe 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,7 +1,11 @@ """Common stuff for Comelit SimpleHome tests.""" -from aiocomelit import ComelitVedoAreaObject, ComelitVedoZoneObject -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.api import ( + AlarmDataObject, + ComelitSerialBridgeObject, + ComelitVedoAreaObject, + ComelitVedoZoneObject, +) from aiocomelit.const import ( CLIMATE, COVER, @@ -9,46 +13,46 @@ from aiocomelit.const import ( LIGHT, OTHER, SCENARIO, - VEDO, WATT, AlarmAreaState, AlarmZoneState, ) -from homeassistant.components.comelit.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE +BRIDGE_HOST = "fake_bridge_host" +BRIDGE_PORT = 80 +BRIDGE_PIN = 1234 -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_PORT: 80, - CONF_PIN: 1234, - }, - { - CONF_HOST: "fake_vedo_host", - CONF_PORT: 8080, - CONF_PIN: 1234, - CONF_TYPE: VEDO, - }, - ] - } -} +VEDO_HOST = "fake_vedo_host" +VEDO_PORT = 8080 +VEDO_PIN = 5678 -MOCK_USER_BRIDGE_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] -MOCK_USER_VEDO_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][1] - -FAKE_PIN = 5678 +FAKE_PIN = 0000 BRIDGE_DEVICE_QUERY = { - CLIMATE: {}, + CLIMATE: { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=[ + [221, 0, "U", "M", 50, 0, 0, "U"], + [650, 0, "U", "M", 500, 0, 0, "U"], + [0, 0], + ], + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + }, COVER: { 0: ComelitSerialBridgeObject( index=0, name="Cover0", status=0, - human_status="closed", + human_status="stopped", type="cover", val=0, protected=0, @@ -71,30 +75,43 @@ BRIDGE_DEVICE_QUERY = { power_unit=WATT, ) }, - OTHER: {}, + OTHER: { + 0: ComelitSerialBridgeObject( + index=0, + name="Switch0", + status=0, + human_status="off", + type="other", + val=0, + protected=0, + zone="Bathroom", + power=0.0, + power_unit=WATT, + ), + }, IRRIGATION: {}, SCENARIO: {}, } -VEDO_DEVICE_QUERY = { - "aree": { +VEDO_DEVICE_QUERY = AlarmDataObject( + alarm_areas={ 0: ComelitVedoAreaObject( index=0, name="Area0", p1=True, - p2=False, + p2=True, ready=False, - armed=False, + armed=0, alarm=False, alarm_memory=False, sabotage=False, anomaly=False, in_time=False, out_time=False, - human_status=AlarmAreaState.UNKNOWN, + human_status=AlarmAreaState.DISARMED, ) }, - "zone": { + alarm_zones={ 0: ComelitVedoZoneObject( index=0, name="Zone0", @@ -103,4 +120,4 @@ VEDO_DEVICE_QUERY = { human_status=AlarmZoneState.REST, ) }, -} +) diff --git a/tests/components/comelit/snapshots/test_climate.ambr b/tests/components/comelit/snapshots/test_climate.ambr new file mode 100644 index 00000000000..0233359bc45 --- /dev/null +++ b/tests/components/comelit/snapshots/test_climate.ambr @@ -0,0 +1,71 @@ +# serializer version: 1 +# name: test_all_entities[climate.climate0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 5, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.climate0', + 'has_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': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[climate.climate0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.1, + 'friendly_name': 'Climate0', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 5, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 5.0, + }), + 'context': , + 'entity_id': 'climate.climate0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_cover.ambr b/tests/components/comelit/snapshots/test_cover.ambr new file mode 100644 index 00000000000..17189344cd1 --- /dev/null +++ b/tests/components/comelit/snapshots/test_cover.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[cover.cover0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.cover0', + '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': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[cover.cover0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'shutter', + 'friendly_name': 'Cover0', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 877f48a4611..c9ebf635353 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -5,13 +5,50 @@ 'devices': list([ dict({ 'clima': list([ + dict({ + '0': dict({ + 'human_status': 'off', + 'name': 'Climate0', + 'power': 0.0, + 'power_unit': 'W', + 'protected': 0, + 'status': 0, + 'val': list([ + list([ + 221, + 0, + 'U', + 'M', + 50, + 0, + 0, + 'U', + ]), + list([ + 650, + 0, + 'U', + 'M', + 500, + 0, + 0, + 'U', + ]), + list([ + 0, + 0, + ]), + ]), + 'zone': 'Living room', + }), + }), ]), }), dict({ 'shutter': list([ dict({ '0': dict({ - 'human_status': 'closed', + 'human_status': 'stopped', 'name': 'Cover0', 'power': 0.0, 'power_unit': 'W', @@ -41,6 +78,18 @@ }), dict({ 'other': list([ + dict({ + '0': dict({ + 'human_status': 'off', + 'name': 'Switch0', + 'power': 0.0, + 'power_unit': 'W', + 'protected': 0, + 'status': 0, + 'val': 0, + 'zone': 'Bathroom', + }), + }), ]), }), dict({ @@ -57,9 +106,10 @@ }), 'entry': dict({ 'data': dict({ - 'host': 'fake_host', + 'host': 'fake_bridge_host', 'pin': '**REDACTED**', 'port': 80, + 'type': 'Serial bridge', }), 'disabled_by': None, 'discovery_keys': dict({ @@ -85,19 +135,19 @@ 'device_info': dict({ 'devices': list([ dict({ - 'aree': list([ + 'alarm_areas': list([ dict({ '0': dict({ 'alarm': False, 'alarm_memory': False, 'anomaly': False, - 'armed': False, - 'human_status': 'unknown', + 'armed': 0, + 'human_status': 'disarmed', 'in_time': False, 'name': 'Area0', 'out_time': False, 'p1': True, - 'p2': False, + 'p2': True, 'ready': False, 'sabotage': False, }), @@ -105,7 +155,7 @@ ]), }), dict({ - 'zone': list([ + 'alarm_zones': list([ dict({ '0': dict({ 'human_status': 'rest', diff --git a/tests/components/comelit/snapshots/test_humidifier.ambr b/tests/components/comelit/snapshots/test_humidifier.ambr new file mode 100644 index 00000000000..ffe53d09c5d --- /dev/null +++ b/tests/components/comelit/snapshots/test_humidifier.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_all_entities[humidifier.climate0_dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 90, + 'min_humidity': 10, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.climate0_dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dehumidifier', + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'dehumidifier', + 'unique_id': 'serial_bridge_config_entry_id-0-dehumidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[humidifier.climate0_dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'action': , + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 65.0, + 'device_class': 'dehumidifier', + 'friendly_name': 'Climate0 Dehumidifier', + 'humidity': 50.0, + 'max_humidity': 90, + 'min_humidity': 10, + 'mode': 'normal', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.climate0_dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[humidifier.climate0_humidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 90, + 'min_humidity': 10, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.climate0_humidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier', + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'humidifier', + 'unique_id': 'serial_bridge_config_entry_id-0-humidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[humidifier.climate0_humidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'action': , + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 65.0, + 'device_class': 'humidifier', + 'friendly_name': 'Climate0 Humidifier', + 'humidity': 50.0, + 'max_humidity': 90, + 'min_humidity': 10, + 'mode': 'normal', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.climate0_humidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_light.ambr b/tests/components/comelit/snapshots/test_light.ambr new file mode 100644 index 00000000000..c60c962e23d --- /dev/null +++ b/tests/components/comelit/snapshots/test_light.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_all_entities[light.light0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.light0', + 'has_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': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.light0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Light0', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_sensor.ambr b/tests/components/comelit/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..dabae2a1bf0 --- /dev/null +++ b/tests/components/comelit/snapshots/test_sensor.ambr @@ -0,0 +1,76 @@ +# serializer version: 1 +# name: test_all_entities[sensor.zone0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'alarm', + 'armed', + 'open', + 'excluded', + 'faulty', + 'inhibited', + 'isolated', + 'rest', + 'sabotated', + 'unavailable', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zone0', + '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': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'zone_status', + 'unique_id': 'vedo_config_entry_id-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zone0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Zone0', + 'options': list([ + 'alarm', + 'armed', + 'open', + 'excluded', + 'faulty', + 'inhibited', + 'isolated', + 'rest', + 'sabotated', + 'unavailable', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.zone0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rest', + }) +# --- diff --git a/tests/components/comelit/snapshots/test_switch.ambr b/tests/components/comelit/snapshots/test_switch.ambr new file mode 100644 index 00000000000..eddecfabb7a --- /dev/null +++ b/tests/components/comelit/snapshots/test_switch.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_all_entities[switch.switch0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.switch0', + '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': 'comelit', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'serial_bridge_config_entry_id-other-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.switch0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Switch0', + }), + 'context': , + 'entity_id': 'switch.switch0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/comelit/test_alarm_control_panel.py b/tests/components/comelit/test_alarm_control_panel.py new file mode 100644 index 00000000000..d3feac6ad3b --- /dev/null +++ b/tests/components/comelit/test_alarm_control_panel.py @@ -0,0 +1,155 @@ +"""Tests for Comelit SimpleHome alarm control panel platform.""" + +from unittest.mock import AsyncMock + +from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject +from aiocomelit.const import AlarmAreaState, AlarmZoneState +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.alarm_control_panel import ( + ATTR_CODE, + DOMAIN as ALARM_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + AlarmControlPanelState, +) +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .const import VEDO_PIN + +from tests.common import MockConfigEntry, async_fire_time_changed + +ENTITY_ID = "alarm_control_panel.area0" + + +@pytest.mark.parametrize( + ("human_status", "armed", "alarm_state"), + [ + (AlarmAreaState.DISARMED, 0, AlarmControlPanelState.DISARMED), + (AlarmAreaState.ARMED, 1, AlarmControlPanelState.ARMED_HOME), + (AlarmAreaState.ARMED, 2, AlarmControlPanelState.ARMED_HOME), + (AlarmAreaState.ARMED, 3, AlarmControlPanelState.ARMED_NIGHT), + (AlarmAreaState.ARMED, 4, AlarmControlPanelState.ARMED_AWAY), + (AlarmAreaState.UNKNOWN, 0, STATE_UNAVAILABLE), + ], +) +async def test_entity_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + human_status: AlarmAreaState, + armed: int, + alarm_state: AlarmControlPanelState, +) -> None: + """Test all entities.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED + + vedo_query = AlarmDataObject( + alarm_areas={ + 0: ComelitVedoAreaObject( + index=0, + name="Area0", + p1=True, + p2=True, + ready=False, + armed=armed, + alarm=False, + alarm_memory=False, + sabotage=False, + anomaly=False, + in_time=False, + out_time=False, + human_status=human_status, + ) + }, + alarm_zones={ + 0: ComelitVedoZoneObject( + index=0, + name="Zone0", + status_api="0x000", + status=0, + human_status=AlarmZoneState.REST, + ) + }, + ) + + mock_vedo.get_all_areas_and_zones.return_value = vedo_query + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == alarm_state + + +@pytest.mark.parametrize( + ("service", "alarm_state"), + [ + (SERVICE_ALARM_DISARM, AlarmControlPanelState.DISARMED), + (SERVICE_ALARM_ARM_AWAY, AlarmControlPanelState.ARMED_AWAY), + (SERVICE_ALARM_ARM_HOME, AlarmControlPanelState.ARMED_HOME), + (SERVICE_ALARM_ARM_NIGHT, AlarmControlPanelState.ARMED_NIGHT), + ], +) +async def test_arming_disarming( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + service: str, + alarm_state: AlarmControlPanelState, +) -> None: + """Test arming and disarming.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED + + await hass.services.async_call( + ALARM_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_CODE: VEDO_PIN}, + blocking=True, + ) + + mock_vedo.set_zone_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == alarm_state + + +async def test_wrong_code( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test disarm service with wrong code.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED + + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_CODE: 1111}, + blocking=True, + ) + + mock_vedo.set_zone_status.assert_not_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmControlPanelState.DISARMED diff --git a/tests/components/comelit/test_climate.py b/tests/components/comelit/test_climate.py new file mode 100644 index 00000000000..3337ba28769 --- /dev/null +++ b/tests/components/comelit/test_climate.py @@ -0,0 +1,282 @@ +"""Tests for Comelit SimpleHome climate platform.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE, WATT +from freezegun.api import FrozenDateTimeFactory +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.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, 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 + +ENTITY_ID = "climate.climate0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("val", "mode", "temp"), + [ + ( + [ + [100, 0, "U", "M", 210, 0, 0, "U"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.HEAT, + 21.0, + ), + ( + [ + [100, 1, "U", "A", 210, 1, 0, "O"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.HEAT, + 21.0, + ), + ( + [ + [100, 0, "O", "A", 210, 0, 0, "O"], + [650, 0, "O", "M", 500, 0, 0, "N"], + [0, 0], + ], + HVACMode.OFF, + 21.0, + ), + ], +) +async def test_climate_data_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + val: list[list[Any]], + mode: HVACMode, + temp: float, +) -> None: + """Test climate data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=val, + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == mode + assert state.attributes[ATTR_TEMPERATURE] == temp + + +async def test_climate_data_update_bad_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val="bad_data", + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + +async def test_climate_set_temperature( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate set temperature service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 23}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 23.0 + + +async def test_climate_set_temperature_when_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate set temperature service when off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + # Switch climate off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + # Test set temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 23}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + +async def test_climate_hvac_mode( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate hvac mode service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + +async def test_climate_hvac_mode_when_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test climate hvac mode service when off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 5.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.OFF + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.AUTO}, + blocking=True, + ) + mock_serial_bridge.set_clima_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == HVACMode.AUTO diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index eeaea0e41e9..dd1d1fb3836 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -1,59 +1,93 @@ """Tests for Comelit SimpleHome config flow.""" -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock from aiocomelit import CannotAuthenticate, CannotConnect +from aiocomelit.const import BRIDGE, VEDO import pytest from homeassistant.components.comelit.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import FAKE_PIN, MOCK_USER_BRIDGE_DATA, MOCK_USER_VEDO_DATA +from .const import ( + BRIDGE_HOST, + BRIDGE_PIN, + BRIDGE_PORT, + FAKE_PIN, + VEDO_HOST, + VEDO_PIN, + VEDO_PORT, +) from tests.common import MockConfigEntry -@pytest.mark.parametrize( - ("class_api", "user_input"), - [ - ("ComeliteSerialBridgeApi", MOCK_USER_BRIDGE_DATA), - ("ComelitVedoApi", MOCK_USER_VEDO_DATA), - ], -) -async def test_full_flow( - hass: HomeAssistant, class_api: str, user_input: dict[str, Any] +async def test_flow_serial_bridge( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, ) -> None: """Test starting a flow by user.""" - with ( - patch( - f"aiocomelit.api.{class_api}.login", - ), - patch( - f"aiocomelit.api.{class_api}.logout", - ), - patch("homeassistant.components.comelit.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_configure( - result["flow_id"], user_input=user_input - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == user_input[CONF_HOST] - assert result["data"][CONF_PORT] == user_input[CONF_PORT] - assert result["data"][CONF_PIN] == user_input[CONF_PIN] - assert not result["result"].unique_id - await hass.async_block_till_done() + 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 mock_setup_entry.called + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + } + assert not result["result"].unique_id + await hass.async_block_till_done() + + +async def test_flow_vedo( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test starting a flow by 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={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + } + assert not result["result"].unique_id + await hass.async_block_till_done() @pytest.mark.parametrize( @@ -64,7 +98,13 @@ async def test_full_flow( (ConnectionResetError, "unknown"), ], ) -async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: +async def test_exception_connection( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + side_effect, + error, +) -> None: """Test starting a flow by user with a connection error.""" result = await hass.config_entries.flow.async_init( @@ -73,59 +113,65 @@ 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( - "aiocomelit.api.ComeliteSerialBridgeApi.login", - side_effect=side_effect, - ), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch( - "homeassistant.components.comelit.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_BRIDGE_DATA - ) + mock_vedo.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: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_vedo.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == VEDO_HOST + assert result["data"] == { + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + } -async def test_reauth_successful(hass: HomeAssistant) -> None: +async def test_reauth_successful( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: """Test starting a reauthentication flow.""" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_vedo_config_entry.add_to_hass(hass) + result = await mock_vedo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", - ), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry"), - patch("requests.get") as mock_request_get, - ): - mock_request_get.return_value.status_code = 200 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: FAKE_PIN, + }, + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PIN: FAKE_PIN, - }, - ) - await hass.async_block_till_done() - - 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( @@ -136,30 +182,40 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: (ConnectionResetError, "unknown"), ], ) -async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: +async def test_reauth_not_successful( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_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_BRIDGE_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_vedo_config_entry.add_to_hass(hass) + result = await mock_vedo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch("aiocomelit.api.ComeliteSerialBridgeApi.login", side_effect=side_effect), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PIN: FAKE_PIN, - }, - ) + mock_vedo.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: FAKE_PIN, + }, + ) - 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} + + mock_vedo.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: VEDO_PIN, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_vedo_config_entry.data[CONF_PIN] == VEDO_PIN diff --git a/tests/components/comelit/test_coordinator.py b/tests/components/comelit/test_coordinator.py new file mode 100644 index 00000000000..49e3164e875 --- /dev/null +++ b/tests/components/comelit/test_coordinator.py @@ -0,0 +1,49 @@ +"""Tests for Comelit SimpleHome coordinator.""" + +from unittest.mock import AsyncMock + +from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.parametrize( + "side_effect", + [ + CannotConnect, + CannotRetrieveData, + CannotAuthenticate, + ], +) +async def test_coordinator_data_update_fails( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test coordinator data update exceptions.""" + + entity_id = "light.light0" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + mock_serial_bridge.login.side_effect = side_effect + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py new file mode 100644 index 00000000000..5513f3c4e25 --- /dev/null +++ b/tests/components/comelit/test_cover.py @@ -0,0 +1,195 @@ +"""Tests for Comelit SimpleHome cover platform.""" + +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import COVER, WATT +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.const import ATTR_ENTITY_ID, 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 + +ENTITY_ID = "cover.cover0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.COVER]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +async def test_cover_open( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover open service.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Open cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OPENING + + # Finish opening, update status + mock_serial_bridge.get_all_devices.return_value[COVER] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Cover0", + status=0, + human_status="stopped", + type="cover", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OPEN + + +async def test_cover_close( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover close and stop service.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Close cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_CLOSING + + # Stop cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_CLOSED + + +async def test_cover_stop_if_stopped( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover stop service when already stopped.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Stop cover while not opening/closing + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_not_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + +async def test_cover_restore_state( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover restore state on reload.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN + + # Open cover + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OPENING + + await hass.config_entries.async_reload(mock_serial_bridge_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OPENING diff --git a/tests/components/comelit/test_diagnostics.py b/tests/components/comelit/test_diagnostics.py index 39d75af1152..8743c5b4b64 100644 --- a/tests/components/comelit/test_diagnostics.py +++ b/tests/components/comelit/test_diagnostics.py @@ -2,21 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.comelit.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .const import ( - BRIDGE_DEVICE_QUERY, - MOCK_USER_BRIDGE_DATA, - MOCK_USER_VEDO_DATA, - VEDO_DEVICE_QUERY, -) +from . import setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -25,25 +18,17 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics_bridge( hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test Bridge config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_serial_bridge_config_entry) - with ( - patch("aiocomelit.api.ComeliteSerialBridgeApi.login"), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.get_all_devices", - return_value=BRIDGE_DEVICE_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_serial_bridge_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", @@ -54,25 +39,17 @@ async def test_entry_diagnostics_bridge( async def test_entry_diagnostics_vedo( hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test Vedo System config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_VEDO_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_vedo_config_entry) - with ( - patch("aiocomelit.api.ComelitVedoApi.login"), - patch( - "aiocomelit.api.ComelitVedoApi.get_all_areas_and_zones", - return_value=VEDO_DEVICE_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_vedo_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", diff --git a/tests/components/comelit/test_humidifier.py b/tests/components/comelit/test_humidifier.py new file mode 100644 index 00000000000..a096a1c0eb4 --- /dev/null +++ b/tests/components/comelit/test_humidifier.py @@ -0,0 +1,292 @@ +"""Tests for Comelit SimpleHome humidifier platform.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE, WATT +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.comelit.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + MODE_AUTO, + MODE_NORMAL, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + 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.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "humidifier.climate0_humidifier" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.HUMIDIFIER] + ): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("val", "mode", "humidity"), + [ + ( + [ + [100, 0, "U", "M", 210, 0, 0, "U"], + [650, 0, "U", "M", 500, 0, 0, "U"], + [0, 0], + ], + STATE_ON, + 50.0, + ), + ( + [ + [100, 1, "U", "A", 210, 1, 0, "O"], + [650, 1, "U", "A", 500, 1, 0, "O"], + [0, 0], + ], + STATE_ON, + 50.0, + ), + ( + [ + [100, 0, "O", "A", 210, 0, 0, "O"], + [650, 0, "O", "A", 500, 0, 0, "O"], + [0, 0], + ], + STATE_OFF, + 50.0, + ), + ], +) +async def test_humidifier_data_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + val: list[list[Any]], + mode: str, + humidity: float, +) -> None: + """Test humidifier data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=val, + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == mode + assert state.attributes[ATTR_HUMIDITY] == humidity + + +async def test_humidifier_data_update_bad_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val="bad_data", + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + +async def test_humidifier_set_humidity( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set humidity service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Test set humidity + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 23}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 23.0 + + +async def test_humidifier_set_humidity_while_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set humidity service while off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Switch humidifier off + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Try setting humidity + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 23}, + blocking=True, + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "humidity_while_off" + + +async def test_humidifier_set_mode( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set mode service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + assert state.attributes[ATTR_MODE] == MODE_NORMAL + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MODE: MODE_AUTO}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_MODE] == MODE_AUTO + + +async def test_humidifier_set_status( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set status service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Test turn off + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test turn on + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON diff --git a/tests/components/comelit/test_light.py b/tests/components/comelit/test_light.py new file mode 100644 index 00000000000..36a191c9ee3 --- /dev/null +++ b/tests/components/comelit/test_light.py @@ -0,0 +1,76 @@ +"""Tests for Comelit SimpleHome light platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TOGGLE, + 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 + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "light.light0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.LIGHT]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("service", "status"), + [ + (SERVICE_TURN_OFF, STATE_OFF), + (SERVICE_TURN_ON, STATE_ON), + (SERVICE_TOGGLE, STATE_ON), + ], +) +async def test_light_set_state( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + service: str, + status: str, +) -> None: + """Test light set state service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test set temperature + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == status diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py new file mode 100644 index 00000000000..1bf717ca894 --- /dev/null +++ b/tests/components/comelit/test_sensor.py @@ -0,0 +1,90 @@ +"""Tests for Comelit SimpleHome sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject +from aiocomelit.const import AlarmAreaState, AlarmZoneState +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.comelit.const import SCAN_INTERVAL +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, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "sensor.zone0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.VEDO_PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_vedo_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_vedo_config_entry.entry_id, + ) + + +async def test_sensor_state_unknown( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test sensor unknown state.""" + + await setup_integration(hass, mock_vedo_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == AlarmZoneState.REST.value + + vedo_query = AlarmDataObject( + alarm_areas={ + 0: ComelitVedoAreaObject( + index=0, + name="Area0", + p1=True, + p2=True, + ready=False, + armed=True, + alarm=False, + alarm_memory=False, + sabotage=False, + anomaly=False, + in_time=False, + out_time=False, + human_status=AlarmAreaState.UNKNOWN, + ) + }, + alarm_zones={ + 0: ComelitVedoZoneObject( + index=0, + name="Zone0", + status_api="0x000", + status=0, + human_status=AlarmZoneState.UNKNOWN, + ) + }, + ) + + mock_vedo.get_all_areas_and_zones.return_value = vedo_query + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/comelit/test_switch.py b/tests/components/comelit/test_switch.py new file mode 100644 index 00000000000..31a4c4b144c --- /dev/null +++ b/tests/components/comelit/test_switch.py @@ -0,0 +1,76 @@ +"""Tests for Comelit SimpleHome switch platform.""" + +from unittest.mock import AsyncMock, patch + +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.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, 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 + +ENTITY_ID = "switch.switch0" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("service", "status"), + [ + (SERVICE_TURN_OFF, STATE_OFF), + (SERVICE_TURN_ON, STATE_ON), + (SERVICE_TOGGLE, STATE_ON), + ], +) +async def test_switch_set_state( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + service: str, + status: str, +) -> None: + """Test switch set state service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test set temperature + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_device_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == status diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index aa49410aacb..fb7a407cee5 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -331,9 +331,10 @@ async def test_updating_manually( "name": "Test", "command": "echo 10", "payload_on": "1.0", - "payload_off": "0", + "payload_off": "0.0", "value_template": "{{ value | multiply(0.1) }}", - "availability": '{{ states("sensor.input1")=="on" }}', + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', } } ] @@ -346,8 +347,7 @@ async def test_availability( freezer: FrozenDateTimeFactory, ) -> None: """Test availability.""" - - hass.states.async_set("sensor.input1", "on") + hass.states.async_set("sensor.input1", STATE_ON) freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) @@ -355,8 +355,9 @@ async def test_availability( entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_ON + assert entity_state.attributes["icon"] == "mdi:on" - hass.states.async_set("sensor.input1", "off") + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) await hass.async_block_till_done() with mock_asyncio_subprocess_run(b"0"): freezer.tick(timedelta(minutes=1)) @@ -366,3 +367,64 @@ async def test_availability( entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", STATE_OFF) + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"0"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_OFF + assert entity_state.attributes["icon"] == "mdi:off" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 10", + "payload_on": "1.0", + "payload_off": "0.0", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + } + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for binary_sensor.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index a6e384fdd6b..5010b85ae70 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -371,7 +371,9 @@ async def test_updating_manually( "cover": { "command_state": "echo 10", "name": "Test", - "availability": '{{ states("sensor.input1")=="on" }}', + "value_template": "{{ value }}", + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', }, } ] @@ -393,8 +395,9 @@ async def test_availability( entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == CoverState.OPEN + assert entity_state.attributes["icon"] == "mdi:on" - hass.states.async_set("sensor.input1", "off") + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) await hass.async_block_till_done() with mock_asyncio_subprocess_run(b"50\n"): freezer.tick(timedelta(minutes=1)) @@ -404,6 +407,19 @@ async def test_availability( entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"25\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == CoverState.OPEN + assert entity_state.attributes["icon"] == "mdi:off" async def test_icon_template(hass: HomeAssistant) -> None: @@ -455,3 +471,49 @@ async def test_icon_template(hass: HomeAssistant) -> None: entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.attributes.get("icon") == "mdi:icon2" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_state": "echo 10", + "name": "Test", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for cover.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index f7879b334cd..9c619537b94 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -772,17 +772,92 @@ async def test_template_not_error_when_data_is_none( { "sensor": { "name": "Test", - "command": "echo January 17, 2022", - "device_class": "date", - "value_template": "{{ strptime(value, '%B %d, %Y').strftime('%Y-%m-%d') }}", - "availability": '{{ states("sensor.input1")=="on" }}', + "command": 'echo { \\"key\\": \\"value\\" }', + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', + "json_attributes": ["key"], } } ] } ], ) -async def test_availability( +async def test_availability_json_attributes_without_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability.""" + hass.states.async_set("sensor.input1", "on") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "unknown" + assert entity_state.attributes["key"] == "value" + assert entity_state.attributes["icon"] == "mdi:on" + + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"Not A Number"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Unable to parse output as JSON" not in caplog.text + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + assert "key" not in entity_state.attributes + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", "on") + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"Not A Number"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Unable to parse output as JSON" in caplog.text + + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + with mock_asyncio_subprocess_run(b'{ "key": "value" }'): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "unknown" + assert entity_state.attributes["key"] == "value" + assert entity_state.attributes["icon"] == "mdi:on" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo January 17, 2022", + "device_class": "date", + "value_template": "{{ strptime(value, '%B %d, %Y').strftime('%Y-%m-%d') }}", + "availability": '{{ states("sensor.input1")=="on" }}', + "icon": "mdi:o{{ 'n' if states('sensor.input1')=='on' else 'ff' }}", + } + } + ] + } + ], +) +async def test_availability_with_value_template( hass: HomeAssistant, load_yaml_integration: None, freezer: FrozenDateTimeFactory, @@ -797,6 +872,7 @@ async def test_availability( entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "2022-01-17" + assert entity_state.attributes["icon"] == "mdi:on" hass.states.async_set("sensor.input1", "off") await hass.async_block_till_done() @@ -808,3 +884,141 @@ async def test_availability( entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + +async def test_template_render_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test availability template render with syntax errors.""" + assert await setup.async_setup_component( + hass, + "command_line", + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo {{ states.sensor.input_sensor.state }}", + "availability": "{{ what_the_heck == 2 }}", + } + } + ] + }, + ) + await hass.async_block_till_done() + + hass.states.async_set("sensor.input_sensor", "1") + await hass.async_block_till_done() + + # Give time for template to load + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == "1" + + assert ( + "Error rendering availability template for sensor.test: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo {{ states.sensor.input_sensor.state }}", + "availability": "{{ value|is_number}}", + "unit_of_measurement": " ", + "state_class": "measurement", + } + } + ] + } + ], +) +async def test_command_template_render_with_availability( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test command template is rendered properly with availability.""" + hass.states.async_set("sensor.input_sensor", "sensor_value") + + # Give time for template to load + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.input_sensor", "1") + + # Give time for template to load + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "1" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 0", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 6b34cf0fa77..8a8835ceaa0 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -735,7 +735,9 @@ async def test_updating_manually( "command_on": "echo 2", "command_off": "echo 3", "name": "Test", - "availability": '{{ states("sensor.input1")=="on" }}', + "value_template": "{{ value_json == 0 }}", + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', }, } ] @@ -749,16 +751,17 @@ async def test_availability( ) -> None: """Test availability.""" - hass.states.async_set("sensor.input1", "on") + hass.states.async_set("sensor.input1", STATE_OFF) freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) entity_state = hass.states.get("switch.test") assert entity_state - assert entity_state.state == STATE_ON + assert entity_state.state == STATE_OFF + assert entity_state.attributes["icon"] == "mdi:off" - hass.states.async_set("sensor.input1", "off") + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) await hass.async_block_till_done() with mock_asyncio_subprocess_run(b"50\n"): freezer.tick(timedelta(minutes=1)) @@ -768,3 +771,64 @@ async def test_availability( entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", STATE_ON) + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"0\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_ON + assert entity_state.attributes["icon"] == "mdi:on" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "switch": { + "command_state": "echo 1", + "command_on": "echo 2", + "command_off": "echo 3", + "name": "Test", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for switch.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 739b79e22bd..6784866ea4b 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -8,11 +8,12 @@ from unittest.mock import ANY, AsyncMock, patch from aiohttp.test_utils import TestClient from freezegun.api import FrozenDateTimeFactory import pytest +from pytest_unordered import unordered import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader from homeassistant.components.config import config_entries -from homeassistant.config_entries import HANDLERS, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType @@ -34,13 +35,6 @@ from tests.common import ( from tests.typing import ClientSessionGenerator, WebSocketGenerator -@pytest.fixture -def clear_handlers() -> Generator[None]: - """Clear config entry handlers.""" - with patch.dict(HANDLERS, clear=True): - yield - - @pytest.fixture(autouse=True) def mock_test_component(hass: HomeAssistant) -> None: """Ensure a component called 'test' exists.""" @@ -74,7 +68,7 @@ def mock_flow() -> Generator[None]: @pytest.mark.usefixtures("freezer") -@pytest.mark.usefixtures("clear_handlers", "mock_flow") +@pytest.mark.usefixtures("mock_flow") async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: """Test get entries.""" mock_integration(hass, MockModule("comp1")) @@ -358,7 +352,7 @@ async def test_reload_entry_in_setup_retry( entry.add_to_hass(hass) hass.config.components.add("comp") - with patch.dict(HANDLERS, {"comp": ConfigFlow, "test": ConfigFlow}): + with mock_config_flow("comp", ConfigFlow), mock_config_flow("test", ConfigFlow): resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" ) @@ -422,7 +416,7 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None: errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "show_advanced_options": True}, @@ -471,7 +465,7 @@ async def test_initialize_flow_unmet_dependency( async def async_step_user(self, user_input=None): pass - with patch.dict(HANDLERS, {"test2": TestFlow}): + with mock_config_flow("test2", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test2", "show_advanced_options": True}, @@ -502,7 +496,7 @@ async def test_initialize_flow_unauth( errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -519,7 +513,7 @@ async def test_abort(hass: HomeAssistant, client: TestClient) -> None: async def async_step_user(self, user_input=None): return self.async_abort(reason="bla") - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -552,7 +546,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: title="Test Entry", data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -620,7 +614,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: title=user_input["user_title"], data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -638,7 +632,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/flow/{flow_id}", json={"user_title": "user-title"}, @@ -707,7 +701,7 @@ async def test_continue_flow_unauth( title=user_input["user_title"], data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -774,7 +768,7 @@ async def test_get_progress_index( assert self._get_reconfigure_entry() is entry return await self.async_step_account() - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): form_hassio = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_HASSIO} ) @@ -838,7 +832,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -874,7 +868,7 @@ async def test_get_progress_flow_unauth( errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -889,6 +883,256 @@ async def test_get_progress_flow_unauth( assert resp2.status == HTTPStatus.UNAUTHORIZED +async def test_get_progress_subscribe( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test querying for the flows that are in progress.""" + assert await async_setup_component(hass, "config", {}) + mock_platform(hass, "test.config_flow", None) + ws_client = await hass_ws_client(hass) + + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + entry = MockConfigEntry(domain="test", title="Test", entry_id="1234") + entry.add_to_hass(hass) + + class TestFlow(core_ce.ConfigFlow): + VERSION = 5 + + async def async_step_bluetooth( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a bluetooth discovery.""" + return self.async_abort(reason="already_configured") + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a Hass.io discovery.""" + return await self.async_step_account() + + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" + return self.async_show_form(step_id="account") + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + return await self.async_step_account() + + async def async_step_reauth(self, user_input: dict[str, Any] | None = None): + """Handle a reauthentication flow.""" + nonlocal entry + assert self._get_reauth_entry() is entry + return await self.async_step_account() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ): + """Handle a reconfiguration flow initialized by the user.""" + nonlocal entry + assert self._get_reconfigure_entry() is entry + return await self.async_step_account() + + await ws_client.send_json({"id": 1, "type": "config_entries/flow/subscribe"}) + response = await ws_client.receive_json() + assert response == {"id": 1, "event": [], "type": "event"} + response = await ws_client.receive_json() + assert response == {"id": 1, "result": None, "success": True, "type": "result"} + + flow_context = { + "bluetooth": {"source": core_ce.SOURCE_BLUETOOTH}, + "hassio": {"source": core_ce.SOURCE_HASSIO}, + "user": {"source": core_ce.SOURCE_USER}, + "reauth": {"source": core_ce.SOURCE_REAUTH, "entry_id": "1234"}, + "reconfigure": {"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"}, + } + forms = {} + + with mock_config_flow("test", TestFlow): + for key, context in flow_context.items(): + forms[key] = await hass.config_entries.flow.async_init( + "test", context=context + ) + + assert forms["bluetooth"]["type"] == data_entry_flow.FlowResultType.ABORT + for key in ("hassio", "user", "reauth", "reconfigure"): + assert forms[key]["type"] == data_entry_flow.FlowResultType.FORM + assert forms[key]["step_id"] == "account" + + for key in ("hassio", "user", "reauth", "reconfigure"): + hass.config_entries.flow.async_abort(forms[key]["flow_id"]) + + # Uninitialized flows and flows with SOURCE_USER and SOURCE_RECONFIGURE + # should be filtered out + for key in ("hassio", "reauth"): + response = await ws_client.receive_json() + assert response == { + "event": [ + { + "flow": { + "flow_id": forms[key]["flow_id"], + "handler": "test", + "step_id": "account", + "context": flow_context[key], + }, + "flow_id": forms[key]["flow_id"], + "type": "added", + } + ], + "id": 1, + "type": "event", + } + for key in ("hassio", "reauth"): + response = await ws_client.receive_json() + assert response == { + "event": [ + { + "flow_id": forms[key]["flow_id"], + "type": "removed", + } + ], + "id": 1, + "type": "event", + } + + +async def test_get_progress_subscribe_in_progress( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test querying for the flows that are in progress.""" + assert await async_setup_component(hass, "config", {}) + mock_platform(hass, "test.config_flow", None) + ws_client = await hass_ws_client(hass) + + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + entry = MockConfigEntry(domain="test", title="Test", entry_id="1234") + entry.add_to_hass(hass) + + class TestFlow(core_ce.ConfigFlow): + VERSION = 5 + + async def async_step_bluetooth( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a bluetooth discovery.""" + return self.async_abort(reason="already_configured") + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a Hass.io discovery.""" + return await self.async_step_account() + + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" + return self.async_show_form(step_id="account") + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + return await self.async_step_account() + + async def async_step_reauth(self, user_input: dict[str, Any] | None = None): + """Handle a reauthentication flow.""" + nonlocal entry + assert self._get_reauth_entry() is entry + return await self.async_step_account() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ): + """Handle a reconfiguration flow initialized by the user.""" + nonlocal entry + assert self._get_reconfigure_entry() is entry + return await self.async_step_account() + + flow_context = { + "bluetooth": {"source": core_ce.SOURCE_BLUETOOTH}, + "hassio": {"source": core_ce.SOURCE_HASSIO}, + "user": {"source": core_ce.SOURCE_USER}, + "reauth": {"source": core_ce.SOURCE_REAUTH, "entry_id": "1234"}, + "reconfigure": {"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"}, + } + forms = {} + + with mock_config_flow("test", TestFlow): + for key, context in flow_context.items(): + forms[key] = await hass.config_entries.flow.async_init( + "test", context=context + ) + + assert forms["bluetooth"]["type"] == data_entry_flow.FlowResultType.ABORT + for key in ("hassio", "user", "reauth", "reconfigure"): + assert forms[key]["type"] == data_entry_flow.FlowResultType.FORM + assert forms[key]["step_id"] == "account" + + await ws_client.send_json({"id": 1, "type": "config_entries/flow/subscribe"}) + + # Uninitialized flows and flows with SOURCE_USER and SOURCE_RECONFIGURE + # should be filtered out + responses = [] + responses.append(await ws_client.receive_json()) + assert responses == [ + { + "event": unordered( + [ + { + "flow": { + "flow_id": forms[key]["flow_id"], + "handler": "test", + "step_id": "account", + "context": flow_context[key], + }, + "flow_id": forms[key]["flow_id"], + "type": None, + } + for key in ("hassio", "reauth") + ] + ), + "id": 1, + "type": "event", + } + ] + + response = await ws_client.receive_json() + assert response == {"id": ANY, "result": None, "success": True, "type": "result"} + + for key in ("hassio", "user", "reauth", "reconfigure"): + hass.config_entries.flow.async_abort(forms[key]["flow_id"]) + + for key in ("hassio", "reauth"): + response = await ws_client.receive_json() + assert response == { + "event": [ + { + "flow_id": forms[key]["flow_id"], + "type": "removed", + } + ], + "id": 1, + "type": "event", + } + + +async def test_get_progress_subscribe_unauth( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser +) -> None: + """Test we can't subscribe to flows.""" + assert await async_setup_component(hass, "config", {}) + hass_admin_user.groups = [] + ws_client = await hass_ws_client(hass) + + await ws_client.send_json({"id": 5, "type": "config_entries/flow/subscribe"}) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "unauthorized" + + async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can change options.""" @@ -918,7 +1162,7 @@ async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None: ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -980,7 +1224,7 @@ async def test_options_flow_unauth( hass_admin_user.groups = [] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id}) assert resp.status == HTTPStatus.UNAUTHORIZED @@ -1017,7 +1261,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client: TestClient) -> ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -1035,7 +1279,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client: TestClient) -> "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/options/flow/{flow_id}", json={"enabled": True}, @@ -1092,7 +1336,7 @@ async def test_options_flow_with_invalid_data( ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -1118,7 +1362,7 @@ async def test_options_flow_with_invalid_data( "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/options/flow/{flow_id}", json={"choices": ["valid", "invalid"]}, @@ -1193,7 +1437,7 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: async def async_step_reconfigure(self, user_input=None): if user_input is not None: return self.async_update_and_abort( - self._get_reconfigure_entry(), + self._get_entry(), self._get_reconfigure_subentry(), title="Test Entry", data={"test": "blah"}, @@ -1812,7 +2056,7 @@ async def test_ignore_flow( ws_client = await hass_ws_client(hass) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): result = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_USER} | flow_context ) @@ -1861,7 +2105,7 @@ async def test_ignore_flow_nonexisting( assert response["error"]["code"] == "not_found" -@pytest.mark.usefixtures("clear_handlers", "freezer") +@pytest.mark.usefixtures("freezer") async def test_get_matching_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -2313,7 +2557,6 @@ async def test_get_matching_entries_ws( assert response["success"] is False -@pytest.mark.usefixtures("clear_handlers") async def test_subscribe_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2532,7 +2775,6 @@ async def test_subscribe_entries_ws( ] -@pytest.mark.usefixtures("clear_handlers") async def test_subscribe_entries_ws_filtered( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2792,7 +3034,7 @@ async def test_flow_with_multiple_schema_errors( ), ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -2834,7 +3076,7 @@ async def test_flow_with_multiple_schema_errors_base( ), ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -2893,7 +3135,7 @@ async def test_supports_reconfigure( data={"secret": "account_token"}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "entry_id": "1"}, @@ -2915,7 +3157,7 @@ async def test_supports_reconfigure( "errors": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/flow/{flow_id}", json={}, @@ -2953,7 +3195,7 @@ async def test_does_not_support_reconfigure( title="Test Entry", data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "entry_id": "1"}, diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 2e3de33d808..ea7a65f25d3 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryDisabler from homeassistant.helpers.entity_registry import ( - RegistryEntry, RegistryEntryDisabler, RegistryEntryHider, ) @@ -23,6 +22,7 @@ from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + RegistryEntryWithDefaults, mock_registry, ) from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -45,13 +45,13 @@ async def test_list_entities( mock_registry( hass, { - "test_domain.name": RegistryEntry( + "test_domain.name": RegistryEntryWithDefaults( entity_id="test_domain.name", unique_id="1234", platform="test_platform", name="Hello World", ), - "test_domain.no_name": RegistryEntry( + "test_domain.no_name": RegistryEntryWithDefaults( entity_id="test_domain.no_name", unique_id="6789", platform="test_platform", @@ -117,13 +117,13 @@ async def test_list_entities( mock_registry( hass, { - "test_domain.name": RegistryEntry( + "test_domain.name": RegistryEntryWithDefaults( entity_id="test_domain.name", unique_id="1234", platform="test_platform", name="Hello World", ), - "test_domain.name_2": RegistryEntry( + "test_domain.name_2": RegistryEntryWithDefaults( entity_id="test_domain.name_2", unique_id="6789", platform="test_platform", @@ -169,7 +169,7 @@ async def test_list_entities_for_display( mock_registry( hass, { - "test_domain.test": RegistryEntry( + "test_domain.test": RegistryEntryWithDefaults( area_id="area52", device_id="device123", entity_category=EntityCategory.DIAGNOSTIC, @@ -181,7 +181,7 @@ async def test_list_entities_for_display( translation_key="translations_galore", unique_id="1234", ), - "test_domain.nameless": RegistryEntry( + "test_domain.nameless": RegistryEntryWithDefaults( area_id="area52", device_id="device123", entity_id="test_domain.nameless", @@ -191,7 +191,7 @@ async def test_list_entities_for_display( platform="test_platform", unique_id="2345", ), - "test_domain.renamed": RegistryEntry( + "test_domain.renamed": RegistryEntryWithDefaults( area_id="area52", device_id="device123", entity_id="test_domain.renamed", @@ -201,31 +201,31 @@ async def test_list_entities_for_display( platform="test_platform", unique_id="3456", ), - "test_domain.boring": RegistryEntry( + "test_domain.boring": RegistryEntryWithDefaults( entity_id="test_domain.boring", platform="test_platform", unique_id="4567", ), - "test_domain.disabled": RegistryEntry( + "test_domain.disabled": RegistryEntryWithDefaults( disabled_by=RegistryEntryDisabler.USER, entity_id="test_domain.disabled", hidden_by=RegistryEntryHider.USER, platform="test_platform", unique_id="789A", ), - "test_domain.hidden": RegistryEntry( + "test_domain.hidden": RegistryEntryWithDefaults( entity_id="test_domain.hidden", hidden_by=RegistryEntryHider.USER, platform="test_platform", unique_id="89AB", ), - "sensor.default_precision": RegistryEntry( + "sensor.default_precision": RegistryEntryWithDefaults( entity_id="sensor.default_precision", options={"sensor": {"suggested_display_precision": 0}}, platform="test_platform", unique_id="9ABC", ), - "sensor.user_precision": RegistryEntry( + "sensor.user_precision": RegistryEntryWithDefaults( entity_id="sensor.user_precision", options={ "sensor": {"display_precision": 0, "suggested_display_precision": 1} @@ -303,7 +303,7 @@ async def test_list_entities_for_display( mock_registry( hass, { - "test_domain.test": RegistryEntry( + "test_domain.test": RegistryEntryWithDefaults( area_id="area52", device_id="device123", entity_id="test_domain.test", @@ -312,7 +312,7 @@ async def test_list_entities_for_display( platform="test_platform", unique_id="1234", ), - "test_domain.name_2": RegistryEntry( + "test_domain.name_2": RegistryEntryWithDefaults( entity_id="test_domain.name_2", has_entity_name=True, original_name=Unserializable(), @@ -348,7 +348,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> mock_registry( hass, { - "test_domain.name": RegistryEntry( + "test_domain.name": RegistryEntryWithDefaults( entity_id="test_domain.name", unique_id="1234", platform="test_platform", @@ -356,7 +356,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> created_at=name_created_at, modified_at=name_created_at, ), - "test_domain.no_name": RegistryEntry( + "test_domain.no_name": RegistryEntryWithDefaults( entity_id="test_domain.no_name", unique_id="6789", platform="test_platform", @@ -445,7 +445,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) mock_registry( hass, { - "test_domain.name": RegistryEntry( + "test_domain.name": RegistryEntryWithDefaults( entity_id="test_domain.name", unique_id="1234", platform="test_platform", @@ -453,7 +453,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) created_at=name_created_at, modified_at=name_created_at, ), - "test_domain.no_name": RegistryEntry( + "test_domain.no_name": RegistryEntryWithDefaults( entity_id="test_domain.no_name", unique_id="6789", platform="test_platform", @@ -545,7 +545,7 @@ async def test_update_entity( registry = mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1009,7 +1009,7 @@ async def test_update_entity_no_changes( mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1110,7 +1110,7 @@ async def test_update_entity_id( mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1179,13 +1179,13 @@ async def test_update_existing_entity_id( mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" platform="test_platform", ), - "test_domain.planet": RegistryEntry( + "test_domain.planet": RegistryEntryWithDefaults( entity_id="test_domain.planet", unique_id="2345", # Using component.async_add_entities is equal to platform "domain" @@ -1217,7 +1217,7 @@ async def test_update_invalid_entity_id( mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1249,7 +1249,7 @@ async def test_remove_entity( registry = mock_registry( hass, { - "test_domain.world": RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 6d6d0d4641f..e0db306cae9 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -929,7 +929,7 @@ async def check_translations( ignored_domains = set(ignore_translations_for_mock_domains) # Set all ignored translation keys to "unused" - translation_errors = {k: "unused" for k in ignore_missing_translations} + translation_errors = dict.fromkeys(ignore_missing_translations, "unused") translation_coros = set() diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index 314188dbd82..eeab8b6b9af 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import AsyncGenerator from dataclasses import dataclass, field from typing import Literal from unittest.mock import patch @@ -49,7 +50,7 @@ class MockAgent(conversation.AbstractConversationAgent): @pytest.fixture -async def mock_chat_log(hass: HomeAssistant) -> MockChatLog: +async def mock_chat_log(hass: HomeAssistant) -> AsyncGenerator[MockChatLog]: """Return mock chat logs.""" # pylint: disable-next=contextmanager-generator-missing-cleanup with ( diff --git a/tests/components/conversation/snapshots/test_chat_log.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr index 1ddbf68bb84..ff8ebf724cd 100644 --- a/tests/components/conversation/snapshots/test_chat_log.ambr +++ b/tests/components/conversation/snapshots/test_chat_log.ambr @@ -151,6 +151,7 @@ # --- # name: test_template_error dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -171,6 +172,7 @@ # --- # name: test_unknown_llm_api dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr index c2b16ea2912..02e4ef1befe 100644 --- a/tests/components/conversation/snapshots/test_default_agent.ambr +++ b/tests/components/conversation/snapshots/test_default_agent.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_custom_sentences dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -26,6 +27,7 @@ # --- # name: test_custom_sentences.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -51,6 +53,7 @@ # --- # name: test_custom_sentences_config dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -76,6 +79,7 @@ # --- # name: test_intent_alias_added_removed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -106,6 +110,7 @@ # --- # name: test_intent_alias_added_removed.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -136,6 +141,7 @@ # --- # name: test_intent_alias_added_removed.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -156,6 +162,7 @@ # --- # name: test_intent_conversion_not_expose_new dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -176,6 +183,7 @@ # --- # name: test_intent_conversion_not_expose_new.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -206,6 +214,7 @@ # --- # name: test_intent_entity_added_removed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -236,6 +245,7 @@ # --- # name: test_intent_entity_added_removed.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -266,6 +276,7 @@ # --- # name: test_intent_entity_added_removed.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -296,6 +307,7 @@ # --- # name: test_intent_entity_added_removed.3 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -316,6 +328,7 @@ # --- # name: test_intent_entity_exposed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -346,6 +359,7 @@ # --- # name: test_intent_entity_fail_if_unexposed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -366,6 +380,7 @@ # --- # name: test_intent_entity_remove_custom_name dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -386,6 +401,7 @@ # --- # name: test_intent_entity_remove_custom_name.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -416,6 +432,7 @@ # --- # name: test_intent_entity_remove_custom_name.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -436,6 +453,7 @@ # --- # name: test_intent_entity_renamed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -466,6 +484,7 @@ # --- # name: test_intent_entity_renamed.1 dict({ + 'continue_conversation': False, '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 c6ac6c2df9c..abce735dd8a 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -32,6 +32,7 @@ 'it', 'ka', 'ko', + 'kw', 'lb', 'lt', 'lv', @@ -202,6 +203,7 @@ # --- # name: test_http_api_handle_failure dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -222,6 +224,7 @@ # --- # name: test_http_api_no_match dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -242,6 +245,7 @@ # --- # name: test_http_api_unexpected_failure dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -262,6 +266,7 @@ # --- # name: test_http_processing_intent[None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -292,6 +297,7 @@ # --- # name: test_http_processing_intent[conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -322,6 +328,7 @@ # --- # name: test_http_processing_intent[homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -352,6 +359,7 @@ # --- # name: test_ws_api[payload0] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -372,6 +380,7 @@ # --- # name: test_ws_api[payload1] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -392,6 +401,7 @@ # --- # name: test_ws_api[payload2] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -412,6 +422,7 @@ # --- # name: test_ws_api[payload3] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -432,6 +443,7 @@ # --- # name: test_ws_api[payload4] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -452,6 +464,7 @@ # --- # name: test_ws_api[payload5] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 911c7043a6d..3d843d4e32a 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_custom_agent dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -44,6 +45,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -74,6 +76,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -104,6 +107,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -134,6 +138,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -164,6 +169,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -194,6 +200,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -224,6 +231,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -254,6 +262,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -284,6 +293,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -314,6 +324,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -344,6 +355,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -374,6 +386,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index a4dc9b819c1..c9e72ae5a03 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -14,6 +14,7 @@ from homeassistant.components.conversation import ( ConversationInput, ConverseError, ToolResultContent, + UserContent, async_get_chat_log, ) from homeassistant.components.conversation.chat_log import DATA_CHAT_LOGS @@ -86,7 +87,9 @@ async def test_default_content( with ( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log2, ): + assert chat_log is chat_log2 assert len(chat_log.content) == 2 assert chat_log.content[0].role == "system" assert chat_log.content[0].content == "" @@ -136,6 +139,48 @@ async def test_unknown_llm_api( assert exc_info.value.as_conversation_result().as_dict() == snapshot +async def test_multiple_llm_apis( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test when we reference an LLM API.""" + + class MyTool(llm.Tool): + """Test tool.""" + + name = "test_tool" + description = "Test function" + parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + + class MyAPI(llm.API): + """Test API.""" + + async def async_get_api_instance( + self, llm_context: llm.LLMContext + ) -> llm.APIInstance: + """Return a list of tools.""" + return llm.APIInstance(self, "My API Prompt", llm_context, [MyTool()]) + + api = MyAPI(hass=hass, id="my-api", name="Test") + llm.async_register_api(hass, api) + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=["assist", "my-api"], + user_llm_prompt=None, + ) + + assert chat_log.llm_api + assert chat_log.llm_api.api.id == "assist|my-api" + + async def test_template_error( hass: HomeAssistant, mock_conversation_input: ConversationInput, @@ -588,7 +633,7 @@ async def test_add_delta_content_stream_errors( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): # Stream content without LLM API set - with pytest.raises(ValueError): # noqa: PT012 + with pytest.raises(ValueError): async for _tool_result_content in chat_log.async_add_delta_content_stream( "mock-agent-id", stream( @@ -610,7 +655,7 @@ async def test_add_delta_content_stream_errors( # Non assistant role for role in "system", "user": - with pytest.raises(ValueError): # noqa: PT012 + with pytest.raises(ValueError): async for ( _tool_result_content ) in chat_log.async_add_delta_content_stream( @@ -641,3 +686,30 @@ async def test_chat_log_reuse( assert len(chat_log.content) == 2 assert chat_log.content[1].role == "user" assert chat_log.content[1].content == mock_conversation_input.text + + +async def test_chat_log_continue_conversation( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test continue conversation.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session) as chat_log, + ): + assert chat_log.continue_conversation is False + chat_log.async_add_user_content(UserContent(mock_conversation_input.text)) + assert chat_log.continue_conversation is False + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Hey? ", + ) + ) + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Ποιο είναι το αγαπημένο σου χρώμα στα ελληνικά;", + ) + ) + assert chat_log.continue_conversation is True diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index dca4653b480..f075f267111 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, patch from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion import yaml from homeassistant.components import conversation, cover, media_player, weather diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 6d69ec3c739..77fa97ad845 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -536,3 +536,60 @@ async def test_ws_hass_agent_debug_sentence_trigger( # Trigger should not have been executed assert len(calls) == 0 + + +async def test_ws_hass_language_scores( + hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting language support scores.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + {"type": "conversation/agent/homeassistant/language_scores"} + ) + + msg = await client.receive_json() + assert msg["success"] + + # Sanity check + result = msg["result"] + assert result["languages"]["en-US"] == { + "cloud": 3, + "focused_local": 2, + "full_local": 3, + } + + +async def test_ws_hass_language_scores_with_filter( + hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting language support scores with language/country filter.""" + client = await hass_ws_client(hass) + + # Language filter + await client.send_json_auto_id( + {"type": "conversation/agent/homeassistant/language_scores", "language": "de"} + ) + + msg = await client.receive_json() + assert msg["success"] + + # German should be preferred + result = msg["result"] + assert result["preferred_language"] == "de-DE" + + # Language/country filter + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/language_scores", + "language": "en", + "country": "GB", + } + ) + + msg = await client.receive_json() + assert msg["success"] + + # GB English should be preferred + result = msg["result"] + assert result["preferred_language"] == "en-GB" diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 3aa8ae2939f..a01f4cd8112 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -104,6 +104,7 @@ async def test_response(hass: HomeAssistant) -> None: "trigger": { "platform": "conversation", "command": ["Open the pod bay door Hal"], + "variables": {"name": "Dr. David Bowman"}, }, "action": { "set_conversation_response": response, diff --git a/tests/components/conversation/test_util.py b/tests/components/conversation/test_util.py deleted file mode 100644 index 72a334232c1..00000000000 --- a/tests/components/conversation/test_util.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Test the conversation utils.""" - -from homeassistant.components.conversation.util import create_matcher - - -def test_create_matcher() -> None: - """Test the create matcher method.""" - # Basic sentence - pattern = create_matcher("Hello world") - assert pattern.match("Hello world") is not None - - # Match a part - pattern = create_matcher("Hello {name}") - match = pattern.match("hello world") - assert match is not None - assert match.groupdict()["name"] == "world" - no_match = pattern.match("Hello world, how are you?") - assert no_match is None - - # Optional and matching part - pattern = create_matcher("Turn on [the] {name}") - match = pattern.match("turn on the kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn off kitchen lights") - assert match is None - - # Two different optional parts, 1 matching part - pattern = create_matcher("Turn on [the] [a] {name}") - match = pattern.match("turn on the kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on a kitchen light") - assert match is not None - assert match.groupdict()["name"] == "kitchen light" - - # Strip plural - pattern = create_matcher("Turn {name}[s] on") - match = pattern.match("turn kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen light" - - # Optional 2 words - pattern = create_matcher("Turn [the great] {name} on") - match = pattern.match("turn the great kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" diff --git a/tests/components/cookidoo/test_button.py b/tests/components/cookidoo/test_button.py index 3e832ec9fe6..f96cbf4665d 100644 --- a/tests/components/cookidoo/test_button.py +++ b/tests/components/cookidoo/test_button.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from cookidoo_api import CookidooRequestException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/cookidoo/test_diagnostics.py b/tests/components/cookidoo/test_diagnostics.py index c253e1f6e09..1bd172f846f 100644 --- a/tests/components/cookidoo/test_diagnostics.py +++ b/tests/components/cookidoo/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/cover/test_reproduce_state.py b/tests/components/cover/test_reproduce_state.py index 4aad27011fa..dfc22abac91 100644 --- a/tests/components/cover/test_reproduce_state.py +++ b/tests/components/cover/test_reproduce_state.py @@ -7,9 +7,11 @@ from homeassistant.components.cover import ( ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, + CoverEntityFeature, CoverState, ) from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -27,35 +29,229 @@ async def test_reproducing_states( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test reproducing Cover states.""" - hass.states.async_set("cover.entity_close", CoverState.CLOSED, {}) + hass.states.async_set( + "cover.entity_close", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_close_open", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.open_only_supports_close_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.open_missing_all_features", + CoverState.OPEN, + ) + hass.states.async_set( + "cover.closed_missing_all_features_has_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + }, + ) + hass.states.async_set( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 50, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_tilt_close_open", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.open_only_supports_tilt_close_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + }, + ) + hass.states.async_set( + "cover.open_only_supports_position", + CoverState.OPEN, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION}, + ) hass.states.async_set( "cover.entity_close_attr", CoverState.CLOSED, - {ATTR_CURRENT_POSITION: 0, ATTR_CURRENT_TILT_POSITION: 0}, + { + ATTR_CURRENT_POSITION: 0, + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( - "cover.entity_close_tilt", CoverState.CLOSED, {ATTR_CURRENT_TILT_POSITION: 50} + "cover.entity_close_tilt", + CoverState.CLOSED, + { + ATTR_CURRENT_TILT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, ) - hass.states.async_set("cover.entity_open", CoverState.OPEN, {}) hass.states.async_set( - "cover.entity_slightly_open", CoverState.OPEN, {ATTR_CURRENT_POSITION: 50} + "cover.entity_open", + CoverState.OPEN, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN}, + ) + hass.states.async_set( + "cover.entity_slightly_open", + CoverState.OPEN, + { + ATTR_CURRENT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_open_attr", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 0}, + { + ATTR_CURRENT_POSITION: 100, + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_open_tilt", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 50, ATTR_CURRENT_TILT_POSITION: 50}, + { + ATTR_CURRENT_POSITION: 50, + ATTR_CURRENT_TILT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_entirely_open", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 100}, + { + ATTR_CURRENT_POSITION: 100, + ATTR_CURRENT_TILT_POSITION: 100, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.closed_supports_all_features", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_only_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.tilt_only_closed", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.tilt_only_tilt_position_100", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 100, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_only_tilt_position_0", + CoverState.CLOSED, + { + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_open_only_supports_tilt_position", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + ATTR_CURRENT_TILT_POSITION: 50, + }, + ) + hass.states.async_set( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + }, ) - close_calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER) open_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) close_tilt_calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER_TILT) @@ -69,7 +265,40 @@ async def test_reproducing_states( await async_reproduce_state( hass, [ + State( + "cover.closed_supports_all_features", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + ATTR_CURRENT_TILT_POSITION: 0, + }, + ), State("cover.entity_close", CoverState.CLOSED), + State("cover.closed_only_supports_close_open", CoverState.CLOSED), + State("cover.closed_only_supports_tilt_close_open", CoverState.CLOSED), + State("cover.open_only_supports_close_open", CoverState.OPEN), + State("cover.open_only_supports_tilt_close_open", CoverState.OPEN), + State("cover.open_missing_all_features", CoverState.OPEN), + State( + "cover.closed_missing_all_features_has_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + }, + ), + State( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 50, + }, + ), + State( + "cover.closed_only_supports_position", + CoverState.CLOSED, + {ATTR_CURRENT_POSITION: 0}, + ), + State("cover.open_only_supports_position", CoverState.OPEN), State( "cover.entity_close_attr", CoverState.CLOSED, @@ -101,6 +330,39 @@ async def test_reproducing_states( CoverState.OPEN, {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 100}, ), + State( + "cover.tilt_only_open", + CoverState.OPEN, + {}, + ), + State( + "cover.tilt_only_tilt_position_100", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 100}, + ), + State( + "cover.tilt_only_closed", + CoverState.CLOSED, + {}, + ), + State( + "cover.tilt_only_tilt_position_0", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 0}, + ), + State( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 50}, + ), + State( + "cover.tilt_open_only_supports_tilt_position", + CoverState.OPEN, + ), + State( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.CLOSED, + ), ], ) @@ -126,7 +388,41 @@ async def test_reproducing_states( await async_reproduce_state( hass, [ + State( + "cover.closed_supports_all_features", + CoverState.CLOSED, + {ATTR_CURRENT_POSITION: 0, ATTR_CURRENT_TILT_POSITION: 50}, + ), State("cover.entity_close", CoverState.OPEN), + State( + "cover.closed_only_supports_close_open", + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 100}, + ), + State( + "cover.open_only_supports_close_open", + CoverState.CLOSED, + {ATTR_CURRENT_POSITION: 50}, + ), + State( + "cover.open_only_supports_tilt_close_open", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 50}, + ), + State("cover.closed_only_supports_tilt_close_open", CoverState.OPEN), + State("cover.open_missing_all_features", CoverState.CLOSED), + State( + "cover.closed_missing_all_features_has_position", + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 70}, + ), + State( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 20}, + ), + State("cover.closed_only_supports_position", CoverState.OPEN), + State("cover.open_only_supports_position", CoverState.CLOSED), State( "cover.entity_close_attr", CoverState.OPEN, @@ -152,15 +448,49 @@ async def test_reproducing_states( ), # Should not raise State("cover.non_existing", "on"), + State( + "cover.tilt_only_open", + CoverState.CLOSED, + {}, + ), + State( + "cover.tilt_only_tilt_position_100", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 0}, + ), + State( + "cover.tilt_only_closed", + CoverState.OPEN, + {}, + ), + State( + "cover.tilt_only_tilt_position_0", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 100}, + ), + State( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 70}, + ), + State( + "cover.tilt_open_only_supports_tilt_position", + CoverState.CLOSED, + ), + State( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.OPEN, + ), ], ) valid_close_calls = [ {"entity_id": "cover.entity_open"}, {"entity_id": "cover.entity_open_attr"}, - {"entity_id": "cover.entity_entirely_open"}, + {"entity_id": "cover.open_only_supports_close_open"}, + {"entity_id": "cover.open_missing_all_features"}, ] - assert len(close_calls) == 3 + assert len(close_calls) == len(valid_close_calls) for call in close_calls: assert call.domain == "cover" assert call.data in valid_close_calls @@ -170,37 +500,118 @@ async def test_reproducing_states( {"entity_id": "cover.entity_close"}, {"entity_id": "cover.entity_slightly_open"}, {"entity_id": "cover.entity_open_tilt"}, + {"entity_id": "cover.closed_only_supports_close_open"}, ] - assert len(open_calls) == 3 + assert len(open_calls) == len(valid_open_calls) for call in open_calls: assert call.domain == "cover" assert call.data in valid_open_calls valid_open_calls.remove(call.data) valid_close_tilt_calls = [ - {"entity_id": "cover.entity_open_tilt"}, - {"entity_id": "cover.entity_entirely_open"}, + {"entity_id": "cover.tilt_only_open"}, + {"entity_id": "cover.entity_open_attr"}, + {"entity_id": "cover.open_only_supports_tilt_close_open"}, ] - assert len(close_tilt_calls) == 2 + assert len(close_tilt_calls) == len(valid_close_tilt_calls) for call in close_tilt_calls: assert call.domain == "cover" assert call.data in valid_close_tilt_calls valid_close_tilt_calls.remove(call.data) - assert len(open_tilt_calls) == 1 - assert open_tilt_calls[0].domain == "cover" - assert open_tilt_calls[0].data == {"entity_id": "cover.entity_close_tilt"} + valid_open_tilt_calls = [ + {"entity_id": "cover.tilt_only_closed"}, + {"entity_id": "cover.closed_only_supports_tilt_close_open"}, + ] + assert len(open_tilt_calls) == len(valid_open_tilt_calls) + for call in open_tilt_calls: + assert call.domain == "cover" + assert call.data in valid_open_tilt_calls + valid_open_tilt_calls.remove(call.data) - assert len(position_calls) == 1 - assert position_calls[0].domain == "cover" - assert position_calls[0].data == { - "entity_id": "cover.entity_close_attr", - ATTR_POSITION: 50, - } + valid_position_calls = [ + { + "entity_id": "cover.entity_close_attr", + ATTR_POSITION: 50, + }, + { + "entity_id": "cover.closed_missing_all_features_has_position", + ATTR_POSITION: 70, + }, + { + "entity_id": "cover.closed_only_supports_position", + ATTR_POSITION: 100, + }, + { + "entity_id": "cover.open_only_supports_position", + ATTR_POSITION: 0, + }, + { + "entity_id": "cover.closed_supports_all_features", + ATTR_POSITION: 0, + }, + { + "entity_id": "cover.entity_entirely_open", + ATTR_POSITION: 0, + }, + ] + assert len(position_calls) == len(valid_position_calls) + for call in position_calls: + assert call.domain == "cover" + assert call.data in valid_position_calls + valid_position_calls.remove(call.data) - assert len(position_tilt_calls) == 1 - assert position_tilt_calls[0].domain == "cover" - assert position_tilt_calls[0].data == { - "entity_id": "cover.entity_close_attr", - ATTR_TILT_POSITION: 50, - } + valid_position_tilt_calls = [ + { + "entity_id": "cover.entity_close_attr", + ATTR_TILT_POSITION: 50, + }, + { + "entity_id": "cover.open_missing_all_features_has_tilt_position", + ATTR_TILT_POSITION: 20, + }, + { + "entity_id": "cover.tilt_open_only_supports_tilt_position", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.tilt_closed_only_supports_tilt_position", + ATTR_TILT_POSITION: 100, + }, + { + "entity_id": "cover.tilt_partial_open_only_supports_tilt_position", + ATTR_TILT_POSITION: 70, + }, + { + "entity_id": "cover.closed_supports_all_features", + ATTR_TILT_POSITION: 50, + }, + { + "entity_id": "cover.entity_close_tilt", + ATTR_TILT_POSITION: 100, + }, + { + "entity_id": "cover.entity_open_tilt", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.entity_entirely_open", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.tilt_only_tilt_position_100", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.tilt_only_tilt_position_0", + ATTR_TILT_POSITION: 100, + }, + ] + for call in position_tilt_calls: + if ATTR_TILT_POSITION not in call.data: + continue + assert len(position_tilt_calls) == len(valid_position_tilt_calls) + for call in position_tilt_calls: + assert call.domain == "cover" + assert call.data in valid_position_tilt_calls + valid_position_tilt_calls.remove(call.data) diff --git a/tests/components/cpuspeed/test_diagnostics.py b/tests/components/cpuspeed/test_diagnostics.py index a596c7d62d9..e84235af3b0 100644 --- a/tests/components/cpuspeed/test_diagnostics.py +++ b/tests/components/cpuspeed/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/deako/test_init.py b/tests/components/deako/test_init.py index c2291330feb..33428f4f81c 100644 --- a/tests/components/deako/test_init.py +++ b/tests/components/deako/test_init.py @@ -21,6 +21,7 @@ async def test_deako_async_setup_entry( "id1": {}, "id2": {}, } + pydeako_deako_mock.return_value.get_name.return_value = "some device" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/deconz/test_alarm_control_panel.py b/tests/components/deconz/test_alarm_control_panel.py index dbe75584df7..8e0b696c274 100644 --- a/tests/components/deconz/test_alarm_control_panel.py +++ b/tests/components/deconz/test_alarm_control_panel.py @@ -5,7 +5,7 @@ from unittest.mock import patch from pydeconz.models.sensor.ancillary_control import AncillaryControlPanel import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index 59d31afb9fc..288be082f43 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.deconz.const import ( CONF_ALLOW_CLIP_SENSOR, diff --git a/tests/components/deconz/test_button.py b/tests/components/deconz/test_button.py index c649dba5b00..4451d68c186 100644 --- a/tests/components/deconz/test_button.py +++ b/tests/components/deconz/test_button.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index e1000f0b4d6..723ff12ad37 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -4,7 +4,7 @@ from collections.abc import Callable from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_FAN_MODE, diff --git a/tests/components/deconz/test_cover.py b/tests/components/deconz/test_cover.py index 47f8083798e..99f78dd1a92 100644 --- a/tests/components/deconz/test_cover.py +++ b/tests/components/deconz/test_cover.py @@ -4,7 +4,7 @@ from collections.abc import Callable from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, diff --git a/tests/components/deconz/test_diagnostics.py b/tests/components/deconz/test_diagnostics.py index 2abc6d83995..640e8947c17 100644 --- a/tests/components/deconz/test_diagnostics.py +++ b/tests/components/deconz/test_diagnostics.py @@ -1,7 +1,7 @@ """Test deCONZ diagnostics.""" from pydeconz.websocket import State -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/deconz/test_fan.py b/tests/components/deconz/test_fan.py index 21809a138c6..a544f46e39d 100644 --- a/tests/components/deconz/test_fan.py +++ b/tests/components/deconz/test_fan.py @@ -4,7 +4,7 @@ from collections.abc import Callable from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PERCENTAGE, diff --git a/tests/components/deconz/test_hub.py b/tests/components/deconz/test_hub.py index 1b000828b85..f674a6ef6df 100644 --- a/tests/components/deconz/test_hub.py +++ b/tests/components/deconz/test_hub.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pydeconz.websocket import State import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 9ac15d4867b..6aacdf7011b 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.deconz.const import CONF_ALLOW_DECONZ_GROUPS from homeassistant.components.light import ( diff --git a/tests/components/deconz/test_number.py b/tests/components/deconz/test_number.py index 962c2c0a89b..dd2f26eec4b 100644 --- a/tests/components/deconz/test_number.py +++ b/tests/components/deconz/test_number.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/deconz/test_scene.py b/tests/components/deconz/test_scene.py index c1240b6881c..d03cbec28e0 100644 --- a/tests/components/deconz/test_scene.py +++ b/tests/components/deconz/test_scene.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/deconz/test_select.py b/tests/components/deconz/test_select.py index c677853841c..5d79cb8cd50 100644 --- a/tests/components/deconz/test_select.py +++ b/tests/components/deconz/test_select.py @@ -10,7 +10,7 @@ from pydeconz.models.sensor.presence import ( PresenceConfigTriggerDistance, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 958cb3b793a..521ff3c7efb 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.deconz.const import CONF_ALLOW_CLIP_SENSOR from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN diff --git a/tests/components/demo/test_light.py b/tests/components/demo/test_light.py index b39b09d9307..af9006f97cc 100644 --- a/tests/components/demo/test_light.py +++ b/tests/components/demo/test_light.py @@ -80,7 +80,7 @@ async def test_state_attributes(hass: HomeAssistant) -> None: SERVICE_TURN_ON, { ATTR_ENTITY_ID: ENTITY_LIGHT, - ATTR_EFFECT: "none", + ATTR_EFFECT: "off", ATTR_COLOR_TEMP_KELVIN: 2500, }, blocking=True, @@ -90,7 +90,7 @@ async def test_state_attributes(hass: HomeAssistant) -> None: 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" + assert state.attributes.get(ATTR_EFFECT) == "off" await hass.services.async_call( LIGHT_DOMAIN, diff --git a/tests/components/demo/test_valve.py b/tests/components/demo/test_valve.py new file mode 100644 index 00000000000..1057065ce70 --- /dev/null +++ b/tests/components/demo/test_valve.py @@ -0,0 +1,83 @@ +"""The tests for the Demo valve platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.demo import DOMAIN, valve as demo_valve +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + ValveState, +) +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events + +FRONT_GARDEN = "valve.front_garden" +ORCHARD = "valve.orchard" + + +@pytest.fixture +async def valve_only() -> None: + """Enable only the valve platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.VALVE], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, valve_only: None): + """Set up demo component.""" + assert await async_setup_component( + hass, VALVE_DOMAIN, {VALVE_DOMAIN: {"platform": DOMAIN}} + ) + await hass.async_block_till_done() + + +@patch.object(demo_valve, "OPEN_CLOSE_DELAY", 0) +async def test_closing(hass: HomeAssistant) -> None: + """Test the closing of a valve.""" + state = hass.states.get(FRONT_GARDEN) + assert state.state == ValveState.OPEN + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: FRONT_GARDEN}, + blocking=False, + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == FRONT_GARDEN + assert state_changes[0].data["new_state"].state == ValveState.CLOSING + + assert state_changes[1].data["entity_id"] == FRONT_GARDEN + assert state_changes[1].data["new_state"].state == ValveState.CLOSED + + +@patch.object(demo_valve, "OPEN_CLOSE_DELAY", 0) +async def test_opening(hass: HomeAssistant) -> None: + """Test the opening of a valve.""" + state = hass.states.get(ORCHARD) + assert state.state == ValveState.CLOSED + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + VALVE_DOMAIN, SERVICE_OPEN_VALVE, {ATTR_ENTITY_ID: ORCHARD}, blocking=False + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == ORCHARD + assert state_changes[0].data["new_state"].state == ValveState.OPENING + + assert state_changes[1].data["entity_id"] == ORCHARD + assert state_changes[1].data["new_state"].state == ValveState.OPEN diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py index efdde93173c..3f27d2366a5 100644 --- a/tests/components/derivative/test_config_flow.py +++ b/tests/components/derivative/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import selector -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -64,17 +64,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: assert config_entry.title == "My derivative" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize("platform", ["sensor"]) async def test_options(hass: HomeAssistant, platform) -> None: """Test reconfiguring.""" @@ -104,10 +93,10 @@ async def test_options(hass: HomeAssistant, platform) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "round") == 1.0 - assert get_suggested(schema, "time_window") == {"seconds": 0.0} - assert get_suggested(schema, "unit_prefix") == "k" - assert get_suggested(schema, "unit_time") == "min" + assert get_schema_suggested_value(schema, "round") == 1.0 + assert get_schema_suggested_value(schema, "time_window") == {"seconds": 0.0} + assert get_schema_suggested_value(schema, "unit_prefix") == "k" + assert get_schema_suggested_value(schema, "unit_time") == "min" source = schema["source"] assert isinstance(source, selector.EntitySelector) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index a543de974f1..f8d88066f16 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -39,7 +39,7 @@ async def test_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}, force_update=True) + hass.states.async_set(entity_id, 1, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -51,6 +51,49 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("unit_of_measurement") == "kW" +async def test_no_change(hass: HomeAssistant) -> None: + """Test derivative sensor state updated when source sensor doesn't change.""" + config = { + "sensor": { + "platform": "derivative", + "name": "derivative", + "source": "sensor.energy", + "unit": "kW", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + base = dt_util.utcnow() + with freeze_time(base) as freezer: + hass.states.async_set(entity_id, 0, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.derivative") + assert state is not None + + # Testing a energy sensor at 1 kWh for 1hour = 0kW + assert round(float(state.state), config["sensor"]["round"]) == 0.0 + + assert state.attributes.get("unit_of_measurement") == "kW" + + assert state.last_changed == base + timedelta(seconds=2 * 3600) + + async def _setup_sensor( hass: HomeAssistant, config: dict[str, Any] ) -> tuple[dict[str, Any], str]: @@ -86,7 +129,7 @@ async def setup_tests( with freeze_time(base) as freezer: for time, value in zip(times, values, strict=False): freezer.move_to(base + timedelta(seconds=time)) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") @@ -159,6 +202,53 @@ async def test_dataSet6(hass: HomeAssistant) -> None: await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) +async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: + """Test that zeroes are properly handled within the time window.""" + # We simulate the following situation: + # The temperature rises 1 °C per minute for 10 minutes long. Then, it + # stays constant for another 10 minutes. There is a data point every + # minute and we use a time window of 10 minutes. + # Therefore, we can expect the derivative to peak at 1 after 10 minutes + # and then fall down to 0 in steps of 10%. + + temperature_values = [] + for temperature in range(10): + temperature_values += [temperature] + temperature_values += [10] * 11 + time_window = 600 + times = list(range(0, 1200 + 60, 60)) + + config, entity_id = await _setup_sensor( + hass, + { + "time_window": {"seconds": time_window}, + "unit_time": UnitOfTime.MINUTES, + "round": 1, + }, + ) + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + last_derivative = 0 + for time, value in zip(times, temperature_values, strict=True): + now = base + timedelta(seconds=time) + freezer.move_to(now) + hass.states.async_set(entity_id, value, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + + if time_window == time: + assert derivative == 1.0 + elif time_window < time < time_window * 2: + assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) + elif time == time_window * 2: + assert derivative == 0 + + last_derivative = derivative + + async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> None: """Test derivative sensor state.""" # We simulate the following situation: @@ -188,7 +278,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time < times[-1] - time_window: @@ -232,7 +322,7 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time and time > times[3]: @@ -270,7 +360,7 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") derivative = round(float(state.state), config["sensor"]["round"]) @@ -302,24 +392,22 @@ async def test_prefix(hass: HomeAssistant) -> None: entity_id, 1000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set( entity_id, - 1000, + 2000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") assert state is not None - # Testing a power sensor at 1000 Watts for 1hour = 0kW/h - assert round(float(state.state), config["sensor"]["round"]) == 0.0 + # Testing a power sensor increasing by 1000 Watts per hour = 1kW/h + assert round(float(state.state), config["sensor"]["round"]) == 1.0 assert state.attributes.get("unit_of_measurement") == f"kW/{UnitOfTime.HOURS}" @@ -345,7 +433,7 @@ async def test_suffix(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1000, {}, force_update=True) + hass.states.async_set(entity_id, 1000, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -375,7 +463,6 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: entity_id, value, {ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING}, - force_update=True, ) await hass.async_block_till_done() diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index fa1e65ded51..e792d239d59 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -146,12 +146,14 @@ class MockTrackerEntity(TrackerEntity): location_name: str | None = None, latitude: float | None = None, longitude: float | None = None, + location_accuracy: float = 0, ) -> None: """Initialize entity.""" self._battery_level = battery_level self._location_name = location_name self._latitude = latitude self._longitude = longitude + self._location_accuracy = location_accuracy @property def battery_level(self) -> int | None: @@ -181,6 +183,11 @@ class MockTrackerEntity(TrackerEntity): """Return longitude value of the device.""" return self._longitude + @property + def location_accuracy(self) -> float: + """Return the accuracy of the location in meters.""" + return self._location_accuracy + @pytest.fixture(name="battery_level") def battery_level_fixture() -> int | None: @@ -206,6 +213,12 @@ def longitude_fixture() -> float | None: return None +@pytest.fixture(name="location_accuracy") +def accuracy_fixture() -> float: + """Return the location accuracy of the entity for the test.""" + return 0 + + @pytest.fixture(name="tracker_entity") def tracker_entity_fixture( entity_id: str, @@ -213,6 +226,7 @@ def tracker_entity_fixture( location_name: str | None, latitude: float | None, longitude: float | None, + location_accuracy: float = 0, ) -> MockTrackerEntity: """Create a test tracker entity.""" entity = MockTrackerEntity( @@ -220,6 +234,7 @@ def tracker_entity_fixture( location_name=location_name, latitude=latitude, longitude=longitude, + location_accuracy=location_accuracy, ) entity.entity_id = entity_id return entity @@ -513,6 +528,7 @@ def test_tracker_entity() -> None: assert entity.battery_level is None assert entity.should_poll is False assert entity.force_update is True + assert entity.location_accuracy == 0 class MockEntity(TrackerEntity): """Mock tracker class.""" diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index ebff89e1a15..860c470fc37 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -33,21 +33,19 @@ HOME_LONGITUDE = -117.237561 @pytest.fixture(autouse=True) -def setup_zone(hass: HomeAssistant) -> None: +async def setup_zone(hass: HomeAssistant) -> None: """Create test zone.""" - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": HOME_LATITUDE, - "longitude": HOME_LONGITUDE, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": HOME_LATITUDE, + "longitude": HOME_LONGITUDE, + "radius": 250, + } + }, ) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index ea07365bd2f..94e1803a92d 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -25,7 +25,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery -from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -34,6 +33,7 @@ from . import common from .common import MockScanner, mock_legacy_device_tracker_setup from tests.common import ( + RegistryEntryWithDefaults, assert_setup_component, async_fire_time_changed, mock_registry, @@ -400,7 +400,7 @@ async def test_see_service_guard_config_entry( mock_registry( hass, { - entity_id: RegistryEntry( + entity_id: RegistryEntryWithDefaults( entity_id=entity_id, unique_id=1, platform=const.DOMAIN ) }, diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index d611c73cf2c..24f4e64ffe6 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -1,5 +1,6 @@ """Mocks for tests.""" +from datetime import UTC from typing import Any from unittest.mock import MagicMock @@ -28,6 +29,7 @@ class BinarySensorPropertyMock(BinarySensorProperty): def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() + self._timezone = UTC self.element_uid = "Test" self.key_count = 1 self.sensor_type = "door" @@ -41,6 +43,7 @@ class BinarySwitchPropertyMock(BinarySwitchProperty): def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() + self._timezone = UTC self.element_uid = "Test" self.state = False @@ -51,6 +54,7 @@ class ConsumptionPropertyMock(ConsumptionProperty): def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() + self._timezone = UTC self.element_uid = "devolo.Meter:Test" self.current_unit = "W" self.total_unit = "kWh" @@ -68,6 +72,7 @@ class MultiLevelSensorPropertyMock(MultiLevelSensorProperty): self._unit = "°C" self._value = 20 self._logger = MagicMock() + self._timezone = UTC class BrightnessSensorPropertyMock(MultiLevelSensorProperty): @@ -80,6 +85,7 @@ class BrightnessSensorPropertyMock(MultiLevelSensorProperty): self._unit = "%" self._value = 20 self._logger = MagicMock() + self._timezone = UTC class MultiLevelSwitchPropertyMock(MultiLevelSwitchProperty): @@ -92,6 +98,7 @@ class MultiLevelSwitchPropertyMock(MultiLevelSwitchProperty): self.max = 24 self._value = 20 self._logger = MagicMock() + self._timezone = UTC class SirenPropertyMock(MultiLevelSwitchProperty): @@ -105,6 +112,7 @@ class SirenPropertyMock(MultiLevelSwitchProperty): self.switch_type = "tone" self._value = 0 self._logger = MagicMock() + self._timezone = UTC class SettingsMock(SettingsProperty): @@ -113,6 +121,7 @@ class SettingsMock(SettingsProperty): def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() + self._timezone = UTC self.name = "Test" self.zone = "Test" self.tone = 1 diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index fd03063cd34..2b3fd989754 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -27,6 +27,13 @@ def mock_repeater_device(mock_device: MockDevice): return mock_device +@pytest.fixture +def mock_ipv6_device(mock_device: MockDevice): + """Mock connecting to a devolo home network device using IPv6.""" + mock_device.ip = "2001:db8::1" + return mock_device + + @pytest.fixture def mock_nonwifi_device(mock_device: MockDevice): """Mock connecting to a devolo home network device without wifi.""" diff --git a/tests/components/devolo_home_network/mock.py b/tests/components/devolo_home_network/mock.py index 82bf3e5ad76..6c0ea9fc6b5 100644 --- a/tests/components/devolo_home_network/mock.py +++ b/tests/components/devolo_home_network/mock.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock from devolo_plc_api.device import Device from devolo_plc_api.device_api.deviceapi import DeviceApi +from devolo_plc_api.exceptions.device import DevicePasswordProtected from devolo_plc_api.plcnet_api.plcnetapi import PlcNetApi import httpx from zeroconf import Zeroconf @@ -64,6 +65,7 @@ class MockDevice(Device): return_value=FIRMWARE_UPDATE_AVAILABLE ) self.device.async_get_led_setting = AsyncMock(return_value=False) + self.device.async_set_led_setting = AsyncMock(return_value=True) self.device.async_restart = AsyncMock(return_value=True) self.device.async_uptime = AsyncMock(return_value=UPTIME) self.device.async_start_wps = AsyncMock(return_value=True) @@ -71,6 +73,7 @@ class MockDevice(Device): return_value=CONNECTED_STATIONS ) self.device.async_get_wifi_guest_access = AsyncMock(return_value=GUEST_WIFI) + self.device.async_set_wifi_guest_access = AsyncMock(return_value=True) self.device.async_get_wifi_neighbor_access_points = AsyncMock( return_value=NEIGHBOR_ACCESS_POINTS ) @@ -79,3 +82,16 @@ class MockDevice(Device): self.plcnet.async_get_network_overview = AsyncMock(return_value=PLCNET) self.plcnet.async_identify_device_start = AsyncMock(return_value=True) self.plcnet.async_pair_device = AsyncMock(return_value=True) + + +class MockDeviceWrongPassword(MockDevice): + """Mock of a devolo Home Network device, that always complains about a wrong password.""" + + def __init__( + self, + ip: str, + zeroconf_instance: AsyncZeroconf | Zeroconf | None = None, + ) -> None: + """Bring mock in a well defined state.""" + super().__init__(ip, zeroconf_instance) + self.device.async_uptime = AsyncMock(side_effect=DevicePasswordProtected) diff --git a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr index 9df6b168f9f..950aff87752 100644 --- a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr +++ b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr @@ -3,7 +3,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'band': '5 GHz', - 'icon': 'mdi:lan-connect', 'mac': 'AA:BB:CC:DD:EE:FF', 'source_type': , 'wifi': 'Main', diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index bdc597819a7..5753fd82817 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -36,6 +36,43 @@ 'via_device_id': None, }) # --- +# name: test_setup_entry[mock_ipv6_device] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://[2001:db8::1]', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'devolo_home_network', + '1234567890', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'devolo', + 'model': 'dLAN pro 1200+ WiFi ac', + 'model_id': '2730', + 'name': 'Mock Title', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890', + 'suggested_area': None, + 'sw_version': '5.6.1', + 'via_device_id': None, + }) +# --- # name: test_setup_entry[mock_repeater_device] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index 8197ec1a1e5..e793c509b13 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -7,11 +7,9 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.devolo_home_network.const import ( - CONNECTED_TO_ROUTER, - LONG_UPDATE_INTERVAL, -) +from homeassistant.components.binary_sensor import DOMAIN as PLATFORM +from homeassistant.components.devolo_home_network.const import LONG_UPDATE_INTERVAL +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,19 +22,20 @@ from tests.common import async_fire_time_changed @pytest.mark.usefixtures("mock_device") -async def test_binary_sensor_setup(hass: HomeAssistant) -> None: +async def test_binary_sensor_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the binary sensor component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert ( - hass.states.get(f"{BINARY_SENSOR_DOMAIN}.{device_name}_{CONNECTED_TO_ROUTER}") - is None - ) - - await hass.config_entries.async_unload(entry.entry_id) + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_connected_to_router" + ).disabled @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -50,7 +49,7 @@ async def test_update_attached_to_router( """Test state change of a attached_to_router binary sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{BINARY_SENSOR_DOMAIN}.{device_name}_{CONNECTED_TO_ROUTER}" + state_key = f"{PLATFORM}.{device_name}_connected_to_router" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -81,5 +80,3 @@ async def test_update_attached_to_router( state = hass.states.get(state_key) assert state is not None assert state.state == STATE_ON - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_button.py b/tests/components/devolo_home_network/test_button.py index b2d410b03f9..8a8028454ea 100644 --- a/tests/components/devolo_home_network/test_button.py +++ b/tests/components/devolo_home_network/test_button.py @@ -8,7 +8,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as PLATFORM, SERVICE_PRESS from homeassistant.components.devolo_home_network.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -19,22 +19,27 @@ from .mock import MockDevice @pytest.mark.usefixtures("mock_device") -async def test_button_setup(hass: HomeAssistant) -> None: +async def test_button_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the button component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert ( - hass.states.get(f"{PLATFORM}.{device_name}_identify_device_with_a_blinking_led") - is not None - ) - assert hass.states.get(f"{PLATFORM}.{device_name}_start_plc_pairing") is not None - assert hass.states.get(f"{PLATFORM}.{device_name}_restart_device") is not None - assert hass.states.get(f"{PLATFORM}.{device_name}_start_wps") is not None - - await hass.config_entries.async_unload(entry.entry_id) + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_identify_device_with_a_blinking_led" + ).disabled + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_start_plc_pairing" + ).disabled + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_restart_device" + ).disabled + assert not entity_registry.async_get(f"{PLATFORM}.{device_name}_start_wps").disabled @pytest.mark.parametrize( @@ -107,8 +112,6 @@ async def test_button( blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) - async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None: """Test setting unautherized triggers the reauth flow.""" @@ -139,5 +142,3 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None assert "context" in flow assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py index 92163b5cb95..589a828f29f 100644 --- a/tests/components/devolo_home_network/test_config_flow.py +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -5,11 +5,10 @@ from __future__ import annotations from typing import Any from unittest.mock import patch -from devolo_plc_api.exceptions.device import DeviceNotFound +from devolo_plc_api.exceptions.device import DeviceNotFound, DevicePasswordProtected import pytest from homeassistant import config_entries -from homeassistant.components.devolo_home_network import config_flow from homeassistant.components.devolo_home_network.const import ( DOMAIN, SERIAL_NUMBER, @@ -27,7 +26,7 @@ from .const import ( IP, IP_ALT, ) -from .mock import MockDevice +from .mock import MockDevice, MockDeviceWrongPassword async def test_form(hass: HomeAssistant, info: dict[str, Any]) -> None: @@ -44,15 +43,13 @@ async def test_form(hass: HomeAssistant, info: dict[str, Any]) -> None: ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_IP_ADDRESS: IP, - }, + {CONF_IP_ADDRESS: IP, CONF_PASSWORD: ""}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["result"].unique_id == info["serial_number"] - assert result2["title"] == info["title"] + assert result2["result"].unique_id == info[SERIAL_NUMBER] + assert result2["title"] == info[TITLE] assert result2["data"] == { CONF_IP_ADDRESS: IP, CONF_PASSWORD: "", @@ -62,7 +59,11 @@ async def test_form(hass: HomeAssistant, info: dict[str, Any]) -> None: @pytest.mark.parametrize( ("exception_type", "expected_error"), - [(DeviceNotFound(IP), "cannot_connect"), (Exception, "unknown")], + [ + (DeviceNotFound(IP), "cannot_connect"), + (DevicePasswordProtected, "invalid_auth"), + (Exception, "unknown"), + ], ) async def test_form_error(hass: HomeAssistant, exception_type, expected_error) -> None: """Test we handle errors.""" @@ -76,14 +77,30 @@ async def test_form_error(hass: HomeAssistant, exception_type, expected_error) - ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_IP_ADDRESS: IP, - }, + {CONF_IP_ADDRESS: IP, CONF_PASSWORD: ""}, ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {CONF_BASE: expected_error} + with ( + patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDevice, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_IP_ADDRESS: IP, CONF_PASSWORD: ""}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + async def test_zeroconf(hass: HomeAssistant) -> None: """Test that the zeroconf form is served.""" @@ -108,9 +125,15 @@ async def test_zeroconf(hass: HomeAssistant) -> None: == DISCOVERY_INFO.hostname.split(".", maxsplit=1)[0] ) - with patch( - "homeassistant.components.devolo_home_network.async_setup_entry", - return_value=True, + with ( + patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDevice, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -127,6 +150,69 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "1234567890" +async def test_zeroconf_wrong_auth(hass: HomeAssistant) -> None: + """Test that the zeroconf form asks for password if authorization fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"] == {"host_name": "test"} + + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + + assert ( + context["title_placeholders"][CONF_NAME] + == DISCOVERY_INFO.hostname.split(".", maxsplit=1)[0] + ) + + with ( + patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDeviceWrongPassword, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {CONF_BASE: "invalid_auth"} + + with ( + patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDevice, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PASSWORD: "new-password", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + + async def test_abort_zeroconf_wrong_device(hass: HomeAssistant) -> None: """Test we abort zeroconf for wrong devices.""" result = await hass.config_entries.flow.async_init( @@ -179,31 +265,42 @@ async def test_form_reauth(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - with patch( - "homeassistant.components.devolo_home_network.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + with ( + patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDeviceWrongPassword, + ), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_PASSWORD: "test-password-new"}, + {CONF_PASSWORD: "test-wrong-password"}, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {CONF_BASE: "invalid_auth"} - await hass.config_entries.async_unload(entry.entry_id) - - -@pytest.mark.usefixtures("mock_device") -@pytest.mark.usefixtures("mock_zeroconf") -async def test_validate_input(hass: HomeAssistant) -> None: - """Test input validation.""" - with patch( - "homeassistant.components.devolo_home_network.config_flow.Device", - new=MockDevice, + with ( + patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDevice, + ), ): - info = await config_flow.validate_input(hass, {CONF_IP_ADDRESS: IP}) - assert SERIAL_NUMBER in info - assert TITLE in info + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "test-right-password"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + assert entry.data[CONF_PASSWORD] == "test-right-password" diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 1cce11c36f9..2af6a1e3759 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.device_tracker import DOMAIN as PLATFORM @@ -25,6 +26,7 @@ STATION = CONNECTED_STATIONS[0] SERIAL = DISCOVERY_INFO.properties["SN"] +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_tracker( hass: HomeAssistant, mock_device: MockDevice, @@ -42,14 +44,6 @@ async def test_device_tracker( freezer.tick(LONG_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - - # Enable entity - entity_registry.async_update_entity(state_key, disabled_by=None) - await hass.async_block_till_done() - freezer.tick(LONG_UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert hass.states.get(state_key) == snapshot # Emulate state change @@ -76,8 +70,6 @@ async def test_device_tracker( assert state is not None assert state.state == STATE_UNAVAILABLE - await hass.config_entries.async_unload(entry.entry_id) - async def test_restoring_clients( hass: HomeAssistant, diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py index f13db4fce9d..54a8af3af6e 100644 --- a/tests/components/devolo_home_network/test_image.py +++ b/tests/components/devolo_home_network/test_image.py @@ -9,7 +9,8 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.devolo_home_network.const import SHORT_UPDATE_INTERVAL -from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN +from homeassistant.components.image import DOMAIN as PLATFORM +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,21 +25,20 @@ from tests.typing import ClientSessionGenerator @pytest.mark.usefixtures("mock_device") -async def test_image_setup(hass: HomeAssistant) -> None: +async def test_image_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the image component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert ( - hass.states.get( - f"{IMAGE_DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" - ) - is not None - ) - - await hass.config_entries.async_unload(entry.entry_id) + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_guest_wi_fi_credentials_as_qr_code" + ).disabled @pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") @@ -53,7 +53,7 @@ async def test_guest_wifi_qr( """Test showing a QR code of the guest wifi credentials.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{IMAGE_DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" + state_key = f"{PLATFORM}.{device_name}_guest_wi_fi_credentials_as_qr_code" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -95,5 +95,3 @@ async def test_guest_wifi_qr( resp = await client.get(f"/api/image_proxy/{state_key}") assert resp.status == HTTPStatus.OK assert await resp.read() != body - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 71823eabe82..c25aff7e9ad 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.components.update import DOMAIN as UPDATE from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import async_get_platforms @@ -24,10 +24,10 @@ from . import configure_integration from .const import IP from .mock import MockDevice -from tests.common import MockConfigEntry - -@pytest.mark.parametrize("device", ["mock_device", "mock_repeater_device"]) +@pytest.mark.parametrize( + "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] +) async def test_setup_entry( hass: HomeAssistant, device: str, @@ -48,27 +48,6 @@ async def test_setup_entry( assert device_info == snapshot -@pytest.mark.usefixtures("mock_device") -async def test_setup_without_password(hass: HomeAssistant) -> None: - """Test setup entry without a device password set like used before HA Core 2022.06.""" - config = { - CONF_IP_ADDRESS: IP, - } - entry = MockConfigEntry(domain=DOMAIN, data=config) - entry.add_to_hass(hass) - # Patching async_forward_entry_setup* is not advisable, and should be refactored - # in the future. - with ( - patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", - return_value=True, - ), - patch("homeassistant.core.EventBus.async_listen_once"), - ): - assert await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED - - async def test_setup_device_not_found(hass: HomeAssistant) -> None: """Test setup entry.""" entry = configure_integration(hass) diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index cf0207a2800..d01eb9f9e38 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -27,49 +27,41 @@ from tests.common import async_fire_time_changed @pytest.mark.usefixtures("mock_device") -async def test_sensor_setup(hass: HomeAssistant) -> None: +async def test_sensor_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the sensor component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert ( - hass.states.get(f"{PLATFORM}.{device_name}_connected_wi_fi_clients") is not None - ) - assert hass.states.get(f"{PLATFORM}.{device_name}_connected_plc_devices") is None - assert ( - hass.states.get(f"{PLATFORM}.{device_name}_neighboring_wi_fi_networks") is None - ) - assert ( - hass.states.get( - f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" - ) - is not None - ) - assert ( - hass.states.get( - f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" - ) - is not None - ) - assert ( - hass.states.get( - f"{PLATFORM}.{device_name}_plc_downlink_phyrate_{PLCNET.devices[2].user_device_name}" - ) - is None - ) - assert ( - hass.states.get( - f"{PLATFORM}.{device_name}_plc_uplink_phyrate_{PLCNET.devices[2].user_device_name}" - ) - is None - ) - assert ( - hass.states.get(f"{PLATFORM}.{device_name}_last_restart_of_the_device") is None - ) - - await hass.config_entries.async_unload(entry.entry_id) + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_connected_wi_fi_clients" + ).disabled + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_connected_plc_devices" + ).disabled + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_neighboring_wi_fi_networks" + ).disabled + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + ).disabled + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + ).disabled + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[2].user_device_name}" + ).disabled + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[2].user_device_name}" + ).disabled + assert entity_registry.async_get( + f"{PLATFORM}.{device_name}_last_restart_of_the_device" + ).disabled @pytest.mark.parametrize( @@ -145,8 +137,6 @@ async def test_sensor( assert state is not None assert state.state == expected_state - await hass.config_entries.async_unload(entry.entry_id) - async def test_update_plc_phyrates( hass: HomeAssistant, @@ -198,8 +188,6 @@ async def test_update_plc_phyrates( assert state is not None assert state.state == str(PLCNET.data_rates[0].tx_rate) - await hass.config_entries.async_unload(entry.entry_id) - async def test_update_last_update_auth_failed( hass: HomeAssistant, mock_device: MockDevice @@ -222,5 +210,3 @@ async def test_update_last_update_auth_failed( assert "context" in flow assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index b96697dc9cc..1ab2a1c354b 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -1,7 +1,7 @@ """Tests for the devolo Home Network switch.""" from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from devolo_plc_api.device_api import WifiGuestAccessGet from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable @@ -16,6 +16,7 @@ from homeassistant.components.devolo_home_network.const import ( from homeassistant.components.switch import DOMAIN as PLATFORM from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -34,17 +35,23 @@ from tests.common import async_fire_time_changed @pytest.mark.usefixtures("mock_device") -async def test_switch_setup(hass: HomeAssistant) -> None: +async def test_switch_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the switch component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(f"{PLATFORM}.{device_name}_enable_guest_wi_fi") is not None - assert hass.states.get(f"{PLATFORM}.{device_name}_enable_leds") is not None - - await hass.config_entries.async_unload(entry.entry_id) + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_enable_guest_wi_fi" + ).disabled + assert not entity_registry.async_get( + f"{PLATFORM}.{device_name}_enable_leds" + ).disabled async def test_update_guest_wifi_status_auth_failed( @@ -69,8 +76,6 @@ async def test_update_guest_wifi_status_auth_failed( assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - await hass.config_entries.async_unload(entry.entry_id) - async def test_update_enable_guest_wifi( hass: HomeAssistant, @@ -106,18 +111,15 @@ async def test_update_enable_guest_wifi( mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( enabled=False ) - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access", - new=AsyncMock(), - ) as turn_off: - await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True - ) + await hass.services.async_call( + PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True + ) - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_OFF - turn_off.assert_called_once_with(False) + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_OFF + mock_device.device.async_set_wifi_guest_access.assert_called_once_with(False) + mock_device.device.async_set_wifi_guest_access.reset_mock() freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) async_fire_time_changed(hass) @@ -127,18 +129,15 @@ async def test_update_enable_guest_wifi( mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( enabled=True ) - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access", - new=AsyncMock(), - ) as turn_on: - await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True - ) + await hass.services.async_call( + PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + ) - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_ON - turn_on.assert_called_once_with(True) + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_ON + mock_device.device.async_set_wifi_guest_access.assert_called_once_with(True) + mock_device.device.async_set_wifi_guest_access.reset_mock() freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) async_fire_time_changed(hass) @@ -146,19 +145,17 @@ async def test_update_enable_guest_wifi( # Device unavailable mock_device.device.async_get_wifi_guest_access.side_effect = DeviceUnavailable() - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access", - side_effect=DeviceUnavailable, + mock_device.device.async_set_wifi_guest_access.side_effect = DeviceUnavailable() + + with pytest.raises( + HomeAssistantError, match=f"Device {entry.title} did not respond" ): await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True + PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True ) - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE - - await hass.config_entries.async_unload(entry.entry_id) + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE async def test_update_enable_leds( @@ -191,18 +188,15 @@ async def test_update_enable_leds( # Switch off mock_device.device.async_get_led_setting.return_value = False - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting", - new=AsyncMock(), - ) as turn_off: - await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True - ) + await hass.services.async_call( + PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True + ) - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_OFF - turn_off.assert_called_once_with(False) + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_OFF + mock_device.device.async_set_led_setting.assert_called_once_with(False) + mock_device.device.async_set_led_setting.reset_mock() freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) async_fire_time_changed(hass) @@ -210,18 +204,15 @@ async def test_update_enable_leds( # Switch on mock_device.device.async_get_led_setting.return_value = True - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting", - new=AsyncMock(), - ) as turn_on: - await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True - ) + await hass.services.async_call( + PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True + ) - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_ON - turn_on.assert_called_once_with(True) + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_ON + mock_device.device.async_set_led_setting.assert_called_once_with(True) + mock_device.device.async_set_led_setting.reset_mock() freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) async_fire_time_changed(hass) @@ -229,19 +220,17 @@ async def test_update_enable_leds( # Device unavailable mock_device.device.async_get_led_setting.side_effect = DeviceUnavailable() - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting", - side_effect=DeviceUnavailable, + mock_device.device.async_set_led_setting.side_effect = DeviceUnavailable() + + with pytest.raises( + HomeAssistantError, match=f"Device {entry.title} did not respond" ): await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True + PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True ) - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE - - await hass.config_entries.async_unload(entry.entry_id) + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -308,7 +297,7 @@ async def test_auth_failed( with pytest.raises(HomeAssistantError): await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True + PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True ) await hass.async_block_till_done() @@ -336,5 +325,3 @@ async def test_auth_failed( assert "context" in flow assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index 4fe7a173309..034d1bad7f6 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -11,7 +11,7 @@ from homeassistant.components.devolo_home_network.const import ( FIRMWARE_UPDATE_INTERVAL, ) from homeassistant.components.update import DOMAIN as PLATFORM, SERVICE_INSTALL -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -25,16 +25,18 @@ from tests.common import async_fire_time_changed @pytest.mark.usefixtures("mock_device") -async def test_update_setup(hass: HomeAssistant) -> None: +async def test_update_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: """Test default setup of the update component.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(f"{PLATFORM}.{device_name}_firmware") is not None - - await hass.config_entries.async_unload(entry.entry_id) + assert not entity_registry.async_get(f"{PLATFORM}.{device_name}_firmware").disabled async def test_update_firmware( @@ -85,8 +87,6 @@ async def test_update_firmware( assert device_info is not None assert device_info.sw_version == mock_device.firmware_version - await hass.config_entries.async_unload(entry.entry_id) - async def test_device_failure_check( hass: HomeAssistant, @@ -137,8 +137,6 @@ async def test_device_failure_update( blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) - async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None: """Test updating unauthorized triggers the reauth flow.""" @@ -168,5 +166,3 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None assert "context" in flow assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 223dc83f83a..4f7680ee2ab 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -1,5 +1,7 @@ """Test the DHCP discovery integration.""" +from __future__ import annotations + from collections.abc import Awaitable, Callable import datetime import threading @@ -24,6 +26,7 @@ from homeassistant.components.device_tracker import ( SourceType, ) from homeassistant.components.dhcp.const import DOMAIN +from homeassistant.components.dhcp.models import DHCPData from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, @@ -147,13 +150,14 @@ async def _async_get_handle_dhcp_packet( integration_matchers: dhcp.DhcpMatchers, address_data: dict | None = None, ) -> Callable[[Any], Awaitable[None]]: + """Make a handler for a dhcp packet.""" if address_data is None: address_data = {} dhcp_watcher = dhcp.DHCPWatcher( hass, - address_data, - integration_matchers, + DHCPData(integration_matchers, set(), address_data), ) + with patch("aiodhcpwatcher.async_start"): await dhcp_watcher.async_start() @@ -168,6 +172,53 @@ async def _async_get_handle_dhcp_packet( return cast("Callable[[Any], Awaitable[None]]", _async_handle_dhcp_packet) +async def test_dhcp_start_using_multiple_interfaces( + hass: HomeAssistant, +) -> None: + """Test start using multiple interfaces.""" + + def _generate_mock_adapters(): + return [ + { + "index": 1, + "auto": False, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.0.1", "network_prefix": 24}], + "ipv6": [], + "name": "eth0", + }, + { + "index": 2, + "auto": True, + "default": True, + "enabled": True, + "ipv4": [{"address": "192.168.1.1", "network_prefix": 24}], + "ipv6": [], + "name": "eth1", + }, + ] + + integration_matchers = dhcp.async_index_integration_matchers( + [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}] + ) + dhcp_watcher = dhcp.DHCPWatcher( + hass, + DHCPData(integration_matchers, set(), {}), + ) + + with ( + patch("aiodhcpwatcher.async_start") as mock_start, + patch( + "homeassistant.components.dhcp.network.async_get_adapters", + return_value=_generate_mock_adapters(), + ), + ): + await dhcp_watcher.async_start() + + mock_start.assert_called_with(dhcp_watcher._async_process_dhcp_request, [1, 2]) + + async def test_dhcp_match_hostname_and_macaddress(hass: HomeAssistant) -> None: """Test matching based on hostname and macaddress.""" integration_matchers = dhcp.async_index_integration_matchers( @@ -666,6 +717,45 @@ async def test_setup_fails_with_broken_libpcap( ) +def _make_device_tracker_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.DeviceTrackerWatcher: + return dhcp.DeviceTrackerWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + +def _make_device_tracker_registered_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.DeviceTrackerRegisteredWatcher: + return dhcp.DeviceTrackerRegisteredWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + +def _make_network_watcher( + hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher] +) -> dhcp.NetworkWatcher: + return dhcp.NetworkWatcher( + hass, + DHCPData( + dhcp.async_index_integration_matchers(matchers), + set(), + {}, + ), + ) + + async def test_device_tracker_hostname_and_macaddress_exists_before_start( hass: HomeAssistant, ) -> None: @@ -682,18 +772,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start( ) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -716,18 +803,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start( async def test_device_tracker_registered(hass: HomeAssistant) -> None: """Test matching based on hostname and macaddress when registered.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher( + device_tracker_watcher = _make_device_tracker_registered_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -756,18 +840,15 @@ async def test_device_tracker_registered(hass: HomeAssistant) -> None: async def test_device_tracker_registered_hostname_none(hass: HomeAssistant) -> None: """Test handle None hostname.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -789,18 +870,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start( """Test matching based on hostname and macaddress after start.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -837,18 +915,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_home( """Test matching based on hostname and macaddress after start but not home.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -875,9 +950,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_router( """Test matching based on hostname and macaddress after start but not router.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -905,9 +979,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi """Test matching based on hostname and macaddress after start but missing hostname.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -934,9 +1007,8 @@ async def test_device_tracker_invalid_ip_address( """Test an invalid ip address.""" with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], ) device_tracker_watcher.async_start() @@ -974,18 +1046,15 @@ async def test_device_tracker_ignore_self_assigned_ips_before_start( ) with patch.object(hass.config_entries.flow, "async_init") as mock_init: - device_tracker_watcher = dhcp.DeviceTrackerWatcher( + device_tracker_watcher = _make_device_tracker_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1010,18 +1079,15 @@ async def test_aiodiscover_finds_new_hosts(hass: HomeAssistant) -> None: ], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1073,18 +1139,15 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname( ], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "irobot-*", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "irobot-*", + "macaddress": "B8B7F1*", + } + ], ) device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1123,19 +1186,17 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) - return_value=[], ), ): - device_tracker_watcher = dhcp.NetworkWatcher( + device_tracker_watcher = _make_network_watcher( hass, - {}, - dhcp.async_index_integration_matchers( - [ - { - "domain": "mock-domain", - "hostname": "connect", - "macaddress": "B8B7F1*", - } - ] - ), + [ + { + "domain": "mock-domain", + "hostname": "connect", + "macaddress": "B8B7F1*", + } + ], ) + device_tracker_watcher.async_start() await hass.async_block_till_done() @@ -1235,7 +1296,7 @@ async def test_dhcp_rediscover( hass, integration_matchers, address_data ) rediscovery_watcher = dhcp.RediscoveryWatcher( - hass, address_data, integration_matchers + hass, DHCPData(integration_matchers, set(), address_data) ) rediscovery_watcher.async_start() with patch.object(hass.config_entries.flow, "async_init") as mock_init: @@ -1329,7 +1390,7 @@ async def test_dhcp_rediscover_no_match( hass, integration_matchers, address_data ) rediscovery_watcher = dhcp.RediscoveryWatcher( - hass, address_data, integration_matchers + hass, DHCPData(integration_matchers, set(), address_data) ) rediscovery_watcher.async_start() with patch.object(hass.config_entries.flow, "async_init") as mock_init: diff --git a/tests/components/dhcp/test_websocket_api.py b/tests/components/dhcp/test_websocket_api.py new file mode 100644 index 00000000000..0b21ef8e856 --- /dev/null +++ b/tests/components/dhcp/test_websocket_api.py @@ -0,0 +1,76 @@ +"""The tests for the dhcp WebSocket API.""" + +import asyncio +from collections.abc import Callable +from unittest.mock import patch + +import aiodhcpwatcher + +from homeassistant.components.dhcp import DOMAIN +from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +async def test_subscribe_discovery( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test dhcp subscribe_discovery.""" + saved_callback: Callable[[aiodhcpwatcher.DHCPRequest], None] | None = None + + async def mock_start( + callback: Callable[[aiodhcpwatcher.DHCPRequest], None], + if_indexes: list[int] | None = None, + ) -> None: + """Mock start.""" + nonlocal saved_callback + saved_callback = callback + + with ( + patch("homeassistant.components.dhcp.aiodhcpwatcher.async_start", mock_start), + patch("homeassistant.components.dhcp.DiscoverHosts"), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.2", "happy", "44:44:33:11:23:12")) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "dhcp/subscribe_discovery", + } + ) + 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": [ + { + "hostname": "happy", + "ip_address": "4.3.2.2", + "mac_address": "44:44:33:11:23:12", + } + ] + } + + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.1", "sad", "44:44:33:11:23:13")) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "hostname": "sad", + "ip_address": "4.3.2.1", + "mac_address": "44:44:33:11:23:13", + } + ] + } diff --git a/tests/components/discovergy/test_diagnostics.py b/tests/components/discovergy/test_diagnostics.py index 5c231c3d221..ca05edfe8c2 100644 --- a/tests/components/discovergy/test_diagnostics.py +++ b/tests/components/discovergy/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Discovergy diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/discovergy/test_sensor.py b/tests/components/discovergy/test_sensor.py index 814efb1ba57..20d8756ec44 100644 --- a/tests/components/discovergy/test_sensor.py +++ b/tests/components/discovergy/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index a92f7807912..f1ac2d6b1c2 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -1058,6 +1058,7 @@ async def test_browse_media( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -1070,6 +1071,7 @@ async def test_browse_media( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -1153,6 +1155,7 @@ async def test_browse_media_unfiltered( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } @@ -1163,6 +1166,7 @@ async def test_browse_media_unfiltered( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } diff --git a/tests/components/dnsip/test_config_flow.py b/tests/components/dnsip/test_config_flow.py index 9d92cb3554c..1a565345275 100644 --- a/tests/components/dnsip/test_config_flow.py +++ b/tests/components/dnsip/test_config_flow.py @@ -224,16 +224,20 @@ async def test_options_flow(hass: HomeAssistant) -> None: 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_RESOLVER: "8.8.8.8", - CONF_RESOLVER_IPV6: "2001:4860:4860::8888", - CONF_PORT: 53, - CONF_PORT_IPV6: 53, - }, - ) - await hass.async_block_till_done() + with patch( + "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RESOLVER: "8.8.8.8", + CONF_RESOLVER_IPV6: "2001:4860:4860::8888", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, + }, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { diff --git a/tests/components/dormakaba_dkey/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index 8d8140d609a..b3657810006 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -9,6 +9,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.dormakaba_dkey.const import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType @@ -143,6 +144,43 @@ async def test_async_step_user_takes_precedence_over_discovery( assert not hass.config_entries.flow.async_progress(DOMAIN) +async def test_user_setup_removes_ignored_entry(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + ignored_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DKEY_DISCOVERY_INFO.address, + source=SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + assert hass.config_entries.async_entries(DOMAIN) == [ignored_entry] + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info", + return_value=[NOT_DKEY_DISCOVERY_INFO, DKEY_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"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: DKEY_DISCOVERY_INFO.address, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + await _test_common_success(hass, result) + + # Check the ignored entry is removed + assert ignored_entry not in hass.config_entries.async_entries(DOMAIN) + + 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/downloader/test_init.py b/tests/components/downloader/test_init.py index 70dfd227019..e74eb376b39 100644 --- a/tests/components/downloader/test_init.py +++ b/tests/components/downloader/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from homeassistant.components.downloader import ( +from homeassistant.components.downloader.const import ( CONF_DOWNLOAD_DIR, DOMAIN, SERVICE_DOWNLOAD_FILE, diff --git a/tests/components/drop_connect/common.py b/tests/components/drop_connect/common.py index 9eb76f57dad..a695d85bab7 100644 --- a/tests/components/drop_connect/common.py +++ b/tests/components/drop_connect/common.py @@ -1,6 +1,6 @@ """Define common test values.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.drop_connect.const import ( CONF_COMMAND_TOPIC, diff --git a/tests/components/drop_connect/test_binary_sensor.py b/tests/components/drop_connect/test_binary_sensor.py index ab89e05d809..41de9d16958 100644 --- a/tests/components/drop_connect/test_binary_sensor.py +++ b/tests/components/drop_connect/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/drop_connect/test_sensor.py b/tests/components/drop_connect/test_sensor.py index c33f0aefe37..40f95c268b6 100644 --- a/tests/components/drop_connect/test_sensor.py +++ b/tests/components/drop_connect/test_sensor.py @@ -4,7 +4,7 @@ from collections.abc import Generator from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/dsmr_reader/test_diagnostics.py b/tests/components/dsmr_reader/test_diagnostics.py index 793fe1362b0..070d7d152ab 100644 --- a/tests/components/dsmr_reader/test_diagnostics.py +++ b/tests/components/dsmr_reader/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.dsmr_reader.const import DOMAIN diff --git a/tests/components/duckdns/test_init.py b/tests/components/duckdns/test_init.py index 313cc91aa18..7806d57e934 100644 --- a/tests/components/duckdns/test_init.py +++ b/tests/components/duckdns/test_init.py @@ -31,16 +31,16 @@ async def async_set_txt(hass: HomeAssistant, txt: str | None) -> None: @pytest.fixture -def setup_duckdns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_duckdns( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up DuckDNS.""" aioclient_mock.get( duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK" ) - hass.loop.run_until_complete( - async_setup_component( - hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} - ) + await async_setup_component( + hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} ) diff --git a/tests/components/duke_energy/conftest.py b/tests/components/duke_energy/conftest.py index f74ef43bf07..f82a2353557 100644 --- a/tests/components/duke_energy/conftest.py +++ b/tests/components/duke_energy/conftest.py @@ -61,8 +61,8 @@ def mock_api() -> Generator[AsyncMock]: ): api = mock_api.return_value api.authenticate.return_value = { - "email": "TEST@EXAMPLE.COM", - "cdp_internal_user_id": "test-username", + "loginEmailAddress": "TEST@EXAMPLE.COM", + "internalUserID": "test-username", } api.get_meters.return_value = {} yield api diff --git a/tests/components/easyenergy/test_diagnostics.py b/tests/components/easyenergy/test_diagnostics.py index d0eb9de3b00..8b9d850d98c 100644 --- a/tests/components/easyenergy/test_diagnostics.py +++ b/tests/components/easyenergy/test_diagnostics.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from easyenergy import EasyEnergyNoDataError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY from homeassistant.const import ATTR_ENTITY_ID diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index c4e5a5b1966..c78df0e189a 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -172,8 +172,11 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Area cleaned', 'platform': 'ecovacs', @@ -187,6 +190,7 @@ # name: test_sensors[5xu9h3][sensor.goat_g1_area_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'area', 'friendly_name': 'Goat G1 Area cleaned', 'unit_of_measurement': , }), @@ -195,7 +199,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '10', + 'state': '0.0010', }) # --- # name: test_sensors[5xu9h3][sensor.goat_g1_battery:entity-registry] @@ -514,8 +518,11 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total area cleaned', 'platform': 'ecovacs', @@ -529,6 +536,7 @@ # name: test_sensors[5xu9h3][sensor.goat_g1_total_area_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'area', 'friendly_name': 'Goat G1 Total area cleaned', 'state_class': , 'unit_of_measurement': , @@ -538,7 +546,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '60', + 'state': '0.0060', }) # --- # name: test_sensors[5xu9h3][sensor.goat_g1_total_cleaning_duration:entity-registry] @@ -762,8 +770,11 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Area cleaned', 'platform': 'ecovacs', @@ -777,6 +788,7 @@ # name: test_sensors[qhe2o2][sensor.dusty_area_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'area', 'friendly_name': 'Dusty Area cleaned', 'unit_of_measurement': , }), @@ -1257,8 +1269,11 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total area cleaned', 'platform': 'ecovacs', @@ -1272,6 +1287,7 @@ # name: test_sensors[qhe2o2][sensor.dusty_total_area_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'area', 'friendly_name': 'Dusty Total area cleaned', 'state_class': , 'unit_of_measurement': , @@ -1553,8 +1569,11 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Area cleaned', 'platform': 'ecovacs', @@ -1568,6 +1587,7 @@ # name: test_sensors[yna5x1][sensor.ozmo_950_area_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'area', 'friendly_name': 'Ozmo 950 Area cleaned', 'unit_of_measurement': , }), @@ -1943,8 +1963,11 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total area cleaned', 'platform': 'ecovacs', @@ -1958,6 +1981,7 @@ # name: test_sensors[yna5x1][sensor.ozmo_950_total_area_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'area', 'friendly_name': 'Ozmo 950 Total area cleaned', 'state_class': , 'unit_of_measurement': , diff --git a/tests/components/ecovacs/test_binary_sensor.py b/tests/components/ecovacs/test_binary_sensor.py index b57f67e948e..0a39d3f2623 100644 --- a/tests/components/ecovacs/test_binary_sensor.py +++ b/tests/components/ecovacs/test_binary_sensor.py @@ -1,8 +1,8 @@ """Tests for Ecovacs binary sensors.""" -from deebot_client.events import WaterAmount, WaterInfoEvent +from deebot_client.events.water_info import MopAttachedEvent import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController @@ -43,16 +43,12 @@ async def test_mop_attached( assert device_entry.identifiers == {(DOMAIN, device.device_info["did"])} event_bus = device.events - await notify_and_wait( - hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=True) - ) + await notify_and_wait(hass, event_bus, MopAttachedEvent(True)) assert (state := hass.states.get(state.entity_id)) assert state == snapshot(name=f"{entity_id}-state") - await notify_and_wait( - hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=False) - ) + await notify_and_wait(hass, event_bus, MopAttachedEvent(False)) assert (state := hass.states.get(state.entity_id)) assert state.state == STATE_OFF diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py index 3021db62e6f..30a7db431d0 100644 --- a/tests/components/ecovacs/test_button.py +++ b/tests/components/ecovacs/test_button.py @@ -9,7 +9,7 @@ from deebot_client.commands.json import ( ) from deebot_client.events import LifeSpan import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.ecovacs.const import DOMAIN diff --git a/tests/components/ecovacs/test_event.py b/tests/components/ecovacs/test_event.py index 03fb79e083f..56a0298bef1 100644 --- a/tests/components/ecovacs/test_event.py +++ b/tests/components/ecovacs/test_event.py @@ -5,7 +5,7 @@ from datetime import timedelta from deebot_client.events import CleanJobStatus, ReportStatsEvent from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 13b73d853d5..c0e5ce143c9 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch from deebot_client.exceptions import DeebotError, InvalidAuthenticationError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/ecovacs/test_lawn_mower.py b/tests/components/ecovacs/test_lawn_mower.py index 2c0abd0a49e..bab1495e16c 100644 --- a/tests/components/ecovacs/test_lawn_mower.py +++ b/tests/components/ecovacs/test_lawn_mower.py @@ -7,7 +7,7 @@ from deebot_client.commands.json import Charge, CleanV2 from deebot_client.events import StateEvent from deebot_client.models import CleanAction, State import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index 32bc8f90696..dd7308e18fd 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -6,7 +6,7 @@ from deebot_client.command import Command from deebot_client.commands.json import SetCutDirection, SetVolume from deebot_client.events import CutDirectionEvent, Event, VolumeEvent import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py index 02a6b5ebfa4..c3025d99cfa 100644 --- a/tests/components/ecovacs/test_select.py +++ b/tests/components/ecovacs/test_select.py @@ -3,9 +3,9 @@ from deebot_client.command import Command from deebot_client.commands.json import SetWaterInfo from deebot_client.event_bus import EventBus -from deebot_client.events import WaterAmount, WaterInfoEvent +from deebot_client.events.water_info import WaterAmount, WaterAmountEvent import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import select from homeassistant.components.ecovacs.const import DOMAIN @@ -33,7 +33,7 @@ def platforms() -> Platform | list[Platform]: async def notify_events(hass: HomeAssistant, event_bus: EventBus): """Notify events.""" - event_bus.notify(WaterInfoEvent(WaterAmount.ULTRAHIGH)) + event_bus.notify(WaterAmountEvent(WaterAmount.ULTRAHIGH)) await block_till_done(hass, event_bus) diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 8222e9976d5..6c3900ccd19 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -14,7 +14,7 @@ from deebot_client.events import ( station, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py index 040528debaa..23c802fa0ef 100644 --- a/tests/components/ecovacs/test_switch.py +++ b/tests/components/ecovacs/test_switch.py @@ -27,7 +27,7 @@ from deebot_client.events import ( TrueDetectEvent, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index ae1bc74df90..5b828f830a4 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -1,12 +1,21 @@ """Configurations for the EHEIM Digital tests.""" from collections.abc import Generator +from datetime import time, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.heater import EheimDigitalHeater from eheimdigital.hub import EheimDigitalHub -from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode +from eheimdigital.types import ( + EheimDeviceType, + FilterErrorCode, + FilterMode, + HeaterMode, + HeaterUnit, + LightMode, +) import pytest from homeassistant.components.eheimdigital.const import DOMAIN @@ -38,6 +47,7 @@ def classic_led_ctrl_mock(): classic_led_ctrl_mock.sw_version = "1.0.0_1.0.0" classic_led_ctrl_mock.light_mode = LightMode.DAYCL_MODE classic_led_ctrl_mock.light_level = (10, 39) + classic_led_ctrl_mock.sys_led = 20 return classic_led_ctrl_mock @@ -53,15 +63,46 @@ def heater_mock(): heater_mock.temperature_unit = HeaterUnit.CELSIUS heater_mock.current_temperature = 24.2 heater_mock.target_temperature = 25.5 + heater_mock.temperature_offset = 0.1 + heater_mock.night_temperature_offset = -0.2 heater_mock.is_heating = True heater_mock.is_active = True heater_mock.operation_mode = HeaterMode.MANUAL + heater_mock.day_start_time = time(8, 0, tzinfo=timezone(timedelta(hours=1))) + heater_mock.night_start_time = time(20, 0, tzinfo=timezone(timedelta(hours=1))) + heater_mock.sys_led = 20 return heater_mock +@pytest.fixture +def classic_vario_mock(): + """Mock a classicVARIO device.""" + classic_vario_mock = MagicMock(spec=EheimDigitalClassicVario) + classic_vario_mock.mac_address = "00:00:00:00:00:03" + classic_vario_mock.device_type = EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + classic_vario_mock.name = "Mock classicVARIO" + classic_vario_mock.aquarium_name = "Mock Aquarium" + classic_vario_mock.sw_version = "1.0.0_1.0.0" + classic_vario_mock.current_speed = 75 + classic_vario_mock.manual_speed = 75 + classic_vario_mock.day_speed = 80 + classic_vario_mock.day_start_time = time(8, 0, tzinfo=timezone(timedelta(hours=1))) + classic_vario_mock.night_start_time = time( + 20, 0, tzinfo=timezone(timedelta(hours=1)) + ) + classic_vario_mock.night_speed = 20 + classic_vario_mock.is_active = True + classic_vario_mock.filter_mode = FilterMode.MANUAL + classic_vario_mock.error_code = FilterErrorCode.NO_ERROR + classic_vario_mock.service_hours = 360 + return classic_vario_mock + + @pytest.fixture def eheimdigital_hub_mock( - classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock + classic_led_ctrl_mock: MagicMock, + heater_mock: MagicMock, + classic_vario_mock: MagicMock, ) -> Generator[AsyncMock]: """Mock eheimdigital hub.""" with ( @@ -77,6 +118,7 @@ def eheimdigital_hub_mock( eheimdigital_hub_mock.return_value.devices = { "00:00:00:00:00:01": classic_led_ctrl_mock, "00:00:00:00:00:02": heater_mock, + "00:00:00:00:00:03": classic_vario_mock, } eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock yield eheimdigital_hub_mock diff --git a/tests/components/eheimdigital/snapshots/test_number.ambr b/tests/components/eheimdigital/snapshots/test_number.ambr new file mode 100644 index 00000000000..554e7c9c3a3 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_number.ambr @@ -0,0 +1,457 @@ +# serializer version: 1 +# name: test_setup[number.mock_classicledcontrol_e_system_led_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicledcontrol_e_system_led_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': 'System LED brightness', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'system_led', + 'unique_id': '00:00:00:00:00:01_system_led', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicledcontrol_e_system_led_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicLEDcontrol+e System LED brightness', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicledcontrol_e_system_led_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_classicvario_day_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicvario_day_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': 'Day speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'day_speed', + 'unique_id': '00:00:00:00:00:03_day_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicvario_day_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Day speed', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicvario_day_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_classicvario_manual_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicvario_manual_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': 'Manual speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'manual_speed', + 'unique_id': '00:00:00:00:00:03_manual_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicvario_manual_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Manual speed', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicvario_manual_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_classicvario_night_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicvario_night_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': 'Night speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_speed', + 'unique_id': '00:00:00:00:00:03_night_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicvario_night_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Night speed', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicvario_night_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_classicvario_system_led_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_classicvario_system_led_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': 'System LED brightness', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'system_led', + 'unique_id': '00:00:00:00:00:03_system_led', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_classicvario_system_led_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO System LED brightness', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_classicvario_system_led_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_heater_night_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': -5, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_heater_night_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': 'Night temperature offset', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_temperature_offset', + 'unique_id': '00:00:00:00:00:02_night_temperature_offset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[number.mock_heater_night_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Heater Night temperature offset', + 'max': 5, + 'min': -5, + 'mode': , + 'step': 0.5, + }), + 'context': , + 'entity_id': 'number.mock_heater_night_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_heater_system_led_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_heater_system_led_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': 'System LED brightness', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'system_led', + 'unique_id': '00:00:00:00:00:02_system_led', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[number.mock_heater_system_led_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Heater System LED brightness', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_heater_system_led_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[number.mock_heater_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': -3, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_heater_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': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '00:00:00:00:00:02_temperature_offset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[number.mock_heater_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Heater Temperature offset', + 'max': 3, + 'min': -3, + 'mode': , + 'step': 0.1, + }), + 'context': , + 'entity_id': 'number.mock_heater_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/snapshots/test_select.ambr b/tests/components/eheimdigital/snapshots/test_select.ambr new file mode 100644 index 00000000000..5416f5a2d78 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_select.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_setup[select.mock_classicvario_filter_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'manual', + 'pulse', + 'bio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_classicvario_filter_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': 'Filter mode', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_mode', + 'unique_id': '00:00:00:00:00:03_filter_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[select.mock_classicvario_filter_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Filter mode', + 'options': list([ + 'manual', + 'pulse', + 'bio', + ]), + }), + 'context': , + 'entity_id': 'select.mock_classicvario_filter_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/snapshots/test_sensor.ambr b/tests/components/eheimdigital/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c5a3d700331 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_sensor.ambr @@ -0,0 +1,160 @@ +# serializer version: 1 +# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_classicvario_current_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': 'Current speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_speed', + 'unique_id': '00:00:00:00:00:03_current_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Current speed', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_current_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'rotor_stuck', + 'air_in_filter', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_classicvario_error_code', + '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 code', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '00:00:00:00:00:03_error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock classicVARIO Error code', + 'options': list([ + 'no_error', + 'rotor_stuck', + 'air_in_filter', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_classicvario_remaining_hours_until_service', + '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': 'Remaining hours until service', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'service_hours', + 'unique_id': '00:00:00:00:00:03_service_hours', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock classicVARIO Remaining hours until service', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_remaining_hours_until_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/snapshots/test_switch.ambr b/tests/components/eheimdigital/snapshots/test_switch.ambr new file mode 100644 index 00000000000..73d229cb4ba --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup_classic_vario[switch.mock_classicvario-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_classicvario', + 'has_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': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_active', + 'unique_id': '00:00:00:00:00:03', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_vario[switch.mock_classicvario-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO', + }), + 'context': , + 'entity_id': 'switch.mock_classicvario', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/eheimdigital/snapshots/test_time.ambr b/tests/components/eheimdigital/snapshots/test_time.ambr new file mode 100644 index 00000000000..bdd4bdaddb7 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_time.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_setup[time.mock_classicvario_day_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_classicvario_day_start_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': 'Day start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'day_start_time', + 'unique_id': '00:00:00:00:00:03_day_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_classicvario_day_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Day start time', + }), + 'context': , + 'entity_id': 'time.mock_classicvario_day_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_classicvario_night_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_classicvario_night_start_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': 'Night start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_start_time', + 'unique_id': '00:00:00:00:00:03_night_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_classicvario_night_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Night start time', + }), + 'context': , + 'entity_id': 'time.mock_classicvario_night_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_heater_day_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_heater_day_start_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': 'Day start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'day_start_time', + 'unique_id': '00:00:00:00:00:02_day_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_heater_day_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Heater Day start time', + }), + 'context': , + 'entity_id': 'time.mock_heater_day_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[time.mock_heater_night_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.mock_heater_night_start_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': 'Night start time', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_start_time', + 'unique_id': '00:00:00:00:00:02_night_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[time.mock_heater_night_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Heater Night start time', + }), + 'context': , + 'entity_id': 'time.mock_heater_night_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/test_number.py b/tests/components/eheimdigital/test_number.py new file mode 100644 index 00000000000..a23f461744a --- /dev/null +++ b/tests/components/eheimdigital/test_number.py @@ -0,0 +1,211 @@ +"""Tests for the number module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from syrupy.assertion 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 .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +async def test_setup( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test number platform setup.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.NUMBER]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + for device in eheimdigital_hub_mock.return_value.devices: + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device, eheimdigital_hub_mock.return_value.devices[device].device_type + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "number.mock_heater_temperature_offset", + 0.4, + "set_temperature_offset", + (0.4,), + ), + ( + "number.mock_heater_night_temperature_offset", + 0.4, + "set_night_temperature_offset", + (0.4,), + ), + ( + "number.mock_heater_system_led_brightness", + 20, + "set_sys_led", + (20,), + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "number.mock_classicvario_manual_speed", + 72.1, + "set_manual_speed", + (int(72.1),), + ), + ( + "number.mock_classicvario_day_speed", + 72.1, + "set_day_speed", + (int(72.1),), + ), + ( + "number.mock_classicvario_night_speed", + 72.1, + "set_night_speed", + (int(72.1),), + ), + ( + "number.mock_classicvario_system_led_brightness", + 20, + "set_sys_led", + (20,), + ), + ], + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, float, str, tuple[float]]], + request: pytest.FixtureRequest, +) -> None: + """Test setting a value.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: item[0], ATTR_VALUE: item[1]}, + blocking=True, + ) + calls = [call for call in device.mock_calls if call[0] == item[2]] + assert len(calls) == 1 and calls[0][1] == item[3] + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "number.mock_heater_temperature_offset", + "temperature_offset", + -1.1, + ), + ( + "number.mock_heater_night_temperature_offset", + "night_temperature_offset", + 2.3, + ), + ( + "number.mock_heater_system_led_brightness", + "sys_led", + 87, + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "number.mock_classicvario_manual_speed", + "manual_speed", + 34, + ), + ( + "number.mock_classicvario_day_speed", + "day_speed", + 79, + ), + ( + "number.mock_classicvario_night_speed", + "night_speed", + 12, + ), + ( + "number.mock_classicvario_system_led_brightness", + "sys_led", + 35, + ), + ], + ), + ], +) +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, str, float]], + request: pytest.FixtureRequest, +) -> None: + """Test state updates.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + setattr(device, item[1], item[2]) + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + assert (state := hass.states.get(item[0])) + assert state.state == str(item[2]) diff --git a/tests/components/eheimdigital/test_select.py b/tests/components/eheimdigital/test_select.py new file mode 100644 index 00000000000..89ec91b62a0 --- /dev/null +++ b/tests/components/eheimdigital/test_select.py @@ -0,0 +1,136 @@ +"""Tests for the select module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.types import FilterMode +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 .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock") +async def test_setup( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test select platform setup.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.SELECT]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + for device in eheimdigital_hub_mock.return_value.devices: + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device, eheimdigital_hub_mock.return_value.devices[device].device_type + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("classic_vario_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "classic_vario_mock", + [ + ( + "select.mock_classicvario_filter_mode", + "manual", + "set_filter_mode", + (FilterMode.MANUAL,), + ), + ], + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, float, str, tuple[FilterMode]]], + request: pytest.FixtureRequest, +) -> None: + """Test setting a value.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: item[0], ATTR_OPTION: item[1]}, + blocking=True, + ) + calls = [call for call in device.mock_calls if call[0] == item[2]] + assert len(calls) == 1 and calls[0][1] == item[3] + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "classic_vario_mock", + [ + ( + "select.mock_classicvario_filter_mode", + "filter_mode", + FilterMode.BIO, + ), + ], + ), + ], +) +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, str, FilterMode]], + request: pytest.FixtureRequest, +) -> None: + """Test state updates.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + setattr(device, item[1], item[2]) + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + assert (state := hass.states.get(item[0])) + assert state.state == item[2].name.lower() diff --git a/tests/components/eheimdigital/test_sensor.py b/tests/components/eheimdigital/test_sensor.py new file mode 100644 index 00000000000..ece4d3eb241 --- /dev/null +++ b/tests/components/eheimdigital/test_sensor.py @@ -0,0 +1,77 @@ +"""Tests for the sensor module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.types import EheimDeviceType, FilterErrorCode +import pytest +from syrupy.assertion import SnapshotAssertion + +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, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock") +async def test_setup_classic_vario( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor platform setup for the filter.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.SENSOR]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + 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:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_vario_mock: MagicMock, +) -> None: + """Test the sensor state update.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + classic_vario_mock.current_speed = 10 + classic_vario_mock.error_code = FilterErrorCode.ROTOR_STUCK + classic_vario_mock.service_hours = 100 + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("sensor.mock_classicvario_current_speed")) + assert state.state == "10" + + assert (state := hass.states.get("sensor.mock_classicvario_error_code")) + assert state.state == "rotor_stuck" + + assert ( + state := hass.states.get( + "sensor.mock_classicvario_remaining_hours_until_service" + ) + ) + assert state.state == str(round(100 / 24, 1)) diff --git a/tests/components/eheimdigital/test_switch.py b/tests/components/eheimdigital/test_switch.py new file mode 100644 index 00000000000..440e4776b37 --- /dev/null +++ b/tests/components/eheimdigital/test_switch.py @@ -0,0 +1,105 @@ +"""Tests for the switch module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.types import EheimDeviceType +import pytest +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, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock") +async def test_setup_classic_vario( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test switch platform setup for the filter.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.SWITCH]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + 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:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "active"), [(SERVICE_TURN_OFF, False), (SERVICE_TURN_ON, True)] +) +async def test_turn_on_off( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_vario_mock: MagicMock, + service: str, + active: bool, +) -> None: + """Test turning on/off the switch.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: "switch.mock_classicvario"}, + blocking=True, + ) + + classic_vario_mock.set_active.assert_awaited_once_with(active=active) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_vario_mock: MagicMock, +) -> None: + """Test the switch state update.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + assert (state := hass.states.get("switch.mock_classicvario")) + assert state.state == STATE_ON + + classic_vario_mock.is_active = False + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("switch.mock_classicvario")) + assert state.state == STATE_OFF diff --git a/tests/components/eheimdigital/test_time.py b/tests/components/eheimdigital/test_time.py new file mode 100644 index 00000000000..acb96ae4023 --- /dev/null +++ b/tests/components/eheimdigital/test_time.py @@ -0,0 +1,179 @@ +"""Tests for the time module.""" + +from datetime import time, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from syrupy.assertion 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 .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +async def test_setup( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test number platform setup.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.TIME]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + for device in eheimdigital_hub_mock.return_value.devices: + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device, eheimdigital_hub_mock.return_value.devices[device].device_type + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "time.mock_heater_day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + "set_day_start_time", + (time(9, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ( + "time.mock_heater_night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=1))), + "set_night_start_time", + (time(19, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "time.mock_classicvario_day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + "set_day_start_time", + (time(9, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ( + "time.mock_classicvario_night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=1))), + "set_night_start_time", + (time(19, 0, tzinfo=timezone(timedelta(hours=1))),), + ), + ], + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, time, str, tuple[time]]], + request: pytest.FixtureRequest, +) -> None: + """Test setting a value.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: item[0], ATTR_TIME: item[1]}, + blocking=True, + ) + calls = [call for call in device.mock_calls if call[0] == item[2]] + assert len(calls) == 1 and calls[0][1] == item[3] + + +@pytest.mark.usefixtures("classic_vario_mock", "heater_mock") +@pytest.mark.parametrize( + ("device_name", "entity_list"), + [ + ( + "heater_mock", + [ + ( + "time.mock_heater_day_start_time", + "day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=3))), + ), + ( + "time.mock_heater_night_start_time", + "night_start_time", + time(19, 0, tzinfo=timezone(timedelta(hours=3))), + ), + ], + ), + ( + "classic_vario_mock", + [ + ( + "time.mock_classicvario_day_start_time", + "day_start_time", + time(9, 0, tzinfo=timezone(timedelta(hours=1))), + ), + ( + "time.mock_classicvario_night_start_time", + "night_start_time", + time(22, 0, tzinfo=timezone(timedelta(hours=1))), + ), + ], + ), + ], +) +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + device_name: str, + entity_list: list[tuple[str, str, time]], + request: pytest.FixtureRequest, +) -> None: + """Test state updates.""" + device: MagicMock = request.getfixturevalue(device_name) + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + device.mac_address, device.device_type + ) + + await hass.async_block_till_done() + + for item in entity_list: + setattr(device, item[1], item[2]) + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + assert (state := hass.states.get(item[0])) + assert state.state == item[2].isoformat() diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index c4234cb38ae..a63672cc85d 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -350,7 +350,7 @@ async def test_tts_service_speak_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) tts_entity._client.generate.assert_called_once_with( diff --git a/tests/components/elmax/test_alarm_control_panel.py b/tests/components/elmax/test_alarm_control_panel.py index 88fc0a33c51..f7e956708ab 100644 --- a/tests/components/elmax/test_alarm_control_panel.py +++ b/tests/components/elmax/test_alarm_control_panel.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.elmax.const import POLLING_SECONDS from homeassistant.const import Platform diff --git a/tests/components/elmax/test_binary_sensor.py b/tests/components/elmax/test_binary_sensor.py index f6cead79ee7..685cf1ff7c1 100644 --- a/tests/components/elmax/test_binary_sensor.py +++ b/tests/components/elmax/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/elmax/test_cover.py b/tests/components/elmax/test_cover.py index 9fa72432072..a42c9c17122 100644 --- a/tests/components/elmax/test_cover.py +++ b/tests/components/elmax/test_cover.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/elmax/test_switch.py b/tests/components/elmax/test_switch.py index ba6efee2184..b11fe447150 100644 --- a/tests/components/elmax/test_switch.py +++ b/tests/components/elmax/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/emoncms/conftest.py b/tests/components/emoncms/conftest.py index 4bd1d68217a..100fb2bd879 100644 --- a/tests/components/emoncms/conftest.py +++ b/tests/components/emoncms/conftest.py @@ -7,14 +7,7 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN -from homeassistant.const import ( - CONF_API_KEY, - CONF_ID, - CONF_PLATFORM, - CONF_URL, - CONF_VALUE_TEMPLATE, -) -from homeassistant.helpers.typing import ConfigType +from homeassistant.const import CONF_API_KEY, CONF_URL from tests.common import MockConfigEntry @@ -50,36 +43,6 @@ FLOW_RESULT = { SENSOR_NAME = "emoncms@1.1.1.1" -YAML_BASE = { - CONF_PLATFORM: "emoncms", - CONF_API_KEY: "my_api_key", - CONF_ID: 1, - CONF_URL: "http://1.1.1.1", -} - -YAML = { - **YAML_BASE, - CONF_ONLY_INCLUDE_FEEDID: [1], -} - - -@pytest.fixture -def emoncms_yaml_config() -> ConfigType: - """Mock emoncms yaml configuration.""" - return {"sensor": YAML} - - -@pytest.fixture -def emoncms_yaml_config_with_template() -> ConfigType: - """Mock emoncms yaml conf with template parameter.""" - return {"sensor": {**YAML, CONF_VALUE_TEMPLATE: "{{ value | float + 1500 }}"}} - - -@pytest.fixture -def emoncms_yaml_config_no_include_only_feed_id() -> ConfigType: - """Mock emoncms yaml configuration without include_only_feed_id parameter.""" - return {"sensor": YAML_BASE} - @pytest.fixture def config_entry() -> MockConfigEntry: diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index 1914f23fb0b..fa8ae7ce068 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -3,64 +3,16 @@ from unittest.mock import AsyncMock from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import setup_integration -from .conftest import EMONCMS_FAILURE, FLOW_RESULT_SINGLE_FEED, SENSOR_NAME, YAML +from .conftest import EMONCMS_FAILURE, SENSOR_NAME from tests.common import MockConfigEntry - -async def test_flow_import_include_feeds( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - emoncms_client: AsyncMock, -) -> None: - """YAML import with included feed - success test.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=YAML, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == SENSOR_NAME - assert result["data"] == FLOW_RESULT_SINGLE_FEED - - -async def test_flow_import_failure( - hass: HomeAssistant, - emoncms_client: AsyncMock, -) -> None: - """YAML import - failure test.""" - emoncms_client.async_request.return_value = EMONCMS_FAILURE - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=YAML, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "api_error" - - -async def test_flow_import_already_configured( - hass: HomeAssistant, - config_entry: MockConfigEntry, - emoncms_client: 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, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - USER_INPUT = { CONF_URL: "http://1.1.1.1", CONF_API_KEY: "my_api_key", diff --git a/tests/components/emoncms/test_sensor.py b/tests/components/emoncms/test_sensor.py index a7bc8059287..2d976f483b3 100644 --- a/tests/components/emoncms/test_sensor.py +++ b/tests/components/emoncms/test_sensor.py @@ -7,12 +7,9 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.emoncms.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState -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 . import setup_integration from .conftest import EMONCMS_FAILURE, get_feed @@ -20,56 +17,6 @@ from .conftest import EMONCMS_FAILURE, get_feed from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def test_deprecated_yaml( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - emoncms_yaml_config: ConfigType, - emoncms_client: AsyncMock, -) -> None: - """Test an issue is created when we import from yaml config.""" - - await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_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_yaml_with_template( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - emoncms_yaml_config_with_template: ConfigType, - emoncms_client: AsyncMock, -) -> None: - """Test an issue is created when we import a yaml config with a value_template parameter.""" - - await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config_with_template) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id=f"remove_value_template_{DOMAIN}" - ) - - -async def test_yaml_no_include_only_feed_id( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - emoncms_yaml_config_no_include_only_feed_id: ConfigType, - emoncms_client: AsyncMock, -) -> None: - """Test an issue is created when we import a yaml config without a include_only_feed_id parameter.""" - - await async_setup_component( - hass, SENSOR_DOMAIN, emoncms_yaml_config_no_include_only_feed_id - ) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id=f"missing_include_only_feed_id_{DOMAIN}" - ) - - async def test_no_feed_selected( hass: HomeAssistant, config_no_feed: MockConfigEntry, diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 97dcc782096..0f8ffcbee9f 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -103,6 +103,7 @@ ENTITY_IDS_BY_NUMBER = { "26": "light.living_room_rgbww_lights", "27": "media_player.group", "28": "media_player.browse", + "29": "media_player.search", } ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} diff --git a/tests/components/emulated_roku/test_binding.py b/tests/components/emulated_roku/test_binding.py index 5bde72d2e4d..ec3f064dfe0 100644 --- a/tests/components/emulated_roku/test_binding.py +++ b/tests/components/emulated_roku/test_binding.py @@ -1,6 +1,7 @@ """Tests for emulated_roku library bindings.""" from unittest.mock import AsyncMock, Mock, patch +from uuid import uuid4 from homeassistant.components.emulated_roku.binding import ( ATTR_APP_ID, @@ -14,14 +15,15 @@ from homeassistant.components.emulated_roku.binding import ( ROKU_COMMAND_LAUNCH, EmulatedRoku, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant async def test_events_fired_properly(hass: HomeAssistant) -> None: """Test that events are fired correctly.""" - binding = EmulatedRoku( - hass, "Test Emulated Roku", "1.2.3.4", 8060, None, None, None - ) + random_name = uuid4().hex + # Note that this test is accessing the internal EmulatedRoku class + # and should be refactored in the future not to do so. + binding = EmulatedRoku(hass, "x", random_name, "1.2.3.4", 8060, None, None, None) events = [] roku_event_handler = None @@ -41,8 +43,9 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: return Mock(start=AsyncMock(), close=AsyncMock()) - def listener(event): - events.append(event) + def listener(event: Event) -> None: + if event.data[ATTR_SOURCE_NAME] == random_name: + events.append(event) with patch( "homeassistant.components.emulated_roku.binding.EmulatedRokuServer", instantiate @@ -53,10 +56,10 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: assert roku_event_handler is not None - roku_event_handler.on_keydown("Test Emulated Roku", "A") - roku_event_handler.on_keyup("Test Emulated Roku", "A") - roku_event_handler.on_keypress("Test Emulated Roku", "C") - roku_event_handler.launch("Test Emulated Roku", "1") + roku_event_handler.on_keydown(random_name, "A") + roku_event_handler.on_keyup(random_name, "A") + roku_event_handler.on_keypress(random_name, "C") + roku_event_handler.launch(random_name, "1") await hass.async_block_till_done() @@ -64,20 +67,20 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: assert events[0].event_type == EVENT_ROKU_COMMAND assert events[0].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYDOWN - assert events[0].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[0].data[ATTR_SOURCE_NAME] == random_name assert events[0].data[ATTR_KEY] == "A" assert events[1].event_type == EVENT_ROKU_COMMAND assert events[1].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYUP - assert events[1].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[1].data[ATTR_SOURCE_NAME] == random_name assert events[1].data[ATTR_KEY] == "A" assert events[2].event_type == EVENT_ROKU_COMMAND assert events[2].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYPRESS - assert events[2].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[2].data[ATTR_SOURCE_NAME] == random_name assert events[2].data[ATTR_KEY] == "C" assert events[3].event_type == EVENT_ROKU_COMMAND assert events[3].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_LAUNCH - assert events[3].data[ATTR_SOURCE_NAME] == "Test Emulated Roku" + assert events[3].data[ATTR_SOURCE_NAME] == random_name assert events[3].data[ATTR_APP_ID] == "1" diff --git a/tests/components/emulated_roku/test_init.py b/tests/components/emulated_roku/test_init.py index cf2a415f19c..473e0c662aa 100644 --- a/tests/components/emulated_roku/test_init.py +++ b/tests/components/emulated_roku/test_init.py @@ -86,16 +86,6 @@ async def test_setup_entry_successful(hass: HomeAssistant) -> None: assert await emulated_roku.async_setup_entry(hass, entry) is True assert len(instantiate.mock_calls) == 1 - assert hass.data[emulated_roku.DOMAIN] - - roku_instance = hass.data[emulated_roku.DOMAIN]["Emulated Roku Test"] - - assert roku_instance.roku_usn == "Emulated Roku Test" - assert roku_instance.host_ip == "1.2.3.5" - assert roku_instance.listen_port == 8060 - assert roku_instance.advertise_ip == "1.2.3.4" - assert roku_instance.advertise_port == 8071 - assert roku_instance.bind_multicast is False async def test_unload_entry(hass: HomeAssistant) -> None: @@ -113,10 +103,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: ): assert await emulated_roku.async_setup_entry(hass, entry) is True - assert emulated_roku.DOMAIN in hass.data - await hass.async_block_till_done() assert await emulated_roku.async_unload_entry(hass, entry) - - assert len(hass.data[emulated_roku.DOMAIN]) == 0 diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index a438842f8a5..a9a249a8498 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -994,7 +994,7 @@ async def test_cost_sensor_handle_late_price_sensor( @pytest.mark.parametrize( "unit", - [UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS], + [UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS, UnitOfVolume.LITERS], ) async def test_cost_sensor_handle_gas( setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any], unit diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index d7f0485139f..6389ac0b372 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -856,7 +856,7 @@ async def test_validation_gas( "affected_entities": {("sensor.gas_consumption_1", "beers")}, "translation_placeholders": { "energy_units": "GJ, kWh, MJ, MWh, Wh", - "gas_units": "CCF, ft³, m³", + "gas_units": "CCF, ft³, m³, L", }, }, { @@ -885,7 +885,7 @@ async def test_validation_gas( "affected_entities": {("sensor.gas_price_2", "EUR/invalid")}, "translation_placeholders": { "price_units": ( - "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh, EUR/CCF, EUR/ft³, EUR/m³" + "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh, EUR/CCF, EUR/ft³, EUR/m³, EUR/L" ) }, }, diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 959ec7d1687..54f2a971fd4 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -149,7 +149,13 @@ async def test_save_preferences( "stat_energy_to": "my_battery_charging", }, ], - "device_consumption": [{"stat_consumption": "some_device_usage"}], + "device_consumption": [ + { + "stat_consumption": "some_device_usage", + "name": "My Device", + "included_in_stat": "sensor.some_other_device", + } + ], } await client.send_json({"id": 6, "type": "energy/save_prefs", **new_prefs}) @@ -1159,3 +1165,59 @@ async def test_fossil_energy_consumption_check_missing_hour( hour3.isoformat(), hour4.isoformat(), ] + + +@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") +async def test_fossil_energy_consumption_missing_sum( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test fossil_energy_consumption statistics missing sum.""" + now = dt_util.utcnow() + later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) + + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + + external_energy_statistics_1 = ( + {"start": period1, "last_reset": None, "state": 0, "mean": 2}, + {"start": period2, "last_reset": None, "state": 1, "mean": 3}, + {"start": period3, "last_reset": None, "state": 2, "mean": 4}, + {"start": period4, "last_reset": None, "state": 3, "mean": 5}, + ) + external_energy_metadata_1 = { + "has_mean": True, + "has_sum": False, + "name": "Mean imported energy", + "source": "test", + "statistic_id": "test:mean_energy_import_tariff", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:mean_energy_import_tariff", + ], + "co2_statistic_id": "", + "period": "hour", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index b860d49aa6b..89a0e9b4610 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -20,6 +20,7 @@ from pyenphase import ( ) from pyenphase.const import SupportedFeatures from pyenphase.models.dry_contacts import EnvoyDryContactSettings, EnvoyDryContactStatus +from pyenphase.models.home import EnvoyInterfaceInformation from pyenphase.models.meters import EnvoyMeterData from pyenphase.models.tariff import EnvoyStorageSettings, EnvoyTariff import pytest @@ -145,6 +146,11 @@ def load_envoy_fixture(mock_envoy: AsyncMock, fixture_name: str) -> None: _load_json_2_encharge_enpower_data(mock_envoy.data, json_fixture) _load_json_2_raw_data(mock_envoy.data, json_fixture) + if item := json_fixture.get("interface_information"): + mock_envoy.interface_settings.return_value = EnvoyInterfaceInformation(**item) + else: + mock_envoy.interface_settings.return_value = None + def _load_json_2_production_data( mocked_data: EnvoyData, json_fixture: dict[str, Any] diff --git a/tests/components/enphase_envoy/fixtures/envoy.json b/tests/components/enphase_envoy/fixtures/envoy.json index 3431dba6766..c619d61a393 100644 --- a/tests/components/enphase_envoy/fixtures/envoy.json +++ b/tests/components/enphase_envoy/fixtures/envoy.json @@ -47,5 +47,13 @@ "raw": { "varies_by": "firmware_version" } + }, + "interface_information": { + "primary_interface": "eth0", + "interface_type": "ethernet", + "mac": "00:11:22:33:44:55", + "dhcp": true, + "software_build_epoch": 1719503966, + "timezone": "Europe/Amsterdam" } } diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 152cf803258..650fb0bb810 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -423,6 +423,8 @@ 'tariff': None, }), 'envoy_properties': dict({ + 'active interface': dict({ + }), 'active_phasecount': 0, 'ct_consumption_meter': None, 'ct_count': 0, @@ -870,6 +872,8 @@ 'tariff': None, }), 'envoy_properties': dict({ + 'active interface': dict({ + }), 'active_phasecount': 0, 'ct_consumption_meter': None, 'ct_count': 0, @@ -892,6 +896,8 @@ '/api/v1/production/inverters': 'Testing request replies.', '/api/v1/production/inverters_log': '{"headers":{"Hello":"World"},"code":200}', '/api/v1/production_log': '{"headers":{"Hello":"World"},"code":200}', + '/home': 'Testing request replies.', + '/home_log': '{"headers":{"Hello":"World"},"code":200}', '/info': 'Testing request replies.', '/info_log': '{"headers":{"Hello":"World"},"code":200}', '/ivp/ensemble/dry_contacts': 'Testing request replies.', @@ -1357,6 +1363,529 @@ 'tariff': None, }), 'envoy_properties': dict({ + 'active interface': dict({ + }), + 'active_phasecount': 0, + 'ct_consumption_meter': None, + 'ct_count': 0, + 'ct_production_meter': None, + 'ct_storage_meter': None, + 'envoy_firmware': '7.6.175', + 'envoy_model': 'Envoy', + 'part_number': '123456789', + 'phase_count': 1, + 'phase_mode': None, + 'supported_features': list([ + 'INVERTERS', + 'PRODUCTION', + ]), + }), + 'fixtures': dict({ + '/admin/lib/tariff_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/api/v1/production/inverters_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/api/v1/production_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/home_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/info_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/dry_contacts_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/generator_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/inventory_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/power_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/secctrl_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/status_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/meters/readings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/meters_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/sc/pvlimit_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/dry_contact_settings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/gen_config_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/gen_schedule_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/pel_settings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production.json?details=1_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production.json_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production_log': dict({ + 'Error': "EnvoyError('Test')", + }), + }), + 'raw_data': dict({ + 'varies_by': 'firmware_version', + }), + }) +# --- +# name: test_entry_diagnostics_with_interface_information + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '1.1.1.1', + 'name': '**REDACTED**', + 'password': '**REDACTED**', + 'token': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'enphase_envoy', + 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'envoy_entities_by_device': list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + '45a36e55aaddb2007c5f6602e0c38e72', + ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'identifiers': list([ + list([ + 'enphase_envoy', + '1', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'Enphase', + 'model': 'Inverter', + 'model_id': None, + 'name': 'Inverter 1', + 'name_by_user': None, + 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + }), + 'entities': list([ + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'power', + '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': 'W', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1', + 'state_class': 'measurement', + 'unit_of_measurement': 'W', + }), + 'entity_id': 'sensor.inverter_1', + 'state': '1', + }), + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'timestamp', + '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, + }), + 'state': None, + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + '45a36e55aaddb2007c5f6602e0c38e72', + ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + list([ + 'mac', + '00:11:22:33:44:55', + ]), + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '<>56789', + 'identifiers': list([ + list([ + 'enphase_envoy', + '<>', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'Enphase', + 'model': 'Envoy', + 'model_id': None, + 'name': 'Envoy <>', + 'name_by_user': None, + 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', + 'serial_number': '<>', + 'suggested_area': None, + 'sw_version': '7.6.175', + }), + 'entities': list([ + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_<>_current_power_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kW', + }), + }), + 'original_device_class': 'power', + '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': '<>_production', + 'unit_of_measurement': 'kW', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Envoy <> Current power production', + 'state_class': 'measurement', + 'unit_of_measurement': 'kW', + }), + 'entity_id': 'sensor.envoy_<>_current_power_production', + 'state': '1.234', + }), + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_<>_energy_production_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_production', + 'unique_id': '<>_daily_production', + 'unit_of_measurement': 'kWh', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy <> Energy production today', + 'state_class': 'total_increasing', + 'unit_of_measurement': 'kWh', + }), + 'entity_id': 'sensor.envoy_<>_energy_production_today', + 'state': '1.234', + }), + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + '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': '<>_seven_days_production', + 'unit_of_measurement': 'kWh', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy <> Energy production last seven days', + 'unit_of_measurement': 'kWh', + }), + 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', + 'state': '1.234', + }), + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_<>_lifetime_energy_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'MWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_production', + 'unique_id': '<>_lifetime_production', + 'unit_of_measurement': 'MWh', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy <> Lifetime energy production', + 'state_class': 'total_increasing', + 'unit_of_measurement': 'MWh', + }), + 'entity_id': 'sensor.envoy_<>_lifetime_energy_production', + 'state': '0.00<>', + }), + }), + ]), + }), + ]), + 'envoy_model_data': dict({ + 'ctmeter_consumption': None, + 'ctmeter_consumption_phases': None, + 'ctmeter_production': None, + 'ctmeter_production_phases': None, + 'ctmeter_storage': None, + 'ctmeter_storage_phases': None, + 'dry_contact_settings': dict({ + }), + 'dry_contact_status': dict({ + }), + 'encharge_aggregate': None, + 'encharge_inventory': None, + 'encharge_power': None, + 'enpower': None, + 'inverters': dict({ + '1': dict({ + '__type': "", + 'repr': "EnvoyInverter(serial_number='1', last_report_date=1, last_report_watts=1, max_report_watts=1)", + }), + }), + 'system_consumption': None, + 'system_consumption_phases': None, + 'system_production': dict({ + '__type': "", + 'repr': 'EnvoySystemProduction(watt_hours_lifetime=1234, watt_hours_last_7_days=1234, watt_hours_today=1234, watts_now=1234)', + }), + 'system_production_phases': None, + 'tariff': None, + }), + 'envoy_properties': dict({ + 'active interface': dict({ + 'envoy timezone': 'Europe/Amsterdam', + 'firmware build date': '2024-06-27 15:59:26', + 'interface type': 'ethernet', + 'mac': '00:11:22:33:44:55', + 'name': 'eth0', + 'uses dhcp': True, + }), 'active_phasecount': 0, 'ct_consumption_meter': None, 'ct_count': 0, @@ -1373,7 +1902,6 @@ ]), }), 'fixtures': dict({ - 'Error': "EnvoyError('Test')", }), 'raw_data': dict({ 'varies_by': 'firmware_version', diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index c1e2c9270e2..101caaf1aea 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -361,7 +361,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -374,7 +374,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -1456,7 +1456,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1471,7 +1471,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1486,7 +1486,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -1495,22 +1495,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1525,7 +1525,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1540,7 +1540,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -1549,15 +1549,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2519,7 +2519,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -2532,7 +2532,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -5374,7 +5374,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5389,7 +5389,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5404,7 +5404,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5413,22 +5413,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_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] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5443,7 +5443,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5458,7 +5458,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5467,22 +5467,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_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] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5497,7 +5497,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5512,7 +5512,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5521,22 +5521,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_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] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5551,7 +5551,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5566,7 +5566,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5575,22 +5575,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_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] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5605,7 +5605,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5620,7 +5620,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5629,22 +5629,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_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] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5659,7 +5659,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5674,7 +5674,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5683,22 +5683,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_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] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5713,7 +5713,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5728,7 +5728,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5737,22 +5737,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_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] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5767,7 +5767,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5782,7 +5782,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5791,15 +5791,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -7026,7 +7026,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -7039,7 +7039,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -9881,7 +9881,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -9896,7 +9896,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9911,7 +9911,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -9920,22 +9920,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -9950,7 +9950,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9965,7 +9965,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -9974,22 +9974,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.22', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10004,7 +10004,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10019,7 +10019,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10028,22 +10028,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10058,7 +10058,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10073,7 +10073,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10082,22 +10082,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.24', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10112,7 +10112,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10127,7 +10127,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10136,22 +10136,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.11', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l1-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10166,7 +10166,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10181,7 +10181,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10190,22 +10190,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.12', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l2-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10220,7 +10220,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10235,7 +10235,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10244,22 +10244,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.13', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l3-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10274,7 +10274,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10289,7 +10289,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10298,15 +10298,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -11630,7 +11630,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11643,7 +11643,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -11688,7 +11688,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l1', + 'original_name': 'Balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11701,7 +11701,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l1', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l1', 'state_class': , 'unit_of_measurement': , }), @@ -11746,7 +11746,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l2', + 'original_name': 'Balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11759,7 +11759,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l2', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l2', 'state_class': , 'unit_of_measurement': , }), @@ -11804,7 +11804,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l3', + 'original_name': 'Balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11817,7 +11817,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l3', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l3', 'state_class': , 'unit_of_measurement': , }), @@ -17547,7 +17547,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17562,7 +17562,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17577,7 +17577,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17586,22 +17586,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17616,7 +17616,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17631,7 +17631,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17640,22 +17640,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.22', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17670,7 +17670,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17685,7 +17685,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17694,22 +17694,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17724,7 +17724,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17739,7 +17739,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17748,22 +17748,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.24', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17778,7 +17778,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17793,7 +17793,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17802,22 +17802,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.11', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l1-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17832,7 +17832,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17847,7 +17847,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17856,22 +17856,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.12', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l2-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17886,7 +17886,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17901,7 +17901,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17910,22 +17910,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.13', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l3-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17940,7 +17940,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17955,7 +17955,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17964,22 +17964,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.14', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17994,7 +17994,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18009,7 +18009,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT', + 'original_name': 'Power factor storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18018,22 +18018,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT', + 'friendly_name': 'Envoy 1234 Power factor storage CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l1-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18048,7 +18048,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18063,7 +18063,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT l1', + 'original_name': 'Power factor storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18072,22 +18072,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l1-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT l1', + 'friendly_name': 'Envoy 1234 Power factor storage CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.32', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l2-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18102,7 +18102,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18117,7 +18117,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT l2', + 'original_name': 'Power factor storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18126,22 +18126,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l2-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT l2', + 'friendly_name': 'Envoy 1234 Power factor storage CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l3-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18156,7 +18156,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18171,7 +18171,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT l3', + 'original_name': 'Power factor storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18180,15 +18180,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l3-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT l3', + 'friendly_name': 'Envoy 1234 Power factor storage CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -19586,7 +19586,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19599,7 +19599,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -19644,7 +19644,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l1', + 'original_name': 'Balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19657,7 +19657,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l1', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l1', 'state_class': , 'unit_of_measurement': , }), @@ -19702,7 +19702,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l2', + 'original_name': 'Balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19715,7 +19715,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l2', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l2', 'state_class': , 'unit_of_measurement': , }), @@ -19760,7 +19760,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l3', + 'original_name': 'Balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19773,7 +19773,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l3', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l3', 'state_class': , 'unit_of_measurement': , }), @@ -24065,7 +24065,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24080,7 +24080,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24095,7 +24095,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24104,22 +24104,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24134,7 +24134,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24149,7 +24149,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24158,22 +24158,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.22', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24188,7 +24188,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24203,7 +24203,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24212,22 +24212,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24242,7 +24242,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24257,7 +24257,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24266,22 +24266,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.24', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24296,7 +24296,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24311,7 +24311,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24320,22 +24320,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.11', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l1-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24350,7 +24350,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24365,7 +24365,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24374,22 +24374,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.12', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l2-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24404,7 +24404,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24419,7 +24419,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24428,22 +24428,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.13', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l3-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24458,7 +24458,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24473,7 +24473,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24482,15 +24482,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -25326,7 +25326,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -25339,7 +25339,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -25799,7 +25799,7 @@ 'state': 'normal', }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -25814,7 +25814,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -25829,7 +25829,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -25838,15 +25838,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/enphase_envoy/test_diagnostics.py b/tests/components/enphase_envoy/test_diagnostics.py index 186ee5c46f3..87e6842616d 100644 --- a/tests/components/enphase_envoy/test_diagnostics.py +++ b/tests/components/enphase_envoy/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -10,11 +11,12 @@ from homeassistant.components.enphase_envoy.const import ( DOMAIN, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, ) +from homeassistant.components.enphase_envoy.coordinator import MAC_VERIFICATION_DELAY from homeassistant.core import HomeAssistant from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -90,3 +92,24 @@ async def test_entry_diagnostics_with_fixtures_with_error( assert await get_diagnostics_for_config_entry( hass, hass_client, config_entry_options ) == snapshot(exclude=limit_diagnostic_attrs) + + +async def test_entry_diagnostics_with_interface_information( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test config entry diagnostics including interface data.""" + await setup_integration(hass, config_entry) + + # move time forward so interface information is collected + freezer.tick(MAC_VERIFICATION_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=limit_diagnostic_attrs) diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 93a150cfc5c..ef071b421fe 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -19,6 +19,7 @@ from homeassistant.components.enphase_envoy.const import ( ) from homeassistant.components.enphase_envoy.coordinator import ( FIRMWARE_REFRESH_INTERVAL, + MAC_VERIFICATION_DELAY, SCAN_INTERVAL, ) from homeassistant.config_entries import ConfigEntryState @@ -443,3 +444,90 @@ async def test_coordinator_firmware_refresh_with_envoy_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Error reading firmware:" in caplog.text + + +@respx.mock +async def test_coordinator_interface_information( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator interface mac verification.""" + await setup_integration(hass, config_entry) + + caplog.set_level(logging.DEBUG) + logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( + logging.DEBUG + ) + + # move time forward so interface information is fetched + freezer.tick(MAC_VERIFICATION_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # verify first time add of mac to connections is in log + assert "added connection" in caplog.text + + # trigger integration reload by changing options + hass.config_entries.async_update_entry( + config_entry, + options={ + OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: False, + OPTION_DISABLE_KEEP_ALIVE: True, + }, + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + caplog.clear() + # envoy reloaded and device registry still has connection info + # force mac verification again to test existing connection is verified + freezer.tick(MAC_VERIFICATION_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # verify existing connection is verified in log + assert "connection verified as existing" in caplog.text + + +@respx.mock +async def test_coordinator_interface_information_no_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, +) -> None: + """Test coordinator interface mac verification full code cov.""" + await setup_integration(hass, config_entry) + + caplog.set_level(logging.DEBUG) + logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( + logging.DEBUG + ) + + # update device to force no device found in mac verification + device_registry = dr.async_get(hass) + envoy_device = device_registry.async_get_device( + identifiers={ + ( + DOMAIN, + mock_envoy.serial_number, + ) + } + ) + device_registry.async_update_device( + device_id=envoy_device.id, + new_identifiers={(DOMAIN, "9999")}, + ) + + # move time forward so interface information is fetched + freezer.tick(MAC_VERIFICATION_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # verify no device found message in log + assert "No envoy device found in device registry" in caplog.text diff --git a/tests/components/environment_canada/__init__.py b/tests/components/environment_canada/__init__.py index edc7a92a12f..61ec97ef794 100644 --- a/tests/components/environment_canada/__init__.py +++ b/tests/components/environment_canada/__init__.py @@ -33,7 +33,7 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry: config_entry.add_to_hass(hass) weather_mock = mock_ec() - ec_data["metadata"]["timestamp"] = datetime(2022, 10, 4, tzinfo=UTC) + ec_data["metadata"].timestamp = datetime(2022, 10, 4, tzinfo=UTC) weather_mock.conditions = ec_data["conditions"] weather_mock.alerts = ec_data["alerts"] weather_mock.daily_forecasts = ec_data["daily_forecasts"] diff --git a/tests/components/environment_canada/conftest.py b/tests/components/environment_canada/conftest.py index 19180052c93..3c7683ad0eb 100644 --- a/tests/components/environment_canada/conftest.py +++ b/tests/components/environment_canada/conftest.py @@ -4,6 +4,7 @@ import contextlib from datetime import datetime import json +from env_canada.ec_weather import MetaData import pytest from tests.common import load_fixture @@ -13,7 +14,7 @@ from tests.common import load_fixture def ec_data(): """Load Environment Canada data.""" - def date_hook(weather): + def data_hook(weather): """Convert timestamp string to datetime.""" if t := weather.get("timestamp"): @@ -22,9 +23,11 @@ def ec_data(): elif t := weather.get("period"): with contextlib.suppress(ValueError): weather["period"] = datetime.fromisoformat(t) + if t := weather.get("metadata"): + weather["metadata"] = MetaData(**t) return weather return json.loads( load_fixture("environment_canada/current_conditions_data.json"), - object_hook=date_hook, + object_hook=data_hook, ) diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index d61966e8da1..9f3fdbd43dc 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -30,7 +30,7 @@ def mocked_ec(): ec_mock.lat = FAKE_CONFIG[CONF_LATITUDE] ec_mock.lon = FAKE_CONFIG[CONF_LONGITUDE] ec_mock.language = FAKE_CONFIG[CONF_LANGUAGE] - ec_mock.metadata = {"location": FAKE_TITLE} + ec_mock.metadata.location = FAKE_TITLE ec_mock.update = AsyncMock() diff --git a/tests/components/environment_canada/test_diagnostics.py b/tests/components/environment_canada/test_diagnostics.py index 79b72961124..f46b89d20c2 100644 --- a/tests/components/environment_canada/test_diagnostics.py +++ b/tests/components/environment_canada/test_diagnostics.py @@ -1,9 +1,8 @@ """Test Environment Canada diagnostics.""" -import json from typing import Any -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.environment_canada.const import CONF_STATION from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE @@ -11,7 +10,6 @@ from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -31,10 +29,6 @@ async def test_entry_diagnostics( ) -> None: """Test config entry diagnostics.""" - ec_data = json.loads( - load_fixture("environment_canada/current_conditions_data.json") - ) - config_entry = await init_integration(hass, ec_data) diagnostics = await get_diagnostics_for_config_entry( hass, hass_client, config_entry diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index 77d315f096d..554f1725f4b 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -6,7 +6,9 @@ from aioesphomeapi import APIClient, APIVersion, BluetoothProxyFeature, DeviceIn from bleak.exc import BleakError from bleak_esphome.backend.client import ESPHomeClient, ESPHomeClientData from bleak_esphome.backend.device import ESPHomeBluetoothDevice -from bleak_esphome.backend.scanner import ESPHomeScanner +from bleak_esphome.backend.scanner import ( # pylint: disable=no-name-in-module + ESPHomeScanner, +) import pytest from homeassistant.components.bluetooth import HaBluetoothConnector diff --git a/tests/components/esphome/common.py b/tests/components/esphome/common.py new file mode 100644 index 00000000000..814fa27215b --- /dev/null +++ b/tests/components/esphome/common.py @@ -0,0 +1,55 @@ +"""ESPHome test common code.""" + +from datetime import datetime + +from homeassistant.components import assist_satellite +from homeassistant.components.assist_satellite import AssistSatelliteEntity +from homeassistant.components.esphome import DOMAIN +from homeassistant.components.esphome.assist_satellite import EsphomeAssistSatellite +from homeassistant.components.esphome.coordinator import REFRESH_INTERVAL +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + + +class MockDashboardRefresh: + """Mock dashboard refresh.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the mock dashboard refresh.""" + self.hass = hass + self.last_time: datetime | None = None + + async def async_refresh(self) -> None: + """Refresh the dashboard.""" + if self.last_time is None: + self.last_time = dt_util.utcnow() + self.last_time += REFRESH_INTERVAL + async_fire_time_changed(self.hass, self.last_time) + await self.hass.async_block_till_done() + + +def get_satellite_entity( + hass: HomeAssistant, mac_address: str +) -> EsphomeAssistSatellite | None: + """Get the satellite entity for a device.""" + ent_reg = er.async_get(hass) + satellite_entity_id = ent_reg.async_get_entity_id( + Platform.ASSIST_SATELLITE, DOMAIN, f"{mac_address}-assist_satellite" + ) + if satellite_entity_id is None: + return None + assert satellite_entity_id.endswith("_assist_satellite") + + component: EntityComponent[AssistSatelliteEntity] = hass.data[ + assist_satellite.DOMAIN + ] + if (entity := component.get_entity(satellite_entity_id)) is not None: + assert isinstance(entity, EsphomeAssistSatellite) + return entity + + return None diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index dc6195bfe1f..08a581be6d9 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -4,9 +4,9 @@ from __future__ import annotations import asyncio from asyncio import Event -from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine +from collections.abc import AsyncGenerator, Callable, Coroutine, Generator from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol from unittest.mock import AsyncMock, MagicMock, Mock, patch from aioesphomeapi import ( @@ -30,6 +30,7 @@ from zeroconf import Zeroconf from homeassistant.components.esphome import dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_NOISE_PSK, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, @@ -47,6 +48,46 @@ if TYPE_CHECKING: from aioesphomeapi.api_pb2 import SubscribeLogsResponse +class MockGenericDeviceEntryType(Protocol): + """Mock ESPHome device entry type.""" + + async def __call__( + self, + mock_client: APIClient, + entity_info: list[EntityInfo], + user_service: list[UserService], + states: list[EntityState], + mock_storage: bool = ..., + ) -> MockConfigEntry: + """Mock an ESPHome device entry.""" + + +class MockESPHomeDeviceType(Protocol): + """Mock ESPHome device type.""" + + async def __call__( + self, + mock_client: APIClient, + entity_info: list[EntityInfo] | None = ..., + user_service: list[UserService] | None = ..., + states: list[EntityState] | None = ..., + entry: MockConfigEntry | None = ..., + device_info: dict[str, Any] | None = ..., + mock_storage: bool = ..., + ) -> MockESPHomeDevice: + """Mock an ESPHome device.""" + + +class MockBluetoothEntryType(Protocol): + """Mock ESPHome bluetooth entry type.""" + + async def __call__( + self, + bluetooth_proxy_feature_flags: BluetoothProxyFeature, + ) -> MockESPHomeDevice: + """Mock an ESPHome bluetooth entry.""" + + _ONE_SECOND = 16000 * 2 # 16Khz 16-bit @@ -132,7 +173,7 @@ async def init_integration( @pytest.fixture -def mock_client(mock_device_info) -> APIClient: +def mock_client(mock_device_info) -> Generator[APIClient]: """Mock APIClient.""" mock_client = Mock(spec=APIClient) @@ -572,25 +613,44 @@ async def mock_voice_assistant_api_entry(mock_voice_assistant_entry) -> MockConf async def mock_bluetooth_entry( hass: HomeAssistant, mock_client: APIClient, -): +) -> MockBluetoothEntryType: """Set up an ESPHome entry with bluetooth.""" async def _mock_bluetooth_entry( bluetooth_proxy_feature_flags: BluetoothProxyFeature, ) -> MockESPHomeDevice: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_BLUETOOTH_MAC_ADDRESS: "AA:BB:CC:DD:EE:FC", + }, + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + }, + ) + entry.add_to_hass(hass) return await _mock_generic_device_entry( hass, mock_client, - {"bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags}, + { + "bluetooth_mac_address": "AA:BB:CC:DD:EE:FC", + "bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags, + }, ([], []), [], + entry=entry, ) return _mock_bluetooth_entry @pytest.fixture -async def mock_bluetooth_entry_with_raw_adv(mock_bluetooth_entry) -> MockESPHomeDevice: +async def mock_bluetooth_entry_with_raw_adv( + mock_bluetooth_entry: MockBluetoothEntryType, +) -> MockESPHomeDevice: """Set up an ESPHome entry with bluetooth and raw advertisements.""" return await mock_bluetooth_entry( bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN @@ -604,7 +664,7 @@ async def mock_bluetooth_entry_with_raw_adv(mock_bluetooth_entry) -> MockESPHome @pytest.fixture async def mock_bluetooth_entry_with_legacy_adv( - mock_bluetooth_entry, + mock_bluetooth_entry: MockBluetoothEntryType, ) -> MockESPHomeDevice: """Set up an ESPHome entry with bluetooth with legacy advertisements.""" return await mock_bluetooth_entry( @@ -620,10 +680,7 @@ async def mock_bluetooth_entry_with_legacy_adv( async def mock_generic_device_entry( hass: HomeAssistant, hass_storage: dict[str, Any], -) -> Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockConfigEntry], -]: +) -> MockGenericDeviceEntryType: """Set up an ESPHome entry and return the MockConfigEntry.""" async def _mock_device_entry( @@ -652,10 +709,7 @@ async def mock_generic_device_entry( async def mock_esphome_device( hass: HomeAssistant, hass_storage: dict[str, Any], -) -> Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], -]: +) -> MockESPHomeDeviceType: """Set up an ESPHome entry and return the MockESPHomeDevice.""" async def _mock_device( diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 8f1711e829e..d88f2045e56 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -26,6 +26,85 @@ 'unique_id': '11:22:33:44:55:aa', 'version': 1, }), - 'dashboard': 'mock-slug', + 'dashboard': dict({ + 'addon': 'mock-slug', + 'configured': True, + 'last_exception': None, + 'last_update_success': True, + 'supports_update': None, + }), + }) +# --- +# name: test_diagnostics_with_dashboard_data + dict({ + 'config': dict({ + 'data': dict({ + 'device_name': 'test', + 'host': 'test.local', + 'password': '', + 'port': 6053, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'esphome', + 'minor_version': 1, + 'options': dict({ + 'allow_service_calls': False, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': '11:22:33:44:55:aa', + 'version': 1, + }), + 'dashboard': dict({ + 'addon': 'mock-slug', + 'configured': True, + 'device': dict({ + 'configuration': 'test.yaml', + 'current_version': '2023.1.0', + 'deployed_version': None, + 'loaded_integrations': None, + 'target_platform': None, + }), + 'has_matching_name': True, + 'last_exception': None, + 'last_update_success': True, + 'supports_update': False, + }), + 'storage_data': dict({ + 'api_version': dict({ + 'major': 99, + 'minor': 99, + }), + 'device_info': dict({ + 'bluetooth_mac_address': '', + 'bluetooth_proxy_feature_flags': 0, + 'compilation_time': '', + 'esphome_version': '1.0.0', + 'friendly_name': 'Test', + 'has_deep_sleep': False, + 'legacy_bluetooth_proxy_version': 0, + 'legacy_voice_assistant_version': 0, + 'mac_address': '**REDACTED**', + 'manufacturer': '', + 'model': '', + 'name': 'test', + 'project_name': '', + 'project_version': '', + 'suggested_area': '', + 'uses_password': False, + 'voice_assistant_feature_flags': 0, + 'webserver_port': 0, + }), + 'services': list([ + ]), + 'update': list([ + ]), + }), }) # --- diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py index a3bfc72f3e2..5a90086eac0 100644 --- a/tests/components/esphome/test_alarm_control_panel.py +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -26,11 +26,13 @@ from homeassistant.components.esphome.alarm_control_panel import EspHomeACPFeatu from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_generic_alarm_control_panel_requires_code( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic alarm_control_panel entity that requires a code.""" entity_info = [ @@ -163,7 +165,7 @@ async def test_generic_alarm_control_panel_requires_code( async def test_generic_alarm_control_panel_no_code( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic alarm_control_panel entity that does not require a code.""" entity_info = [ @@ -209,7 +211,7 @@ async def test_generic_alarm_control_panel_no_code( async def test_generic_alarm_control_panel_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic alarm_control_panel entity that is missing state.""" entity_info = [ diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 30535236970..50ce362d7b6 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -1,7 +1,6 @@ """Test ESPHome voice assistant server.""" import asyncio -from collections.abc import Awaitable, Callable from dataclasses import replace import io import socket @@ -10,12 +9,9 @@ import wave from aioesphomeapi import ( APIClient, - EntityInfo, - EntityState, MediaPlayerFormatPurpose, MediaPlayerInfo, MediaPlayerSupportedFormat, - UserService, VoiceAssistantAnnounceFinished, VoiceAssistantAudioSettings, VoiceAssistantCommandFlag, @@ -25,59 +21,35 @@ from aioesphomeapi import ( ) import pytest -from homeassistant.components import assist_satellite, tts +from homeassistant.components import ( + assist_pipeline, + assist_satellite, + conversation, + tts, +) from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, - AssistSatelliteEntity, AssistSatelliteEntityFeature, AssistSatelliteWakeWord, ) # pylint: disable-next=hass-component-root-import from homeassistant.components.assist_satellite.entity import AssistSatelliteState -from homeassistant.components.esphome import DOMAIN -from homeassistant.components.esphome.assist_satellite import ( - EsphomeAssistSatellite, - VoiceAssistantUDPServer, -) -from homeassistant.components.media_source import PlayMedia +from homeassistant.components.esphome.assist_satellite import VoiceAssistantUDPServer from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - intent as intent_helper, -) -from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers import device_registry as dr, intent as intent_helper +from homeassistant.helpers.network import get_url -from .conftest import MockESPHomeDevice +from .common import get_satellite_entity +from .conftest import MockESPHomeDeviceType - -def get_satellite_entity( - hass: HomeAssistant, mac_address: str -) -> EsphomeAssistSatellite | None: - """Get the satellite entity for a device.""" - ent_reg = er.async_get(hass) - satellite_entity_id = ent_reg.async_get_entity_id( - Platform.ASSIST_SATELLITE, DOMAIN, f"{mac_address}-assist_satellite" - ) - if satellite_entity_id is None: - return None - assert satellite_entity_id.endswith("_assist_satellite") - - component: EntityComponent[AssistSatelliteEntity] = hass.data[ - assist_satellite.DOMAIN - ] - if (entity := component.get_entity(satellite_entity_id)) is not None: - assert isinstance(entity, EsphomeAssistSatellite) - return entity - - return None +from tests.components.tts.common import MockResultStream @pytest.fixture @@ -96,13 +68,10 @@ def mock_wav() -> bytes: async def test_no_satellite_without_voice_assistant( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that an assist satellite entity is not created if a voice assistant is not present.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -119,18 +88,13 @@ async def test_pipeline_api_audio( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_wav: bytes, ) -> None: """Test a complete pipeline run with API audio (over the TCP connection).""" conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -285,12 +249,21 @@ async def test_pipeline_api_audio( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + continue_conversation=True, + ).as_dict() + }, ) ) assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END, - {"conversation_id": conversation_id}, + { + "conversation_id": conversation_id, + "continue_conversation": "1", + }, ) # TTS @@ -313,15 +286,39 @@ async def test_pipeline_api_audio( assert satellite.state == AssistSatelliteState.RESPONDING # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END, - {"url": media_url}, + {"url": get_url(hass) + mock_tts_result_stream.url}, + ) + + event_callback( + PipelineEvent( + type=PipelineEventType.RUN_START, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START, + {"url": get_url(hass) + mock_tts_result_stream.url}, ) event_callback(PipelineEvent(type=PipelineEventType.RUN_END)) @@ -340,12 +337,6 @@ async def test_pipeline_api_audio( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -358,10 +349,6 @@ async def test_pipeline_api_audio( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "_stream_tts_audio", _stream_tts_audio), patch.object(satellite, "tts_response_finished", tts_response_finished), @@ -407,10 +394,7 @@ async def test_pipeline_api_audio( async def test_pipeline_udp_audio( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_wav: bytes, ) -> None: """Test a complete pipeline run with legacy UDP audio. @@ -419,10 +403,8 @@ async def test_pipeline_udp_audio( mainly focused on the UDP server. """ conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -484,7 +466,12 @@ async def test_pipeline_udp_audio( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + ).as_dict() + }, ) ) @@ -502,10 +489,17 @@ async def test_pipeline_udp_audio( ) # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) @@ -518,12 +512,6 @@ async def test_pipeline_udp_audio( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -547,10 +535,6 @@ async def test_pipeline_udp_audio( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "tts_response_finished", tts_response_finished), ): @@ -620,10 +604,7 @@ async def test_udp_errors() -> None: async def test_pipeline_media_player( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_wav: bytes, ) -> None: """Test a complete pipeline run with the TTS response sent to a media player instead of a speaker. @@ -632,10 +613,8 @@ async def test_pipeline_media_player( mainly focused on tts_response_finished getting automatically called. """ conversation_id = "test-conversation-id" - media_url = "http://test.url" - media_id = "test-media-id" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -690,7 +669,12 @@ async def test_pipeline_media_player( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + ).as_dict() + }, ) ) @@ -708,10 +692,17 @@ async def test_pipeline_media_player( ) # Should return mock_wav audio + mock_tts_result_stream = MockResultStream(hass, "wav", mock_wav) event_callback( PipelineEvent( type=PipelineEventType.TTS_END, - data={"tts_output": {"url": media_url, "media_id": media_id}}, + data={ + "tts_output": { + "media_id": "test-media-id", + "url": mock_tts_result_stream.url, + "token": mock_tts_result_stream.token, + } + }, ) ) @@ -724,12 +715,6 @@ async def test_pipeline_media_player( original_handle_pipeline_finished() pipeline_finished.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("wav", mock_wav) - tts_finished = asyncio.Event() original_tts_response_finished = satellite.tts_response_finished @@ -742,10 +727,6 @@ async def test_pipeline_media_player( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), patch.object(satellite, "tts_response_finished", tts_response_finished), ): @@ -775,14 +756,11 @@ async def test_timer_events( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that injecting timer events results in the correct api client calls.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -849,14 +827,11 @@ async def test_unknown_timer_event( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that unknown (new) timer event types do not result in api calls.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -896,14 +871,11 @@ async def test_unknown_timer_event( async def test_streaming_tts_errors( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_wav: bytes, ) -> None: """Test error conditions for _stream_tts_audio function.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -919,92 +891,72 @@ async def test_streaming_tts_errors( # Should not stream if not running satellite._is_running = False - await satellite._stream_tts_audio("test-media-id") + await satellite._stream_tts_audio(MockResultStream(hass, "wav", mock_wav)) mock_client.send_voice_assistant_audio.assert_not_called() satellite._is_running = True # Should only stream WAV - async def get_mp3( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - return ("mp3", b"") - - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_mp3 - ): - await satellite._stream_tts_audio("test-media-id") - mock_client.send_voice_assistant_audio.assert_not_called() + await satellite._stream_tts_audio(MockResultStream(hass, "mp3", b"")) + mock_client.send_voice_assistant_audio.assert_not_called() # Needs to be the correct sample rate, etc. - async def get_bad_wav( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - with io.BytesIO() as wav_io: - with wave.open(wav_io, "wb") as wav_file: - wav_file.setframerate(48000) - wav_file.setsampwidth(2) - wav_file.setnchannels(1) - wav_file.writeframes(b"test-wav") + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(48000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(b"test-wav") - return ("wav", wav_io.getvalue()) + mock_tts_result_stream = MockResultStream(hass, "wav", wav_io.getvalue()) - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_bad_wav - ): - await satellite._stream_tts_audio("test-media-id") - mock_client.send_voice_assistant_audio.assert_not_called() + await satellite._stream_tts_audio(mock_tts_result_stream) + mock_client.send_voice_assistant_audio.assert_not_called() # Check that TTS_STREAM_* events still get sent after cancel media_fetched = asyncio.Event() - async def get_slow_wav( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: + mock_tts_result_stream = MockResultStream(hass, "wav", b"") + + async def async_stream_result_slowly(): media_fetched.set() await asyncio.sleep(1) - return ("wav", mock_wav) + yield mock_wav + + mock_tts_result_stream.async_stream_result = async_stream_result_slowly mock_client.send_voice_assistant_event.reset_mock() - with patch( - "homeassistant.components.tts.async_get_media_source_audio", new=get_slow_wav - ): - task = asyncio.create_task(satellite._stream_tts_audio("test-media-id")) - async with asyncio.timeout(1): - # Wait for media to be fetched - await media_fetched.wait() - # Cancel task - task.cancel() - await task + task = asyncio.create_task(satellite._stream_tts_audio(mock_tts_result_stream)) + async with asyncio.timeout(1): + # Wait for media to be fetched + await media_fetched.wait() - # No audio should have gone out - mock_client.send_voice_assistant_audio.assert_not_called() - assert len(mock_client.send_voice_assistant_event.call_args_list) == 2 + # Cancel task + task.cancel() + await task - # The TTS_STREAM_* events should have gone out - assert mock_client.send_voice_assistant_event.call_args_list[-2].args == ( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, - {}, - ) - assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, - {}, - ) + # No audio should have gone out + mock_client.send_voice_assistant_audio.assert_not_called() + assert len(mock_client.send_voice_assistant_event.call_args_list) == 2 + + # The TTS_STREAM_* events should have gone out + assert mock_client.send_voice_assistant_event.call_args_list[-2].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, + {}, + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, + {}, + ) async def test_tts_format_from_media_player( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that the text-to-speech format is pulled from the first media player.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[ MediaPlayerInfo( @@ -1068,13 +1020,10 @@ async def test_tts_format_from_media_player( async def test_tts_minimal_format_from_media_player( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test text-to-speech format when media player only specifies the codec.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[ MediaPlayerInfo( @@ -1132,42 +1081,13 @@ async def test_tts_minimal_format_from_media_player( } -async def test_announce_supported_features( - hass: HomeAssistant, - mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], -) -> None: - """Test that the announce supported feature is set by flags.""" - 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 - }, - ) - 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.supported_features & AssistSatelliteEntityFeature.ANNOUNCE) - - async def test_announce_message( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test announcement with message.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1187,25 +1107,32 @@ async def test_announce_message( done = asyncio.Event() async def send_voice_assistant_announcement_await_response( - media_id: str, timeout: float, text: str + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str | None = None, ): assert satellite.state == AssistSatelliteState.RESPONDING - assert media_id == "https://www.home-assistant.io/resolved.mp3" + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" assert text == "test-text" + assert not start_conversation + assert not preannounce_media_id done.set() with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.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", - ), + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), ), patch.object( mock_client, @@ -1217,7 +1144,11 @@ async def test_announce_message( await hass.services.async_call( assist_satellite.DOMAIN, "announce", - {"entity_id": satellite.entity_id, "message": "test-text"}, + { + ATTR_ENTITY_ID: satellite.entity_id, + "message": "test-text", + "preannounce": False, + }, blocking=True, ) await done.wait() @@ -1227,14 +1158,11 @@ async def test_announce_message( async def test_announce_media_id( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, device_registry: dr.DeviceRegistry, ) -> None: """Test announcement with media id.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[ MediaPlayerInfo( @@ -1275,10 +1203,16 @@ async def test_announce_media_id( done = asyncio.Event() async def send_voice_assistant_announcement_await_response( - media_id: str, timeout: float, text: str + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str | None = None, ): assert satellite.state == AssistSatelliteState.RESPONDING assert media_id == "https://www.home-assistant.io/proxied.flac" + assert not start_conversation + assert not preannounce_media_id done.set() @@ -1298,8 +1232,9 @@ async def test_announce_media_id( assist_satellite.DOMAIN, "announce", { - "entity_id": satellite.entity_id, + ATTR_ENTITY_ID: satellite.entity_id, "media_id": "https://www.home-assistant.io/resolved.mp3", + "preannounce": False, }, blocking=True, ) @@ -1307,9 +1242,9 @@ async def test_announce_media_id( assert satellite.state == AssistSatelliteState.IDLE mock_async_create_proxy_url.assert_called_once_with( - hass, - dev.id, - "https://www.home-assistant.io/resolved.mp3", + hass=hass, + device_id=dev.id, + media_url="https://www.home-assistant.io/resolved.mp3", media_format="flac", rate=48000, channels=2, @@ -1317,16 +1252,415 @@ async def test_announce_media_id( ) +async def test_announce_message_with_preannounce( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test announcement with message and preannounce media id.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str | None = None, + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" + assert text == "test-text" + assert not start_conversation + assert preannounce_media_id == "test-preannounce" + + done.set() + + with ( + patch( + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "announce", + { + ATTR_ENTITY_ID: satellite.entity_id, + "message": "test-text", + "preannounce_media_id": "test-preannounce", + }, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + +async def test_non_default_supported_features( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that the start conversation and announce are not set by default.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + 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.supported_features & AssistSatelliteEntityFeature.START_CONVERSATION + ) + assert not (satellite.supported_features & AssistSatelliteEntityFeature.ANNOUNCE) + + +async def test_start_conversation_message( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test start conversation with message.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + | VoiceAssistantFeature.START_CONVERSATION + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test engine", + conversation_language="en", + language="en", + name="test pipeline", + stt_engine="test stt", + stt_language="en", + tts_engine="test tts", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str, + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" + assert text == "test-text" + assert start_conversation + assert not preannounce_media_id + + done.set() + + with ( + patch( + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipeline", + return_value=pipeline, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "start_conversation", + { + ATTR_ENTITY_ID: satellite.entity_id, + "start_message": "test-text", + "preannounce": False, + }, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + +async def test_start_conversation_media_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + device_registry: dr.DeviceRegistry, +) -> None: + """Test start conversation with media id.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[ + MediaPlayerInfo( + object_id="mymedia_player", + key=1, + name="my media_player", + unique_id="my_media_player", + supports_pause=True, + supported_formats=[ + MediaPlayerSupportedFormat( + format="flac", + sample_rate=48000, + num_channels=2, + purpose=MediaPlayerFormatPurpose.ANNOUNCEMENT, + sample_bytes=2, + ), + ], + ) + ], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + | VoiceAssistantFeature.START_CONVERSATION + }, + ) + await hass.async_block_till_done() + + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} + ) + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test engine", + conversation_language="en", + language="en", + name="test pipeline", + stt_engine="test stt", + stt_language="en", + tts_engine="test tts", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str, + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "https://www.home-assistant.io/proxied.flac" + assert start_conversation + assert not preannounce_media_id + + done.set() + + with ( + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + patch( + "homeassistant.components.esphome.assist_satellite.async_create_proxy_url", + return_value="https://www.home-assistant.io/proxied.flac", + ) as mock_async_create_proxy_url, + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipeline", + return_value=pipeline, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "start_conversation", + { + ATTR_ENTITY_ID: satellite.entity_id, + "start_media_id": "https://www.home-assistant.io/resolved.mp3", + "preannounce": False, + }, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + mock_async_create_proxy_url.assert_called_once_with( + hass=hass, + device_id=dev.id, + media_url="https://www.home-assistant.io/resolved.mp3", + media_format="flac", + rate=48000, + channels=2, + width=2, + ) + + +async def test_start_conversation_message_with_preannounce( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test start conversation with message and preannounce media id.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + | VoiceAssistantFeature.START_CONVERSATION + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test engine", + conversation_language="en", + language="en", + name="test pipeline", + stt_engine="test stt", + stt_language="en", + tts_engine="test tts", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str, + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" + assert text == "test-text" + assert start_conversation + assert preannounce_media_id == "test-preannounce" + + done.set() + + with ( + patch( + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipeline", + return_value=pipeline, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "start_conversation", + { + ATTR_ENTITY_ID: satellite.entity_id, + "start_message": "test-text", + "preannounce_media_id": "test-preannounce", + }, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + async def test_satellite_unloaded_on_disconnect( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test that the assist satellite platform is unloaded on disconnect.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1355,13 +1689,10 @@ async def test_satellite_unloaded_on_disconnect( async def test_pipeline_abort( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test aborting a pipeline (no further processing).""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1432,10 +1763,7 @@ async def test_pipeline_abort( async def test_get_set_configuration( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test getting and setting the satellite configuration.""" expected_config = AssistSatelliteConfiguration( @@ -1448,7 +1776,7 @@ async def test_get_set_configuration( ) mock_client.get_voice_assistant_configuration.return_value = expected_config - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1485,10 +1813,7 @@ async def test_get_set_configuration( async def test_wake_word_select( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test wake word select.""" device_config = AssistSatelliteConfiguration( @@ -1512,7 +1837,7 @@ async def test_wake_word_select( mock_client.set_voice_assistant_configuration = AsyncMock(side_effect=wrapper) - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -1537,7 +1862,7 @@ async def test_wake_word_select( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {"entity_id": "select.test_wake_word", "option": "Okay Nabu"}, + {ATTR_ENTITY_ID: "select.test_wake_word", "option": "Okay Nabu"}, blocking=True, ) await hass.async_block_till_done() @@ -1552,122 +1877,3 @@ async def test_wake_word_select( # 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_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index 25d8b60f574..fee285ea312 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -1,178 +1,12 @@ """Test ESPHome binary sensors.""" -from collections.abc import Awaitable, Callable -from http import HTTPStatus - -from aioesphomeapi import ( - APIClient, - BinarySensorInfo, - BinarySensorState, - EntityInfo, - EntityState, - UserService, -) +from aioesphomeapi import APIClient, BinarySensorInfo, BinarySensorState import pytest -from homeassistant.components.esphome import DOMAIN, DomainData -from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.setup import async_setup_component -from .conftest import MockESPHomeDevice - -from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_assist_in_progress( - hass: HomeAssistant, - mock_voice_assistant_v1_entry, -) -> None: - """Test assist in progress binary sensor.""" - - entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry) - - state = hass.states.get("binary_sensor.test_assist_in_progress") - assert state is not None - assert state.state == "off" - - entry_data.async_set_assist_pipeline_state(True) - - state = hass.states.get("binary_sensor.test_assist_in_progress") - assert state.state == "on" - - entry_data.async_set_assist_pipeline_state(False) - - state = hass.states.get("binary_sensor.test_assist_in_progress") - assert state.state == "off" - - -async def test_assist_in_progress_disabled_by_default( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - mock_voice_assistant_v1_entry, -) -> None: - """Test assist in progress binary sensor is added disabled.""" - - assert not hass.states.get("binary_sensor.test_assist_in_progress") - entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") - assert entity_entry - assert entity_entry.disabled - assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Test no issue for disabled entity - assert len(issue_registry.issues) == 0 - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_assist_in_progress_issue( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - mock_voice_assistant_v1_entry, -) -> None: - """Test assist in progress binary sensor.""" - - state = hass.states.get("binary_sensor.test_assist_in_progress") - assert state is not None - - entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") - issue = issue_registry.async_get_issue( - DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" - ) - assert issue is not None - - # Test issue goes away after disabling the entity - entity_registry.async_update_entity( - "binary_sensor.test_assist_in_progress", - disabled_by=er.RegistryEntryDisabler.USER, - ) - await hass.async_block_till_done() - issue = issue_registry.async_get_issue( - DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" - ) - assert issue is None - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_assist_in_progress_repair_flow( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - mock_voice_assistant_v1_entry, -) -> None: - """Test assist in progress binary sensor deprecation issue flow.""" - - state = hass.states.get("binary_sensor.test_assist_in_progress") - assert state is not None - - entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") - assert entity_entry.disabled_by is None - issue = issue_registry.async_get_issue( - DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" - ) - assert issue is not None - assert issue.data == { - "entity_id": "binary_sensor.test_assist_in_progress", - "entity_uuid": entity_entry.id, - "integration_name": "ESPHome", - } - assert issue.translation_key == "assist_in_progress_deprecated" - assert issue.translation_placeholders == {"integration_name": "ESPHome"} - - assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) - await hass.async_block_till_done() - await hass.async_start() - - client = await hass_client() - - resp = await client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data == { - "data_schema": [], - "description_placeholders": { - "assist_satellite_domain": "assist_satellite", - "entity_id": "binary_sensor.test_assist_in_progress", - "integration_name": "ESPHome", - }, - "errors": None, - "flow_id": flow_id, - "handler": DOMAIN, - "last_step": None, - "preview": None, - "step_id": "confirm_disable_entity", - "type": "form", - } - - resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data == { - "description": None, - "description_placeholders": None, - "flow_id": flow_id, - "handler": DOMAIN, - "type": "create_entry", - } - - # Test the entity is disabled - entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") - assert entity_entry.disabled_by is er.RegistryEntryDisabler.USER +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType @pytest.mark.parametrize( @@ -182,10 +16,7 @@ async def test_binary_sensor_generic_entity( hass: HomeAssistant, mock_client: APIClient, binary_state: tuple[bool, str], - mock_generic_device_entry: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockConfigEntry], - ], + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic binary_sensor entity.""" entity_info = [ @@ -213,10 +44,7 @@ async def test_binary_sensor_generic_entity( async def test_status_binary_sensor( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockConfigEntry], - ], + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic binary_sensor entity.""" entity_info = [ @@ -244,10 +72,7 @@ async def test_status_binary_sensor( async def test_binary_sensor_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockConfigEntry], - ], + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic binary_sensor that is missing state.""" entity_info = [ @@ -274,10 +99,7 @@ async def test_binary_sensor_missing_state( async def test_binary_sensor_has_state_false( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic binary_sensor where has_state is false.""" entity_info = [ diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index 19bc5a2e7c7..dd7a8f59fe5 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -13,7 +13,7 @@ async def test_bluetooth_connect_with_raw_adv( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with raw advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -21,11 +21,11 @@ async def test_bluetooth_connect_with_raw_adv( await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_raw_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True @@ -33,7 +33,7 @@ async def test_bluetooth_connect_with_legacy_adv( hass: HomeAssistant, mock_bluetooth_entry_with_legacy_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with legacy advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -41,11 +41,11 @@ async def test_bluetooth_connect_with_legacy_adv( await mock_bluetooth_entry_with_legacy_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_legacy_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True @@ -55,10 +55,10 @@ async def test_bluetooth_device_linked_via_device( device_registry: dr.DeviceRegistry, ) -> None: """Test the Bluetooth device is linked to the ESPHome device.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.connectable is True entry = hass.config_entries.async_entry_for_domain_unique_id( - "bluetooth", "11:22:33:44:55:AA" + "bluetooth", "AA:BB:CC:DD:EE:FC" ) assert entry is not None esp_device = device_registry.async_get_device( @@ -71,7 +71,7 @@ async def test_bluetooth_device_linked_via_device( ) assert esp_device is not None device = device_registry.async_get_device( - connections={(dr.CONNECTION_BLUETOOTH, "11:22:33:44:55:AA")} + connections={(dr.CONNECTION_BLUETOOTH, "AA:BB:CC:DD:EE:FC")} ) assert device is not None assert device.via_device_id == esp_device.id @@ -81,7 +81,7 @@ 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") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.connectable is True await hass.config_entries.async_unload( mock_bluetooth_entry_with_raw_adv.entry.entry_id diff --git a/tests/components/esphome/test_camera.py b/tests/components/esphome/test_camera.py index 87b86b039fd..b03d2bb7983 100644 --- a/tests/components/esphome/test_camera.py +++ b/tests/components/esphome/test_camera.py @@ -1,21 +1,12 @@ """Test ESPHome cameras.""" -from collections.abc import Awaitable, Callable - -from aioesphomeapi import ( - APIClient, - CameraInfo, - CameraState as ESPHomeCameraState, - EntityInfo, - EntityState, - UserService, -) +from aioesphomeapi import APIClient, CameraInfo, CameraState as ESPHomeCameraState from homeassistant.components.camera import CameraState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType from tests.typing import ClientSessionGenerator @@ -30,10 +21,7 @@ SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) async def test_camera_single_image( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera single image request.""" @@ -78,10 +66,7 @@ async def test_camera_single_image( async def test_camera_single_image_unavailable_before_requested( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera that goes unavailable before the request.""" @@ -119,10 +104,7 @@ async def test_camera_single_image_unavailable_before_requested( async def test_camera_single_image_unavailable_during_request( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera that goes unavailable before the request.""" @@ -164,10 +146,7 @@ async def test_camera_single_image_unavailable_during_request( async def test_camera_stream( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera stream.""" @@ -224,10 +203,7 @@ async def test_camera_stream( async def test_camera_stream_unavailable( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera stream when the device is disconnected.""" @@ -264,10 +240,7 @@ async def test_camera_stream_unavailable( async def test_camera_stream_with_disconnection( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, hass_client: ClientSessionGenerator, ) -> None: """Test a generic camera stream that goes unavailable during the request.""" diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 2a5013444dd..dd42ee97029 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -14,7 +14,7 @@ from aioesphomeapi import ( ClimateSwingMode, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -44,9 +44,13 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from .conftest import MockGenericDeviceEntryType + async def test_climate_entity( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic climate entity.""" entity_info = [ @@ -94,7 +98,9 @@ async def test_climate_entity( async def test_climate_entity_with_step_and_two_point( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic climate entity.""" entity_info = [ @@ -168,7 +174,9 @@ async def test_climate_entity_with_step_and_two_point( async def test_climate_entity_with_step_and_target_temp( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic climate entity.""" entity_info = [ @@ -318,7 +326,9 @@ async def test_climate_entity_with_step_and_target_temp( async def test_climate_entity_with_humidity( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic climate entity with humidity.""" entity_info = [ @@ -378,7 +388,9 @@ async def test_climate_entity_with_humidity( async def test_climate_entity_with_inf_value( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic climate entity with infinite temp.""" entity_info = [ @@ -407,7 +419,7 @@ async def test_climate_entity_with_inf_value( target_temperature=math.inf, fan_mode=ClimateFanMode.AUTO, swing_mode=ClimateSwingMode.BOTH, - current_humidity=20.1, + current_humidity=math.inf, target_humidity=25.7, ) ] @@ -422,7 +434,7 @@ async def test_climate_entity_with_inf_value( assert state is not None assert state.state == HVACMode.AUTO attributes = state.attributes - assert attributes[ATTR_CURRENT_HUMIDITY] == 20 + assert ATTR_CURRENT_HUMIDITY not in attributes assert attributes[ATTR_HUMIDITY] == 26 assert attributes[ATTR_MAX_HUMIDITY] == 30 assert attributes[ATTR_MIN_HUMIDITY] == 10 @@ -433,7 +445,7 @@ async def test_climate_entity_with_inf_value( async def test_climate_entity_attributes( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, snapshot: SnapshotAssertion, ) -> None: """Test a climate entity sets correct attributes.""" @@ -489,7 +501,7 @@ async def test_climate_entity_attributes( async def test_climate_entity_attribute_current_temperature_unsupported( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a climate entity with current temperature unsupported.""" entity_info = [ diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index afca6f76b43..ead9167d258 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -27,6 +27,7 @@ from homeassistant.components.esphome.const import ( DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) +from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -36,6 +37,7 @@ from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import VALID_NOISE_PSK +from .conftest import MockGenericDeviceEntryType from tests.common import MockConfigEntry @@ -50,24 +52,33 @@ def mock_setup_entry(): yield -@pytest.mark.usefixtures("mock_zeroconf") +def get_flow_context(hass: HomeAssistant, result: ConfigFlowResult) -> dict[str, Any]: + """Get the flow context from the result of async_init or async_configure.""" + flow = next( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + + return flow["context"] + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_connection_works( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=None, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - "esphome", - context={"source": config_entries.SOURCE_USER}, - data={CONF_HOST: "127.0.0.1", CONF_PORT: 80}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 80}, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -93,10 +104,8 @@ async def test_user_connection_works( assert mock_client.noise_psk is None -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_connection_updates_host( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_user_connection_updates_host(hass: HomeAssistant) -> None: """Test setup up the same name updates the host.""" entry = MockConfigEntry( domain=DOMAIN, @@ -105,7 +114,7 @@ async def test_user_connection_updates_host( ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None, ) @@ -113,20 +122,22 @@ async def test_user_connection_updates_host( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - "esphome", - context={"source": config_entries.SOURCE_USER}, - data={CONF_HOST: "127.0.0.1", CONF_PORT: 80}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 80}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "127.0.0.1" -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_sets_unique_id( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_user_sets_unique_id(hass: HomeAssistant) -> None: """Test that the user flow sets the unique id.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -140,11 +151,14 @@ async def test_user_sets_unique_id( type="mock_type", ) discovery_result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert discovery_result["type"] is FlowResultType.FORM assert discovery_result["step_id"] == "discovery_confirm" + assert discovery_result["description_placeholders"] == { + "name": "test8266", + } discovery_result = await hass.config_entries.flow.async_configure( discovery_result["flow_id"], @@ -160,7 +174,7 @@ async def test_user_sets_unique_id( } result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None, ) @@ -173,13 +187,16 @@ async def test_user_sets_unique_id( {CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "test", + "name": "test", + "mac": "11:22:33:44:55:aa", + } -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_resolve_error( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_resolve_error(hass: HomeAssistant, mock_client: APIClient) -> None: """Test user step with IP resolve error.""" with patch( @@ -188,7 +205,7 @@ async def test_user_resolve_error( ) as exc: mock_client.device_info.side_effect = exc result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -201,11 +218,27 @@ async def test_user_resolve_error( assert len(mock_client.device_info.mock_calls) == 1 assert len(mock_client.disconnect.mock_calls) == 1 + # Now simulate the user retrying with the same host and a successful connection + mock_client.device_info.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_causes_zeroconf_to_abort( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_user_causes_zeroconf_to_abort(hass: HomeAssistant) -> None: """Test that the user flow sets the unique id and aborts the zeroconf flow.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -219,14 +252,17 @@ async def test_user_causes_zeroconf_to_abort( type="mock_type", ) discovery_result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert discovery_result["type"] is FlowResultType.FORM assert discovery_result["step_id"] == "discovery_confirm" + assert discovery_result["description_placeholders"] == { + "name": "test8266", + } result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None, ) @@ -250,15 +286,16 @@ async def test_user_causes_zeroconf_to_abort( assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_connection_error( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, + mock_client: APIClient, ) -> None: """Test user step with connection error.""" mock_client.device_info.side_effect = APIConnectionError result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -271,22 +308,42 @@ async def test_user_connection_error( assert len(mock_client.device_info.mock_calls) == 1 assert len(mock_client.disconnect.mock_calls) == 1 + # Now simulate the user retrying with the same host and a successful connection + mock_client.device_info.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_with_password( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, + mock_client: APIClient, ) -> None: """Test user step with password.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password1"} @@ -304,18 +361,21 @@ async def test_user_with_password( @pytest.mark.usefixtures("mock_zeroconf") -async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None: +async def test_user_invalid_password( + hass: HomeAssistant, mock_client: APIClient +) -> None: """Test user step with invalid password.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} mock_client.connect.side_effect = InvalidAuthAPIError @@ -325,20 +385,35 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} assert result["errors"] == {"base": "invalid_auth"} + mock_client.connect.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "good"} + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "good", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_dashboard", "mock_setup_entry", "mock_zeroconf") async def test_user_dashboard_has_wrong_key( hass: HomeAssistant, - mock_client, - mock_dashboard: dict[str, Any], - mock_setup_entry: None, + mock_client: APIClient, ) -> None: """Test user step with key from dashboard that is incorrect.""" mock_client.device_info.side_effect = [ RequiresEncryptionAPIError, - InvalidEncryptionKeyAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo( uses_password=False, name="test", @@ -351,7 +426,7 @@ async def test_user_dashboard_has_wrong_key( return_value=WRONG_NOISE_PSK, ): result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -359,6 +434,7 @@ async def test_user_dashboard_has_wrong_key( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -375,12 +451,11 @@ async def test_user_dashboard_has_wrong_key( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_and_gets_key_from_dashboard( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test user step can discover the name and get the key from the dashboard.""" mock_client.device_info.side_effect = [ @@ -406,7 +481,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard( return_value=VALID_NOISE_PSK, ): result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -427,13 +502,12 @@ async def test_user_discovers_name_and_gets_key_from_dashboard( "dashboard_exception", [aiohttp.ClientError(), json.JSONDecodeError("test", "test", 0)], ) -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_and_gets_key_from_dashboard_fails( hass: HomeAssistant, dashboard_exception: Exception, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test user step can discover the name and get the key from the dashboard.""" mock_client.device_info.side_effect = [ @@ -459,7 +533,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( side_effect=dashboard_exception, ): result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -467,6 +541,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -483,12 +558,11 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_and_dashboard_is_unavailable( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test user step can discover the name but the dashboard is unavailable.""" mock_client.device_info.side_effect = [ @@ -509,12 +583,12 @@ async def test_user_discovers_name_and_dashboard_is_unavailable( ) with patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices", side_effect=TimeoutError, ): await dashboard.async_get_dashboard(hass).async_refresh() result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -522,6 +596,7 @@ async def test_user_discovers_name_and_dashboard_is_unavailable( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -538,21 +613,22 @@ async def test_user_discovers_name_and_dashboard_is_unavailable( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_login_connection_error( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test user step with connection error on login attempt.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} mock_client.connect.side_effect = APIConnectionError @@ -562,13 +638,28 @@ async def test_login_connection_error( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == {"name": "test"} assert result["errors"] == {"base": "connection_error"} + mock_client.connect.side_effect = None -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_initiation( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "good"} + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_DEVICE_NAME: "test", + CONF_PASSWORD: "good", + CONF_NOISE_PSK: "", + } + + +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_initiation(hass: HomeAssistant) -> None: """Test discovery importing works.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -578,12 +669,18 @@ async def test_discovery_initiation( port=6053, properties={ "mac": "1122334455aa", + "friendly_name": "The Test", }, type="mock_type", ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) + assert get_flow_context(hass, flow) == { + "source": config_entries.SOURCE_ZEROCONF, + "title_placeholders": {"name": "The Test (test)"}, + "unique_id": "11:22:33:44:55:aa", + } result = await hass.config_entries.flow.async_configure( flow["flow_id"], user_input={} @@ -598,10 +695,8 @@ async def test_discovery_initiation( assert result["result"].unique_id == "11:22:33:44:55:aa" -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_no_mac( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_no_mac(hass: HomeAssistant) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -613,15 +708,14 @@ async def test_discovery_no_mac( type="mock_type", ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert flow["type"] is FlowResultType.ABORT assert flow["reason"] == "mdns_missing_mac" -async def test_discovery_already_configured( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_already_configured(hass: HomeAssistant) -> None: """Test discovery aborts if already configured via hostname.""" entry = MockConfigEntry( domain=DOMAIN, @@ -641,16 +735,49 @@ async def test_discovery_already_configured( type="mock_type", ) result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } + + +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_ignored(hass: HomeAssistant) -> None: + """Test discovery does not probe and ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test8266.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + source=SOURCE_IGNORE, + ) + + entry.add_to_hass(hass) + + service_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={"mac": "1122334455aa"}, + type="mock_type", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_discovery_duplicate_data( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_duplicate_data(hass: HomeAssistant) -> None: """Test discovery aborts if same mDNS packet arrives.""" service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), @@ -663,21 +790,21 @@ async def test_discovery_duplicate_data( ) result = await hass.config_entries.flow.async_init( - "esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} + DOMAIN, data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_init( - "esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} + DOMAIN, data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_in_progress" -async def test_discovery_updates_unique_id( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: """Test a duplicate discovery host aborts and updates existing entry.""" entry = MockConfigEntry( domain=DOMAIN, @@ -687,6 +814,44 @@ async def test_discovery_updates_unique_id( entry.add_to_hass(hass) + service_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.43.184"), + ip_addresses=[ip_address("192.168.43.184")], + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={"address": "test8266.local", "mac": "1122334455aa"}, + type="mock_type", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } + + assert entry.data[CONF_HOST] == "192.168.43.184" + assert entry.unique_id == "11:22:33:44:55:aa" + + +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_abort_without_update_same_host_port( + hass: HomeAssistant, +) -> None: + """Test discovery aborts without update when hsot and port are the same.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + + entry.add_to_hass(hass) + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], @@ -697,24 +862,20 @@ async def test_discovery_updates_unique_id( type="mock_type", ) result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert entry.unique_id == "11:22:33:44:55:aa" - -@pytest.mark.usefixtures("mock_zeroconf") -async def test_user_requires_psk( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_requires_psk(hass: HomeAssistant, mock_client: APIClient) -> None: """Test user step with requiring encryption key.""" mock_client.device_info.side_effect = RequiresEncryptionAPIError result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -722,28 +883,52 @@ async def test_user_requires_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" assert result["errors"] == {} + assert result["description_placeholders"] == {"name": "ESPHome"} assert len(mock_client.connect.mock_calls) == 2 assert len(mock_client.device_info.mock_calls) == 2 assert len(mock_client.disconnect.mock_calls) == 2 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: INVALID_NOISE_PSK} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["errors"] == {"base": "requires_encryption_key"} + assert result["description_placeholders"] == {"name": "ESPHome"} -@pytest.mark.usefixtures("mock_zeroconf") + mock_client.device_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + assert mock_client.noise_psk == VALID_NOISE_PSK + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_encryption_key_valid_psk( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test encryption key step with valid key.""" mock_client.device_info.side_effect = RequiresEncryptionAPIError result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "ESPHome"} mock_client.device_info = AsyncMock( return_value=DeviceInfo(uses_password=False, name="test") @@ -763,22 +948,23 @@ async def test_encryption_key_valid_psk( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_encryption_key_invalid_psk( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test encryption key step with invalid key.""" mock_client.device_info.side_effect = RequiresEncryptionAPIError result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "ESPHome"} mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError result = await hass.config_entries.flow.async_configure( @@ -788,37 +974,47 @@ async def test_encryption_key_invalid_psk( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" assert result["errors"] == {"base": "invalid_psk"} + assert result["description_placeholders"] == {"name": "ESPHome"} assert mock_client.noise_psk == INVALID_NOISE_PSK + mock_client.device_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) -@pytest.mark.usefixtures("mock_zeroconf") -async def test_reauth_initiation(hass: HomeAssistant, mock_client) -> None: - """Test reauth initiation shows form.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + assert mock_client.noise_psk == VALID_NOISE_PSK + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_reauth_confirm_valid( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth initiation with valid PSK.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", ) 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 result["description_placeholders"] == { + "name": "Mock Title (test)", + } - -@pytest.mark.usefixtures("mock_zeroconf") -async def test_reauth_confirm_valid( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: - """Test reauth initiation with valid PSK.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" ) - entry.add_to_hass(hass) - - result = await entry.start_reauth_flow(hass) - - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} ) @@ -828,12 +1024,53 @@ async def test_reauth_confirm_valid( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reauth_attempt_to_change_mac_aborts( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth initiation with valid PSK attempting to change mac. + + This can happen if reauth starts, but they don't finish it before + a new device takes the place of the old one at the same IP. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unique_id_changed" + assert CONF_NOISE_PSK not in entry.data + assert result["description_placeholders"] == { + "expected_mac": "11:22:33:44:55:aa", + "host": "127.0.0.1", + "name": "test", + "unexpected_device_name": "test", + "unexpected_mac": "11:22:33:44:55:bb", + } + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard.""" @@ -845,10 +1082,13 @@ async def test_reauth_fixed_via_dashboard( CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) mock_dashboard["configured"].append( { @@ -872,18 +1112,17 @@ async def test_reauth_fixed_via_dashboard( assert len(mock_get_encryption_key.mock_calls) == 1 -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], mock_config_entry: MockConfigEntry, - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard with password removed.""" mock_client.device_info.side_effect = ( InvalidAuthAPIError, - DeviceInfo(uses_password=False, name="test"), + DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), ) mock_dashboard["configured"].append( @@ -909,15 +1148,16 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( assert len(mock_get_encryption_key.mock_calls) == 1 +@pytest.mark.usefixtures("mock_dashboard", "mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_remove_password( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_config_entry: MockConfigEntry, - mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically by seeing password removed.""" - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) result = await mock_config_entry.start_reauth_flow(hass) @@ -926,12 +1166,11 @@ async def test_reauth_fixed_via_remove_password( assert mock_config_entry.data[CONF_PASSWORD] == "" -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard_at_confirm( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test reauth fixed automatically via dashboard at confirm step.""" @@ -943,15 +1182,21 @@ async def test_reauth_fixed_via_dashboard_at_confirm( CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM, result assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } mock_dashboard["configured"].append( { @@ -976,14 +1221,15 @@ async def test_reauth_fixed_via_dashboard_at_confirm( assert len(mock_get_encryption_key.mock_calls) == 1 -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_confirm_invalid( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) @@ -996,11 +1242,16 @@ async def test_reauth_confirm_invalid( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } assert result["errors"] assert result["errors"]["base"] == "invalid_psk" mock_client.device_info = AsyncMock( - return_value=DeviceInfo(uses_password=False, name="test") + return_value=DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1011,15 +1262,15 @@ async def test_reauth_confirm_invalid( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_confirm_invalid_with_unique_id( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, - unique_id="test", + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) @@ -1032,11 +1283,16 @@ async def test_reauth_confirm_invalid_with_unique_id( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } assert result["errors"] assert result["errors"]["base"] == "invalid_psk" mock_client.device_info = AsyncMock( - return_value=DeviceInfo(uses_password=False, name="test") + return_value=DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1047,8 +1303,40 @@ async def test_reauth_confirm_invalid_with_unique_id( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_reauth_encryption_key_removed(hass: HomeAssistant) -> None: + """Test reauth when the encryption key was removed.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_encryption_removed_confirm" + assert result["description_placeholders"] == { + "name": "Mock Title (test)", + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_NOISE_PSK] == "" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_discovery_dhcp_updates_host( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test dhcp discovery updates host and aborts.""" entry = MockConfigEntry( @@ -1057,6 +1345,9 @@ async def test_discovery_dhcp_updates_host( unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(name="test8266", mac_address="1122334455aa") + ) service_info = DhcpServiceInfo( ip="192.168.43.184", @@ -1064,17 +1355,130 @@ async def test_discovery_dhcp_updates_host( macaddress="1122334455aa", ) result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "192.168.43.184" +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_dhcp_does_not_update_host_wrong_mac( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test dhcp discovery does not update the host if the mac is wrong.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(name="test8266", mac_address="1122334455ff") + ) + + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } + + # Mac was wrong, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_dhcp_does_not_update_host_wrong_mac_bad_key( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test dhcp discovery does not update the host if the mac is wrong.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError( + "Wrong key", "test8266", "1122334455cc" + ) + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } + + # Mac was wrong, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_dhcp_does_not_update_host_missing_mac_bad_key( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test dhcp discovery does not update the host if the mac is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError( + "Wrong key", "test8266", None + ) + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_detailed" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "unknown", + "mac": "11:22:33:44:55:aa", + } + + # Mac was missing, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + +@pytest.mark.usefixtures("mock_setup_entry") async def test_discovery_dhcp_no_changes( - hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test dhcp discovery updates host and aborts.""" entry = MockConfigEntry( @@ -1091,7 +1495,7 @@ async def test_discovery_dhcp_no_changes( macaddress="000000000000", ) result = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info ) assert result["type"] is FlowResultType.ABORT @@ -1100,12 +1504,11 @@ async def test_discovery_dhcp_no_changes( assert entry.data[CONF_HOST] == "192.168.43.183" -async def test_discovery_hassio( - hass: HomeAssistant, mock_dashboard: dict[str, Any] -) -> None: +@pytest.mark.usefixtures("mock_dashboard") +async def test_discovery_hassio(hass: HomeAssistant) -> None: """Test dashboard discovery.""" result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, data=HassioServiceInfo( config={ "host": "mock-esphome", @@ -1126,12 +1529,11 @@ async def test_discovery_hassio( assert dash.addon_slug == "mock-slug" -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_zeroconf_encryption_key_via_dashboard( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard.""" service_info = ZeroconfServiceInfo( @@ -1146,11 +1548,12 @@ async def test_zeroconf_encryption_key_via_dashboard( type="mock_type", ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} mock_dashboard["configured"].append( { @@ -1192,12 +1595,11 @@ async def test_zeroconf_encryption_key_via_dashboard( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( hass: HomeAssistant, - mock_client, + mock_client: APIClient, mock_dashboard: dict[str, Any], - mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard with api_encryption property set.""" service_info = ZeroconfServiceInfo( @@ -1213,11 +1615,12 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( type="mock_type", ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} mock_dashboard["configured"].append( { @@ -1258,12 +1661,10 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( assert mock_client.noise_psk == VALID_NOISE_PSK -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_dashboard", "mock_setup_entry", "mock_zeroconf") async def test_zeroconf_no_encryption_key_via_dashboard( hass: HomeAssistant, - mock_client, - mock_dashboard: dict[str, Any], - mock_setup_entry: None, + mock_client: APIClient, ) -> None: """Test encryption key not retrieved from dashboard.""" service_info = ZeroconfServiceInfo( @@ -1278,11 +1679,12 @@ async def test_zeroconf_no_encryption_key_via_dashboard( type="mock_type", ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info ) assert flow["type"] is FlowResultType.FORM assert flow["step_id"] == "discovery_confirm" + assert flow["description_placeholders"] == {"name": "test8266"} await dashboard.async_get_dashboard(hass).async_refresh() @@ -1294,12 +1696,28 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test8266"} + + mock_client.device_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.43.183", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + assert mock_client.noise_psk == VALID_NOISE_PSK async def test_option_flow_allow_service_calls( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test config flow options for allow service calls.""" entry = await mock_generic_device_entry( @@ -1344,7 +1762,7 @@ async def test_option_flow_allow_service_calls( async def test_option_flow_subscribe_logs( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test config flow options with subscribe logs.""" entry = await mock_generic_device_entry( @@ -1380,11 +1798,10 @@ async def test_option_flow_subscribe_logs( assert len(mock_reload.mock_calls) == 1 -@pytest.mark.usefixtures("mock_zeroconf") +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_user_discovers_name_no_dashboard( hass: HomeAssistant, - mock_client, - mock_setup_entry: None, + mock_client: APIClient, ) -> None: """Test user step can discover the name and the there is not dashboard.""" mock_client.device_info.side_effect = [ @@ -1398,7 +1815,7 @@ async def test_user_discovers_name_no_dashboard( ] result = await hass.config_entries.flow.async_init( - "esphome", + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, ) @@ -1406,6 +1823,7 @@ async def test_user_discovers_name_no_dashboard( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "test"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1422,7 +1840,9 @@ async def test_user_discovers_name_no_dashboard( assert mock_client.noise_psk == VALID_NOISE_PSK -async def mqtt_discovery_test_abort(hass: HomeAssistant, payload: str, reason: str): +async def mqtt_discovery_test_abort( + hass: HomeAssistant, payload: str, reason: str +) -> None: """Test discovery aborted.""" service_info = MqttServiceInfo( topic="esphome/discover/test", @@ -1433,50 +1853,40 @@ async def mqtt_discovery_test_abort(hass: HomeAssistant, payload: str, reason: s timestamp=None, ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_MQTT}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_MQTT}, data=service_info ) assert flow["type"] is FlowResultType.ABORT assert flow["reason"] == reason -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_no_mac( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_no_mac(hass: HomeAssistant) -> None: """Test discovery aborted if mac is missing in MQTT payload.""" await mqtt_discovery_test_abort(hass, "{}", "mqtt_missing_mac") -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_empty_payload( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_empty_payload(hass: HomeAssistant) -> None: """Test discovery aborted if MQTT payload is empty.""" await mqtt_discovery_test_abort(hass, "", "mqtt_missing_payload") -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_no_api( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_no_api(hass: HomeAssistant) -> None: """Test discovery aborted if api/port is missing in MQTT payload.""" await mqtt_discovery_test_abort(hass, '{"mac":"abcdef123456"}', "mqtt_missing_api") -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_no_ip( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_no_ip(hass: HomeAssistant) -> None: """Test discovery aborted if ip is missing in MQTT payload.""" await mqtt_discovery_test_abort( hass, '{"mac":"abcdef123456","port":6053}', "mqtt_missing_ip" ) -@pytest.mark.usefixtures("mock_zeroconf") -async def test_discovery_mqtt_initiation( - hass: HomeAssistant, mock_client, mock_setup_entry: None -) -> None: +@pytest.mark.usefixtures("mock_client", "mock_setup_entry", "mock_zeroconf") +async def test_discovery_mqtt_initiation(hass: HomeAssistant) -> None: """Test discovery importing works.""" service_info = MqttServiceInfo( topic="esphome/discover/test", @@ -1487,7 +1897,7 @@ async def test_discovery_mqtt_initiation( timestamp=None, ) flow = await hass.config_entries.flow.async_init( - "esphome", context={"source": config_entries.SOURCE_MQTT}, data=service_info + DOMAIN, context={"source": config_entries.SOURCE_MQTT}, data=service_info ) result = await hass.config_entries.flow.async_configure( @@ -1501,3 +1911,468 @@ async def test_discovery_mqtt_initiation( assert result["result"] assert result["result"].unique_id == "11:22:33:44:55:aa" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_flow_name_conflict_migrate( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test handle migration on name conflict.""" + existing_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE_NAME: "test"}, + unique_id="11:22:33:44:55:cc", + ) + existing_entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + uses_password=False, + name="test", + mac_address="11:22:33:44:55:AA", + ) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_migrate"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "name_conflict_migrated" + assert result["description_placeholders"] == { + "existing_mac": "11:22:33:44:55:cc", + "mac": "11:22:33:44:55:aa", + "name": "test", + } + assert existing_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert existing_entry.unique_id == "11:22:33:44:55:aa" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") +async def test_user_flow_name_conflict_overwrite( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test handle overwrite on name conflict.""" + existing_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE_NAME: "test"}, + unique_id="11:22:33:44:55:cc", + ) + existing_entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + uses_password=False, + name="test", + mac_address="11:22:33:44:55:AA", + ) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert result["context"]["unique_id"] == "11:22:33:44:55:aa" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_with_same_ip_new_name( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with same ip and new name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_DEVICE_NAME] == "other" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_with_new_ip_new_name( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with new ip and new name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.2" + assert entry.data[CONF_DEVICE_NAME] == "other" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_with_new_ip_same_name( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with new ip and same name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + CONF_NOISE_PSK: VALID_NOISE_PSK, + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_DEVICE_NAME] == "test" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_noise_psk_changes( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with new ip and new noise psk.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + CONF_NOISE_PSK: VALID_NOISE_PSK, + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + mock_client.device_info.side_effect = [ + RequiresEncryptionAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), + DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), + ] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "Mock Title (test)"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["description_placeholders"] == {"name": "Mock Title (test)"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_DEVICE_NAME] == "test" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_name_conflict_with_existing_entry( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig with a name conflict with an existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + entry2 = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "other", + }, + unique_id="11:22:33:44:55:bb", + ) + entry2.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.3", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_name_conflict" + assert result["description_placeholders"] == { + "existing_title": "Mock Title", + "expected_mac": "11:22:33:44:55:aa", + "host": "127.0.0.3", + "name": "test", + } + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_attempt_to_change_mac_aborts( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with valid PSK attempting to change mac.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_unique_id_changed" + assert CONF_NOISE_PSK not in entry.data + assert result["description_placeholders"] == { + "expected_mac": "11:22:33:44:55:aa", + "host": "127.0.0.2", + "name": "test", + "unexpected_device_name": "other", + "unexpected_mac": "11:22:33:44:55:bb", + } + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_mac_used_by_other_entry( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig when there is another entry for the mac.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + entry2 = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test4", + }, + unique_id="11:22:33:44:55:bb", + ) + entry2.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_already_configured" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test4", + "mac": "11:22:33:44:55:bb", + } + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_name_conflict_migrate( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation when device has been replaced.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_migrate"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "name_conflict_migrated" + + assert entry.data == { + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert entry.unique_id == "11:22:33:44:55:bb" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_name_conflict_overwrite( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation when device has been replaced.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert result["context"]["unique_id"] == "11:22:33:44:55:bb" + assert ( + hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, "11:22:33:44:55:aa" + ) + is None + ) diff --git a/tests/components/esphome/test_cover.py b/tests/components/esphome/test_cover.py index 4cfe91c6dea..2ea789e9cc1 100644 --- a/tests/components/esphome/test_cover.py +++ b/tests/components/esphome/test_cover.py @@ -1,6 +1,5 @@ """Test ESPHome covers.""" -from collections.abc import Awaitable, Callable from unittest.mock import call from aioesphomeapi import ( @@ -8,9 +7,6 @@ from aioesphomeapi import ( CoverInfo, CoverOperation, CoverState as ESPHomeCoverState, - EntityInfo, - EntityState, - UserService, ) from homeassistant.components.cover import ( @@ -31,16 +27,13 @@ from homeassistant.components.cover import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType async def test_cover_entity( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic cover entity.""" entity_info = [ @@ -168,10 +161,7 @@ async def test_cover_entity( async def test_cover_entity_without_position( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic cover entity without position, tilt, or stop.""" entity_info = [ diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 1641804e458..340a10a86d1 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -3,22 +3,25 @@ from typing import Any from unittest.mock import patch -from aioesphomeapi import DeviceInfo, InvalidAuthAPIError +from aioesphomeapi import APIClient, DeviceInfo, InvalidAuthAPIError +import pytest -from homeassistant.components.esphome import CONF_NOISE_PSK, coordinator, dashboard +from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, dashboard from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component from . import VALID_NOISE_PSK +from .common import MockDashboardRefresh +from .conftest import MockESPHomeDeviceType from tests.common import MockConfigEntry +@pytest.mark.usefixtures("init_integration", "mock_dashboard") async def test_dashboard_storage( hass: HomeAssistant, - init_integration, - mock_dashboard: dict[str, Any], hass_storage: dict[str, Any], ) -> None: """Test dashboard storage.""" @@ -33,7 +36,6 @@ async def test_dashboard_storage( async def test_restore_dashboard_storage( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, hass_storage: dict[str, Any], ) -> None: """Restore dashboard url and slug from storage.""" @@ -46,14 +48,13 @@ async def test_restore_dashboard_storage( with patch.object( dashboard, "async_get_or_create_dashboard_manager" ) as mock_get_or_create: - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert mock_get_or_create.call_count == 1 async def test_restore_dashboard_storage_end_to_end( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, hass_storage: dict[str, Any], ) -> None: """Restore dashboard url and slug from storage.""" @@ -63,28 +64,62 @@ async def test_restore_dashboard_storage_end_to_end( "key": dashboard.STORAGE_KEY, "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, } - with patch( - "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" - ) as mock_dashboard_api: - await hass.config_entries.async_setup(mock_config_entry.entry_id) + with ( + patch( + "homeassistant.components.esphome.dashboard.is_hassio", return_value=False + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" + ) as mock_dashboard_api, + ): + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_dashboard_api.mock_calls[0][1][0] == "http://new-host:6052" +@pytest.mark.usefixtures("hassio_stubs") +async def test_restore_dashboard_storage_skipped_if_addon_uninstalled( + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Restore dashboard restore is skipped if the addon is uninstalled.""" + hass_storage[dashboard.STORAGE_KEY] = { + "version": dashboard.STORAGE_VERSION, + "minor_version": dashboard.STORAGE_VERSION, + "key": dashboard.STORAGE_KEY, + "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, + } + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" + ) as mock_dashboard_api, + patch( + "homeassistant.components.esphome.dashboard.is_hassio", return_value=True + ), + patch( + "homeassistant.components.hassio.get_addons_info", + return_value={}, + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert "test-slug is no longer installed" in caplog.text + assert not mock_dashboard_api.called + + async def test_setup_dashboard_fails( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, hass_storage: dict[str, Any], ) -> None: """Test that nothing is stored on failed dashboard setup when there was no dashboard before.""" - with patch.object( - coordinator.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError + with patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices", + side_effect=TimeoutError, ) as mock_get_devices: - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052) - assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_get_devices.call_count == 1 # The dashboard addon might recover later so we still @@ -98,8 +133,8 @@ async def test_setup_dashboard_fails_when_already_setup( hass_storage: dict[str, Any], ) -> None: """Test failed dashboard setup still reloads entries if one existed before.""" - with patch.object( - coordinator.ESPHomeDashboardAPI, "get_devices" + with patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices" ) as mock_get_devices: await dashboard.async_set_dashboard_info( hass, "test-slug", "working-host", 6052 @@ -113,8 +148,9 @@ async def test_setup_dashboard_fails_when_already_setup( await hass.async_block_till_done() with ( - patch.object( - coordinator.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices", + side_effect=TimeoutError, ) as mock_get_devices, patch( "homeassistant.components.esphome.async_setup_entry", return_value=True @@ -130,8 +166,9 @@ async def test_setup_dashboard_fails_when_already_setup( assert len(mock_setup.mock_calls) == 1 +@pytest.mark.usefixtures("mock_dashboard") async def test_new_info_reload_config_entries( - hass: HomeAssistant, init_integration, mock_dashboard + hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test config entries are reloaded when new info is set.""" assert init_integration.state is ConfigEntryState.LOADED @@ -150,12 +187,15 @@ async def test_new_info_reload_config_entries( async def test_new_dashboard_fix_reauth( - hass: HomeAssistant, mock_client, mock_config_entry: MockConfigEntry, mock_dashboard + hass: HomeAssistant, + mock_client: APIClient, + mock_config_entry: MockConfigEntry, + mock_dashboard: dict[str, Any], ) -> None: """Test config entries waiting for reauth are triggered.""" mock_client.device_info.side_effect = ( InvalidAuthAPIError, - DeviceInfo(uses_password=False, name="test"), + DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:AA"), ) with patch( @@ -174,7 +214,7 @@ async def test_new_dashboard_fix_reauth( } ) - await dashboard.async_get_dashboard(hass).async_refresh() + await MockDashboardRefresh(hass).async_refresh() with ( patch( @@ -194,15 +234,29 @@ async def test_new_dashboard_fix_reauth( async def test_dashboard_supports_update( - hass: HomeAssistant, mock_dashboard: dict[str, Any] + hass: HomeAssistant, + mock_dashboard: dict[str, Any], + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test dashboard supports update.""" dash = dashboard.async_get_dashboard(hass) + mock_refresh = MockDashboardRefresh(hass) + + entity_info = [] + states = [] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) # No data assert not dash.supports_update - await dash.async_refresh() + await mock_refresh.async_refresh() assert dash.supports_update is None # supported version @@ -213,12 +267,44 @@ async def test_dashboard_supports_update( "current_version": "2023.2.0-dev", } ) - await dash.async_refresh() + + await mock_refresh.async_refresh() assert dash.supports_update is True - # unsupported version - dash.supports_update = None - mock_dashboard["configured"][0]["current_version"] = "2023.1.0" - await dash.async_refresh() +async def test_dashboard_unsupported_version( + hass: HomeAssistant, + mock_dashboard: dict[str, Any], + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test dashboard with unsupported version.""" + dash = dashboard.async_get_dashboard(hass) + mock_refresh = MockDashboardRefresh(hass) + + entity_info = [] + states = [] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + + # No data + assert not dash.supports_update + + await mock_refresh.async_refresh() + assert dash.supports_update is None + + # unsupported version + mock_dashboard["configured"].append( + { + "name": "test", + "configuration": "test.yaml", + "current_version": "2023.1.0", + } + ) + await mock_refresh.async_refresh() assert dash.supports_update is False diff --git a/tests/components/esphome/test_date.py b/tests/components/esphome/test_date.py index 2deb92775fb..4bf291c50f5 100644 --- a/tests/components/esphome/test_date.py +++ b/tests/components/esphome/test_date.py @@ -12,11 +12,13 @@ from homeassistant.components.date import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_generic_date_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic date entity.""" entity_info = [ @@ -52,7 +54,7 @@ async def test_generic_date_entity( async def test_generic_date_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic date entity with missing state.""" entity_info = [ diff --git a/tests/components/esphome/test_datetime.py b/tests/components/esphome/test_datetime.py index 3bdc196de95..1ccb101f581 100644 --- a/tests/components/esphome/test_datetime.py +++ b/tests/components/esphome/test_datetime.py @@ -12,11 +12,13 @@ from homeassistant.components.datetime import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_generic_datetime_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic datetime entity.""" entity_info = [ @@ -55,7 +57,7 @@ async def test_generic_datetime_entity( async def test_generic_datetime_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic datetime entity with missing state.""" entity_info = [ diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 2b2629324d2..84f2243a844 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -3,14 +3,16 @@ from typing import Any from unittest.mock import ANY +from aioesphomeapi import APIClient import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant -from .conftest import MockESPHomeDevice +from .common import MockDashboardRefresh +from .conftest import MockESPHomeDevice, MockESPHomeDeviceType from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -31,18 +33,52 @@ async def test_diagnostics( assert result == snapshot(exclude=props("created_at", "modified_at")) +@pytest.mark.usefixtures("enable_bluetooth") +async def test_diagnostics_with_dashboard_data( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], + mock_client: APIClient, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry with dashboard data.""" + mock_dashboard["configured"].append( + { + "name": "test", + "configuration": "test.yaml", + "current_version": "2023.1.0", + } + ) + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await MockDashboardRefresh(hass).async_refresh() + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_device.entry + ) + + assert result == snapshot(exclude=props("entry_id", "created_at", "modified_at")) + + async def test_diagnostics_with_bluetooth( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, ) -> None: """Test diagnostics for config entry with Bluetooth.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True entry = mock_bluetooth_entry_with_raw_adv.entry result = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert result == { + "dashboard": { + "configured": False, + }, "bluetooth": { "available": True, "connections_free": 0, @@ -55,9 +91,9 @@ async def test_diagnostics_with_bluetooth( "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, - "name": "test (11:22:33:44:55:AA)", + "name": "test (AA:BB:CC:DD:EE:FC)", "scanning": True, - "source": "11:22:33:44:55:AA", + "source": "AA:BB:CC:DD:EE:FC", "start_time": ANY, "time_since_last_device_detection": {}, "type": "ESPHomeScanner", @@ -66,6 +102,7 @@ async def test_diagnostics_with_bluetooth( "config": { "created_at": ANY, "data": { + "bluetooth_mac_address": "**REDACTED**", "device_name": "test", "host": "test.local", "password": "", @@ -89,6 +126,7 @@ async def test_diagnostics_with_bluetooth( "storage_data": { "api_version": {"major": 99, "minor": 99}, "device_info": { + "bluetooth_mac_address": "**REDACTED**", "bluetooth_proxy_feature_flags": 63, "compilation_time": "", "esphome_version": "1.0.0", diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 296d61b664d..36185efeb72 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -1,7 +1,7 @@ """Test ESPHome binary sensors.""" import asyncio -from collections.abc import Awaitable, Callable +from dataclasses import asdict from typing import Any from unittest.mock import AsyncMock @@ -9,25 +9,28 @@ from aioesphomeapi import ( APIClient, BinarySensorInfo, BinarySensorState, - EntityInfo, - EntityState, + DeviceInfo, SensorInfo, SensorState, - UserService, + build_unique_id, ) +import pytest +from homeassistant.components.esphome import DOMAIN from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_state_change_event -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDevice, MockESPHomeDeviceType async def test_entities_removed( @@ -35,10 +38,7 @@ async def test_entities_removed( entity_registry: er.EntityRegistry, mock_client: APIClient, hass_storage: dict[str, Any], - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test entities are removed when static info changes.""" entity_info = [ @@ -130,10 +130,7 @@ async def test_entities_removed_after_reload( entity_registry: er.EntityRegistry, mock_client: APIClient, hass_storage: dict[str, Any], - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test entities and their registry entry are removed when static info changes after a reload.""" entity_info = [ @@ -220,9 +217,6 @@ async def test_entities_removed_after_reload( unique_id="my_binary_sensor", ), ] - states = [ - BinarySensorState(key=1, state=True, missing_state=False), - ] mock_device.client.list_entities_services = AsyncMock( return_value=(entity_info, user_service) ) @@ -260,13 +254,77 @@ async def test_entities_removed_after_reload( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 +async def test_entities_for_entire_platform_removed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test removing all entities for a specific platform when static info changes.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor_to_be_removed", + key=1, + name="my binary_sensor to be removed", + unique_id="mybinary_sensor_to_be_removed", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + entry = mock_device.entry + entry_id = entry.entry_id + storage_key = f"esphome.{entry_id}" + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + assert state.state == STATE_ON + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 + + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) + assert reg_entry is not None + assert state.attributes[ATTR_RESTORED] is True + + entity_info = [] + states = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + entry=entry, + ) + assert mock_device.entry.entry_id == entry_id + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is None + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) + assert reg_entry is None + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 0 + + async def test_entity_info_object_ids( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test how object ids affect entity id.""" entity_info = [ @@ -293,10 +351,7 @@ async def test_deep_sleep_device( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a deep sleep device.""" entity_info = [ @@ -404,10 +459,7 @@ async def test_esphome_device_without_friendly_name( hass: HomeAssistant, mock_client: APIClient, hass_storage: dict[str, Any], - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device without friendly_name set.""" entity_info = [ @@ -430,6 +482,248 @@ async def test_esphome_device_without_friendly_name( states=states, device_info={"friendly_name": None}, ) - state = hass.states.get("binary_sensor.my_binary_sensor") + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + + +async def test_entity_without_name_device_with_friendly_name( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test name and entity_id for a device a friendly name and an entity without a name.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="", + unique_id="my_binary_sensor", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"friendly_name": "The Best Mixer", "name": "mixer"}, + ) + state = hass.states.get("binary_sensor.mixer") + assert state is not None + assert state.state == STATE_ON + # Make sure we have set the name to `None` as otherwise + # the friendly_name will be "The Best Mixer " + assert state.attributes[ATTR_FRIENDLY_NAME] == "The Best Mixer" + + +@pytest.mark.usefixtures("hass_storage") +async def test_entity_id_preserved_on_upgrade( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity_id is preserved on upgrade.""" + entity_info = [ + BinarySensorInfo( + object_id="my", + key=1, + name="my", + unique_id="binary_sensor_my", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + assert ( + build_unique_id("11:22:33:44:55:AA", entity_info[0]) + == "11:22:33:44:55:AA-binary_sensor-my" + ) + + entry = entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + "11:22:33:44:55:AA-binary_sensor-my", + suggested_object_id="should_not_change", + ) + assert entry.entity_id == "binary_sensor.should_not_change" + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"friendly_name": "The Best Mixer", "name": "mixer"}, + ) + state = hass.states.get("binary_sensor.should_not_change") + assert state is not None + + +@pytest.mark.usefixtures("hass_storage") +async def test_entity_id_preserved_on_upgrade_old_format_entity_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity_id is preserved on upgrade from old format.""" + entity_info = [ + BinarySensorInfo( + object_id="my", + key=1, + name="my", + unique_id="binary_sensor_my", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + assert ( + build_unique_id("11:22:33:44:55:AA", entity_info[0]) + == "11:22:33:44:55:AA-binary_sensor-my" + ) + + entry = entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + "11:22:33:44:55:AA-binary_sensor-my", + suggested_object_id="my", + ) + assert entry.entity_id == "binary_sensor.my" + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"name": "mixer"}, + ) + state = hass.states.get("binary_sensor.my") + assert state is not None + + +async def test_entity_id_preserved_on_upgrade_when_in_storage( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: MockESPHomeDeviceType, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity_id is preserved on upgrade with user defined entity_id.""" + entity_info = [ + BinarySensorInfo( + object_id="my", + key=1, + name="my", + unique_id="binary_sensor_my", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + user_service = [] + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"friendly_name": "The Best Mixer", "name": "mixer"}, + ) + state = hass.states.get("binary_sensor.mixer_my") + assert state is not None + # now rename the entity + ent_reg_entry = entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + "11:22:33:44:55:AA-binary_sensor-my", + ) + entity_registry.async_update_entity( + ent_reg_entry.entity_id, + new_entity_id="binary_sensor.user_named", + ) + await hass.config_entries.async_unload(device.entry.entry_id) + await hass.async_block_till_done() + entry = device.entry + entry_id = entry.entry_id + storage_key = f"esphome.{entry_id}" + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 + binary_sensor_data: dict[str, Any] = hass_storage[storage_key]["data"][ + "binary_sensor" + ][0] + assert binary_sensor_data["name"] == "my" + assert binary_sensor_data["object_id"] == "my" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + entry=entry, + device_info={"friendly_name": "The Best Mixer", "name": "mixer"}, + ) + state = hass.states.get("binary_sensor.user_named") + assert state is not None + + +async def test_deep_sleep_added_after_setup( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test deep sleep added after setup.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[ + BinarySensorInfo( + object_id="test", + key=1, + name="test", + unique_id="test", + ), + ], + user_service=[], + states=[ + BinarySensorState(key=1, state=True, missing_state=False), + ], + device_info={"has_deep_sleep": False}, + ) + + entity_id = "binary_sensor.test_test" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + await mock_device.mock_disconnect(expected_disconnect=True) + + # No deep sleep, should be unavailable + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + await mock_device.mock_connect() + + # reconnect, should be available + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + await mock_device.mock_disconnect(expected_disconnect=True) + new_device_info = DeviceInfo( + **{**asdict(mock_device.device_info), "has_deep_sleep": True} + ) + mock_device.client.device_info = AsyncMock(return_value=new_device_info) + mock_device.device_info = new_device_info + + await mock_device.mock_connect() + + # Now disconnect that deep sleep is set in device info + await mock_device.mock_disconnect(expected_disconnect=True) + + # Deep sleep, should be available + state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index a8535c38224..886e5317462 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -7,15 +7,19 @@ from aioesphomeapi import ( SensorState, ) +from homeassistant.components.esphome import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import MockGenericDeviceEntryType + async def test_migrate_entity_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic sensor entity unique id migration.""" entity_registry.async_get_or_create( @@ -58,19 +62,19 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test unique id migration prefers the original entity on downgrade upgrade.""" entity_registry.async_get_or_create( - "sensor", - "esphome", + SENSOR_DOMAIN, + DOMAIN, "my_sensor", suggested_object_id="old_sensor", disabled_by=None, ) entity_registry.async_get_or_create( - "sensor", - "esphome", + SENSOR_DOMAIN, + DOMAIN, "11:22:33:44:55:AA-sensor-mysensor", suggested_object_id="new_sensor", disabled_by=None, @@ -103,7 +107,7 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( # entity that was only created on downgrade and they keep # the original one. assert ( - entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, "my_sensor") is not None ) # Note that ESPHome includes the EntityInfo type in the unique id diff --git a/tests/components/esphome/test_event.py b/tests/components/esphome/test_event.py index c17dc4d98a9..d4688e8ab4e 100644 --- a/tests/components/esphome/test_event.py +++ b/tests/components/esphome/test_event.py @@ -4,6 +4,7 @@ from aioesphomeapi import APIClient, Event, EventInfo import pytest from homeassistant.components.event import EventDeviceClass +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -11,9 +12,9 @@ from homeassistant.core import HomeAssistant async def test_generic_event_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_esphome_device, ) -> None: - """Test a generic event entity.""" + """Test a generic event entity and its availability behavior.""" entity_info = [ EventInfo( object_id="myevent", @@ -26,13 +27,31 @@ async def test_generic_event_entity( ] states = [Event(key=1, event_type="type1")] user_service = [] - await mock_generic_device_entry( + device = await mock_esphome_device( mock_client=mock_client, entity_info=entity_info, user_service=user_service, states=states, ) + await hass.async_block_till_done() + + # Test initial state state = hass.states.get("event.test_myevent") assert state is not None assert state.state == "2024-04-24T00:00:00.000+00:00" assert state.attributes["event_type"] == "type1" + + # Test device becomes unavailable + await device.mock_disconnect(True) + await hass.async_block_till_done() + state = hass.states.get("event.test_myevent") + assert state.state == STATE_UNAVAILABLE + + # Test device becomes available again + await device.mock_connect() + await hass.async_block_till_done() + + # Event entity should be available immediately without waiting for data + state = hass.states.get("event.test_myevent") + assert state.state == "2024-04-24T00:00:00.000+00:00" + assert state.attributes["event_type"] == "type1" diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 064b37b1ec1..05a95fe0e00 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -30,9 +30,13 @@ from homeassistant.components.fan import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_fan_entity_with_all_features_old_api( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic fan entity that uses the old api and has all features.""" entity_info = [ @@ -132,7 +136,9 @@ async def test_fan_entity_with_all_features_old_api( async def test_fan_entity_with_all_features_new_api( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic fan entity that uses the new api and has all features.""" mock_client.api_version = APIVersion(1, 4) @@ -142,7 +148,7 @@ async def test_fan_entity_with_all_features_new_api( key=1, name="my fan", unique_id="my_fan", - supported_speed_levels=4, + supported_speed_count=4, supports_direction=True, supports_speed=True, supports_oscillation=True, @@ -284,7 +290,9 @@ async def test_fan_entity_with_all_features_new_api( async def test_fan_entity_with_no_features_new_api( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic fan entity that uses the new api and has no features.""" mock_client.api_version = APIVersion(1, 4) diff --git a/tests/components/esphome/test_init.py b/tests/components/esphome/test_init.py index 9e4c9709e7d..7473734ff3e 100644 --- a/tests/components/esphome/test_init.py +++ b/tests/components/esphome/test_init.py @@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -@pytest.mark.usefixtures("mock_zeroconf") -async def test_delete_entry(hass: HomeAssistant, mock_client) -> None: - """Test we can delete an entry with error.""" +@pytest.mark.usefixtures("mock_client", "mock_zeroconf") +async def test_delete_entry(hass: HomeAssistant) -> None: + """Test we can delete an entry without error.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 8e4f37079d1..0cf3e10f11e 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -5,6 +5,7 @@ from unittest.mock import call from aioesphomeapi import ( APIClient, APIVersion, + ColorMode as ESPColorMode, LightColorCapability, LightInfo, LightState, @@ -38,9 +39,15 @@ from homeassistant.components.light import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + +LIGHT_COLOR_CAPABILITY_UNKNOWN = 1 << 8 # 256 + async def test_light_on_off( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that only supports on/off.""" mock_client.api_version = APIVersion(1, 7) @@ -52,7 +59,7 @@ async def test_light_on_off( unique_id="my_light", min_mireds=153, max_mireds=400, - supported_color_modes=[LightColorCapability.ON_OFF], + supported_color_modes=[ESPColorMode.ON_OFF], ) ] states = [LightState(key=1, state=True)] @@ -80,7 +87,9 @@ async def test_light_on_off( async def test_light_brightness( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that only supports brightness.""" mock_client.api_version = APIVersion(1, 7) @@ -195,8 +204,59 @@ async def test_light_brightness( mock_client.light_command.reset_mock() +async def test_light_legacy_brightness( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a generic light entity that only supports legacy brightness.""" + mock_client.api_version = APIVersion(1, 7) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[LightColorCapability.BRIGHTNESS, 2], + ) + ] + states = [ + LightState( + key=1, state=True, brightness=100, color_mode=ESPColorMode.LEGACY_BRIGHTNESS + ) + ] + 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("light.test_mylight") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.BRIGHTNESS, + ] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [call(key=1, state=True, color_mode=LightColorCapability.BRIGHTNESS)] + ) + mock_client.light_command.reset_mock() + + async def test_light_brightness_on_off( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that only supports brightness.""" mock_client.api_version = APIVersion(1, 7) @@ -208,12 +268,14 @@ async def test_light_brightness_on_off( unique_id="my_light", min_mireds=153, max_mireds=400, - supported_color_modes=[ - LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS - ], + supported_color_modes=[ESPColorMode.ON_OFF, ESPColorMode.BRIGHTNESS], + ) + ] + states = [ + LightState( + key=1, state=True, brightness=100, color_mode=ESPColorMode.BRIGHTNESS ) ] - states = [LightState(key=1, state=True, brightness=100)] user_service = [] await mock_generic_device_entry( mock_client=mock_client, @@ -224,6 +286,10 @@ async def test_light_brightness_on_off( state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.BRIGHTNESS, + ] + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.BRIGHTNESS await hass.services.async_call( LIGHT_DOMAIN, @@ -236,8 +302,7 @@ async def test_light_brightness_on_off( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.BRIGHTNESS.value, ) ] ) @@ -254,8 +319,7 @@ async def test_light_brightness_on_off( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.BRIGHTNESS.value, brightness=pytest.approx(0.4980392156862745), ) ] @@ -264,7 +328,9 @@ async def test_light_brightness_on_off( async def test_light_legacy_white_converted_to_brightness( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that only supports legacy white.""" mock_client.api_version = APIVersion(1, 7) @@ -316,7 +382,9 @@ async def test_light_legacy_white_converted_to_brightness( async def test_light_legacy_white_with_rgb( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity with rgb and white.""" mock_client.api_version = APIVersion(1, 7) @@ -378,7 +446,9 @@ async def test_light_legacy_white_with_rgb( async def test_light_brightness_on_off_with_unknown_color_mode( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that only supports brightness along with an unknown color mode.""" mock_client.api_version = APIVersion(1, 7) @@ -391,11 +461,18 @@ async def test_light_brightness_on_off_with_unknown_color_mode( min_mireds=153, max_mireds=400, supported_color_modes=[ - LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS | 1 << 8 + ESPColorMode.ON_OFF, + ESPColorMode.BRIGHTNESS, + LIGHT_COLOR_CAPABILITY_UNKNOWN, ], ) ] - states = [LightState(key=1, state=True, brightness=100)] + entity_info[0].supported_color_modes.append(LIGHT_COLOR_CAPABILITY_UNKNOWN) + states = [ + LightState( + key=1, state=True, brightness=100, color_mode=LIGHT_COLOR_CAPABILITY_UNKNOWN + ) + ] user_service = [] await mock_generic_device_entry( mock_client=mock_client, @@ -418,9 +495,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS - | 1 << 8, + color_mode=LIGHT_COLOR_CAPABILITY_UNKNOWN, ) ] ) @@ -437,9 +512,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS - | 1 << 8, + color_mode=ESPColorMode.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), ) ] @@ -448,7 +521,9 @@ async def test_light_brightness_on_off_with_unknown_color_mode( async def test_light_on_and_brightness( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that supports on and on and brightness.""" mock_client.api_version = APIVersion(1, 7) @@ -491,17 +566,16 @@ async def test_light_on_and_brightness( async def test_rgb_color_temp_light( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light that supports color temp and RGB.""" color_modes = [ - LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, - LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS - | LightColorCapability.COLOR_TEMPERATURE, - LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS - | LightColorCapability.RGB, + ESPColorMode.ON_OFF, + ESPColorMode.BRIGHTNESS, + ESPColorMode.COLOR_TEMPERATURE, + ESPColorMode.RGB, ] mock_client.api_version = APIVersion(1, 7) @@ -516,7 +590,11 @@ async def test_rgb_color_temp_light( supported_color_modes=color_modes, ) ] - states = [LightState(key=1, state=True, brightness=100)] + states = [ + LightState( + key=1, state=True, brightness=100, color_mode=ESPColorMode.BRIGHTNESS + ) + ] user_service = [] await mock_generic_device_entry( mock_client=mock_client, @@ -539,8 +617,7 @@ async def test_rgb_color_temp_light( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.BRIGHTNESS, ) ] ) @@ -557,8 +634,7 @@ async def test_rgb_color_temp_light( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), ) ] @@ -576,9 +652,7 @@ async def test_rgb_color_temp_light( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS - | LightColorCapability.COLOR_TEMPERATURE, + color_mode=ESPColorMode.COLOR_TEMPERATURE, color_temperature=400, ) ] @@ -587,7 +661,9 @@ async def test_rgb_color_temp_light( async def test_light_rgb( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic RGB light entity.""" mock_client.api_version = APIVersion(1, 7) @@ -704,7 +780,9 @@ async def test_light_rgb( async def test_light_rgbw( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic RGBW light entity.""" mock_client.api_version = APIVersion(1, 7) @@ -867,7 +945,9 @@ async def test_light_rgbw( async def test_light_rgbww_with_cold_warm_white_support( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic RGBWW light entity with cold warm white support.""" mock_client.api_version = APIVersion(1, 7) @@ -880,12 +960,14 @@ async def test_light_rgbww_with_cold_warm_white_support( min_mireds=153, max_mireds=400, supported_color_modes=[ - LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS + ESPColorMode.RGB, + ESPColorMode.WHITE, + ESPColorMode.COLOR_TEMPERATURE, + ESPColorMode.COLD_WARM_WHITE, + ESPColorMode.ON_OFF, + ESPColorMode.BRIGHTNESS, + ESPColorMode.RGB_COLD_WARM_WHITE, + ESPColorMode.RGB_WHITE, ], ) ] @@ -900,12 +982,7 @@ async def test_light_rgbww_with_cold_warm_white_support( blue=1, warm_white=1, cold_white=1, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, ) ] user_service = [] @@ -918,7 +995,13 @@ async def test_light_rgbww_with_cold_warm_white_support( state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBWW] + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ColorMode.WHITE, + ] assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGBWW assert state.attributes[ATTR_RGBWW_COLOR] == (255, 255, 255, 255, 255) @@ -933,12 +1016,7 @@ async def test_light_rgbww_with_cold_warm_white_support( call( key=1, state=True, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, ) ] ) @@ -955,12 +1033,7 @@ async def test_light_rgbww_with_cold_warm_white_support( call( key=1, state=True, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, brightness=pytest.approx(0.4980392156862745), ) ] @@ -983,14 +1056,7 @@ async def test_light_rgbww_with_cold_warm_white_support( key=1, state=True, color_brightness=1.0, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, - cold_white=0, - warm_white=0, + color_mode=ESPColorMode.RGB, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), ) @@ -1009,16 +1075,9 @@ async def test_light_rgbww_with_cold_warm_white_support( call( key=1, state=True, - color_brightness=pytest.approx(0.4235294117647059), - cold_white=1, - warm_white=1, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, - rgb=(0, pytest.approx(0.5462962962962963), 1.0), + color_brightness=1.0, + color_mode=ESPColorMode.RGB, + rgb=(1.0, 1.0, 1.0), ) ] ) @@ -1035,16 +1094,10 @@ async def test_light_rgbww_with_cold_warm_white_support( call( key=1, state=True, - color_brightness=pytest.approx(0.4235294117647059), - cold_white=1, - warm_white=1, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, - rgb=(0, pytest.approx(0.5462962962962963), 1.0), + color_brightness=1.0, + white=1, + color_mode=ESPColorMode.RGB_WHITE, + rgb=(1.0, 1.0, 1.0), ) ] ) @@ -1067,12 +1120,7 @@ async def test_light_rgbww_with_cold_warm_white_support( color_brightness=1, cold_white=1, warm_white=1, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, rgb=(1, 1, 1), ) ] @@ -1090,16 +1138,8 @@ async def test_light_rgbww_with_cold_warm_white_support( call( key=1, state=True, - color_brightness=0, - cold_white=0, - warm_white=100, - color_mode=LightColorCapability.RGB - | LightColorCapability.WHITE - | LightColorCapability.COLOR_TEMPERATURE - | LightColorCapability.COLD_WARM_WHITE - | LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, - rgb=(0, 0, 0), + color_temperature=400.0, + color_mode=ESPColorMode.COLOR_TEMPERATURE, ) ] ) @@ -1107,7 +1147,9 @@ async def test_light_rgbww_with_cold_warm_white_support( async def test_light_rgbww_without_cold_warm_white_support( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic RGBWW light entity without cold warm white support.""" mock_client.api_version = APIVersion(1, 7) @@ -1337,7 +1379,9 @@ async def test_light_rgbww_without_cold_warm_white_support( async def test_light_color_temp( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that does supports color temp.""" mock_client.api_version = APIVersion(1, 7) @@ -1409,7 +1453,9 @@ async def test_light_color_temp( async def test_light_color_temp_no_mireds_set( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic color temp with no mireds set uses the defaults.""" mock_client.api_version = APIVersion(1, 7) @@ -1501,7 +1547,9 @@ async def test_light_color_temp_no_mireds_set( async def test_light_color_temp_legacy( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a legacy light entity that does supports color temp.""" mock_client.api_version = APIVersion(1, 7) @@ -1583,7 +1631,9 @@ async def test_light_color_temp_legacy( async def test_light_rgb_legacy( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a legacy light entity that supports rgb.""" mock_client.api_version = APIVersion(1, 5) @@ -1679,7 +1729,9 @@ async def test_light_rgb_legacy( async def test_light_effects( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity that supports on and on and brightness.""" mock_client.api_version = APIVersion(1, 7) @@ -1693,11 +1745,16 @@ async def test_light_effects( max_mireds=400, effects=["effect1", "effect2"], supported_color_modes=[ - LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + ESPColorMode.ON_OFF, + ESPColorMode.BRIGHTNESS, ], ) ] - states = [LightState(key=1, state=True, brightness=100)] + states = [ + LightState( + key=1, state=True, brightness=100, color_mode=ESPColorMode.BRIGHTNESS + ) + ] user_service = [] await mock_generic_device_entry( mock_client=mock_client, @@ -1721,8 +1778,7 @@ async def test_light_effects( call( key=1, state=True, - color_mode=LightColorCapability.ON_OFF - | LightColorCapability.BRIGHTNESS, + color_mode=ESPColorMode.BRIGHTNESS, effect="effect1", ) ] @@ -1731,7 +1787,9 @@ async def test_light_effects( async def test_only_cold_warm_white_support( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity with only cold warm white support.""" mock_client.api_version = APIVersion(1, 7) @@ -1827,7 +1885,9 @@ async def test_only_cold_warm_white_support( async def test_light_no_color_modes( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic light entity with no color modes.""" mock_client.api_version = APIVersion(1, 7) diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index ae54b16d6e2..96c91b1d79f 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -20,9 +20,13 @@ from homeassistant.components.lock import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_lock_entity_no_open( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic lock entity that does not support open.""" entity_info = [ @@ -58,7 +62,9 @@ async def test_lock_entity_no_open( async def test_lock_entity_start_locked( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic lock entity that does not support open.""" entity_info = [ @@ -83,7 +89,9 @@ async def test_lock_entity_start_locked( async def test_lock_entity_supports_open( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic lock entity that supports open.""" entity_info = [ diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index b805b065d5a..ac7c7ce1d47 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1,7 +1,6 @@ """Test ESPHome manager.""" import asyncio -from collections.abc import Awaitable, Callable import logging from unittest.mock import AsyncMock, Mock, call @@ -9,8 +8,7 @@ from aioesphomeapi import ( APIClient, APIConnectionError, DeviceInfo, - EntityInfo, - EntityState, + EncryptionPlaintextAPIError, HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, @@ -25,11 +23,15 @@ import pytest from homeassistant import config_entries from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DOMAIN, + STABLE_BLE_URL_VERSION, STABLE_BLE_VERSION_STR, ) +from homeassistant.components.esphome.manager import DEVICE_CONFLICT_ISSUE_FORMAT +from homeassistant.components.tag import DOMAIN as TAG_DOMAIN from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -38,22 +40,29 @@ 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.exceptions import HomeAssistantError +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType -from tests.common import MockConfigEntry, async_capture_events, async_mock_service +from tests.common import ( + MockConfigEntry, + async_call_logger_set_level, + async_capture_events, + async_mock_service, +) async def test_esphome_device_subscribe_logs( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, ) -> None: """Test configuring a device to subscribe to logs.""" @@ -68,7 +77,7 @@ async def test_esphome_device_subscribe_logs( options={CONF_SUBSCRIBE_LOGS: True}, ) entry.add_to_hass(hass) - device: MockESPHomeDevice = await mock_esphome_device( + device = await mock_esphome_device( mock_client=mock_client, entry=entry, entity_info=[], @@ -78,71 +87,56 @@ async def test_esphome_device_subscribe_logs( ) await hass.async_block_till_done() - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "DEBUG"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE + async with async_call_logger_set_level( + "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE - caplog.set_level(logging.DEBUG) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") - ) - await hass.async_block_till_done() - assert "test_log_message" in caplog.text + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") + ) + await hass.async_block_till_done() + assert "test_log_message" in caplog.text - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") - ) - await hass.async_block_till_done() - assert "test_error_log_message" in caplog.text + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") + ) + await hass.async_block_till_done() + assert "test_error_log_message" in caplog.text - caplog.set_level(logging.ERROR) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") - ) - await hass.async_block_till_done() - assert "test_debug_log_message" not in caplog.text + caplog.set_level(logging.ERROR) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" not in caplog.text - caplog.set_level(logging.DEBUG) - device.mock_on_log_message( - Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") - ) - await hass.async_block_till_done() - assert "test_debug_log_message" in caplog.text + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" in caplog.text - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "WARNING"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_WARN - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "ERROR"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "INFO"}, - blocking=True, - ) - assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG + async with async_call_logger_set_level( + "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_WARN + async with async_call_logger_set_level( + "homeassistant.components.esphome", "ERROR", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR + async with async_call_logger_set_level( + "homeassistant.components.esphome", "INFO", hass=hass, caplog=caplog + ): + assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG async def test_esphome_device_service_calls_not_allowed( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, issue_registry: ir.IssueRegistry, ) -> None: @@ -150,7 +144,7 @@ async def test_esphome_device_service_calls_not_allowed( entity_info = [] states = [] user_service = [] - device: MockESPHomeDevice = await mock_esphome_device( + device = await mock_esphome_device( mock_client=mock_client, entity_info=entity_info, user_service=user_service, @@ -182,22 +176,19 @@ async def test_esphome_device_service_calls_allowed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, caplog: pytest.LogCaptureFixture, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with service calls are allowed.""" - await async_setup_component(hass, "tag", {}) + await async_setup_component(hass, TAG_DOMAIN, {}) entity_info = [] states = [] user_service = [] hass.config_entries.async_update_entry( mock_config_entry, options={CONF_ALLOW_SERVICE_CALLS: True} ) - device: MockESPHomeDevice = await mock_esphome_device( + device = await mock_esphome_device( mock_client=mock_client, entity_info=entity_info, user_service=user_service, @@ -342,10 +333,7 @@ async def test_esphome_device_service_calls_allowed( async def test_esphome_device_with_old_bluetooth( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with old bluetooth creates an issue.""" @@ -365,17 +353,14 @@ async def test_esphome_device_with_old_bluetooth( ) assert ( issue.learn_more_url - == f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" + == f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" ) async def test_esphome_device_with_password( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with legacy password creates an issue.""" @@ -415,10 +400,7 @@ async def test_esphome_device_with_password( async def test_esphome_device_with_current_bluetooth( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, issue_registry: ir.IssueRegistry, ) -> None: """Test a device with recent bluetooth does not create an issue.""" @@ -447,7 +429,9 @@ async def test_esphome_device_with_current_bluetooth( @pytest.mark.usefixtures("mock_zeroconf") -async def test_unique_id_updated_to_mac(hass: HomeAssistant, mock_client) -> None: +async def test_unique_id_updated_to_mac( + hass: HomeAssistant, mock_client: APIClient +) -> None: """Test we update config entry unique ID to MAC address.""" entry = MockConfigEntry( domain=DOMAIN, @@ -475,6 +459,39 @@ async def test_unique_id_updated_to_mac(hass: HomeAssistant, mock_client) -> Non assert entry.unique_id == "11:22:33:44:55:aa" +@pytest.mark.usefixtures("mock_zeroconf") +async def test_add_missing_bluetooth_mac_address( + hass: HomeAssistant, mock_client +) -> None: + """Test bluetooth mac is added if its missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="mock-config-name", + ) + entry.add_to_hass(hass) + subscribe_done = hass.loop.create_future() + + def async_subscribe_states(*args, **kwargs) -> None: + subscribe_done.set_result(None) + + mock_client.subscribe_states = async_subscribe_states + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="1122334455aa", + bluetooth_mac_address="AA:BB:CC:DD:EE:FF", + ) + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await subscribe_done + + assert entry.unique_id == "11:22:33:44:55:aa" + assert entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) == "AA:BB:CC:DD:EE:FF" + + @pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_not_updated_if_name_same_and_already_mac( hass: HomeAssistant, mock_client: APIClient @@ -652,6 +669,7 @@ async def test_connection_aborted_wrong_device( hass: HomeAssistant, mock_client: APIClient, caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test we abort the connection if the unique id is a mac and neither name or mac match.""" entry = MockConfigEntry( @@ -685,6 +703,13 @@ async def test_connection_aborted_wrong_device( "with mac address `11:22:33:44:55:aa`, found `different` " "with mac address `11:22:33:44:55:ab`" in caplog.text ) + # If its a different name, it means their DHCP + # reservations are missing and the device is not + # actually the same device, and there is nothing + # we can do to fix it so we only log a warning + assert not issue_registry.async_get_issue( + domain=DOMAIN, issue_id=DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) + ) assert "Error getting setting up connection for" not in caplog.text mock_client.disconnect = AsyncMock() @@ -704,10 +729,89 @@ async def test_connection_aborted_wrong_device( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test", + "mac": "11:22:33:44:55:aa", + } assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() - assert len(new_info.mock_calls) == 1 + assert len(new_info.mock_calls) == 2 + assert "Unexpected device found at" not in caplog.text + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_connection_aborted_wrong_device_same_name( + hass: HomeAssistant, + mock_client: APIClient, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we abort the connection if the unique id is a mac and the name matches.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.43.183", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + disconnect_done = hass.loop.create_future() + + async def async_disconnect(*args, **kwargs) -> None: + disconnect_done.set_result(None) + + mock_client.disconnect = async_disconnect + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455ab", name="test") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await disconnect_done + + assert ( + "Unexpected device found at 192.168.43.183; expected `test` " + "with mac address `11:22:33:44:55:aa`, found `test` " + "with mac address `11:22:33:44:55:ab`" in caplog.text + ) + # We should start a repair flow to help them fix the issue + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id=DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) + ) + + assert "Error getting setting up connection for" not in caplog.text + mock_client.disconnect = AsyncMock() + caplog.clear() + # Make sure discovery triggers a reconnect + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test", + macaddress="1122334455aa", + ) + new_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455aa", name="test") + ) + mock_client.device_info = new_info + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_updates" + assert result["description_placeholders"] == { + "title": "Mock Title", + "name": "test", + "mac": "11:22:33:44:55:aa", + } + assert entry.data[CONF_HOST] == "192.168.43.184" + await hass.async_block_till_done() + assert len(new_info.mock_calls) == 2 assert "Unexpected device found at" not in caplog.text @@ -748,13 +852,10 @@ async def test_failure_during_connect( async def test_state_subscription( mock_client: APIClient, hass: HomeAssistant, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome subscribes to state changes.""" - device: MockESPHomeDevice = await mock_esphome_device( + device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -811,13 +912,10 @@ async def test_state_subscription( async def test_state_request( mock_client: APIClient, hass: HomeAssistant, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome requests state change.""" - device: MockESPHomeDevice = await mock_esphome_device( + device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -839,10 +937,8 @@ async def test_state_request( async def test_debug_logging( mock_client: APIClient, hass: HomeAssistant, - mock_generic_device_entry: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockConfigEntry], - ], + mock_generic_device_entry: MockGenericDeviceEntryType, + caplog: pytest.LogCaptureFixture, ) -> None: """Test enabling and disabling debug logging.""" assert await async_setup_component(hass, "logger", {"logger": {}}) @@ -852,33 +948,22 @@ async def test_debug_logging( user_service=[], states=[], ) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "DEBUG"}, - blocking=True, - ) - await hass.async_block_till_done() - mock_client.set_debug.assert_has_calls([call(True)]) + async with async_call_logger_set_level( + "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog + ): + mock_client.set_debug.assert_has_calls([call(True)]) + mock_client.reset_mock() - mock_client.reset_mock() - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.esphome": "WARNING"}, - blocking=True, - ) - await hass.async_block_till_done() - mock_client.set_debug.assert_has_calls([call(False)]) + async with async_call_logger_set_level( + "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog + ): + mock_client.set_debug.assert_has_calls([call(False)]) async def test_esphome_device_with_dash_in_name_user_services( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with user services and a dash in the name.""" entity_info = [] @@ -947,10 +1032,7 @@ async def test_esphome_device_with_dash_in_name_user_services( async def test_esphome_user_services_ignores_invalid_arg_types( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with user services and a dash in the name.""" entity_info = [] @@ -1009,13 +1091,66 @@ async def test_esphome_user_services_ignores_invalid_arg_types( assert not hass.services.has_service(DOMAIN, "with_dash_bad_service") +async def test_esphome_user_service_fails( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test executing a user service fails due to disconnect.""" + entity_info = [] + states = [] + service1 = UserService( + name="simple_service", + key=2, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), + ], + ) + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=[service1], + device_info={"name": "with-dash"}, + states=states, + ) + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + + mock_client.execute_service = Mock(side_effect=APIConnectionError("fail")) + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, "with_dash_simple_service", {"arg1": True}, blocking=True + ) + assert exc.value.translation_domain == DOMAIN + assert exc.value.translation_key == "action_call_failed" + assert exc.value.translation_placeholders == { + "call_name": "simple_service", + "device_name": "with-dash", + "error": "fail", + } + assert ( + str(exc.value) + == "Failed to execute the action call simple_service on with-dash: fail" + ) + + mock_client.execute_service.assert_has_calls( + [ + call( + UserService( + name="simple_service", + key=2, + args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], + ), + {"arg1": True}, + ) + ] + ) + + async def test_esphome_user_services_changes( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with user services that change arguments.""" entity_info = [] @@ -1094,10 +1229,7 @@ async def test_esphome_device_with_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with suggested area.""" device = await mock_esphome_device( @@ -1119,10 +1251,7 @@ async def test_esphome_device_with_project( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a project.""" device = await mock_esphome_device( @@ -1146,10 +1275,7 @@ async def test_esphome_device_with_manufacturer( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a manufacturer.""" device = await mock_esphome_device( @@ -1171,10 +1297,7 @@ async def test_esphome_device_with_web_server( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a web server.""" device = await mock_esphome_device( @@ -1196,10 +1319,7 @@ async def test_esphome_device_with_ipv6_web_server( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a web server.""" entry = MockConfigEntry( @@ -1232,10 +1352,7 @@ async def test_esphome_device_with_compilation_time( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a device with a compilation_time.""" device = await mock_esphome_device( @@ -1256,10 +1373,7 @@ async def test_esphome_device_with_compilation_time( async def test_disconnects_at_close_event( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test the device is disconnected at the close event.""" await mock_esphome_device( @@ -1281,6 +1395,7 @@ async def test_disconnects_at_close_event( @pytest.mark.parametrize( "error", [ + EncryptionPlaintextAPIError, RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError, InvalidAuthAPIError, @@ -1289,10 +1404,7 @@ async def test_disconnects_at_close_event( async def test_start_reauth( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, error: Exception, ) -> None: """Test exceptions on connect error trigger reauth.""" @@ -1314,13 +1426,43 @@ async def test_start_reauth( assert flow["context"]["source"] == "reauth" +async def test_no_reauth_wrong_mac( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test exceptions on connect error trigger reauth.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"compilation_time": "comp_time"}, + states=[], + ) + await hass.async_block_till_done() + + await device.mock_connect_error( + InvalidEncryptionKeyAPIError( + "fail", received_mac="aabbccddeeff", received_name="test" + ) + ) + await hass.async_block_till_done() + + # Reauth should not be triggered + flows = hass.config_entries.flow.async_progress(DOMAIN) + assert len(flows) == 0 + assert ( + "Unexpected device found at test.local; expected `test` " + "with mac address `11:22:33:44:55:aa`, found `test` " + "with mac address `aa:bb:cc:dd:ee:ff`" in caplog.text + ) + + async def test_entry_missing_unique_id( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test the unique id is added from storage if available.""" entry = MockConfigEntry( @@ -1337,3 +1479,124 @@ async def test_entry_missing_unique_id( await mock_esphome_device(mock_client=mock_client, mock_storage=True) await hass.async_block_till_done() assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_entry_missing_bluetooth_mac_address( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test the bluetooth_mac_address is added if available.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=None, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={CONF_ALLOW_SERVICE_CALLS: True}, + ) + entry.add_to_hass(hass) + await mock_esphome_device( + mock_client=mock_client, + mock_storage=True, + device_info={"bluetooth_mac_address": "AA:BB:CC:DD:EE:FC"}, + ) + await hass.async_block_till_done() + assert entry.data[CONF_BLUETOOTH_MAC_ADDRESS] == "AA:BB:CC:DD:EE:FC" + + +async def test_device_adds_friendly_name( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a device with user services that change arguments.""" + entity_info = [] + states = [] + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=[], + device_info={"name": "nofriendlyname", "friendly_name": ""}, + states=states, + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.entry.unique_id)} + ) + assert dev.name == "Nofriendlyname" + assert ( + "No `friendly_name` set in the `esphome:` section of " + "the YAML config for device 'nofriendlyname'" + ) in caplog.text + caplog.clear() + + await device.mock_disconnect(True) + await hass.async_block_till_done() + device.device_info = DeviceInfo( + **{**device.device_info.to_dict(), "friendly_name": "I have a friendly name"} + ) + mock_client.device_info = AsyncMock(return_value=device.device_info) + await device.mock_connect() + await hass.async_block_till_done() + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.entry.unique_id)} + ) + assert dev.name == "I have a friendly name" + assert ( + "No `friendly_name` set in the `esphome:` section of the YAML config for device" + ) not in caplog.text + + +async def test_assist_in_progress_issue_deleted( + hass: HomeAssistant, + mock_client: APIClient, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test assist in progress entity and issue is deleted. + + Remove this cleanup after 2026.4 + """ + entry = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="binary_sensor", + unique_id="11:22:33:44:55:AA-assist_in_progress", + ) + ir.async_create_issue( + hass, + DOMAIN, + f"assist_in_progress_deprecated_{entry.id}", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="assist_in_progress_deprecated", + translation_placeholders={ + "integration_name": "ESPHome", + }, + ) + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={}, + states=[], + mock_storage=True, + ) + assert ( + entity_registry.async_get_entity_id( + DOMAIN, "binary_sensor", "11:22:33:44:55:AA-assist_in_progress" + ) + is None + ) + assert ( + issue_registry.async_get_issue( + DOMAIN, f"assist_in_progress_deprecated_{entry.id}" + ) + is None + ) diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index a425b730771..18a997dc09a 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -1,12 +1,9 @@ """Test ESPHome media_players.""" -from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, Mock, call, patch from aioesphomeapi import ( APIClient, - EntityInfo, - EntityState, MediaPlayerCommand, MediaPlayerEntityState, MediaPlayerFormatPurpose, @@ -41,14 +38,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType from tests.common import mock_platform from tests.typing import WebSocketGenerator async def test_media_player_entity( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic media_player entity.""" entity_info = [ @@ -160,7 +159,7 @@ async def test_media_player_entity_with_source( hass: HomeAssistant, mock_client: APIClient, hass_ws_client: WebSocketGenerator, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic media_player entity media source.""" await async_setup_component(hass, "media_source", {"media_source": {}}) @@ -293,13 +292,10 @@ async def test_media_player_proxy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a media_player entity with a proxy URL.""" - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[ MediaPlayerInfo( diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py index 557425052f3..9a711f2766e 100644 --- a/tests/components/esphome/test_number.py +++ b/tests/components/esphome/test_number.py @@ -21,11 +21,13 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from .conftest import MockGenericDeviceEntryType + async def test_generic_number_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic number entity.""" entity_info = [ @@ -65,7 +67,7 @@ async def test_generic_number_entity( async def test_generic_number_nan( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic number entity with nan state.""" entity_info = [ @@ -97,7 +99,7 @@ async def test_generic_number_nan( async def test_generic_number_with_unit_of_measurement_as_empty_string( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic number entity with nan state.""" entity_info = [ @@ -130,7 +132,7 @@ async def test_generic_number_with_unit_of_measurement_as_empty_string( async def test_generic_number_entity_set_when_disconnected( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic number entity.""" entity_info = [ diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index c365e65cbe1..268b30f8b52 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -1,13 +1,246 @@ """Test ESPHome repairs.""" +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock + +from aioesphomeapi import APIClient, BinarySensorInfo, BinarySensorState, DeviceInfo import pytest from homeassistant.components.esphome import repairs +from homeassistant.components.esphome.const import DOMAIN +from homeassistant.components.esphome.manager import DEVICE_CONFLICT_ISSUE_FORMAT +from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) + +from .conftest import MockESPHomeDeviceType + +from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + get_repairs, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator, WebSocketGenerator async def test_create_fix_flow_raises_on_unknown_issue_id(hass: HomeAssistant) -> None: - """Test reate_fix_flow raises on unknown issue_id.""" + """Test create_fix_flow raises on unknown issue_id.""" with pytest.raises(ValueError): await repairs.async_create_fix_flow(hass, "no_such_issue", None) + + +async def test_device_conflict_manual( + hass: HomeAssistant, + mock_client: APIClient, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test guided manual conflict resolution.""" + disconnect_done = hass.loop.create_future() + + async def async_disconnect(*args, **kwargs) -> None: + disconnect_done.set_result(None) + + mock_client.disconnect = async_disconnect + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="1122334455ab", name="test", model="esp32-iso-poe" + ) + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await disconnect_done + + assert "Unexpected device found" in caplog.text + issue_id = DEVICE_CONFLICT_ISSUE_FORMAT.format(mock_config_entry.entry_id) + + issues = await get_repairs(hass, hass_ws_client) + assert issues + assert len(issues) == 1 + assert any(True for issue in issues if issue["issue_id"] == issue_id) + + await async_process_repairs_platforms(hass) + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "192.168.1.2", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.MENU + assert data["step_id"] == "init" + + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "manual"} + ) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "192.168.1.2", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.FORM + assert data["step_id"] == "manual" + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="11:22:33:44:55:aa", name="test", model="esp32-iso-poe" + ) + ) + caplog.clear() + data = await process_repair_fix_flow(client, flow_id) + + assert data["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + assert "Unexpected device found" not in caplog.text + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + +async def test_device_conflict_migration( + hass: HomeAssistant, + mock_client: APIClient, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test migrating existing configuration to new hardware.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + is_status_binary_sensor=True, + ) + ] + states = [BinarySensorState(key=1, state=None)] + user_service = [] + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + mock_config_entry = device.entry + + ent_reg_entry = entity_registry.async_get("binary_sensor.test_mybinary_sensor") + assert ent_reg_entry + assert ent_reg_entry.unique_id == "11:22:33:44:55:AA-binary_sensor-mybinary_sensor" + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert entries is not None + for entry in entries: + assert entry.unique_id.startswith("11:22:33:44:55:AA-") + disconnect_done = hass.loop.create_future() + + async def async_disconnect(*args, **kwargs) -> None: + if not disconnect_done.done(): + disconnect_done.set_result(None) + + mock_client.disconnect = async_disconnect + new_device_info = DeviceInfo( + mac_address="11:22:33:44:55:AB", name="test", model="esp32-iso-poe" + ) + mock_client.device_info = AsyncMock(return_value=new_device_info) + device.device_info = new_device_info + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await disconnect_done + + assert "Unexpected device found" in caplog.text + issue_id = DEVICE_CONFLICT_ISSUE_FORMAT.format(mock_config_entry.entry_id) + + issues = await get_repairs(hass, hass_ws_client) + assert issues + assert len(issues) == 1 + assert any(True for issue in issues if issue["issue_id"] == issue_id) + + await async_process_repairs_platforms(hass) + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "test.local", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.MENU + assert data["step_id"] == "init" + + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "migrate"} + ) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "test.local", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.FORM + assert data["step_id"] == "migrate" + + caplog.clear() + data = await process_repair_fix_flow(client, flow_id) + + assert data["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + assert "Unexpected device found" not in caplog.text + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + assert mock_config_entry.unique_id == "11:22:33:44:55:ab" + ent_reg_entry = entity_registry.async_get("binary_sensor.test_mybinary_sensor") + assert ent_reg_entry + assert ent_reg_entry.unique_id == "11:22:33:44:55:AB-binary_sensor-mybinary_sensor" + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert entries is not None + for entry in entries: + assert entry.unique_id.startswith("11:22:33:44:55:AB-") + + dev_entry = device_registry.async_get_device( + identifiers={}, connections={(dr.CONNECTION_NETWORK_MAC, "11:22:33:44:55:ab")} + ) + assert dev_entry is not None + + old_dev_entry = device_registry.async_get_device( + identifiers={}, connections={(dr.CONNECTION_NETWORK_MAC, "11:22:33:44:55:aa")} + ) + assert old_dev_entry is None diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index 6ae1260a89d..09a8f739e71 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -2,8 +2,13 @@ from unittest.mock import call -from aioesphomeapi import APIClient, SelectInfo, SelectState +from aioesphomeapi import APIClient, SelectInfo, SelectState, VoiceAssistantFeature +import pytest +from homeassistant.components.assist_satellite import ( + AssistSatelliteConfiguration, + AssistSatelliteWakeWord, +) from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -12,10 +17,13 @@ from homeassistant.components.select import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from .common import get_satellite_entity +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType + +@pytest.mark.usefixtures("mock_voice_assistant_v1_entry") async def test_pipeline_selector( hass: HomeAssistant, - mock_voice_assistant_v1_entry, ) -> None: """Test assist pipeline selector.""" @@ -24,9 +32,9 @@ async def test_pipeline_selector( assert state.state == "preferred" +@pytest.mark.usefixtures("mock_voice_assistant_v1_entry") async def test_vad_sensitivity_select( hass: HomeAssistant, - mock_voice_assistant_v1_entry, ) -> None: """Test VAD sensitivity select. @@ -38,9 +46,9 @@ async def test_vad_sensitivity_select( assert state.state == "default" +@pytest.mark.usefixtures("mock_voice_assistant_v1_entry") 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") @@ -49,7 +57,9 @@ async def test_wake_word_select( async def test_select_generic_entity( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic select entity.""" entity_info = [ @@ -80,3 +90,113 @@ async def test_select_generic_entity( blocking=True, ) mock_client.select_command.assert_has_calls([call(1, "b")]) + + +async def test_wake_word_select_no_wake_words( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> 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 = 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: MockESPHomeDeviceType, +) -> 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 = 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: MockESPHomeDeviceType, +) -> 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 = 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_sensor.py b/tests/components/esphome/test_sensor.py index 76f71b53167..6763d2ab9a9 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -1,21 +1,17 @@ """Test ESPHome sensors.""" -from collections.abc import Awaitable, Callable import logging import math from aioesphomeapi import ( APIClient, EntityCategory as ESPHomeEntityCategory, - EntityInfo, - EntityState, LastResetType, SensorInfo, SensorState, SensorStateClass as ESPHomeSensorStateClass, TextSensorInfo, TextSensorState, - UserService, ) from homeassistant.components.sensor import ( @@ -33,16 +29,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType async def test_generic_numeric_sensor( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic sensor entity.""" logging.getLogger("homeassistant.components.esphome").setLevel(logging.DEBUG) @@ -99,7 +92,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic sensor entity.""" entity_info = [ @@ -136,7 +129,7 @@ async def test_generic_numeric_sensor_state_class_measurement( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic sensor entity.""" entity_info = [ @@ -173,7 +166,7 @@ async def test_generic_numeric_sensor_state_class_measurement( async def test_generic_numeric_sensor_device_class_timestamp( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a sensor entity that uses timestamp (epoch).""" entity_info = [ @@ -201,7 +194,7 @@ async def test_generic_numeric_sensor_device_class_timestamp( async def test_generic_numeric_sensor_legacy_last_reset_convert( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a state class of measurement with last reset type of auto is converted to total increasing.""" entity_info = [ @@ -210,7 +203,7 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( key=1, name="my sensor", unique_id="my_sensor", - last_reset_type=LastResetType.AUTO, + legacy_last_reset_type=LastResetType.AUTO, state_class=ESPHomeSensorStateClass.MEASUREMENT, ) ] @@ -229,7 +222,9 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( async def test_generic_numeric_sensor_no_state( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic numeric sensor that has no state.""" entity_info = [ @@ -254,7 +249,9 @@ async def test_generic_numeric_sensor_no_state( async def test_generic_numeric_sensor_nan_state( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic numeric sensor that has nan state.""" entity_info = [ @@ -279,7 +276,9 @@ async def test_generic_numeric_sensor_nan_state( async def test_generic_numeric_sensor_missing_state( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic numeric sensor that is missing state.""" entity_info = [ @@ -306,7 +305,7 @@ async def test_generic_numeric_sensor_missing_state( async def test_generic_text_sensor( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic text sensor entity.""" entity_info = [ @@ -331,7 +330,9 @@ async def test_generic_text_sensor( async def test_generic_text_sensor_missing_state( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic text sensor that is missing state.""" entity_info = [ @@ -358,7 +359,7 @@ async def test_generic_text_sensor_missing_state( async def test_generic_text_sensor_device_class_timestamp( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a sensor entity that uses timestamp (datetime).""" entity_info = [ @@ -387,7 +388,7 @@ async def test_generic_text_sensor_device_class_timestamp( async def test_generic_text_sensor_device_class_date( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a sensor entity that uses date (datetime).""" entity_info = [ @@ -414,7 +415,9 @@ async def test_generic_text_sensor_device_class_date( async def test_generic_numeric_sensor_empty_string_uom( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic numeric sensor that has an empty string as the uom.""" entity_info = [ diff --git a/tests/components/esphome/test_switch.py b/tests/components/esphome/test_switch.py index 561ac0b369f..b3c13ee2fe5 100644 --- a/tests/components/esphome/test_switch.py +++ b/tests/components/esphome/test_switch.py @@ -12,9 +12,13 @@ from homeassistant.components.switch import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_switch_generic_entity( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic switch entity.""" entity_info = [ diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py index 07157d98ac6..899b4a732ca 100644 --- a/tests/components/esphome/test_text.py +++ b/tests/components/esphome/test_text.py @@ -12,11 +12,13 @@ from homeassistant.components.text import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_generic_text_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic text entity.""" entity_info = [ @@ -56,7 +58,7 @@ async def test_generic_text_entity( async def test_generic_text_entity_no_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic text entity that has no state.""" entity_info = [ @@ -87,7 +89,7 @@ async def test_generic_text_entity_no_state( async def test_generic_text_entity_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic text entity that has no state.""" entity_info = [ diff --git a/tests/components/esphome/test_time.py b/tests/components/esphome/test_time.py index aaa18c77a47..543a903f0a9 100644 --- a/tests/components/esphome/test_time.py +++ b/tests/components/esphome/test_time.py @@ -12,11 +12,13 @@ from homeassistant.components.time import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockGenericDeviceEntryType + async def test_generic_time_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic time entity.""" entity_info = [ @@ -52,7 +54,7 @@ async def test_generic_time_entity( async def test_generic_time_missing_state( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic time entity with missing state.""" entity_info = [ diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 5060471f5d2..a612f44c07f 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -1,18 +1,10 @@ """Test ESPHome update entities.""" -from collections.abc import Awaitable, Callable +import asyncio from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import patch -from aioesphomeapi import ( - APIClient, - EntityInfo, - EntityState, - UpdateCommand, - UpdateInfo, - UpdateState, - UserService, -) +from aioesphomeapi import APIClient, UpdateCommand, UpdateInfo, UpdateState import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard @@ -21,6 +13,8 @@ from homeassistant.components.homeassistant import ( SERVICE_UPDATE_ENTITY, ) from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + ATTR_UPDATE_PERCENTAGE, DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, UpdateEntityFeature, @@ -35,7 +29,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType + +from tests.typing import WebSocketGenerator + +RELEASE_SUMMARY = "This is a release summary" +RELEASE_URL = "https://esphome.io/changelog" +ENTITY_ID = "update.test_myupdate" @pytest.fixture(autouse=True) @@ -86,26 +86,25 @@ def stub_reconnect(): ) async def test_update_entity( hass: HomeAssistant, - stub_reconnect, - mock_config_entry, - mock_device_info, mock_dashboard: dict[str, Any], - devices_payload, - expected_state, - expected_attributes, + devices_payload: list[dict[str, Any]], + expected_state: str, + expected_attributes: dict[str, Any], + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome update entity.""" mock_dashboard["configured"] = devices_payload await async_get_dashboard(hass).async_refresh() - with patch( - "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info, info={}), - ): - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state is not None assert state.state == expected_state for key, expected_value in expected_attributes.items(): @@ -117,10 +116,12 @@ async def test_update_entity( # Compile failed, don't try to upload with ( patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=False + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=False, ) as mock_compile, patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, ) as mock_upload, pytest.raises( HomeAssistantError, @@ -128,9 +129,9 @@ async def test_update_entity( ), ): await hass.services.async_call( - "update", - "install", - {"entity_id": "update.none_firmware"}, + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, blocking=True, ) @@ -142,10 +143,12 @@ async def test_update_entity( # Compile success, upload fails with ( patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, ) as mock_compile, patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=False + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=False, ) as mock_upload, pytest.raises( HomeAssistantError, @@ -153,9 +156,9 @@ async def test_update_entity( ), ): await hass.services.async_call( - "update", - "install", - {"entity_id": "update.none_firmware"}, + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, blocking=True, ) @@ -168,16 +171,18 @@ async def test_update_entity( # Everything works with ( patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, ) as mock_compile, patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, ) as mock_upload, ): await hass.services.async_call( - "update", - "install", - {"entity_id": "update.none_firmware"}, + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, blocking=True, ) @@ -191,10 +196,7 @@ async def test_update_entity( async def test_update_static_info( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity.""" @@ -206,7 +208,7 @@ async def test_update_static_info( ] await async_get_dashboard(hass).async_refresh() - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=[], user_service=[], @@ -243,10 +245,7 @@ async def test_update_device_state_for_availability( has_deep_sleep: bool, mock_dashboard: dict[str, Any], mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome update entity changes availability with the device.""" mock_dashboard["configured"] = [ @@ -274,28 +273,27 @@ async def test_update_device_state_for_availability( async def test_update_entity_dashboard_not_available_startup( hass: HomeAssistant, - stub_reconnect, - mock_config_entry, - mock_device_info, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity when dashboard is not available at startup.""" with ( patch( - "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info, info={}), - ), - patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices", side_effect=TimeoutError, ), ): await async_get_dashboard(hass).async_refresh() - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) # We have a dashboard but it is not available - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state is None mock_dashboard["configured"] = [ @@ -308,7 +306,7 @@ async def test_update_entity_dashboard_not_available_startup( await async_get_dashboard(hass).async_refresh() await hass.async_block_till_done() - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state.state == STATE_ON expected_attributes = { "latest_version": "2023.2.0-dev", @@ -322,15 +320,12 @@ async def test_update_entity_dashboard_not_available_startup( async def test_update_entity_dashboard_discovered_after_startup_but_update_failed( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity when dashboard is discovered after startup and the first update fails.""" with patch( - "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_devices", side_effect=TimeoutError, ): await async_get_dashboard(hass).async_refresh() @@ -370,27 +365,26 @@ async def test_update_entity_dashboard_discovered_after_startup_but_update_faile async def test_update_entity_not_present_without_dashboard( - hass: HomeAssistant, stub_reconnect, mock_config_entry, mock_device_info + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test ESPHome update entity does not get created if there is no dashboard.""" - with patch( - "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info, info={}), - ): - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) - state = hass.states.get("update.none_firmware") + state = hass.states.get("update.test_firmware") assert state is None async def test_update_becomes_available_at_runtime( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity when the dashboard has no device at startup but gets them later.""" @@ -426,10 +420,7 @@ async def test_update_becomes_available_at_runtime( async def test_update_entity_not_present_with_dashboard_but_unknown_device( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, mock_dashboard: dict[str, Any], ) -> None: """Test ESPHome update entity does not get created if the device is unknown to the dashboard.""" @@ -461,7 +452,7 @@ async def test_update_entity_not_present_with_dashboard_but_unknown_device( async def test_generic_device_update_entity( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test a generic device update entity.""" entity_info = [ @@ -478,8 +469,8 @@ async def test_generic_device_update_entity( current_version="2024.6.0", latest_version="2024.6.0", title="ESPHome Project", - release_summary="This is a release summary", - release_url="https://esphome.io/changelog", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, ) ] user_service = [] @@ -489,7 +480,7 @@ async def test_generic_device_update_entity( user_service=user_service, states=states, ) - state = hass.states.get("update.test_myupdate") + state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_OFF @@ -497,10 +488,7 @@ async def test_generic_device_update_entity( async def test_generic_device_update_entity_has_update( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic device update entity with an update.""" entity_info = [ @@ -517,25 +505,25 @@ async def test_generic_device_update_entity_has_update( current_version="2024.6.0", latest_version="2024.6.1", title="ESPHome Project", - release_summary="This is a release summary", - release_url="https://esphome.io/changelog", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, ) ] user_service = [] - mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=entity_info, user_service=user_service, states=states, ) - state = hass.states.get("update.test_myupdate") + state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_ON await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_myupdate"}, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) @@ -548,22 +536,417 @@ async def test_generic_device_update_entity_has_update( current_version="2024.6.0", latest_version="2024.6.1", title="ESPHome Project", - release_summary="This is a release summary", - release_url="https://esphome.io/changelog", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, ) ) - state = hass.states.get("update.test_myupdate") + state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_ON - assert state.attributes["in_progress"] is True - assert state.attributes["update_percentage"] == 50 - + assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 50 await hass.services.async_call( HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: "update.test_myupdate"}, + {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) + mock_device.set_state( + UpdateState( + key=1, + in_progress=True, + has_progress=False, + current_version="2024.6.0", + latest_version="2024.6.1", + title="ESPHome Project", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, + ) + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None + mock_client.update_command.assert_called_with(key=1, command=UpdateCommand.CHECK) + + +async def test_update_entity_release_notes( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test ESPHome update entity release notes.""" + entity_info = [ + UpdateInfo( + object_id="myupdate", + key=1, + name="my update", + unique_id="my_update", + ) + ] + + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=[], + ) + + # release notes + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": ENTITY_ID, + } + ) + + result = await client.receive_json() + assert result["result"] is None + + mock_device.set_state( + UpdateState( + key=1, + current_version="2024.6.0", + latest_version="2024.6.1", + title="ESPHome Project", + release_summary="", + release_url=RELEASE_URL, + ) + ) + + await client.send_json( + { + "id": 2, + "type": "update/release_notes", + "entity_id": ENTITY_ID, + } + ) + + result = await client.receive_json() + assert result["result"] is None + + mock_device.set_state( + UpdateState( + key=1, + current_version="2024.6.0", + latest_version="2024.6.1", + title="ESPHome Project", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, + ) + ) + + await client.send_json( + { + "id": 3, + "type": "update/release_notes", + "entity_id": ENTITY_ID, + } + ) + + result = await client.receive_json() + assert result["result"] == RELEASE_SUMMARY + + +async def test_attempt_to_update_twice( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test attempting to update twice.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + + async def delayed_compile(*args: Any, **kwargs: Any) -> None: + """Delay the update.""" + await asyncio.sleep(0) + return True + + # Compile success, upload fails + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + delayed_compile, + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=False, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + + with pytest.raises(HomeAssistantError, match="update is already in progress"): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="OTA"): + await update_task + + +async def test_update_deep_sleep_already_online( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test attempting to update twice.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={"has_deep_sleep": True}, + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + + # Compile success, upload success + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + + +async def test_update_deep_sleep_offline( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test device comes online while updating.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={"has_deep_sleep": True}, + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + await device.mock_disconnect(True) + + # Compile success, upload success + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + await asyncio.sleep(0) + assert not update_task.done() + await device.mock_connect() + await hass.async_block_till_done() + + +async def test_update_deep_sleep_offline_sleep_during_ota( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test device goes to sleep right as we start the OTA.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={"has_deep_sleep": True}, + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + await device.mock_disconnect(True) + + upload_attempt = 0 + upload_attempt_2_future = hass.loop.create_future() + disconnect_future = hass.loop.create_future() + + async def upload_takes_a_while(*args: Any, **kwargs: Any) -> None: + """Delay the update.""" + nonlocal upload_attempt + upload_attempt += 1 + if upload_attempt == 1: + # We are simulating the device going back to sleep + # before the upload can be started + # Wait for the device to go unavailable + # before returning false + await disconnect_future + return False + upload_attempt_2_future.set_result(None) + return True + + # Compile success, upload fails first time, success second time + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + upload_takes_a_while, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + await asyncio.sleep(0) + assert not update_task.done() + await device.mock_connect() + # Mock device being at the end of its sleep cycle + # and going to sleep right as the upload starts + # This can happen because there is non zero time + # between when we tell the dashboard to upload and + # when the upload actually starts + await device.mock_disconnect(True) + disconnect_future.set_result(None) + assert not upload_attempt_2_future.done() + # Now the device wakes up and the upload is attempted + await device.mock_connect() + await upload_attempt_2_future + await hass.async_block_till_done() + + +async def test_update_deep_sleep_offline_cancelled_unload( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test deep sleep update attempt is cancelled on unload.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={"has_deep_sleep": True}, + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + await device.mock_disconnect(True) + + # Compile success, upload success, but we cancel the update + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + await asyncio.sleep(0) + assert not update_task.done() + await hass.config_entries.async_unload(device.entry.entry_id) + await hass.async_block_till_done() + assert update_task.cancelled() diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py index 7a7e22b1713..bc5c77a62d6 100644 --- a/tests/components/esphome/test_valve.py +++ b/tests/components/esphome/test_valve.py @@ -1,13 +1,9 @@ """Test ESPHome valves.""" -from collections.abc import Awaitable, Callable from unittest.mock import call from aioesphomeapi import ( APIClient, - EntityInfo, - EntityState, - UserService, ValveInfo, ValveOperation, ValveState as ESPHomeValveState, @@ -26,16 +22,13 @@ from homeassistant.components.valve import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from .conftest import MockESPHomeDevice +from .conftest import MockESPHomeDeviceType async def test_valve_entity( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic valve entity.""" entity_info = [ @@ -133,10 +126,7 @@ async def test_valve_entity( async def test_valve_entity_without_position( hass: HomeAssistant, mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], + mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test a generic valve entity without position or stop.""" entity_info = [ diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index 5f60bc418e3..313982e3f97 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -48,18 +48,18 @@ def location_status_fixture(install: str, loc_id: str | None = None) -> JsonObje return load_json_object_fixture(f"{install}/status_{loc_id}.json", DOMAIN) -def dhw_schedule_fixture(install: str) -> JsonObjectType: +def dhw_schedule_fixture(install: str, dhw_id: str | None = None) -> JsonObjectType: """Load JSON for the schedule of a domesticHotWater zone.""" try: - return load_json_object_fixture(f"{install}/schedule_dhw.json", DOMAIN) + return load_json_object_fixture(f"{install}/schedule_{dhw_id}.json", DOMAIN) except FileNotFoundError: return load_json_object_fixture("default/schedule_dhw.json", DOMAIN) -def zone_schedule_fixture(install: str) -> JsonObjectType: +def zone_schedule_fixture(install: str, zon_id: str | None = None) -> JsonObjectType: """Load JSON for the schedule of a temperatureZone zone.""" try: - return load_json_object_fixture(f"{install}/schedule_zone.json", DOMAIN) + return load_json_object_fixture(f"{install}/schedule_{zon_id}.json", DOMAIN) except FileNotFoundError: return load_json_object_fixture("default/schedule_zone.json", DOMAIN) @@ -120,9 +120,9 @@ def mock_make_request(install: str) -> Callable: elif "schedule" in url: if url.startswith("domesticHotWater"): # /v2/domesticHotWater/{id}/schedule - return dhw_schedule_fixture(install) + return dhw_schedule_fixture(install, url[16:23]) if url.startswith("temperatureZone"): # /v2/temperatureZone/{id}/schedule - return zone_schedule_fixture(install) + return zone_schedule_fixture(install, url[16:23]) pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}") diff --git a/tests/components/evohome/const.py b/tests/components/evohome/const.py index c3dc92c3fbc..dceb2f60a06 100644 --- a/tests/components/evohome/const.py +++ b/tests/components/evohome/const.py @@ -15,8 +15,9 @@ TEST_INSTALLS: Final = ( "default", # evohome: multi-zone, with DHW "h032585", # VisionProWifi: no preset modes for TCS, zoneId=systemId "h099625", # RoundThermostat + "h139906", # zone with null schedule "sys_004", # RoundModulation ) # "botched", # as default: but with activeFaults, ghost zones & unknown types -TEST_INSTALLS_WITH_DHW: Final = ("default",) +TEST_INSTALLS_WITH_DHW: Final = ("default", "botched") diff --git a/tests/components/evohome/fixtures/botched/schedule_3933910.json b/tests/components/evohome/fixtures/botched/schedule_3933910.json new file mode 100644 index 00000000000..0e5a9308d5b --- /dev/null +++ b/tests/components/evohome/fixtures/botched/schedule_3933910.json @@ -0,0 +1,3 @@ +{ + "dailySchedules": [] +} diff --git a/tests/components/evohome/fixtures/botched/user_locations.json b/tests/components/evohome/fixtures/botched/user_locations.json index f2f4091a2dc..0016c5db007 100644 --- a/tests/components/evohome/fixtures/botched/user_locations.json +++ b/tests/components/evohome/fixtures/botched/user_locations.json @@ -8,14 +8,14 @@ "country": "UnitedKingdom", "postcode": "E1 1AA", "locationType": "Residential", - "useDaylightSaveSwitching": true, "timeZone": { - "timeZoneId": "GMTStandardTime", - "displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London", - "offsetMinutes": 0, - "currentOffsetMinutes": 60, + "timeZoneId": "PacificSAStandardTime", + "displayName": "(UTC-04:00) Santiago", + "offsetMinutes": -240, + "currentOffsetMinutes": -180, "supportsDaylightSaving": true }, + "useDaylightSaveSwitching": true, "locationOwner": { "userId": "2263181", "username": "user_2263181@gmail.com", diff --git a/tests/components/evohome/fixtures/h139906/schedule_3454854.json b/tests/components/evohome/fixtures/h139906/schedule_3454854.json new file mode 100644 index 00000000000..0e5a9308d5b --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/schedule_3454854.json @@ -0,0 +1,3 @@ +{ + "dailySchedules": [] +} diff --git a/tests/components/evohome/fixtures/h139906/schedule_3454855.json b/tests/components/evohome/fixtures/h139906/schedule_3454855.json new file mode 100644 index 00000000000..12f8a6cb390 --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/schedule_3454855.json @@ -0,0 +1,143 @@ +{ + "dailySchedules": [ + { + "dayOfWeek": "Monday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Tuesday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Wednesday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "12:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Thursday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Friday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Saturday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "07:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Sunday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "07:30:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + } + ] +} diff --git a/tests/components/evohome/fixtures/h139906/status_2727366.json b/tests/components/evohome/fixtures/h139906/status_2727366.json new file mode 100644 index 00000000000..2c123b796bd --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/status_2727366.json @@ -0,0 +1,52 @@ +{ + "locationId": "2727366", + "gateways": [ + { + "gatewayId": "2513794", + "temperatureControlSystems": [ + { + "systemId": "3454856", + "zones": [ + { + "zoneId": "3454854", + "temperatureStatus": { + "temperature": 22.0, + "isAvailable": true + }, + "activeFaults": [ + { + "faultType": "TempZoneSensorCommunicationLost", + "since": "2025-02-06T11:20:29" + } + ], + "setpointStatus": { + "targetHeatTemperature": 5.0, + "setpointMode": "FollowSchedule" + }, + "name": "Thermostat" + }, + { + "zoneId": "3454855", + "temperatureStatus": { + "temperature": 22.0, + "isAvailable": true + }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 20.0, + "setpointMode": "FollowSchedule" + }, + "name": "Thermostat 2" + } + ], + "activeFaults": [], + "systemModeStatus": { + "mode": "Auto", + "isPermanent": true + } + } + ], + "activeFaults": [] + } + ] +} diff --git a/tests/components/evohome/fixtures/h139906/user_locations.json b/tests/components/evohome/fixtures/h139906/user_locations.json new file mode 100644 index 00000000000..14db65a5e0d --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/user_locations.json @@ -0,0 +1,125 @@ +[ + { + "locationInfo": { + "locationId": "2727366", + "name": "Vr**********", + "streetAddress": "********** *", + "city": "*********", + "country": "Netherlands", + "postcode": "******", + "locationType": "Residential", + "useDaylightSaveSwitching": true, + "timeZone": { + "timeZoneId": "WEuropeStandardTime", + "displayName": "(UTC+01:00) Amsterdam, Berlijn, Bern, Rome, Stockholm, Wenen", + "offsetMinutes": 60, + "currentOffsetMinutes": 60, + "supportsDaylightSaving": true + }, + "locationOwner": { + "userId": "2276512", + "username": "nobody@nowhere.com", + "firstname": "Gl***", + "lastname": "de*****" + } + }, + "gateways": [ + { + "gatewayInfo": { + "gatewayId": "2513794", + "mac": "************", + "crc": "****", + "isWiFi": false + }, + "temperatureControlSystems": [ + { + "systemId": "3454856", + "modelType": "EvoTouch", + "zones": [ + { + "zoneId": "3454854", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Thermostat", + "zoneType": "ZoneTemperatureControl" + }, + { + "zoneId": "3454855", + "modelType": "RoundWireless", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 0, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Thermostat 2", + "zoneType": "Thermostat" + } + ], + "allowedSystemModes": [ + { + "systemMode": "Auto", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "AutoWithEco", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "1.00:00:00", + "timingResolution": "01:00:00", + "timingMode": "Duration" + }, + { + "systemMode": "Away", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + }, + { + "systemMode": "HeatingOff", + "canBePermanent": true, + "canBeTemporary": false + } + ] + } + ] + } + ] + } +] diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index 23a15e3f64f..7fb0ae5aaec 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -29,6 +29,16 @@ ), ]) # --- +# name: test_ctl_set_hvac_mode[h139906] + list([ + tuple( + , + ), + tuple( + , + ), + ]) +# --- # name: test_ctl_set_hvac_mode[minimal] list([ tuple( @@ -70,6 +80,13 @@ ), ]) # --- +# name: test_ctl_turn_off[h139906] + list([ + tuple( + , + ), + ]) +# --- # name: test_ctl_turn_off[minimal] list([ tuple( @@ -105,6 +122,13 @@ ), ]) # --- +# name: test_ctl_turn_on[h139906] + list([ + tuple( + , + ), + ]) +# --- # name: test_ctl_turn_on[minimal] list([ tuple( @@ -144,10 +168,10 @@ 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -191,10 +215,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': False, @@ -233,19 +257,19 @@ 'activeFaults': tuple( dict({ 'fault_type': 'TempZoneActuatorLowBattery', - 'since': '2022-03-02T04:50:20+00:00', + 'since': '2022-03-02T04:50:20-03:00', }), ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T19:00:00+00:00', + 'until': '2022-03-07T16:00:00-03:00', }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -289,10 +313,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -336,10 +360,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -383,10 +407,10 @@ 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -426,7 +450,7 @@ 'activeFaults': tuple( dict({ 'fault_type': 'TempZoneActuatorCommunicationLost', - 'since': '2022-03-02T15:56:01+00:00', + 'since': '2022-03-02T15:56:01-03:00', }), ), 'setpoint_status': dict({ @@ -434,10 +458,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -1118,6 +1142,136 @@ 'state': 'heat', }) # --- +# name: test_setup_platform[h139906][climate.thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Thermostat', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'activeFaults': tuple( + dict({ + 'fault_type': 'TempZoneSensorCommunicationLost', + 'since': '2025-02-06T11:20:29+01:00', + }), + ), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 5.0, + }), + 'setpoints': dict({ + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 22.0, + }), + 'zone_id': '3454854', + }), + 'supported_features': , + 'temperature': 5.0, + }), + 'context': , + 'entity_id': 'climate.thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_platform[h139906][climate.thermostat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Thermostat 2', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'activeFaults': tuple( + ), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 20.0, + }), + 'setpoints': dict({ + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), + 'next_sp_temp': 15.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), + 'this_sp_temp': 22.5, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 22.0, + }), + 'zone_id': '3454855', + }), + 'supported_features': , + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[h139906][climate.vr-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Vr**********', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:thermostat', + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'away', + ]), + 'status': dict({ + 'activeSystemFaults': tuple( + ), + 'system_id': '3454856', + 'system_mode_status': dict({ + 'is_permanent': True, + 'mode': 'Auto', + }), + }), + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.vr', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_setup_platform[minimal][climate.main_room-state] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1312,6 +1466,13 @@ ), ]) # --- +# name: test_zone_set_hvac_mode[h139906] + list([ + tuple( + 5.0, + ), + ]) +# --- # name: test_zone_set_hvac_mode[minimal] list([ tuple( @@ -1365,6 +1526,19 @@ }), ]) # --- +# name: test_zone_set_preset_mode[h139906] + list([ + tuple( + 5.0, + ), + tuple( + 5.0, + ), + dict({ + 'until': None, + }), + ]) +# --- # name: test_zone_set_preset_mode[minimal] list([ tuple( @@ -1412,6 +1586,13 @@ }), ]) # --- +# name: test_zone_set_temperature[h139906] + list([ + dict({ + 'until': None, + }), + ]) +# --- # name: test_zone_set_temperature[minimal] list([ dict({ @@ -1447,6 +1628,13 @@ ), ]) # --- +# name: test_zone_turn_off[h139906] + list([ + tuple( + 5.0, + ), + ]) +# --- # name: test_zone_turn_off[minimal] list([ tuple( diff --git a/tests/components/evohome/snapshots/test_init.ambr b/tests/components/evohome/snapshots/test_init.ambr index d2e91e3c43d..d6174a53356 100644 --- a/tests/components/evohome/snapshots/test_init.ambr +++ b/tests/components/evohome/snapshots/test_init.ambr @@ -11,6 +11,9 @@ # name: test_setup[h099625] dict_keys(['refresh_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) # --- +# name: test_setup[h139906] + dict_keys(['refresh_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) +# --- # name: test_setup[minimal] dict_keys(['refresh_system', 'reset_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) # --- diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 771e2c20cba..13fb375c097 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -1,4 +1,14 @@ # serializer version: 1 +# name: test_set_operation_mode[botched] + list([ + dict({ + 'until': HAFakeDatetime(2024, 7, 10, 12, 30, tzinfo=datetime.timezone.utc), + }), + dict({ + 'until': HAFakeDatetime(2024, 7, 10, 12, 30, tzinfo=datetime.timezone.utc), + }), + ]) +# --- # name: test_set_operation_mode[default] list([ dict({ @@ -29,9 +39,9 @@ ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 30, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), 'next_sp_state': 'Off', - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 6, 30, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), 'this_sp_state': 'On', }), 'state_status': dict({ diff --git a/tests/components/evohome/test_climate.py b/tests/components/evohome/test_climate.py index b1b930c6382..171b910690b 100644 --- a/tests/components/evohome/test_climate.py +++ b/tests/components/evohome/test_climate.py @@ -9,7 +9,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_HVAC_MODE, diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index a201ff63d1e..c06f57b61ed 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -10,7 +10,7 @@ from unittest.mock import patch from evohomeasync2 import EvohomeClient from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.water_heater import ( ATTR_AWAY_MODE, @@ -33,7 +33,7 @@ from .const import TEST_INSTALLS_WITH_DHW DHW_ENTITY_ID = "water_heater.domestic_hot_water" -@pytest.mark.parametrize("install", [*TEST_INSTALLS_WITH_DHW, "botched"]) +@pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) async def test_setup_platform( hass: HomeAssistant, config: dict[str, str], diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index ff538b31edb..20d70902e83 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from pyezviz.exceptions import ( +from pyezvizapi.exceptions import ( EzvizAuthVerificationCode, InvalidHost, InvalidURL, diff --git a/tests/components/fastdotcom/test_diagnostics.py b/tests/components/fastdotcom/test_diagnostics.py index 7ea644665c7..36b29c8a9f1 100644 --- a/tests/components/fastdotcom/test_diagnostics.py +++ b/tests/components/fastdotcom/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_USER diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 583c44a41e6..fde92faa673 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from pyfibaro.fibaro_device import SceneEvent import pytest from homeassistant.components.fibaro import CONF_IMPORT_PLUGINS, DOMAIN @@ -69,6 +70,11 @@ def mock_power_sensor() -> Mock: } sensor.actions = {} sensor.has_central_scene_event = False + sensor.raw_data = { + "fibaro_id": 1, + "name": "Test sensor", + "properties": {"power": 6.6, "password": "mysecret"}, + } value_mock = Mock() value_mock.has_value = False value_mock.is_bool_value = False @@ -122,6 +128,7 @@ def mock_light() -> Mock: light.properties = {"manufacturer": ""} light.actions = {"setValue": 1, "on": 0, "off": 0} light.supported_features = {} + light.raw_data = {"fibaro_id": 3, "name": "Test light", "properties": {"value": 20}} value_mock = Mock() value_mock.has_value = True value_mock.int_value.return_value = 20 @@ -157,12 +164,31 @@ def mock_thermostat() -> Mock: return climate +@pytest.fixture +def mock_thermostat_parent() -> Mock: + """Fixture for a thermostat.""" + climate = Mock() + climate.fibaro_id = 5 + 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.device" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = [] + 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.fibaro_id = 6 + climate.endpoint_id = 1 + climate.parent_fibaro_id = 5 climate.name = "Test climate" climate.room_id = 1 climate.dead = False @@ -171,20 +197,69 @@ def mock_thermostat_with_operating_mode() -> Mock: climate.type = "com.fibaro.thermostatDanfoss" climate.base_type = "com.fibaro.device" climate.properties = {"manufacturer": ""} - climate.actions = {"setOperationMode": 1} + climate.actions = {"setOperatingMode": 1, "setTargetLevel": 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_supported_thermostat_modes = False climate.has_thermostat_mode = False + climate.has_unit = True + climate.unit = "C" + climate.has_heating_thermostat_setpoint = False + climate.has_heating_thermostat_setpoint_future = False + climate.target_level = 23 value_mock = Mock() value_mock.has_value = True - value_mock.int_value.return_value = 20 + value_mock.float_value.return_value = 20 climate.value = value_mock return climate +@pytest.fixture +def mock_fan_device() -> Mock: + """Fixture for a fan endpoint of a thermostat device.""" + climate = Mock() + climate.fibaro_id = 7 + climate.endpoint_id = 1 + climate.parent_fibaro_id = 5 + climate.name = "Test fan" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.fan" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = {"setFanMode": 1} + climate.supported_modes = [0, 1, 2] + climate.mode = 1 + return climate + + +@pytest.fixture +def mock_button_device() -> Mock: + """Fixture for a button device.""" + climate = Mock() + climate.fibaro_id = 8 + climate.parent_fibaro_id = 0 + climate.name = "Test button" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.remoteController" + climate.base_type = "com.fibaro.actor" + climate.properties = {"manufacturer": ""} + climate.central_scene_event = [SceneEvent(1, "Pressed")] + climate.actions = {} + climate.interfaces = ["zwaveCentralScene"] + climate.battery_level = 100 + climate.armed = False + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" @@ -209,19 +284,22 @@ def mock_fibaro_client() -> Generator[Mock]: info_mock.hc_name = TEST_NAME info_mock.current_version = TEST_VERSION info_mock.platform = TEST_MODEL + info_mock.manufacturer_name = "Fibaro" + info_mock.model_name = "Home Center 2" + info_mock.mac_address = "00:22:4d:b7:13:24" with patch( "homeassistant.components.fibaro.FibaroClient", autospec=True ) as fibaro_client_mock: client = fibaro_client_mock.return_value - client.set_authentication.return_value = None - client.connect.return_value = True + client.connect_with_credentials.return_value = info_mock client.read_info.return_value = info_mock client.read_rooms.return_value = [] client.read_scenes.return_value = [] client.read_devices.return_value = [] client.register_update_handler.return_value = None client.unregister_update_handler.return_value = None + client.frontend_url.return_value = TEST_URL.removesuffix("/api/") yield client diff --git a/tests/components/fibaro/snapshots/test_diagnostics.ambr b/tests/components/fibaro/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e9d5e48e08c --- /dev/null +++ b/tests/components/fibaro/snapshots/test_diagnostics.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'config': dict({ + 'import_plugins': True, + }), + 'fibaro_devices': list([ + dict({ + 'fibaro_id': 3, + 'name': 'Test light', + 'properties': dict({ + 'value': 20, + }), + }), + ]), + }) +# --- +# name: test_device_diagnostics + dict({ + 'config': dict({ + 'import_plugins': True, + }), + 'fibaro_devices': list([ + dict({ + 'fibaro_id': 3, + 'name': 'Test light', + 'properties': dict({ + 'value': 20, + }), + }), + ]), + }) +# --- +# name: test_device_diagnostics_for_hub + dict({ + 'config': dict({ + 'import_plugins': True, + }), + 'fibaro_devices': list([ + dict({ + 'fibaro_id': 3, + 'name': 'Test light', + 'properties': dict({ + 'value': 20, + }), + }), + dict({ + 'fibaro_id': 1, + 'name': 'Test sensor', + 'properties': dict({ + 'password': '**REDACTED**', + 'power': 6.6, + }), + }), + ]), + }) +# --- diff --git a/tests/components/fibaro/test_climate.py b/tests/components/fibaro/test_climate.py index 31022e19a08..339d9d23077 100644 --- a/tests/components/fibaro/test_climate.py +++ b/tests/components/fibaro/test_climate.py @@ -130,5 +130,153 @@ async def test_hvac_mode_with_operation_mode_support( # Act await init_integration(hass, mock_config_entry) # Assert - state = hass.states.get("climate.room_1_test_climate_4") + state = hass.states.get("climate.room_1_test_climate_6") assert state.state == HVACMode.AUTO + + +async def test_set_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 set_hvac_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) + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.room_1_test_climate_6", "hvac_mode": HVACMode.HEAT}, + blocking=True, + ) + + # Assert + mock_thermostat_with_operating_mode.execute_action.assert_called_once() + + +async def test_fan_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: 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_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + 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_6") + assert state.attributes["fan_mode"] == "low" + assert state.attributes["fan_modes"] == ["off", "low", "auto_high"] + + +async def test_set_fan_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that set_fan_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.room_1_test_climate_6", "fan_mode": "off"}, + blocking=True, + ) + + # Assert + mock_fan_device.execute_action.assert_called_once() + + +async def test_target_temperature( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: 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_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + 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_6") + assert state.attributes["temperature"] == 23 + + +async def test_set_target_temperature( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that set_fan_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": "climate.room_1_test_climate_6", "temperature": 25.5}, + blocking=True, + ) + + # Assert + mock_thermostat_with_operating_mode.execute_action.assert_called_once() diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py index 508bb81973d..aee7c2eb903 100644 --- a/tests/components/fibaro/test_config_flow.py +++ b/tests/components/fibaro/test_config_flow.py @@ -2,8 +2,8 @@ from unittest.mock import Mock +from pyfibaro.fibaro_client import FibaroAuthenticationFailed, FibaroConnectFailed import pytest -from requests.exceptions import HTTPError from homeassistant import config_entries from homeassistant.components.fibaro import DOMAIN @@ -23,8 +23,10 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_fibaro_client") async def _recovery_after_failure_works( hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult ) -> None: - mock_fibaro_client.connect.side_effect = None - mock_fibaro_client.connect.return_value = True + mock_fibaro_client.connect_with_credentials.side_effect = None + mock_fibaro_client.connect_with_credentials.return_value = ( + mock_fibaro_client.read_info() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -48,8 +50,10 @@ async def _recovery_after_failure_works( async def _recovery_after_reauth_failure_works( hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult ) -> None: - mock_fibaro_client.connect.side_effect = None - mock_fibaro_client.connect.return_value = True + mock_fibaro_client.connect_with_credentials.side_effect = None + mock_fibaro_client.connect_with_credentials.return_value = ( + mock_fibaro_client.read_info() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -101,7 +105,9 @@ async def test_config_flow_user_initiated_auth_failure( assert result["step_id"] == "user" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) + mock_fibaro_client.connect_with_credentials.side_effect = ( + FibaroAuthenticationFailed() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -119,7 +125,7 @@ async def test_config_flow_user_initiated_auth_failure( await _recovery_after_failure_works(hass, mock_fibaro_client, result) -async def test_config_flow_user_initiated_unknown_failure_1( +async def test_config_flow_user_initiated_connect_failure( hass: HomeAssistant, mock_fibaro_client: Mock ) -> None: """Unknown failure in flow manually initialized by the user.""" @@ -131,37 +137,7 @@ async def test_config_flow_user_initiated_unknown_failure_1( assert result["step_id"] == "user" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=500)) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - await _recovery_after_failure_works(hass, mock_fibaro_client, result) - - -async def test_config_flow_user_initiated_unknown_failure_2( - hass: HomeAssistant, mock_fibaro_client: Mock -) -> None: - """Unknown failure in flow manually initialized by the user.""" - 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"] == {} - - mock_fibaro_client.connect.side_effect = Exception() + mock_fibaro_client.connect_with_credentials.side_effect = FibaroConnectFailed() result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -208,7 +184,7 @@ async def test_reauth_connect_failure( assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = Exception() + mock_fibaro_client.connect_with_credentials.side_effect = FibaroConnectFailed() result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -233,7 +209,9 @@ async def test_reauth_auth_failure( assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) + mock_fibaro_client.connect_with_credentials.side_effect = ( + FibaroAuthenticationFailed() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/fibaro/test_diagnostics.py b/tests/components/fibaro/test_diagnostics.py new file mode 100644 index 00000000000..35b75a79ba9 --- /dev/null +++ b/tests/components/fibaro/test_diagnostics.py @@ -0,0 +1,96 @@ +"""Tests for the diagnostics data provided by the fibaro integration.""" + +from unittest.mock import Mock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fibaro import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import TEST_SERIALNUMBER, init_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + # Act + await init_integration(hass, mock_config_entry) + # Assert + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + # Act + await init_integration(hass, mock_config_entry) + entry = entity_registry.async_get("light.room_1_test_light_3") + device = device_registry.async_get(entry.device_id) + # Assert + assert device + assert ( + await get_diagnostics_for_device(hass, hass_client, mock_config_entry, device) + == snapshot + ) + + +async def test_device_diagnostics_for_hub( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_power_sensor: Mock, + mock_room: Mock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for the hub.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light, mock_power_sensor] + # Act + await init_integration(hass, mock_config_entry) + device = device_registry.async_get_device({(DOMAIN, TEST_SERIALNUMBER)}) + # Assert + assert device + assert ( + await get_diagnostics_for_device(hass, hass_client, mock_config_entry, device) + == snapshot + ) diff --git a/tests/components/fibaro/test_event.py b/tests/components/fibaro/test_event.py new file mode 100644 index 00000000000..ced39b71197 --- /dev/null +++ b/tests/components/fibaro/test_event.py @@ -0,0 +1,35 @@ +"""Test the Fibaro event platform.""" + +from unittest.mock import Mock, patch + +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_entity_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_button_device: Mock, + mock_room: Mock, +) -> None: + """Test that the button device creates an entity.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_button_device] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.EVENT]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + entry = entity_registry.async_get("event.room_1_test_button_8_button_1") + assert entry + assert entry.unique_id == "hc2_111111.8.1" + assert entry.original_name == "Room 1 Test button Button 1" diff --git a/tests/components/fibaro/test_init.py b/tests/components/fibaro/test_init.py new file mode 100644 index 00000000000..330de74d6af --- /dev/null +++ b/tests/components/fibaro/test_init.py @@ -0,0 +1,31 @@ +"""Test init methods.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_unload_integration( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test unload integration stops state listener.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + # Act + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + # Assert + assert mock_fibaro_client.unregister_update_handler.call_count == 1 diff --git a/tests/components/fibaro/test_light.py b/tests/components/fibaro/test_light.py index d0a24e009b7..88576e86dc6 100644 --- a/tests/components/fibaro/test_light.py +++ b/tests/components/fibaro/test_light.py @@ -2,7 +2,8 @@ from unittest.mock import Mock, patch -from homeassistant.const import Platform +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -55,3 +56,28 @@ async def test_light_brightness( state = hass.states.get("light.room_1_test_light_3") assert state.attributes["brightness"] == 51 assert state.state == "on" + + +async def test_light_turn_off( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test activate scene is called.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + # Act + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.room_1_test_light_3"}, + blocking=True, + ) + # Assert + assert mock_light.execute_action.call_count == 1 diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index be361541c39..e3c04a1a48f 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -27,7 +27,7 @@ from homeassistant.components.flexit_bacnet.const import PRESET_TO_VENTILATION_M from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_component, entity_registry as er from . import setup_with_selected_platforms @@ -156,14 +156,14 @@ async def test_hvac_action( # Simulate electric heater being ON mock_flexit_bacnet.electric_heater = True - await hass.helpers.entity_component.async_update_entity(ENTITY_ID) + await entity_component.async_update_entity(hass, ENTITY_ID) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING # Simulate electric heater being OFF mock_flexit_bacnet.electric_heater = False - await hass.helpers.entity_component.async_update_entity(ENTITY_ID) + await entity_component.async_update_entity(hass, ENTITY_ID) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.FAN diff --git a/tests/components/flipr/test_init.py b/tests/components/flipr/test_init.py index 6e9341b1e06..50a240958f8 100644 --- a/tests/components/flipr/test_init.py +++ b/tests/components/flipr/test_init.py @@ -2,9 +2,7 @@ from unittest.mock import AsyncMock -from homeassistant.components.flipr.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from . import setup_integration @@ -29,62 +27,3 @@ async def test_unload_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_duplicate_config_entries( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_flipr_client: AsyncMock, -) -> None: - """Test duplicate config entries.""" - - mock_config_entry_dup = MockConfigEntry( - version=2, - domain=DOMAIN, - unique_id="toto@toto.com", - data={ - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - "flipr_id": "myflipr_id_dup", - }, - ) - - mock_config_entry.add_to_hass(hass) - # Initialize the first entry with default mock - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Initialize the second entry with another flipr id - mock_config_entry_dup.add_to_hass(hass) - assert not await hass.config_entries.async_setup(mock_config_entry_dup.entry_id) - await hass.async_block_till_done() - assert mock_config_entry_dup.state is ConfigEntryState.SETUP_ERROR - - -async def test_migrate_entry( - hass: HomeAssistant, - mock_flipr_client: AsyncMock, -) -> None: - """Test migrate config entry from v1 to v2.""" - - mock_config_entry_v1 = MockConfigEntry( - version=1, - domain=DOMAIN, - title="myfliprid", - unique_id="test_entry_unique_id", - data={ - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - "flipr_id": "myfliprid", - }, - ) - - await setup_integration(hass, mock_config_entry_v1) - assert mock_config_entry_v1.state is ConfigEntryState.LOADED - assert mock_config_entry_v1.version == 2 - assert mock_config_entry_v1.unique_id == "toto@toto.com" - assert mock_config_entry_v1.data == { - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - "flipr_id": "myfliprid", - } diff --git a/tests/components/flo/test_init.py b/tests/components/flo/test_init.py index c1983b898da..8dfa712ecb1 100644 --- a/tests/components/flo/test_init.py +++ b/tests/components/flo/test_init.py @@ -1,7 +1,7 @@ """Test init.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant diff --git a/tests/components/flux_led/test_config_flow.py b/tests/components/flux_led/test_config_flow.py index f486d27244e..14ac4dd23ab 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -356,6 +356,60 @@ async def test_manual_working_discovery(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" +async def test_user_flow_can_replace_ignored(hass: HomeAssistant) -> None: + """Test a user flow can replace an ignored entry.""" + ignored_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + title=DEFAULT_ENTRY_TITLE, + source=config_entries.SOURCE_IGNORE, + ) + ignored_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 not result["errors"] + + # Cannot connect (timeout) + with _patch_discovery(no_device=True), _patch_wifibulb(no_device=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + # Success + with ( + _patch_discovery(), + _patch_wifibulb(), + patch(f"{MODULE}.async_setup", return_value=True), + patch(f"{MODULE}.async_setup_entry", return_value=True), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == DEFAULT_ENTRY_TITLE + assert result4["data"] == { + CONF_MINOR_VERSION: 4, + CONF_HOST: IP_ADDRESS, + CONF_MODEL: MODEL, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_INFO: MODEL, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_HOST: "the.cloud", + CONF_REMOTE_ACCESS_PORT: 8816, + } + + async def test_manual_no_discovery_data(hass: HomeAssistant) -> None: """Test manually setup without discovery data.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/forecast_solar/test_diagnostics.py b/tests/components/forecast_solar/test_diagnostics.py index 0e80fba7647..e29b4a468ab 100644 --- a/tests/components/forecast_solar/test_diagnostics.py +++ b/tests/components/forecast_solar/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Forecast.Solar integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py index 481ec3c0c9d..680a30580cb 100644 --- a/tests/components/forecast_solar/test_init.py +++ b/tests/components/forecast_solar/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch from forecast_solar import ForecastSolarConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.forecast_solar.const import ( CONF_AZIMUTH, diff --git a/tests/components/forked_daapd/test_browse_media.py b/tests/components/forked_daapd/test_browse_media.py index cbd278128ae..88b29c2bbba 100644 --- a/tests/components/forked_daapd/test_browse_media.py +++ b/tests/components/forked_daapd/test_browse_media.py @@ -34,7 +34,7 @@ async def test_async_browse_media( await hass.async_block_till_done() with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} @@ -214,7 +214,7 @@ async def test_async_browse_media_not_found( await hass.async_block_till_done() with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} @@ -375,7 +375,7 @@ async def test_async_browse_image( """Test browse media images.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} @@ -430,7 +430,7 @@ async def test_async_browse_image_missing( """Test browse media images with no image available.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index 8bf5de31da2..ba1f0e6c227 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -1,7 +1,7 @@ """The config flow tests for the forked_daapd media player platform.""" from ipaddress import ip_address -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -12,12 +12,10 @@ from homeassistant.components.forked_daapd.const import ( CONF_TTS_VOLUME, DOMAIN, ) -from homeassistant.components.forked_daapd.media_player import async_setup_entry -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigEntryState 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 @@ -75,7 +73,7 @@ async def test_config_flow(hass: HomeAssistant, config_entry: MockConfigEntry) - new=AsyncMock(), ) as mock_test_connection, patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI.get_request", + "homeassistant.components.forked_daapd.ForkedDaapdAPI.get_request", autospec=True, ) as mock_get_request, ): @@ -232,7 +230,7 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) """Test config flow options.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI.get_request", + "homeassistant.components.forked_daapd.ForkedDaapdAPI.get_request", autospec=True, ) as mock_get_request: mock_get_request.return_value = SAMPLE_CONFIG @@ -256,17 +254,18 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) async def test_async_setup_entry_not_ready( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture ) -> None: """Test that a PlatformNotReady exception is thrown during platform setup.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = None config_entry.add_to_hass(hass) - with pytest.raises(PlatformNotReady): - await async_setup_entry(hass, config_entry, MagicMock()) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() mock_api.return_value.get_request.assert_called_once() + assert "Platform forked_daapd not ready yet" in caplog.text + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py index 6d7d267eb63..8f0105d48d7 100644 --- a/tests/components/forked_daapd/test_media_player.py +++ b/tests/components/forked_daapd/test_media_player.py @@ -313,7 +313,7 @@ async def mock_api_object_fixture( return get_request_return_values[update_type] with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.side_effect = get_request_side_effect @@ -808,7 +808,7 @@ async def test_invalid_websocket_port( ) -> None: """Test invalid websocket port on async_init.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = SAMPLE_CONFIG_NO_WEBSOCKET diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index e6adae572f3..abf0153fede 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -16,7 +16,9 @@ from .const import ( DATA_HOME_PIR_GET_VALUE, DATA_HOME_SET_VALUE, DATA_LAN_GET_HOSTS_LIST, + DATA_LAN_GET_HOSTS_LIST_GUEST, DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE, + DATA_LAN_GET_INTERFACES, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, DATA_SYSTEM_GET_CONFIG, @@ -68,7 +70,12 @@ def mock_router(mock_device_registry_devices): instance.open = AsyncMock() instance.system.get_config = AsyncMock(return_value=DATA_SYSTEM_GET_CONFIG) # device_tracker - instance.lan.get_hosts_list = AsyncMock(return_value=DATA_LAN_GET_HOSTS_LIST) + instance.lan.get_interfaces = AsyncMock(return_value=DATA_LAN_GET_INTERFACES) + instance.lan.get_hosts_list = AsyncMock( + side_effect=lambda interface: DATA_LAN_GET_HOSTS_LIST + if interface == "pub" + else DATA_LAN_GET_HOSTS_LIST_GUEST + ) # sensor instance.call.get_calls_log = AsyncMock(return_value=DATA_CALL_GET_CALLS_LOG) instance.storage.get_disks = AsyncMock(return_value=DATA_STORAGE_GET_DISKS) @@ -96,6 +103,12 @@ def mock_router(mock_device_registry_devices): def mock_router_bridge_mode(mock_device_registry_devices, router): """Mock a successful connection to Freebox Bridge mode.""" + router().lan.get_interfaces = AsyncMock( + side_effect=HttpRequestError( + f"Request failed (APIResponse: {json.dumps(DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE)})" + ) + ) + router().lan.get_hosts_list = AsyncMock( side_effect=HttpRequestError( f"Request failed (APIResponse: {json.dumps(DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE)})" diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 5211b793918..47dfac636a7 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -25,7 +25,11 @@ DATA_WIFI_GET_GLOBAL_CONFIG = load_json_object_fixture( ) # device_tracker +DATA_LAN_GET_INTERFACES = load_json_array_fixture("freebox/lan_get_interfaces.json") DATA_LAN_GET_HOSTS_LIST = load_json_array_fixture("freebox/lan_get_hosts_list.json") +DATA_LAN_GET_HOSTS_LIST_GUEST = load_json_array_fixture( + "freebox/lan_get_hosts_list_guest.json" +) DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE = load_json_object_fixture( "freebox/lan_get_hosts_list_bridge.json" ) diff --git a/tests/components/freebox/fixtures/lan_get_hosts_list_guest.json b/tests/components/freebox/fixtures/lan_get_hosts_list_guest.json new file mode 100644 index 00000000000..9e2cdffef0a --- /dev/null +++ b/tests/components/freebox/fixtures/lan_get_hosts_list_guest.json @@ -0,0 +1,81 @@ +[ + { + "l2ident": { + "id": "8C:97:EA:00:00:01", + "type": "mac_address" + }, + "active": true, + "persistent": false, + "names": [ + { + "name": "d633d0c8-958c-42cc-e807-d881b476924b", + "source": "mdns" + }, + { + "name": "Freebox Player POP 2", + "source": "mdns_srv" + } + ], + "vendor_name": "Freebox SAS", + "host_type": "smartphone", + "interface": "pub", + "id": "ether-8c:97:ea:00:00:01", + "last_time_reachable": 1614107662, + "primary_name_manual": false, + "l3connectivities": [ + { + "addr": "192.168.27.181", + "active": true, + "reachable": true, + "last_activity": 1614107614, + "af": "ipv4", + "last_time_reachable": 1614104242 + }, + { + "addr": "fe80::dcef:dbba:6604:31d1", + "active": true, + "reachable": true, + "last_activity": 1614107645, + "af": "ipv6", + "last_time_reachable": 1614107645 + }, + { + "addr": "2a01:e34:eda1:eb40:8102:4704:7ce0:2ace", + "active": false, + "reachable": false, + "last_activity": 1611574428, + "af": "ipv6", + "last_time_reachable": 1611574428 + }, + { + "addr": "2a01:e34:eda1:eb40:c8e5:c524:c96d:5f5e", + "active": false, + "reachable": false, + "last_activity": 1612475101, + "af": "ipv6", + "last_time_reachable": 1612475101 + }, + { + "addr": "2a01:e34:eda1:eb40:583a:49df:1df0:c2df", + "active": true, + "reachable": true, + "last_activity": 1614107652, + "af": "ipv6", + "last_time_reachable": 1614107652 + }, + { + "addr": "2a01:e34:eda1:eb40:147e:3569:86ab:6aaa", + "active": false, + "reachable": false, + "last_activity": 1612486752, + "af": "ipv6", + "last_time_reachable": 1612486752 + } + ], + "default_name": "Freebox Player POP", + "model": "fbx8am", + "reachable": true, + "last_activity": 1614107652, + "primary_name": "Freebox Player POP" + } +] diff --git a/tests/components/freebox/fixtures/lan_get_interfaces.json b/tests/components/freebox/fixtures/lan_get_interfaces.json new file mode 100644 index 00000000000..2646ee38b50 --- /dev/null +++ b/tests/components/freebox/fixtures/lan_get_interfaces.json @@ -0,0 +1,4 @@ +[ + { "name": "pub", "host_count": 4 }, + { "name": "wifiguest", "host_count": 1 } +] diff --git a/tests/components/freebox/test_device_tracker.py b/tests/components/freebox/test_device_tracker.py index 405166d6ba2..f0821daabc3 100644 --- a/tests/components/freebox/test_device_tracker.py +++ b/tests/components/freebox/test_device_tracker.py @@ -21,14 +21,14 @@ async def test_router_mode( """Test get_hosts_list invoqued multiple times if freebox into router mode.""" await setup_platform(hass, DEVICE_TRACKER_DOMAIN) - assert router().lan.get_hosts_list.call_count == 1 + assert router().lan.get_hosts_list.call_count == 2 # Simulate an update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert router().lan.get_hosts_list.call_count == 2 + assert router().lan.get_hosts_list.call_count == 4 async def test_bridge_mode( @@ -36,15 +36,15 @@ async def test_bridge_mode( freezer: FrozenDateTimeFactory, router_bridge_mode: Mock, ) -> None: - """Test get_hosts_list invoqued once if freebox into bridge mode.""" + """Test get_interfaces invoqued once if freebox into bridge mode.""" await setup_platform(hass, DEVICE_TRACKER_DOMAIN) - assert router_bridge_mode().lan.get_hosts_list.call_count == 1 + assert router_bridge_mode().lan.get_interfaces.call_count == 1 # Simulate an update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - # If get_hosts_list failed, not called again - assert router_bridge_mode().lan.get_hosts_list.call_count == 1 + # If get_interfaces failed, not called again + assert router_bridge_mode().lan.get_interfaces.call_count == 1 diff --git a/tests/components/freebox/test_init.py b/tests/components/freebox/test_init.py index 4be58f247cd..c696ba838be 100644 --- a/tests/components/freebox/test_init.py +++ b/tests/components/freebox/test_init.py @@ -1,11 +1,11 @@ """Tests for the Freebox init.""" -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, Mock from pytest_unordered import unordered from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN -from homeassistant.components.freebox.const import DOMAIN, SERVICE_REBOOT +from homeassistant.components.freebox.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -33,19 +33,6 @@ async def test_setup(hass: HomeAssistant, router: Mock) -> None: assert router.call_count == 1 assert router().open.call_count == 1 - assert hass.services.has_service(DOMAIN, SERVICE_REBOOT) - - with patch( - "homeassistant.components.freebox.router.FreeboxRouter.reboot" - ) as mock_service: - await hass.services.async_call( - DOMAIN, - SERVICE_REBOOT, - blocking=True, - ) - await hass.async_block_till_done() - mock_service.assert_called_once() - async def test_setup_import(hass: HomeAssistant, router: Mock) -> None: """Test setup of integration from import.""" @@ -65,8 +52,6 @@ async def test_setup_import(hass: HomeAssistant, router: Mock) -> None: assert router.call_count == 1 assert router().open.call_count == 1 - assert hass.services.has_service(DOMAIN, SERVICE_REBOOT) - async def test_unload_remove(hass: HomeAssistant, router: Mock) -> None: """Test unload and remove of integration.""" @@ -106,7 +91,6 @@ async def test_unload_remove(hass: HomeAssistant, router: Mock) -> None: assert state_switch.state == STATE_UNAVAILABLE assert router().close.call_count == 1 - assert not hass.services.has_service(DOMAIN, SERVICE_REBOOT) await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/freebox/test_router.py b/tests/components/freebox/test_router.py index 623f595e1ad..3d98abf71a2 100644 --- a/tests/components/freebox/test_router.py +++ b/tests/components/freebox/test_router.py @@ -35,7 +35,10 @@ async def test_get_hosts_list_if_supported( assert supports_hosts is True # List must not be empty; but it's content depends on how many unit tests are executed... assert fbx_devices + # We expect 4 devices from lan_get_hosts_list.json and 1 from lan_get_hosts_list_guest.json + assert len(fbx_devices) == 5 assert "d633d0c8-958c-43cc-e807-d881b076924b" in str(fbx_devices) + assert "d633d0c8-958c-42cc-e807-d881b476924b" in str(fbx_devices) async def test_get_hosts_list_if_supported_bridge( diff --git a/tests/components/freedns/test_init.py b/tests/components/freedns/test_init.py index d142fd767e1..eab0a1793ce 100644 --- a/tests/components/freedns/test_init.py +++ b/tests/components/freedns/test_init.py @@ -16,7 +16,9 @@ UPDATE_URL = freedns.UPDATE_URL @pytest.fixture -def setup_freedns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_freedns( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up FreeDNS.""" params = {} params[ACCESS_TOKEN] = "" @@ -24,17 +26,15 @@ def setup_freedns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> N UPDATE_URL, params=params, text="Successfully updated 1 domains." ) - hass.loop.run_until_complete( - async_setup_component( - hass, - freedns.DOMAIN, - { - freedns.DOMAIN: { - "access_token": ACCESS_TOKEN, - "scan_interval": UPDATE_INTERVAL, - } - }, - ) + await async_setup_component( + hass, + freedns.DOMAIN, + { + freedns.DOMAIN: { + "access_token": ACCESS_TOKEN, + "scan_interval": UPDATE_INTERVAL, + } + }, ) diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index 1e292ed22bb..c1908c12a14 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -200,6 +200,7 @@ MOCK_FB_SERVICES: dict[str, dict] = { MOCK_IPS["printer"]: {"NewDisallow": False, "NewWANAccess": "granted"} } }, + "X_AVM-DE_UPnP1": {"GetInfo": {"NewEnable": True}}, } MOCK_MESH_DATA = { diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 9b5b8c9353a..c2ca866ceb6 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -27,6 +27,7 @@ 'WLANConfiguration1', 'X_AVM-DE_Homeauto1', 'X_AVM-DE_HostFilter1', + 'X_AVM-DE_UPnP1', ]), 'is_router': True, 'last_exception': None, diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index 5ff0e448b15..ffdd3d23f50 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -357,7 +357,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_download_noise_margin', 'has_entity_name': True, 'hidden_by': None, @@ -405,7 +405,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_download_power_attenuation', 'has_entity_name': True, 'hidden_by': None, @@ -453,7 +453,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_download_throughput', 'has_entity_name': True, 'hidden_by': None, @@ -502,7 +502,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_upload_noise_margin', 'has_entity_name': True, 'hidden_by': None, @@ -550,7 +550,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', 'has_entity_name': True, 'hidden_by': None, @@ -598,7 +598,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.mock_title_link_upload_throughput', 'has_entity_name': True, 'hidden_by': None, @@ -647,7 +647,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.mock_title_max_connection_download_throughput', 'has_entity_name': True, 'hidden_by': None, @@ -696,7 +696,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , + 'entity_category': None, 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index f4c4229af74..f790489c341 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for Fritz!Tools config flow.""" +from copy import deepcopy import dataclasses from unittest.mock import patch @@ -15,11 +16,13 @@ from homeassistant.components.device_tracker import ( DEFAULT_CONSIDER_HOME, ) from homeassistant.components.fritz.const import ( + CONF_FEATURE_DEVICE_TRACKING, CONF_OLD_DISCOVERY, DOMAIN, ERROR_AUTH_INVALID, ERROR_CANNOT_CONNECT, ERROR_UNKNOWN, + ERROR_UPNP_NOT_CONFIGURED, FRITZ_AUTH_EXCEPTIONS, ) from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER @@ -38,7 +41,9 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) +from .conftest import FritzConnectionMock from .const import ( + MOCK_FB_SERVICES, MOCK_FIRMWARE_INFO, MOCK_IPS, MOCK_REQUEST, @@ -740,6 +745,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["data"] == { CONF_OLD_DISCOVERY: False, CONF_CONSIDER_HOME: 37, + CONF_FEATURE_DEVICE_TRACKING: True, } @@ -761,3 +767,54 @@ async def test_ssdp_ipv6_link_local(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "ignore_ip6_link_local" + + +async def test_upnp_not_enabled(hass: HomeAssistant) -> None: + """Test if UPNP service is enabled on the router.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Disable UPnP + services = deepcopy(MOCK_FB_SERVICES) + services["X_AVM-DE_UPnP1"]["GetInfo"]["NewEnable"] = False + + with patch( + "homeassistant.components.fritz.config_flow.FritzConnection", + return_value=FritzConnectionMock(services), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT_SIMPLE + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == ERROR_UPNP_NOT_CONFIGURED + + # Enable UPnP + services["X_AVM-DE_UPnP1"]["GetInfo"]["NewEnable"] = True + + with ( + patch( + "homeassistant.components.fritz.config_flow.FritzConnection", + return_value=FritzConnectionMock(services), + ), + patch( + "homeassistant.components.fritz.config_flow.socket.gethostbyname", + return_value=MOCK_IPS["fritz.box"], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT_SIMPLE + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_PASSWORD] == "fake_pass" + assert result["data"][CONF_USERNAME] == "fake_user" + assert result["data"][CONF_PORT] == 49000 + assert result["data"][CONF_SSL] is False diff --git a/tests/components/fritz/test_diagnostics.py b/tests/components/fritz/test_diagnostics.py index cbcaa57dab4..84b06a3dd4a 100644 --- a/tests/components/fritz/test_diagnostics.py +++ b/tests/components/fritz/test_diagnostics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.fritz.const import DOMAIN diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 034b86497db..5792ccf85b1 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -25,7 +25,7 @@ async def setup_config_entry( device: Mock | None = None, fritz: Mock | None = None, template: Mock | None = None, -) -> bool: +) -> MockConfigEntry: """Do setup of a MockConfigEntry.""" entry = MockConfigEntry( domain=DOMAIN, @@ -39,10 +39,10 @@ async def setup_config_entry( if template is not None and fritz is not None: fritz().get_templates.return_value = [template] - result = await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) if device is not None: await hass.async_block_till_done() - return result + return entry def set_devices( @@ -60,6 +60,7 @@ class FritzEntityBaseMock(Mock): """base mock of a AVM Fritz!Box binary sensor device.""" ain = CONF_FAKE_AIN + device_and_unit_id = (CONF_FAKE_AIN, None) manufacturer = CONF_FAKE_MANUFACTURER name = CONF_FAKE_NAME productname = CONF_FAKE_PRODUCTNAME diff --git a/tests/components/fritzbox/snapshots/test_binary_sensor.ambr b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..1d645947ceb --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr @@ -0,0 +1,334 @@ +# serializer version: 1 +# name: test_setup[binary_sensor.fake_name_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm', + 'unique_id': '12345 1234567_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'fake_name Alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[binary_sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_on_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.fake_name_button_lock_on_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button lock on device', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '12345 1234567_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_on_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'fake_name Button lock on device', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_button_lock_on_device', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_via_ui-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.fake_name_button_lock_via_ui', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button lock via UI', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_lock', + 'unique_id': '12345 1234567_device_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_via_ui-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'fake_name Button lock via UI', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_button_lock_via_ui', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[binary_sensor.fake_name_holiday_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fake_name_holiday_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': 'Holiday mode', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'holiday_active', + 'unique_id': '12345 1234567_holiday_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_holiday_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Holiday mode', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_holiday_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[binary_sensor.fake_name_open_window_detected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fake_name_open_window_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open window detected', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'window_open', + 'unique_id': '12345 1234567_window_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_open_window_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Open window detected', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_open_window_detected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[binary_sensor.fake_name_summer_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fake_name_summer_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': 'Summer mode', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'summer_active', + 'unique_id': '12345 1234567_summer_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_summer_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Summer mode', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_summer_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_button.ambr b/tests/components/fritzbox/snapshots/test_button.ambr new file mode 100644 index 00000000000..95e757da3cc --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_button.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup[button.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.fake_name', + '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': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[button.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name', + }), + 'context': , + 'entity_id': 'button.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_climate.ambr b/tests/components/fritzbox/snapshots/test_climate.ambr new file mode 100644 index 00000000000..26e06105152 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_climate.ambr @@ -0,0 +1,80 @@ +# serializer version: 1 +# name: test_setup[climate.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 8.0, + 'preset_modes': list([ + 'eco', + 'comfort', + 'boost', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fake_name', + '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': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'thermostat', + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[climate.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_level': 23, + 'battery_low': True, + 'current_temperature': 18.0, + 'friendly_name': 'fake_name', + 'holiday_mode': False, + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 8.0, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'comfort', + 'boost', + ]), + 'summer_mode': False, + 'supported_features': , + 'temperature': 19.5, + 'window_open': 'fake_window', + }), + 'context': , + 'entity_id': 'climate.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_cover.ambr b/tests/components/fritzbox/snapshots/test_cover.ambr new file mode 100644 index 00000000000..ce6b305e154 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_cover.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_setup[cover.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[cover.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'blind', + 'friendly_name': 'fake_name', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_light.ambr b/tests/components/fritzbox/snapshots/test_light.ambr new file mode 100644 index 00000000000..f6f4516bdec --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_light.ambr @@ -0,0 +1,278 @@ +# serializer version: 1 +# name: test_setup[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + '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': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'color_temp': 370, + 'color_temp_kelvin': 2700, + 'friendly_name': 'fake_name', + 'hs_color': tuple( + 28.395, + 65.723, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 167, + 87, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.525, + 0.388, + ), + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_color[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + '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': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_color[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'fake_name', + 'hs_color': tuple( + 100, + 70.0, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'rgb_color': tuple( + 136, + 255, + 77, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.271, + 0.609, + ), + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_non_color[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + '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': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_non_color[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'friendly_name': 'fake_name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_non_color_non_level[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + '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': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_non_color_non_level[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'fake_name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_sensor.ambr b/tests/components/fritzbox/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..68f8e161d07 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_sensor.ambr @@ -0,0 +1,810 @@ +# serializer version: 1 +# name: test_setup[FritzDeviceBinarySensorMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceBinarySensorMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_comfort_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_comfort_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': 'Comfort temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'comfort_temperature', + 'unique_id': '12345 1234567_comfort_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_comfort_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Comfort temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_comfort_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_current_scheduled_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_current_scheduled_preset', + 'has_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 scheduled preset', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'scheduled_preset', + 'unique_id': '12345 1234567_scheduled_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_current_scheduled_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Current scheduled preset', + }), + 'context': , + 'entity_id': 'sensor.fake_name_current_scheduled_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'eco', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_eco_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_eco_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': 'Eco temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'eco_temperature', + 'unique_id': '12345 1234567_eco_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_eco_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Eco temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_eco_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_change_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_change_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': 'Next scheduled change time', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_time', + 'unique_id': '12345 1234567_nextchange_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_change_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'fake_name Next scheduled change time', + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_change_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-09-20T18:00:00+00:00', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_preset', + 'has_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 scheduled preset', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_preset', + 'unique_id': '12345 1234567_nextchange_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Next scheduled preset', + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'comfort', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_next_scheduled_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': 'Next scheduled temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_temperature', + 'unique_id': '12345 1234567_nextchange_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Next scheduled temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'fake_name Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_electric_current', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'fake_name Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.025', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_total_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'fake_name Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_power_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'fake_name Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.678', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_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': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'fake_name Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_switch.ambr b/tests/components/fritzbox/snapshots/test_switch.ambr new file mode 100644 index 00000000000..23deb8183fc --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup[switch.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_name', + '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': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[switch.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name', + }), + 'context': , + 'entity_id': 'switch.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index 594ed14a7d1..ae691f6107e 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -2,83 +2,51 @@ from datetime import timedelta from unittest import mock -from unittest.mock import Mock +from unittest.mock import Mock, patch +import pytest from requests.exceptions import HTTPError +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, - BinarySensorDeviceClass, -) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzDeviceBinarySensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{BINARY_SENSOR_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.BINARY_SENSOR]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(f"{ENTITY_ID}_alarm") - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Alarm" - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_button_lock_on_device") - assert state - assert state.state == STATE_OFF - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Button lock on device" - ) - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_button_lock_via_ui") - assert state - assert state.state == STATE_OFF - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Button lock via UI" - ) - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") - assert state - assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert ATTR_STATE_CLASS not in state.attributes + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: """Test state of platform.""" device = FritzDeviceBinarySensorMock() device.present = False - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -98,7 +66,7 @@ async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -117,7 +85,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceBinarySensorMock() device.update.side_effect = [mock.DEFAULT, HTTPError("Boom")] - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -135,7 +103,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -144,6 +112,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: new_device = FritzDeviceBinarySensorMock() new_device.ain = "7890 1234" + new_device.device_and_unit_id = ("7890 1234", None) new_device.name = "new_device" set_devices(fritz, devices=[device, new_device]) diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 0053a8d3446..ada50d7f16c 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -1,44 +1,50 @@ """Tests for AVM Fritz!Box templates.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch + +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - CONF_DEVICES, - STATE_UNKNOWN, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICES, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzEntityBaseMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{BUTTON_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test if is initialized correctly.""" template = FritzEntityBaseMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.BUTTON]): + entry = await setup_config_entry( + hass, + MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + fritz=fritz, + template=template, + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.state == STATE_UNKNOWN + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: """Test if applies works.""" template = FritzEntityBaseMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template ) @@ -51,7 +57,7 @@ async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" template = FritzEntityBaseMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template ) diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index f170836fa9b..bf8ce5d8a5b 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -1,11 +1,12 @@ """Tests for AVM Fritz!Box climate component.""" from datetime import timedelta -from unittest.mock import Mock, _Call, call +from unittest.mock import Mock, _Call, call, patch from freezegun.api import FrozenDateTimeFactory import pytest from requests.exceptions import HTTPError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -16,6 +17,7 @@ from homeassistant.components.climate import ( ATTR_PRESET_MODE, ATTR_PRESET_MODES, DOMAIN as CLIMATE_DOMAIN, + PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, SERVICE_SET_HVAC_MODE, @@ -30,25 +32,15 @@ from homeassistant.components.fritzbox.climate import ( PRESET_SUMMER, ) from homeassistant.components.fritzbox.const import ( - ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, ATTR_STATE_SUMMER_MODE, - ATTR_STATE_WINDOW_OPEN, DOMAIN as FB_DOMAIN, ) -from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - UnitOfTemperature, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_DEVICES, 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 . import ( @@ -59,123 +51,31 @@ from . import ( ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{CLIMATE_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.CLIMATE]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) - state = hass.states.get(ENTITY_ID) - assert state - assert state.attributes[ATTR_BATTERY_LEVEL] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 18 - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] - assert state.attributes[ATTR_MAX_TEMP] == 28 - assert state.attributes[ATTR_MIN_TEMP] == 8 - assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] - assert state.attributes[ATTR_STATE_BATTERY_LOW] is True - assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False - assert state.attributes[ATTR_STATE_SUMMER_MODE] is False - assert state.attributes[ATTR_STATE_WINDOW_OPEN] == "fake_window" - assert state.attributes[ATTR_TEMPERATURE] == 19.5 - assert ATTR_STATE_CLASS not in state.attributes - assert state.state == HVACMode.HEAT - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") - assert state - assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_comfort_temperature") - assert state - assert state.state == "22.0" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Comfort temperature" - ) - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_eco_temperature") - assert state - assert state.state == "16.0" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Eco temperature" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_temperature" - ) - assert state - assert state.state == "22.0" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled temperature" - ) - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_change_time" - ) - assert state - assert state.state == "2024-09-20T18:00:00+00:00" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled change time" - ) - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") - assert state - assert state.state == PRESET_COMFORT - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled preset" - ) - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" - ) - assert state - assert state.state == PRESET_ECO - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Current scheduled preset" - ) - assert ATTR_STATE_CLASS not in state.attributes - - device.nextchange_temperature = 16 - - next_update = dt_util.utcnow() + timedelta(seconds=200) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") - assert state - assert state.state == PRESET_ECO - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" - ) - assert state - assert state.state == PRESET_COMFORT + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_hkr_wo_temperature_sensor(hass: HomeAssistant, fritz: Mock) -> None: """Test hkr without exposing dedicated temperature sensor data block.""" device = FritzDeviceClimateWithoutTempSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -188,32 +88,32 @@ async def test_target_temperature_on(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceClimateMock() device.target_temperature = 127.0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) state = hass.states.get(ENTITY_ID) assert state - assert state.attributes[ATTR_TEMPERATURE] == 30 + assert state.attributes[ATTR_TEMPERATURE] is None async def test_target_temperature_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceClimateMock() device.target_temperature = 126.5 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) state = hass.states.get(ENTITY_ID) assert state - assert state.attributes[ATTR_TEMPERATURE] == 0 + assert state.attributes[ATTR_TEMPERATURE] is None async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -244,7 +144,7 @@ async def test_automatic_offset(hass: HomeAssistant, fritz: Mock) -> None: device.temperature = 18 device.actual_temperature = 19 device.target_temperature = 20 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -260,9 +160,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceClimateMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -276,15 +177,20 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: @pytest.mark.parametrize( - ("service_data", "expected_call_args"), + ( + "service_data", + "expected_set_target_temperature_call_args", + "expected_set_hkr_state_call_args", + ), [ - ({ATTR_TEMPERATURE: 23}, [call(23, True)]), + ({ATTR_TEMPERATURE: 23}, [call(23, True)], []), ( { ATTR_HVAC_MODE: HVACMode.OFF, ATTR_TEMPERATURE: 23, }, - [call(0, True)], + [], + [call("off", True)], ), ( { @@ -292,6 +198,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: ATTR_TEMPERATURE: 23, }, [call(23, True)], + [], ), ], ) @@ -299,11 +206,14 @@ async def test_set_temperature( hass: HomeAssistant, fritz: Mock, service_data: dict, - expected_call_args: list[_Call], + expected_set_target_temperature_call_args: list[_Call], + expected_set_hkr_state_call_args: list[_Call], ) -> None: """Test setting temperature.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + device.lock = False + + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -313,8 +223,297 @@ async def test_set_temperature( {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, True, ) - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args + assert device.set_target_temperature.call_count == len( + expected_set_target_temperature_call_args + ) + assert ( + device.set_target_temperature.call_args_list + == expected_set_target_temperature_call_args + ) + assert device.set_hkr_state.call_count == len(expected_set_hkr_state_call_args) + assert device.set_hkr_state.call_args_list == expected_set_hkr_state_call_args + + +@pytest.mark.parametrize( + ( + "service_data", + "target_temperature", + "current_preset", + "expected_set_target_temperature_call_args", + "expected_set_hkr_state_call_args", + ), + [ + # mode off always sets hkr state off + ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [], [call("off", True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [], [call("off", True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [], [call("off", True)]), + # mode heat sets target temperature based on current scheduled preset, + # when not already in mode heat + ( + {ATTR_HVAC_MODE: HVACMode.HEAT}, + OFF_API_TEMPERATURE, + PRESET_COMFORT, + [call(22, True)], + [], + ), + ( + {ATTR_HVAC_MODE: HVACMode.HEAT}, + OFF_API_TEMPERATURE, + PRESET_ECO, + [call(16, True)], + [], + ), + ( + {ATTR_HVAC_MODE: HVACMode.HEAT}, + OFF_API_TEMPERATURE, + None, + [call(22, True)], + [], + ), + # mode heat does not set target temperature, when already in mode heat + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_COMFORT, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_ECO, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, None, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_COMFORT, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, PRESET_ECO, [], []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, None, [], []), + ], +) +async def test_set_hvac_mode( + hass: HomeAssistant, + fritz: Mock, + service_data: dict, + target_temperature: float, + current_preset: str, + expected_set_target_temperature_call_args: list[_Call], + expected_set_hkr_state_call_args: list[_Call], +) -> None: + """Test setting hvac mode.""" + device = FritzDeviceClimateMock() + + device.lock = False + device.target_temperature = target_temperature + + if current_preset is PRESET_COMFORT: + device.nextchange_temperature = device.eco_temperature + elif current_preset is PRESET_ECO: + device.nextchange_temperature = device.comfort_temperature + else: + device.nextchange_endperiod = 0 + + await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, + True, + ) + + assert device.set_target_temperature.call_count == len( + expected_set_target_temperature_call_args + ) + assert ( + device.set_target_temperature.call_args_list + == expected_set_target_temperature_call_args + ) + assert device.set_hkr_state.call_count == len(expected_set_hkr_state_call_args) + assert device.set_hkr_state.call_args_list == expected_set_hkr_state_call_args + + +@pytest.mark.parametrize( + ("comfort_temperature", "expected_call_args"), + [ + (20, [call("comfort", True)]), + (28, [call("comfort", True)]), + (ON_API_TEMPERATURE, [call("comfort", True)]), + ], +) +async def test_set_preset_mode_comfort( + hass: HomeAssistant, + fritz: Mock, + comfort_temperature: int, + expected_call_args: list[_Call], +) -> None: + """Test setting preset mode.""" + device = FritzDeviceClimateMock() + + device.lock = False + device.comfort_temperature = comfort_temperature + await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, + True, + ) + assert device.set_hkr_state.call_count == len(expected_call_args) + assert device.set_hkr_state.call_args_list == expected_call_args + + +@pytest.mark.parametrize( + ("eco_temperature", "expected_call_args"), + [ + (20, [call("eco", True)]), + (16, [call("eco", True)]), + (OFF_API_TEMPERATURE, [call("eco", True)]), + ], +) +async def test_set_preset_mode_eco( + hass: HomeAssistant, + fritz: Mock, + eco_temperature: int, + expected_call_args: list[_Call], +) -> None: + """Test setting preset mode.""" + device = FritzDeviceClimateMock() + + device.lock = False + device.eco_temperature = eco_temperature + await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, + True, + ) + assert device.set_hkr_state.call_count == len(expected_call_args) + assert device.set_hkr_state.call_args_list == expected_call_args + + +async def test_set_preset_mode_boost( + hass: HomeAssistant, + fritz: Mock, +) -> None: + """Test setting preset mode.""" + device = FritzDeviceClimateMock() + device.lock = False + + await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_BOOST}, + True, + ) + assert device.set_hkr_state.call_count == 1 + assert device.set_hkr_state.call_args_list == [call("on", True)] + + +async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: + """Test preset mode.""" + device = FritzDeviceClimateMock() + device.comfort_temperature = 23 + device.eco_temperature = 20 + await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_PRESET_MODE] is None + + # test comfort preset + device.target_temperature = 23 + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get(ENTITY_ID) + + assert fritz().update_devices.call_count == 2 + assert state + assert state.attributes[ATTR_PRESET_MODE] == PRESET_COMFORT + + # test eco preset + device.target_temperature = 20 + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get(ENTITY_ID) + + assert fritz().update_devices.call_count == 3 + assert state + assert state.attributes[ATTR_PRESET_MODE] == PRESET_ECO + + # test boost preset + device.target_temperature = 127 # special temp from the api + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get(ENTITY_ID) + + assert fritz().update_devices.call_count == 4 + assert state + assert state.attributes[ATTR_PRESET_MODE] == PRESET_BOOST + + +async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: + """Test adding new discovered devices during runtime.""" + device = FritzDeviceClimateMock() + await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + + new_device = FritzDeviceClimateMock() + new_device.ain = "7890 1234" + new_device.name = "new_climate" + set_devices(fritz, devices=[device, new_device]) + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(f"{CLIMATE_DOMAIN}.new_climate") + assert state + + +@pytest.mark.parametrize( + "service_data", + [ + {ATTR_TEMPERATURE: 23}, + { + ATTR_HVAC_MODE: HVACMode.HEAT, + ATTR_TEMPERATURE: 25, + }, + ], +) +async def test_set_temperature_lock( + hass: HomeAssistant, + fritz: Mock, + service_data: dict, +) -> None: + """Test setting temperature while device is locked.""" + device = FritzDeviceClimateMock() + + device.lock = True + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + with pytest.raises( + HomeAssistantError, + match="Can't change settings while manual access for telephone, app, or user interface is disabled on the device", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, + True, + ) @pytest.mark.parametrize( @@ -338,7 +537,7 @@ async def test_set_temperature( ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, None, []), ], ) -async def test_set_hvac_mode( +async def test_set_hvac_mode_lock( hass: HomeAssistant, fritz: Mock, service_data: dict, @@ -346,8 +545,10 @@ async def test_set_hvac_mode( current_preset: str, expected_call_args: list[_Call], ) -> None: - """Test setting hvac mode.""" + """Test setting hvac mode while device is locked.""" device = FritzDeviceClimateMock() + + device.lock = True device.target_temperature = target_temperature if current_preset is PRESET_COMFORT: @@ -361,136 +562,16 @@ async def test_set_hvac_mode( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, - True, - ) - - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args - - -@pytest.mark.parametrize( - ("comfort_temperature", "expected_call_args"), - [ - (20, [call(20, True)]), - (28, [call(28, True)]), - (ON_API_TEMPERATURE, [call(30, True)]), - ], -) -async def test_set_preset_mode_comfort( - hass: HomeAssistant, - fritz: Mock, - comfort_temperature: int, - expected_call_args: list[_Call], -) -> None: - """Test setting preset mode.""" - device = FritzDeviceClimateMock() - device.comfort_temperature = comfort_temperature - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, - True, - ) - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args - - -@pytest.mark.parametrize( - ("eco_temperature", "expected_call_args"), - [ - (20, [call(20, True)]), - (16, [call(16, True)]), - (OFF_API_TEMPERATURE, [call(0, True)]), - ], -) -async def test_set_preset_mode_eco( - hass: HomeAssistant, - fritz: Mock, - eco_temperature: int, - expected_call_args: list[_Call], -) -> None: - """Test setting preset mode.""" - device = FritzDeviceClimateMock() - device.eco_temperature = eco_temperature - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, - True, - ) - assert device.set_target_temperature.call_count == len(expected_call_args) - assert device.set_target_temperature.call_args_list == expected_call_args - - -async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: - """Test preset mode.""" - device = FritzDeviceClimateMock() - device.comfort_temperature = 98 - device.eco_temperature = 99 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - - state = hass.states.get(ENTITY_ID) - assert state - assert state.attributes[ATTR_PRESET_MODE] is None - - device.target_temperature = 98 - - next_update = dt_util.utcnow() + timedelta(seconds=200) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(ENTITY_ID) - - assert fritz().update_devices.call_count == 2 - assert state - assert state.attributes[ATTR_PRESET_MODE] == PRESET_COMFORT - - device.target_temperature = 99 - - next_update = dt_util.utcnow() + timedelta(seconds=200) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(ENTITY_ID) - - assert fritz().update_devices.call_count == 3 - assert state - assert state.attributes[ATTR_PRESET_MODE] == PRESET_ECO - - -async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: - """Test adding new discovered devices during runtime.""" - device = FritzDeviceClimateMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - - state = hass.states.get(ENTITY_ID) - assert state - - new_device = FritzDeviceClimateMock() - new_device.ain = "7890 1234" - new_device.name = "new_climate" - set_devices(fritz, devices=[device, new_device]) - - next_update = dt_util.utcnow() + timedelta(seconds=200) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get(f"{CLIMATE_DOMAIN}.new_climate") - assert state + with pytest.raises( + HomeAssistantError, + match="Can't change settings while manual access for telephone, app, or user interface is disabled on the device", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, + True, + ) async def test_holidy_summer_mode( @@ -498,7 +579,9 @@ async def test_holidy_summer_mode( ) -> None: """Test holiday and summer mode.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + device.lock = False + + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -509,7 +592,11 @@ async def test_holidy_summer_mode( assert state.attributes[ATTR_STATE_SUMMER_MODE] is False assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] + assert state.attributes[ATTR_PRESET_MODES] == [ + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] # test holiday mode device.holiday_active = True @@ -528,7 +615,7 @@ async def test_holidy_summer_mode( with pytest.raises( HomeAssistantError, - match="Can't change hvac mode while holiday or summer mode is active on the device", + match="Can't change settings while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", @@ -538,7 +625,7 @@ async def test_holidy_summer_mode( ) with pytest.raises( HomeAssistantError, - match="Can't change preset while holiday or summer mode is active on the device", + match="Can't change settings while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", @@ -564,7 +651,7 @@ async def test_holidy_summer_mode( with pytest.raises( HomeAssistantError, - match="Can't change hvac mode while holiday or summer mode is active on the device", + match="Can't change settings while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", @@ -574,7 +661,7 @@ async def test_holidy_summer_mode( ) with pytest.raises( HomeAssistantError, - match="Can't change preset while holiday or summer mode is active on the device", + match="Can't change settings while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", @@ -596,4 +683,8 @@ async def test_holidy_summer_mode( assert state.attributes[ATTR_STATE_SUMMER_MODE] is False assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] + assert state.attributes[ATTR_PRESET_MODES] == [ + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ] diff --git a/tests/components/fritzbox/test_coordinator.py b/tests/components/fritzbox/test_coordinator.py index 401fab8f169..4c329daa640 100644 --- a/tests/components/fritzbox/test_coordinator.py +++ b/tests/components/fritzbox/test_coordinator.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow -from . import FritzDeviceCoverMock, FritzDeviceSwitchMock +from . import FritzDeviceCoverMock, FritzDeviceSwitchMock, FritzEntityBaseMock from .const import MOCK_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed @@ -84,9 +84,26 @@ async def test_coordinator_automatic_registry_cleanup( entity_registry: er.EntityRegistry, ) -> None: """Test automatic registry cleanup.""" + + # init with 2 devices and 1 template fritz().get_devices.return_value = [ - FritzDeviceSwitchMock(ain="fake ain switch", name="fake_switch"), - FritzDeviceCoverMock(ain="fake ain cover", name="fake_cover"), + FritzDeviceSwitchMock( + ain="fake ain switch", + device_and_unit_id=("fake ain switch", None), + name="fake_switch", + ), + FritzDeviceCoverMock( + ain="fake ain cover", + device_and_unit_id=("fake ain cover", None), + name="fake_cover", + ), + ] + fritz().get_templates.return_value = [ + FritzEntityBaseMock( + ain="fake ain template", + device_and_unit_id=("fake ain template", None), + name="fake_template", + ) ] entry = MockConfigEntry( domain=FB_DOMAIN, @@ -97,15 +114,29 @@ async def test_coordinator_automatic_registry_cleanup( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) - assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 11 - assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 2 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 20 + assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 3 + # remove one device, keep the template fritz().get_devices.return_value = [ - FritzDeviceSwitchMock(ain="fake ain switch", name="fake_switch") + FritzDeviceSwitchMock( + ain="fake ain switch", + device_and_unit_id=("fake ain switch", None), + name="fake_switch", + ) ] async_fire_time_changed(hass, utcnow() + timedelta(seconds=35)) await hass.async_block_till_done(wait_background_tasks=True) - assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 8 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 13 + assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 2 + + # remove the template, keep the device + fritz().get_templates.return_value = [] + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=35)) + await hass.async_block_till_done(wait_background_tasks=True) + + 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)) == 1 diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index 535306e4ef2..75e11983f39 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -1,15 +1,13 @@ """Tests for AVM Fritz!Box switch component.""" from datetime import timedelta -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch -from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_POSITION, - DOMAIN as COVER_DOMAIN, - CoverState, -) +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import ATTR_POSITION, DOMAIN as COVER_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICES, @@ -18,8 +16,10 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( @@ -30,28 +30,32 @@ from . import ( ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{COVER_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.COVER]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_unknown_position(hass: HomeAssistant, fritz: Mock) -> None: """Test cover with unknown position.""" device = FritzDeviceCoverUnknownPositionMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -63,7 +67,7 @@ async def test_unknown_position(hass: HomeAssistant, fritz: Mock) -> None: async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test opening the cover.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -76,7 +80,7 @@ async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test closing the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -89,7 +93,7 @@ async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test stopping the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -105,7 +109,7 @@ async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test stopping the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -118,7 +122,7 @@ async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index fe8bb32066e..7e6fa05d8cd 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -1,9 +1,10 @@ """Tests for AVM Fritz!Box light component.""" from datetime import timedelta -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch from requests.exceptions import HTTPError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fritzbox.const import ( COLOR_MODE, @@ -12,35 +13,36 @@ from homeassistant.components.fritzbox.const import ( ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, - ColorMode, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, CONF_DEVICES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - 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 . import FritzDeviceLightMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{LIGHT_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceLightMock() device.get_color_temps.return_value = [2700, 6500] @@ -50,42 +52,42 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: device.color_mode = COLOR_TEMP_MODE device.color_temp = 2700 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2700 - assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 - assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 - assert state.attributes[ATTR_HS_COLOR] == (28.395, 65.723) - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_non_color(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_non_color( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform of non color bulb.""" device = FritzDeviceLightMock() device.has_color = False device.get_color_temps.return_value = [] device.get_colors.return_value = {} - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_BRIGHTNESS] == 100 - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_non_color_non_level( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform of non color and non level bulb.""" device = FritzDeviceLightMock() device.has_color = False @@ -93,22 +95,21 @@ async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> No device.get_color_temps.return_value = [] device.get_colors.return_value = {} - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert ATTR_BRIGHTNESS not in state.attributes - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.ONOFF - assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None - assert state.attributes.get(ATTR_HS_COLOR) is None + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_color( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform in color mode.""" device = FritzDeviceLightMock() device.get_color_temps.return_value = [2700, 6500] @@ -119,19 +120,13 @@ async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: device.hue = 100 device.saturation = 70 * 255.0 / 100.0 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] is None - assert state.attributes[ATTR_BRIGHTNESS] == 100 - assert state.attributes[ATTR_HS_COLOR] == (100, 70) - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: @@ -258,9 +253,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 67b2c3e8ab6..4d12e8750a3 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -1,97 +1,69 @@ """Tests for AVM Fritz!Box sensor component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import PRESET_COMFORT, PRESET_ECO from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - STATE_UNKNOWN, - EntityCategory, - UnitOfTemperature, -) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( + FritzDeviceBinarySensorMock, FritzDeviceClimateMock, FritzDeviceSensorMock, + FritzDeviceSwitchMock, + FritzEntityBaseMock, set_devices, setup_config_entry, ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}" +@pytest.mark.parametrize( + "device", + [ + FritzDeviceBinarySensorMock, + FritzDeviceClimateMock, + FritzDeviceSensorMock, + FritzDeviceSwitchMock, + ], +) async def test_setup( - hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, + device: FritzEntityBaseMock, ) -> None: - """Test setup of platform.""" - device = FritzDeviceSensorMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - await hass.async_block_till_done() + """Test setup of sensor platform for different device types.""" + device = device() - sensors = ( - [ - f"{ENTITY_ID}_temperature", - "1.23", - f"{CONF_FAKE_NAME} Temperature", - UnitOfTemperature.CELSIUS, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{ENTITY_ID}_humidity", - "42", - f"{CONF_FAKE_NAME} Humidity", - PERCENTAGE, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{ENTITY_ID}_battery", - "23", - f"{CONF_FAKE_NAME} Battery", - PERCENTAGE, - None, - EntityCategory.DIAGNOSTIC, - ], - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SENSOR]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - for sensor in sensors: - state = hass.states.get(sensor[0]) - assert state - assert state.state == sensor[1] - assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] - assert state.attributes.get(ATTR_STATE_CLASS) == sensor[4] - entry = entity_registry.async_get(sensor[0]) - assert entry - assert entry.entity_category is sensor[5] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) assert fritz().update_devices.call_count == 1 @@ -109,9 +81,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceSensorMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -126,7 +99,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -135,6 +108,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: new_device = FritzDeviceSensorMock() new_device.ain = "7890 1234" + new_device.device_and_unit_id = ("7890 1234", None) new_device.name = "new_device" set_devices(fritz, devices=[device, new_device]) @@ -175,7 +149,7 @@ async def test_next_change_sensors( device.nextchange_endperiod = next_changes[0] device.nextchange_temperature = next_changes[1] - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 511725c663f..d8894c0ae93 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -1,33 +1,22 @@ """Tests for AVM Fritz!Box switch component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_ON, STATE_UNAVAILABLE, - EntityCategory, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -37,89 +26,32 @@ from homeassistant.util import dt as dt_util from . import FritzDeviceSwitchMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup( - hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, ) -> None: """Test setup of platform.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SWITCH]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_humidity") - assert state is None - - sensors = ( - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_temperature", - "1.23", - f"{CONF_FAKE_NAME} Temperature", - UnitOfTemperature.CELSIUS, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_power", - "5.678", - f"{CONF_FAKE_NAME} Power", - UnitOfPower.WATT, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_energy", - "1.234", - f"{CONF_FAKE_NAME} Energy", - UnitOfEnergy.KILO_WATT_HOUR, - SensorStateClass.TOTAL_INCREASING, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_voltage", - "230.0", - f"{CONF_FAKE_NAME} Voltage", - UnitOfElectricPotential.VOLT, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current", - "0.025", - f"{CONF_FAKE_NAME} Current", - UnitOfElectricCurrent.AMPERE, - SensorStateClass.MEASUREMENT, - None, - ], - ) - - for sensor in sensors: - state = hass.states.get(sensor[0]) - assert state - assert state.state == sensor[1] - assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] - assert state.attributes[ATTR_STATE_CLASS] == sensor[4] - assert state.attributes[ATTR_STATE_CLASS] == sensor[4] - entry = entity_registry.async_get(sensor[0]) - assert entry - assert entry.entity_category is sensor[5] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -133,7 +65,7 @@ async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device off.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -149,7 +81,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: device = FritzDeviceSwitchMock() device.lock = True - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -173,7 +105,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) assert fritz().update_devices.call_count == 1 @@ -191,9 +123,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceSwitchMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -211,7 +144,7 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No device.voltage = 0 device.energy = 0 device.power = 0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -223,7 +156,7 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fronius/snapshots/test_sensor.ambr b/tests/components/fronius/snapshots/test_sensor.ambr index 5384e9c6389..1c718910428 100644 --- a/tests/components/fronius/snapshots/test_sensor.ambr +++ b/tests/components/fronius/snapshots/test_sensor.ambr @@ -238,7 +238,7 @@ 'platform': 'fronius', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'current_dc_2', + 'translation_key': 'current_dc_mppt_no', 'unique_id': '12345678-current_dc_2', 'unit_of_measurement': , }) @@ -342,7 +342,7 @@ 'platform': 'fronius', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_dc_2', + 'translation_key': 'voltage_dc_mppt_no', 'unique_id': '12345678-voltage_dc_2', 'unit_of_measurement': , }) @@ -3179,7 +3179,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Relative self consumption', + 'original_name': 'Relative self-consumption', 'platform': 'fronius', 'previous_unique_id': None, 'supported_features': 0, @@ -3191,7 +3191,7 @@ # name: test_gen24[sensor.solarnet_relative_self_consumption-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'SolarNet Relative self consumption', + 'friendly_name': 'SolarNet Relative self-consumption', 'state_class': , 'unit_of_measurement': '%', }), @@ -3798,7 +3798,7 @@ 'platform': 'fronius', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'current_dc_2', + 'translation_key': 'current_dc_mppt_no', 'unique_id': '12345678-current_dc_2', 'unit_of_measurement': , }) @@ -3902,7 +3902,7 @@ 'platform': 'fronius', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_dc_2', + 'translation_key': 'voltage_dc_mppt_no', 'unique_id': '12345678-voltage_dc_2', 'unit_of_measurement': , }) @@ -7163,7 +7163,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Relative self consumption', + 'original_name': 'Relative self-consumption', 'platform': 'fronius', 'previous_unique_id': None, 'supported_features': 0, @@ -7175,7 +7175,7 @@ # name: test_gen24_storage[sensor.solarnet_relative_self_consumption-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'SolarNet Relative self consumption', + 'friendly_name': 'SolarNet Relative self-consumption', 'state_class': , 'unit_of_measurement': '%', }), @@ -10087,7 +10087,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Relative self consumption', + 'original_name': 'Relative self-consumption', 'platform': 'fronius', 'previous_unique_id': None, 'supported_features': 0, @@ -10099,7 +10099,7 @@ # name: test_primo_s0[sensor.solarnet_relative_self_consumption-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'SolarNet Relative self consumption', + 'friendly_name': 'SolarNet Relative self-consumption', 'state_class': , 'unit_of_measurement': '%', }), diff --git a/tests/components/fronius/test_diagnostics.py b/tests/components/fronius/test_diagnostics.py index ddef5b4a18c..cb6faf547e2 100644 --- a/tests/components/fronius/test_diagnostics.py +++ b/tests/components/fronius/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Fronius integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 63f36705c8f..be8cd43cf2b 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -2,7 +2,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.fronius.coordinator import ( diff --git a/tests/components/frontend/test_storage.py b/tests/components/frontend/test_storage.py index ce7f7aeb4a1..f4a61b743c5 100644 --- a/tests/components/frontend/test_storage.py +++ b/tests/components/frontend/test_storage.py @@ -13,9 +13,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def setup_frontend(hass: HomeAssistant) -> None: +async def setup_frontend(hass: HomeAssistant) -> None: """Fixture to setup the frontend.""" - hass.loop.run_until_complete(async_setup_component(hass, "frontend", {})) + await async_setup_component(hass, "frontend", {}) async def test_get_user_data_empty( @@ -79,12 +79,46 @@ async def test_get_user_data( assert res["result"]["value"]["test-complex"][0]["foo"] == "bar" +@pytest.mark.parametrize( + ("subscriptions", "events"), + [ + ([], []), + ([(1, {}, {})], [(1, {"test-key": "test-value"})]), + ([(1, {"key": "test-key"}, None)], [(1, "test-value")]), + ([(1, {"key": "other-key"}, None)], []), + ], +) async def test_set_user_data_empty( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + subscriptions: list[tuple[int, dict[str, str], Any]], + events: list[tuple[int, Any]], ) -> None: - """Test set_user_data command.""" + """Test set_user_data command. + + Also test subscribing. + """ client = await hass_ws_client(hass) + for msg_id, key, event_data in subscriptions: + await client.send_json( + { + "id": msg_id, + "type": "frontend/subscribe_user_data", + } + | key + ) + + event = await client.receive_json() + assert event == { + "id": msg_id, + "type": "event", + "event": {"value": event_data}, + } + + res = await client.receive_json() + assert res["success"], res + # test creating await client.send_json( @@ -104,6 +138,10 @@ async def test_set_user_data_empty( } ) + for msg_id, event_data in events: + event = await client.receive_json() + assert event == {"id": msg_id, "type": "event", "event": {"value": event_data}} + res = await client.receive_json() assert res["success"], res @@ -116,11 +154,63 @@ async def test_set_user_data_empty( assert res["result"]["value"] == "test-value" +@pytest.mark.parametrize( + ("subscriptions", "events"), + [ + ( + [], + [[], []], + ), + ( + [(1, {}, {"test-key": "test-value", "test-complex": "string"})], + [ + [ + ( + 1, + { + "test-complex": "string", + "test-key": "test-value", + "test-non-existent-key": "test-value-new", + }, + ) + ], + [ + ( + 1, + { + "test-complex": [{"foo": "bar"}], + "test-key": "test-value", + "test-non-existent-key": "test-value-new", + }, + ) + ], + ], + ), + ( + [(1, {"key": "test-key"}, "test-value")], + [[], []], + ), + ( + [(1, {"key": "test-non-existent-key"}, None)], + [[(1, "test-value-new")], []], + ), + ( + [(1, {"key": "test-complex"}, "string")], + [[], [(1, [{"foo": "bar"}])]], + ), + ( + [(1, {"key": "other-key"}, None)], + [[], []], + ), + ], +) async def test_set_user_data( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], hass_admin_user: MockUser, + subscriptions: list[tuple[int, dict[str, str], Any]], + events: list[list[tuple[int, Any]]], ) -> None: """Test set_user_data command with initial data.""" storage_key = f"{DOMAIN}.user_data_{hass_admin_user.id}" @@ -131,6 +221,25 @@ async def test_set_user_data( client = await hass_ws_client(hass) + for msg_id, key, event_data in subscriptions: + await client.send_json( + { + "id": msg_id, + "type": "frontend/subscribe_user_data", + } + | key + ) + + event = await client.receive_json() + assert event == { + "id": msg_id, + "type": "event", + "event": {"value": event_data}, + } + + res = await client.receive_json() + assert res["success"], res + # test creating await client.send_json( @@ -142,6 +251,10 @@ async def test_set_user_data( } ) + for msg_id, event_data in events[0]: + event = await client.receive_json() + assert event == {"id": msg_id, "type": "event", "event": {"value": event_data}} + res = await client.receive_json() assert res["success"], res @@ -164,6 +277,10 @@ async def test_set_user_data( } ) + for msg_id, event_data in events[1]: + event = await client.receive_json() + assert event == {"id": msg_id, "type": "event", "event": {"value": event_data}} + res = await client.receive_json() assert res["success"], res diff --git a/tests/components/fujitsu_fglair/test_climate.py b/tests/components/fujitsu_fglair/test_climate.py index 676ff97f26a..4e9dc750af9 100644 --- a/tests/components/fujitsu_fglair/test_climate.py +++ b/tests/components/fujitsu_fglair/test_climate.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_FAN_MODE, diff --git a/tests/components/fujitsu_fglair/test_sensor.py b/tests/components/fujitsu_fglair/test_sensor.py index e3f6109a2e8..45d455200fb 100644 --- a/tests/components/fujitsu_fglair/test_sensor.py +++ b/tests/components/fujitsu_fglair/test_sensor.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -31,3 +31,20 @@ async def test_entities( assert await integration_setup() await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_no_outside_temperature( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_ayla_api: AsyncMock, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test that the outside sensor doesn't get added if the reading is None.""" + mock_ayla_api.async_get_devices.return_value[0].outdoor_temperature = None + + assert await integration_setup() + + assert ( + len(entity_registry.entities) + == len(mock_ayla_api.async_get_devices.return_value) - 1 + ) diff --git a/tests/components/fully_kiosk/test_media_player.py b/tests/components/fully_kiosk/test_media_player.py index aa53421616f..e46a50100b2 100644 --- a/tests/components/fully_kiosk/test_media_player.py +++ b/tests/components/fully_kiosk/test_media_player.py @@ -184,6 +184,7 @@ async def test_browse_media( "media_content_id": "media-source://media_source/local/test.mp3", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": None, "children_media_class": None, } diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index 21e1fcfb0ab..91157c57c3a 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -6,10 +6,18 @@ "low_battery": false, "last_updated": "2023-01-10 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Gummibaum", "nutrients_status": 3, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E2", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": null, "plant_id": 0, "plant_origin_path": "http://www.plant_picture.com/picture", "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": false, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Ficus elastica", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/fixtures/plant_status1_update.json b/tests/components/fyta/fixtures/plant_status1_update.json index 98a4c6a9d91..5363c5bd290 100644 --- a/tests/components/fyta/fixtures/plant_status1_update.json +++ b/tests/components/fyta/fixtures/plant_status1_update.json @@ -6,10 +6,18 @@ "low_battery": false, "last_updated": "2023-01-10 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Gummibaum", "nutrients_status": 3, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E2", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": null, "plant_id": 0, "plant_origin_path": "http://www.plant_picture.com/picture1", "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": false, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Ficus elastica", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/fixtures/plant_status2.json b/tests/components/fyta/fixtures/plant_status2.json index bf90ab1e50d..5a181bee576 100644 --- a/tests/components/fyta/fixtures/plant_status2.json +++ b/tests/components/fyta/fixtures/plant_status2.json @@ -6,10 +6,18 @@ "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Kakaobaum", "nutrients_status": 3, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E3", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": 7, "plant_id": 0, "plant_origin_path": "", "plant_thumb_path": "", "is_productive_plant": false, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Theobroma cacao", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json index 4bb4e0b81a7..ad34e01065e 100644 --- a/tests/components/fyta/fixtures/plant_status3.json +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -6,10 +6,18 @@ "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Tomatenpflanze", "nutrients_status": 0, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E3", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": 7, "plant_id": 0, "plant_origin_path": "http://www.plant_picture.com/picture", "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": true, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Solanum lycopersicum", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index 24206fbb875..7bc6a6f7b5a 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -32,9 +32,17 @@ 'fertilise_next': None, 'last_updated': '2023-01-10T10:10:00', 'light': 2.0, + 'light_max_acceptable': 675.0, + 'light_max_good': 450.0, + 'light_min_acceptable': 18.0, + 'light_min_good': 20.0, 'light_status': 3, 'low_battery': False, 'moisture': 61.0, + 'moisture_max_acceptable': 80.0, + 'moisture_max_good': 70.0, + 'moisture_min_acceptable': 25.0, + 'moisture_min_good': 35.0, 'moisture_status': 3, 'name': 'Gummibaum', 'notification_light': False, @@ -50,6 +58,10 @@ 'productive_plant': False, 'repotted': True, 'salinity': 1.0, + 'salinity_max_acceptable': 1.2, + 'salinity_max_good': 1.0, + 'salinity_min_acceptable': 0.4, + 'salinity_min_good': 0.6, 'salinity_status': 4, 'scientific_name': 'Ficus elastica', 'sensor_available': True, @@ -59,7 +71,13 @@ 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, + 'temperature_max_acceptable': 42.0, + 'temperature_max_good': 36.0, + 'temperature_min_acceptable': 10.0, + 'temperature_min_good': 17.0, 'temperature_status': 3, + 'user_picture_path': 'http://www.plant_picture.com/user_picture', + 'user_thumb_path': 'http://www.plant_picture.com/user_picture_thumb', }), '1': dict({ 'battery_level': 80.0, @@ -67,9 +85,17 @@ 'fertilise_next': None, 'last_updated': '2023-01-02T10:10:00', 'light': 2.0, + 'light_max_acceptable': 675.0, + 'light_max_good': 450.0, + 'light_min_acceptable': 18.0, + 'light_min_good': 20.0, 'light_status': 3, 'low_battery': True, 'moisture': 61.0, + 'moisture_max_acceptable': 80.0, + 'moisture_max_good': 70.0, + 'moisture_min_acceptable': 25.0, + 'moisture_min_good': 35.0, 'moisture_status': 3, 'name': 'Kakaobaum', 'notification_light': False, @@ -85,6 +111,10 @@ 'productive_plant': False, 'repotted': True, 'salinity': 1.0, + 'salinity_max_acceptable': 1.2, + 'salinity_max_good': 1.0, + 'salinity_min_acceptable': 0.4, + 'salinity_min_good': 0.6, 'salinity_status': 4, 'scientific_name': 'Theobroma cacao', 'sensor_available': True, @@ -94,7 +124,13 @@ 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, + 'temperature_max_acceptable': 42.0, + 'temperature_max_good': 36.0, + 'temperature_min_acceptable': 10.0, + 'temperature_min_good': 17.0, 'temperature_status': 3, + 'user_picture_path': 'http://www.plant_picture.com/user_picture', + 'user_thumb_path': 'http://www.plant_picture.com/user_picture_thumb', }), }), }) diff --git a/tests/components/fyta/test_binary_sensor.py b/tests/components/fyta/test_binary_sensor.py index 9d6a4ae3b0e..aa5c45b6ebc 100644 --- a/tests/components/fyta/test_binary_sensor.py +++ b/tests/components/fyta/test_binary_sensor.py @@ -7,7 +7,7 @@ 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 syrupy.assertion import SnapshotAssertion from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform diff --git a/tests/components/fyta/test_diagnostics.py b/tests/components/fyta/test_diagnostics.py index cfaa5484b82..1fb626756e5 100644 --- a/tests/components/fyta/test_diagnostics.py +++ b/tests/components/fyta/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.const import Platform diff --git a/tests/components/fyta/test_image.py b/tests/components/fyta/test_image.py index 4feb125bd15..93cca1a1c09 100644 --- a/tests/components/fyta/test_image.py +++ b/tests/components/fyta/test_image.py @@ -7,7 +7,7 @@ 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 syrupy.assertion import SnapshotAssertion from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN from homeassistant.components.image import ImageEntity diff --git a/tests/components/fyta/test_sensor.py b/tests/components/fyta/test_sensor.py index 07e3965e66f..e9835ff5dfc 100644 --- a/tests/components/fyta/test_sensor.py +++ b/tests/components/fyta/test_sensor.py @@ -7,7 +7,7 @@ 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 syrupy.assertion import SnapshotAssertion from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform diff --git a/tests/components/garages_amsterdam/test_binary_sensor.py b/tests/components/garages_amsterdam/test_binary_sensor.py index b7d0333f7e3..b610ad484e8 100644 --- a/tests/components/garages_amsterdam/test_binary_sensor.py +++ b/tests/components/garages_amsterdam/test_binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/garages_amsterdam/test_sensor.py b/tests/components/garages_amsterdam/test_sensor.py index bc36401ea47..5e573cf3100 100644 --- a/tests/components/garages_amsterdam/test_sensor.py +++ b/tests/components/garages_amsterdam/test_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/gdacs/conftest.py b/tests/components/gdacs/conftest.py index 9d9a91aa407..46ac1d0aab2 100644 --- a/tests/components/gdacs/conftest.py +++ b/tests/components/gdacs/conftest.py @@ -2,7 +2,7 @@ import pytest -from homeassistant.components.gdacs import CONF_CATEGORIES, DOMAIN +from homeassistant.components.gdacs.const import CONF_CATEGORIES, DOMAIN from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, diff --git a/tests/components/gdacs/test_config_flow.py b/tests/components/gdacs/test_config_flow.py index f11848162cd..da9b2f7c9bf 100644 --- a/tests/components/gdacs/test_config_flow.py +++ b/tests/components/gdacs/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components.gdacs import CONF_CATEGORIES, DOMAIN +from homeassistant.components.gdacs.const import CONF_CATEGORIES, DOMAIN from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, diff --git a/tests/components/gdacs/test_diagnostics.py b/tests/components/gdacs/test_diagnostics.py index 3c6cf4080a6..8e8882ff6e7 100644 --- a/tests/components/gdacs/test_diagnostics.py +++ b/tests/components/gdacs/test_diagnostics.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index 68e2d061259..a6937f80d59 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -5,7 +5,7 @@ from unittest.mock import patch from freezegun import freeze_time -from homeassistant.components.gdacs import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED +from homeassistant.components.gdacs.const import DEFAULT_SCAN_INTERVAL from homeassistant.components.gdacs.geo_location import ( ATTR_ALERT_LEVEL, ATTR_COUNTRY, @@ -251,10 +251,7 @@ async def test_setup_imperial( ) # Test conversion of 200 miles to kilometers. - feeds = hass.data[DOMAIN][FEED] - assert feeds is not None - assert len(feeds) == 1 - manager = list(feeds.values())[0] + manager = config_entry.runtime_data # Ensure that the filter value in km is correctly set. assert manager._feed_manager._feed._filter_radius == 321.8688 diff --git a/tests/components/gdacs/test_init.py b/tests/components/gdacs/test_init.py index 1da4b0d9b9f..bdd11242b25 100644 --- a/tests/components/gdacs/test_init.py +++ b/tests/components/gdacs/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import patch -from homeassistant.components.gdacs import DOMAIN, FEED from homeassistant.core import HomeAssistant @@ -14,8 +13,7 @@ async def test_component_unload_config_entry(hass: HomeAssistant, config_entry) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert mock_feed_manager_update.call_count == 1 - assert hass.data[DOMAIN][FEED][config_entry.entry_id] is not None + # Unload config entry. assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN][FEED].get(config_entry.entry_id) is None diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index 01609cf485e..abc095fb4f5 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -4,9 +4,8 @@ from unittest.mock import patch from freezegun import freeze_time -from homeassistant.components import gdacs from homeassistant.components.gdacs import DEFAULT_SCAN_INTERVAL -from homeassistant.components.gdacs.const import CONF_CATEGORIES +from homeassistant.components.gdacs.const import CONF_CATEGORIES, DOMAIN from homeassistant.components.gdacs.sensor import ( ATTR_CREATED, ATTR_LAST_UPDATE, @@ -73,7 +72,7 @@ async def test_setup(hass: HomeAssistant) -> None: CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL.seconds, } config_entry = MockConfigEntry( - domain=gdacs.DOMAIN, + domain=DOMAIN, title=f"{latitude}, {longitude}", data=entry_data, unique_id="my_very_unique_id", diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 7e2e92f025b..65be83bad20 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -1119,6 +1119,52 @@ async def test_precision(hass: HomeAssistant) -> None: assert state.attributes.get("target_temp_step") == 0.1 +@pytest.fixture( + params=[ + HVACMode.HEAT, + HVACMode.COOL, + ] +) +async def setup_comp_10(hass: HomeAssistant, request: pytest.FixtureRequest) -> None: + """Initialize components.""" + assert await async_setup_component( + hass, + CLIMATE_DOMAIN, + { + "climate": { + "platform": "generic_thermostat", + "name": "test", + "cold_tolerance": 0, + "hot_tolerance": 0, + "target_temp": 25, + "heater": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "initial_hvac_mode": request.param, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("setup_comp_10") +async def test_zero_tolerances(hass: HomeAssistant) -> None: + """Test that having a zero tolerance doesn't cause the switch to flip-flop.""" + + # if the switch is off, it should remain off + calls = _setup_switch(hass, False) + _setup_sensor(hass, 25) + await hass.async_block_till_done() + await common.async_set_temperature(hass, 25) + assert len(calls) == 0 + + # if the switch is on, it should turn off + calls = _setup_switch(hass, True) + _setup_sensor(hass, 25) + await hass.async_block_till_done() + await common.async_set_temperature(hass, 25) + assert len(calls) == 1 + + async def test_custom_setup_params(hass: HomeAssistant) -> None: """Test the setup with custom parameters.""" result = await async_setup_component( diff --git a/tests/components/geniushub/test_binary_sensor.py b/tests/components/geniushub/test_binary_sensor.py index 682929eb696..6edeb317a55 100644 --- a/tests/components/geniushub/test_binary_sensor.py +++ b/tests/components/geniushub/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/geniushub/test_climate.py b/tests/components/geniushub/test_climate.py index d14e57b9552..d116f862b55 100644 --- a/tests/components/geniushub/test_climate.py +++ b/tests/components/geniushub/test_climate.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/geniushub/test_sensor.py b/tests/components/geniushub/test_sensor.py index a75329ca7fc..6e3af621bcc 100644 --- a/tests/components/geniushub/test_sensor.py +++ b/tests/components/geniushub/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/geniushub/test_switch.py b/tests/components/geniushub/test_switch.py index 0e88562e381..905c32e0c35 100644 --- a/tests/components/geniushub/test_switch.py +++ b/tests/components/geniushub/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index 7673f357a08..0a9ad8a5b16 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -29,22 +29,20 @@ def calls(hass: HomeAssistant) -> list[ServiceCall]: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": 32.880837, - "longitude": -117.237561, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + } + }, ) diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 33740397868..0e8752c97ec 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -318,12 +318,11 @@ async def test_load_unload_entry( state_1 = hass.states.get(f"device_tracker.{device_name}") assert state_1.state == STATE_HOME - assert len(hass.data[DOMAIN]["devices"]) == 1 entry = hass.config_entries.async_entries(DOMAIN)[0] + assert len(entry.runtime_data) == 1 assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[DOMAIN]["devices"]) == 0 assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/geonetnz_quakes/test_diagnostics.py b/tests/components/geonetnz_quakes/test_diagnostics.py index db5e1300768..ffe570cb269 100644 --- a/tests/components/geonetnz_quakes/test_diagnostics.py +++ b/tests/components/geonetnz_quakes/test_diagnostics.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index fd8ba81fca7..7373b207bab 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -5,9 +5,8 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory -from homeassistant.components import geonetnz_quakes from homeassistant.components.geo_location import ATTR_SOURCE -from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED +from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL, DOMAIN from homeassistant.components.geonetnz_quakes.geo_location import ( ATTR_DEPTH, ATTR_EXTERNAL_ID, @@ -38,7 +37,7 @@ from . import _generate_mock_feed_entry from tests.common import async_fire_time_changed -CONFIG = {geonetnz_quakes.DOMAIN: {CONF_RADIUS: 200}} +CONFIG = {DOMAIN: {CONF_RADIUS: 200}} async def test_setup( @@ -74,7 +73,7 @@ async def test_setup( freezer.move_to(utcnow) with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update: mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3] - assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG) + assert await async_setup_component(hass, DOMAIN, CONFIG) await hass.async_block_till_done() # Artificially trigger update and collect events. hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -188,7 +187,7 @@ async def test_setup_imperial( patch("aio_geojson_client.feed.GeoJsonFeed.last_timestamp", create=True), ): mock_feed_update.return_value = "OK", [mock_entry_1] - assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG) + assert await async_setup_component(hass, DOMAIN, CONFIG) await hass.async_block_till_done() # Artificially trigger update and collect events. hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -201,10 +200,7 @@ async def test_setup_imperial( ) # Test conversion of 200 miles to kilometers. - feeds = hass.data[DOMAIN][FEED] - assert feeds is not None - assert len(feeds) == 1 - manager = list(feeds.values())[0] + manager = hass.config_entries.async_loaded_entries(DOMAIN)[0].runtime_data # Ensure that the filter value in km is correctly set. assert manager._feed_manager._feed._filter_radius == 321.8688 diff --git a/tests/components/geonetnz_quakes/test_init.py b/tests/components/geonetnz_quakes/test_init.py index 6730fa53ece..fd334fa57ee 100644 --- a/tests/components/geonetnz_quakes/test_init.py +++ b/tests/components/geonetnz_quakes/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import patch -from homeassistant.components.geonetnz_quakes import DOMAIN, FEED from homeassistant.core import HomeAssistant @@ -16,8 +15,7 @@ async def test_component_unload_config_entry(hass: HomeAssistant, config_entry) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert mock_feed_manager_update.call_count == 1 - assert hass.data[DOMAIN][FEED][config_entry.entry_id] is not None + # Unload config entry. assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN][FEED].get(config_entry.entry_id) is None diff --git a/tests/components/geonetnz_volcano/test_init.py b/tests/components/geonetnz_volcano/test_init.py index fe113434dc6..49b4af2abec 100644 --- a/tests/components/geonetnz_volcano/test_init.py +++ b/tests/components/geonetnz_volcano/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, patch -from homeassistant.components.geonetnz_volcano import DOMAIN, FEED from homeassistant.core import HomeAssistant @@ -17,8 +16,7 @@ async def test_component_unload_config_entry(hass: HomeAssistant, config_entry) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert mock_feed_manager_update.call_count == 1 - assert hass.data[DOMAIN][FEED][config_entry.entry_id] is not None + # Unload config entry. assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN][FEED].get(config_entry.entry_id) is None diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py index d81758b0de0..3764c52a810 100644 --- a/tests/components/gios/test_config_flow.py +++ b/tests/components/gios/test_config_flow.py @@ -6,7 +6,8 @@ from unittest.mock import patch from gios import ApiError from homeassistant.components.gios import config_flow -from homeassistant.components.gios.const import CONF_STATION_ID +from homeassistant.components.gios.const import CONF_STATION_ID, DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -17,36 +18,35 @@ from tests.common import load_fixture CONFIG = { CONF_NAME: "Foo", - CONF_STATION_ID: 123, + CONF_STATION_ID: "123", } async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" - flow = config_flow.GiosFlowHandler() - flow.hass = hass - - result = await flow.async_step_user(user_input=None) + with patch( + "homeassistant.components.gios.coordinator.Gios._get_stations", + return_value=STATIONS, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" -async def test_invalid_station_id(hass: HomeAssistant) -> None: - """Test that errors are shown when measuring station ID is invalid.""" +async def test_form_with_api_error(hass: HomeAssistant) -> None: + """Test the form is aborted because of API error.""" with patch( "homeassistant.components.gios.coordinator.Gios._get_stations", - return_value=STATIONS, + side_effect=ApiError("error"), ): - flow = config_flow.GiosFlowHandler() - flow.hass = hass - flow.context = {} - - result = await flow.async_step_user( - user_input={CONF_NAME: "Foo", CONF_STATION_ID: 0} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - assert result["errors"] == {CONF_STATION_ID: "wrong_station_id"} + assert result["type"] is FlowResultType.ABORT async def test_invalid_sensor_data(hass: HomeAssistant) -> None: @@ -76,17 +76,25 @@ async def test_invalid_sensor_data(hass: HomeAssistant) -> None: async def test_cannot_connect(hass: HomeAssistant) -> None: """Test that errors are shown when cannot connect to GIOS server.""" - with patch( - "homeassistant.components.gios.coordinator.Gios._async_get", - side_effect=ApiError("error"), + with ( + patch( + "homeassistant.components.gios.coordinator.Gios._get_stations", + return_value=STATIONS, + ), + patch( + "homeassistant.components.gios.coordinator.Gios._async_get", + side_effect=ApiError("error"), + ), ): - flow = config_flow.GiosFlowHandler() - flow.hass = hass - flow.context = {} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG + ) + await hass.async_block_till_done() - result = await flow.async_step_user(user_input=CONFIG) - - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "cannot_connect"} async def test_create_entry(hass: HomeAssistant) -> None: diff --git a/tests/components/gios/test_diagnostics.py b/tests/components/gios/test_diagnostics.py index a965e5550df..cc3df9e3593 100644 --- a/tests/components/gios/test_diagnostics.py +++ b/tests/components/gios/test_diagnostics.py @@ -1,6 +1,6 @@ """Test GIOS diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index d9096916106..fd343d16525 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -6,7 +6,7 @@ import json from unittest.mock import patch from gios import ApiError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.gios.const import DOMAIN from homeassistant.components.sensor import DOMAIN as PLATFORM diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index 8e0367a712c..71bb689f3ff 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.glances.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE diff --git a/tests/components/goodwe/test_diagnostics.py b/tests/components/goodwe/test_diagnostics.py index 0a997edc594..fa90889e75e 100644 --- a/tests/components/goodwe/test_diagnostics.py +++ b/tests/components/goodwe/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.goodwe import CONF_MODEL_FAMILY, DOMAIN diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 3d10e753714..720c0176850 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1451,6 +1451,14 @@ async def test_working_location_ignored( assert state.attributes.get("message") == expected_event_message +@pytest.mark.parametrize( + ("event_type", "expected_event_message"), + [ + ("workingLocation", "Test All Day Event"), + ("birthday", None), + ("default", None), + ], +) @pytest.mark.parametrize("calendar_is_primary", [True]) async def test_working_location_entity( hass: HomeAssistant, @@ -1458,12 +1466,14 @@ async def test_working_location_entity( entity_registry: er.EntityRegistry, mock_events_list_items: Callable[[list[dict[str, Any]]], None], component_setup: ComponentSetup, + event_type: str, + expected_event_message: str | None, ) -> None: """Test that working location events are registered under a disabled by default entity.""" event = { **TEST_EVENT, **upcoming(), - "eventType": "workingLocation", + "eventType": event_type, } mock_events_list_items([event]) assert await component_setup() @@ -1484,7 +1494,7 @@ async def test_working_location_entity( state = hass.states.get("calendar.working_location") assert state assert state.name == "Working location" - assert state.attributes.get("message") == "Test All Day Event" + assert state.attributes.get("message") == expected_event_message @pytest.mark.parametrize("calendar_is_primary", [False]) @@ -1506,3 +1516,49 @@ async def test_no_working_location_entity( entity_entry = entity_registry.async_get("calendar.working_location") assert not entity_entry + + +@pytest.mark.parametrize( + ("event_type", "expected_event_message"), + [ + ("workingLocation", None), + ("birthday", "Test All Day Event"), + ("default", None), + ], +) +@pytest.mark.parametrize("calendar_is_primary", [True]) +async def test_birthday_entity( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + mock_events_list_items: Callable[[list[dict[str, Any]]], None], + component_setup: ComponentSetup, + event_type: str, + expected_event_message: str | None, +) -> None: + """Test that birthday events appear only on the birthdays calendar.""" + event = { + **TEST_EVENT, + **upcoming(), + "eventType": event_type, + } + mock_events_list_items([event]) + assert await component_setup() + + entity_entry = entity_registry.async_get("calendar.birthdays") + assert entity_entry + assert entity_entry.disabled_by is None # Enabled by default + + entity_registry.async_update_entity( + entity_id="calendar.birthdays", disabled_by=None + ) + async_fire_time_changed( + hass, + dt_util.utcnow() + datetime.timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + state = hass.states.get("calendar.birthdays") + assert state + assert state.name == "Birthdays" + assert state.attributes.get("message") == expected_event_message diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index de882a6f791..e5f4e512579 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -570,7 +570,7 @@ async def test_reauth_flow( ("primary_calendar_error", "primary_calendar_status", "reason"), [ (ClientError(), None, "cannot_connect"), - (None, HTTPStatus.FORBIDDEN, "api_disabled"), + (None, HTTPStatus.FORBIDDEN, "calendar_api_disabled"), (None, HTTPStatus.SERVICE_UNAVAILABLE, "cannot_connect"), ], ) diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 6be58f50469..015c20e8393 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -259,6 +259,13 @@ DEMO_DEVICES = [ "type": "action.devices.types.SETTOP", "willReportState": False, }, + { + "id": "media_player.search", + "name": {"name": "Search"}, + "traits": ["action.devices.traits.MediaState", "action.devices.traits.OnOff"], + "type": "action.devices.types.SETTOP", + "willReportState": False, + }, { "id": "fan.living_room_fan", "name": {"name": "Living Room Fan"}, diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index 1ecedbd1173..cc5ccbb1de1 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -98,6 +98,7 @@ 'humidifier', 'input_boolean', 'input_select', + 'lawn_mower', 'light', 'lock', 'media_player', diff --git a/tests/components/google_assistant/test_button.py b/tests/components/google_assistant/test_button.py index 6fdb94a5610..9e60576b3e6 100644 --- a/tests/components/google_assistant/test_button.py +++ b/tests/components/google_assistant/test_button.py @@ -29,7 +29,7 @@ async def test_sync_button(hass: HomeAssistant, hass_owner_user: MockUser) -> No assert state config_entry = hass.config_entries.async_entries("google_assistant")[0] - google_config: ga.GoogleConfig = hass.data[ga.DOMAIN][config_entry.entry_id] + google_config: ga.GoogleConfig = config_entry.runtime_data with patch.object(google_config, "async_sync_entities") as mock_sync_entities: mock_sync_entities.return_value = 200 diff --git a/tests/components/google_assistant/test_diagnostics.py b/tests/components/google_assistant/test_diagnostics.py index 1d68079563c..b75654edd1b 100644 --- a/tests/components/google_assistant/test_diagnostics.py +++ b/tests/components/google_assistant/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant import setup diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 2b0bfd82908..26541d33613 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -1,6 +1,5 @@ """The tests for the Google Assistant component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json from unittest.mock import patch @@ -16,13 +15,9 @@ from homeassistant.components import ( light, media_player, ) -from homeassistant.const import ( - CLOUD_NEVER_EXPOSED_ENTITIES, - EntityCategory, - Platform, - UnitOfTemperature, -) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, EntityCategory, Platform from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import DEMO_DEVICES @@ -42,32 +37,28 @@ def auth_header(hass_access_token: str) -> dict[str, str]: @pytest.fixture -def assistant_client( - event_loop: AbstractEventLoop, +async def assistant_client( hass: core.HomeAssistant, hass_client_no_auth: ClientSessionGenerator, ) -> TestClient: """Create web client for the Google Assistant API.""" - loop = event_loop - loop.run_until_complete( - setup.async_setup_component( - hass, - "google_assistant", - { - "google_assistant": { - "project_id": PROJECT_ID, - "entity_config": { - "light.ceiling_lights": { - "aliases": ["top lights", "ceiling lights"], - "name": "Roof Lights", - } - }, - } - }, - ) + await setup.async_setup_component( + hass, + "google_assistant", + { + "google_assistant": { + "project_id": PROJECT_ID, + "entity_config": { + "light.ceiling_lights": { + "aliases": ["top lights", "ceiling lights"], + "name": "Roof Lights", + } + }, + } + }, ) - return loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() @pytest.fixture(autouse=True) @@ -91,16 +82,12 @@ async def wanted_platforms_only() -> None: @pytest.fixture -def hass_fixture( - event_loop: AbstractEventLoop, hass: core.HomeAssistant -) -> core.HomeAssistant: +async def hass_fixture(hass: core.HomeAssistant) -> core.HomeAssistant: """Set up a Home Assistant instance for these tests.""" - loop = event_loop - # We need to do this to get access to homeassistant/turn_(on,off) - loop.run_until_complete(setup.async_setup_component(hass, core.DOMAIN, {})) + await setup.async_setup_component(hass, core.DOMAIN, {}) - loop.run_until_complete(setup.async_setup_component(hass, "demo", {})) + await setup.async_setup_component(hass, "demo", {}) return hass @@ -275,7 +262,7 @@ async def test_query_climate_request_f( ) -> None: """Test a query request.""" # Mock demo devices as fahrenheit to see if we convert to celsius - hass_fixture.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT + hass_fixture.config.units = US_CUSTOMARY_SYSTEM for entity_id in ("climate.hvac", "climate.heatpump", "climate.ecobee"): state = hass_fixture.states.get(entity_id) attr = dict(state.attributes) @@ -332,7 +319,6 @@ async def test_query_climate_request_f( "thermostatHumidityAmbient": 54.2, "currentFanSpeedSetting": "on_high", } - hass_fixture.config.units.temperature_unit = UnitOfTemperature.CELSIUS async def test_query_humidifier_request( diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 3b43728988b..2dba083185d 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -235,11 +235,11 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: ], }, { - "setting_name": "none", + "setting_name": "off", "setting_values": [ { "lang": "en", - "setting_synonym": ["none"], + "setting_synonym": ["off"], } ], }, @@ -356,9 +356,9 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> ], }, { - "setting_name": "none", + "setting_name": "off", "setting_values": [ - {"lang": "en", "setting_synonym": ["none"]} + {"lang": "en", "setting_synonym": ["off"]} ], }, ], @@ -957,9 +957,9 @@ async def test_unavailable_state_does_sync(hass: HomeAssistant) -> None: ], }, { - "setting_name": "none", + "setting_name": "off", "setting_values": [ - {"lang": "en", "setting_synonym": ["none"]} + {"lang": "en", "setting_synonym": ["off"]} ], }, ], diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index dafe85d97b2..cf9c8047049 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -21,6 +21,7 @@ from homeassistant.components import ( input_boolean, input_button, input_select, + lawn_mower, light, lock, media_player, @@ -44,6 +45,7 @@ from homeassistant.components.fan import FanEntityFeature from homeassistant.components.google_assistant import const, error, helpers, trait from homeassistant.components.google_assistant.error import SmartHomeError from homeassistant.components.humidifier import HumidifierEntityFeature +from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import LockEntityFeature from homeassistant.components.media_player import ( @@ -79,6 +81,11 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, St from homeassistant.core_config import async_process_ha_core_config from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import TemperatureConverter +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from . import BASIC_CONFIG, MockConfig @@ -584,6 +591,64 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: assert unpause_calls[0].data == {ATTR_ENTITY_ID: "vacuum.bla"} +async def test_dock_lawn_mower(hass: HomeAssistant) -> None: + """Test dock trait support for lawn mower domain.""" + assert helpers.get_google_type(lawn_mower.DOMAIN, None) is not None + assert trait.DockTrait.supported(lawn_mower.DOMAIN, 0, None, None) + + trt = trait.DockTrait( + hass, State("lawn_mower.bla", lawn_mower.LawnMowerActivity.MOWING), BASIC_CONFIG + ) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == {"isDocked": False} + + calls = async_mock_service(hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_DOCK) + await trt.execute(trait.COMMAND_DOCK, BASIC_DATA, {}, {}) + assert len(calls) == 1 + assert calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + +async def test_startstop_lawn_mower(hass: HomeAssistant) -> None: + """Test startStop trait support for lawn mower domain.""" + assert helpers.get_google_type(lawn_mower.DOMAIN, None) is not None + assert trait.StartStopTrait.supported(lawn_mower.DOMAIN, 0, None, None) + + trt = trait.StartStopTrait( + hass, + State( + "lawn_mower.bla", + lawn_mower.LawnMowerActivity.PAUSED, + {ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.PAUSE}, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == {"pausable": True} + + assert trt.query_attributes() == {"isRunning": False, "isPaused": True} + + start_calls = async_mock_service( + hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_START_MOWING + ) + await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": True}, {}) + assert len(start_calls) == 1 + assert start_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + pause_calls = async_mock_service(hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_PAUSE) + await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"pause": True}, {}) + assert len(pause_calls) == 1 + assert pause_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + unpause_calls = async_mock_service( + hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_START_MOWING + ) + await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"pause": False}, {}) + assert len(unpause_calls) == 1 + assert unpause_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + @pytest.mark.parametrize( ( "domain", @@ -1072,7 +1137,7 @@ async def test_temperature_setting_climate_onoff(hass: HomeAssistant) -> None: assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT + hass.config.units = US_CUSTOMARY_SYSTEM trt = trait.TemperatureSettingTrait( hass, @@ -1123,8 +1188,6 @@ async def test_temperature_setting_climate_no_modes(hass: HomeAssistant) -> None assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureSettingTrait( hass, State( @@ -1153,7 +1216,7 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT + hass.config.units = US_CUSTOMARY_SYSTEM trt = trait.TemperatureSettingTrait( hass, @@ -1261,7 +1324,6 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: ATTR_ENTITY_ID: "climate.bla", climate.ATTR_TEMPERATURE: 75, } - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None: @@ -1269,8 +1331,6 @@ async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureSettingTrait( hass, State( @@ -1356,8 +1416,6 @@ async def test_temperature_setting_climate_setpoint_auto(hass: HomeAssistant) -> Setpoint in auto mode. """ - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureSettingTrait( hass, State( @@ -1407,8 +1465,6 @@ async def test_temperature_setting_climate_setpoint_auto(hass: HomeAssistant) -> async def test_temperature_control(hass: HomeAssistant) -> None: """Test TemperatureControl trait support for sensor domain.""" - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureControlTrait( hass, State("sensor.temp", 18), @@ -1431,13 +1487,13 @@ async def test_temperature_control(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("unit_in", "unit_out", "temp_in", "temp_out", "current_in", "current_out"), [ - (UnitOfTemperature.CELSIUS, "C", "120", 120, "130", 130), - (UnitOfTemperature.FAHRENHEIT, "F", "248", 120, "266", 130), + (METRIC_SYSTEM, "C", "120", 120, "130", 130), + (US_CUSTOMARY_SYSTEM, "F", "248", 120, "266", 130), ], ) async def test_temperature_control_water_heater( hass: HomeAssistant, - unit_in: UnitOfTemperature, + unit_in: UnitSystem, unit_out: str, temp_in: str, temp_out: float, @@ -1445,17 +1501,17 @@ async def test_temperature_control_water_heater( current_out: float, ) -> None: """Test TemperatureControl trait support for water heater domain.""" - hass.config.units.temperature_unit = unit_in + hass.config.units = unit_in min_temp = TemperatureConverter.convert( water_heater.DEFAULT_MIN_TEMP, UnitOfTemperature.CELSIUS, - unit_in, + unit_in.temperature_unit, ) max_temp = TemperatureConverter.convert( water_heater.DEFAULT_MAX_TEMP, UnitOfTemperature.CELSIUS, - unit_in, + unit_in.temperature_unit, ) trt = trait.TemperatureControlTrait( @@ -1489,30 +1545,30 @@ async def test_temperature_control_water_heater( @pytest.mark.parametrize( ("unit", "temp_init", "temp_in", "temp_out", "current_init"), [ - (UnitOfTemperature.CELSIUS, "180", 220, 220, "180"), - (UnitOfTemperature.FAHRENHEIT, "356", 220, 428, "356"), + (METRIC_SYSTEM, "180", 220, 220, "180"), + (US_CUSTOMARY_SYSTEM, "356", 220, 428, "356"), ], ) async def test_temperature_control_water_heater_set_temperature( hass: HomeAssistant, - unit: UnitOfTemperature, + unit: UnitSystem, temp_init: str, temp_in: float, temp_out: float, current_init: str, ) -> None: """Test TemperatureControl trait support for water heater domain - SetTemperature.""" - hass.config.units.temperature_unit = unit + hass.config.units = unit min_temp = TemperatureConverter.convert( 40, UnitOfTemperature.CELSIUS, - unit, + unit.temperature_unit, ) max_temp = TemperatureConverter.convert( 230, UnitOfTemperature.CELSIUS, - unit, + unit.temperature_unit, ) trt = trait.TemperatureControlTrait( @@ -3633,17 +3689,17 @@ async def test_temperature_control_sensor(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("unit_in", "unit_out", "state", "ambient"), [ - (UnitOfTemperature.FAHRENHEIT, "F", "70", 21.1), - (UnitOfTemperature.CELSIUS, "C", "21.1", 21.1), - (UnitOfTemperature.FAHRENHEIT, "F", "unavailable", None), - (UnitOfTemperature.FAHRENHEIT, "F", "unknown", None), + (US_CUSTOMARY_SYSTEM, "F", "70", 21.1), + (METRIC_SYSTEM, "C", "21.1", 21.1), + (US_CUSTOMARY_SYSTEM, "F", "unavailable", None), + (US_CUSTOMARY_SYSTEM, "F", "unknown", None), ], ) async def test_temperature_control_sensor_data( - hass: HomeAssistant, unit_in, unit_out, state, ambient + hass: HomeAssistant, unit_in: UnitSystem, unit_out, state, ambient ) -> None: """Test TemperatureControl trait support for temperature sensor.""" - hass.config.units.temperature_unit = unit_in + hass.config.units = unit_in trt = trait.TemperatureControlTrait( hass, @@ -3668,7 +3724,6 @@ async def test_temperature_control_sensor_data( } else: assert trt.query_attributes() == {} - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS async def test_humidity_setting_sensor(hass: HomeAssistant) -> None: diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 2da397def5b..9cf86a280bd 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -247,9 +247,9 @@ async def test_agents_download_file_not_found( resp = await client.get( f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" ) - assert resp.status == 500 + assert resp.status == 404 content = await resp.content.read() - assert "Backup not found" in content.decode() + assert content == b"" async def test_agents_download_metadata_not_found( diff --git a/tests/components/google_generative_ai_conversation/__init__.py b/tests/components/google_generative_ai_conversation/__init__.py index 6e2d37b035b..fbf9ee545db 100644 --- a/tests/components/google_generative_ai_conversation/__init__.py +++ b/tests/components/google_generative_ai_conversation/__init__.py @@ -3,12 +3,12 @@ from unittest.mock import Mock from google.genai.errors import ClientError -import requests +import httpx CLIENT_ERROR_500 = ClientError( 500, Mock( - __class__=requests.Response, + __class__=httpx.Response, json=Mock( return_value={ "message": "Internal Server Error", @@ -20,7 +20,7 @@ CLIENT_ERROR_500 = ClientError( CLIENT_ERROR_API_KEY_INVALID = ClientError( 400, Mock( - __class__=requests.Response, + __class__=httpx.Response, json=Mock( return_value={ "message": "'reason': API_KEY_INVALID", diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 2bc81b10ce4..6ec147da2ab 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -4,6 +4,9 @@ from unittest.mock import Mock, patch import pytest +from homeassistant.components.google_generative_ai_conversation.conversation import ( + CONF_USE_GOOGLE_SEARCH_TOOL, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant @@ -41,6 +44,23 @@ async def mock_config_entry_with_assist( return mock_config_entry +@pytest.fixture +async def mock_config_entry_with_google_search( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Mock a config entry with assist.""" + with patch("google.genai.models.AsyncModels.get"): + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + ) + await hass.async_block_till_done() + return mock_config_entry + + @pytest.fixture async def mock_init_component( hass: HomeAssistant, mock_config_entry: ConfigEntry diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 7c9bb896bd3..ce257e61d53 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format='lower', items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=1500, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', @@ -25,7 +25,9 @@ tuple( ), dict({ - 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), + 'message': list([ + Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), + ]), }), ), ]) @@ -37,7 +39,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=None)], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=1500, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=None)], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', @@ -56,7 +58,42 @@ tuple( ), dict({ - 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), + 'message': list([ + Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), + ]), + }), + ), + ]) +# --- +# name: test_use_google_search + list([ + tuple( + '', + tuple( + ), + dict({ + 'config': GenerateContentConfig(http_options=None, system_instruction="You are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.\nCurrent time is 05:00:00. Today's date is 2024-05-24.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=1500, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None), Tool(function_declarations=None, retrieval=None, google_search=GoogleSearch(), google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'history': list([ + ]), + 'model': 'models/gemini-2.0-flash', + }), + ), + tuple( + '().send_message', + tuple( + ), + dict({ + 'message': 'Please call the test function', + }), + ), + tuple( + '().send_message', + tuple( + ), + dict({ + 'message': list([ + Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), + ]), }), ), ]) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index b445499ad49..60d388d0502 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -9,7 +9,7 @@ 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'max_tokens': 150, + 'max_tokens': 1500, 'prompt': 'Speak like a pirate', 'recommended': False, 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 8e6231cbffd..d8e54b15f61 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -1,4 +1,21 @@ # serializer version: 1 +# name: test_generate_content_file_processing_succeeds + list([ + tuple( + '', + tuple( + ), + dict({ + 'contents': list([ + 'Describe this image from my doorbell camera', + File(name='doorbell_snapshot.jpg', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), + File(name='context.txt', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), + ]), + 'model': 'models/gemini-2.0-flash', + }), + ), + ]) +# --- # name: test_generate_content_service_with_image list([ tuple( @@ -31,3 +48,18 @@ ), ]) # --- +# name: test_load_entry_with_unloaded_entries + list([ + tuple( + '', + tuple( + ), + dict({ + 'contents': list([ + 'Write an opening speech for a Home Assistant release party', + ]), + 'model': 'models/gemini-2.0-flash', + }), + ), + ]) +# --- diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 30c9d6c46e6..13063580c95 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -21,12 +21,14 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant @@ -37,9 +39,8 @@ from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID from tests.common import MockConfigEntry -@pytest.fixture -def mock_models(): - """Mock the model list API.""" +def get_models_pager(): + """Return a generator that yields the models.""" model_20_flash = Mock( display_name="Gemini 2.0 Flash", supported_actions=["generateContent"], @@ -70,11 +71,7 @@ def mock_models(): yield model_15_pro yield model_10_pro - with patch( - "google.genai.models.AsyncModels.list", - return_value=models_pager(), - ): - yield + return models_pager() async def test_form(hass: HomeAssistant) -> None: @@ -117,13 +114,17 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +def will_options_be_rendered_again(current_options, new_options) -> bool: + """Determine if options will be rendered again.""" + return current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED) + + @pytest.mark.parametrize( - ("current_options", "new_options", "expected_options"), + ("current_options", "new_options", "expected_options", "errors"), [ ( { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "none", CONF_PROMPT: "bla", }, { @@ -143,7 +144,9 @@ async def test_form(hass: HomeAssistant) -> None: CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, }, + None, ), ( { @@ -154,17 +157,128 @@ async def test_form(hass: HomeAssistant) -> None: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_USE_GOOGLE_SEARCH_TOOL: True, }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, + None, + ), + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + None, + ), + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + {CONF_USE_GOOGLE_SEARCH_TOOL: "invalid_google_search_option"}, + ), + ( + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: "assist", + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + None, ), ], ) @@ -172,10 +286,10 @@ async def test_form(hass: HomeAssistant) -> None: async def test_options_switching( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_models, current_options, new_options, expected_options, + errors, ) -> None: """Test the options form.""" with patch("google.genai.models.AsyncModels.get"): @@ -183,24 +297,42 @@ async def test_options_switching( mock_config_entry, options=current_options ) await hass.async_block_till_done() - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id - ) - if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): - options_flow = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - **current_options, - CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], - }, + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + if will_options_be_rendered_again(current_options, new_options): + retry_options = { + **current_options, + CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], + } + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options_flow = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + retry_options, + ) + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + new_options, ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - new_options, - ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == expected_options + if errors is None: + assert options["type"] is FlowResultType.CREATE_ENTRY + assert options["data"] == expected_options + + else: + assert options["type"] is FlowResultType.FORM + assert options.get("errors", None) == errors @pytest.mark.parametrize( diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 229ee0b323e..75cb308d5de 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -10,15 +10,16 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation -from homeassistant.components.conversation import trace +from homeassistant.components.conversation import UserContent, async_get_chat_log, trace from homeassistant.components.google_generative_ai_conversation.conversation import ( + ERROR_GETTING_RESPONSE, _escape_decode, _format_schema, ) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import chat_session, intent, llm from . import CLIENT_ERROR_500 @@ -104,28 +105,24 @@ async def test_function_call( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_create.mock_calls[2][2]["message"] - assert mock_tool_call.model_dump() == { - "parts": [ - { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "result": "Test response", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, + mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] + assert len(mock_tool_response_parts) == 1 + assert mock_tool_response_parts[0].model_dump() == { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, + "function_response": { + "id": None, + "name": "test_tool", + "response": { + "result": "Test response", }, - ], - "role": None, + }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, } mock_tool.async_call.assert_awaited_once_with( @@ -156,8 +153,10 @@ async def test_function_call( trace_events = last_trace.get("events", []) assert [event["event_type"] for event in trace_events] == [ trace.ConversationTraceEventType.ASYNC_PROCESS, - trace.ConversationTraceEventType.AGENT_DETAIL, + trace.ConversationTraceEventType.AGENT_DETAIL, # prompt and tools + trace.ConversationTraceEventType.AGENT_DETAIL, # stats for response trace.ConversationTraceEventType.TOOL_CALL, + trace.ConversationTraceEventType.AGENT_DETAIL, # stats for response ] # AGENT_DETAIL event contains the raw prompt passed to the model detail_event = trace_events[1] @@ -166,6 +165,79 @@ async def test_function_call( p["tool_name"] for p in detail_event["data"]["messages"][2]["tool_calls"] ] == ["test_tool"] + detail_event = trace_events[2] + assert set(detail_event["data"]["stats"].keys()) == { + "input_tokens", + "cached_input_tokens", + "output_tokens", + } + + +@patch( + "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" +) +@pytest.mark.usefixtures("mock_init_component") +@pytest.mark.usefixtures("mock_ulid_tools") +async def test_use_google_search( + mock_get_tools, + hass: HomeAssistant, + mock_config_entry_with_google_search: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test function calling.""" + agent_id = "conversation.google_generative_ai_conversation" + context = Context() + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + { + vol.Optional("param1", description="Test parameters"): [ + vol.All(str, vol.Lower) + ], + vol.Optional("param2"): vol.Any(float, int), + vol.Optional("param3"): dict, + } + ) + + mock_get_tools.return_value = [mock_tool] + + with patch("google.genai.chats.AsyncChats.create") as mock_create: + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + mock_part = Mock() + mock_part.text = "" + mock_part.function_call = FunctionCall( + name="test_tool", + args={ + "param1": ["test_value", "param1\\'s value"], + "param2": 2.7, + }, + ) + + def tool_call( + hass: HomeAssistant, tool_input: llm.ToolInput, tool_context: llm.LLMContext + ) -> dict[str, Any]: + mock_part.function_call = None + mock_part.text = "Hi there!" + return {"result": "Test response"} + + mock_tool.async_call.side_effect = tool_call + chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] + await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + device_id="test_device", + ) + + assert [tuple(mock_call) for mock_call in mock_create.mock_calls] == snapshot + @patch( "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" @@ -217,28 +289,24 @@ async def test_function_call_without_parameters( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_create.mock_calls[2][2]["message"] - assert mock_tool_call.model_dump() == { - "parts": [ - { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "result": "Test response", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, + mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] + assert len(mock_tool_response_parts) == 1 + assert mock_tool_response_parts[0].model_dump() == { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, + "function_response": { + "id": None, + "name": "test_tool", + "response": { + "result": "Test response", }, - ], - "role": None, + }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, } mock_tool.async_call.assert_awaited_once_with( @@ -315,29 +383,25 @@ async def test_function_exception( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_create.mock_calls[2][2]["message"] - assert mock_tool_call.model_dump() == { - "parts": [ - { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "error": "HomeAssistantError", - "error_text": "Test tool exception", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, + mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] + assert len(mock_tool_response_parts) == 1 + assert mock_tool_response_parts[0].model_dump() == { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, + "function_response": { + "id": None, + "name": "test_tool", + "response": { + "error": "HomeAssistantError", + "error_text": "Test tool exception", }, - ], - "role": None, + }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, } mock_tool.async_call.assert_awaited_once_with( hass, @@ -429,7 +493,33 @@ async def test_empty_response( assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "Sorry, I had a problem getting a response from Google Generative AI." + ERROR_GETTING_RESPONSE + ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_none_response( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test empty response.""" + with patch("google.genai.chats.AsyncChats.create") as mock_create: + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + chat_response.candidates = None + result = await conversation.async_converse( + hass, + "hello", + None, + Context(), + agent_id="conversation.google_generative_ai_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + ERROR_GETTING_RESPONSE ) @@ -493,33 +583,79 @@ async def test_escape_decode() -> None: {"type": "string", "enum": ["a", "b", "c"]}, {"type": "STRING", "enum": ["a", "b", "c"]}, ), + ( + {"type": "string", "default": "default"}, + {"type": "STRING"}, + ), + ( + {"type": "string", "pattern": "default"}, + {"type": "STRING"}, + ), + ( + {"type": "string", "maxLength": 10}, + {"type": "STRING"}, + ), + ( + {"type": "string", "minLength": 10}, + {"type": "STRING"}, + ), + ( + {"type": "string", "title": "title"}, + {"type": "STRING"}, + ), + ( + {"type": "string", "format": "enum", "enum": ["a", "b", "c"]}, + {"type": "STRING", "format": "enum", "enum": ["a", "b", "c"]}, + ), + ( + {"type": "string", "format": "date-time"}, + {"type": "STRING", "format": "date-time"}, + ), + ( + {"type": "string", "format": "byte"}, + {"type": "STRING"}, + ), + ( + {"type": "number", "format": "float"}, + {"type": "NUMBER", "format": "float"}, + ), + ( + {"type": "number", "format": "double"}, + {"type": "NUMBER", "format": "double"}, + ), + ( + {"type": "number", "format": "hex"}, + {"type": "NUMBER"}, + ), + ( + {"type": "number", "minimum": 1}, + {"type": "NUMBER"}, + ), + ( + {"type": "integer", "format": "int32"}, + {"type": "INTEGER", "format": "int32"}, + ), + ( + {"type": "integer", "format": "int64"}, + {"type": "INTEGER", "format": "int64"}, + ), + ( + {"type": "integer", "format": "int8"}, + {"type": "INTEGER"}, + ), ( {"type": "integer", "enum": [1, 2, 3]}, {"type": "STRING", "enum": ["1", "2", "3"]}, ), ( {"anyOf": [{"type": "integer"}, {"type": "number"}]}, - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, + {}, ), - ( - { - "any_of": [ - {"any_of": [{"type": "integer"}, {"type": "number"}]}, - {"any_of": [{"type": "integer"}, {"type": "number"}]}, - ] - }, - { - "any_of": [ - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, - ] - }, - ), - ({"type": "string", "format": "lower"}, {"format": "lower", "type": "STRING"}), - ({"type": "boolean", "format": "bool"}, {"format": "bool", "type": "BOOLEAN"}), + ({"type": "string", "format": "lower"}, {"type": "STRING"}), + ({"type": "boolean", "format": "bool"}, {"type": "BOOLEAN"}), ( {"type": "number", "format": "percent"}, - {"type": "NUMBER", "format": "percent"}, + {"type": "NUMBER"}, ), ( { @@ -534,7 +670,15 @@ async def test_escape_decode() -> None: }, ), ( - {"type": "object", "additionalProperties": True}, + {"type": "object", "additionalProperties": True, "minProperties": 1}, + { + "type": "OBJECT", + "properties": {"json": {"type": "STRING"}}, + "required": [], + }, + ), + ( + {"type": "object", "additionalProperties": True, "maxProperties": 1}, { "type": "OBJECT", "properties": {"json": {"type": "STRING"}}, @@ -545,8 +689,101 @@ async def test_escape_decode() -> None: {"type": "array", "items": {"type": "string"}}, {"type": "ARRAY", "items": {"type": "STRING"}}, ), + ( + { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 2, + }, + { + "type": "ARRAY", + "items": {"type": "STRING"}, + "min_items": 1, + "max_items": 2, + }, + ), ], ) async def test_format_schema(openapi, genai_schema) -> None: """Test _format_schema.""" assert _format_schema(openapi) == genai_schema + + +@pytest.mark.usefixtures("mock_init_component") +async def test_empty_content_in_chat_history( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Tests that in case of an empty entry in the chat history the google API will receive an injected space sign instead.""" + with ( + patch("google.genai.chats.AsyncChats.create") as mock_create, + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session) as chat_log, + ): + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + + # Chat preparation with two inputs, one being an empty string + first_input = "First request" + second_input = "" + chat_log.async_add_user_content(UserContent(first_input)) + chat_log.async_add_user_content(UserContent(second_input)) + + await conversation.async_converse( + hass, + "Second request", + session.conversation_id, + Context(), + agent_id="conversation.google_generative_ai_conversation", + ) + + _, kwargs = mock_create.call_args + actual_history = kwargs.get("history") + + assert actual_history[0].parts[0].text == first_input + assert actual_history[1].parts[0].text == " " + + +@pytest.mark.usefixtures("mock_init_component") +async def test_history_always_user_first_turn( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test that the user is always first in the chat history.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session) as chat_log, + ): + chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id="conversation.google_generative_ai_conversation", + content="Garage door left open, do you want to close it?", + ) + ) + + with patch("google.genai.chats.AsyncChats.create") as mock_create: + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + chat_response.candidates = [Mock(content=Mock(parts=[]))] + + await conversation.async_converse( + hass, + "hello", + chat_log.conversation_id, + Context(), + agent_id="conversation.google_generative_ai_conversation", + ) + + _, kwargs = mock_create.call_args + actual_history = kwargs.get("history") + + assert actual_history[0].parts[0].text == " " + assert actual_history[0].role == "user" + assert ( + actual_history[1].parts[0].text + == "Garage door left open, do you want to close it?" + ) + assert actual_history[1].role == "model" diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 0dad485812e..94308260f74 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,7 +1,8 @@ """Tests for the Google Generative AI Conversation integration.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, mock_open, patch +from google.genai.types import File, FileState import pytest from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion @@ -71,6 +72,8 @@ async def test_generate_content_service_with_image( ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), + patch("builtins.open", mock_open(read_data="this is an image")), + patch("mimetypes.guess_type", return_value=["image/jpeg"]), ): response = await hass.services.async_call( "google_generative_ai_conversation", @@ -89,6 +92,117 @@ async def test_generate_content_service_with_image( assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_file_processing_succeeds( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test generate content service.""" + stubbed_generated_content = ( + "A mail carrier is at your front door delivering a package" + ) + + with ( + patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ) as mock_generate, + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=True), + patch("builtins.open", mock_open(read_data="this is an image")), + patch("mimetypes.guess_type", return_value=["image/jpeg"]), + patch( + "google.genai.files.Files.upload", + side_effect=[ + File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE), + File(name="context.txt", state=FileState.PROCESSING), + ], + ), + patch( + "google.genai.files.AsyncFiles.get", + side_effect=[ + File(name="context.txt", state=FileState.PROCESSING), + File(name="context.txt", state=FileState.ACTIVE), + ], + ), + ): + response = await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], + }, + blocking=True, + return_response=True, + ) + + assert response == { + "text": stubbed_generated_content, + } + assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_file_processing_fails( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test generate content service.""" + stubbed_generated_content = ( + "A mail carrier is at your front door delivering a package" + ) + + with ( + patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ), + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=True), + patch("builtins.open", mock_open(read_data="this is an image")), + patch("mimetypes.guess_type", return_value=["image/jpeg"]), + patch( + "google.genai.files.Files.upload", + side_effect=[ + File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE), + File(name="context.txt", state=FileState.PROCESSING), + ], + ), + patch( + "google.genai.files.AsyncFiles.get", + side_effect=[ + File(name="context.txt", state=FileState.PROCESSING), + File( + name="context.txt", + state=FileState.FAILED, + error={"message": "File processing failed"}, + ), + ], + ), + pytest.raises( + HomeAssistantError, + match="File `context.txt` processing failed, reason: File processing failed", + ), + ): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], + }, + blocking=True, + return_response=True, + ) + + @pytest.mark.usefixtures("mock_init_component") async def test_generate_content_service_error( hass: HomeAssistant, @@ -224,3 +338,52 @@ async def test_config_entry_error( await hass.async_block_till_done() assert mock_config_entry.state == state assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) == reauth + + +@pytest.mark.usefixtures("mock_init_component") +async def test_load_entry_with_unloaded_entries( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test loading an entry with unloaded entries.""" + config_entries = hass.config_entries.async_entries( + "google_generative_ai_conversation" + ) + runtime_data = config_entries[0].runtime_data + await hass.config_entries.async_unload(config_entries[0].entry_id) + + entry = MockConfigEntry( + domain="google_generative_ai_conversation", + title="Google Generative AI Conversation", + data={ + "api_key": "bla", + }, + state=ConfigEntryState.LOADED, + ) + entry.runtime_data = runtime_data + entry.add_to_hass(hass) + + stubbed_generated_content = ( + "I'm thrilled to welcome you all to the release " + "party for the latest version of Home Assistant!" + ) + + with patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ) as mock_generate: + response = await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "Write an opening speech for a Home Assistant release party"}, + blocking=True, + return_response=True, + ) + + assert response == { + "text": stubbed_generated_content, + } + assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index 5b691da4bdc..54ad47405a1 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -475,6 +475,6 @@ async def test_service_say_error( await retrieve_media( hass, hass_client, service_calls[1].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(mock_gtts.mock_calls) == 2 diff --git a/tests/components/google_travel_time/conftest.py b/tests/components/google_travel_time/conftest.py index 7d1e4791eee..ef066bfe2a4 100644 --- a/tests/components/google_travel_time/conftest.py +++ b/tests/components/google_travel_time/conftest.py @@ -2,9 +2,11 @@ from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, patch -from googlemaps.exceptions import ApiError, Timeout, TransportError +from google.maps.routing_v2 import ComputeRoutesResponse, Route +from google.protobuf import duration_pb2 +from google.type import localized_text_pb2 import pytest from homeassistant.components.google_travel_time.const import DOMAIN @@ -30,8 +32,8 @@ async def mock_config_fixture( return config_entry -@pytest.fixture(name="bypass_setup") -def bypass_setup_fixture() -> Generator[None]: +@pytest.fixture +def mock_setup_entry() -> Generator[None]: """Bypass entry setup.""" with patch( "homeassistant.components.google_travel_time.async_setup_entry", @@ -40,48 +42,42 @@ def bypass_setup_fixture() -> Generator[None]: yield -@pytest.fixture(name="bypass_platform_setup") -def bypass_platform_setup_fixture() -> Generator[None]: - """Bypass platform setup.""" - with patch( - "homeassistant.components.google_travel_time.sensor.async_setup_entry", - return_value=True, - ): - yield - - -@pytest.fixture(name="validate_config_entry") -def validate_config_entry_fixture() -> Generator[MagicMock]: - """Return valid config entry.""" +@pytest.fixture +def routes_mock() -> Generator[AsyncMock]: + """Return valid API result.""" with ( - patch("homeassistant.components.google_travel_time.helpers.Client"), patch( - "homeassistant.components.google_travel_time.helpers.distance_matrix" - ) as distance_matrix_mock, + "homeassistant.components.google_travel_time.helpers.RoutesAsyncClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.google_travel_time.sensor.RoutesAsyncClient", + new=mock_client, + ), ): - distance_matrix_mock.return_value = None - yield distance_matrix_mock - - -@pytest.fixture(name="invalidate_config_entry") -def invalidate_config_entry_fixture(validate_config_entry: MagicMock) -> None: - """Return invalid config entry.""" - validate_config_entry.side_effect = ApiError("test") - - -@pytest.fixture(name="invalid_api_key") -def invalid_api_key_fixture(validate_config_entry: MagicMock) -> None: - """Throw a REQUEST_DENIED ApiError.""" - validate_config_entry.side_effect = ApiError("REQUEST_DENIED", "Invalid API key.") - - -@pytest.fixture(name="timeout") -def timeout_fixture(validate_config_entry: MagicMock) -> None: - """Throw a Timeout exception.""" - validate_config_entry.side_effect = Timeout() - - -@pytest.fixture(name="transport_error") -def transport_error_fixture(validate_config_entry: MagicMock) -> None: - """Throw a TransportError exception.""" - validate_config_entry.side_effect = TransportError("Unknown.") + client_mock = mock_client.return_value + client_mock.compute_routes.return_value = ComputeRoutesResponse( + mapping={ + "routes": [ + Route( + mapping={ + "localized_values": Route.RouteLocalizedValues( + mapping={ + "distance": localized_text_pb2.LocalizedText( + text="21.3 km" + ), + "duration": localized_text_pb2.LocalizedText( + text="27 mins" + ), + "static_duration": localized_text_pb2.LocalizedText( + text="26 mins" + ), + } + ), + "duration": duration_pb2.Duration(seconds=1620), + } + ) + ] + } + ) + yield client_mock diff --git a/tests/components/google_travel_time/const.py b/tests/components/google_travel_time/const.py index 29cf32b8e29..dd83e1366ac 100644 --- a/tests/components/google_travel_time/const.py +++ b/tests/components/google_travel_time/const.py @@ -3,13 +3,15 @@ from homeassistant.components.google_travel_time.const import ( CONF_DESTINATION, CONF_ORIGIN, + CONF_UNITS, + UNITS_METRIC, ) -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_MODE MOCK_CONFIG = { CONF_API_KEY: "api_key", CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", + CONF_DESTINATION: "49.983862755708444,8.223882827079068", } RECONFIGURE_CONFIG = { @@ -17,3 +19,5 @@ RECONFIGURE_CONFIG = { CONF_ORIGIN: "location3", CONF_DESTINATION: "location4", } + +DEFAULT_OPTIONS = {CONF_MODE: "driving", CONF_UNITS: UNITS_METRIC} diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 5f9d5d4549b..8cdb3c270d0 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -1,10 +1,10 @@ """Test the Google Maps Travel Time config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from google.api_core.exceptions import GatewayTimeout, GoogleAPIError, Unauthorized import pytest -from homeassistant import config_entries from homeassistant.components.google_travel_time.const import ( ARRIVAL_TIME, CONF_ARRIVAL_TIME, @@ -23,26 +23,32 @@ from homeassistant.components.google_travel_time.const import ( DOMAIN, UNITS_IMPERIAL, ) +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import MOCK_CONFIG, RECONFIGURE_CONFIG +from .const import DEFAULT_OPTIONS, MOCK_CONFIG, RECONFIGURE_CONFIG from tests.common import MockConfigEntry async def assert_common_reconfigure_steps( - hass: HomeAssistant, reconfigure_result: config_entries.ConfigFlowResult + hass: HomeAssistant, reconfigure_result: ConfigFlowResult ) -> None: """Step through and assert the happy case reconfigure flow.""" + client_mock = AsyncMock() with ( - patch("homeassistant.components.google_travel_time.helpers.Client"), patch( - "homeassistant.components.google_travel_time.helpers.distance_matrix", - return_value=None, + "homeassistant.components.google_travel_time.helpers.RoutesAsyncClient", + return_value=client_mock, + ), + patch( + "homeassistant.components.google_travel_time.sensor.RoutesAsyncClient", + return_value=client_mock, ), ): + client_mock.compute_routes.return_value = None reconfigure_successful_result = await hass.config_entries.flow.async_configure( reconfigure_result["flow_id"], RECONFIGURE_CONFIG, @@ -56,38 +62,28 @@ async def assert_common_reconfigure_steps( async def assert_common_create_steps( - hass: HomeAssistant, user_step_result: config_entries.ConfigFlowResult + hass: HomeAssistant, result: ConfigFlowResult ) -> None: """Step through and assert the happy case create flow.""" - with ( - patch("homeassistant.components.google_travel_time.helpers.Client"), - patch( - "homeassistant.components.google_travel_time.helpers.distance_matrix", - return_value=None, - ), - ): - create_result = await hass.config_entries.flow.async_configure( - user_step_result["flow_id"], - MOCK_CONFIG, - ) - assert create_result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - - entry = hass.config_entries.async_entries(DOMAIN)[0] - assert entry.title == DEFAULT_NAME - assert entry.data == { - CONF_NAME: DEFAULT_NAME, - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_API_KEY: "api_key", + CONF_ORIGIN: "location1", + CONF_DESTINATION: "49.983862755708444,8.223882827079068", + } -@pytest.mark.usefixtures("validate_config_entry", "bypass_setup") +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") async def test_minimum_fields(hass: HomeAssistant) -> 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.FORM assert result["errors"] is None @@ -95,255 +91,101 @@ async def test_minimum_fields(hass: HomeAssistant) -> None: await assert_common_create_steps(hass, result) -@pytest.mark.usefixtures("invalidate_config_entry") -async def test_invalid_config_entry(hass: HomeAssistant) -> None: - """Test we get the form.""" +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (GoogleAPIError("test"), "cannot_connect"), + (GatewayTimeout("Timeout error."), "timeout_connect"), + (Unauthorized("Invalid API key."), "invalid_auth"), + ], +) +async def test_errors( + hass: HomeAssistant, routes_mock: AsyncMock, exception: Exception, error: str +) -> None: + """Test errors in the flow.""" + routes_mock.compute_routes.side_effect = exception 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.FORM assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( + + result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - await assert_common_create_steps(hass, result2) - - -@pytest.mark.usefixtures("invalid_api_key") -async def test_invalid_api_key(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 result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) + assert result["errors"] == {"base": error} - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - await assert_common_create_steps(hass, result2) - - -@pytest.mark.usefixtures("transport_error") -async def test_transport_error(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 result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - await assert_common_create_steps(hass, result2) - - -@pytest.mark.usefixtures("timeout") -async def test_timeout(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 result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "timeout_connect"} - await assert_common_create_steps(hass, result2) - - -async def test_malformed_api_key(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 result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + routes_mock.compute_routes.side_effect = None + await assert_common_create_steps(hass, result) @pytest.mark.parametrize( ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("validate_config_entry", "bypass_setup") +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") async def test_reconfigure(hass: HomeAssistant, mock_config: MockConfigEntry) -> None: """Test reconfigure flow.""" - reconfigure_result = await mock_config.start_reconfigure_flow(hass) - assert reconfigure_result["type"] is FlowResultType.FORM - assert reconfigure_result["step_id"] == "reconfigure" + result = await mock_config.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" - await assert_common_reconfigure_steps(hass, reconfigure_result) + await assert_common_reconfigure_steps(hass, result) +@pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +@pytest.mark.parametrize( + ("exception", "error"), [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) + (GoogleAPIError("test"), "cannot_connect"), + (GatewayTimeout("Timeout error."), "timeout_connect"), + (Unauthorized("Invalid API key."), "invalid_auth"), ], ) -@pytest.mark.usefixtures("invalidate_config_entry") async def test_reconfigure_invalid_config_entry( - hass: HomeAssistant, mock_config: MockConfigEntry + hass: HomeAssistant, + mock_config: MockConfigEntry, + routes_mock: AsyncMock, + exception: Exception, + error: str, ) -> None: """Test we get the form.""" result = await mock_config.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( + + routes_mock.compute_routes.side_effect = exception + + result = await hass.config_entries.flow.async_configure( result["flow_id"], RECONFIGURE_CONFIG, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - await assert_common_reconfigure_steps(hass, result2) - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], -) -@pytest.mark.usefixtures("invalid_api_key") -async def test_reconfigure_invalid_api_key( - hass: HomeAssistant, mock_config: MockConfigEntry -) -> None: - """Test we get the form.""" - result = await mock_config.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - RECONFIGURE_CONFIG, - ) + assert result["errors"] == {"base": error} - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - await assert_common_reconfigure_steps(hass, result2) + routes_mock.compute_routes.side_effect = None + + await assert_common_reconfigure_steps(hass, result) @pytest.mark.parametrize( ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("transport_error") -async def test_reconfigure_transport_error( - hass: HomeAssistant, mock_config: MockConfigEntry -) -> None: - """Test we get the form.""" - result = await mock_config.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - RECONFIGURE_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - await assert_common_reconfigure_steps(hass, result2) - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], -) -@pytest.mark.usefixtures("timeout") -async def test_reconfigure_timeout( - hass: HomeAssistant, mock_config: MockConfigEntry -) -> None: - """Test we get the form.""" - result = await mock_config.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - RECONFIGURE_CONFIG, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "timeout_connect"} - await assert_common_reconfigure_steps(hass, result2) - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], -) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) -> None: """Test options flow.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -356,7 +198,7 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: ARRIVAL_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -369,7 +211,7 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -380,7 +222,7 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -389,24 +231,14 @@ async def test_options_flow(hass: HomeAssistant, mock_config: MockConfigEntry) - @pytest.mark.parametrize( ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_MODE: "driving", - CONF_UNITS: UNITS_IMPERIAL, - }, - ) - ], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_options_flow_departure_time( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test options flow with departure time.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -419,7 +251,7 @@ async def test_options_flow_departure_time( CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: DEPARTURE_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -432,7 +264,7 @@ async def test_options_flow_departure_time( CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -443,7 +275,7 @@ async def test_options_flow_departure_time( CONF_LANGUAGE: "en", CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -458,7 +290,7 @@ async def test_options_flow_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", }, ), ( @@ -466,19 +298,17 @@ async def test_options_flow_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", }, ), ], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_reset_departure_time( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test resetting departure time.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -492,6 +322,8 @@ async def test_reset_departure_time( }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config.options == { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, @@ -506,7 +338,7 @@ async def test_reset_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", }, ), ( @@ -514,19 +346,17 @@ async def test_reset_departure_time( { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_DEPARTURE_TIME: "test", + CONF_DEPARTURE_TIME: "08:00", }, ), ], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_reset_arrival_time( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test resetting arrival time.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -540,6 +370,8 @@ async def test_reset_arrival_time( }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config.options == { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, @@ -557,7 +389,7 @@ async def test_reset_arrival_time( CONF_AVOID: "tolls", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: ARRIVAL_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", @@ -565,14 +397,12 @@ async def test_reset_arrival_time( ) ], ) -@pytest.mark.usefixtures("validate_config_entry") +@pytest.mark.usefixtures("routes_mock") async def test_reset_options_flow_fields( hass: HomeAssistant, mock_config: MockConfigEntry ) -> None: """Test resetting options flow fields that are not time related to None.""" - result = await hass.config_entries.options.async_init( - mock_config.entry_id, data=None - ) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -583,52 +413,39 @@ async def test_reset_options_flow_fields( CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: ARRIVAL_TIME, - CONF_TIME: "test", + CONF_TIME: "08:00", }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config.options == { CONF_MODE: "driving", CONF_UNITS: UNITS_IMPERIAL, - CONF_ARRIVAL_TIME: "test", + CONF_ARRIVAL_TIME: "08:00", } -@pytest.mark.usefixtures("validate_config_entry", "bypass_setup") -async def test_dupe(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") +async def test_dupe(hass: HomeAssistant, mock_config: MockConfigEntry) -> None: """Test setting up the same entry data twice is OK.""" 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.FORM assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_API_KEY: "test", CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", + CONF_DESTINATION: "49.983862755708444,8.223882827079068", }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_API_KEY: "test", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/google_travel_time/test_init.py b/tests/components/google_travel_time/test_init.py new file mode 100644 index 00000000000..246804d6bbc --- /dev/null +++ b/tests/components/google_travel_time/test_init.py @@ -0,0 +1,82 @@ +"""Tests for Google Maps Travel Time init.""" + +import pytest + +from homeassistant.components.google_travel_time.const import ( + ARRIVAL_TIME, + CONF_TIME, + CONF_TIME_TYPE, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .const import DEFAULT_OPTIONS, MOCK_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("v1", "v2"), + [ + ("08:00", "08:00"), + ("08:00:00", "08:00:00"), + ("1742144400", "17:00"), + ("now", None), + (None, None), + ], +) +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") +async def test_migrate_entry_v1_v2( + hass: HomeAssistant, + v1: str, + v2: str | None, +) -> None: + """Test successful migration of entry data.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data=MOCK_CONFIG, + options={ + **DEFAULT_OPTIONS, + CONF_TIME_TYPE: ARRIVAL_TIME, + CONF_TIME: v1, + }, + ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.version == 2 + assert updated_entry.options[CONF_TIME] == v2 + + +@pytest.mark.usefixtures("routes_mock", "mock_setup_entry") +async def test_migrate_entry_v1_v2_invalid_time( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test successful migration of entry data.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data=MOCK_CONFIG, + options={ + **DEFAULT_OPTIONS, + CONF_TIME_TYPE: ARRIVAL_TIME, + CONF_TIME: "invalid", + }, + ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.version == 2 + assert updated_entry.options[CONF_TIME] is None + assert "Invalid time format found while migrating" in caplog.text diff --git a/tests/components/google_travel_time/test_sensor.py b/tests/components/google_travel_time/test_sensor.py index 9ee6ebbbc7b..58843d8275c 100644 --- a/tests/components/google_travel_time/test_sensor.py +++ b/tests/components/google_travel_time/test_sensor.py @@ -1,97 +1,48 @@ """Test the Google Maps Travel Time sensors.""" -from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock -from googlemaps.exceptions import ApiError, Timeout, TransportError +from freezegun.api import FrozenDateTimeFactory +from google.api_core.exceptions import GoogleAPIError +from google.maps.routing_v2 import Units import pytest from homeassistant.components.google_travel_time.config_flow import default_options from homeassistant.components.google_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, + CONF_TRANSIT_MODE, + CONF_TRANSIT_ROUTING_PREFERENCE, + CONF_UNITS, DOMAIN, - UNITS_IMPERIAL, UNITS_METRIC, ) from homeassistant.components.google_travel_time.sensor import SCAN_INTERVAL +from homeassistant.const import CONF_MODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import ( METRIC_SYSTEM, US_CUSTOMARY_SYSTEM, UnitSystem, ) -from .const import MOCK_CONFIG +from .const import DEFAULT_OPTIONS, MOCK_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.fixture(name="mock_update") -def mock_update_fixture() -> Generator[MagicMock]: - """Mock an update to the sensor.""" - with ( - patch("homeassistant.components.google_travel_time.sensor.Client"), - patch( - "homeassistant.components.google_travel_time.sensor.distance_matrix" - ) as distance_matrix_mock, - ): - distance_matrix_mock.return_value = { - "rows": [ - { - "elements": [ - { - "duration_in_traffic": { - "value": 1620, - "text": "27 mins", - }, - "duration": { - "value": 1560, - "text": "26 mins", - }, - "distance": {"text": "21.3 km"}, - } - ] - } - ] - } - yield distance_matrix_mock - - -@pytest.fixture(name="mock_update_duration") -def mock_update_duration_fixture(mock_update: MagicMock) -> MagicMock: - """Mock an update to the sensor returning no duration_in_traffic.""" - mock_update.return_value = { - "rows": [ - { - "elements": [ - { - "duration": { - "value": 1560, - "text": "26 mins", - }, - "distance": {"text": "21.3 km"}, - } - ] - } - ] - } - return mock_update - - @pytest.fixture(name="mock_update_empty") -def mock_update_empty_fixture(mock_update: MagicMock) -> MagicMock: +def mock_update_empty_fixture(routes_mock: AsyncMock) -> AsyncMock: """Mock an update to the sensor with an empty response.""" - mock_update.return_value = None - return mock_update + routes_mock.compute_routes.return_value = None + return routes_mock @pytest.mark.parametrize( ("data", "options"), - [(MOCK_CONFIG, {})], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) -@pytest.mark.usefixtures("mock_update", "mock_config") +@pytest.mark.usefixtures("routes_mock", "mock_config") async def test_sensor(hass: HomeAssistant) -> None: """Test that sensor works.""" assert hass.states.get("sensor.google_travel_time").state == "27" @@ -114,7 +65,7 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert ( hass.states.get("sensor.google_travel_time").attributes["destination"] - == "location2" + == "49.983862755708444,8.223882827079068" ) assert ( hass.states.get("sensor.google_travel_time").attributes["unit_of_measurement"] @@ -122,24 +73,14 @@ async def test_sensor(hass: HomeAssistant) -> None: ) -@pytest.mark.parametrize( - ("data", "options"), - [(MOCK_CONFIG, {})], -) -@pytest.mark.usefixtures("mock_update_duration", "mock_config") -async def test_sensor_duration(hass: HomeAssistant) -> None: - """Test that sensor works with no duration_in_traffic in response.""" - assert hass.states.get("sensor.google_travel_time").state == "26" - - -@pytest.mark.parametrize( - ("data", "options"), - [(MOCK_CONFIG, {})], -) @pytest.mark.usefixtures("mock_update_empty", "mock_config") +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) async def test_sensor_empty_response(hass: HomeAssistant) -> None: """Test that sensor works for an empty response.""" - assert hass.states.get("sensor.google_travel_time").state == "unknown" + assert hass.states.get("sensor.google_travel_time").state == STATE_UNKNOWN @pytest.mark.parametrize( @@ -148,12 +89,13 @@ async def test_sensor_empty_response(hass: HomeAssistant) -> None: ( MOCK_CONFIG, { + **DEFAULT_OPTIONS, CONF_DEPARTURE_TIME: "10:00", }, ), ], ) -@pytest.mark.usefixtures("mock_update", "mock_config") +@pytest.mark.usefixtures("routes_mock", "mock_config") async def test_sensor_departure_time(hass: HomeAssistant) -> None: """Test that sensor works for departure time.""" assert hass.states.get("sensor.google_travel_time").state == "27" @@ -165,60 +107,31 @@ async def test_sensor_departure_time(hass: HomeAssistant) -> None: ( MOCK_CONFIG, { - CONF_DEPARTURE_TIME: "custom_timestamp", - }, - ), - ], -) -@pytest.mark.usefixtures("mock_update", "mock_config") -async def test_sensor_departure_time_custom_timestamp(hass: HomeAssistant) -> None: - """Test that sensor works for departure time with a custom timestamp.""" - assert hass.states.get("sensor.google_travel_time").state == "27" - - -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { + CONF_MODE: "transit", + CONF_UNITS: UNITS_METRIC, + CONF_TRANSIT_ROUTING_PREFERENCE: "fewer_transfers", + CONF_TRANSIT_MODE: "bus", CONF_ARRIVAL_TIME: "10:00", }, ), ], ) -@pytest.mark.usefixtures("mock_update", "mock_config") +@pytest.mark.usefixtures("routes_mock", "mock_config") async def test_sensor_arrival_time(hass: HomeAssistant) -> None: """Test that sensor works for arrival time.""" assert hass.states.get("sensor.google_travel_time").state == "27" -@pytest.mark.parametrize( - ("data", "options"), - [ - ( - MOCK_CONFIG, - { - CONF_ARRIVAL_TIME: "custom_timestamp", - }, - ), - ], -) -@pytest.mark.usefixtures("mock_update", "mock_config") -async def test_sensor_arrival_time_custom_timestamp(hass: HomeAssistant) -> None: - """Test that sensor works for arrival time with a custom timestamp.""" - assert hass.states.get("sensor.google_travel_time").state == "27" - - @pytest.mark.parametrize( ("unit_system", "expected_unit_option"), [ - (METRIC_SYSTEM, UNITS_METRIC), - (US_CUSTOMARY_SYSTEM, UNITS_IMPERIAL), + (METRIC_SYSTEM, Units.METRIC), + (US_CUSTOMARY_SYSTEM, Units.IMPERIAL), ], ) async def test_sensor_unit_system( hass: HomeAssistant, + routes_mock: AsyncMock, unit_system: UnitSystem, expected_unit_option: str, ) -> None: @@ -232,36 +145,28 @@ async def test_sensor_unit_system( entry_id="test", ) config_entry.add_to_hass(hass) - with ( - patch("homeassistant.components.google_travel_time.sensor.Client"), - patch( - "homeassistant.components.google_travel_time.sensor.distance_matrix" - ) as distance_matrix_mock, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - distance_matrix_mock.assert_called_once() - assert distance_matrix_mock.call_args.kwargs["units"] == expected_unit_option + routes_mock.compute_routes.assert_called_once() + assert routes_mock.compute_routes.call_args.args[0].units == expected_unit_option -@pytest.mark.parametrize( - ("exception"), - [(ApiError), (TransportError), (Timeout)], -) @pytest.mark.parametrize( ("data", "options"), - [(MOCK_CONFIG, {})], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) async def test_sensor_exception( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mock_update: MagicMock, - mock_config: MagicMock, - exception: Exception, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test that exception gets caught.""" - mock_update.side_effect = exception("Errormessage") - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + routes_mock.compute_routes.side_effect = GoogleAPIError("Errormessage") + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() + assert hass.states.get("sensor.google_travel_time").state == STATE_UNKNOWN assert "Error getting travel time" in caplog.text diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index c5dde6a9b9e..40748c0598e 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -1,13 +1,24 @@ """Test Govee light local.""" from errno import EADDRINUSE, ENETDOWN -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch from govee_local_api import GoveeDevice +import pytest from homeassistant.components.govee_light_local.const import DOMAIN -from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + DOMAIN as LIGHT_DOMAIN, + ColorMode, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES @@ -197,8 +208,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id}, blocking=True, ) @@ -211,8 +222,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N # Turn off await hass.services.async_call( - "light", - "turn_off", + LIGHT_DOMAIN, + SERVICE_TURN_OFF, {"entity_id": light.entity_id}, blocking=True, ) @@ -224,6 +235,77 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) +@pytest.mark.parametrize( + ("attribute", "value", "mock_call", "mock_call_args", "mock_call_kwargs"), + [ + ( + ATTR_RGB_COLOR, + [100, 255, 50], + "set_color", + [], + {"temperature": None, "rgb": (100, 255, 50)}, + ), + ( + ATTR_COLOR_TEMP_KELVIN, + 4400, + "set_color", + [], + {"temperature": 4400, "rgb": None}, + ), + (ATTR_EFFECT, "sunrise", "set_scene", ["sunrise"], {}), + ], +) +async def test_turn_on_call_order( + hass: HomeAssistant, + mock_govee_api: MagicMock, + attribute: str, + value: str | int | list[int], + mock_call: str, + mock_call_args: list[str], + mock_call_kwargs: dict[str, any], +) -> None: + """Test that turn_on is called after set_brightness/set_color/set_preset.""" + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, + ) + ] + + 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 len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS_PCT: 50, attribute: value}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_govee_api.assert_has_calls( + [ + call.set_brightness(mock_govee_api.devices[0], 50), + getattr(call, mock_call)( + mock_govee_api.devices[0], *mock_call_args, **mock_call_kwargs + ), + call.turn_on_off(mock_govee_api.devices[0], True), + ] + ) + + async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: """Test changing brightness.""" mock_govee_api.devices = [ @@ -249,8 +331,8 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "brightness_pct": 50}, blocking=True, ) @@ -260,12 +342,12 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) assert light is not None assert light.state == "on" mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) - assert light.attributes["brightness"] == 127 + assert light.attributes[ATTR_BRIGHTNESS] == 127 await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -273,13 +355,13 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -287,7 +369,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) @@ -316,9 +398,9 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: [100, 255, 50]}, blocking=True, ) await hass.async_block_till_done() @@ -326,7 +408,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == (100, 255, 50) + assert light.attributes[ATTR_RGB_COLOR] == (100, 255, 50) assert light.attributes["color_mode"] == ColorMode.RGB mock_govee_api.set_color.assert_awaited_with( @@ -334,8 +416,8 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No ) await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "kelvin": 4400}, blocking=True, ) @@ -378,9 +460,9 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: assert light.state == "off" await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -388,7 +470,7 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) @@ -422,16 +504,16 @@ async def test_scene_restore_rgb( # Set initial color await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": initial_color}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -439,15 +521,15 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -455,14 +537,14 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Deactivate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() @@ -470,9 +552,9 @@ async def test_scene_restore_rgb( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_EFFECT] is None + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 async def test_scene_restore_temperature( @@ -505,8 +587,8 @@ async def test_scene_restore_temperature( # Set initial color await hass.services.async_call( - "light", - "turn_on", + LIGHT_DOMAIN, + SERVICE_TURN_ON, {"entity_id": light.entity_id, "color_temp_kelvin": initial_color}, blocking=True, ) @@ -520,9 +602,9 @@ async def test_scene_restore_temperature( # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "sunrise"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"}, blocking=True, ) await hass.async_block_till_done() @@ -530,14 +612,14 @@ async def test_scene_restore_temperature( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] == "sunrise" + assert light.attributes[ATTR_EFFECT] == "sunrise" mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise") # Deactivate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() @@ -545,7 +627,7 @@ async def test_scene_restore_temperature( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None + assert light.attributes[ATTR_EFFECT] is None assert light.attributes["color_temp_kelvin"] == initial_color @@ -577,16 +659,16 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non # Set initial color await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": initial_color}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255}, blocking=True, ) await hass.async_block_till_done() @@ -594,21 +676,20 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["rgb_color"] == initial_color - assert light.attributes["brightness"] == 255 + assert light.attributes[ATTR_RGB_COLOR] == initial_color + assert light.attributes[ATTR_BRIGHTNESS] == 255 mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Activate scene await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "effect": "none"}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": light.entity_id, ATTR_EFFECT: "none"}, blocking=True, ) await hass.async_block_till_done() - light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["effect"] is None + assert light.attributes[ATTR_EFFECT] is None mock_govee_api.set_scene.assert_not_called() diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index 836641cb2ab..c3fa3ae24c7 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -16,10 +16,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Quiet', + 'friendly_name': 'fake-device-1 Quiet mode', }), 'context': , - 'entity_id': 'switch.fake_device_1_quiet', + 'entity_id': 'switch.fake_device_1_quiet_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -40,10 +40,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 XFan', + 'friendly_name': 'fake-device-1 Xtra fan', }), 'context': , - 'entity_id': 'switch.fake_device_1_xfan', + 'entity_id': 'switch.fake_device_1_xtra_fan', 'last_changed': , 'last_reported': , 'last_updated': , @@ -109,7 +109,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.fake_device_1_quiet', + 'entity_id': 'switch.fake_device_1_quiet_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -121,7 +121,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Quiet', + 'original_name': 'Quiet mode', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, @@ -173,7 +173,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.fake_device_1_xfan', + 'entity_id': 'switch.fake_device_1_xtra_fan', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -185,7 +185,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'XFan', + 'original_name': 'Xtra fan', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index acfa1ba43f5..1c67da1f675 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -6,11 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode -from homeassistant.components.gree.const import ( - COORDINATORS, - DOMAIN as GREE, - UPDATE_INTERVAL, -) +from homeassistant.components.gree.const import UPDATE_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -42,13 +38,13 @@ async def test_discovery_after_setup( discovery.return_value.mock_devices = [mock_device_1, mock_device_2] device.side_effect = [mock_device_1, mock_device_2] - await async_setup_gree(hass) + entry = await async_setup_gree(hass) await hass.async_block_till_done() assert discovery.return_value.scan_count == 1 assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 2 - device_infos = [x.device.device_info for x in hass.data[GREE][COORDINATORS]] + device_infos = [x.device.device_info for x in entry.runtime_data.coordinators] assert device_infos[0].ip == "1.1.1.1" assert device_infos[1].ip == "2.2.2.2" @@ -70,7 +66,7 @@ async def test_discovery_after_setup( assert discovery.return_value.scan_count == 2 assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 2 - device_infos = [x.device.device_info for x in hass.data[GREE][COORDINATORS]] + device_infos = [x.device.device_info for x in entry.runtime_data.coordinators] assert device_infos[0].ip == "1.1.1.2" assert device_infos[1].ip == "2.2.2.1" diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 0cb187f5a60..e6bfc43252f 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -52,6 +52,7 @@ from homeassistant.components.gree.const import ( DISCOVERY_SCAN_INTERVAL, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, + MAX_EXPECTED_RESPONSE_TIME_INTERVAL, UPDATE_INTERVAL, ) from homeassistant.const import ( @@ -66,6 +67,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .common import async_setup_gree, build_device_mock @@ -346,7 +352,7 @@ async def test_unresponsive_device( await async_setup_gree(hass) async def run_update(): - freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) + freezer.tick(timedelta(seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -410,19 +416,19 @@ async def test_send_power_off_device_timeout( @pytest.mark.parametrize( ("units", "temperature"), - [(UnitOfTemperature.CELSIUS, 26), (UnitOfTemperature.FAHRENHEIT, 73)], + [(METRIC_SYSTEM, 26), (US_CUSTOMARY_SYSTEM, 73)], ) async def test_send_target_temperature( - hass: HomeAssistant, discovery, device, units, temperature + hass: HomeAssistant, discovery, device, units: UnitSystem, temperature ) -> None: """Test for sending target temperature command to the device.""" - hass.config.units.temperature_unit = units + hass.config.units = units device().power = True device().mode = HVAC_MODES_REVERSE.get(HVACMode.AUTO) fake_device = device() - if units == UnitOfTemperature.FAHRENHEIT: + if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: fake_device.temperature_units = 1 await async_setup_gree(hass) @@ -434,7 +440,7 @@ async def test_send_target_temperature( ENTITY_ID, "off", { - ATTR_UNIT_OF_MEASUREMENT: units, + ATTR_UNIT_OF_MEASUREMENT: units.temperature_unit, }, ) @@ -450,10 +456,6 @@ async def test_send_target_temperature( assert state.attributes.get(ATTR_TEMPERATURE) == temperature assert state.state == HVAC_MODES.get(fake_device.mode) - # Reset config temperature_unit back to CELSIUS, required for - # additional tests outside this component. - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - @pytest.mark.parametrize( ("temperature", "hvac_mode"), @@ -492,17 +494,17 @@ async def test_send_target_temperature_with_hvac_mode( @pytest.mark.parametrize( ("units", "temperature"), [ - (UnitOfTemperature.CELSIUS, 25), - (UnitOfTemperature.FAHRENHEIT, 73), - (UnitOfTemperature.FAHRENHEIT, 74), + (METRIC_SYSTEM, 25), + (US_CUSTOMARY_SYSTEM, 73), + (US_CUSTOMARY_SYSTEM, 74), ], ) async def test_send_target_temperature_device_timeout( - hass: HomeAssistant, discovery, device, units, temperature + hass: HomeAssistant, discovery, device, units: UnitSystem, temperature ) -> None: """Test for sending target temperature command to the device with a device timeout.""" - hass.config.units.temperature_unit = units - if units == UnitOfTemperature.FAHRENHEIT: + hass.config.units = units + if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: device().temperature_units = 1 device().push_state_update.side_effect = DeviceTimeoutError @@ -519,24 +521,21 @@ async def test_send_target_temperature_device_timeout( assert state is not None assert state.attributes.get(ATTR_TEMPERATURE) == temperature - # Reset config temperature_unit back to CELSIUS, required for additional tests outside this component. - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - @pytest.mark.parametrize( ("units", "temperature"), [ - (UnitOfTemperature.CELSIUS, 25), - (UnitOfTemperature.FAHRENHEIT, 73), - (UnitOfTemperature.FAHRENHEIT, 74), + (METRIC_SYSTEM, 25), + (US_CUSTOMARY_SYSTEM, 73), + (US_CUSTOMARY_SYSTEM, 74), ], ) async def test_update_target_temperature( - hass: HomeAssistant, discovery, device, units, temperature + hass: HomeAssistant, discovery, device, units: UnitSystem, temperature ) -> None: """Test for updating target temperature from the device.""" - hass.config.units.temperature_unit = units - if units == UnitOfTemperature.FAHRENHEIT: + hass.config.units = units + if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: device().temperature_units = 1 device().target_temperature = temperature @@ -553,9 +552,6 @@ async def test_update_target_temperature( assert state is not None assert state.attributes.get(ATTR_TEMPERATURE) == temperature - # Reset config temperature_unit back to CELSIUS, required for additional tests outside this component. - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - @pytest.mark.parametrize( "preset", [PRESET_AWAY, PRESET_ECO, PRESET_SLEEP, PRESET_BOOST, PRESET_NONE] diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index e9491796bdf..331b6dfa4a6 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -22,11 +22,11 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -ENTITY_ID_LIGHT_PANEL = f"{SWITCH_DOMAIN}.fake_device_1_panel_light" +ENTITY_ID_PANEL_LIGHT = f"{SWITCH_DOMAIN}.fake_device_1_panel_light" ENTITY_ID_HEALTH_MODE = f"{SWITCH_DOMAIN}.fake_device_1_health_mode" -ENTITY_ID_QUIET = f"{SWITCH_DOMAIN}.fake_device_1_quiet" +ENTITY_ID_QUIET_MODE = f"{SWITCH_DOMAIN}.fake_device_1_quiet_mode" ENTITY_ID_FRESH_AIR = f"{SWITCH_DOMAIN}.fake_device_1_fresh_air" -ENTITY_ID_XFAN = f"{SWITCH_DOMAIN}.fake_device_1_xfan" +ENTITY_ID_XTRA_FAN = f"{SWITCH_DOMAIN}.fake_device_1_xtra_fan" async def async_setup_gree(hass: HomeAssistant) -> MockConfigEntry: @@ -54,11 +54,11 @@ async def test_registry_settings( @pytest.mark.parametrize( "entity", [ - ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_PANEL_LIGHT, ENTITY_ID_HEALTH_MODE, - ENTITY_ID_QUIET, + ENTITY_ID_QUIET_MODE, ENTITY_ID_FRESH_AIR, - ENTITY_ID_XFAN, + ENTITY_ID_XTRA_FAN, ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -81,11 +81,11 @@ async def test_send_switch_on(hass: HomeAssistant, entity: str) -> None: @pytest.mark.parametrize( "entity", [ - ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_PANEL_LIGHT, ENTITY_ID_HEALTH_MODE, - ENTITY_ID_QUIET, + ENTITY_ID_QUIET_MODE, ENTITY_ID_FRESH_AIR, - ENTITY_ID_XFAN, + ENTITY_ID_XTRA_FAN, ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -112,11 +112,11 @@ async def test_send_switch_on_device_timeout( @pytest.mark.parametrize( "entity", [ - ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_PANEL_LIGHT, ENTITY_ID_HEALTH_MODE, - ENTITY_ID_QUIET, + ENTITY_ID_QUIET_MODE, ENTITY_ID_FRESH_AIR, - ENTITY_ID_XFAN, + ENTITY_ID_XTRA_FAN, ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -139,11 +139,11 @@ async def test_send_switch_off(hass: HomeAssistant, entity: str) -> None: @pytest.mark.parametrize( "entity", [ - ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_PANEL_LIGHT, ENTITY_ID_HEALTH_MODE, - ENTITY_ID_QUIET, + ENTITY_ID_QUIET_MODE, ENTITY_ID_FRESH_AIR, - ENTITY_ID_XFAN, + ENTITY_ID_XTRA_FAN, ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 461df19ebf8..30adae2fd2a 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -12,7 +12,7 @@ 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 +from tests.common import MockConfigEntry, get_schema_suggested_value from tests.typing import WebSocketGenerator @@ -201,17 +201,6 @@ async def test_config_flow_hides_members( assert entity_registry.async_get(f"{group_type}.three").hidden_by == hidden_by -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize( ("group_type", "member_state", "extra_options", "options_options"), [ @@ -269,7 +258,9 @@ async def test_options( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == group_type - assert get_suggested(result["data_schema"].schema, "entities") == members1 + assert ( + get_schema_suggested_value(result["data_schema"].schema, "entities") == members1 + ) assert "name" not in result["data_schema"].schema assert result["data_schema"].schema["entities"].config["exclude_entities"] == [ f"{group_type}.bed_room" @@ -316,8 +307,8 @@ async def test_options( assert result["type"] is FlowResultType.FORM assert result["step_id"] == group_type - assert get_suggested(result["data_schema"].schema, "entities") is None - assert get_suggested(result["data_schema"].schema, "name") is None + assert get_schema_suggested_value(result["data_schema"].schema, "entities") is None + assert get_schema_suggested_value(result["data_schema"].schema, "name") is None @pytest.mark.parametrize( diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index 4487d0b6ac6..8851b6589f6 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Guardian diagnostics.""" from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.guardian import DOMAIN, GuardianData +from homeassistant.components.guardian import GuardianData from homeassistant.core import HomeAssistant from tests.common import ANY, MockConfigEntry @@ -16,7 +16,7 @@ async def test_entry_diagnostics( setup_guardian: None, # relies on config_entry fixture ) -> None: """Test config entry diagnostics.""" - data: GuardianData = hass.data[DOMAIN][config_entry.entry_id] + data: GuardianData = config_entry.runtime_data # Simulate the pairing of a paired sensor: await data.paired_sensor_manager.async_pair_sensor("AABBCCDDEEFF") diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 45c33a9ebb6..4ef14699e0b 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -1,7 +1,8 @@ """Tests for the habitica component.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import UUID from habiticalib import ( BadRequestError, @@ -151,6 +152,9 @@ async def mock_habiticalib() -> Generator[AsyncMock]: client.create_tag.return_value = HabiticaTagResponse.from_json( load_fixture("create_tag.json", DOMAIN) ) + client.create_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) client.habitipy.return_value = { "tasks": { "user": { @@ -173,3 +177,13 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.habitica.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_uuid4() -> Generator[MagicMock]: + """Mock uuid4.""" + with patch( + "homeassistant.components.habitica.services.uuid4", autospec=True + ) as mock_uuid4: + mock_uuid4.return_value = UUID("12345678-1234-5678-1234-567812345678") + yield mock_uuid4 diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 378652138bc..ecbe0a1f86d 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -425,7 +425,18 @@ "date": "2024-09-27T22:17:00.000Z", "completed": false, "collapseChecklist": false, - "checklist": [], + "checklist": [ + { + "completed": false, + "id": "fccc26f2-1e2b-4bf8-9dd0-a405be261036", + "text": "Checklist-item1" + }, + { + "completed": true, + "id": "5a897af4-ea94-456a-a2bd-f336bcd79509", + "text": "Checklist-item2" + } + ], "type": "todo", "text": "Buch zu Ende lesen", "notes": "Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.", @@ -594,7 +605,18 @@ "startDate": "2024-09-20T23:00:00.000Z", "daysOfMonth": [], "weeksOfMonth": [3], - "checklist": [], + "checklist": [ + { + "completed": false, + "id": "a2a6702d-58e1-46c2-a3ce-422d525cc0b6", + "text": "Checklist-item1" + }, + { + "completed": true, + "id": "9f64e1cd-b0ab-4577-8344-c7a5e1827997", + "text": "Checklist-item2" + } + ], "reminders": [], "createdAt": "2024-10-10T15:57:14.304Z", "updatedAt": "2024-11-27T23:47:29.986Z", @@ -602,6 +624,49 @@ "isDue": false, "id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef" }, + { + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": true + }, + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "369afeed-61e3-4bf7-9747-66e05807134c", + "frequency": "monthly", + "everyX": 1, + "streak": 1, + "nextDue": ["2024-12-14T23:00:00.000Z", "2025-01-18T23:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Monatliche Finanzübersicht erstellen", + "notes": "Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.", + "tags": [], + "value": -0.9215181434950852, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "startDate": "2024-04-04T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [0], + "checklist": [], + "reminders": [], + "createdAt": "2024-04-04T22:00:00.000Z", + "updatedAt": "2024-04-04T22:00:00.000Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "369afeed-61e3-4bf7-9747-66e05807134c" + }, { "repeat": { "m": false, diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 58eca2837b6..d2f0091b6dd 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -66,7 +66,8 @@ "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", "f2c85972-1a19-4426-bc6d-ce3337b9d99f", "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", - "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + "6e53f1f5-a315-4edd-984d-8d762e4a08ef", + "369afeed-61e3-4bf7-9747-66e05807134c" ], "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] }, diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index 2948f31f1cf..c7f12684efe 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -87,6 +87,20 @@ 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), + dict({ + 'description': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'end': dict({ + 'date': '2024-09-23', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=MONTHLY;BYSETPOS=4;BYDAY=SU', + 'start': dict({ + 'date': '2024-09-22', + }), + 'summary': 'Arbeite an einem kreativen Projekt', + 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', + }), dict({ 'description': 'Klicke um Änderungen zu machen!', 'end': dict({ @@ -563,6 +577,20 @@ 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), + dict({ + 'description': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'end': dict({ + 'date': '2024-10-07', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=MONTHLY;BYSETPOS=1;BYDAY=SU', + 'start': dict({ + 'date': '2024-10-06', + }), + 'summary': 'Monatliche Finanzübersicht erstellen', + 'uid': '369afeed-61e3-4bf7-9747-66e05807134c', + }), dict({ 'description': 'Klicke um Änderungen zu machen!', 'end': dict({ diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index 79c9e3eab66..9fbb6a43e94 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -736,6 +736,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -1106,6 +1116,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -1173,6 +1193,81 @@ ]), 'yesterDaily': True, }), + dict({ + '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-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + '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': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', @@ -1834,6 +1929,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -2978,6 +3083,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -3348,6 +3463,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -3415,6 +3540,81 @@ ]), 'yesterDaily': True, }), + dict({ + '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-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + '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': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', @@ -4481,6 +4681,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -4548,6 +4758,81 @@ ]), 'yesterDaily': True, }), + dict({ + '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-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + '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': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', @@ -5062,6 +5347,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -5129,6 +5424,81 @@ ]), 'yesterDaily': True, }), + dict({ + '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-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + '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': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', @@ -5615,6 +5985,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -6137,6 +6517,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr index 88204d53ded..fef9404a0f0 100644 --- a/tests/components/habitica/snapshots/test_todo.ambr +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -49,6 +49,13 @@ 'summary': 'Arbeite an einem kreativen Projekt', 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', }), + dict({ + 'description': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'due': '2024-12-14', + 'status': 'needs_action', + 'summary': 'Monatliche Finanzübersicht erstellen', + 'uid': '369afeed-61e3-4bf7-9747-66e05807134c', + }), dict({ 'description': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', 'status': 'needs_action', @@ -151,7 +158,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '4', + 'state': '5', }) # --- # name: test_todos[todo.test_user_to_do_s-entry] diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index 07678b031bc..5ec998ec82e 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -76,8 +76,9 @@ async def test_form_login(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N assert "login" in result["menu_options"] assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "login"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "login"}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -123,8 +124,9 @@ async def test_form_login_errors( assert result["type"] is FlowResultType.MENU assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "login"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "login"}, ) habitica.login.side_effect = raise_error @@ -156,7 +158,7 @@ async def test_form_login_errors( @pytest.mark.usefixtures("habitica") -async def test_form__already_configured( +async def test_form_already_configured( hass: HomeAssistant, config_entry: MockConfigEntry, ) -> None: @@ -171,13 +173,14 @@ async def test_form__already_configured( 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"], + {"next_step_id": "login"}, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=MOCK_DATA_ADVANCED_STEP, + user_input=MOCK_DATA_LOGIN_STEP, ) assert result["type"] is FlowResultType.ABORT @@ -196,19 +199,14 @@ async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) - assert "advanced" in result["menu_options"] 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"], + {"next_step_id": "advanced"}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "advanced" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "advanced"} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_DATA_ADVANCED_STEP, @@ -249,8 +247,9 @@ async def test_form_advanced_errors( 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"], + {"next_step_id": "advanced"}, ) habitica.get_user.side_effect = raise_error @@ -298,8 +297,9 @@ async def test_form_advanced_already_configured( 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"], + {"next_step_id": "advanced"}, ) result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index e953ec254d6..e904ccc890d 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -8,17 +8,9 @@ from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.habitica.const import ( - ATTR_ARGS, - ATTR_DATA, - ATTR_PATH, - DOMAIN, - EVENT_API_CALL_SUCCESS, - SERVICE_API_CALL, -) +from homeassistant.components.habitica.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_NAME -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import HomeAssistant from .conftest import ( ERROR_BAD_REQUEST, @@ -27,13 +19,7 @@ from .conftest import ( ERROR_TOO_MANY_REQUESTS, ) -from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed - - -@pytest.fixture -def capture_api_call_success(hass: HomeAssistant) -> list[Event]: - """Capture api_call events.""" - return async_capture_events(hass, EVENT_API_CALL_SUCCESS) +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("habitica") @@ -53,37 +39,6 @@ async def test_entry_setup_unload( assert config_entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.usefixtures("habitica") -async def test_service_call( - hass: HomeAssistant, - config_entry: MockConfigEntry, - capture_api_call_success: list[Event], -) -> None: - """Test integration setup, service call and unload.""" - 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 - - assert len(capture_api_call_success) == 0 - - TEST_SERVICE_DATA = { - ATTR_NAME: "test-user", - ATTR_PATH: ["tasks", "user", "post"], - ATTR_ARGS: {"text": "Use API from Home Assistant", "type": "todo"}, - } - await hass.services.async_call( - DOMAIN, SERVICE_API_CALL, TEST_SERVICE_DATA, blocking=True - ) - - assert len(capture_api_call_success) == 1 - captured_data = capture_api_call_success[0].data - captured_data[ATTR_ARGS] = captured_data[ATTR_DATA] - del captured_data[ATTR_DATA] - assert captured_data == TEST_SERVICE_DATA - - @pytest.mark.parametrize( ("exception"), [ERROR_BAD_REQUEST, ERROR_TOO_MANY_REQUESTS, ClientError], diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index a4442016784..774593fa0f6 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -1,35 +1,69 @@ """Test Habitica actions.""" from collections.abc import Generator +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, HabiticaTaskResponse, Skill, Task +from freezegun.api import freeze_time +from habiticalib import ( + Checklist, + Direction, + Frequency, + HabiticaTaskResponse, + Reminders, + Repeat, + Skill, + Task, + TaskPriority, + TaskType, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.habitica.const import ( + ATTR_ADD_CHECKLIST_ITEM, ATTR_ALIAS, + ATTR_CLEAR_DATE, + ATTR_CLEAR_REMINDER, ATTR_CONFIG_ENTRY, ATTR_COST, + ATTR_COUNTER_DOWN, + ATTR_COUNTER_UP, ATTR_DIRECTION, + ATTR_FREQUENCY, + ATTR_INTERVAL, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, ATTR_PRIORITY, + ATTR_REMINDER, + ATTR_REMOVE_CHECKLIST_ITEM, + ATTR_REMOVE_REMINDER, ATTR_REMOVE_TAG, + ATTR_REPEAT, + ATTR_REPEAT_MONTHLY, + ATTR_SCORE_CHECKLIST_ITEM, ATTR_SKILL, + ATTR_START_DATE, + ATTR_STREAK, ATTR_TAG, ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UNSCORE_CHECKLIST_ITEM, + ATTR_UP_DOWN, DOMAIN, SERVICE_ABORT_QUEST, SERVICE_ACCEPT_QUEST, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_DAILY, + SERVICE_CREATE_HABIT, + SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -37,10 +71,14 @@ from homeassistant.components.habitica.const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_DAILY, + SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, ) from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DATE, ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -917,6 +955,15 @@ async def test_get_tasks( ), ], ) +@pytest.mark.parametrize( + ("service", "task_id"), + [ + (SERVICE_UPDATE_REWARD, "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), + (SERVICE_UPDATE_HABIT, "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"), + (SERVICE_UPDATE_TODO, "88de7cd9-af2b-49ce-9afd-bf941d87336b"), + (SERVICE_UPDATE_DAILY, "6e53f1f5-a315-4edd-984d-8d762e4a08ef"), + ], +) @pytest.mark.usefixtures("habitica") async def test_update_task_exceptions( hass: HomeAssistant, @@ -925,15 +972,16 @@ async def test_update_task_exceptions( exception: Exception, expected_exception: Exception, exception_msg: str, + service: str, + task_id: str, ) -> None: """Test Habitica task action exceptions.""" - task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" habitica.update_task.side_effect = exception with pytest.raises(expected_exception, match=exception_msg): await hass.services.async_call( DOMAIN, - SERVICE_UPDATE_REWARD, + service, service_data={ ATTR_CONFIG_ENTRY: config_entry.entry_id, ATTR_TASK: task_id, @@ -943,6 +991,61 @@ async def test_update_task_exceptions( ) +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +@pytest.mark.parametrize( + "service", + [ + SERVICE_CREATE_DAILY, + SERVICE_CREATE_HABIT, + SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, + ], +) +@pytest.mark.usefixtures("habitica") +async def test_create_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, + service: str, +) -> None: + """Test Habitica task create action exceptions.""" + + habitica.create_task.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + service, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_NAME: "TITLE", + }, + return_response=True, + blocking=True, + ) + + @pytest.mark.usefixtures("habitica") async def test_task_not_found( hass: HomeAssistant, @@ -1024,6 +1127,940 @@ async def test_update_reward( habitica.update_task.assert_awaited_with(UUID(task_id), call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + ATTR_COST: 100, + }, + Task(type=TaskType.REWARD, text="TITLE", value=100), + ), + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.REWARD, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.REWARD, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.REWARD, text="TITLE", alias="ALIAS"), + ), + ], +) +async def test_create_reward( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create_reward action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_UP_DOWN: [""], + }, + Task(up=False, down=False), + ), + ( + { + ATTR_UP_DOWN: ["up"], + }, + Task(up=True, down=False), + ), + ( + { + ATTR_UP_DOWN: ["down"], + }, + Task(up=False, down=True), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_FREQUENCY: "daily", + }, + Task(frequency=Frequency.DAILY), + ), + ( + { + ATTR_COUNTER_UP: 1, + ATTR_COUNTER_DOWN: 2, + }, + Task(counterUp=1, counterDown=2), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +async def test_update_habit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica habit action.""" + task_id = "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_HABIT, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.HABIT, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.HABIT, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_UP_DOWN: [""], + }, + Task(type=TaskType.HABIT, text="TITLE", up=False, down=False), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_UP_DOWN: ["up"], + }, + Task(type=TaskType.HABIT, text="TITLE", up=True, down=False), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_UP_DOWN: ["down"], + }, + Task(type=TaskType.HABIT, text="TITLE", up=False, down=True), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_PRIORITY: "trivial", + }, + Task(type=TaskType.HABIT, text="TITLE", priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "daily", + }, + Task(type=TaskType.HABIT, text="TITLE", frequency=Frequency.DAILY), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.HABIT, text="TITLE", alias="ALIAS"), + ), + ], +) +async def test_create_habit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create_habit action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_HABIT, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("fccc26f2-1e2b-4bf8-9dd0-a405be261036"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=True, + ), + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_REMOVE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_SCORE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("fccc26f2-1e2b-4bf8-9dd0-a405be261036"), + text="Checklist-item1", + completed=True, + ), + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_UNSCORE_CHECKLIST_ITEM: "Checklist-item2", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("fccc26f2-1e2b-4bf8-9dd0-a405be261036"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_DATE: "2025-03-05", + }, + Task(date=datetime(2025, 3, 5)), + ), + ( + { + ATTR_CLEAR_DATE: True, + }, + Task(date=None), + ), + ( + { + ATTR_REMINDER: ["2025-02-25T00:00"], + }, + Task( + { + "reminders": [ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 0, 0), + startDate=None, + ) + ] + } + ), + ), + ( + { + ATTR_REMOVE_REMINDER: ["2025-02-25T00:00"], + }, + Task({"reminders": []}), + ), + ( + { + ATTR_CLEAR_REMINDER: True, + }, + Task({"reminders": []}), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +async def test_update_todo( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica update todo action.""" + task_id = "88de7cd9-af2b-49ce-9afd-bf941d87336b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_TODO, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.TODO, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.TODO, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + type=TaskType.TODO, + text="TITLE", + checklist=[ + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_PRIORITY: "trivial", + }, + Task(type=TaskType.TODO, text="TITLE", priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_DATE: "2025-03-05", + }, + Task(type=TaskType.TODO, text="TITLE", date=datetime(2025, 3, 5)), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_REMINDER: ["2025-02-25T00:00"], + }, + Task( + type=TaskType.TODO, + text="TITLE", + reminders=[ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 0, 0), + startDate=None, + ) + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.TODO, text="TITLE", alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +async def test_create_todo( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create todo action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_TODO, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("a2a6702d-58e1-46c2-a3ce-422d525cc0b6"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=True, + ), + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_REMOVE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_SCORE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("a2a6702d-58e1-46c2-a3ce-422d525cc0b6"), + text="Checklist-item1", + completed=True, + ), + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_UNSCORE_CHECKLIST_ITEM: "Checklist-item2", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("a2a6702d-58e1-46c2-a3ce-422d525cc0b6"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_START_DATE: "2025-03-05", + }, + Task(startDate=datetime(2025, 3, 5)), + ), + ( + { + ATTR_FREQUENCY: "weekly", + }, + Task(frequency=Frequency.WEEKLY), + ), + ( + { + ATTR_INTERVAL: 5, + }, + Task(everyX=5), + ), + ( + { + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT: ["m", "t", "w", "th"], + }, + Task( + frequency=Frequency.WEEKLY, + repeat=Repeat(m=True, t=True, w=True, th=True), + ), + ), + ( + { + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_month", + }, + Task(frequency=Frequency.MONTHLY, daysOfMonth=[20], weeksOfMonth=[]), + ), + ( + { + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_week", + }, + Task( + frequency=Frequency.MONTHLY, + daysOfMonth=[], + weeksOfMonth=[2], + repeat=Repeat( + m=False, t=False, w=False, th=False, f=True, s=False, su=False + ), + ), + ), + ( + { + ATTR_REMINDER: ["10:00"], + }, + Task( + { + "reminders": [ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 10, 0, tzinfo=UTC), + startDate=None, + ) + ] + } + ), + ), + ( + { + ATTR_REMOVE_REMINDER: ["10:00"], + }, + Task({"reminders": []}), + ), + ( + { + ATTR_CLEAR_REMINDER: True, + }, + Task({"reminders": []}), + ), + ( + { + ATTR_STREAK: 10, + }, + Task(streak=10), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +@freeze_time("2025-02-25T22:00:00.000Z") +async def test_update_daily( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica update daily action.""" + task_id = "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_DAILY, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.DAILY, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.DAILY, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + type=TaskType.DAILY, + text="TITLE", + checklist=[ + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_PRIORITY: "trivial", + }, + Task(type=TaskType.DAILY, text="TITLE", priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_START_DATE: "2025-03-05", + }, + Task(type=TaskType.DAILY, text="TITLE", startDate=datetime(2025, 3, 5)), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "weekly", + }, + Task(type=TaskType.DAILY, text="TITLE", frequency=Frequency.WEEKLY), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_INTERVAL: 5, + }, + Task(type=TaskType.DAILY, text="TITLE", everyX=5), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT: ["m", "t", "w", "th"], + }, + Task( + type=TaskType.DAILY, + text="TITLE", + frequency=Frequency.WEEKLY, + repeat=Repeat(m=True, t=True, w=True, th=True), + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_month", + }, + Task( + type=TaskType.DAILY, + text="TITLE", + frequency=Frequency.MONTHLY, + daysOfMonth=[25], + weeksOfMonth=[], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_week", + }, + Task( + type=TaskType.DAILY, + text="TITLE", + frequency=Frequency.MONTHLY, + daysOfMonth=[], + weeksOfMonth=[3], + repeat=Repeat( + m=False, t=True, w=False, th=False, f=False, s=False, su=False + ), + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_REMINDER: ["10:00"], + }, + Task( + type=TaskType.DAILY, + text="TITLE", + reminders=[ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 10, 0, tzinfo=UTC), + startDate=None, + ) + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_REMOVE_REMINDER: ["10:00"], + }, + Task(type=TaskType.DAILY, text="TITLE", reminders=[]), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_CLEAR_REMINDER: True, + }, + Task(type=TaskType.DAILY, text="TITLE", reminders=[]), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_STREAK: 10, + }, + Task(type=TaskType.DAILY, text="TITLE", streak=10), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.DAILY, text="TITLE", alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +@freeze_time("2025-02-25T22:00:00.000Z") +async def test_create_daily( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create daily action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_DAILY, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + +@pytest.mark.parametrize( + "service_data", + [ + { + ATTR_FREQUENCY: "daily", + ATTR_REPEAT: ["m", "t", "w", "th"], + }, + { + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT_MONTHLY: "day_of_month", + }, + { + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT_MONTHLY: "day_of_week", + }, + ], +) +@pytest.mark.usefixtures("mock_uuid4") +@freeze_time("2025-02-25T22:00:00.000Z") +async def test_update_daily_service_validation_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], +) -> None: + """Test Habitica update daily action.""" + task_id = "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_DAILY, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/hardware/test_websocket_api.py b/tests/components/hardware/test_websocket_api.py index 64fcda02df4..f5591ff8480 100644 --- a/tests/components/hardware/test_websocket_api.py +++ b/tests/components/hardware/test_websocket_api.py @@ -50,7 +50,7 @@ async def test_system_status_subscription( return mock_psutil with patch( - "homeassistant.components.hardware.websocket_api.ha_psutil.PsutilWrapper", + "homeassistant.components.hardware.ha_psutil.PsutilWrapper", wraps=create_mock_psutil, ): assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/harmony/test_init.py b/tests/components/harmony/test_init.py index 971983fc3b6..10befc40b8e 100644 --- a/tests/components/harmony/test_init.py +++ b/tests/components/harmony/test_init.py @@ -17,7 +17,7 @@ from .const import ( WATCH_TV_ACTIVITY_ID, ) -from tests.common import MockConfigEntry, mock_registry +from tests.common import MockConfigEntry, RegistryEntryWithDefaults, mock_registry async def test_unique_id_migration( @@ -33,35 +33,35 @@ async def test_unique_id_migration( hass, { # old format - ENTITY_WATCH_TV: er.RegistryEntry( + ENTITY_WATCH_TV: RegistryEntryWithDefaults( entity_id=ENTITY_WATCH_TV, unique_id="123443-Watch TV", platform="harmony", config_entry_id=entry.entry_id, ), # old format, activity name with - - ENTITY_NILE_TV: er.RegistryEntry( + ENTITY_NILE_TV: RegistryEntryWithDefaults( entity_id=ENTITY_NILE_TV, unique_id="123443-Nile-TV", platform="harmony", config_entry_id=entry.entry_id, ), # new format - ENTITY_PLAY_MUSIC: er.RegistryEntry( + ENTITY_PLAY_MUSIC: RegistryEntryWithDefaults( entity_id=ENTITY_PLAY_MUSIC, unique_id=f"activity_{PLAY_MUSIC_ACTIVITY_ID}", platform="harmony", config_entry_id=entry.entry_id, ), # old entity which no longer has a matching activity on the hub. skipped. - "switch.some_other_activity": er.RegistryEntry( + "switch.some_other_activity": RegistryEntryWithDefaults( entity_id="switch.some_other_activity", unique_id="123443-Some Other Activity", platform="harmony", config_entry_id=entry.entry_id, ), # select entity - ENTITY_SELECT: er.RegistryEntry( + ENTITY_SELECT: RegistryEntryWithDefaults( entity_id=ENTITY_SELECT, unique_id=f"{HUB_NAME}_activities", platform="harmony", diff --git a/tests/components/harmony/test_subscriber.py b/tests/components/harmony/test_subscriber.py index f1d1866a044..22957fc3f69 100644 --- a/tests/components/harmony/test_subscriber.py +++ b/tests/components/harmony/test_subscriber.py @@ -38,7 +38,7 @@ async def test_empty_callbacks(hass: HomeAssistant) -> None: """Ensure we handle a missing callback in a subscription.""" subscriber = HarmonySubscriberMixin(hass) - callbacks = {k: None for k in _ALL_CALLBACK_NAMES} + callbacks = dict.fromkeys(_ALL_CALLBACK_NAMES) subscriber.async_subscribe(HarmonyCallback(**callbacks)) _call_all_callbacks(subscriber) await hass.async_block_till_done() diff --git a/tests/components/hassio/common.py b/tests/components/hassio/common.py index 82d3564440b..5cf7e80b191 100644 --- a/tests/components/hassio/common.py +++ b/tests/components/hassio/common.py @@ -151,8 +151,7 @@ def mock_addon_installed( def mock_addon_running(addon_store_info: AsyncMock, addon_info: AsyncMock) -> AsyncMock: """Mock add-on already running.""" - addon_store_info.return_value.available = True - addon_store_info.return_value.installed = True + mock_addon_installed(addon_store_info, addon_info) addon_info.return_value.state = "started" return addon_info diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 7075b9d6982..ea38865ac5a 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -3,17 +3,16 @@ from collections.abc import Generator import os import re -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, patch from aiohasupervisor.models import AddonsStats, AddonState from aiohttp.test_utils import TestClient import pytest from homeassistant.auth.models import RefreshToken -from homeassistant.components.hassio.handler import HassIO, HassioAPIError -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.components.hassio.handler import HassIO +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.setup import async_setup_component from . import SUPERVISOR_TOKEN @@ -32,71 +31,21 @@ def disable_security_filter() -> Generator[None]: @pytest.fixture -def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]: - """Fixture to inject hassio env.""" - with ( - patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), - patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}), - patch( - "homeassistant.components.hassio.HassIO.get_info", - Mock(side_effect=HassioAPIError()), - ), - ): - yield - - -@pytest.fixture -def hassio_stubs( - hassio_env: None, - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - supervisor_client: AsyncMock, -) -> RefreshToken: - """Create mock hassio http client.""" - with ( - patch( - "homeassistant.components.hassio.HassIO.update_hass_api", - return_value={"result": "ok"}, - ) as hass_api, - patch( - "homeassistant.components.hassio.HassIO.update_hass_timezone", - return_value={"result": "ok"}, - ), - patch( - "homeassistant.components.hassio.HassIO.get_info", - side_effect=HassioAPIError(), - ), - patch( - "homeassistant.components.hassio.HassIO.get_ingress_panels", - return_value={"panels": []}, - ), - patch( - "homeassistant.components.hassio.issues.SupervisorIssues.setup", - ), - ): - hass.set_state(CoreState.starting) - hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) - - return hass_api.call_args[0][1] - - -@pytest.fixture -def hassio_client( +async def hassio_client( hassio_stubs: RefreshToken, hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> TestClient: """Return a Hass.io HTTP client.""" - return hass.loop.run_until_complete(hass_client()) + return await hass_client() @pytest.fixture -def hassio_noauth_client( +async def hassio_noauth_client( hassio_stubs: RefreshToken, hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, ) -> TestClient: """Return a Hass.io HTTP client without auth.""" - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return await aiohttp_client(hass.http.app) @pytest.fixture diff --git a/tests/components/hassio/snapshots/test_config.ambr b/tests/components/hassio/snapshots/test_config.ambr new file mode 100644 index 00000000000..905c4155184 --- /dev/null +++ b/tests/components/hassio/snapshots/test_config.ambr @@ -0,0 +1,46 @@ +# serializer version: 1 +# name: test_load_config_store[storage_data0] + dict({ + 'hassio_user': '766572795f764572b95f72616e646f6d', + 'update_config': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + }) +# --- +# name: test_load_config_store[storage_data1] + dict({ + 'hassio_user': '00112233445566778899aabbccddeeff', + 'update_config': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + }) +# --- +# name: test_load_config_store[storage_data2] + dict({ + 'hassio_user': '00112233445566778899aabbccddeeff', + 'update_config': dict({ + 'add_on_backup_before_update': True, + 'add_on_backup_retain_copies': 2, + 'core_backup_before_update': True, + }), + }) +# --- +# name: test_save_config_store + dict({ + 'data': dict({ + 'hassio_user': '766572795f764572b95f72616e646f6d', + 'update_config': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + }), + 'key': 'hassio', + 'minor_version': 1, + 'version': 1, + }) +# --- diff --git a/tests/components/hassio/snapshots/test_websocket_api.ambr b/tests/components/hassio/snapshots/test_websocket_api.ambr new file mode 100644 index 00000000000..e3ff6c978c1 --- /dev/null +++ b/tests/components/hassio/snapshots/test_websocket_api.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_read_update_config + dict({ + 'id': 1, + 'result': dict({ + 'add_on_backup_before_update': False, + 'add_on_backup_retain_copies': 1, + 'core_backup_before_update': False, + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_read_update_config.1 + dict({ + 'id': 2, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_read_update_config.2 + dict({ + 'id': 3, + 'result': dict({ + 'add_on_backup_before_update': True, + 'add_on_backup_retain_copies': 2, + 'core_backup_before_update': True, + }), + 'success': True, + 'type': 'result', + }) +# --- diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 6e4fe4dd428..544b9bd5958 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -3,16 +3,18 @@ from collections.abc import ( AsyncGenerator, AsyncIterator, + Buffer, Callable, Coroutine, Generator, + Iterable, ) from dataclasses import replace from datetime import datetime from io import StringIO import os from pathlib import PurePath -from typing import Any +from typing import Any, cast from unittest.mock import ANY, AsyncMock, Mock, patch from uuid import UUID @@ -30,7 +32,7 @@ from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL from aiohasupervisor.models.mounts import MountsInfo from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -38,6 +40,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupAgentPlatformProtocol, + BackupNotFound, Folder, store as backup_store, ) @@ -326,43 +329,70 @@ async def setup_backup_integration( await hass.async_block_till_done() -class BackupAgentTest(BackupAgent): - """Test backup agent.""" +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i - def __init__(self, name: str, domain: str = "test") -> None: - """Initialize the backup agent.""" - self.domain = domain - self.name = name - self.unique_id = name - async def async_download_backup( - self, backup_id: str, **kwargs: Any - ) -> AsyncIterator[bytes]: - """Download a backup file.""" - return AsyncMock(spec_set=["__aiter__"]) +def mock_backup_agent( + name: str, domain: str = "test", backups: list[AgentBackup] | None = None +) -> Mock: + """Create a mock backup agent.""" - async def async_upload_backup( - self, + async def delete_backup(backup_id: str, **kwargs: Any) -> None: + """Mock delete.""" + await get_backup(backup_id) + + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: + """Mock download.""" + return aiter_from_iter((backups_data.get(backup_id, b"backup data"),)) + + async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup: + """Get a backup.""" + backup = next((b for b in _backups if b.backup_id == backup_id), None) + if backup is None: + raise BackupNotFound + return backup + + async def upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, **kwargs: Any, ) -> None: """Upload a backup.""" - await open_stream() + _backups.append(backup) + backup_stream = await open_stream() + backup_data = bytearray() + async for chunk in backup_stream: + backup_data += chunk + backups_data[backup.backup_id] = backup_data - 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.""" + _backups = backups or [] + backups_data: dict[str, Buffer] = {} + mock_agent = Mock(spec=BackupAgent) + mock_agent.domain = domain + mock_agent.name = name + mock_agent.unique_id = name + type(mock_agent).agent_id = BackupAgent.agent_id + mock_agent.async_delete_backup = AsyncMock( + side_effect=delete_backup, spec_set=[BackupAgent.async_delete_backup] + ) + mock_agent.async_download_backup = AsyncMock( + side_effect=download_backup, spec_set=[BackupAgent.async_download_backup] + ) + mock_agent.async_get_backup = AsyncMock( + side_effect=get_backup, spec_set=[BackupAgent.async_get_backup] + ) + mock_agent.async_list_backups = AsyncMock( + return_value=backups, spec_set=[BackupAgent.async_list_backups] + ) + mock_agent.async_upload_backup = AsyncMock( + side_effect=upload_backup, + spec_set=[BackupAgent.async_upload_backup], + ) + return mock_agent async def _setup_backup_platform( @@ -372,7 +402,7 @@ async def _setup_backup_platform( platform: BackupAgentPlatformProtocol, ) -> None: """Set up a mock domain.""" - mock_platform(hass, f"{domain}.backup", platform) + mock_platform(hass, f"{domain}.backup", cast(Mock, platform)) assert await async_setup_component(hass, domain, {}) await hass.async_block_till_done() @@ -383,7 +413,7 @@ async def _setup_backup_platform( [ ( MountsInfo(default_backup_mount=None, mounts=[]), - [BackupAgentTest("local", DOMAIN)], + [mock_backup_agent("local", DOMAIN)], ), ( MountsInfo( @@ -394,14 +424,14 @@ async def _setup_backup_platform( name="test", read_only=False, state=supervisor_mounts.MountState.ACTIVE, - user_path="test", + user_path=PurePath("test"), usage=supervisor_mounts.MountUsage.BACKUP, server="test", type=supervisor_mounts.MountType.CIFS, ) ], ), - [BackupAgentTest("local", DOMAIN), BackupAgentTest("test", DOMAIN)], + [mock_backup_agent("local", DOMAIN), mock_backup_agent("test", DOMAIN)], ), ( MountsInfo( @@ -412,14 +442,14 @@ async def _setup_backup_platform( name="test", read_only=False, state=supervisor_mounts.MountState.ACTIVE, - user_path="test", + user_path=PurePath("test"), usage=supervisor_mounts.MountUsage.MEDIA, server="test", type=supervisor_mounts.MountType.CIFS, ) ], ), - [BackupAgentTest("local", DOMAIN)], + [mock_backup_agent("local", DOMAIN)], ), ], ) @@ -576,40 +606,13 @@ async def test_agent_upload( ) -> 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")}, - ) + 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() @@ -852,7 +855,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( "with_automatic_settings": False, }, filename=PurePath("Test_2025-01-30_05.42_12345678.tar"), - folders={"ssl"}, + folders={supervisor_backups.Folder("ssl")}, homeassistant_exclude_database=False, homeassistant=True, location=[LOCATION_LOCAL_STORAGE], @@ -875,7 +878,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( ), ( {"include_all_addons": True}, - replace(DEFAULT_BACKUP_OPTIONS, addons="ALL"), + replace(DEFAULT_BACKUP_OPTIONS, addons=supervisor_backups.AddonSet("ALL")), ), ( {"include_database": False}, @@ -883,7 +886,14 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( ), ( {"include_folders": ["media", "share"]}, - replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share", "ssl"}), + replace( + DEFAULT_BACKUP_OPTIONS, + folders={ + supervisor_backups.Folder("media"), + supervisor_backups.Folder("share"), + supervisor_backups.Folder("ssl"), + }, + ), ), ( { @@ -893,7 +903,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( }, replace( DEFAULT_BACKUP_OPTIONS, - folders={"media"}, + folders={supervisor_backups.Folder("media")}, homeassistant=False, homeassistant_exclude_database=True, ), @@ -1249,11 +1259,11 @@ async def test_reader_writer_create_per_agent_encryption( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, - commands: dict[str, Any], + commands: list[dict[str, Any]], password: str | None, agent_ids: list[str], password_sent_to_supervisor: str | None, - create_locations: list[str | None], + create_locations: list[str], create_protected: bool, upload_locations: list[str | None], ) -> None: @@ -1268,7 +1278,7 @@ async def test_reader_writer_create_per_agent_encryption( name=f"share{i}", read_only=False, state=supervisor_mounts.MountState.ACTIVE, - user_path=f"share{i}", + user_path=PurePath(f"share{i}"), usage=supervisor_mounts.MountUsage.BACKUP, server=f"share{i}", type=supervisor_mounts.MountType.CIFS, @@ -1551,7 +1561,7 @@ async def test_reader_writer_create_download_remove_error( method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception - remote_agent = BackupAgentTest("remote") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1636,7 +1646,7 @@ async def test_reader_writer_create_info_error( supervisor_client.backups.backup_info.side_effect = exception supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE - remote_agent = BackupAgentTest("remote") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1713,7 +1723,7 @@ async def test_reader_writer_create_remote_backup( supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE - remote_agent = BackupAgentTest("remote") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1861,24 +1871,10 @@ async def test_agent_receive_remote_backup( ) -> 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_5 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") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1889,23 +1885,10 @@ async def test_agent_receive_remote_backup( ) 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")}, - ) + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO("test")}, + ) assert resp.status == 201 @@ -1996,6 +1979,103 @@ async def test_reader_writer_restore( assert response["result"] is None +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_reader_writer_restore_remote_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup from a remote agent.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.backups.list.return_value = [TEST_BACKUP_5] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + backup_id = "abc123" + 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, + ) + remote_agent = mock_backup_agent("remote", backups=[test_backup]) + 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/restore", "agent_id": "test.remote", "backup_id": backup_id} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + remote_agent.async_download_backup.assert_called_once_with(backup_id) + assert len(remote_agent.async_get_backup.mock_calls) == 2 + for call in remote_agent.async_get_backup.mock_calls: + assert call.args[0] == backup_id + supervisor_client.backups.partial_restore.assert_called_once_with( + backup_id, + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=LOCATION_CLOUD_BACKUP, + password=None, + ), + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "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.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_report_progress( hass: HomeAssistant, @@ -2322,7 +2402,7 @@ async def test_reader_writer_restore_wrong_parameters( @pytest.mark.parametrize( - ("get_job_result", "last_non_idle_event"), + ("get_job_result", "last_action_event"), [ ( TEST_JOB_DONE, @@ -2350,7 +2430,7 @@ async def test_restore_progress_after_restart( hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, get_job_result: supervisor_jobs.Job, - last_non_idle_event: dict[str, Any], + last_action_event: dict[str, Any], ) -> None: """Test restore backup progress after restart.""" @@ -2366,7 +2446,7 @@ async def test_restore_progress_after_restart( response = await client.receive_json() assert response["success"] - assert response["result"]["last_non_idle_event"] == last_non_idle_event + assert response["result"]["last_action_event"] == last_action_event assert response["result"]["state"] == "idle" @@ -2444,7 +2524,7 @@ async def test_restore_progress_after_restart_report_progress( response = await client.receive_json() assert response["success"] - assert response["result"]["last_non_idle_event"] == { + assert response["result"]["last_action_event"] == { "manager_state": "restore_backup", "reason": None, "stage": "addons", @@ -2473,7 +2553,7 @@ async def test_restore_progress_after_restart_unknown_job( response = await client.receive_json() assert response["success"] - assert response["result"]["last_non_idle_event"] is None + assert response["result"]["last_action_event"] is None assert response["result"]["state"] == "idle" @@ -2554,7 +2634,7 @@ async def test_config_load_config_info( freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, hass_storage: dict[str, Any], - storage_data: dict[str, Any] | None, + storage_data: dict[str, Any], ) -> None: """Test loading stored backup config and reading it via config/info.""" client = await hass_ws_client(hass) diff --git a/tests/components/hassio/test_config.py b/tests/components/hassio/test_config.py new file mode 100644 index 00000000000..4df8d2e81ac --- /dev/null +++ b/tests/components/hassio/test_config.py @@ -0,0 +1,182 @@ +"""Test websocket API.""" + +from typing import Any +from unittest.mock import AsyncMock, patch +from uuid import UUID + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.components.hassio.const import DATA_CONFIG_STORE, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockUser +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator + +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} + + +@pytest.fixture(autouse=True) +def mock_all( + aioclient_mock: AiohttpClientMocker, + supervisor_is_connected: AsyncMock, + resolution_info: AsyncMock, + addon_info: AsyncMock, +) -> None: + """Mock all setup requests.""" + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/info", + json={ + "result": "ok", + "data": {"supervisor": "222", "homeassistant": "0.110.0", "hassos": None}, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/host/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "data": { + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", + }, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": { + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test", + "state": "started", + "slug": "test", + "installed": True, + "update_available": True, + "icon": False, + "version": "2.0.0", + "version_latest": "2.0.1", + "repository": "core", + "url": "https://github.com/home-assistant/addons/test", + }, + ], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) + + +@pytest.mark.usefixtures("hassio_env") +@pytest.mark.parametrize( + "storage_data", + [ + {}, + { + "hassio": { + "data": { + "hassio_user": "00112233445566778899aabbccddeeff", + "update_config": { + "add_on_backup_before_update": False, + "add_on_backup_retain_copies": 1, + "core_backup_before_update": False, + }, + }, + "key": "hassio", + "minor_version": 1, + "version": 1, + } + }, + { + "hassio": { + "data": { + "hassio_user": "00112233445566778899aabbccddeeff", + "update_config": { + "add_on_backup_before_update": True, + "add_on_backup_retain_copies": 2, + "core_backup_before_update": True, + }, + }, + "key": "hassio", + "minor_version": 1, + "version": 1, + } + }, + ], +) +async def test_load_config_store( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + hass_storage: dict[str, Any], + storage_data: dict[str, dict[str, Any]], + snapshot: SnapshotAssertion, +) -> None: + """Test loading the config store.""" + hass_storage.update(storage_data) + + user = MockUser(id="00112233445566778899aabbccddeeff", system_generated=True) + user.add_to_hass(hass) + await hass.auth.async_create_refresh_token(user) + await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN]) + + with ( + patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), + patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass.data[DATA_CONFIG_STORE].data.to_dict() == snapshot + + +@pytest.mark.usefixtures("hassio_env") +async def test_save_config_store( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + hass_storage: dict[str, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test saving the config store.""" + with ( + patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), + patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass_storage[DOMAIN] == snapshot diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 805b5292edb..069abaa8513 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -269,6 +269,49 @@ async def test_ingress_request_options( assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO] +@pytest.mark.parametrize( + "build_type", + [ + ("a3_vl", "test/beer/ping?index=1"), + ("core", "index.html"), + ("local", "panel/config"), + ("jk_921", "editor.php?idx=3&ping=5"), + ("fsadjf10312", ""), + ], +) +async def test_ingress_request_head( + hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker +) -> None: + """Test no auth needed for .""" + aioclient_mock.head( + f"http://127.0.0.1/ingress/{build_type[0]}/{build_type[1]}", + text="test", + ) + + resp = await hassio_noauth_client.head( + f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", + headers={"X-Test-Header": "beer"}, + ) + + # Check we got right response + assert resp.status == HTTPStatus.OK + body = await resp.text() + assert body == "" # head does not return a body + + # Check we forwarded command + assert len(aioclient_mock.mock_calls) == 1 + assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3] + assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress" + assert ( + aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] + == f"/api/hassio_ingress/{build_type[0]}" + ) + assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer" + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR] + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST] + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO] + + @pytest.mark.parametrize( "build_type", [ diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 5c11370ae74..d34aed608fb 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -17,16 +17,16 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.hassio import ( ADDONS_COORDINATOR, DOMAIN, - STORAGE_KEY, get_core_info, get_supervisor_ip, hostname_from_addon_slug, is_hassio as deprecated_is_hassio, ) +from homeassistant.components.hassio.config import STORAGE_KEY from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.setup import async_setup_component @@ -228,7 +228,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert get_core_info(hass)["version_latest"] == "1.0.0" assert is_hassio(hass) @@ -275,7 +275,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 9999 assert "watchdog" not in aioclient_mock.mock_calls[0][2] @@ -296,7 +296,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 9999 assert not aioclient_mock.mock_calls[0][2]["watchdog"] @@ -309,12 +309,15 @@ async def test_setup_api_push_api_data_default( supervisor_client: AsyncMock, ) -> None: """Test setup with API push default data.""" - with patch.dict(os.environ, MOCK_ENVIRON): + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), + ): result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[0][2]["refresh_token"] @@ -395,13 +398,13 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert not aioclient_mock.mock_calls[0][2]["ssl"] assert aioclient_mock.mock_calls[0][2]["port"] == 8123 assert aioclient_mock.mock_calls[0][2]["refresh_token"] == token.token -async def test_setup_core_push_timezone( +async def test_setup_core_push_config( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, supervisor_client: AsyncMock, @@ -414,13 +417,14 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert aioclient_mock.mock_calls[1][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): - await hass.config.async_update(time_zone="America/New_York") + await hass.config.async_update(time_zone="America/New_York", country="US") await hass.async_block_till_done() assert aioclient_mock.mock_calls[-1][2]["timezone"] == "America/New_York" + assert aioclient_mock.mock_calls[-1][2]["country"] == "US" async def test_setup_hassio_no_additional_data( @@ -437,7 +441,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -470,7 +474,6 @@ async def test_service_register(hass: HomeAssistant) -> None: assert hass.services.has_service("hassio", "addon_start") assert hass.services.has_service("hassio", "addon_stop") assert hass.services.has_service("hassio", "addon_restart") - assert hass.services.has_service("hassio", "addon_update") assert hass.services.has_service("hassio", "addon_stdin") assert hass.services.has_service("hassio", "host_shutdown") assert hass.services.has_service("hassio", "host_reboot") @@ -489,7 +492,6 @@ async def test_service_calls( supervisor_client: AsyncMock, addon_installed: AsyncMock, supervisor_is_connected: AsyncMock, - issue_registry: ir.IssueRegistry, ) -> None: """Call service and check the API calls behind that.""" supervisor_is_connected.side_effect = SupervisorError @@ -516,21 +518,19 @@ async def test_service_calls( await hass.services.async_call("hassio", "addon_start", {"addon": "test"}) await hass.services.async_call("hassio", "addon_stop", {"addon": "test"}) await hass.services.async_call("hassio", "addon_restart", {"addon": "test"}) - await hass.services.async_call("hassio", "addon_update", {"addon": "test"}) - assert (DOMAIN, "update_service_deprecated") in issue_registry.issues await hass.services.async_call( "hassio", "addon_stdin", {"addon": "test", "input": "test"} ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 25 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 22 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 27 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 24 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -545,7 +545,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 29 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 26 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -570,7 +570,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 31 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 28 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -589,7 +589,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 32 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 29 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -605,7 +605,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 33 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 30 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -624,7 +624,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 35 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 32 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, @@ -1070,7 +1070,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done(wait_background_tasks=True) assert result - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 18 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index a3718454538..6ecc2b44244 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -5,8 +5,16 @@ import os from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from aiohasupervisor import SupervisorBadRequestError, SupervisorError -from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate +from aiohasupervisor import ( + SupervisorBadRequestError, + SupervisorError, + SupervisorNotFoundError, +) +from aiohasupervisor.models import ( + HomeAssistantUpdateOptions, + OSUpdate, + StoreAddonUpdate, +) import pytest from homeassistant.components.backup import BackupManagerError, ManagerBackup @@ -475,13 +483,123 @@ async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> N await hass.async_block_till_done() supervisor_client.os.update.return_value = None - await hass.services.async_call( - "update", - "install", - {"entity_id": "update.home_assistant_operating_system_update"}, - blocking=True, - ) - supervisor_client.os.update.assert_called_once() + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_operating_system_update"}, + blocking=True, + ) + mock_create_backup.assert_not_called() + supervisor_client.os.update.assert_called_once_with(OSUpdate(version=None)) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [ + { + "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", + }, + }, + ], + None, + { + "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, + }, + ), + ], +) +async def test_update_os_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating OS update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + 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"] + + supervisor_client.os.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + { + "entity_id": "update.home_assistant_operating_system_update", + "backup": True, + }, + blocking=True, + ) + mock_create_backup.assert_called_once_with(**expected_kwargs) + supervisor_client.os.update.assert_called_once_with(OSUpdate(version=None)) async def test_update_core(hass: HomeAssistant, supervisor_client: AsyncMock) -> None: @@ -663,7 +781,7 @@ async def test_update_addon_with_error( update_addon.side_effect = SupervisorError with pytest.raises(HomeAssistantError, match=r"^Error updating test:"): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.test_update"}, @@ -711,7 +829,7 @@ async def test_update_addon_with_backup_and_error( ), pytest.raises(HomeAssistantError, match=message), ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.test_update", "backup": True}, @@ -738,7 +856,7 @@ async def test_update_os_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Operating System:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_operating_system_update"}, @@ -746,6 +864,43 @@ async def test_update_os_with_error( ) +async def test_update_os_with_backup_and_error( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test updating OS update entity with error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + supervisor_client.os.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + ): + await hass.services.async_call( + "update", + "install", + { + "entity_id": "update.home_assistant_operating_system_update", + "backup": True, + }, + blocking=True, + ) + + async def test_update_supervisor_with_error( hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: @@ -765,7 +920,7 @@ async def test_update_supervisor_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Supervisor:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_supervisor_update"}, @@ -792,7 +947,7 @@ async def test_update_core_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Core:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_core_update"}, @@ -826,7 +981,7 @@ async def test_update_core_with_backup_and_error( ), pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_core_update", "backup": True}, @@ -836,6 +991,7 @@ async def test_update_core_with_backup_and_error( async def test_release_notes_between_versions( hass: HomeAssistant, + addon_changelog: AsyncMock, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: @@ -843,12 +999,10 @@ async def test_release_notes_between_versions( config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) + addon_changelog.return_value = "# 2.0.1\nNew updates\n# 2.0.0\nOld updates" + with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.hassio.coordinator.get_addons_changelogs", - return_value={"test": "# 2.0.1\nNew updates\n# 2.0.0\nOld updates"}, - ), ): result = await async_setup_component( hass, @@ -875,6 +1029,7 @@ async def test_release_notes_between_versions( async def test_release_notes_full( hass: HomeAssistant, + addon_changelog: AsyncMock, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: @@ -882,12 +1037,11 @@ async def test_release_notes_full( config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) + full_changelog = "# 2.0.0\nNew updates\n# 2.0.0\nOld updates" + addon_changelog.return_value = full_changelog + with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.hassio.coordinator.get_addons_changelogs", - return_value={"test": "# 2.0.0\nNew updates\n# 2.0.0\nOld updates"}, - ), ): result = await async_setup_component( hass, @@ -911,9 +1065,21 @@ async def test_release_notes_full( assert "Old updates" in result["result"] assert "New updates" in result["result"] + # Update entity without update should returns full changelog + await client.send_json( + { + "id": 2, + "type": "update/release_notes", + "entity_id": "update.test2_update", + } + ) + result = await client.receive_json() + assert result["result"] == full_changelog + async def test_not_release_notes( hass: HomeAssistant, + addon_changelog: AsyncMock, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: @@ -921,12 +1087,10 @@ async def test_not_release_notes( config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) + addon_changelog.side_effect = SupervisorNotFoundError() + with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.hassio.coordinator.get_addons_changelogs", - return_value={"test": None}, - ), ): result = await async_setup_component( hass, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index b695cc1794a..8c68e9bf705 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup import BackupManagerError, ManagerBackup @@ -42,6 +43,7 @@ def mock_all( aioclient_mock: AiohttpClientMocker, supervisor_is_connected: AsyncMock, resolution_info: AsyncMock, + addon_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -468,13 +470,15 @@ async def test_update_addon_with_backup( @pytest.mark.parametrize( - ("backups", "removed_backups"), + ("ws_commands", "backups", "removed_backups"), [ ( + [], {}, [], ), ( + [], { "backup-1": MagicMock( agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, @@ -519,6 +523,52 @@ async def test_update_addon_with_backup( }, ["backup-5"], ), + ( + [{"type": "hassio/update/config/update", "add_on_backup_retain_copies": 2}], + { + "backup-1": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + [], + ), ], ) async def test_update_addon_with_backup_removes_old_backups( @@ -526,6 +576,7 @@ async def test_update_addon_with_backup_removes_old_backups( hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, update_addon: AsyncMock, + ws_commands: list[dict[str, Any]], backups: dict[str, ManagerBackup], removed_backups: list[str], ) -> None: @@ -543,6 +594,12 @@ async def test_update_addon_with_backup_removes_old_backups( await setup_backup_integration(hass) client = await hass_ws_client(hass) + + for command in ws_commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + supervisor_client.mounts.info.return_value.default_backup_mount = None with ( patch( @@ -848,12 +905,38 @@ async def test_update_core_with_backup_and_error( side_effect=BackupManagerError, ), ): - await client.send_json_auto_id( - {"type": "hassio/update/addon", "addon": "test", "backup": True} - ) + await client.send_json_auto_id({"type": "hassio/update/core", "backup": True}) result = await client.receive_json() assert not result["success"] assert result["error"] == { "code": "home_assistant_error", "message": "Error creating backup: ", } + + +@pytest.mark.usefixtures("hassio_env") +async def test_read_update_config( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test read and update config.""" + assert await async_setup_component(hass, "hassio", {}) + websocket_client = await hass_ws_client(hass) + + await websocket_client.send_json_auto_id({"type": "hassio/update/config/info"}) + assert await websocket_client.receive_json() == snapshot + + await websocket_client.send_json_auto_id( + { + "type": "hassio/update/config/update", + "add_on_backup_before_update": True, + "add_on_backup_retain_copies": 2, + "core_backup_before_update": True, + } + ) + assert await websocket_client.receive_json() == snapshot + + await websocket_client.send_json_auto_id({"type": "hassio/update/config/info"}) + assert await websocket_client.receive_json() == snapshot diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 016cc7b3580..edc128f2f78 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -2,7 +2,14 @@ from unittest.mock import AsyncMock -from pyheos import ConnectionState, Heos, HeosGroup, HeosOptions, HeosPlayer +from pyheos import ( + ConnectionState, + Heos, + HeosGroup, + HeosOptions, + HeosPlayer, + MediaMusicSource, +) class MockHeos(Heos): @@ -13,6 +20,7 @@ class MockHeos(Heos): super().__init__(options) # Overwrite the methods with async mocks, changing type self.add_to_queue: AsyncMock = AsyncMock() + self.browse_media: AsyncMock = AsyncMock() self.connect: AsyncMock = AsyncMock() self.disconnect: AsyncMock = AsyncMock() self.get_favorites: AsyncMock = AsyncMock() @@ -20,6 +28,7 @@ class MockHeos(Heos): self.get_input_sources: AsyncMock = AsyncMock() self.get_playlists: AsyncMock = AsyncMock() self.get_players: AsyncMock = AsyncMock() + self.get_music_sources: AsyncMock = AsyncMock() self.group_volume_down: AsyncMock = AsyncMock() self.group_volume_up: AsyncMock = AsyncMock() self.get_system_info: AsyncMock = AsyncMock() @@ -28,10 +37,14 @@ class MockHeos(Heos): self.play_preset_station: AsyncMock = AsyncMock() self.play_url: AsyncMock = AsyncMock() self.player_clear_queue: AsyncMock = AsyncMock() + self.player_get_queue: AsyncMock = AsyncMock() self.player_get_quick_selects: AsyncMock = AsyncMock() + self.player_move_queue_item: AsyncMock = AsyncMock() self.player_play_next: AsyncMock = AsyncMock() self.player_play_previous: AsyncMock = AsyncMock() + self.player_play_queue: AsyncMock = AsyncMock() self.player_play_quick_select: AsyncMock = AsyncMock() + self.player_remove_from_queue: AsyncMock = AsyncMock() self.player_set_mute: AsyncMock = AsyncMock() self.player_set_play_mode: AsyncMock = AsyncMock() self.player_set_play_state: AsyncMock = AsyncMock() @@ -64,3 +77,17 @@ class MockHeos(Heos): def mock_set_connection_state(self, connection_state: ConnectionState) -> None: """Set the connection state on the mock instance.""" self._connection._state = connection_state + + def mock_set_current_host(self, host: str) -> None: + """Set the current host on the mock instance.""" + self._connection._host = host + + def mock_set_music_sources( + self, music_sources: dict[int, MediaMusicSource] + ) -> None: + """Set the music sources on the mock instance.""" + for music_source in music_sources.values(): + music_source.heos = self + self._music_sources = music_sources + self._music_sources_loaded = bool(music_sources) + self.get_music_sources.return_value = music_sources diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 7bed05a0289..835e4436398 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Iterator from unittest.mock import Mock, patch from pyheos import ( + BrowseResult, HeosGroup, HeosHost, HeosNowPlayingMedia, @@ -14,10 +15,12 @@ from pyheos import ( HeosSystem, LineOutLevelType, MediaItem, + MediaMusicSource, MediaType, NetworkType, PlayerUpdateResult, PlayState, + QueueItem, RepeatType, const, ) @@ -294,10 +297,10 @@ def quick_selects_fixture() -> dict[int, str]: } -@pytest.fixture(name="playlists") -def playlists_fixture() -> list[MediaItem]: - """Create favorites fixture.""" - playlist = MediaItem( +@pytest.fixture(name="playlist") +def playlist_fixture() -> MediaItem: + """Create playlist fixture.""" + return MediaItem( source_id=const.MUSIC_SOURCE_PLAYLISTS, name="Awesome Music", type=MediaType.PLAYLIST, @@ -306,6 +309,44 @@ def playlists_fixture() -> list[MediaItem]: image_url="", heos=None, ) + + +@pytest.fixture(name="music_sources") +def music_sources_fixture() -> dict[int, MediaMusicSource]: + """Create music sources fixture.""" + return { + const.MUSIC_SOURCE_PANDORA: MediaMusicSource( + source_id=const.MUSIC_SOURCE_PANDORA, + name="Pandora", + type=MediaType.MUSIC_SERVICE, + available=True, + service_username="user", + image_url="", + heos=None, + ), + const.MUSIC_SOURCE_TUNEIN: MediaMusicSource( + source_id=const.MUSIC_SOURCE_TUNEIN, + name="TuneIn", + type=MediaType.MUSIC_SERVICE, + available=False, + service_username=None, + image_url="", + heos=None, + ), + } + + +@pytest.fixture(name="pandora_browse_result") +def pandora_browse_response_fixture(favorites: dict[int, MediaItem]) -> BrowseResult: + """Create a mock response for browsing Pandora.""" + return BrowseResult( + 1, 1, const.MUSIC_SOURCE_PANDORA, items=[favorites[1]], options=[] + ) + + +@pytest.fixture(name="playlists") +def playlists_fixture(playlist: MediaItem) -> list[MediaItem]: + """Create playlists fixture.""" return [playlist] @@ -319,3 +360,28 @@ def change_data_fixture() -> PlayerUpdateResult: def change_data_mapped_ids_fixture() -> PlayerUpdateResult: """Create player change data for testing.""" return PlayerUpdateResult(updated_player_ids={1: 101}) + + +@pytest.fixture(name="queue") +def queue_fixture() -> list[QueueItem]: + """Create a queue fixture.""" + return [ + QueueItem( + queue_id=1, + song="Espresso", + album="Espresso", + artist="Sabrina Carpenter", + image_url="http://resources.wimpmusic.com/images/e4f2d75f/a69e/4b8a/b800/e18546b1ad4c/640x640.jpg", + media_id="356276483", + album_id="356276481", + ), + QueueItem( + queue_id=2, + song="A Bar Song (Tipsy)", + album="A Bar Song (Tipsy)", + artist="Shaboozey", + image_url="http://resources.wimpmusic.com/images/d05b8da3/4fae/45ff/ac1b/7ab7caab3523/640x640.jpg", + media_id="354365598", + album_id="354365596", + ), + ] diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 98ce8a7bcbf..58685f5cf8f 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -106,6 +106,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -116,6 +117,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -125,6 +127,7 @@ 'model': 'Speaker', 'name': 'Test Player 2', 'network': 'wifi', + 'preferred_host': False, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -137,6 +140,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 88d27f2073a..68ab24c6479 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -1,4 +1,203 @@ # serializer version: 1 +# name: test_browse_media_heos_media + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'heos://media/1/station?name=Today%27s+Hits+Radio&image_url=&playable=True&browsable=False&media_id=123456789', + 'media_content_type': '', + 'thumbnail': '', + 'title': "Today's Hits Radio", + }), + ]), + 'children_media_class': 'track', + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': '', + 'title': 'Pandora', + }) +# --- +# name: test_browse_media_heos_media_error_returns_empty + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children': list([ + ]), + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': '', + 'title': 'Pandora', + }) +# --- +# name: test_browse_media_media_source + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'music', + 'media_content_id': 'media-source://media_source/local/test.mp3', + 'media_content_type': 'audio/mpeg', + 'thumbnail': None, + 'title': 'test.mp3', + }), + ]), + 'children_media_class': 'music', + 'media_class': 'directory', + 'media_content_id': 'media-source://media_source/local/.', + 'media_content_type': '', + 'not_shown': 1, + 'thumbnail': None, + 'title': 'media', + }) +# --- +# name: test_browse_media_root + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'Pandora', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'TuneIn', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': 'music', + 'media_class': 'directory', + 'media_content_id': 'media-source://media_source/local/.', + 'media_content_type': '', + 'thumbnail': None, + 'title': 'media', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'heos://media', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Music Sources', + }) +# --- +# name: test_browse_media_root_no_media_source + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'Pandora', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'TuneIn', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'heos://media', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Music Sources', + }) +# --- +# name: test_browse_media_root_source_error_continues + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children': list([ + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'heos://media', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Music Sources', + }) +# --- +# name: test_get_queue + dict({ + 'media_player.test_player': dict({ + 'queue': list([ + dict({ + 'album': 'Espresso', + 'album_id': '356276481', + 'artist': 'Sabrina Carpenter', + 'image_url': 'http://resources.wimpmusic.com/images/e4f2d75f/a69e/4b8a/b800/e18546b1ad4c/640x640.jpg', + 'media_id': '356276483', + 'queue_id': 1, + 'song': 'Espresso', + }), + dict({ + 'album': 'A Bar Song (Tipsy)', + 'album_id': '354365596', + 'artist': 'Shaboozey', + 'image_url': 'http://resources.wimpmusic.com/images/d05b8da3/4fae/45ff/ac1b/7ab7caab3523/640x640.jpg', + 'media_id': '354365598', + 'queue_id': 2, + 'song': 'A Bar Song (Tipsy)', + }), + ]), + }), + }) +# --- # name: test_state_attributes StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 396c3743663..69d9aa3a38e 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -14,7 +14,12 @@ from pyheos import ( import pytest from homeassistant.components.heos.const import DOMAIN -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + 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 @@ -83,6 +88,35 @@ async def test_create_entry_when_host_valid( assert controller.disconnect.call_count == 1 +async def test_manual_setup_with_discovery_in_progress( + hass: HomeAssistant, + discovery_data: SsdpServiceInfo, + controller: MockHeos, + system: HeosSystem, +) -> None: + """Test user can manually set up when discovery is in progress.""" + # Single discovered, selects preferred host, shows confirm + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + # Setup manually + user_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert user_result["type"] is FlowResultType.FORM + user_result = await hass.config_entries.flow.async_configure( + user_result["flow_id"], user_input={CONF_HOST: "127.0.0.1"} + ) + assert user_result["type"] is FlowResultType.CREATE_ENTRY + + # Discovery flow is removed + assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) + + async def test_discovery( hass: HomeAssistant, discovery_data: SsdpServiceInfo, @@ -160,6 +194,22 @@ async def test_discovery_aborts_same_system( assert config_entry.data[CONF_HOST] == "127.0.0.1" +async def test_discovery_ignored_aborts( + hass: HomeAssistant, + discovery_data: SsdpServiceInfo, +) -> None: + """Test discovery aborts when ignored.""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, source=SOURCE_IGNORE).add_to_hass( + hass + ) + + 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_fails_to_connect_aborts( hass: HomeAssistant, discovery_data: SsdpServiceInfo, controller: MockHeos ) -> None: diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 87cc8dd7dde..7bc232ad5a6 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -285,11 +285,11 @@ async def test_reconnected_new_entities_created( players = controller.players.copy() players[3] = player_factory(3, "Test Player 3", "HEOS Link") controller.mock_set_players(players) - controller.load_players.return_value = PlayerUpdateResult([3], [], {}) + update = PlayerUpdateResult([3], [], {}) # Simulate reconnection await controller.dispatcher.wait_send( - SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, update ) await hass.async_block_till_done() @@ -297,6 +297,25 @@ async def test_reconnected_new_entities_created( assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") +async def test_reconnected_failover_updates_host( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the config entry host is updated after failover.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + # Simulate reconnection + controller.mock_set_current_host("127.0.0.2") + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() + + # Assert config entry host updated + assert config_entry.data[CONF_HOST] == "127.0.0.2" + + async def test_players_changed_new_entities_created( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 3e755a29a0a..30d17f4a8ca 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -7,26 +7,35 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory from pyheos import ( AddCriteriaType, + BrowseResult, CommandFailedError, HeosError, MediaItem, + MediaMusicSource, MediaType as HeosMediaType, PlayerUpdateResult, PlayState, + QueueItem, RepeatType, SignalHeosEvent, SignalType, const, ) +from pyheos.util import mediauri import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.heos.const import ( + ATTR_DESTINATION_POSITION, + ATTR_QUEUE_IDS, DOMAIN, + SERVICE_GET_QUEUE, SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_UP, + SERVICE_MOVE_QUEUE_ITEM, + SERVICE_REMOVE_FROM_QUEUE, ) from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, @@ -51,6 +60,7 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) +from homeassistant.components.media_source import DOMAIN as MS_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, @@ -73,6 +83,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from . import MockHeos from tests.common import MockConfigEntry, async_fire_time_changed +from tests.conftest import async_setup_component +from tests.typing import WebSocketGenerator async def test_state_attributes( @@ -158,7 +170,6 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None assert state.state == STATE_IDLE - assert controller.load_players.call_count == 1 # Disconnected controller.load_players.reset_mock() @@ -170,11 +181,8 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None assert state.state == STATE_UNAVAILABLE - assert controller.load_players.call_count == 0 - # Connected handles refresh failure - controller.load_players.reset_mock() - controller.load_players.side_effect = CommandFailedError("", "Failure", 1) + # Reconnect and state updates player.available = True await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED @@ -183,38 +191,6 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None 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: MockHeos, - 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( @@ -1275,6 +1251,312 @@ async def test_play_media_invalid_type( ) +async def test_play_media_media_uri( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + playlist: MediaItem, +) -> None: + """Test the play media service with HEOS media uri.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + media_content_id = mediauri.to_media_uri(playlist) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: media_content_id, + ATTR_MEDIA_CONTENT_TYPE: "", + }, + blocking=True, + ) + controller.play_media.assert_called_once() + + +async def test_play_media_media_uri_invalid( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test the play media service with an invalid HEOS media uri raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + media_id = "heos://media/1/music_service?name=Pandora&available=False&image_url=" + + with pytest.raises( + HomeAssistantError, + match=re.escape(f"Unable to play media: Invalid media id '{media_id}'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: media_id, + ATTR_MEDIA_CONTENT_TYPE: "", + }, + blocking=True, + ) + controller.play_media.assert_not_called() + + +async def test_play_media_music_source_url( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test the play media service with a music source url.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/test.mp3", + ATTR_MEDIA_CONTENT_TYPE: "", + }, + blocking=True, + ) + controller.play_url.assert_called_once() + + +async def test_play_media_queue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test the play media service with type queue.""" + 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_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "queue", + ATTR_MEDIA_CONTENT_ID: "2", + }, + blocking=True, + ) + controller.player_play_queue.assert_called_once_with(1, 2) + + +async def test_play_media_queue_invalid( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the play media service with an invalid queue id.""" + 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: Invalid queue id 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "queue", + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) + assert controller.player_play_queue.call_count == 0 + + +async def test_browse_media_root( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + music_sources: dict[int, MediaMusicSource], + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing the root.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + + controller.mock_set_music_sources(music_sources) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_root_no_media_source( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + music_sources: dict[int, MediaMusicSource], + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing the root.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_music_sources(music_sources) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_root_source_error_continues( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing the root with an error getting sources continues.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.get_music_sources.side_effect = HeosError("error") + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + assert "Unable to load music sources" in caplog.text + + +async def test_browse_media_heos_media( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + hass_ws_client: WebSocketGenerator, + pandora_browse_result: BrowseResult, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a heos media item.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.browse_media.return_value = pandora_browse_result + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_heos_media_error_returns_empty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a heos media item results in an error, returns empty children.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.browse_media.side_effect = HeosError("error") + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + assert "Unable to browse media" in caplog.text + + +async def test_browse_media_media_source( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a media source.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "media-source://media_source/local/.", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_invalid_content_id( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test browsing an invalid content id fails.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "invalid", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert not response["success"] + + @pytest.mark.parametrize( ("members", "expected"), [ @@ -1465,3 +1747,84 @@ async def test_media_player_group_fails_wrong_integration( blocking=True, ) controller.set_group.assert_not_called() + + +async def test_get_queue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + queue: list[QueueItem], + snapshot: SnapshotAssertion, +) -> None: + """Test the get queue service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_get_queue.return_value = queue + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_QUEUE, + { + ATTR_ENTITY_ID: "media_player.test_player", + }, + blocking=True, + return_response=True, + ) + controller.player_get_queue.assert_called_once_with(1, None, None) + assert response == snapshot + + +async def test_remove_from_queue( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the get queue service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_FROM_QUEUE, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_QUEUE_IDS: [1, "2"]}, + blocking=True, + ) + controller.player_remove_from_queue.assert_called_once_with(1, [1, 2]) + + +async def test_move_queue_item_queue( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the move queue service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_MOVE_QUEUE_ITEM, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_QUEUE_IDS: [1, "2"], + ATTR_DESTINATION_POSITION: 10, + }, + blocking=True, + ) + controller.player_move_queue_item.assert_called_once_with(1, [1, 2], 10) + + +async def test_move_queue_item_queue_error_raises( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test move queue raises error when failed.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_move_queue_item.side_effect = HeosError("error") + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to move queue item: error"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_MOVE_QUEUE_ITEM, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_QUEUE_IDS: [1, "2"], + ATTR_DESTINATION_POSITION: 10, + }, + blocking=True, + ) diff --git a/tests/components/here_travel_time/test_init.py b/tests/components/here_travel_time/test_init.py index 682d8c560bb..ff09c7e6ae9 100644 --- a/tests/components/here_travel_time/test_init.py +++ b/tests/components/here_travel_time/test_init.py @@ -50,4 +50,3 @@ async def test_unload_entry(hass: HomeAssistant, options) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) - assert not hass.data[DOMAIN] diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py index 7b84c47e81b..c9577e20fcf 100644 --- a/tests/components/history/test_websocket_api_schema_32.py +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -1,5 +1,7 @@ """The tests the History component websocket_api.""" +from collections.abc import Generator + import pytest from homeassistant.components import recorder @@ -17,9 +19,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 721e540b04d..ee426cf3048 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -122,6 +122,7 @@ async def test_setup_multiple_states( }, ], ) +@pytest.mark.usefixtures("hass") def test_setup_invalid_config(config) -> None: """Test the history statistics sensor setup with invalid config.""" @@ -968,6 +969,135 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_mul assert hass.states.get("sensor.sensor4").state == "87.5" +async def test_start_from_history_then_watch_state_changes_sliding( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test we startup from history and switch to watching state changes. + + With a sliding window, history_stats does not requery the recorder. + """ + await hass.config.async_set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + time = start_time + + def _fake_states(*args, **kwargs): + return { + "binary_sensor.state": [ + ha.State( + "binary_sensor.state", + "off", + last_changed=start_time - timedelta(hours=1), + last_updated=start_time - timedelta(hours=1), + ), + ] + } + + 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": f"sensor{i}", + "state": "on", + "end": "{{ utcnow() }}", + "duration": {"hours": 1}, + "type": sensor_type, + } + for i, sensor_type in enumerate(["time", "ratio", "count"]) + ] + }, + ) + await hass.async_block_till_done() + + for i in range(3): + await async_update_entity(hass, f"sensor.sensor{i}") + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "0" + + with freeze_time(time): + hass.states.async_set("binary_sensor.state", "on") + await hass.async_block_till_done() + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "1" + + # After sensor has been on for 15 minutes, check state + time += timedelta(minutes=15) # 00:15 + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.25" + assert hass.states.get("sensor.sensor1").state == "25.0" + assert hass.states.get("sensor.sensor2").state == "1" + + with freeze_time(time): + hass.states.async_set("binary_sensor.state", "off") + await hass.async_block_till_done() + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + time += timedelta(minutes=30) # 00:45 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.25" + assert hass.states.get("sensor.sensor1").state == "25.0" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=20) # 01:05 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + # Sliding window will have started to erase the initial on period, so now it will only be on for 10 minutes + assert hass.states.get("sensor.sensor0").state == "0.17" + assert hass.states.get("sensor.sensor1").state == "16.7" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=5) # 01:10 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + # Sliding window will continue to erase the initial on period, so now it will only be on for 5 minutes + assert hass.states.get("sensor.sensor0").state == "0.08" + assert hass.states.get("sensor.sensor1").state == "8.3" + assert hass.states.get("sensor.sensor2").state == "1" + + time += timedelta(minutes=10) # 01:20 + + with freeze_time(time): + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor0").state == "0.0" + assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "0" + + async def test_does_not_work_into_the_future( recorder_mock: Recorder, hass: HomeAssistant ) -> None: @@ -1365,10 +1495,6 @@ async def test_measure_from_end_going_backwards( past_next_update = start_time + timedelta(minutes=30) with ( - patch( - "homeassistant.components.recorder.history.state_changes_during_period", - _fake_states, - ), freeze_time(past_next_update), ): async_fire_time_changed(hass, past_next_update) @@ -1525,29 +1651,10 @@ async def test_state_change_during_window_rollover( 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. + # One minute has passed and the time has now rolled over into a new day, resetting the recorder window. + # The sensor will be ON since 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), - ): + with 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) diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py index db58b7b1f73..6733d38442b 100644 --- a/tests/components/holiday/test_calendar.py +++ b/tests/components/holiday/test_calendar.py @@ -49,7 +49,7 @@ async def test_holiday_calendar_entity( SERVICE_GET_EVENTS, { "entity_id": "calendar.united_states_ak", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -135,7 +135,7 @@ async def test_default_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.france_bl", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -164,7 +164,7 @@ async def test_default_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.france_bl", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -211,7 +211,7 @@ async def test_no_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.albania", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -308,7 +308,7 @@ async def test_language_not_exist( SERVICE_GET_EVENTS, { "entity_id": "calendar.norge", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -336,7 +336,7 @@ async def test_language_not_exist( SERVICE_GET_EVENTS, { "entity_id": "calendar.norge", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, diff --git a/tests/components/home_connect/__init__.py b/tests/components/home_connect/__init__.py index 2b61501c59a..8c256cb23f3 100644 --- a/tests/components/home_connect/__init__.py +++ b/tests/components/home_connect/__init__.py @@ -1 +1,16 @@ """Tests for the Home Connect integration.""" + +from typing import Any + +from aiohomeconnect.model import ArrayOfStatus + +from tests.common import load_json_object_fixture + +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") +MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") +MOCK_STATUS = ArrayOfStatus.from_dict( + load_json_object_fixture("home_connect/status.json")["data"] # type: ignore[arg-type] +) +MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( + "home_connect/available_commands.json" +) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 49cbc89ba41..4442f9622de 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -15,7 +15,6 @@ from aiohomeconnect.model import ( ArrayOfOptions, ArrayOfPrograms, ArrayOfSettings, - ArrayOfStatus, Event, EventKey, EventMessage, @@ -37,28 +36,22 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture - -MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( - load_json_object_fixture("home_connect/appliances.json")["data"] -) -MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") -MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") -MOCK_STATUS = ArrayOfStatus.from_dict( - load_json_object_fixture("home_connect/status.json")["data"] -) -MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( - "home_connect/available_commands.json" -) +from . import MOCK_AVAILABLE_COMMANDS, MOCK_PROGRAMS, MOCK_SETTINGS, MOCK_STATUS +from tests.common import MockConfigEntry, load_fixture CLIENT_ID = "1234" CLIENT_SECRET = "5678" -FAKE_ACCESS_TOKEN = "some-access-token" +FAKE_ACCESS_TOKEN = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ" + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" +) FAKE_REFRESH_TOKEN = "some-refresh-token" FAKE_AUTH_IMPL = "conftest-imported-cred" @@ -96,7 +89,8 @@ def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: "auth_implementation": FAKE_AUTH_IMPL, "token": token_entry, }, - minor_version=2, + minor_version=3, + unique_id="1234567890", ) @@ -113,7 +107,20 @@ def mock_config_entry_v1_1(token_entry: dict[str, Any]) -> MockConfigEntry: ) -@pytest.fixture +@pytest.fixture(name="config_entry_v1_2") +def mock_config_entry_v1_2(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": FAKE_AUTH_IMPL, + "token": token_entry, + }, + minor_version=2, + ) + + +@pytest.fixture(autouse=True) async def setup_credentials(hass: HomeAssistant) -> None: """Fixture to setup credentials.""" assert await async_setup_component(hass, "application_credentials", {}) @@ -141,6 +148,7 @@ async def mock_integration_setup( config_entry.add_to_hass(hass) async def run(client: MagicMock) -> bool: + assert config_entry.state is ConfigEntryState.NOT_LOADED with ( patch("homeassistant.components.home_connect.PLATFORMS", platforms), patch( @@ -155,14 +163,6 @@ async def mock_integration_setup( return run -def _get_specific_appliance_side_effect(ha_id: str) -> HomeAppliance: - """Get specific appliance side effect.""" - for appliance in copy.deepcopy(MOCK_APPLIANCES).homeappliances: - if appliance.ha_id == ha_id: - return appliance - raise HomeConnectApiError("error.key", "error description") - - def _get_set_program_side_effect( event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey ): @@ -278,68 +278,12 @@ def _get_set_program_options_side_effect( return set_program_options_side_effect -async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: - """Get all programs.""" - appliance_type = next( - appliance - for appliance in MOCK_APPLIANCES.homeappliances - if appliance.ha_id == ha_id - ).type - if appliance_type not in MOCK_PROGRAMS: - raise HomeConnectApiError("error.key", "error description") - - return ArrayOfPrograms( - [ - EnumerateProgram.from_dict(program) - for program in MOCK_PROGRAMS[appliance_type]["data"]["programs"] - ], - Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), - Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), - ) - - -async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: - """Get settings.""" - return ArrayOfSettings.from_dict( - MOCK_SETTINGS.get( - next( - appliance - for appliance in MOCK_APPLIANCES.homeappliances - if appliance.ha_id == ha_id - ).type, - {}, - ).get("data", {"settings": []}) - ) - - -async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): - """Get setting.""" - for appliance in MOCK_APPLIANCES.homeappliances: - if appliance.ha_id == ha_id: - settings = MOCK_SETTINGS.get( - next( - appliance - for appliance in MOCK_APPLIANCES.homeappliances - if appliance.ha_id == ha_id - ).type, - {}, - ).get("data", {"settings": []}) - for setting_dict in cast(list[dict], settings["settings"]): - if setting_dict["key"] == setting_key: - return GetSetting.from_dict(setting_dict) - raise HomeConnectApiError("error.key", "error description") - - -async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: - """Get available commands.""" - for appliance in MOCK_APPLIANCES.homeappliances: - if appliance.ha_id == ha_id and appliance.type in MOCK_AVAILABLE_COMMANDS: - return ArrayOfCommands.from_dict(MOCK_AVAILABLE_COMMANDS[appliance.type]) - raise HomeConnectApiError("error.key", "error description") - - @pytest.fixture(name="client") -def mock_client(request: pytest.FixtureRequest) -> MagicMock: +def mock_client( + appliances: list[HomeAppliance], + appliance: HomeAppliance | None, + request: pytest.FixtureRequest, +) -> MagicMock: """Fixture to mock Client from HomeConnect.""" mock = MagicMock( @@ -376,17 +320,78 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: ] ) + appliances = [appliance] if appliance else appliances + async def stream_all_events() -> AsyncGenerator[EventMessage]: """Mock stream_all_events.""" while True: for event in await event_queue.get(): yield event - mock.get_home_appliances = AsyncMock(return_value=copy.deepcopy(MOCK_APPLIANCES)) + mock.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances(appliances)) + + def _get_specific_appliance_side_effect(ha_id: str) -> HomeAppliance: + """Get specific appliance side effect.""" + for appliance_ in appliances: + if appliance_.ha_id == ha_id: + return appliance_ + raise HomeConnectApiError("error.key", "error description") + mock.get_specific_appliance = AsyncMock( side_effect=_get_specific_appliance_side_effect ) mock.stream_all_events = stream_all_events + + async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: + """Get all programs.""" + appliance_type = next( + appliance for appliance in appliances if appliance.ha_id == ha_id + ).type + if appliance_type not in MOCK_PROGRAMS: + raise HomeConnectApiError("error.key", "error description") + + return ArrayOfPrograms( + [ + EnumerateProgram.from_dict(program) + for program in MOCK_PROGRAMS[appliance_type]["data"]["programs"] + ], + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + ) + + async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + """Get settings.""" + return ArrayOfSettings.from_dict( + MOCK_SETTINGS.get( + next( + appliance for appliance in appliances if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + ) + + async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): + """Get setting.""" + for appliance_ in appliances: + if appliance_.ha_id == ha_id: + settings = MOCK_SETTINGS.get( + appliance_.type, + {}, + ).get("data", {"settings": []}) + for setting_dict in cast(list[dict], settings["settings"]): + if setting_dict["key"] == setting_key: + return GetSetting.from_dict(setting_dict) + raise HomeConnectApiError("error.key", "error description") + + async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + """Get available commands.""" + for appliance_ in appliances: + if appliance_.ha_id == ha_id and appliance_.type in MOCK_AVAILABLE_COMMANDS: + return ArrayOfCommands.from_dict( + MOCK_AVAILABLE_COMMANDS[appliance_.type] + ) + raise HomeConnectApiError("error.key", "error description") + mock.start_program = AsyncMock( side_effect=_get_set_program_side_effect( event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM @@ -438,7 +443,11 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: @pytest.fixture(name="client_with_exception") -def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: +def mock_client_with_exception( + appliances: list[HomeAppliance], + appliance: HomeAppliance | None, + request: pytest.FixtureRequest, +) -> MagicMock: """Fixture to mock Client from HomeConnect that raise exceptions.""" mock = MagicMock( autospec=HomeConnectClient, @@ -456,7 +465,8 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: for event in await event_queue.get(): yield event - mock.get_home_appliances = AsyncMock(return_value=copy.deepcopy(MOCK_APPLIANCES)) + appliances = [appliance] if appliance else appliances + mock.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances(appliances)) mock.stream_all_events = stream_all_events mock.start_program = AsyncMock(side_effect=exception) @@ -483,13 +493,39 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: return mock -@pytest.fixture(name="appliance_ha_id") -def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str: - """Fixture to mock Appliance.""" - app = "Washer" +@pytest.fixture(name="appliances") +def mock_appliances( + appliances_data: str, request: pytest.FixtureRequest +) -> list[HomeAppliance]: + """Fixture to mock the returned appliances.""" + appliances = ArrayOfHomeAppliances.from_json(appliances_data).homeappliances + appliance_types = {appliance.type for appliance in appliances} if hasattr(request, "param") and request.param: - app = request.param - for appliance in MOCK_APPLIANCES.homeappliances: - if appliance.type == app: - return appliance.ha_id - raise ValueError(f"Appliance {app} not found") + appliance_types = request.param + return [appliance for appliance in appliances if appliance.type in appliance_types] + + +@pytest.fixture(name="appliance") +def mock_appliance( + appliances_data: str, request: pytest.FixtureRequest +) -> HomeAppliance | None: + """Fixture to mock a single specific appliance to return.""" + appliance_type = None + if hasattr(request, "param") and request.param: + appliance_type = request.param + return next( + ( + appliance + for appliance in ArrayOfHomeAppliances.from_json( + appliances_data + ).homeappliances + if appliance.type == appliance_type + ), + None, + ) + + +@pytest.fixture(name="appliances_data") +def appliances_data_fixture() -> str: + """Fixture to return a the string for an array of appliances.""" + return load_fixture("appliances.json", integration=DOMAIN) diff --git a/tests/components/home_connect/fixtures/appliances.json b/tests/components/home_connect/fixtures/appliances.json index ada18b3482c..3d2e236b28c 100644 --- a/tests/components/home_connect/fixtures/appliances.json +++ b/tests/components/home_connect/fixtures/appliances.json @@ -1,123 +1,121 @@ { - "data": { - "homeappliances": [ - { - "name": "FridgeFreezer", - "brand": "SIEMENS", - "vib": "HCS05FRF1", - "connected": true, - "type": "FridgeFreezer", - "enumber": "HCS05FRF1/03", - "haId": "SIEMENS-HCS05FRF1-304F4F9E541D" - }, - { - "name": "Dishwasher", - "brand": "SIEMENS", - "vib": "HCS02DWH1", - "connected": true, - "type": "Dishwasher", - "enumber": "HCS02DWH1/03", - "haId": "SIEMENS-HCS02DWH1-6BE58C26DCC1" - }, - { - "name": "Oven", - "brand": "BOSCH", - "vib": "HCS01OVN1", - "connected": true, - "type": "Oven", - "enumber": "HCS01OVN1/03", - "haId": "BOSCH-HCS01OVN1-43E0065FE245" - }, - { - "name": "Washer", - "brand": "SIEMENS", - "vib": "HCS03WCH1", - "connected": true, - "type": "Washer", - "enumber": "HCS03WCH1/03", - "haId": "SIEMENS-HCS03WCH1-7BC6383CF794" - }, - { - "name": "Dryer", - "brand": "BOSCH", - "vib": "HCS04DYR1", - "connected": true, - "type": "Dryer", - "enumber": "HCS04DYR1/03", - "haId": "BOSCH-HCS04DYR1-831694AE3C5A" - }, - { - "name": "CoffeeMaker", - "brand": "BOSCH", - "vib": "HCS06COM1", - "connected": true, - "type": "CoffeeMaker", - "enumber": "HCS06COM1/03", - "haId": "BOSCH-HCS06COM1-D70390681C2C" - }, - { - "name": "WasherDryer", - "brand": "BOSCH", - "vib": "HCS000001", - "connected": true, - "type": "WasherDryer", - "enumber": "HCS000000/01", - "haId": "BOSCH-HCS000000-D00000000001" - }, - { - "name": "Refrigerator", - "brand": "BOSCH", - "vib": "HCS000002", - "connected": true, - "type": "Refrigerator", - "enumber": "HCS000000/02", - "haId": "BOSCH-HCS000000-D00000000002" - }, - { - "name": "Freezer", - "brand": "BOSCH", - "vib": "HCS000003", - "connected": true, - "type": "Freezer", - "enumber": "HCS000000/03", - "haId": "BOSCH-HCS000000-D00000000003" - }, - { - "name": "Hood", - "brand": "BOSCH", - "vib": "HCS000004", - "connected": true, - "type": "Hood", - "enumber": "HCS000000/04", - "haId": "BOSCH-HCS000000-D00000000004" - }, - { - "name": "Hob", - "brand": "BOSCH", - "vib": "HCS000005", - "connected": true, - "type": "Hob", - "enumber": "HCS000000/05", - "haId": "BOSCH-HCS000000-D00000000005" - }, - { - "name": "CookProcessor", - "brand": "BOSCH", - "vib": "HCS000006", - "connected": true, - "type": "CookProcessor", - "enumber": "HCS000000/06", - "haId": "BOSCH-HCS000000-D00000000006" - }, - { - "name": "DNE", - "brand": "BOSCH", - "vib": "HCS000000", - "connected": true, - "type": "DNE", - "enumber": "HCS000000/00", - "haId": "BOSCH-000000000-000000000000" - } - ] - } + "homeappliances": [ + { + "name": "FridgeFreezer", + "brand": "SIEMENS", + "vib": "HCS05FRF1", + "connected": true, + "type": "FridgeFreezer", + "enumber": "HCS05FRF1/03", + "haId": "SIEMENS-HCS05FRF1-304F4F9E541D" + }, + { + "name": "Dishwasher", + "brand": "SIEMENS", + "vib": "HCS02DWH1", + "connected": true, + "type": "Dishwasher", + "enumber": "HCS02DWH1/03", + "haId": "SIEMENS-HCS02DWH1-6BE58C26DCC1" + }, + { + "name": "Oven", + "brand": "BOSCH", + "vib": "HCS01OVN1", + "connected": true, + "type": "Oven", + "enumber": "HCS01OVN1/03", + "haId": "BOSCH-HCS01OVN1-43E0065FE245" + }, + { + "name": "Washer", + "brand": "SIEMENS", + "vib": "HCS03WCH1", + "connected": true, + "type": "Washer", + "enumber": "HCS03WCH1/03", + "haId": "SIEMENS-HCS03WCH1-7BC6383CF794" + }, + { + "name": "Dryer", + "brand": "BOSCH", + "vib": "HCS04DYR1", + "connected": true, + "type": "Dryer", + "enumber": "HCS04DYR1/03", + "haId": "BOSCH-HCS04DYR1-831694AE3C5A" + }, + { + "name": "CoffeeMaker", + "brand": "BOSCH", + "vib": "HCS06COM1", + "connected": true, + "type": "CoffeeMaker", + "enumber": "HCS06COM1/03", + "haId": "BOSCH-HCS06COM1-D70390681C2C" + }, + { + "name": "WasherDryer", + "brand": "BOSCH", + "vib": "HCS000001", + "connected": true, + "type": "WasherDryer", + "enumber": "HCS000000/01", + "haId": "BOSCH-HCS000000-D00000000001" + }, + { + "name": "Refrigerator", + "brand": "BOSCH", + "vib": "HCS000002", + "connected": true, + "type": "Refrigerator", + "enumber": "HCS000000/02", + "haId": "BOSCH-HCS000000-D00000000002" + }, + { + "name": "Freezer", + "brand": "BOSCH", + "vib": "HCS000003", + "connected": true, + "type": "Freezer", + "enumber": "HCS000000/03", + "haId": "BOSCH-HCS000000-D00000000003" + }, + { + "name": "Hood", + "brand": "BOSCH", + "vib": "HCS000004", + "connected": true, + "type": "Hood", + "enumber": "HCS000000/04", + "haId": "BOSCH-HCS000000-D00000000004" + }, + { + "name": "Hob", + "brand": "BOSCH", + "vib": "HCS000005", + "connected": true, + "type": "Hob", + "enumber": "HCS000000/05", + "haId": "BOSCH-HCS000000-68A40E000000" + }, + { + "name": "CookProcessor", + "brand": "BOSCH", + "vib": "HCS000006", + "connected": true, + "type": "CookProcessor", + "enumber": "HCS000000/06", + "haId": "123456789012345678" + }, + { + "name": "DNE", + "brand": "BOSCH", + "vib": "HCS000000", + "connected": true, + "type": "DNE", + "enumber": "HCS000000/00", + "haId": "BOSCH-000000000-000000000000" + } + ] } diff --git a/tests/components/home_connect/fixtures/programs.json b/tests/components/home_connect/fixtures/programs.json index bba1a5d2721..e8d8bd24705 100644 --- a/tests/components/home_connect/fixtures/programs.json +++ b/tests/components/home_connect/fixtures/programs.json @@ -181,5 +181,29 @@ } ] } + }, + "Hood": { + "data": { + "programs": [ + { + "key": "Cooking.Common.Program.Hood.Automatic", + "constraints": { + "execution": "selectandstart" + } + }, + { + "key": "Cooking.Common.Program.Hood.Venting", + "constraints": { + "execution": "selectandstart" + } + }, + { + "key": "Cooking.Common.Program.Hood.DelayedShutOff", + "constraints": { + "execution": "selectandstart" + } + } + ] + } } } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index 28f45ce97ba..e18489d5220 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -1,6 +1,26 @@ # serializer version: 1 # name: test_async_get_config_entry_diagnostics dict({ + '123456789012345678': dict({ + 'brand': 'BOSCH', + 'connected': True, + 'e_number': 'HCS000000/06', + 'ha_id': '123456789012345678', + 'name': 'CookProcessor', + 'programs': list([ + ]), + 'settings': dict({ + }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CookProcessor', + 'vib': 'HCS000006', + }), 'BOSCH-000000000-000000000000': dict({ 'brand': 'BOSCH', 'connected': True, @@ -21,6 +41,26 @@ 'type': 'DNE', 'vib': 'HCS000000', }), + 'BOSCH-HCS000000-68A40E000000': dict({ + 'brand': 'BOSCH', + 'connected': True, + 'e_number': 'HCS000000/05', + 'ha_id': 'BOSCH-HCS000000-68A40E000000', + 'name': 'Hob', + 'programs': list([ + ]), + 'settings': dict({ + }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hob', + 'vib': 'HCS000005', + }), 'BOSCH-HCS000000-D00000000001': dict({ 'brand': 'BOSCH', 'connected': True, @@ -90,6 +130,9 @@ 'ha_id': 'BOSCH-HCS000000-D00000000004', 'name': 'Hood', 'programs': list([ + 'Cooking.Common.Program.Hood.Automatic', + 'Cooking.Common.Program.Hood.Venting', + 'Cooking.Common.Program.Hood.DelayedShutOff', ]), 'settings': dict({ 'BSH.Common.Setting.AmbientLightBrightness': 70, @@ -111,46 +154,6 @@ 'type': 'Hood', 'vib': 'HCS000004', }), - 'BOSCH-HCS000000-D00000000005': dict({ - 'brand': 'BOSCH', - 'connected': True, - 'e_number': 'HCS000000/05', - 'ha_id': 'BOSCH-HCS000000-D00000000005', - 'name': 'Hob', - 'programs': list([ - ]), - 'settings': dict({ - }), - 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', - }), - 'type': 'Hob', - 'vib': 'HCS000005', - }), - 'BOSCH-HCS000000-D00000000006': dict({ - 'brand': 'BOSCH', - 'connected': True, - 'e_number': 'HCS000000/06', - 'ha_id': 'BOSCH-HCS000000-D00000000006', - 'name': 'CookProcessor', - 'programs': list([ - ]), - 'settings': dict({ - }), - 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', - }), - 'type': 'CookProcessor', - 'vib': 'HCS000006', - }), 'BOSCH-HCS01OVN1-43E0065FE245': dict({ 'brand': 'BOSCH', 'connected': True, diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_services.ambr similarity index 96% rename from tests/components/home_connect/snapshots/test_init.ambr rename to tests/components/home_connect/snapshots/test_services.ambr index 709621aaefb..610e9fa1248 100644 --- a/tests/components/home_connect/snapshots/test_init.ambr +++ b/tests/components/home_connect/snapshots/test_services.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_set_program_and_options[service_call0-set_selected_program] +# name: test_set_program_and_options[service_call0-set_selected_program-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -18,7 +18,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call1-start_program] +# name: test_set_program_and_options[service_call1-start_program-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -37,7 +37,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call2-set_active_program_options] +# name: test_set_program_and_options[service_call2-set_active_program_options-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -57,7 +57,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call3-set_selected_program_options] +# name: test_set_program_and_options[service_call3-set_selected_program_options-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index a06e386b84f..a88c8954c64 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -3,21 +3,23 @@ from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, MagicMock -from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType +from aiohomeconnect.model import ( + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + HomeAppliance, + StatusKey, +) from aiohomeconnect.model.error import HomeConnectApiError import pytest -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( STATE_OFF, @@ -28,8 +30,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -40,34 +40,21 @@ def platforms() -> list[str]: return [Platform.BINARY_SENSOR] -async def test_binary_sensors( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test binary sensor entities.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -75,7 +62,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -83,7 +70,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -92,7 +79,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -100,20 +87,30 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize( + ("appliance", "keys_to_check"), + [ + ( + "Washer", + (StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE,), + ) + ], + indirect=["appliance"], +) async def test_connected_devices( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -123,26 +120,35 @@ async def test_connected_devices( get_status_original_mock = client.get_status def get_status_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return get_status_original_mock.return_value client.get_status = AsyncMock(side_effect=get_status_side_effect) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_status = get_status_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device - entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, + DOMAIN, + f"{appliance.ha_id}-{EventKey.BSH_COMMON_APPLIANCE_CONNECTED}", + ) + for key in keys_to_check: + assert not entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -150,28 +156,29 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) - assert device - new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) - assert len(new_entity_entries) > len(entity_entries) + for key in (*keys_to_check, EventKey.BSH_COMMON_APPLIANCE_CONNECTED): + assert entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) -async def test_binary_sensors_entity_availabilty( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_binary_sensors_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if binary sensor entities availability are based on the appliance connection state.""" entity_ids = [ - "binary_sensor.washer_door", "binary_sensor.washer_remote_control", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -181,7 +188,7 @@ async def test_binary_sensors_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -195,7 +202,7 @@ async def test_binary_sensors_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -210,56 +217,7 @@ async def test_binary_sensors_entity_availabilty( @pytest.mark.parametrize( - ("value", "expected"), - [ - (BSH_DOOR_STATE_CLOSED, "off"), - (BSH_DOOR_STATE_LOCKED, "off"), - (BSH_DOOR_STATE_OPEN, "on"), - ("", STATE_UNKNOWN), - ], -) -async def test_binary_sensors_door_states( - appliance_ha_id: str, - expected: str, - value: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Tests for Appliance door states.""" - entity_id = "binary_sensor.washer_door" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - await client.add_events( - [ - EventMessage( - appliance_ha_id, - EventType.STATUS, - ArrayOfEvents( - [ - Event( - key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, - raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, - timestamp=0, - level="", - handling="", - value=value, - ) - ], - ), - ) - ] - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, expected) - - -@pytest.mark.parametrize( - ("entity_id", "event_key", "event_value_update", "expected", "appliance_ha_id"), + ("entity_id", "event_key", "event_value_update", "expected", "appliance"), [ ( "binary_sensor.washer_remote_control", @@ -304,28 +262,26 @@ async def test_binary_sensors_door_states( "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_binary_sensors_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, event_key: EventKey, event_value_update: str, - appliance_ha_id: str, + appliance: HomeAppliance, expected: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Tests for Home Connect Fridge appliance door states.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -346,26 +302,25 @@ async def test_binary_sensors_functionality( assert hass.states.is_state(entity_id, expected) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_sensor_functionality( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if the connected binary sensor reports the right values.""" entity_id = "binary_sensor.washer_connectivity" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert hass.states.is_state(entity_id, STATE_ON) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -378,7 +333,7 @@ async def test_connected_sensor_functionality( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -387,68 +342,3 @@ async def test_connected_sensor_functionality( await hass.async_block_till_done() assert hass.states.is_state(entity_id, STATE_ON) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_create_issue( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Test we create an issue when an automation or script is using a deprecated entity.""" - entity_id = "binary_sensor.washer_door" - issue_id = f"deprecated_binary_common_door_sensor_{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": [ - { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - 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_button.py b/tests/components/home_connect/test_button.py index 5af7e40ca43..ee4d5f1d729 100644 --- a/tests/components/home_connect/test_button.py +++ b/tests/components/home_connect/test_button.py @@ -4,7 +4,12 @@ from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import AsyncMock, MagicMock -from aiohomeconnect.model import ArrayOfCommands, CommandKey, EventMessage +from aiohomeconnect.model import ( + ArrayOfCommands, + CommandKey, + EventMessage, + HomeAppliance, +) from aiohomeconnect.model.command import Command from aiohomeconnect.model.error import HomeConnectApiError from aiohomeconnect.model.event import ArrayOfEvents, EventType @@ -27,35 +32,21 @@ def platforms() -> list[str]: return [Platform.BUTTON] -async def test_buttons( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test button entities.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -63,7 +54,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -71,7 +62,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -80,7 +71,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -88,20 +79,30 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize( + ("appliance", "keys_to_check"), + [ + ( + "Washer", + (CommandKey.BSH_COMMON_PAUSE_PROGRAM,), + ) + ], + indirect=["appliance"], +) async def test_connected_devices( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -109,42 +110,49 @@ async def test_connected_devices( not be obtained while disconnected and once connected, the entities are added. """ get_available_commands_original_mock = client.get_available_commands - get_available_programs_mock = client.get_available_programs + get_all_programs_mock = client.get_all_programs async def get_available_commands_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_available_commands_original_mock.side_effect(ha_id) - async def get_available_programs_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + async def get_all_programs_side_effect(ha_id: str): + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) - return await get_available_programs_mock.side_effect(ha_id) + return await get_all_programs_mock.side_effect(ha_id) client.get_available_commands = AsyncMock( side_effect=get_available_commands_side_effect ) - client.get_available_programs = AsyncMock( - side_effect=get_available_programs_side_effect - ) - assert config_entry.state == ConfigEntryState.NOT_LOADED + client.get_all_programs = AsyncMock(side_effect=get_all_programs_side_effect) assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_available_commands = get_available_commands_original_mock - client.get_available_programs = get_available_programs_mock + client.get_all_programs = get_all_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device - entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_registry.async_get_entity_id( + Platform.BUTTON, + DOMAIN, + f"{appliance.ha_id}-StopProgram", + ) + for key in keys_to_check: + assert not entity_registry.async_get_entity_id( + Platform.BUTTON, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -152,28 +160,29 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) - assert device - new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) - assert len(new_entity_entries) > len(entity_entries) + for key in (*keys_to_check, "StopProgram"): + assert entity_registry.async_get_entity_id( + Platform.BUTTON, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) -async def test_button_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_button_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if button entities availability are based on the appliance connection state.""" entity_ids = [ "button.washer_pause_program", "button.washer_stop_program", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -183,7 +192,7 @@ async def test_button_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -197,7 +206,7 @@ async def test_button_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -211,6 +220,7 @@ async def test_button_entity_availabilty( assert state.state != STATE_UNAVAILABLE +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("entity_id", "method_call", "expected_kwargs"), [ @@ -224,19 +234,17 @@ async def test_button_entity_availabilty( ) async def test_button_functionality( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, entity_id: str, method_call: str, expected_kwargs: dict[str, Any], - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if button entities availability are based on the appliance connection state.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity = hass.states.get(entity_id) assert entity @@ -248,15 +256,14 @@ async def test_button_functionality( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - getattr(client, method_call).assert_called_with(appliance_ha_id, **expected_kwargs) + getattr(client, method_call).assert_called_with(appliance.ha_id, **expected_kwargs) async def test_command_button_exception( hass: HomeAssistant, + client_with_exception: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test if button entities availability are based on the appliance connection state.""" entity_id = "button.washer_pause_program" @@ -271,9 +278,8 @@ async def test_command_button_exception( ] ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity = hass.states.get(entity_id) assert entity @@ -290,17 +296,15 @@ async def test_command_button_exception( async def test_stop_program_button_exception( hass: HomeAssistant, + client_with_exception: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test if button entities availability are based on the appliance connection state.""" entity_id = "button.washer_stop_program" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity = hass.states.get(entity_id) assert entity diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index c35678e4e5f..3d239d63bd0 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -5,17 +5,18 @@ from http import HTTPStatus from unittest.mock import MagicMock, patch from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from aiohomeconnect.model import HomeAppliance import pytest from homeassistant import config_entries, setup -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.home_connect.const import DOMAIN +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 import config_entry_oauth2_flow, device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .conftest import FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -24,6 +25,39 @@ from tests.typing import ClientSessionGenerator CLIENT_ID = "1234" CLIENT_SECRET = "5678" +DHCP_DISCOVERY = ( + DhcpServiceInfo( + ip="1.1.1.1", + hostname="balay-dishwasher-000000000000000000", + macaddress="C8:D7:78:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="BOSCH-ABCDE1234-68A40E000000", + macaddress="68:A4:0E:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="SIEMENS-ABCDE1234-68A40E000000", + macaddress="68:A4:0E:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="SIEMENS-ABCDE1234-38B4D3000000", + macaddress="38:B4:D3:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="siemens-dishwasher-000000000000000000", + macaddress="68:A4:0E:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="siemens-dishwasher-000000000000000000", + macaddress="38:B4:D3:00:00:00", + ), +) + @pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( @@ -34,10 +68,6 @@ async def test_full_flow( """Check full flow.""" assert await setup.async_setup_component(hass, "home_connect", {}) - await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) - ) - result = await hass.config_entries.flow.async_init( "home_connect", context={"source": config_entries.SOURCE_USER} ) @@ -64,8 +94,8 @@ async def test_full_flow( aioclient_mock.post( OAUTH2_TOKEN, json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": FAKE_ACCESS_TOKEN, "type": "Bearer", "expires_in": 60, }, @@ -77,36 +107,72 @@ async def test_full_flow( 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 hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890") assert len(mock_setup_entry.mock_calls) == 1 -async def test_prevent_multiple_config_entries( +@pytest.mark.usefixtures("current_request_with_host") +async def test_prevent_reconfiguring_same_account( hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, ) -> None: - """Test we only allow one config entry.""" + """Test we only allow one config entry per account.""" config_entry.add_to_hass(hass) + assert await setup.async_setup_component(hass, "home_connect", {}) + result = await hass.config_entries.flow.async_init( "home_connect", context={"source": config_entries.SOURCE_USER} ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) - assert result["type"] == "abort" - assert result["reason"] == "single_instance_allowed" + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": FAKE_ACCESS_TOKEN, + "type": "Bearer", + "expires_in": 60, + }, + ) + + 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" @pytest.mark.usefixtures("current_request_with_host") async def test_reauth_flow( hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, ) -> None: """Test reauth flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM @@ -129,8 +195,8 @@ async def test_reauth_flow( aioclient_mock.post( OAUTH2_TOKEN, json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": FAKE_ACCESS_TOKEN, "type": "Bearer", "expires_in": 60, }, @@ -142,9 +208,251 @@ async def test_reauth_flow( 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 + entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890") + assert entry + assert entry.state is ConfigEntryState.LOADED assert len(mock_setup_entry.mock_calls) == 1 - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow_with_different_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, +) -> 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"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + _client = await hass_client_no_auth() + resp = await _client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiJBQkNERSIsIm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp0cnVlLCJpYXQiOjE1MTYyMzkwMjJ9" + ".Q9z9JT4qgNg9Y9ki61jzvd69j043GFWJk9HNYosAPzs" + ), + "type": "Bearer", + "expires_in": 60, + }, + ) + + 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"] == "wrong_account" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_zeroconf_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test zeroconf flow.""" + assert await setup.async_setup_component(hass, "home_connect", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF} + ) + 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"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": FAKE_ACCESS_TOKEN, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.home_connect.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890") + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_zeroconf_flow_already_setup( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, +) -> None: + """Test zeroconf discovery with already setup device.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DHCP_DISCOVERY[0], + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize("dhcp_discovery", DHCP_DISCOVERY) +async def test_dhcp_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + dhcp_discovery: DhcpServiceInfo, +) -> None: + """Test DHCP discovery.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_discovery + ) + 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"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": FAKE_ACCESS_TOKEN, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.home_connect.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234567890") + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_dhcp_flow_already_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery with already setup device.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY[0] + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("dhcp_discovery", "appliance"), + [ + ( + DhcpServiceInfo( + ip="1.1.1.1", + hostname="bosch-cookprocessor-123456789012345678", + macaddress="c8:d7:78:00:00:00", + ), + "CookProcessor", + ), + ( + DhcpServiceInfo( + ip="1.1.1.1", + hostname="BOSCH-HCS000000-68A40E000000", + macaddress="68:a4:0e:00:00:00", + ), + "Hob", + ), + ], + indirect=["appliance"], +) +async def test_dhcp_flow_complete_device_information( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + dhcp_discovery: DhcpServiceInfo, + appliance: HomeAppliance, +) -> None: + """Test DHCP discovery with complete device information.""" + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) + assert device + assert device.connections == set() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_discovery + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) + assert device + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, dhcp_discovery.macaddress) + } diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 51f42a98f42..40af64f9042 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -1,19 +1,23 @@ """Test for Home Connect coordinator.""" from collections.abc import Awaitable, Callable -from typing import Any +from datetime import timedelta +from http import HTTPStatus +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.model import ( ArrayOfEvents, + ArrayOfHomeAppliances, ArrayOfSettings, ArrayOfStatus, Event, EventKey, EventMessage, EventType, - Status, - StatusKey, + GetSetting, + HomeAppliance, + SettingKey, ) from aiohomeconnect.model.error import ( EventStreamInterruptedError, @@ -21,25 +25,52 @@ from aiohomeconnect.model.error import ( HomeConnectError, HomeConnectRequestError, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_PRESENT, BSH_POWER_OFF, + DOMAIN, +) +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, ) from homeassistant.config_entries import ConfigEntries, ConfigEntryState -from homeassistant.const import EVENT_STATE_REPORTED, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_STATE_REPORTED, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import ( Event as HassEvent, EventStateReportedData, HomeAssistant, callback, ) -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +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_fire_time_changed +from tests.typing import ClientSessionGenerator + +INITIAL_FETCH_CLIENT_METHODS = [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", +] @pytest.fixture @@ -48,52 +79,177 @@ def platforms() -> list[str]: return [Platform.SENSOR, Platform.SWITCH] -async def test_coordinator_update( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, +@pytest.mark.parametrize("platforms", [("binary_sensor",)]) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_coordinator_failure_refresh_and_stream( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: - """Test that the coordinator can update.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED + """Test entity available state via coordinator refresh and event stream.""" + appliance_data = ( + cast(str, appliance.to_json()) + .replace("ha_id", "haId") + .replace("e_number", "enumber") + ) + entity_id_1 = "binary_sensor.washer_remote_control" + entity_id_2 = "binary_sensor.washer_remote_start" + await async_setup_component(hass, HA_DOMAIN, {}) await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED + state = hass.states.get(entity_id_1) + assert state + assert state.state != STATE_UNAVAILABLE + state = hass.states.get(entity_id_2) + assert state + assert state.state != STATE_UNAVAILABLE + + client.get_home_appliances.side_effect = HomeConnectError() + + # Force a coordinator refresh. + await hass.services.async_call( + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state == STATE_UNAVAILABLE + state = hass.states.get(entity_id_2) + assert state + assert state.state == STATE_UNAVAILABLE + + # Test that the entity becomes available again after a successful update. + + client.get_home_appliances.side_effect = None + client.get_home_appliances.return_value = ArrayOfHomeAppliances( + [HomeAppliance.from_json(appliance_data)] + ) + + # Move time forward to pass the debounce time. + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh. + await hass.services.async_call( + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state != STATE_UNAVAILABLE + state = hass.states.get(entity_id_2) + assert state + assert state.state != STATE_UNAVAILABLE + + # Test that the event stream makes the entity go available too. + + # First make the entity unavailable. + client.get_home_appliances.side_effect = HomeConnectError() + + # Move time forward to pass the debounce time + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh + await hass.services.async_call( + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state == STATE_UNAVAILABLE + state = hass.states.get(entity_id_2) + assert state + assert state.state == STATE_UNAVAILABLE + + # Now make the entity available again. + client.get_home_appliances.side_effect = None + client.get_home_appliances.return_value = ArrayOfHomeAppliances( + [HomeAppliance.from_json(appliance_data)] + ) + + # One event should make all entities for this appliance available again. + event_message = EventMessage( + appliance.ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + raw_key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE.value, + timestamp=0, + level="", + handling="", + value=False, + ) + ], + ), + ) + await client.add_events([event_message]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state != STATE_UNAVAILABLE + state = hass.states.get(entity_id_2) + assert state + assert state.state != STATE_UNAVAILABLE -async def test_coordinator_update_failing_get_appliances( +@pytest.mark.parametrize( + "appliance", + ["Dishwasher"], + indirect=True, +) +async def test_coordinator_not_fetching_on_disconnected_appliance( + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, + appliance: HomeAppliance, ) -> None: - """Test that the coordinator raises ConfigEntryNotReady when it fails to get appliances.""" - client_with_exception.get_home_appliances.return_value = None - client_with_exception.get_home_appliances.side_effect = HomeConnectError() + """Test that the coordinator does not fetch anything on disconnected appliance.""" + appliance.connected = False - assert config_entry.state == ConfigEntryState.NOT_LOADED - await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.SETUP_RETRY + await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + for method in INITIAL_FETCH_CLIENT_METHODS: + assert getattr(client, method).call_count == 0 -async def test_coordinator_update_failing_get_settings_status( +@pytest.mark.parametrize( + "mock_method", + INITIAL_FETCH_CLIENT_METHODS, +) +async def test_coordinator_update_failing( + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, + mock_method: str, ) -> None: """Test that although is not possible to get settings and status, the config entry is loaded. This is for cases where some appliances are reachable and some are not in the same configuration entry. """ - # Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again - assert config_entry.state == ConfigEntryState.NOT_LOADED - await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + setattr(client, mock_method, AsyncMock(side_effect=HomeConnectError())) + + await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + getattr(client, mock_method).assert_called() -@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) @pytest.mark.parametrize( - ("event_type", "event_key", "event_value", "entity_id"), + ("event_type", "event_key", "event_value", ATTR_ENTITY_ID), [ ( EventType.STATUS, @@ -116,27 +272,25 @@ async def test_coordinator_update_failing_get_settings_status( ], ) async def test_event_listener( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, event_type: EventType, event_key: EventKey, event_value: str, entity_id: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, - entity_registry: er.EntityRegistry, ) -> None: """Test that the event listener works.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED state = hass.states.get(entity_id) - assert state + event_message = EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -156,7 +310,8 @@ async def test_event_listener( new_state = hass.states.get(entity_id) assert new_state - assert new_state.state != state.state + if state is not None: + assert new_state.state != state.state # Following, we are gonna check that the listeners are clean up correctly new_entity_id = entity_id + "_new" @@ -170,7 +325,7 @@ async def test_event_listener( def event_filter(_: EventStateReportedData) -> bool: return True - hass.bus.async_listen(EVENT_STATE_REPORTED, listener_callback, event_filter) + hass.bus.async_listen_once(EVENT_STATE_REPORTED, listener_callback, event_filter) entity_registry.async_update_entity(entity_id, new_entity_id=new_entity_id) await hass.async_block_till_done() @@ -183,26 +338,25 @@ async def test_event_listener( listener.assert_called_once_with(new_entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def tests_receive_setting_and_status_for_first_time_at_events( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test that the event listener is capable of receiving settings and status for the first time.""" client.get_setting = AsyncMock(return_value=ArrayOfSettings([])) client.get_status = AsyncMock(return_value=ArrayOfStatus([])) - assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -218,7 +372,7 @@ async def tests_receive_setting_and_status_for_first_time_at_events( ), ), EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -237,15 +391,14 @@ async def tests_receive_setting_and_status_for_first_time_at_events( ) await hass.async_block_till_done() assert len(config_entry._background_tasks) == 1 - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED async def test_event_listener_error( hass: HomeAssistant, + client_with_exception: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test that the configuration entry is reloaded when the event stream raises an API error.""" client_with_exception.stream_all_events = MagicMock( @@ -264,6 +417,8 @@ async def test_event_listener_error( assert not config_entry._background_tasks +@pytest.mark.parametrize("platforms", [("sensor",)]) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "exception", [HomeConnectRequestError(), EventStreamInterruptedError()], @@ -272,9 +427,6 @@ async def test_event_listener_error( ( "entity_id", "initial_state", - "status_key", - "status_value", - "after_refresh_expected_state", "event_key", "event_value", "after_event_expected_state", @@ -283,34 +435,24 @@ async def test_event_listener_error( ( "sensor.washer_door", "closed", - StatusKey.BSH_COMMON_DOOR_STATE, - BSH_DOOR_STATE_LOCKED, - "locked", EventKey.BSH_COMMON_STATUS_DOOR_STATE, BSH_DOOR_STATE_OPEN, "open", ), ], ) -@patch( - "homeassistant.components.home_connect.coordinator.EVENT_STREAM_RECONNECT_DELAY", 0 -) async def test_event_listener_resilience( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + exception: HomeConnectError, entity_id: str, initial_state: str, - status_key: StatusKey, - status_value: Any, - after_refresh_expected_state: str, event_key: EventKey, event_value: Any, after_event_expected_state: str, - exception: HomeConnectError, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, ) -> None: """Test that the event listener is resilient to interruptions.""" future = hass.loop.create_future() @@ -322,30 +464,28 @@ async def test_event_listener_resilience( side_effect=[stream_exception(), client.stream_all_events()] ) - assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert len(config_entry._background_tasks) == 1 - assert hass.states.is_state(entity_id, initial_state) + state = hass.states.get(entity_id) + assert state + assert state.state == initial_state - client.get_status.return_value = ArrayOfStatus( - [Status(key=status_key, raw_key=status_key.value, value=status_value)], - ) - await hass.async_block_till_done() future.set_exception(exception) await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() assert client.stream_all_events.call_count == 2 - assert hass.states.is_state(entity_id, after_refresh_expected_state) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -364,4 +504,240 @@ async def test_event_listener_resilience( ) await hass.async_block_till_done() - assert hass.states.is_state(entity_id, after_event_expected_state) + state = hass.states.get(entity_id) + assert state + assert state.state == after_event_expected_state + + +async def test_devices_updated_on_refresh( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], +) -> None: + """Test handling of devices added or deleted while event stream is down.""" + appliances: list[HomeAppliance] = ( + client.get_home_appliances.return_value.homeappliances + ) + assert len(appliances) >= 3 + client.get_home_appliances = AsyncMock( + return_value=ArrayOfHomeAppliances(appliances[:2]), + ) + + await async_setup_component(hass, HA_DOMAIN, {}) + await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + for appliance in appliances[:2]: + assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) + assert not device_registry.async_get_device({(DOMAIN, appliances[2].ha_id)}) + + client.get_home_appliances = AsyncMock( + return_value=ArrayOfHomeAppliances(appliances[1:3]), + ) + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "switch.dishwasher_power"}, + blocking=True, + ) + + assert not device_registry.async_get_device({(DOMAIN, appliances[0].ha_id)}) + for appliance in appliances[2:3]: + assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_paired_disconnected_devices_not_fetching( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, +) -> None: + """Test that Home Connect API is not fetched after pairing a disconnected device.""" + client.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances([])) + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + appliance.connected = False + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + client.get_specific_appliance.assert_awaited_once_with(appliance.ha_id) + for method in INITIAL_FETCH_CLIENT_METHODS: + assert getattr(client, method).call_count == 0 + + +async def test_coordinator_disabling_updates_for_appliance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], +) -> None: + """Test coordinator disables appliance updates on frequent connect/paired events. + + A repair issue should be created when the updates are disabled. + When the user confirms the issue the updates should be enabled again. + """ + appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" + issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(8) + ] + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + get_settings_original_side_effect = client.get_settings.side_effect + + async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + if ha_id == appliance_ha_id: + return ArrayOfSettings( + [ + GetSetting( + SettingKey.BSH_COMMON_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE.value, + BSH_POWER_OFF, + ) + ] + ) + return cast(ArrayOfSettings, get_settings_original_side_effect(ha_id)) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + assert resp.status == HTTPStatus.OK + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + + +async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_reload( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], +) -> None: + """Test that updates are enabled again after unloading the entry. + + The repair issue should also be deleted. + """ + appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" + issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(8) + ] + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + get_settings_original_side_effect = client.get_settings.side_effect + + async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + if ha_id == appliance_ha_id: + return ArrayOfSettings( + [ + GetSetting( + SettingKey.BSH_COMMON_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE.value, + BSH_POWER_OFF, + ) + ] + ) + return cast(ArrayOfSettings, get_settings_original_side_effect(ha_id)) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index ab6823411dc..858f331a33d 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -19,33 +19,29 @@ from tests.common import MockConfigEntry async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot async def test_async_get_device_diagnostics( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: """Test device config entry diagnostics.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index 2422cbe547c..61a0c4005fb 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -11,6 +11,7 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + HomeAppliance, Option, OptionKey, Program, @@ -23,6 +24,7 @@ from aiohomeconnect.model.error import ( SelectedProgramNotSetError, ) from aiohomeconnect.model.program import ( + EnumerateProgram, ProgramDefinitionConstraints, ProgramDefinitionOption, ) @@ -66,7 +68,7 @@ def platforms() -> list[str]: ) @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "option_entity_id", "options_state_stage_1", "options_availability_stage_2", @@ -90,22 +92,21 @@ def platforms() -> list[str]: (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_program_options_retrieval( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], array_of_programs_program_arg: str, event_key: EventKey, - appliance_ha_id: str, + appliance: HomeAppliance, option_entity_id: dict[OptionKey, str], options_state_stage_1: list[tuple[str, bool | None]], options_availability_stage_2: list[bool], option_without_default: tuple[OptionKey, str], option_without_constraints: tuple[OptionKey, str], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test that the options are correctly retrieved at the start and updated on program updates.""" original_get_all_programs_mock = client.get_all_programs.side_effect @@ -121,7 +122,7 @@ async def test_program_options_retrieval( ] async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return await original_get_all_programs_mock(ha_id) array_of_programs: ArrayOfPrograms = await original_get_all_programs_mock(ha_id) @@ -156,9 +157,8 @@ async def test_program_options_retrieval( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id, (state, _) in zip( option_entity_id.values(), options_state_stage_1, strict=True @@ -203,7 +203,7 @@ async def test_program_options_retrieval( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, data=ArrayOfEvents( [ @@ -234,6 +234,77 @@ async def test_program_options_retrieval( assert hass.states.is_state(entity_id, STATE_UNKNOWN) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + ("array_of_programs_program_arg", "event_key"), + [ + ( + "active", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + "selected", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ], +) +async def test_no_options_retrieval_on_unknown_program( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + array_of_programs_program_arg: str, + event_key: EventKey, +) -> None: + """Test that no options are retrieved when the program is unknown.""" + + async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: + return ArrayOfPrograms( + **( + { + "programs": [ + EnumerateProgram(ProgramKey.UNKNOWN, "unknown program") + ], + array_of_programs_program_arg: Program( + ProgramKey.UNKNOWN, options=[] + ), + } + ) + ) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_available_program.call_count == 0 + + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert client.get_available_program.call_count == 0 + + @pytest.mark.parametrize( "event_key", [ @@ -242,7 +313,7 @@ async def test_program_options_retrieval( ], ) @pytest.mark.parametrize( - ("appliance_ha_id", "option_key", "option_entity_id"), + ("appliance", "option_key", "option_entity_id"), [ ( "Dishwasher", @@ -250,18 +321,17 @@ async def test_program_options_retrieval( "switch.dishwasher_half_load", ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_program_options_retrieval_after_appliance_connection( - event_key: EventKey, - appliance_ha_id: str, - option_key: OptionKey, - option_entity_id: str, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + event_key: EventKey, + appliance: HomeAppliance, + option_key: OptionKey, + option_entity_id: str, ) -> None: """Test that the options are correctly retrieved at the start and updated on program updates.""" array_of_home_appliances = client.get_home_appliances.return_value @@ -271,7 +341,7 @@ async def test_program_options_retrieval_after_appliance_connection( [ appliance for appliance in array_of_home_appliances.homeappliances - if appliance.ha_id != appliance_ha_id + if appliance.ha_id != appliance.ha_id ] ) @@ -285,16 +355,15 @@ async def test_program_options_retrieval_after_appliance_connection( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert not hass.states.get(option_entity_id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents( [ @@ -332,7 +401,7 @@ async def test_program_options_retrieval_after_appliance_connection( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, data=ArrayOfEvents( [ @@ -375,14 +444,12 @@ async def test_program_options_retrieval_after_appliance_connection( ], ) async def test_option_entity_functionality_exception( - set_active_program_option_side_effect: HomeConnectError | None, - set_selected_program_option_side_effect: HomeConnectError | None, - appliance_ha_id: str, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + set_active_program_option_side_effect: HomeConnectError | None, + set_selected_program_option_side_effect: HomeConnectError | None, ) -> None: """Test that the option entity handles exceptions correctly.""" entity_id = "switch.washer_i_dos_1_active" @@ -399,9 +466,8 @@ async def test_option_entity_functionality_exception( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get(entity_id) diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 6e4e428bf6a..2820eea3031 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -1,17 +1,18 @@ """Test the integration init functionality.""" from collections.abc import Awaitable, Callable -from http import HTTPStatus from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN -from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey -from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError +from aiohomeconnect.model import HomeAppliance, SettingKey, StatusKey +from aiohomeconnect.model.error import ( + HomeConnectError, + TooManyRequestsError, + UnauthorizedError, +) +import aiohttp import pytest -import requests_mock -import respx -from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.home_connect.const import DOMAIN @@ -22,9 +23,8 @@ 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.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir from script.hassfest.translations import RE_TRANSLATION_KEY from .conftest import ( @@ -37,206 +37,37 @@ from .conftest import ( from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - -DEPRECATED_SERVICE_KV_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "set_option_active", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_option_selected", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, -] - -SERVICE_KV_CALL_PARAMS = [ - *DEPRECATED_SERVICE_KV_CALL_PARAMS, - { - "domain": DOMAIN, - "service": "change_setting", - "service_data": { - "device_id": "DEVICE_ID", - "key": SettingKey.BSH_COMMON_CHILD_LOCK.value, - "value": True, - }, - "blocking": True, - }, -] - -SERVICE_COMMAND_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "pause_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "resume_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, -] - - -SERVICE_PROGRAM_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "select_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "start_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, -] - -SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_active_program_option", - "set_option_selected": "set_selected_program_option", - "change_setting": "set_setting", - "pause_program": "put_command", - "resume_program": "put_command", - "select_program": "set_selected_program", - "start_program": "start_program", -} - -SERVICE_VALIDATION_ERROR_MAPPING = { - "set_option_active": r"Error.*setting.*options.*active.*program.*", - "set_option_selected": r"Error.*setting.*options.*selected.*program.*", - "change_setting": r"Error.*assigning.*value.*setting.*", - "pause_program": r"Error.*executing.*command.*", - "resume_program": r"Error.*executing.*command.*", - "select_program": r"Error.*selecting.*program.*", - "start_program": r"Error.*starting.*program.*", -} - - -SERVICES_SET_PROGRAM_AND_OPTIONS = [ - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "selected_program", - "program": "dishcare_dishwasher_program_eco_50", - "b_s_h_common_option_start_in_relative": 1800, - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "active_program", - "program": "consumer_products_coffee_maker_program_beverage_coffee", - "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "active_program", - "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "selected_program", - "consumer_products_coffee_maker_option_fill_quantity": 35, - }, - "blocking": True, - }, -] async def test_entry_setup( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test setup and unload.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.NOT_LOADED - - -async def test_exception_handling( - integration_setup: Callable[[MagicMock], Awaitable[bool]], - config_entry: MockConfigEntry, - setup_credentials: None, - client_with_exception: MagicMock, -) -> None: - """Test exception handling.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED @pytest.mark.parametrize("token_expiration_time", [12345]) -@respx.mock async def test_token_refresh_success( hass: HomeAssistant, - platforms: list[Platform], + aioclient_mock: AiohttpClientMocker, + client: MagicMock, integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - requests_mock: requests_mock.Mocker, - setup_credentials: None, - client: MagicMock, + platforms: list[Platform], ) -> None: """Test where token is expired and the refresh attempt succeeds.""" assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN - requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN) aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, @@ -254,7 +85,7 @@ async def test_token_refresh_success( client._auth = auth return client - assert config_entry.state == ConfigEntryState.NOT_LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED with ( patch("homeassistant.components.home_connect.PLATFORMS", platforms), patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock, @@ -262,7 +93,7 @@ async def test_token_refresh_success( client_mock.side_effect = MagicMock(side_effect=init_side_effect) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED # Verify token request assert aioclient_mock.call_count == 1 @@ -280,6 +111,59 @@ async def test_token_refresh_success( ) +@pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize( + ("aioclient_mock_args", "expected_config_entry_state"), + [ + ( + { + "status": 400, + "json": {"error": "invalid_grant"}, + }, + ConfigEntryState.SETUP_ERROR, + ), + ( + { + "status": 500, + }, + ConfigEntryState.SETUP_RETRY, + ), + ( + { + "exc": aiohttp.ClientError, + }, + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_token_refresh_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + aioclient_mock_args: dict[str, Any], + expected_config_entry_state: ConfigEntryState, +) -> None: + """Test where token is expired and the refresh attempt fails.""" + + config_entry.data["token"]["access_token"] = FAKE_ACCESS_TOKEN + + aioclient_mock.post( + OAUTH2_TOKEN, + **aioclient_mock_args, + ) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + with patch( + "homeassistant.components.home_connect.HomeConnectClient", return_value=client + ): + assert not await integration_setup(client) + await hass.async_block_till_done() + + assert config_entry.state == expected_config_entry_state + + @pytest.mark.parametrize( ("exception", "expected_state"), [ @@ -288,231 +172,77 @@ async def test_token_refresh_success( ], ) async def test_client_error( - exception: HomeConnectError, - expected_state: ConfigEntryState, + client_with_exception: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, + exception: HomeConnectError, + expected_state: ConfigEntryState, ) -> None: """Test client errors during setup integration.""" client_with_exception.get_home_appliances.return_value = None client_with_exception.get_home_appliances.side_effect = exception - assert config_entry.state == ConfigEntryState.NOT_LOADED assert not await integration_setup(client_with_exception) assert config_entry.state == expected_state assert client_with_exception.get_home_appliances.call_count == 1 @pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_key_value_services( - service_call: dict[str, Any], - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, -) -> None: - """Create and test services.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_name = service_call["service"] - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - assert ( - getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 - ) - - -@pytest.mark.parametrize( - ("service_call", "issue_id"), + "raising_exception_method", [ - *zip( - DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, - ["deprecated_set_program_and_option_actions"] - * ( - len(DEPRECATED_SERVICE_KV_CALL_PARAMS) - + len(SERVICE_PROGRAM_CALL_PARAMS) - ), - strict=True, - ), - *zip( - SERVICE_COMMAND_CALL_PARAMS, - ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), - strict=True, - ), + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", ], ) -async def test_programs_and_options_actions_deprecation( - service_call: dict[str, Any], - issue_id: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, +async def test_client_rate_limit_error( client: MagicMock, - appliance_ha_id: str, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, -) -> None: - """Test deprecated service keys.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - 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 - - -@pytest.mark.parametrize( - ("service_call", "called_method"), - zip( - SERVICES_SET_PROGRAM_AND_OPTIONS, - [ - "set_selected_program", - "start_program", - "set_active_program_options", - "set_selected_program_options", - ], - strict=True, - ), -) -async def test_set_program_and_options( - service_call: dict[str, Any], - called_method: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, - snapshot: SnapshotAssertion, + raising_exception_method: str, ) -> None: - """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + """Test client errors during setup integration.""" + retry_after = 42 - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) + original_mock = getattr(client, raising_exception_method) + mock = AsyncMock() - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - method_mock: MagicMock = getattr(client, called_method) - assert method_mock.call_count == 1 - assert method_mock.call_args == snapshot - - -@pytest.mark.parametrize( - ("service_call", "error_regex"), - zip( - SERVICES_SET_PROGRAM_AND_OPTIONS, - [ - r"Error.*selecting.*program.*", - r"Error.*starting.*program.*", - r"Error.*setting.*options.*active.*program.*", - r"Error.*setting.*options.*selected.*program.*", - ], - strict=True, - ), -) -async def test_set_program_and_options_exceptions( - service_call: dict[str, Any], - error_regex: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, -) -> None: - """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - with pytest.raises(HomeAssistantError, match=error_regex): - await hass.services.async_call(**service_call) + async def side_effect(*args, **kwargs): + if mock.call_count <= 1: + raise TooManyRequestsError("error.key", retry_after=retry_after) + return await original_mock(*args, **kwargs) + + mock.side_effect = side_effect + setattr(client, raising_exception_method, mock) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + with patch( + "homeassistant.components.home_connect.coordinator.asyncio_sleep", + ) as asyncio_sleep_mock: + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + assert mock.call_count >= 2 + asyncio_sleep_mock.assert_called_once_with(retry_after) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_required_program_or_at_least_an_option( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: "Test that the set_program_and_options does raise an exception if no program nor options are set." - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) with pytest.raises( @@ -529,120 +259,14 @@ async def test_required_program_or_at_least_an_option( ) -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_services_exception_device_id( - service_call: dict[str, Any], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a HomeAssistantError when there is an API error.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(HomeAssistantError): - await hass.services.async_call(**service_call) - - -async def test_services_appliance_not_found( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a ServiceValidationError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - service_call = SERVICE_KV_CALL_PARAMS[0] - - service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" - - with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): - await hass.services.async_call(**service_call) - - unrelated_config_entry = MockConfigEntry( - domain="TEST", - ) - unrelated_config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=unrelated_config_entry.entry_id, - identifiers={("RANDOM", "ABCD")}, - ) - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): - await hass.services.async_call(**service_call) - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("RANDOM", "ABCD")}, - ) - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): - await hass.services.async_call(**service_call) - - -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_services_exception( - service_call: dict[str, Any], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a ValueError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - - service_name = service_call["service"] - with pytest.raises( - HomeAssistantError, - match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], - ): - await hass.services.async_call(**service_call) - - +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_v1_1: MockConfigEntry, - appliance_ha_id: str, platforms: list[Platform], + appliance: HomeAppliance, ) -> None: """Test entity migration.""" @@ -650,7 +274,7 @@ async def test_entity_migration( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_v1_1.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) test_entities = [ @@ -690,7 +314,7 @@ async def test_entity_migration( entity_registry.async_get_or_create( domain, DOMAIN, - f"{appliance_ha_id}-{old_unique_id_suffix}", + f"{appliance.ha_id}-{old_unique_id_suffix}", device_id=device_entry.id, config_entry=config_entry_v1_1, ) @@ -701,7 +325,7 @@ async def test_entity_migration( for domain, _, expected_unique_id_suffix in test_entities: assert entity_registry.async_get_entity_id( - domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}" + domain, DOMAIN, f"{appliance.ha_id}-{expected_unique_id_suffix}" ) assert config_entry_v1_1.minor_version == 2 @@ -711,3 +335,20 @@ async def test_bsh_key_transformations() -> None: program = "Dishcare.Dishwasher.Program.Eco50" translation_key = bsh_key_to_translation_key(program) assert RE_TRANSLATION_KEY.match(translation_key) + + +async def test_config_entry_unique_id_migration( + hass: HomeAssistant, + config_entry_v1_2: MockConfigEntry, +) -> None: + """Test that old config entries use the unique id obtained from the JWT subject.""" + config_entry_v1_2.add_to_hass(hass) + + assert config_entry_v1_2.unique_id != "1234567890" + assert config_entry_v1_2.minor_version == 2 + + await hass.config_entries.async_setup(config_entry_v1_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_v1_2.unique_id == "1234567890" + assert config_entry_v1_2.minor_version == 3 diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 6021c99bb5e..b467dd2a7d2 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError @@ -21,9 +22,15 @@ from homeassistant.components.home_connect.const import ( BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -46,35 +53,21 @@ def platforms() -> list[str]: return [Platform.LIGHT] -async def test_light( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test switch entities.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -82,7 +75,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -90,7 +83,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -99,7 +92,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -107,21 +100,30 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ("appliance", "keys_to_check"), + [ + ( + "Hood", + (SettingKey.COOKING_COMMON_LIGHTING,), + ) + ], + indirect=["appliance"], +) async def test_connected_devices( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -129,40 +131,32 @@ async def test_connected_devices( not be obtained while disconnected and once connected, the entities are added. """ get_settings_original_mock = client.get_settings - get_available_programs_mock = client.get_available_programs async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_settings_original_mock.side_effect(ha_id) - async def get_available_programs_side_effect(ha_id: str): - if ha_id == appliance_ha_id: - raise HomeConnectApiError( - "SDK.Error.HomeAppliance.Connection.Initialization.Failed" - ) - return await get_available_programs_mock.side_effect(ha_id) - client.get_settings = AsyncMock(side_effect=get_settings_side_effect) - client.get_available_programs = AsyncMock( - side_effect=get_available_programs_side_effect - ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_settings = get_settings_original_mock - client.get_available_programs = get_available_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device - entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + for key in keys_to_check: + assert not entity_registry.async_get_entity_id( + Platform.LIGHT, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -170,28 +164,28 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) - assert device - new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) - assert len(new_entity_entries) > len(entity_entries) + for key in keys_to_check: + assert entity_registry.async_get_entity_id( + Platform.LIGHT, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) -async def test_light_availabilty( +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) +async def test_light_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if light entities availability are based on the appliance connection state.""" entity_ids = [ "light.hood_functional_light", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -201,7 +195,7 @@ async def test_light_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -215,7 +209,7 @@ async def test_light_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -236,7 +230,7 @@ async def test_light_availabilty( "service", "exprected_attributes", "state", - "appliance_ha_id", + "appliance", ), [ ( @@ -256,7 +250,7 @@ async def test_light_availabilty( SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 199}, + {ATTR_BRIGHTNESS: 199}, STATE_ON, "Hood", ), @@ -277,7 +271,7 @@ async def test_light_availabilty( SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 199}, + {ATTR_BRIGHTNESS: 199}, STATE_ON, "Hood", ), @@ -310,7 +304,7 @@ async def test_light_availabilty( }, SERVICE_TURN_ON, { - "rgb_color": (255, 255, 0), + ATTR_RGB_COLOR: (255, 255, 0), }, STATE_ON, "Hood", @@ -324,8 +318,8 @@ async def test_light_availabilty( }, SERVICE_TURN_ON, { - "hs_color": (255.484, 15.196), - "brightness": 199, + ATTR_HS_COLOR: (255.484, 15.196), + ATTR_BRIGHTNESS: 199, }, STATE_ON, "Hood", @@ -341,28 +335,26 @@ async def test_light_availabilty( "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_light_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, set_settings_args: dict[SettingKey, Any], service: str, exprected_attributes: dict[str, Any], state: str, - appliance_ha_id: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + appliance: HomeAppliance, ) -> None: """Test light functionality.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED service_data = exprected_attributes.copy() - service_data["entity_id"] = entity_id + service_data[ATTR_ENTITY_ID] = entity_id await hass.services.async_call( LIGHT_DOMAIN, service, @@ -371,7 +363,7 @@ async def test_light_functionality( await hass.async_block_till_done() client.set_setting.assert_has_calls( [ - call(appliance_ha_id, setting_key=setting_key, value=value) + call(appliance.ha_id, setting_key=setting_key, value=value) for setting_key, value in set_settings_args.items() ] ) @@ -386,7 +378,7 @@ async def test_light_functionality( ( "entity_id", "events", - "appliance_ha_id", + "appliance", ), [ ( @@ -397,41 +389,39 @@ async def test_light_functionality( "Hood", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_light_color_different_than_custom( - entity_id: str, - events: dict[EventKey, Any], - appliance_ha_id: str, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + entity_id: str, + events: dict[EventKey, Any], + appliance: HomeAppliance, ) -> None: """Test that light color attributes are not set if color is different than custom.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, { - "rgb_color": (255, 255, 0), - "entity_id": entity_id, + ATTR_RGB_COLOR: (255, 255, 0), + ATTR_ENTITY_ID: entity_id, }, ) await hass.async_block_till_done() entity_state = hass.states.get(entity_id) assert entity_state is not None assert entity_state.state == STATE_ON - assert entity_state.attributes["rgb_color"] is not None - assert entity_state.attributes["hs_color"] is not None + assert entity_state.attributes[ATTR_RGB_COLOR] is not None + assert entity_state.attributes[ATTR_HS_COLOR] is not None await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -454,8 +444,8 @@ async def test_light_color_different_than_custom( entity_state = hass.states.get(entity_id) assert entity_state is not None assert entity_state.state == STATE_ON - assert entity_state.attributes["rgb_color"] is None - assert entity_state.attributes["hs_color"] is None + assert entity_state.attributes[ATTR_RGB_COLOR] is None + assert entity_state.attributes[ATTR_HS_COLOR] is None @pytest.mark.parametrize( @@ -485,7 +475,7 @@ async def test_light_color_different_than_custom( SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70, }, SERVICE_TURN_ON, - {"brightness": 200}, + {ATTR_BRIGHTNESS: 200}, [HomeConnectError, HomeConnectError], r"Error.*turn.*on.*", ), @@ -517,7 +507,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, - {"brightness": 200}, + {ATTR_BRIGHTNESS: 200}, [HomeConnectError, None, HomeConnectError], r"Error.*set.*brightness.*", ), @@ -530,7 +520,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, - {"rgb_color": (255, 255, 0)}, + {ATTR_RGB_COLOR: (255, 255, 0)}, [HomeConnectError, None, HomeConnectError], r"Error.*select.*custom color.*", ), @@ -543,7 +533,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, - {"rgb_color": (255, 255, 0)}, + {ATTR_RGB_COLOR: (255, 255, 0)}, [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", ), @@ -556,8 +546,8 @@ async def test_light_color_different_than_custom( }, SERVICE_TURN_ON, { - "hs_color": (255.484, 15.196), - "brightness": 199, + ATTR_HS_COLOR: (255.484, 15.196), + ATTR_BRIGHTNESS: 199, }, [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", @@ -565,17 +555,16 @@ async def test_light_color_different_than_custom( ], ) async def test_light_exception_handling( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, setting: dict[SettingKey, dict[str, Any]], service: str, service_data: dict, attr_side_effect: list[type[HomeConnectError] | None], exception_match: str, - hass: HomeAssistant, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - config_entry: MockConfigEntry, - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test light exception handling.""" client_with_exception.get_settings.side_effect = None @@ -592,15 +581,14 @@ async def test_light_exception_handling( client_with_exception.set_setting.side_effect = [ exception() if exception else None for exception in attr_side_effect ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): await client_with_exception.set_setting() - service_data["entity_id"] = entity_id + service_data[ATTR_ENTITY_ID] = entity_id 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 214dcb6137c..58d6dae2900 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable import random -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.model import ( ArrayOfEvents, @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -22,6 +23,7 @@ from aiohomeconnect.model.error import ( HomeConnectApiError, HomeConnectError, SelectedProgramNotSetError, + TooManyRequestsError, ) from aiohomeconnect.model.program import ( ProgramDefinitionConstraints, @@ -47,7 +49,7 @@ 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 +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -56,27 +58,15 @@ def platforms() -> list[str]: return [Platform.NUMBER] -async def test_number( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test number entity.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - - +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" client.get_available_program = AsyncMock( @@ -90,11 +80,10 @@ async def test_paired_depaired_devices_flow( ], ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -102,7 +91,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -110,7 +99,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -119,7 +108,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -127,21 +116,32 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize( + ("appliance", "keys_to_check"), + [ + ( + "FridgeFreezer", + ( + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + ), + ) + ], + indirect=["appliance"], +) async def test_connected_devices( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -151,26 +151,30 @@ async def test_connected_devices( get_settings_original_mock = client.get_settings def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return get_settings_original_mock.return_value client.get_settings = AsyncMock(side_effect=get_settings_side_effect) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_settings = get_settings_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device - entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + for key in keys_to_check: + assert not entity_registry.async_get_entity_id( + Platform.NUMBER, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -178,20 +182,21 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) - assert device - new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) - assert len(new_entity_entries) > len(entity_entries) + for key in keys_to_check: + assert entity_registry.async_get_entity_id( + Platform.NUMBER, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) -async def test_number_entity_availabilty( +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) +async def test_number_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if number entities availability are based on the appliance connection state.""" entity_ids = [ @@ -202,9 +207,8 @@ async def test_number_entity_availabilty( # Setting constrains are not needed for this test # so we rise an error to easily test the availability client.get_setting = AsyncMock(side_effect=HomeConnectError()) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -214,7 +218,7 @@ async def test_number_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -228,7 +232,7 @@ async def test_number_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -242,7 +246,7 @@ async def test_number_entity_availabilty( assert state.state != STATE_UNAVAILABLE -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -278,7 +282,11 @@ async def test_number_entity_availabilty( ], ) async def test_number_entity_functionality( - appliance_ha_id: str, + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, type: str, @@ -287,11 +295,6 @@ async def test_number_entity_functionality( max_value: int, step_size: float, unit_of_measurement: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test number entity functionality.""" client.get_setting.side_effect = None @@ -310,7 +313,6 @@ async def test_number_entity_functionality( ) ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED entity_state = hass.states.get(entity_id) @@ -335,11 +337,101 @@ async def test_number_entity_functionality( ) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, setting_key=setting_key, value=value + appliance.ha_id, setting_key=setting_key, value=value ) assert hass.states.is_state(entity_id, str(float(value))) +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("retry_after", [0, None]) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "type", + "min_value", + "max_value", + "step_size", + "unit_of_measurement", + ), + [ + ( + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 7, + 15, + 5, + "°C", + ), + ], +) +@patch("homeassistant.components.home_connect.entity.API_DEFAULT_RETRY_AFTER", new=0) +async def test_fetch_constraints_after_rate_limit_error( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + retry_after: int | None, + appliance: HomeAppliance, + entity_id: str, + setting_key: SettingKey, + type: str, + min_value: int, + max_value: int, + step_size: int, + unit_of_measurement: str, +) -> None: + """Test that, if a API rate limit error is raised, the constraints are fetched later.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance.ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=random.randint(min_value, max_value), + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=retry_after), + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=random.randint(min_value, max_value), + unit=unit_of_measurement, + type=type, + constraints=SettingConstraints( + min=min_value, + max=max_value, + step_size=step_size, + ), + ), + ] + ) + + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_state + attributes = entity_state.attributes + assert attributes["min"] == min_value + assert attributes["max"] == max_value + assert attributes["step"] == step_size + assert attributes["unit_of_measurement"] == unit_of_measurement + + @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ @@ -351,14 +443,13 @@ async def test_number_entity_functionality( ], ) async def test_number_entity_error( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, setting_key: SettingKey, mock_attr: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test number entity error.""" client_with_exception.get_settings.side_effect = None @@ -376,7 +467,6 @@ async def test_number_entity_error( ) ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED @@ -418,7 +508,7 @@ async def test_number_entity_error( ], ) @pytest.mark.parametrize( - ("appliance_ha_id", "entity_id", "option_key", "min", "max", "step_size", "unit"), + ("appliance", "entity_id", "option_key", "min", "max", "step_size", "unit"), [ ( "Oven", @@ -430,12 +520,16 @@ async def test_number_entity_error( "°C", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_options_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, option_key: OptionKey, - appliance_ha_id: str, + appliance: HomeAppliance, min: int, max: int, step_size: int, @@ -443,11 +537,6 @@ async def test_options_functionality( set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test options functionality.""" @@ -504,9 +593,8 @@ async def test_options_functionality( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity_state = hass.states.get(entity_id) assert entity_state assert entity_state.attributes["unit_of_measurement"] == unit @@ -522,7 +610,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": 80, diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 22ece365e6b..a4263808276 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -19,8 +20,10 @@ from aiohomeconnect.model import ( ) from aiohomeconnect.model.error import ( ActiveProgramNotSetError, + HomeConnectApiError, HomeConnectError, SelectedProgramNotSetError, + TooManyRequestsError, ) from aiohomeconnect.model.program import ( EnumerateProgram, @@ -50,7 +53,7 @@ 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 +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -59,27 +62,15 @@ def platforms() -> list[str]: return [Platform.SELECT] -async def test_select( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test select entity.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state is ConfigEntryState.LOADED - - +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" client.get_available_program = AsyncMock( @@ -93,11 +84,10 @@ async def test_paired_depaired_devices_flow( ], ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -105,7 +95,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -113,7 +103,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -122,7 +112,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -130,38 +120,77 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize( + ("appliance", "keys_to_check"), + [ + ( + "Hood", + ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + ), + ) + ], + indirect=["appliance"], +) async def test_connected_devices( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. Specifically those devices whose settings, status, etc. could not be obtained while disconnected and once connected, the entities are added. """ + get_settings_original_mock = client.get_settings + get_all_programs_mock = client.get_all_programs - assert config_entry.state == ConfigEntryState.NOT_LOADED + async def get_settings_side_effect(ha_id: str): + if ha_id == appliance.ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_settings_original_mock.side_effect(ha_id) + + async def get_all_programs_side_effect(ha_id: str): + if ha_id == appliance.ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_all_programs_mock.side_effect(ha_id) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_all_programs = AsyncMock(side_effect=get_all_programs_side_effect) assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED + client.get_settings = get_settings_original_mock + client.get_all_programs = get_all_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device + for key in keys_to_check: + assert not entity_registry.async_get_entity_id( + Platform.SELECT, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -169,27 +198,28 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) - assert device - entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) - assert entity_entries + for key in keys_to_check: + assert entity_registry.async_get_entity_id( + Platform.SELECT, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) -async def test_select_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_select_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if select entities availability are based on the appliance connection state.""" entity_ids = [ "select.washer_active_program", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -199,7 +229,7 @@ async def test_select_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -213,7 +243,7 @@ async def test_select_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -228,11 +258,10 @@ async def test_select_entity_availabilty( async def test_filter_programs( + entity_registry: er.EntityRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - entity_registry: er.EntityRegistry, ) -> None: """Test select that only right programs are shown.""" client.get_all_programs.side_effect = None @@ -266,7 +295,6 @@ async def test_filter_programs( ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -289,7 +317,7 @@ async def test_filter_programs( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "expected_initial_state", "mock_method", @@ -317,24 +345,22 @@ async def test_filter_programs( EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_select_program_functionality( - appliance_ha_id: str, + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, entity_id: str, expected_initial_state: str, mock_method: str, program_key: ProgramKey, program_to_set: str, event_key: EventKey, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test select functionality.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -346,14 +372,14 @@ async def test_select_program_functionality( ) await hass.async_block_till_done() getattr(client, mock_method).assert_awaited_once_with( - appliance_ha_id, program_key=program_key + appliance.ha_id, program_key=program_key ) assert hass.states.is_state(entity_id, program_to_set) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -397,15 +423,14 @@ async def test_select_program_functionality( ], ) async def test_select_exception_handling( + hass: HomeAssistant, + client_with_exception: MagicMock, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + config_entry: MockConfigEntry, entity_id: str, program_to_set: str, mock_attr: str, exception_match: str, - hass: HomeAssistant, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - config_entry: MockConfigEntry, - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test exception handling.""" client_with_exception.get_all_programs.side_effect = None @@ -418,7 +443,6 @@ async def test_select_exception_handling( ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED @@ -432,13 +456,64 @@ async def test_select_exception_handling( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {"entity_id": entity_id, "option": program_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_programs_updated_on_connect( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_all_programs_mock = client.get_all_programs + + returned_programs = ( + await get_all_programs_mock.side_effect(appliance.ha_id) + ).programs + assert len(returned_programs) > 1 + + async def get_all_programs_side_effect(ha_id: str): + if ha_id == appliance.ha_id: + return ArrayOfPrograms(returned_programs[:1]) + return await get_all_programs_mock.side_effect(ha_id) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_side_effect) + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + client.get_all_programs = get_all_programs_mock + + state = hass.states.get("select.washer_active_program") + assert state + programs = state.attributes[ATTR_OPTIONS] + + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + state = hass.states.get("select.washer_active_program") + assert state + assert state.attributes[ATTR_OPTIONS] != programs + assert len(state.attributes[ATTR_OPTIONS]) > len(programs) + + +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -472,20 +547,18 @@ async def test_select_exception_handling( ], ) async def test_select_functionality( - appliance_ha_id: str, + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, expected_options: set[str], value_to_set: str, expected_value_call_arg: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test select functionality.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -496,12 +569,12 @@ async def test_select_functionality( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: value_to_set}, ) await hass.async_block_till_done() client.set_setting.assert_called_once() - assert client.set_setting.call_args.args == (appliance_ha_id,) + assert client.set_setting.call_args.args == (appliance.ha_id,) assert client.set_setting.call_args.kwargs == { "setting_key": setting_key, "value": expected_value_call_arg, @@ -509,7 +582,7 @@ async def test_select_functionality( assert hass.states.is_state(entity_id, value_to_set) -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -521,22 +594,30 @@ async def test_select_functionality( ( "select.hood_ambient_light_color", SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, - [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)], + [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(1, 50)], {str(i) for i in range(1, 50)}, ), + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + [ + "A.Non.Documented.Option", + "BSH.Common.EnumType.AmbientLightColor.Color42", + ], + {"42"}, + ), ], ) async def test_fetch_allowed_values( - appliance_ha_id: str, + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, entity_id: str, test_setting_key: SettingKey, allowed_values: list[str | None], expected_options: set[str], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test fetch allowed values.""" original_get_setting_side_effect = client.get_setting @@ -544,7 +625,7 @@ async def test_fetch_allowed_values( async def get_setting_side_effect( ha_id: str, setting_key: SettingKey ) -> GetSetting: - if ha_id != appliance_ha_id or setting_key != test_setting_key: + if ha_id != appliance.ha_id or setting_key != test_setting_key: return await original_get_setting_side_effect(ha_id, setting_key) return GetSetting( key=test_setting_key, @@ -557,7 +638,6 @@ async def test_fetch_allowed_values( client.get_setting = AsyncMock(side_effect=get_setting_side_effect) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -566,6 +646,135 @@ async def test_fetch_allowed_values( assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "allowed_values", + "expected_options", + ), + [ + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)], + {str(i) for i in range(1, 50)}, + ), + ], +) +async def test_fetch_allowed_values_after_rate_limit_error( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + entity_id: str, + setting_key: SettingKey, + allowed_values: list[str | None], + expected_options: set[str], +) -> None: + """Test fetch allowed values.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance.ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=0), + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ), + ] + ) + + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "exception", + "expected_options", + ), + [ + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + HomeConnectError(), + { + "b_s_h_common_enum_type_ambient_light_color_custom_color", + *{str(i) for i in range(1, 100)}, + }, + ), + ], +) +async def test_default_values_after_fetch_allowed_values_error( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + entity_id: str, + setting_key: SettingKey, + exception: Exception, + expected_options: set[str], +) -> None: + """Test fetch allowed values.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance.ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock(side_effect=exception) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 1 + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + @pytest.mark.parametrize( ("entity_id", "setting_key", "allowed_value", "value_to_set", "mock_attr"), [ @@ -579,16 +788,15 @@ async def test_fetch_allowed_values( ], ) async def test_select_entity_error( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, setting_key: SettingKey, allowed_value: str, value_to_set: str, mock_attr: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test select entity error.""" client_with_exception.get_settings.side_effect = None @@ -602,7 +810,6 @@ async def test_select_entity_error( ) ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED @@ -615,12 +822,13 @@ async def test_select_entity_error( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: value_to_set}, blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ( "set_active_program_options_side_effect", @@ -679,22 +887,32 @@ async def test_select_entity_error( "laundry_care_washer_enum_type_temperature_ul_extra_hot", }, ), + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + [ + "A.Non.Documented.Option", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + ], + { + "laundry_care_washer_enum_type_temperature_ul_warm", + }, + ), ], ) async def test_options_functionality( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, option_key: OptionKey, allowed_values: list[str | None] | None, expected_options: set[str], - appliance_ha_id: str, + appliance: HomeAppliance, set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test options functionality.""" if set_active_program_options_side_effect: @@ -722,9 +940,8 @@ async def test_options_functionality( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity_state = hass.states.get(entity_id) assert entity_state assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options @@ -740,7 +957,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": "LaundryCare.Washer.EnumType.Temperature.UlWarm", diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 31fc9ea6d3f..47badd8d06d 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,6 +1,7 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable +import logging from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -10,10 +11,11 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + HomeAppliance, Status, StatusKey, ) -from aiohomeconnect.model.error import HomeConnectApiError +from aiohomeconnect.model.error import HomeConnectApiError, TooManyRequestsError from freezegun.api import FrozenDateTimeFactory import pytest @@ -26,12 +28,13 @@ from homeassistant.components.home_connect.const import ( BSH_EVENT_PRESENT_STATE_PRESENT, DOMAIN, ) +from homeassistant.components.home_connect.coordinator import HomeConnectError 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 device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed TEST_HC_APP = "Dishwasher" @@ -86,34 +89,21 @@ def platforms() -> list[str]: return [Platform.SENSOR] -async def test_sensors( - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test sensor entities.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -121,7 +111,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -129,7 +119,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -138,7 +128,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -146,20 +136,53 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR, + raw_key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR.value, + timestamp=0, + level="", + handling="", + value=BSH_EVENT_PRESENT_STATE_PRESENT, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state("sensor.washer_poor_i_dos_1_fill_level", "present") + +@pytest.mark.parametrize( + ("appliance", "keys_to_check"), + [ + ( + "Washer", + (StatusKey.BSH_COMMON_OPERATION_STATE,), + ) + ], + indirect=["appliance"], +) async def test_connected_devices( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -169,26 +192,30 @@ async def test_connected_devices( get_status_original_mock = client.get_status def get_status_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return get_status_original_mock.return_value client.get_status = AsyncMock(side_effect=get_status_side_effect) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_status = get_status_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device - entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + for key in keys_to_check: + assert not entity_registry.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -196,29 +223,51 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) - assert device - new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) - assert len(new_entity_entries) > len(entity_entries) + for key in keys_to_check: + assert entity_registry.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) -async def test_sensor_entity_availabilty( +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) +async def test_sensor_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if sensor entities availability are based on the appliance connection state.""" entity_ids = [ "sensor.dishwasher_operation_state", "sensor.dishwasher_salt_nearly_empty", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED + + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + raw_key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY.value, + timestamp=0, + level="", + handling="", + value=BSH_EVENT_PRESENT_STATE_OFF, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -228,7 +277,7 @@ async def test_sensor_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -242,7 +291,7 @@ async def test_sensor_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -256,7 +305,7 @@ async def test_sensor_entity_availabilty( assert state.state != STATE_UNAVAILABLE -# Appliance_ha_id program sequence with a delayed start. +# Appliance program sequence with a delayed start. PROGRAM_SEQUENCE_EVENTS = ( EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, @@ -291,7 +340,7 @@ ENTITY_ID_STATES = { } -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("states", "event_run"), list( @@ -302,24 +351,23 @@ ENTITY_ID_STATES = { ) ), ) -async def test_event_sensors( - client: MagicMock, - appliance_ha_id: str, - states: tuple, - event_run: dict[EventType, dict[EventKey, str | int]], - freezer: FrozenDateTimeFactory, +async def test_program_sensors( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, + appliance: HomeAppliance, + states: tuple, + event_run: dict[EventType, dict[EventKey, str | int]], ) -> None: - """Test sequence for sensors that are only available after an event happens.""" + """Test sequence for sensors that expose information about a program.""" entity_ids = ENTITY_ID_STATES.keys() time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) - assert config_entry.state == ConfigEntryState.NOT_LOADED + assert config_entry.state is ConfigEntryState.NOT_LOADED client.get_status.return_value.status.extend( Status( key=StatusKey(event_key.value), @@ -329,12 +377,12 @@ async def test_event_sensors( for event_key, value in EVENT_PROG_DELAYED_START[EventType.STATUS].items() ) assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -358,6 +406,80 @@ async def test_event_sensors( assert hass.states.is_state(entity_id, state) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize( + ("initial_operation_state", "initial_state", "event_order", "entity_states"), + [ + ( + "BSH.Common.EnumType.OperationState.Ready", + STATE_UNAVAILABLE, + (EventType.STATUS, EventType.EVENT), + (STATE_UNKNOWN, "60"), + ), + ( + "BSH.Common.EnumType.OperationState.Run", + STATE_UNKNOWN, + (EventType.EVENT, EventType.STATUS), + ("60", "60"), + ), + ], +) +async def test_program_sensor_edge_case( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + initial_operation_state: str, + initial_state: str, + event_order: tuple[EventType, EventType], + entity_states: tuple[str, str], + appliance: HomeAppliance, +) -> None: + """Test edge case for the program related entities.""" + entity_id = "sensor.dishwasher_program_progress" + client.get_status = AsyncMock( + return_value=ArrayOfStatus( + [ + Status( + StatusKey.BSH_COMMON_OPERATION_STATE, + StatusKey.BSH_COMMON_OPERATION_STATE.value, + initial_operation_state, + ) + ] + ) + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.states.is_state(entity_id, initial_state) + + for event_type, state in zip(event_order, entity_states, strict=True): + await client.add_events( + [ + EventMessage( + appliance.ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_key, value in EVENT_PROG_RUN[event_type].items() + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, state) + + # Program sequence for SensorDeviceClass.TIMESTAMP edge cases. PROGRAM_SEQUENCE_EDGE_CASE = [ EVENT_PROG_DELAYED_START, @@ -375,24 +497,22 @@ ENTITY_ID_EDGE_CASE_STATES = [ ] -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) async def test_remaining_prog_time_edge_cases( - appliance_ha_id: str, - freezer: FrozenDateTimeFactory, hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + appliance: HomeAppliance, ) -> None: """Run program sequence to test edge cases for the remaining_prog_time entity.""" entity_id = "sensor.dishwasher_program_finish_time" time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for ( event, @@ -401,7 +521,7 @@ async def test_remaining_prog_time_edge_cases( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -429,148 +549,149 @@ async def test_remaining_prog_time_edge_cases( ( "entity_id", "event_key", - "event_type", - "event_value_update", - "expected", - "appliance_ha_id", + "value_expected_state", + "appliance", ), [ ( "sensor.dishwasher_door", EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_LOCKED, - "locked", + [ + ( + BSH_DOOR_STATE_LOCKED, + "locked", + ), + ( + BSH_DOOR_STATE_CLOSED, + "closed", + ), + ( + BSH_DOOR_STATE_OPEN, + "open", + ), + ], "Dishwasher", ), - ( - "sensor.dishwasher_door", - EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_CLOSED, - "closed", - "Dishwasher", - ), - ( - "sensor.dishwasher_door", - EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_OPEN, - "open", - "Dishwasher", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", - EventType.EVENT, - "", - "off", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_OFF, - "off", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_PRESENT, - "present", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_CONFIRMED, - "confirmed", - "FridgeFreezer", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventType.EVENT, - "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", - "", - "off", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_OFF, - "off", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_PRESENT, - "present", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_CONFIRMED, - "confirmed", - "CoffeeMaker", - ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensors_states( - entity_id: str, - event_key: EventKey, - event_type: EventType, - event_value_update: str, - appliance_ha_id: str, - expected: str, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + entity_id: str, + event_key: EventKey, + value_expected_state: list[tuple[str, str]], + appliance: HomeAppliance, ) -> None: - """Tests for Appliance_ha_id alarm sensors.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED + """Tests for appliance sensors.""" assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED - await client.add_events( - [ - EventMessage( - appliance_ha_id, - event_type, - ArrayOfEvents( - [ - Event( - key=event_key, - raw_key=str(event_key), - timestamp=0, - level="", - handling="", - value=event_value_update, - ) - ], + for value, expected_state in value_expected_state: + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), ), - ), - ] - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, expected) + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected_state) @pytest.mark.parametrize( ( - "appliance_ha_id", + "entity_id", + "event_key", + "appliance", + ), + [ + ( + "sensor.fridgefreezer_freezer_door_alarm", + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + "FridgeFreezer", + ), + ( + "sensor.coffeemaker_bean_container_empty", + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + "CoffeeMaker", + ), + ], + indirect=["appliance"], +) +async def test_event_sensors_states( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + entity_id: str, + event_key: EventKey, + appliance: HomeAppliance, +) -> None: + """Tests for appliance event sensors.""" + caplog.set_level(logging.ERROR) + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert not hass.states.get(entity_id) + + for value, expected_state in ( + (BSH_EVENT_PRESENT_STATE_OFF, "off"), + (BSH_EVENT_PRESENT_STATE_PRESENT, "present"), + (BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed"), + ): + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected_state) + + # Verify that the integration doesn't attempt to add the event sensors more than once + # If that happens, the EntityPlatform logs an error with the entity's unique ID. + assert "exists" not in caplog.text + assert entity_id not in caplog.text + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.unique_id not in caplog.text + + +@pytest.mark.parametrize( + ( + "appliance", "entity_id", "status_key", "unit_get_status", @@ -595,25 +716,24 @@ async def test_sensors_states( 1, ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensor_unit_fetching( - appliance_ha_id: str, + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, entity_id: str, status_key: StatusKey, unit_get_status: str | None, unit_get_status_value: str | None, get_status_value_call_count: int, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, ) -> None: """Test that the sensor entities are capable of fetching units.""" async def get_status_mock(ha_id: str) -> ArrayOfStatus: - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfStatus([]) return ArrayOfStatus( [ @@ -636,9 +756,8 @@ async def test_sensor_unit_fetching( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED entity_state = hass.states.get(entity_id) assert entity_state @@ -648,3 +767,118 @@ async def test_sensor_unit_fetching( ) assert client.get_status_value.call_count == get_status_value_call_count + + +@pytest.mark.parametrize( + ( + "appliance", + "entity_id", + "status_key", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + ), + ], + indirect=["appliance"], +) +async def test_sensor_unit_fetching_error( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + entity_id: str, + status_key: StatusKey, +) -> None: + """Test that the sensor entities are capable of fetching units.""" + + async def get_status_mock(ha_id: str) -> ArrayOfStatus: + if ha_id != appliance.ha_id: + return ArrayOfStatus([]) + return ArrayOfStatus( + [ + Status( + key=status_key, + raw_key=status_key.value, + value=0, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock(side_effect=HomeConnectError()) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.states.get(entity_id) + + +@pytest.mark.parametrize( + ( + "appliance", + "entity_id", + "status_key", + "unit", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + "°C", + ), + ], + indirect=["appliance"], +) +async def test_sensor_unit_fetching_after_rate_limit_error( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + entity_id: str, + status_key: StatusKey, + unit: str, +) -> None: + """Test that the sensor entities are capable of fetching units.""" + + async def get_status_mock(ha_id: str) -> ArrayOfStatus: + if ha_id != appliance.ha_id: + return ArrayOfStatus([]) + return ArrayOfStatus( + [ + Status( + key=status_key, + raw_key=status_key.value, + value=0, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=0), + Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit, + ), + ] + ) + + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_status_value.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes["unit_of_measurement"] == unit diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py new file mode 100644 index 00000000000..33a7f7aee71 --- /dev/null +++ b/tests/components/home_connect/test_services.py @@ -0,0 +1,460 @@ +"""Tests for the Home Connect actions.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock + +from aiohomeconnect.model import HomeAppliance, OptionKey, ProgramKey, SettingKey +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr +import homeassistant.helpers.issue_registry as ir + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + +DEPRECATED_SERVICE_KV_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "set_option_active", + "service_data": { + "device_id": "DEVICE_ID", + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_option_selected", + "service_data": { + "device_id": "DEVICE_ID", + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", + }, + "blocking": True, + }, +] + +SERVICE_KV_CALL_PARAMS = [ + *DEPRECATED_SERVICE_KV_CALL_PARAMS, + { + "domain": DOMAIN, + "service": "change_setting", + "service_data": { + "device_id": "DEVICE_ID", + "key": SettingKey.BSH_COMMON_CHILD_LOCK.value, + "value": True, + }, + "blocking": True, + }, +] + +SERVICE_COMMAND_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "pause_program", + "service_data": { + "device_id": "DEVICE_ID", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "resume_program", + "service_data": { + "device_id": "DEVICE_ID", + }, + "blocking": True, + }, +] + + +SERVICE_PROGRAM_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "select_program", + "service_data": { + "device_id": "DEVICE_ID", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "start_program", + "service_data": { + "device_id": "DEVICE_ID", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", + }, + "blocking": True, + }, +] + +SERVICE_APPLIANCE_METHOD_MAPPING = { + "set_option_active": "set_active_program_option", + "set_option_selected": "set_selected_program_option", + "change_setting": "set_setting", + "pause_program": "put_command", + "resume_program": "put_command", + "select_program": "set_selected_program", + "start_program": "start_program", +} + +SERVICE_VALIDATION_ERROR_MAPPING = { + "set_option_active": r"Error.*setting.*options.*active.*program.*", + "set_option_selected": r"Error.*setting.*options.*selected.*program.*", + "change_setting": r"Error.*assigning.*value.*setting.*", + "pause_program": r"Error.*executing.*command.*", + "resume_program": r"Error.*executing.*command.*", + "select_program": r"Error.*selecting.*program.*", + "start_program": r"Error.*starting.*program.*", +} + + +SERVICES_SET_PROGRAM_AND_OPTIONS = [ + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "program": "dishcare_dishwasher_program_eco_50", + "b_s_h_common_option_start_in_relative": 1800, + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "program": "consumer_products_coffee_maker_program_beverage_coffee", + "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "consumer_products_coffee_maker_option_fill_quantity": 35, + }, + "blocking": True, + }, +] + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_key_value_services( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + service_call: dict[str, Any], +) -> None: + """Create and test services.""" + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_name = service_call["service"] + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + assert ( + getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 + ) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + ("service_call", "issue_id"), + [ + *zip( + DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + ["deprecated_set_program_and_option_actions"] + * ( + len(DEPRECATED_SERVICE_KV_CALL_PARAMS) + + len(SERVICE_PROGRAM_CALL_PARAMS) + ), + strict=True, + ), + *zip( + SERVICE_COMMAND_CALL_PARAMS, + ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), + strict=True, + ), + ], +) +async def test_programs_and_options_actions_deprecation( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + service_call: dict[str, Any], + issue_id: str, +) -> None: + """Test deprecated service keys.""" + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + 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 + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + ("service_call", "called_method"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + "set_selected_program", + "start_program", + "set_active_program_options", + "set_selected_program_options", + ], + strict=True, + ), +) +async def test_set_program_and_options( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + service_call: dict[str, Any], + called_method: str, + snapshot: SnapshotAssertion, +) -> None: + """Test recognized options.""" + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + method_mock: MagicMock = getattr(client, called_method) + assert method_mock.call_count == 1 + assert method_mock.call_args == snapshot + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + ("service_call", "error_regex"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + r"Error.*selecting.*program.*", + r"Error.*starting.*program.*", + r"Error.*setting.*options.*active.*program.*", + r"Error.*setting.*options.*selected.*program.*", + ], + strict=True, + ), +) +async def test_set_program_and_options_exceptions( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + service_call: dict[str, Any], + error_regex: str, +) -> None: + """Test recognized options.""" + assert await integration_setup(client_with_exception) + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(HomeAssistantError, match=error_regex): + await hass.services.async_call(**service_call) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + service_call: dict[str, Any], +) -> None: + """Raise a HomeAssistantError when there is an API error.""" + assert await integration_setup(client_with_exception) + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(HomeAssistantError): + await hass.services.async_call(**service_call) + + +async def test_services_appliance_not_found( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], +) -> None: + """Raise a ServiceValidationError when device id does not match.""" + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + service_call = SERVICE_KV_CALL_PARAMS[0] + + service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" + + with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): + await hass.services.async_call(**service_call) + + unrelated_config_entry = MockConfigEntry( + domain="TEST", + ) + unrelated_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=unrelated_config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): + await hass.services.async_call(**service_call) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): + await hass.services.async_call(**service_call) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + service_call: dict[str, Any], +) -> None: + """Raise a ValueError when device id does not match.""" + assert await integration_setup(client_with_exception) + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + service_name = service_call["service"] + with pytest.raises( + HomeAssistantError, + match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], + ): + await hass.services.async_call(**service_call) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 1b38809dc05..1131f0ab46e 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -6,13 +6,11 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, - ArrayOfPrograms, ArrayOfSettings, - Event, - EventKey, EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -24,19 +22,16 @@ from aiohomeconnect.model.error import ( HomeConnectError, SelectedProgramNotSetError, ) -from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption +from aiohomeconnect.model.program import ProgramDefinitionOption from aiohomeconnect.model.setting import SettingConstraints import pytest -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN, ) -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 ( @@ -50,12 +45,7 @@ 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, - issue_registry as ir, -) -from homeassistant.setup import async_setup_component +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -66,46 +56,21 @@ def platforms() -> list[str]: return [Platform.SWITCH] -async def test_switches( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Test switch entities.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" - client.get_available_program = AsyncMock( - return_value=ProgramDefinition( - ProgramKey.UNKNOWN, - options=[ - ProgramDefinitionOption( - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, - "Boolean", - ) - ], - ) - ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -113,7 +78,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -121,7 +86,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -130,7 +95,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -138,20 +103,33 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize( + ("appliance", "keys_to_check"), + [ + ( + "Washer", + ( + SettingKey.BSH_COMMON_POWER_STATE, + SettingKey.BSH_COMMON_CHILD_LOCK, + ), + ) + ], + indirect=["appliance"], +) async def test_connected_devices( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -159,40 +137,32 @@ async def test_connected_devices( not be obtained while disconnected and once connected, the entities are added. """ get_settings_original_mock = client.get_settings - get_available_programs_mock = client.get_available_programs async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_settings_original_mock.side_effect(ha_id) - async def get_available_programs_side_effect(ha_id: str): - if ha_id == appliance_ha_id: - raise HomeConnectApiError( - "SDK.Error.HomeAppliance.Connection.Initialization.Failed" - ) - return await get_available_programs_mock.side_effect(ha_id) - client.get_settings = AsyncMock(side_effect=get_settings_side_effect) - client.get_available_programs = AsyncMock( - side_effect=get_available_programs_side_effect - ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_settings = get_settings_original_mock - client.get_available_programs = get_available_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device - entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + for key in keys_to_check: + assert not entity_registry.async_get_entity_id( + Platform.SWITCH, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -200,30 +170,30 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) - assert device - new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) - assert len(new_entity_entries) > len(entity_entries) + for key in keys_to_check: + assert entity_registry.async_get_entity_id( + Platform.SWITCH, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) -@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) -async def test_switch_entity_availabilty( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) +async def test_switch_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if switch entities availability are based on the appliance connection state.""" entity_ids = [ "switch.dishwasher_power", "switch.dishwasher_child_lock", - "switch.dishwasher_program_eco50", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -233,7 +203,7 @@ async def test_switch_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -247,7 +217,7 @@ async def test_switch_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -268,7 +238,7 @@ async def test_switch_entity_availabilty( "settings_key_arg", "setting_value_arg", "state", - "appliance_ha_id", + "appliance", ), [ ( @@ -288,112 +258,34 @@ async def test_switch_entity_availabilty( "Dishwasher", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_switch_functionality( - entity_id: str, - settings_key_arg: SettingKey, - setting_value_arg: Any, - service: str, - state: str, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - appliance_ha_id: str, - client: MagicMock, + entity_id: str, + service: str, + settings_key_arg: SettingKey, + setting_value_arg: Any, + state: str, + appliance: HomeAppliance, ) -> None: """Test switch functionality.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, setting_key=settings_key_arg, value=setting_value_arg + appliance.ha_id, setting_key=settings_key_arg, value=setting_value_arg ) assert hass.states.is_state(entity_id, state) -@pytest.mark.parametrize( - ("entity_id", "program_key", "initial_state", "appliance_ha_id"), - [ - ( - "switch.dryer_program_mix", - ProgramKey.LAUNDRY_CARE_DRYER_MIX, - STATE_OFF, - "Dryer", - ), - ( - "switch.dryer_program_cotton", - ProgramKey.LAUNDRY_CARE_DRYER_COTTON, - STATE_ON, - "Dryer", - ), - ], - indirect=["appliance_ha_id"], -) -async def test_program_switch_functionality( - entity_id: str, - program_key: ProgramKey, - initial_state: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - appliance_ha_id: str, - client: MagicMock, -) -> None: - """Test switch functionality.""" - - async def mock_stop_program(ha_id: str) -> None: - """Mock stop program.""" - await client.add_events( - [ - EventMessage( - ha_id, - EventType.NOTIFY, - ArrayOfEvents( - [ - Event( - key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, - timestamp=0, - level="", - handling="", - value=ProgramKey.UNKNOWN, - ) - ] - ), - ), - ] - ) - - client.stop_program = AsyncMock(side_effect=mock_stop_program) - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, initial_state) - - await hass.services.async_call( - SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, STATE_ON) - client.start_program.assert_awaited_once_with( - appliance_ha_id, program_key=program_key - ) - - await hass.services.async_call( - SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, STATE_OFF) - client.stop_program.assert_awaited_once_with(appliance_ha_id) - - +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ( "entity_id", @@ -402,18 +294,6 @@ async def test_program_switch_functionality( "exception_match", ), [ - ( - "switch.dishwasher_program_eco50", - SERVICE_TURN_ON, - "start_program", - r"Error.*start.*program.*", - ), - ( - "switch.dishwasher_program_eco50", - SERVICE_TURN_OFF, - "stop_program", - r"Error.*stop.*program.*", - ), ( "switch.dishwasher_power", SERVICE_TURN_OFF, @@ -441,26 +321,16 @@ async def test_program_switch_functionality( ], ) async def test_switch_exception_handling( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, service: str, mock_attr: str, exception_match: str, - hass: HomeAssistant, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - config_entry: MockConfigEntry, - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - client_with_exception.get_all_programs.side_effect = None - client_with_exception.get_all_programs.return_value = ArrayOfPrograms( - [ - EnumerateProgram( - key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, - raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, - ) - ] - ) client_with_exception.get_settings.side_effect = None client_with_exception.get_settings.return_value = ArrayOfSettings( [ @@ -480,9 +350,8 @@ async def test_switch_exception_handling( ] ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): @@ -496,42 +365,36 @@ async def test_switch_exception_handling( @pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance_ha_id"), + ("entity_id", "service", "state", "appliance"), [ ( "switch.fridgefreezer_freezer_super_mode", - {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: True}, SERVICE_TURN_ON, STATE_ON, "FridgeFreezer", ), ( "switch.fridgefreezer_freezer_super_mode", - {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: False}, SERVICE_TURN_OFF, STATE_OFF, "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_ent_desc_switch_functionality( - entity_id: str, - status: dict, - service: str, - state: str, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - appliance_ha_id: str, - client: MagicMock, + entity_id: str, + service: str, + state: str, ) -> None: """Test switch functionality - entity description setup.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) await hass.async_block_till_done() @@ -543,8 +406,7 @@ async def test_ent_desc_switch_functionality( "entity_id", "status", "service", - "mock_attr", - "appliance_ha_id", + "appliance", "exception_match", ), [ @@ -552,7 +414,6 @@ async def test_ent_desc_switch_functionality( "switch.fridgefreezer_freezer_super_mode", {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_ON, - "set_setting", "FridgeFreezer", r"Error.*turn.*on.*", ), @@ -560,25 +421,21 @@ async def test_ent_desc_switch_functionality( "switch.fridgefreezer_freezer_super_mode", {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_OFF, - "set_setting", "FridgeFreezer", r"Error.*turn.*off.*", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_ent_desc_switch_exception_handling( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, status: dict[SettingKey, str], service: str, - mock_attr: str, exception_match: str, - hass: HomeAssistant, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - config_entry: MockConfigEntry, - setup_credentials: None, - appliance_ha_id: str, - client_with_exception: MagicMock, ) -> None: """Test switch exception handling - entity description setup.""" client_with_exception.get_settings.side_effect = None @@ -592,9 +449,8 @@ async def test_ent_desc_switch_exception_handling( for key, value in status.items() ] ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): @@ -613,7 +469,7 @@ async def test_ent_desc_switch_exception_handling( "service", "setting_value_arg", "power_state", - "appliance_ha_id", + "appliance", ), [ ( @@ -649,20 +505,19 @@ async def test_ent_desc_switch_exception_handling( "Dishwasher", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) -async def test_power_swtich( +async def test_power_switch( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, allowed_values: list[str | None] | None, service: str, setting_value_arg: str, power_state: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - appliance_ha_id: str, - client: MagicMock, + appliance: HomeAppliance, ) -> None: """Test power switch functionality.""" client.get_settings.side_effect = None @@ -679,14 +534,13 @@ async def test_power_swtich( ] ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, + appliance.ha_id, setting_key=SettingKey.BSH_COMMON_POWER_STATE, value=setting_value_arg, ) @@ -701,12 +555,11 @@ async def test_power_swtich( ], ) async def test_power_switch_fetch_off_state_from_current_value( - initial_value: str, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + initial_value: str, ) -> None: """Test power switch functionality to fetch the off state from the current value.""" client.get_settings.side_effect = None @@ -720,9 +573,8 @@ async def test_power_switch_fetch_off_state_from_current_value( ] ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) @@ -751,15 +603,14 @@ async def test_power_switch_fetch_off_state_from_current_value( ], ) async def test_power_switch_service_validation_errors( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + exception_match: str, entity_id: str, allowed_values: list[str | None] | None | HomeConnectError, service: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - exception_match: str, - client: MagicMock, ) -> None: """Test power switch functionality validation errors.""" client.get_settings.side_effect = None @@ -787,9 +638,8 @@ async def test_power_switch_service_validation_errors( client.get_settings.return_value = ArrayOfSettings([setting]) client.get_setting = AsyncMock(return_value=setting) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( @@ -797,71 +647,6 @@ async def test_power_switch_service_validation_errors( ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_create_issue( - hass: HomeAssistant, - appliance_ha_id: str, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: 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" - 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(client) - 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 - - @pytest.mark.parametrize( ( "set_active_program_options_side_effect", @@ -882,7 +667,7 @@ async def test_create_issue( ], ) @pytest.mark.parametrize( - ("entity_id", "option_key", "appliance_ha_id"), + ("entity_id", "option_key", "appliance"), [ ( "switch.dishwasher_half_load", @@ -890,20 +675,19 @@ async def test_create_issue( "Dishwasher", ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_options_functionality( - entity_id: str, - option_key: OptionKey, - appliance_ha_id: str, + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + entity_id: str, + option_key: OptionKey, + appliance: HomeAppliance, ) -> None: """Test options functionality.""" if set_active_program_options_side_effect: @@ -922,9 +706,8 @@ async def test_options_functionality( ) ) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get(entity_id) await hass.services.async_call( @@ -933,7 +716,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": False, @@ -946,7 +729,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": True, diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index affb5ecfedf..9e114768b6f 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from datetime import time +from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -10,20 +11,32 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest +from homeassistant.components.automation import ( + DOMAIN as AUTOMATION_DOMAIN, + automations_with_entity, +) from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN, scripts_with_entity from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, STATE_UNAVAILABLE, 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 ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator @pytest.fixture @@ -32,35 +45,22 @@ def platforms() -> list[str]: return [Platform.TIME] -async def test_time( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_paired_depaired_devices_flow( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + appliance: HomeAppliance, ) -> None: - """Test time entity.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED + """Test that removed devices are correctly removed from and added to hass on API events.""" assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) -async def test_paired_depaired_devices_flow( - appliance_ha_id: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test that removed devices are correctly removed from and added to hass on API events.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -68,7 +68,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -76,7 +76,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -85,7 +85,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -93,21 +93,31 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("appliance", "keys_to_check"), + [ + ( + "Oven", + (SettingKey.BSH_COMMON_ALARM_CLOCK,), + ) + ], + indirect=["appliance"], +) async def test_connected_devices( - appliance_ha_id: str, hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, + keys_to_check: tuple, ) -> None: """Test that devices reconnected. @@ -117,26 +127,30 @@ async def test_connected_devices( get_settings_original_mock = client.get_settings async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_settings_original_mock.side_effect(ha_id) client.get_settings = AsyncMock(side_effect=get_settings_side_effect) - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED client.get_settings = get_settings_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device - entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + for key in keys_to_check: + assert not entity_registry.async_get_entity_id( + Platform.TIME, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -144,28 +158,29 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) - assert device - new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) - assert len(new_entity_entries) > len(entity_entries) + for key in keys_to_check: + assert entity_registry.async_get_entity_id( + Platform.TIME, + DOMAIN, + f"{appliance.ha_id}-{key}", + ) -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) -async def test_time_entity_availabilty( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_time_entity_availability( hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if time entities availability are based on the appliance connection state.""" entity_ids = [ "time.oven_alarm_clock", ] - assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED for entity_id in entity_ids: state = hass.states.get(entity_id) @@ -175,7 +190,7 @@ async def test_time_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -189,7 +204,7 @@ async def test_time_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -203,7 +218,8 @@ async def test_time_entity_availabilty( assert state.state != STATE_UNAVAILABLE -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key"), [ @@ -214,17 +230,15 @@ async def test_time_entity_availabilty( ], ) async def test_time_entity_functionality( - appliance_ha_id: str, - entity_id: str, - setting_key: SettingKey, hass: HomeAssistant, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, + appliance: HomeAppliance, + entity_id: str, + setting_key: SettingKey, ) -> None: """Test time entity functionality.""" - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -242,11 +256,12 @@ async def test_time_entity_functionality( ) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, setting_key=setting_key, value=value + appliance.ha_id, setting_key=setting_key, value=value ) assert hass.states.is_state(entity_id, str(time(second=value))) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ @@ -258,14 +273,13 @@ async def test_time_entity_functionality( ], ) async def test_time_entity_error( + hass: HomeAssistant, + client_with_exception: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], entity_id: str, setting_key: SettingKey, mock_attr: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, ) -> None: """Test time entity error.""" client_with_exception.get_settings.side_effect = None @@ -278,7 +292,6 @@ async def test_time_entity_error( ) ] ) - assert config_entry.state is ConfigEntryState.NOT_LOADED assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED @@ -298,3 +311,164 @@ async def test_time_entity_error( blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_create_alarm_clock_deprecation_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], +) -> None: + """Test that we create an issue when an automation or script is using a alarm clock time entity or the entity is used by the user.""" + entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" + automation_script_issue_id = ( + f"deprecated_time_alarm_clock_in_automations_scripts_{entity_id}" + ) + action_handler_issue_id = f"deprecated_time_alarm_clock_{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 await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TIME: time(minute=1), + }, + blocking=True, + ) + + 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) == 2 + assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert issue_registry.async_get_issue(DOMAIN, action_handler_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, automation_script_issue_id) + assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_alarm_clock_deprecation_issue_fix( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], +) -> None: + """Test we can fix the issues created when a alarm clock time entity is in an automation or in a script or when is used.""" + entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" + automation_script_issue_id = ( + f"deprecated_time_alarm_clock_in_automations_scripts_{entity_id}" + ) + action_handler_issue_id = f"deprecated_time_alarm_clock_{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 await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TIME: time(minute=1), + }, + blocking=True, + ) + + assert len(issue_registry.issues) == 2 + assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + + for issue in issue_registry.issues.copy().values(): + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index ffce8cd476b..2e7fa9dae08 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -365,6 +365,7 @@ async def test_invalid_schemas() -> None: {"platform": "time_pattern", "minutes": "/"}, {"platform": "time_pattern", "minutes": "*/5"}, {"platform": "time_pattern", "minutes": "/90"}, + {"platform": "time_pattern", "hours": "/0", "minutes": 10}, {"platform": "time_pattern", "hours": 12, "minutes": 0, "seconds": 100}, ) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 32c5a381233..2d5067bea3e 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -381,6 +381,32 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> assert result["step_id"] == "confirm_zigbee" +async def test_config_flow_auto_confirm_if_running(hass: HomeAssistant) -> None: + """Test the config flow skips the confirmation step the hardware is already used.""" + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_firmware_info", + return_value=FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0", + owners=[Mock(is_running=AsyncMock(return_value=True))], + source="guess", + ), + ): + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + # There are no steps, the config entry is automatically created + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert config_entry.data == { + "firmware": "ezsp", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } + + async def test_config_flow_thread(hass: HomeAssistant) -> None: """Test the config flow.""" result = await hass.config_entries.flow.async_init( @@ -558,6 +584,7 @@ async def test_config_flow_zigbee_not_hassio(hass: HomeAssistant) -> None: assert zha_flow["step_id"] == "confirm" +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: """Test the options flow, migrating Zigbee to Thread.""" config_entry = MockConfigEntry( @@ -649,6 +676,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert config_entry.data["firmware"] == "spinel" +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: """Test the options flow, migrating Thread to Zigbee.""" config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index fb38704ae61..38c2696a62a 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -45,6 +45,7 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): STEP_PICK_FIRMWARE_THREAD, ], ) +@pytest.mark.usefixtures("addon_store_info") async def test_config_flow_cannot_probe_firmware( next_step: str, hass: HomeAssistant ) -> None: @@ -660,6 +661,7 @@ async def test_options_flow_zigbee_to_thread_zha_configured( "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) +@pytest.mark.usefixtures("addon_store_info") async def test_options_flow_thread_to_zigbee_otbr_configured( hass: HomeAssistant, ) -> None: diff --git a/tests/components/homeassistant_hardware/test_coordinator.py b/tests/components/homeassistant_hardware/test_coordinator.py new file mode 100644 index 00000000000..9c57aac6811 --- /dev/null +++ b/tests/components/homeassistant_hardware/test_coordinator.py @@ -0,0 +1,55 @@ +"""Test firmware update coordinator for Home Assistant Hardware.""" + +from unittest.mock import AsyncMock, Mock, call, patch + +from ha_silabs_firmware_client import FirmwareManifest, ManifestMissing +import pytest +from yarl import URL + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util + + +async def test_firmware_update_coordinator_fetching( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the firmware update coordinator loads manifests.""" + session = async_get_clientsession(hass) + + manifest = FirmwareManifest( + url=URL("https://example.org/firmware"), + html_url=URL("https://example.org/release_notes"), + created_at=dt_util.utcnow(), + firmwares=(), + ) + + mock_client = Mock() + mock_client.async_update_data = AsyncMock(side_effect=[ManifestMissing(), manifest]) + + with patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateClient", + return_value=mock_client, + ): + coordinator = FirmwareUpdateCoordinator( + hass, session, "https://example.org/firmware" + ) + + listener = Mock() + coordinator.async_add_listener(listener) + + # The first update will fail + await coordinator.async_refresh() + assert listener.mock_calls == [call()] + assert coordinator.data is None + assert "GitHub release assets haven't been uploaded yet" in caplog.text + + # The second will succeed + await coordinator.async_refresh() + assert listener.mock_calls == [call(), call()] + assert coordinator.data == manifest + + await coordinator.async_shutdown() diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py new file mode 100644 index 00000000000..23d1e546791 --- /dev/null +++ b/tests/components/homeassistant_hardware/test_update.py @@ -0,0 +1,644 @@ +"""Test Home Assistant Hardware firmware update entity.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +import dataclasses +import logging +from unittest.mock import AsyncMock, Mock, patch + +import aiohttp +from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata +import pytest +from yarl import URL + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_provider, +) +from homeassistant.components.homeassistant_hardware.update import ( + BaseFirmwareUpdateEntity, + FirmwareUpdateEntityDescription, + FirmwareUpdateExtraStoredData, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) +from homeassistant.components.update import UpdateDeviceClass +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import EVENT_STATE_CHANGED, EntityCategory +from homeassistant.core import ( + Event, + EventStateChangedData, + HomeAssistant, + HomeAssistantError, + State, + callback, +) +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + async_capture_events, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache_with_extra_data, +) + +TEST_DOMAIN = "test" +TEST_DEVICE = "/dev/serial/by-id/some-unique-serial-device-12345" +TEST_FIRMWARE_RELEASES_URL = "https://example.org/firmware" +TEST_UPDATE_ENTITY_ID = "update.mock_name_firmware" +TEST_MANIFEST = FirmwareManifest( + url=URL("https://example.org/firmware"), + html_url=URL("https://example.org/release_notes"), + created_at=dt_util.utcnow(), + firmwares=( + FirmwareMetadata( + filename="skyconnect_zigbee_ncp_test.gbl", + checksum="aaa", + size=123, + release_notes="Some release notes go here", + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, + url=URL("https://example.org/firmwares/skyconnect_zigbee_ncp_test.gbl"), + ), + ), +) + + +TEST_FIRMWARE_ENTITY_DESCRIPTIONS: dict[ + ApplicationType | None, FirmwareUpdateEntityDescription +] = { + ApplicationType.EZSP: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split(" ", 1)[0], + fw_type="skyconnect_zigbee_ncp", + version_key="ezsp_version", + expected_firmware_type=ApplicationType.EZSP, + firmware_name="EmberZNet", + ), + ApplicationType.SPINEL: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0], + fw_type="skyconnect_openthread_rcp", + version_key="ot_rcp_version", + expected_firmware_type=ApplicationType.SPINEL, + firmware_name="OpenThread RCP", + ), + None: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, + version_key=None, + expected_firmware_type=None, + firmware_name=None, + ), +} + + +def _mock_async_create_update_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + session: aiohttp.ClientSession, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> MockFirmwareUpdateEntity: + """Create an update entity that handles firmware type changes.""" + firmware_type = config_entry.data["firmware"] + entity_description = TEST_FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) if firmware_type is not None else None + ] + + entity = MockFirmwareUpdateEntity( + device=config_entry.data["device"], + config_entry=config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + TEST_FIRMWARE_RELEASES_URL, + ), + entity_description=entity_description, + ) + + def firmware_type_changed( + old_type: ApplicationType | None, new_type: ApplicationType | None + ) -> None: + """Replace the current entity when the firmware type changes.""" + er.async_get(hass).async_remove(entity.entity_id) + async_add_entities( + [ + _mock_async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + ] + ) + + entity.async_on_remove( + entity.add_firmware_type_changed_callback(firmware_type_changed) + ) + + return entity + + +async def mock_async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, ["update"]) + return True + + +async def mock_async_setup_update_entities( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the firmware update config entry.""" + session = async_get_clientsession(hass) + entity = _mock_async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + + async_add_entities([entity]) + + +class MockFirmwareUpdateEntity(BaseFirmwareUpdateEntity): + """Mock SkyConnect firmware update entity.""" + + bootloader_reset_type = None + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the mock SkyConnect firmware update entity.""" + super().__init__(device, config_entry, update_coordinator, entity_description) + self._attr_unique_id = self.entity_description.key + self._attr_device_info = DeviceInfo( + identifiers={(TEST_DOMAIN, "yellow")}, + name="Mock Name", + model="Mock Model", + manufacturer="Mock Manufacturer", + ) + + # Use the cached firmware info if it exists + if self._config_entry.data["firmware"] is not None: + self._current_firmware_info = FirmwareInfo( + device=device, + firmware_type=ApplicationType(self._config_entry.data["firmware"]), + firmware_version=self._config_entry.data["firmware_version"], + owners=[], + source=TEST_DOMAIN, + ) + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + super()._firmware_info_callback(firmware_info) + + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + "firmware": firmware_info.firmware_type, + "firmware_version": firmware_info.firmware_version, + }, + ) + + +@pytest.fixture(name="update_config_entry") +async def mock_update_config_entry( + hass: HomeAssistant, +) -> AsyncGenerator[ConfigEntry]: + """Set up a mock Home Assistant Hardware firmware update entity.""" + await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, "homeassistant_hardware", {}) + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=mock_async_setup_entry, + ), + built_in=False, + ) + mock_platform(hass, "test.config_flow") + mock_platform( + hass, + "test.update", + MockPlatform(async_setup_entry=mock_async_setup_update_entities), + ) + + # Set up a mock integration using the hardware update entity + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "device": TEST_DEVICE, + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateClient", + autospec=True, + ) as mock_update_client, + mock_config_flow(TEST_DOMAIN, ConfigFlow), + ): + mock_update_client.return_value.async_update_data.return_value = TEST_MANIFEST + yield config_entry + + +async def test_update_entity_installation( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test the Hardware firmware update entity installation.""" + + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + # Set up another integration communicating with the device + owning_config_entry = MockConfigEntry( + domain="another_integration", + data={ + "device": { + "path": TEST_DEVICE, + "flow_control": "hardware", + "baudrate": 115200, + }, + "radio_type": "ezsp", + }, + version=4, + ) + owning_config_entry.add_to_hass(hass) + owning_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + # The integration provides firmware info + mock_hw_module = Mock() + mock_hw_module.get_firmware_info = lambda hass, config_entry: FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.3.1.0 build 0", + owners=[OwningIntegration(config_entry_id=config_entry.entry_id)], + source="another_integration", + ) + + async_register_firmware_info_provider(hass, "another_integration", mock_hw_module) + + # Pretend the other integration loaded and notified hardware of the running firmware + await async_notify_firmware_info( + hass, + "another_integration", + mock_hw_module.get_firmware_info(hass, owning_config_entry), + ) + + state_before_update = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_before_update is not None + assert state_before_update.state == "unknown" + assert state_before_update.attributes["title"] == "EmberZNet" + assert state_before_update.attributes["installed_version"] == "7.3.1.0" + assert state_before_update.attributes["latest_version"] is None + + # When we check for an update, one will be shown + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + state_after_update = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_update is not None + assert state_after_update.state == "on" + assert state_after_update.attributes["title"] == "EmberZNet" + assert state_after_update.attributes["installed_version"] == "7.3.1.0" + assert state_after_update.attributes["latest_version"] == "7.4.4.0" + assert state_after_update.attributes["release_summary"] == ( + "Some release notes go here" + ) + assert state_after_update.attributes["release_url"] == ( + "https://example.org/release_notes" + ) + + mock_firmware = Mock() + mock_flasher = AsyncMock() + + async def mock_flash_firmware(fw_image, progress_callback): + await asyncio.sleep(0) + progress_callback(0, 100) + await asyncio.sleep(0) + progress_callback(50, 100) + await asyncio.sleep(0) + progress_callback(100, 100) + + mock_flasher.flash_firmware = mock_flash_firmware + + # When we install it, the other integration is reloaded + with ( + patch( + "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", + return_value=mock_firmware, + ), + patch( + "homeassistant.components.homeassistant_hardware.update.Flasher", + return_value=mock_flasher, + ), + patch( + "homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ), + ), + patch.object( + owning_config_entry, "async_unload", wraps=owning_config_entry.async_unload + ) as owning_config_entry_unload, + ): + state_changes: list[Event[EventStateChangedData]] = async_capture_events( + hass, EVENT_STATE_CHANGED + ) + await hass.services.async_call( + "update", + "install", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + # Progress events are emitted during the installation + assert len(state_changes) == 7 + + # Indeterminate progress first + assert state_changes[0].data["new_state"].attributes["in_progress"] is True + assert state_changes[0].data["new_state"].attributes["update_percentage"] is None + + # Then the update starts + assert state_changes[1].data["new_state"].attributes["update_percentage"] == 0 + assert state_changes[2].data["new_state"].attributes["update_percentage"] == 50 + assert state_changes[3].data["new_state"].attributes["update_percentage"] == 100 + + # Once it is done, we probe the firmware + assert state_changes[4].data["new_state"].attributes["in_progress"] is True + assert state_changes[4].data["new_state"].attributes["update_percentage"] is None + + # Finally, the update finishes + assert state_changes[5].data["new_state"].attributes["update_percentage"] is None + assert state_changes[6].data["new_state"].attributes["update_percentage"] is None + assert state_changes[6].data["new_state"].attributes["in_progress"] is False + + # The owning integration was unloaded and is again running + assert len(owning_config_entry_unload.mock_calls) == 1 + + # After the firmware update, the entity has the new version and the correct state + state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_install is not None + assert state_after_install.state == "off" + assert state_after_install.attributes["title"] == "EmberZNet" + assert state_after_install.attributes["installed_version"] == "7.4.4.0" + assert state_after_install.attributes["latest_version"] == "7.4.4.0" + + +async def test_update_entity_installation_failure( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test installation failing during flashing.""" + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + state_before_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_before_install is not None + assert state_before_install.state == "on" + assert state_before_install.attributes["title"] == "EmberZNet" + assert state_before_install.attributes["installed_version"] == "7.3.1.0" + assert state_before_install.attributes["latest_version"] == "7.4.4.0" + + mock_flasher = AsyncMock() + mock_flasher.flash_firmware.side_effect = RuntimeError( + "Something broke during flashing!" + ) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", + return_value=Mock(), + ), + patch( + "homeassistant.components.homeassistant_hardware.update.Flasher", + return_value=mock_flasher, + ), + pytest.raises(HomeAssistantError, match="Failed to flash firmware"), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + # After the firmware update fails, we can still try again + state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_install is not None + assert state_after_install.state == "on" + assert state_after_install.attributes["title"] == "EmberZNet" + assert state_after_install.attributes["installed_version"] == "7.3.1.0" + assert state_after_install.attributes["latest_version"] == "7.4.4.0" + + +async def test_update_entity_installation_probe_failure( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test installation failing during post-flashing probing.""" + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + state_before_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_before_install is not None + assert state_before_install.state == "on" + assert state_before_install.attributes["title"] == "EmberZNet" + assert state_before_install.attributes["installed_version"] == "7.3.1.0" + assert state_before_install.attributes["latest_version"] == "7.4.4.0" + + with ( + patch( + "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", + return_value=Mock(), + ), + patch( + "homeassistant.components.homeassistant_hardware.update.Flasher", + return_value=AsyncMock(), + ), + patch( + "homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info", + return_value=None, + ), + pytest.raises( + HomeAssistantError, match="Failed to probe the firmware after flashing" + ), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + # After the firmware update fails, we can still try again + state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_install is not None + assert state_after_install.state == "on" + assert state_after_install.attributes["title"] == "EmberZNet" + assert state_after_install.attributes["installed_version"] == "7.3.1.0" + assert state_after_install.attributes["latest_version"] == "7.4.4.0" + + +async def test_update_entity_state_restoration( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test the Hardware firmware update entity state restoration.""" + + mock_restore_cache_with_extra_data( + hass, + [ + ( + State(TEST_UPDATE_ENTITY_ID, "on"), + FirmwareUpdateExtraStoredData( + firmware_manifest=TEST_MANIFEST + ).as_dict(), + ) + ], + ) + + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + # The state is correctly restored + state = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state is not None + assert state.state == "on" + assert state.attributes["title"] == "EmberZNet" + assert state.attributes["installed_version"] == "7.3.1.0" + assert state.attributes["latest_version"] == "7.4.4.0" + assert state.attributes["release_summary"] == ("Some release notes go here") + assert state.attributes["release_url"] == ("https://example.org/release_notes") + + +async def test_update_entity_firmware_missing_from_manifest( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test the Hardware firmware update entity handles missing firmware.""" + + mock_restore_cache_with_extra_data( + hass, + [ + ( + State(TEST_UPDATE_ENTITY_ID, "on"), + # Ensure the manifest does not contain our expected firmware type + FirmwareUpdateExtraStoredData( + firmware_manifest=dataclasses.replace(TEST_MANIFEST, firmwares=()) + ).as_dict(), + ) + ], + ) + + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + # The state is restored, accounting for the missing firmware + state = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state is not None + assert state.state == "unknown" + assert state.attributes["title"] == "EmberZNet" + assert state.attributes["installed_version"] == "7.3.1.0" + assert state.attributes["latest_version"] is None + assert state.attributes["release_summary"] is None + assert state.attributes["release_url"] is None + + +async def test_update_entity_graceful_firmware_type_callback_errors( + hass: HomeAssistant, + update_config_entry: ConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test firmware update entity handling of firmware type callback errors.""" + + session = async_get_clientsession(hass) + update_entity = MockFirmwareUpdateEntity( + device=TEST_DEVICE, + config_entry=update_config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + TEST_FIRMWARE_RELEASES_URL, + ), + entity_description=TEST_FIRMWARE_ENTITY_DESCRIPTIONS[ApplicationType.EZSP], + ) + update_entity.hass = hass + await update_entity.async_added_to_hass() + + callback = Mock(side_effect=RuntimeError("Callback failed")) + unregister_callback = update_entity.add_firmware_type_changed_callback(callback) + + with caplog.at_level(logging.WARNING): + await async_notify_firmware_info( + hass, + "some_integration", + FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.SPINEL, + firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57", + owners=[], + source="probe", + ), + ) + + unregister_callback() + assert "Failed to call firmware type changed callback" in caplog.text diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index b467380c431..1b7bfe4a8ac 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -205,6 +205,93 @@ async def test_owning_addon(hass: HomeAssistant) -> None: assert (await owning_addon.is_running(hass)) is False +async def test_owning_addon_temporarily_stop_info_error(hass: HomeAssistant) -> None: + """Test `OwningAddon` temporarily stopping with an info error.""" + + owning_addon = OwningAddon(slug="some-addon-slug") + mock_manager = AsyncMock() + mock_manager.async_get_addon_info.side_effect = AddonError() + + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", + return_value=mock_manager, + ): + async with owning_addon.temporarily_stop(hass): + pass + + # We never restart it + assert len(mock_manager.async_get_addon_info.mock_calls) == 1 + assert len(mock_manager.async_stop_addon.mock_calls) == 0 + assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 0 + assert len(mock_manager.async_start_addon_waiting.mock_calls) == 0 + + +async def test_owning_addon_temporarily_stop_not_running(hass: HomeAssistant) -> None: + """Test `OwningAddon` temporarily stopping when the addon is not running.""" + + owning_addon = OwningAddon(slug="some-addon-slug") + + mock_manager = AsyncMock() + mock_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ) + + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", + return_value=mock_manager, + ): + async with owning_addon.temporarily_stop(hass): + pass + + # We never restart it + assert len(mock_manager.async_get_addon_info.mock_calls) == 1 + assert len(mock_manager.async_stop_addon.mock_calls) == 0 + assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 0 + assert len(mock_manager.async_start_addon_waiting.mock_calls) == 0 + + +async def test_owning_addon_temporarily_stop(hass: HomeAssistant) -> None: + """Test `OwningAddon` temporarily stopping when the addon is running.""" + + owning_addon = OwningAddon(slug="some-addon-slug") + + mock_manager = AsyncMock() + mock_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) + + mock_manager.async_stop_addon = AsyncMock() + mock_manager.async_wait_until_addon_state = AsyncMock() + mock_manager.async_start_addon_waiting = AsyncMock() + + # The error is propagated but it doesn't affect restarting the addon + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", + return_value=mock_manager, + ), + pytest.raises(RuntimeError), + ): + async with owning_addon.temporarily_stop(hass): + raise RuntimeError("Some error") + + # We restart it + assert len(mock_manager.async_get_addon_info.mock_calls) == 1 + assert len(mock_manager.async_stop_addon.mock_calls) == 1 + assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 1 + assert len(mock_manager.async_start_addon_waiting.mock_calls) == 1 + + async def test_owning_integration(hass: HomeAssistant) -> None: """Test `OwningIntegration`.""" config_entry = MockConfigEntry(domain="mock_domain", unique_id="some_unique_id") @@ -225,6 +312,67 @@ async def test_owning_integration(hass: HomeAssistant) -> None: assert (await owning_integration2.is_running(hass)) is False +async def test_owning_integration_temporarily_stop_missing_entry( + hass: HomeAssistant, +) -> None: + """Test temporarily stopping the integration when the config entry doesn't exist.""" + missing_integration = OwningIntegration(config_entry_id="missing_entry_id") + + with ( + patch.object(hass.config_entries, "async_unload") as mock_unload, + patch.object(hass.config_entries, "async_setup") as mock_setup, + ): + async with missing_integration.temporarily_stop(hass): + pass + + # Because there's no matching entry, no unload or setup calls are made + assert len(mock_unload.mock_calls) == 0 + assert len(mock_setup.mock_calls) == 0 + + +async def test_owning_integration_temporarily_stop_not_loaded( + hass: HomeAssistant, +) -> None: + """Test temporarily stopping the integration when the config entry is not loaded.""" + entry = MockConfigEntry(domain="test_domain") + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.NOT_LOADED) + + integration = OwningIntegration(config_entry_id=entry.entry_id) + + with ( + patch.object(hass.config_entries, "async_unload") as mock_unload, + patch.object(hass.config_entries, "async_setup") as mock_setup, + ): + async with integration.temporarily_stop(hass): + pass + + # Since the entry was not loaded, we never unload or re-setup + assert len(mock_unload.mock_calls) == 0 + assert len(mock_setup.mock_calls) == 0 + + +async def test_owning_integration_temporarily_stop_loaded(hass: HomeAssistant) -> None: + """Test temporarily stopping the integration when the config entry is loaded.""" + entry = MockConfigEntry(domain="test_domain") + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + + integration = OwningIntegration(config_entry_id=entry.entry_id) + + with ( + patch.object(hass.config_entries, "async_unload") as mock_unload, + patch.object(hass.config_entries, "async_setup") as mock_setup, + pytest.raises(RuntimeError), + ): + async with integration.temporarily_stop(hass): + raise RuntimeError("Some error during the temporary stop") + + # We expect one unload followed by one setup call + mock_unload.assert_called_once_with(entry.entry_id) + mock_setup.assert_called_once_with(entry.entry_id) + + async def test_firmware_info(hass: HomeAssistant) -> None: """Test `FirmwareInfo`.""" diff --git a/tests/components/homeassistant_sky_connect/common.py b/tests/components/homeassistant_sky_connect/common.py new file mode 100644 index 00000000000..335fd6d2e12 --- /dev/null +++ b/tests/components/homeassistant_sky_connect/common.py @@ -0,0 +1,21 @@ +"""Common constants for the SkyConnect integration tests.""" + +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +USB_DATA_SKY = UsbServiceInfo( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + vid="10C4", + pid="EA60", + serial_number="9e2adbd75b8beb119fe564a0f320645d", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", +) + +USB_DATA_ZBT1 = UsbServiceInfo( + device="/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + vid="10C4", + pid="EA60", + serial_number="9e2adbd75b8beb119fe564a0f320645d", + manufacturer="Nabu Casa", + description="Home Assistant Connect ZBT-1", +) diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index c5bfa4bd609..89ec292d879 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -47,3 +47,13 @@ def mock_zha_get_last_network_settings() -> Generator[None]: AsyncMock(return_value=None), ): yield + + +@pytest.fixture(autouse=True) +def mock_usb_path_exists() -> Generator[None]: + """Mock os.path.exists to allow the ZBT-1 integration to load.""" + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists", + return_value=True, + ): + yield diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index d8542002ae8..44a5e0029c3 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -22,26 +22,10 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.usb import UsbServiceInfo +from .common import USB_DATA_SKY, USB_DATA_ZBT1 + from tests.common import MockConfigEntry -USB_DATA_SKY = UsbServiceInfo( - device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", - vid="10C4", - pid="EA60", - serial_number="9e2adbd75b8beb119fe564a0f320645d", - manufacturer="Nabu Casa", - description="SkyConnect v1.0", -) - -USB_DATA_ZBT1 = UsbServiceInfo( - device="/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", - vid="10C4", - pid="EA60", - serial_number="9e2adbd75b8beb119fe564a0f320645d", - manufacturer="Nabu Casa", - description="Home Assistant Connect ZBT-1", -) - @pytest.mark.parametrize( ("usb_data", "model"), @@ -76,7 +60,7 @@ async def test_config_flow( return_value=FirmwareInfo( device=usb_data.device, firmware_type=ApplicationType.EZSP, - firmware_version=None, + firmware_version="7.4.4.0 build 0", owners=[], source="probe", ), @@ -92,6 +76,7 @@ async def test_config_flow( config_entry = result["result"] assert config_entry.data == { "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", "device": usb_data.device, "manufacturer": usb_data.manufacturer, "pid": usb_data.pid, @@ -161,7 +146,7 @@ async def test_options_flow( return_value=FirmwareInfo( device=usb_data.device, firmware_type=ApplicationType.EZSP, - firmware_version=None, + firmware_version="7.4.4.0 build 0", owners=[], source="probe", ), @@ -177,6 +162,7 @@ async def test_options_flow( assert config_entry.data == { "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", "device": usb_data.device, "manufacturer": usb_data.manufacturer, "pid": usb_data.pid, diff --git a/tests/components/homeassistant_sky_connect/test_hardware.py b/tests/components/homeassistant_sky_connect/test_hardware.py index f39e648b0f2..e59a1e7df06 100644 --- a/tests/components/homeassistant_sky_connect/test_hardware.py +++ b/tests/components/homeassistant_sky_connect/test_hardware.py @@ -28,6 +28,10 @@ CONFIG_ENTRY_DATA_2 = { "firmware": "ezsp", } +CONFIG_ENTRY_DATA_BAD = { + "device": "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_a87b7d75b18beb119fe564a0f320645d-if00-port0", +} + async def test_hardware_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info @@ -59,9 +63,20 @@ async def test_hardware_info( minor_version=2, ) config_entry_2.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry_2.entry_id) + config_entry_bad = MockConfigEntry( + data=CONFIG_ENTRY_DATA_BAD, + domain=DOMAIN, + options={}, + title="Home Assistant Connect ZBT-1", + unique_id="unique_3", + version=1, + minor_version=2, + ) + config_entry_bad.add_to_hass(hass) + assert not await hass.config_entries.async_setup(config_entry_bad.entry_id) + client = await hass_ws_client(hass) await client.send_json({"id": 1, "type": "hardware/info"}) @@ -97,5 +112,6 @@ async def test_hardware_info( "name": "Home Assistant Connect ZBT-1", "url": "https://skyconnect.home-assistant.io/documentation/", }, + # Bad entry is skipped ] } diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 8e90039a4fc..f027a6d2fb8 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -1,15 +1,36 @@ """Test the Home Assistant SkyConnect integration.""" +from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) -from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.components.homeassistant_sky_connect.const import ( + DESCRIPTION, + DOMAIN, + MANUFACTURER, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, +) +from homeassistant.components.usb import USBDevice +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant +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_fire_time_changed +from tests.components.usb import ( + async_request_scan, + force_usb_polling_watcher, # noqa: F401 + patch_scanned_serial_ports, +) async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: @@ -44,7 +65,7 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.version == 1 - assert config_entry.minor_version == 2 + assert config_entry.minor_version == 4 assert config_entry.data == { "description": "SkyConnect v1.0", "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", @@ -54,6 +75,222 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: "manufacturer": "Nabu Casa", "product": "SkyConnect v1.0", # `description` has been copied to `product` "firmware": "spinel", # new key + "firmware_version": None, # new key } await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: + """Test setup failing when the USB port is missing.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "description": "SkyConnect v1.0", + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + # Set up the config entry + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_exists.return_value = True + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + # Now it's ready + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.usefixtures("force_usb_polling_watcher") +async def test_usb_device_reactivity(hass: HomeAssistant) -> None: + """Test setting up USB monitoring.""" + 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() + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "description": "SkyConnect v1.0", + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + # Now we make it available but do not wait + mock_exists.return_value = True + + with patch_scanned_serial_ports( + return_value=[ + USBDevice( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + vid="10C4", + pid="EA60", + serial_number="3c0ed67c628beb11b1cd64a0f320645d", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", + ) + ], + ): + await async_request_scan(hass) + + # It loads immediately + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Wait for a bit for the USB scan debouncer to cool off + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=5)) + + # Unplug the stick + mock_exists.return_value = False + + with patch_scanned_serial_ports(return_value=[]): + await async_request_scan(hass) + + # The integration has reloaded and is now in a failed state + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_bad_config_entry_fixing(hass: HomeAssistant) -> None: + """Test fixing/deleting config entries with bad data.""" + + # Newly-added ZBT-1 + new_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-9e2adbd75b8beb119fe564a0f320645d", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "9e2adbd75b8beb119fe564a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0 (build 123)", + }, + version=1, + minor_version=3, + ) + + new_entry.add_to_hass(hass) + + # Old config entry, without firmware info + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-3c0ed67c628beb11b1cd64a0f320645d", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_3c0ed67c628beb11b1cd64a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "description": "SkyConnect v1.0", + }, + version=1, + minor_version=1, + ) + + old_entry.add_to_hass(hass) + + # Bad config entry, missing most keys + bad_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-9f6c4bba657cc9a4f0cea48bc5948562", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9f6c4bba657cc9a4f0cea48bc5948562-if00-port0", + }, + version=1, + minor_version=2, + ) + + bad_entry.add_to_hass(hass) + + # Bad config entry, missing most keys, but fixable since the device is present + fixable_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-4f5f3b26d59f8714a78b599690741999", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_4f5f3b26d59f8714a78b599690741999-if00-port0", + }, + version=1, + minor_version=2, + ) + + fixable_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_sky_connect.scan_serial_ports", + return_value=[ + USBDevice( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_4f5f3b26d59f8714a78b599690741999-if00-port0", + vid="10C4", + pid="EA60", + serial_number="4f5f3b26d59f8714a78b599690741999", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", + ) + ], + ): + await async_setup_component(hass, "homeassistant_sky_connect", {}) + + assert hass.config_entries.async_get_entry(new_entry.entry_id) is not None + assert hass.config_entries.async_get_entry(old_entry.entry_id) is not None + assert hass.config_entries.async_get_entry(fixable_entry.entry_id) is not None + + updated_entry = hass.config_entries.async_get_entry(fixable_entry.entry_id) + assert updated_entry is not None + assert updated_entry.data[VID] == "10C4" + assert updated_entry.data[PID] == "EA60" + assert updated_entry.data[SERIAL_NUMBER] == "4f5f3b26d59f8714a78b599690741999" + assert updated_entry.data[MANUFACTURER] == "Nabu Casa" + assert updated_entry.data[PRODUCT] == "SkyConnect v1.0" + assert updated_entry.data[DESCRIPTION] == "SkyConnect v1.0" + + untouched_bad_entry = hass.config_entries.async_get_entry(bad_entry.entry_id) + assert untouched_bad_entry.minor_version == 3 diff --git a/tests/components/homeassistant_sky_connect/test_update.py b/tests/components/homeassistant_sky_connect/test_update.py new file mode 100644 index 00000000000..b6c7291e0af --- /dev/null +++ b/tests/components/homeassistant_sky_connect/test_update.py @@ -0,0 +1,132 @@ +"""Test SkyConnect firmware update entity.""" + +import pytest + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import USB_DATA_ZBT1 + +from tests.common import MockConfigEntry + +UPDATE_ENTITY_ID = "update.home_assistant_connect_zbt_1_9e2adbd7_firmware" + + +async def test_zbt1_update_entity(hass: HomeAssistant) -> None: + """Test the ZBT-1 firmware update entity.""" + await async_setup_component(hass, "homeassistant", {}) + + # Set up the ZBT-1 integration + zbt1_config_entry = MockConfigEntry( + domain="homeassistant_sky_connect", + data={ + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + "device": USB_DATA_ZBT1.device, + "manufacturer": USB_DATA_ZBT1.manufacturer, + "pid": USB_DATA_ZBT1.pid, + "product": USB_DATA_ZBT1.description, + "serial_number": USB_DATA_ZBT1.serial_number, + "vid": USB_DATA_ZBT1.vid, + }, + version=1, + minor_version=3, + ) + zbt1_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(zbt1_config_entry.entry_id) + await hass.async_block_till_done() + + # Pretend ZHA loaded and notified hardware of the running firmware + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=USB_DATA_ZBT1.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.3.1.0 build 0", + owners=[], + source="zha", + ), + ) + await hass.async_block_till_done() + + state_ezsp = hass.states.get(UPDATE_ENTITY_ID) + assert state_ezsp is not None + assert state_ezsp.state == "unknown" + assert state_ezsp.attributes["title"] == "EmberZNet Zigbee" + assert state_ezsp.attributes["installed_version"] == "7.3.1.0" + assert state_ezsp.attributes["latest_version"] is None + + # Now, have OTBR push some info + await async_notify_firmware_info( + hass, + "otbr", + FirmwareInfo( + device=USB_DATA_ZBT1.device, + firmware_type=ApplicationType.SPINEL, + firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57", + owners=[], + source="otbr", + ), + ) + await hass.async_block_till_done() + + # After the firmware update, the entity has the new version and the correct state + state_spinel = hass.states.get(UPDATE_ENTITY_ID) + assert state_spinel is not None + assert state_spinel.state == "unknown" + assert state_spinel.attributes["title"] == "OpenThread RCP" + assert state_spinel.attributes["installed_version"] == "2.4.4.0" + assert state_spinel.attributes["latest_version"] is None + + +@pytest.mark.parametrize( + ("firmware", "version", "expected"), + [ + ("ezsp", "7.3.1.0 build 0", "EmberZNet Zigbee 7.3.1.0"), + ("spinel", "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", "OpenThread RCP 2.4.4.0"), + ("bootloader", "2.4.2", "Gecko Bootloader 2.4.2"), + ("cpc", "4.3.2", "Multiprotocol 4.3.2"), + ("router", "1.2.3.4", "Unknown 1.2.3.4"), # Not supported but still shown + ], +) +async def test_zbt1_update_entity_state( + hass: HomeAssistant, firmware: str, version: str, expected: str +) -> None: + """Test the ZBT-1 firmware update entity with different firmware types.""" + await async_setup_component(hass, "homeassistant", {}) + + zbt1_config_entry = MockConfigEntry( + domain="homeassistant_sky_connect", + data={ + "firmware": firmware, + "firmware_version": version, + "device": USB_DATA_ZBT1.device, + "manufacturer": USB_DATA_ZBT1.manufacturer, + "pid": USB_DATA_ZBT1.pid, + "product": USB_DATA_ZBT1.description, + "serial_number": USB_DATA_ZBT1.serial_number, + "vid": USB_DATA_ZBT1.vid, + }, + version=1, + minor_version=3, + ) + zbt1_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(zbt1_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert ( + f"{state.attributes['title']} {state.attributes['installed_version']}" + == expected + ) diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 78fd45c6b5b..1d5a64eafb9 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -101,12 +101,12 @@ async def test_config_flow(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home Assistant Yellow" - assert result["data"] == {"firmware": "ezsp"} + assert result["data"] == {"firmware": "ezsp", "firmware_version": None} assert result["options"] == {} assert len(mock_setup_entry.mock_calls) == 1 config_entry = hass.config_entries.async_entries(DOMAIN)[0] - assert config_entry.data == {"firmware": "ezsp"} + assert config_entry.data == {"firmware": "ezsp", "firmware_version": None} assert config_entry.options == {} assert config_entry.title == "Home Assistant Yellow" @@ -350,7 +350,7 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: return_value=FirmwareInfo( device=RADIO_DEVICE, firmware_type=ApplicationType.EZSP, - firmware_version=None, + firmware_version="7.4.4.0 build 0", owners=[], source="probe", ), @@ -366,6 +366,7 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: assert config_entry.data == { "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", } diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 57d63c7441e..00e3383cf77 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -10,6 +10,9 @@ from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) +from homeassistant.components.homeassistant_yellow.config_flow import ( + HomeAssistantYellowConfigFlow, +) from homeassistant.components.homeassistant_yellow.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -248,3 +251,71 @@ async def test_setup_entry_addon_info_fails( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("start_version", "data", "migrated_data"), + [ + (1, {}, {"firmware": "ezsp", "firmware_version": None}), + (2, {"firmware": "ezsp"}, {"firmware": "ezsp", "firmware_version": None}), + ( + 2, + {"firmware": "ezsp", "firmware_version": "123"}, + {"firmware": "ezsp", "firmware_version": "123"}, + ), + (3, {"firmware": "ezsp"}, {"firmware": "ezsp", "firmware_version": None}), + ( + 3, + {"firmware": "ezsp", "firmware_version": "123"}, + {"firmware": "ezsp", "firmware_version": "123"}, + ), + ], +) +async def test_migrate_entry( + hass: HomeAssistant, + start_version: int, + data: dict, + migrated_data: dict, +) -> None: + """Test migration of a config entry.""" + mock_integration(hass, MockModule("hassio")) + await async_setup_component(hass, HASSIO_DOMAIN, {}) + + # Setup the config entry + config_entry = MockConfigEntry( + data=data, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + version=1, + minor_version=start_version, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ), + patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=True, + ), + patch( + "homeassistant.components.homeassistant_yellow.guess_firmware_info", + return_value=FirmwareInfo( # Nothing is setup + device="/dev/ttyAMA1", + firmware_version="1234", + firmware_type=ApplicationType.EZSP, + source="unknown", + owners=[], + ), + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data == migrated_data + assert config_entry.options == {} + assert config_entry.minor_version == HomeAssistantYellowConfigFlow.MINOR_VERSION + assert config_entry.version == HomeAssistantYellowConfigFlow.VERSION diff --git a/tests/components/homeassistant_yellow/test_update.py b/tests/components/homeassistant_yellow/test_update.py new file mode 100644 index 00000000000..66404dc2176 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_update.py @@ -0,0 +1,144 @@ +"""Test Yellow firmware update entity.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.components.homeassistant_yellow.const import RADIO_DEVICE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +UPDATE_ENTITY_ID = "update.home_assistant_yellow_radio_firmware" + + +async def test_yellow_update_entity(hass: HomeAssistant) -> None: + """Test the Yellow firmware update entity.""" + await async_setup_component(hass, "homeassistant", {}) + + # Set up the Yellow integration + yellow_config_entry = MockConfigEntry( + title="Home Assistant Yellow", + domain="homeassistant_yellow", + data={ + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + "device": RADIO_DEVICE, + }, + version=1, + minor_version=3, + ) + yellow_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_yellow.is_hassio", return_value=True + ), + patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ), + ): + assert await hass.config_entries.async_setup(yellow_config_entry.entry_id) + await hass.async_block_till_done() + + # Pretend ZHA loaded and notified hardware of the running firmware + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.3.1.0 build 0", + owners=[], + source="zha", + ), + ) + await hass.async_block_till_done() + + state_ezsp = hass.states.get(UPDATE_ENTITY_ID) + assert state_ezsp is not None + assert state_ezsp.state == "unknown" + assert state_ezsp.attributes["title"] == "EmberZNet Zigbee" + assert state_ezsp.attributes["installed_version"] == "7.3.1.0" + assert state_ezsp.attributes["latest_version"] is None + + # Now, have OTBR push some info + await async_notify_firmware_info( + hass, + "otbr", + FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.SPINEL, + firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57", + owners=[], + source="otbr", + ), + ) + await hass.async_block_till_done() + + # After the firmware update, the entity has the new version and the correct state + state_spinel = hass.states.get(UPDATE_ENTITY_ID) + assert state_spinel is not None + assert state_spinel.state == "unknown" + assert state_spinel.attributes["title"] == "OpenThread RCP" + assert state_spinel.attributes["installed_version"] == "2.4.4.0" + assert state_spinel.attributes["latest_version"] is None + + +@pytest.mark.parametrize( + ("firmware", "version", "expected"), + [ + ("ezsp", "7.3.1.0 build 0", "EmberZNet Zigbee 7.3.1.0"), + ("spinel", "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", "OpenThread RCP 2.4.4.0"), + ("bootloader", "2.4.2", "Gecko Bootloader 2.4.2"), + ("cpc", "4.3.2", "Multiprotocol 4.3.2"), + ("router", "1.2.3.4", "Unknown 1.2.3.4"), # Not supported but still shown + ], +) +async def test_yellow_update_entity_state( + hass: HomeAssistant, firmware: str, version: str, expected: str +) -> None: + """Test the Yellow firmware update entity with different firmware types.""" + await async_setup_component(hass, "homeassistant", {}) + + # Set up the Yellow integration + yellow_config_entry = MockConfigEntry( + title="Home Assistant Yellow", + domain="homeassistant_yellow", + data={ + "firmware": firmware, + "firmware_version": version, + "device": RADIO_DEVICE, + }, + version=1, + minor_version=3, + ) + yellow_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_yellow.is_hassio", return_value=True + ), + patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ), + ): + assert await hass.config_entries.async_setup(yellow_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert ( + f"{state.attributes['title']} {state.attributes['installed_version']}" + == expected + ) diff --git a/tests/components/homee/fixtures/binary_sensors.json b/tests/components/homee/fixtures/binary_sensors.json new file mode 100644 index 00000000000..5ced5dc51da --- /dev/null +++ b/tests/components/homee/fixtures/binary_sensors.json @@ -0,0 +1,891 @@ +{ + "id": 1, + "name": "Test Binary Sensor", + "profile": 4026, + "image": "default", + "favorite": 0, + "order": 20, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1709379826, + "added": 1676199446, + "history": 1, + "cube_type": 1, + "note": "", + "services": 5, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 69, + "state": 1, + "last_changed": 1706461181, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 17, + "state": 1, + "last_changed": 1691668428, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 132, + "state": 1, + "last_changed": 1691668428, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 4, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 228, + "state": 1, + "last_changed": 1691668428, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 5, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 12, + "state": 1, + "last_changed": 1699456267, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 6, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 52, + "state": 1, + "last_changed": 1694176210, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 7, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 68, + "state": 1, + "last_changed": 1694176210, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 8, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 139, + "state": 1, + "last_changed": 1650402359, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 232, + "state": 1, + "last_changed": 1711897362, + "changed_by": 4, + "changed_by_id": 5, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 10, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 54, + "state": 1, + "last_changed": 1650402359, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 11, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 70, + "state": 1, + "last_changed": 1738231378, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 78, + "state": 1, + "last_changed": 1738231378, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 13, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 77, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 14, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 25, + "state": 1, + "last_changed": 1709933563, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 15, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 330, + "state": 1, + "last_changed": 1709933563, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 16, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 17, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 14, + "state": 1, + "last_changed": 1739320320, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 18, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 143, + "state": 1, + "last_changed": 1694992768, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 19, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 140, + "state": 1, + "last_changed": 1718900928, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 20, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 76, + "state": 1, + "last_changed": 1718900928, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 21, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 182, + "state": 1, + "last_changed": 1718900928, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 22, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 101, + "state": 1, + "last_changed": 1700056646, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 23, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n%2Fa", + "step_value": 1.0, + "editable": 0, + "type": 289, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 24, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 16, + "state": 1, + "last_changed": 1616314530, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 25, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 181, + "state": 1, + "last_changed": 1616314530, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 26, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 138, + "state": 1, + "last_changed": 1700747644, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 27, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 30, + "state": 1, + "last_changed": 1709933563, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 28, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 141, + "state": 1, + "last_changed": 1700747644, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 29, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 80, + "state": 1, + "last_changed": 1700747644, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/fan.json b/tests/components/homee/fixtures/fan.json new file mode 100644 index 00000000000..9a6cd028dc1 --- /dev/null +++ b/tests/components/homee/fixtures/fan.json @@ -0,0 +1,73 @@ +{ + "id": 77, + "name": "Test Fan", + "profile": 3019, + "image": "default", + "favorite": 0, + "order": 76, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1736106044, + "added": 1723550156, + "history": 1, + "cube_type": 3, + "note": "", + "services": 1, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 77, + "instance": 0, + "minimum": 0, + "maximum": 8, + "current_value": 3.0, + "target_value": 3.0, + "last_value": 6.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 99, + "state": 5, + "last_changed": 1729920212, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 77, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 100, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/fixtures/homee.json b/tests/components/homee/fixtures/homee.json new file mode 100644 index 00000000000..763e594c2fa --- /dev/null +++ b/tests/components/homee/fixtures/homee.json @@ -0,0 +1,135 @@ +{ + "id": -1, + "name": "homee", + "profile": 1, + "image": "default", + "favorite": 0, + "order": 0, + "protocol": 0, + "routing": 0, + "state": 1, + "state_changed": 16, + "added": 16, + "history": 1, + "cube_type": 0, + "note": "", + "services": 0, + "phonetic_name": "", + "owner": 0, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": -1, + "instance": 0, + "minimum": 0, + "maximum": 200, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 205, + "state": 1, + "last_changed": 1735815716, + "changed_by": 2, + "changed_by_id": 4, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 18, + "node_id": -1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 15.0, + "target_value": 15.0, + "last_value": 15.0, + "unit": "%", + "step_value": 0.1, + "editable": 0, + "type": 311, + "state": 1, + "last_changed": 1739390161, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + }, + { + "id": 19, + "node_id": -1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 5.0, + "target_value": 5.0, + "last_value": 10.0, + "unit": "%", + "step_value": 0.1, + "editable": 0, + "type": 312, + "state": 1, + "last_changed": 1739390161, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + }, + { + "id": 20, + "node_id": -1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 10.0, + "target_value": 10.0, + "last_value": 10.0, + "unit": "%", + "step_value": 0.1, + "editable": 0, + "type": 313, + "state": 1, + "last_changed": 1739390161, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/lock.json b/tests/components/homee/fixtures/lock.json new file mode 100644 index 00000000000..79fd53e0311 --- /dev/null +++ b/tests/components/homee/fixtures/lock.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "name": "Test Lock", + "profile": 2007, + "image": "default", + "favorite": 0, + "order": 31, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1711799526, + "added": 1645036891, + "history": 1, + "cube_type": 1, + "note": "", + "services": 3, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 232, + "state": 1, + "last_changed": 1711897362, + "changed_by": 4, + "changed_by_id": 5, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/numbers.json b/tests/components/homee/fixtures/numbers.json index c8773a89568..fd00ca4b5bd 100644 --- a/tests/components/homee/fixtures/numbers.json +++ b/tests/components/homee/fixtures/numbers.json @@ -19,7 +19,7 @@ "security": 0, "attributes": [ { - "id": 2, + "id": 1, "node_id": 1, "instance": 0, "minimum": 0, @@ -40,7 +40,7 @@ "name": "" }, { - "id": 3, + "id": 2, "node_id": 1, "instance": 0, "minimum": -75, @@ -61,7 +61,7 @@ "name": "" }, { - "id": 4, + "id": 3, "node_id": 1, "instance": 0, "minimum": 4, @@ -82,7 +82,7 @@ "name": "" }, { - "id": 5, + "id": 4, "node_id": 1, "instance": 0, "minimum": 0, @@ -103,7 +103,7 @@ "name": "" }, { - "id": 6, + "id": 5, "node_id": 1, "instance": 0, "minimum": 1, @@ -124,7 +124,7 @@ "name": "" }, { - "id": 7, + "id": 6, "node_id": 1, "instance": 0, "minimum": 0, @@ -145,7 +145,7 @@ "name": "" }, { - "id": 8, + "id": 7, "node_id": 1, "instance": 0, "minimum": 5, @@ -166,7 +166,7 @@ "name": "" }, { - "id": 9, + "id": 8, "node_id": 1, "instance": 0, "minimum": 0, @@ -187,7 +187,7 @@ "name": "" }, { - "id": 10, + "id": 9, "node_id": 1, "instance": 0, "minimum": -127, @@ -208,7 +208,7 @@ "name": "" }, { - "id": 11, + "id": 10, "node_id": 1, "instance": 0, "minimum": -127, @@ -229,7 +229,7 @@ "name": "" }, { - "id": 12, + "id": 11, "node_id": 1, "instance": 0, "minimum": 1, @@ -250,7 +250,7 @@ "name": "" }, { - "id": 13, + "id": 12, "node_id": 1, "instance": 0, "minimum": -5, @@ -271,7 +271,7 @@ "name": "" }, { - "id": 14, + "id": 13, "node_id": 1, "instance": 0, "minimum": 4, @@ -292,7 +292,7 @@ "name": "" }, { - "id": 15, + "id": 14, "node_id": 1, "instance": 0, "minimum": 30, @@ -313,7 +313,7 @@ "name": "" }, { - "id": 16, + "id": 15, "node_id": 1, "instance": 0, "minimum": 0, @@ -332,6 +332,27 @@ "based_on": 1, "data": "fixed_value", "name": "" + }, + { + "id": 16, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 9, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 338, + "state": 1, + "last_changed": 1684668852, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" } ] } diff --git a/tests/components/homee/fixtures/selects.json b/tests/components/homee/fixtures/selects.json new file mode 100644 index 00000000000..27adcf07298 --- /dev/null +++ b/tests/components/homee/fixtures/selects.json @@ -0,0 +1,43 @@ +{ + "id": 1, + "name": "Test Select", + "profile": 33, + "image": "nodeicon_dimmablebulb", + "favorite": 0, + "order": 27, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1736188706, + "added": 1610308228, + "history": 1, + "cube_type": 3, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 226, + "state": 1, + "last_changed": 1680027880, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json index f4a7f462218..bcc36a85ee7 100644 --- a/tests/components/homee/fixtures/sensors.json +++ b/tests/components/homee/fixtures/sensors.json @@ -81,6 +81,27 @@ "data": "", "name": "" }, + { + "id": 34, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 8, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, { "id": 4, "node_id": 1, @@ -93,7 +114,7 @@ "unit": "%", "step_value": 1.0, "editable": 0, - "type": 8, + "type": 11, "state": 1, "last_changed": 1709982926, "changed_by": 1, diff --git a/tests/components/homee/fixtures/thermostat_only_targettemp.json b/tests/components/homee/fixtures/thermostat_only_targettemp.json new file mode 100644 index 00000000000..4bdbaa0df78 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_only_targettemp.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "name": "Test Thermostat 1", + "profile": 3003, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 12, + "maximum": 28, + "current_value": 20.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_currenttemp.json b/tests/components/homee/fixtures/thermostat_with_currenttemp.json new file mode 100644 index 00000000000..9685034f178 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_currenttemp.json @@ -0,0 +1,77 @@ +{ + "id": 2, + "name": "Test Thermostat 2", + "profile": 3003, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 2, + "instance": 0, + "minimum": 15, + "maximum": 30, + "current_value": 22.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 2, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_heating_mode.json b/tests/components/homee/fixtures/thermostat_with_heating_mode.json new file mode 100644 index 00000000000..fe06e9ef4a5 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_heating_mode.json @@ -0,0 +1,127 @@ +{ + "id": 3, + "name": "Test Thermostat 3", + "profile": 3006, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 14, + "maximum": 25, + "current_value": 24.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 258, + "state": 1, + "last_changed": 1711796635, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 70.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_preset.json b/tests/components/homee/fixtures/thermostat_with_preset.json new file mode 100644 index 00000000000..63491d45be2 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_preset.json @@ -0,0 +1,98 @@ +{ + "id": 4, + "name": "Test Thermostat 4", + "profile": 3033, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 4, + "instance": 0, + "minimum": 10, + "maximum": 32, + "current_value": 12.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.5, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 4, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + }, + { + "id": 3, + "node_id": 4, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 258, + "state": 1, + "last_changed": 1711796635, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_alarm_control_panel.ambr b/tests/components/homee/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..59a22f74080 --- /dev/null +++ b/tests/components/homee/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_alarm_control_panel_snapshot[alarm_control_panel.testhomee_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.testhomee_status', + 'has_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', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee_mode', + 'unique_id': '00055511EECC--1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel_snapshot[alarm_control_panel.testhomee_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': 'user - 4', + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'TestHomee Status', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.testhomee_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'armed_home', + }) +# --- diff --git a/tests/components/homee/snapshots/test_binary_sensor.ambr b/tests/components/homee/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..4926c048f5b --- /dev/null +++ b/tests/components/homee/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1392 @@ +# serializer version: 1 +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_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': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Binary Sensor Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_blackout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_blackout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Blackout', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'blackout_alarm', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_blackout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Blackout', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_blackout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_dioxide', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Carbon dioxide', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_monoxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon monoxide', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_monoxide', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_monoxide', + 'friendly_name': 'Test Binary Sensor Carbon monoxide', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_flood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_flood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flood', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flood', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_flood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Flood', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_flood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_high_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_high_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': 'High temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'high_temperature', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_high_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Binary Sensor High temperature', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_high_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_leak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_leak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Leak', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak_alarm', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_leak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Leak', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_leak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Load', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'load_alarm', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Binary Sensor Load', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_lock', + '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', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'Test Binary Sensor Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_low_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_low_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': 'Low temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_temperature', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_low_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'cold', + 'friendly_name': 'Test Binary Sensor Low temperature', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_low_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_malfunction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_malfunction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Malfunction', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'malfunction', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_malfunction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Malfunction', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_malfunction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximum_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_maximum_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': 'Maximum level', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'maximum', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximum_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Maximum level', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_maximum_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minimum_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_minimum_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': 'Minimum level', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'minimum', + 'unique_id': '00055511EECC-1-13', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minimum_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Minimum level', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_minimum_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion', + 'unique_id': '00055511EECC-1-14', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Test Binary Sensor Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_motor_blocked-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_motor_blocked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motor blocked', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motor_blocked', + 'unique_id': '00055511EECC-1-15', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_motor_blocked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Motor blocked', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_motor_blocked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_opening-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_opening', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Opening', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'opening', + 'unique_id': '00055511EECC-1-17', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_opening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Test Binary Sensor Opening', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_opening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overcurrent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overcurrent', + 'unique_id': '00055511EECC-1-18', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overload-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_overload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overload', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overload', + 'unique_id': '00055511EECC-1-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Overload', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_overload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug', + 'unique_id': '00055511EECC-1-16', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'Test Binary Sensor Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_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': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00055511EECC-1-21', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Binary Sensor Power', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_presence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_presence', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Presence', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'presence', + 'unique_id': '00055511EECC-1-20', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Test Binary Sensor Presence', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rain', + 'unique_id': '00055511EECC-1-22', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Rain', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_replace_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_replace_filter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Replace filter', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'replace_filter', + 'unique_id': '00055511EECC-1-23', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_replace_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Replace filter', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_replace_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_smoke-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smoke', + 'unique_id': '00055511EECC-1-24', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Test Binary Sensor Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage', + 'unique_id': '00055511EECC-1-25', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Storage', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_surge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_surge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Surge', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'surge', + 'unique_id': '00055511EECC-1-26', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_surge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Surge', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_surge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tamper', + 'unique_id': '00055511EECC-1-27', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Test Binary Sensor Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_voltage_drop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_voltage_drop', + '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 drop', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_drop', + 'unique_id': '00055511EECC-1-28', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_voltage_drop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Voltage drop', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_voltage_drop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_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': 'Water', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water', + 'unique_id': '00055511EECC-1-29', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Water', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/homee/snapshots/test_climate.ambr b/tests/components/homee/snapshots/test_climate.ambr new file mode 100644 index 00000000000..b79538ddcf0 --- /dev/null +++ b/tests/components/homee/snapshots/test_climate.ambr @@ -0,0 +1,274 @@ +# serializer version: 1 +# name: test_climate_snapshot[climate.test_thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 28, + 'min_temp': 12, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_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': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 28, + 'min_temp': 12, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 15, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_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': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-2-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 2', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 15, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 25, + 'min_temp': 14, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_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': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 3', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 25, + 'min_temp': 14, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 24.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 10, + 'preset_modes': list([ + 'none', + 'eco', + 'boost', + 'manual', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_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': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-4-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 4', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 10, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'eco', + 'boost', + 'manual', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 12.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/homee/snapshots/test_fan.ambr b/tests/components/homee/snapshots/test_fan.ambr new file mode 100644 index 00000000000..f680ec63e0f --- /dev/null +++ b/tests/components/homee/snapshots/test_fan.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_fan_snapshot[fan.test_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'manual', + 'auto', + 'summer', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.test_fan', + 'has_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': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-77', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_snapshot[fan.test_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fan', + 'percentage': 37, + 'percentage_step': 12.5, + 'preset_mode': 'manual', + 'preset_modes': list([ + 'manual', + 'auto', + 'summer', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.test_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/homee/snapshots/test_lock.ambr b/tests/components/homee/snapshots/test_lock.ambr new file mode 100644 index 00000000000..d055039cca4 --- /dev/null +++ b/tests/components/homee/snapshots/test_lock.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_lock_snapshot[lock.test_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_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': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_snapshot[lock.test_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': 'unknown-5', + 'friendly_name': 'Test Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- diff --git a/tests/components/homee/snapshots/test_number.ambr b/tests/components/homee/snapshots/test_number.ambr index 04b1aefab00..1fa2e0ef697 100644 --- a/tests/components/homee/snapshots/test_number.ambr +++ b/tests/components/homee/snapshots/test_number.ambr @@ -34,7 +34,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'down_time', - 'unique_id': '00055511EECC-1-4', + 'unique_id': '00055511EECC-1-3', 'unit_of_measurement': , }) # --- @@ -54,7 +54,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '57', + 'state': '57.0', }) # --- # name: test_number_snapshot[number.test_number_down_position-entry] @@ -92,7 +92,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'down_position', - 'unique_id': '00055511EECC-1-2', + 'unique_id': '00055511EECC-1-1', 'unit_of_measurement': '%', }) # --- @@ -111,7 +111,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_number_snapshot[number.test_number_down_slat_position-entry] @@ -149,7 +149,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'down_slat_position', - 'unique_id': '00055511EECC-1-3', + 'unique_id': '00055511EECC-1-2', 'unit_of_measurement': '°', }) # --- @@ -168,7 +168,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '38', + 'state': '38.0', }) # --- # name: test_number_snapshot[number.test_number_end_position-entry] @@ -206,7 +206,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'endposition_configuration', - 'unique_id': '00055511EECC-1-5', + 'unique_id': '00055511EECC-1-4', 'unit_of_measurement': None, }) # --- @@ -224,7 +224,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '129', + 'state': '129.0', }) # --- # name: test_number_snapshot[number.test_number_maximum_slat_angle-entry] @@ -262,7 +262,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'slat_max_angle', - 'unique_id': '00055511EECC-1-10', + 'unique_id': '00055511EECC-1-9', 'unit_of_measurement': '°', }) # --- @@ -281,7 +281,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '75', + 'state': '75.0', }) # --- # name: test_number_snapshot[number.test_number_minimum_slat_angle-entry] @@ -319,7 +319,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'slat_min_angle', - 'unique_id': '00055511EECC-1-11', + 'unique_id': '00055511EECC-1-10', 'unit_of_measurement': '°', }) # --- @@ -338,7 +338,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-75', + 'state': '-75.0', }) # --- # name: test_number_snapshot[number.test_number_motion_alarm_delay-entry] @@ -376,7 +376,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'motion_alarm_cancelation_delay', - 'unique_id': '00055511EECC-1-6', + 'unique_id': '00055511EECC-1-5', 'unit_of_measurement': , }) # --- @@ -434,7 +434,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'polling_interval', - 'unique_id': '00055511EECC-1-8', + 'unique_id': '00055511EECC-1-7', 'unit_of_measurement': , }) # --- @@ -454,7 +454,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '30.0', }) # --- # name: test_number_snapshot[number.test_number_slat_steps-entry] @@ -492,7 +492,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'slat_steps', - 'unique_id': '00055511EECC-1-12', + 'unique_id': '00055511EECC-1-11', 'unit_of_measurement': None, }) # --- @@ -510,7 +510,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '6', + 'state': '6.0', }) # --- # name: test_number_snapshot[number.test_number_slat_turn_duration-entry] @@ -548,7 +548,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'shutter_slat_time', - 'unique_id': '00055511EECC-1-9', + 'unique_id': '00055511EECC-1-8', 'unit_of_measurement': , }) # --- @@ -568,7 +568,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '1.6', }) # --- # name: test_number_snapshot[number.test_number_temperature_offset-entry] @@ -606,7 +606,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_offset', - 'unique_id': '00055511EECC-1-13', + 'unique_id': '00055511EECC-1-12', 'unit_of_measurement': , }) # --- @@ -628,6 +628,64 @@ 'state': 'unavailable', }) # --- +# name: test_number_snapshot[number.test_number_threshold_for_wind_trigger-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 22.5, + 'min': 0, + 'mode': , + 'step': 2.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_threshold_for_wind_trigger', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Threshold for wind trigger', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_monitoring_state', + 'unique_id': '00055511EECC-1-16', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_threshold_for_wind_trigger-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Test Number Threshold for wind trigger', + 'max': 22.5, + 'min': 0, + 'mode': , + 'step': 2.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_threshold_for_wind_trigger', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- # name: test_number_snapshot[number.test_number_up_movement_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -663,7 +721,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'up_time', - 'unique_id': '00055511EECC-1-14', + 'unique_id': '00055511EECC-1-13', 'unit_of_measurement': , }) # --- @@ -683,7 +741,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '57', + 'state': '57.0', }) # --- # name: test_number_snapshot[number.test_number_wake_up_interval-entry] @@ -721,7 +779,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wake_up_interval', - 'unique_id': '00055511EECC-1-15', + 'unique_id': '00055511EECC-1-14', 'unit_of_measurement': , }) # --- @@ -741,7 +799,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '600', + 'state': '600.0', }) # --- # name: test_number_snapshot[number.test_number_window_open_sensibility-entry] @@ -779,7 +837,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'open_window_detection_sensibility', - 'unique_id': '00055511EECC-1-7', + 'unique_id': '00055511EECC-1-6', 'unit_of_measurement': None, }) # --- @@ -797,6 +855,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '3.0', }) # --- diff --git a/tests/components/homee/snapshots/test_select.ambr b/tests/components/homee/snapshots/test_select.ambr new file mode 100644 index 00000000000..9fa831230c2 --- /dev/null +++ b/tests/components/homee/snapshots/test_select.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_select_snapshot[select.test_select_repeater_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'level1', + 'level2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_select_repeater_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': 'Repeater mode', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'repeater_mode', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_snapshot[select.test_select_repeater_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Select Repeater mode', + 'options': list([ + 'off', + 'level1', + 'level2', + ]), + }), + 'context': , + 'entity_id': 'select.test_select_repeater_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level1', + }) +# --- diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr index 3101723232e..ff04f245504 100644 --- a/tests/components/homee/snapshots/test_sensor.ambr +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -82,8 +82,8 @@ 'platform': 'homee', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': '00055511EECC-1-4', + 'translation_key': 'battery_instance', + 'unique_id': '00055511EECC-1-34', 'unit_of_measurement': '%', }) # --- @@ -518,6 +518,57 @@ 'state': '51.0', }) # --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Illuminance', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1540,57 +1591,6 @@ 'state': '6.0', }) # --- -# name: test_sensor_snapshot[sensor.test_multisensor_valve_position-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_multisensor_valve_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': 'Valve position', - 'platform': 'homee', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'valve_position', - 'unique_id': '00055511EECC-1-9', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensor_snapshot[sensor.test_multisensor_valve_position-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test MultiSensor Valve position', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.test_multisensor_valve_position', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '70.0', - }) -# --- # name: test_sensor_snapshot[sensor.test_multisensor_voltage_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/homee/test_alarm_control_panel.py b/tests/components/homee/test_alarm_control_panel.py new file mode 100644 index 00000000000..dafe74660ac --- /dev/null +++ b/tests/components/homee/test_alarm_control_panel.py @@ -0,0 +1,96 @@ +"""Test Homee alarm control panels.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISARM, +) +from homeassistant.components.homee.const import DOMAIN +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 . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_alarm_control_panel( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Setups the integration for select tests.""" + mock_homee.nodes = [build_mock_node("homee.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("service", "state"), + [ + (SERVICE_ALARM_ARM_HOME, 0), + (SERVICE_ALARM_ARM_NIGHT, 1), + (SERVICE_ALARM_ARM_AWAY, 2), + (SERVICE_ALARM_ARM_VACATION, 3), + ], +) +async def test_alarm_control_panel_services( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + service: str, + state: int, +) -> None: + """Test alarm control panel services.""" + await setup_alarm_control_panel(hass, mock_homee, mock_config_entry) + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.testhomee_status"}, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(-1, 1, state) + + +async def test_alarm_control_panel_service_disarm_error( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that disarm service calls no action.""" + await setup_alarm_control_panel(hass, mock_homee, mock_config_entry) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.testhomee_status"}, + blocking=True, + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "disarm_not_supported" + + +async def test_alarm_control_panel_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the alarm-control_panel snapshots.""" + with patch( + "homeassistant.components.homee.PLATFORMS", [Platform.ALARM_CONTROL_PANEL] + ): + await setup_alarm_control_panel(hass, mock_homee, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_binary_sensor.py b/tests/components/homee/test_binary_sensor.py new file mode 100644 index 00000000000..50662616379 --- /dev/null +++ b/tests/components/homee/test_binary_sensor.py @@ -0,0 +1,29 @@ +"""Test homee binary sensors.""" + +from unittest.mock import MagicMock, 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 build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensor_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the multisensor snapshot.""" + mock_homee.nodes = [build_mock_node("binary_sensors.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.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/homee/test_climate.py b/tests/components/homee/test_climate.py new file mode 100644 index 00000000000..bb5ad98c7d2 --- /dev/null +++ b/tests/components/homee/test_climate.py @@ -0,0 +1,270 @@ +"""Test Homee climate entities.""" + +from unittest.mock import MagicMock, patch + +from pyHomee.const import AttributeType +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.homee.const import PRESET_MANUAL +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_mock_climate( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + file: str, +) -> None: + """Setups a climate node for the tests.""" + mock_homee.nodes = [build_mock_node(file)] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("file", "entity_id", "features", "hvac_modes"), + [ + ( + "thermostat_only_targettemp.json", + "climate.test_thermostat_1", + ClimateEntityFeature.TARGET_TEMPERATURE, + [HVACMode.HEAT], + ), + ( + "thermostat_with_currenttemp.json", + "climate.test_thermostat_2", + ClimateEntityFeature.TARGET_TEMPERATURE, + [HVACMode.HEAT], + ), + ( + "thermostat_with_heating_mode.json", + "climate.test_thermostat_3", + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF, + [HVACMode.HEAT, HVACMode.OFF], + ), + ( + "thermostat_with_preset.json", + "climate.test_thermostat_4", + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE, + [HVACMode.HEAT, HVACMode.OFF], + ), + ], +) +async def test_climate_features( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + file: str, + entity_id: str, + features: ClimateEntityFeature, + hvac_modes: list[HVACMode], +) -> None: + """Test available features of climate entities.""" + await setup_mock_climate(hass, mock_config_entry, mock_homee, file) + + attributes = hass.states.get(entity_id).attributes + assert attributes["supported_features"] == features + assert attributes[ATTR_HVAC_MODES] == hvac_modes + + +async def test_climate_preset_modes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, +) -> None: + """Test available preset modes of climate entities.""" + await setup_mock_climate( + hass, mock_config_entry, mock_homee, "thermostat_with_preset.json" + ) + + attributes = hass.states.get("climate.test_thermostat_4").attributes + assert attributes[ATTR_PRESET_MODES] == [ + PRESET_NONE, + PRESET_ECO, + PRESET_BOOST, + PRESET_MANUAL, + ] + + +@pytest.mark.parametrize( + ("attribute_type", "value", "expected"), + [ + (AttributeType.HEATING_MODE, 0.0, HVACAction.OFF), + (AttributeType.CURRENT_VALVE_POSITION, 0.0, HVACAction.IDLE), + (AttributeType.TEMPERATURE, 25.0, HVACAction.IDLE), + (AttributeType.TEMPERATURE, 18.0, HVACAction.HEATING), + ], +) +async def test_hvac_action( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + attribute_type: AttributeType, + value: float, + expected: HVACAction, +) -> None: + """Test hvac action of climate entities.""" + mock_homee.nodes = [build_mock_node("thermostat_with_heating_mode.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + node = mock_homee.nodes[0] + # set target temperature to 24.0 + node.attributes[0].current_value = 24.0 + attribute = node.get_attribute_by_type(attribute_type) + attribute.current_value = value + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("climate.test_thermostat_3").attributes + assert attributes[ATTR_HVAC_ACTION] == expected + + +@pytest.mark.parametrize( + ("preset_mode_int", "expected"), + [ + (0, PRESET_NONE), + (1, PRESET_NONE), + (2, PRESET_ECO), + (3, PRESET_BOOST), + (4, PRESET_MANUAL), + ], +) +async def test_current_preset_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + preset_mode_int: int, + expected: str, +) -> None: + """Test current preset mode of climate entities.""" + mock_homee.nodes = [build_mock_node("thermostat_with_preset.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + node = mock_homee.nodes[0] + node.attributes[2].current_value = preset_mode_int + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("climate.test_thermostat_4").attributes + assert attributes[ATTR_PRESET_MODE] == expected + + +@pytest.mark.parametrize( + ("service", "service_data", "expected"), + [ + ( + SERVICE_TURN_ON, + {}, + (4, 3, 1), + ), + ( + SERVICE_TURN_OFF, + {}, + (4, 3, 0), + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + (4, 3, 1), + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + (4, 3, 0), + ), + ( + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 20}, + (4, 1, 20), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_NONE}, + (4, 3, 1), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_ECO}, + (4, 3, 2), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_BOOST}, + (4, 3, 3), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_MANUAL}, + (4, 3, 4), + ), + ], +) +async def test_climate_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + service: str, + service_data: dict, + expected: tuple[int, int, int], +) -> None: + """Test available services of climate entities.""" + await setup_mock_climate( + hass, mock_config_entry, mock_homee, "thermostat_with_preset.json" + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: "climate.test_thermostat_4", **service_data}, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(*expected) + + +async def test_climate_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test snapshot of climates.""" + mock_homee.nodes = [ + build_mock_node("thermostat_only_targettemp.json"), + build_mock_node("thermostat_with_currenttemp.json"), + build_mock_node("thermostat_with_heating_mode.json"), + build_mock_node("thermostat_with_preset.json"), + ] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_fan.py b/tests/components/homee/test_fan.py new file mode 100644 index 00000000000..55d019af746 --- /dev/null +++ b/tests/components/homee/test_fan.py @@ -0,0 +1,192 @@ +"""Test Homee fans.""" + +from unittest.mock import MagicMock, call, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + SERVICE_INCREASE_SPEED, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.homee.const import ( + DOMAIN, + PRESET_AUTO, + PRESET_MANUAL, + PRESET_SUMMER, +) +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 . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + ("speed", "expected"), + [ + (0, 0), + (1, 12), + (2, 25), + (3, 37), + (4, 50), + (5, 62), + (6, 75), + (7, 87), + (8, 100), + ], +) +async def test_percentage( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + speed: int, + expected: int, +) -> None: + """Test percentage.""" + mock_homee.nodes = [build_mock_node("fan.json")] + mock_homee.nodes[0].attributes[0].current_value = speed + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("fan.test_fan").attributes["percentage"] == expected + + +@pytest.mark.parametrize( + ("mode_value", "expected"), + [ + (0, "manual"), + (1, "auto"), + (2, "summer"), + ], +) +async def test_preset_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + mode_value: int, + expected: str, +) -> None: + """Test preset mode.""" + mock_homee.nodes = [build_mock_node("fan.json")] + mock_homee.nodes[0].attributes[1].current_value = mode_value + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("fan.test_fan").attributes["preset_mode"] == expected + + +@pytest.mark.parametrize( + ("service", "options", "expected"), + [ + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 100}, (77, 1, 8)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 86}, (77, 1, 7)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 63}, (77, 1, 6)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 60}, (77, 1, 5)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 50}, (77, 1, 4)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 34}, (77, 1, 3)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 17}, (77, 1, 2)), + (SERVICE_TURN_ON, {ATTR_PERCENTAGE: 8}, (77, 1, 1)), + (SERVICE_TURN_ON, {}, (77, 1, 6)), + (SERVICE_TURN_OFF, {}, (77, 1, 0)), + (SERVICE_INCREASE_SPEED, {}, (77, 1, 4)), + (SERVICE_DECREASE_SPEED, {}, (77, 1, 2)), + (SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 42}, (77, 1, 4)), + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: PRESET_MANUAL}, (77, 2, 0)), + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: PRESET_AUTO}, (77, 2, 1)), + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: PRESET_SUMMER}, (77, 2, 2)), + (SERVICE_TOGGLE, {}, (77, 1, 0)), + ], +) +async def test_fan_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + service: str, + options: int | None, + expected: tuple[int, int, int], +) -> None: + """Test fan services.""" + mock_homee.nodes = [build_mock_node("fan.json")] + await setup_integration(hass, mock_config_entry) + + OPTIONS = {ATTR_ENTITY_ID: "fan.test_fan"} + OPTIONS.update(options) + + await hass.services.async_call( + FAN_DOMAIN, + service, + OPTIONS, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(*expected) + + +async def test_turn_on_preset_last_value_zero( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, +) -> None: + """Test turn on with preset last value == 0.""" + mock_homee.nodes = [build_mock_node("fan.json")] + mock_homee.nodes[0].attributes[0].last_value = 0 + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.test_fan", ATTR_PRESET_MODE: PRESET_MANUAL}, + blocking=True, + ) + + assert mock_homee.set_value.call_args_list == [ + call(77, 2, 0), + call(77, 1, 8), + ] + + +async def test_turn_on_invalid_preset( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, +) -> None: + """Test turn on with invalid preset.""" + mock_homee.nodes = [build_mock_node("fan.json")] + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.test_fan", ATTR_PRESET_MODE: PRESET_AUTO}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_preset_mode" + + +async def test_fan_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the fan snapshot.""" + mock_homee.nodes = [build_mock_node("fan.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.FAN]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_lock.py b/tests/components/homee/test_lock.py new file mode 100644 index 00000000000..3e6ff3f8ec6 --- /dev/null +++ b/tests/components/homee/test_lock.py @@ -0,0 +1,125 @@ +"""Test Homee locks.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, + LockState, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_lock( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homee: MagicMock +) -> None: + """Setups the integration lock tests.""" + mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("service", "target_value"), + [ + (SERVICE_LOCK, 1), + (SERVICE_UNLOCK, 0), + ], +) +async def test_lock_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + service: str, + target_value: int, +) -> None: + """Test lock services.""" + await setup_lock(hass, mock_config_entry, mock_homee) + + await hass.services.async_call( + LOCK_DOMAIN, + service, + {ATTR_ENTITY_ID: "lock.test_lock"}, + ) + mock_homee.set_value.assert_called_once_with(1, 1, target_value) + + +@pytest.mark.parametrize( + ("target_value", "current_value", "expected"), + [ + (1.0, 1.0, LockState.LOCKED), + (0.0, 0.0, LockState.UNLOCKED), + (1.0, 0.0, LockState.LOCKING), + (0.0, 1.0, LockState.UNLOCKING), + ], +) +async def test_lock_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + target_value: float, + current_value: float, + expected: LockState, +) -> None: + """Test lock state.""" + mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + attribute = mock_homee.nodes[0].attributes[0] + attribute.target_value = target_value + attribute.current_value = current_value + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("lock.test_lock").state == expected + + +@pytest.mark.parametrize( + ("attr_changed_by", "changed_by_id", "expected"), + [ + (1, 0, "itself-0"), + (2, 1, "user-testuser"), + (3, 54, "homeegram-54"), + (6, 0, "ai-0"), + ], +) +async def test_lock_changed_by( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + attr_changed_by: int, + changed_by_id: int, + expected: str, +) -> None: + """Test lock changed by entries.""" + mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + mock_homee.get_user_by_id.return_value = MagicMock(username="testuser") + attribute = mock_homee.nodes[0].attributes[0] + attribute.changed_by = attr_changed_by + attribute.changed_by_id = changed_by_id + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("lock.test_lock").attributes["changed_by"] == expected + + +async def test_lock_snapshot( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the lock snapshots.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.LOCK]): + await setup_lock(hass, mock_config_entry, mock_homee) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_number.py b/tests/components/homee/test_number.py index 73ca707c2d5..2825152241a 100644 --- a/tests/components/homee/test_number.py +++ b/tests/components/homee/test_number.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( @@ -18,24 +19,62 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform -async def test_set_value( - hass: HomeAssistant, - mock_homee: MagicMock, - mock_config_entry: MockConfigEntry, +async def setup_numbers( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry ) -> None: - """Test set_value service.""" + """Set up the number platform.""" mock_homee.nodes = [build_mock_node("numbers.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) + +@pytest.mark.parametrize( + ("entity_id", "expected"), + [ + ("number.test_number_down_position", 100.0), + ("number.test_number_threshold_for_wind_trigger", 5.0), + ], +) +async def test_value_fn( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + expected: float, +) -> None: + """Test the value_fn of the number entity.""" + await setup_numbers(hass, mock_homee, mock_config_entry) + + assert hass.states.get(entity_id).state == str(expected) + + +@pytest.mark.parametrize( + ("entity_id", "attribute_index", "value", "expected"), + [ + ("number.test_number_down_position", 0, 90, 90), + ("number.test_number_threshold_for_wind_trigger", 15, 7.5, 3), + ], +) +async def test_set_value( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + attribute_index: int, + value: float, + expected: float, +) -> None: + """Test set_value service.""" + await setup_numbers(hass, mock_homee, mock_config_entry) + await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "number.test_number_down_position", ATTR_VALUE: 90}, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, ) - number = mock_homee.nodes[0].attributes[0] - mock_homee.set_value.assert_called_once_with(number.node_id, number.id, 90) + number = mock_homee.nodes[0].attributes[attribute_index] + mock_homee.set_value.assert_called_once_with(number.node_id, number.id, expected) async def test_set_value_not_editable( @@ -44,9 +83,7 @@ async def test_set_value_not_editable( mock_config_entry: MockConfigEntry, ) -> None: """Test set_value if attribute is not editable.""" - mock_homee.nodes = [build_mock_node("numbers.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) + await setup_numbers(hass, mock_homee, mock_config_entry) await hass.services.async_call( NUMBER_DOMAIN, @@ -66,9 +103,7 @@ async def test_number_snapshot( snapshot: SnapshotAssertion, ) -> None: """Test the multisensor snapshot.""" - mock_homee.nodes = [build_mock_node("numbers.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] with patch("homeassistant.components.homee.PLATFORMS", [Platform.NUMBER]): - await setup_integration(hass, mock_config_entry) + await setup_numbers(hass, mock_homee, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_select.py b/tests/components/homee/test_select.py new file mode 100644 index 00000000000..c0dec2234d6 --- /dev/null +++ b/tests/components/homee/test_select.py @@ -0,0 +1,106 @@ +"""Test homee selects.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_FIRST, + SERVICE_SELECT_LAST, + SERVICE_SELECT_NEXT, + SERVICE_SELECT_OPTION, + SERVICE_SELECT_PREVIOUS, +) +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 . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_select( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Setups the integration for select tests.""" + mock_homee.nodes = [build_mock_node("selects.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("service", "extra_options", "expected"), + [ + (SERVICE_SELECT_FIRST, {}, 0), + (SERVICE_SELECT_LAST, {}, 2), + (SERVICE_SELECT_NEXT, {}, 2), + (SERVICE_SELECT_PREVIOUS, {}, 0), + ( + SERVICE_SELECT_OPTION, + { + "option": "level2", + }, + 2, + ), + ], +) +async def test_select_services( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + service: str, + extra_options: dict[str, str], + expected: int, +) -> None: + """Test the select services.""" + await setup_select(hass, mock_homee, mock_config_entry) + + OPTIONS = {ATTR_ENTITY_ID: "select.test_select_repeater_mode"} + OPTIONS.update(extra_options) + + await hass.services.async_call( + SELECT_DOMAIN, + service, + OPTIONS, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(1, 1, expected) + + +async def test_select_option_service_error( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the select_option service called with invalid option.""" + await setup_select(hass, mock_homee, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_select_repeater_mode", + "option": "invalid", + }, + blocking=True, + ) + + +async def test_select_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the select entity snapshot.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SELECT]): + await setup_select(hass, mock_homee, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index a2ba991c49b..14a9320ffa1 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -6,16 +6,19 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.homee.const import ( + DOMAIN, OPEN_CLOSE_MAP, OPEN_CLOSE_MAP_REVERSED, WINDOW_MAP, WINDOW_MAP_REVERSED, ) -from homeassistant.const import LIGHT_LUX, Platform +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from . import async_update_attribute_value, build_mock_node, setup_integration +from .conftest import HOMEE_ID from tests.common import MockConfigEntry, snapshot_platform @@ -25,19 +28,26 @@ def enable_all_entities(entity_registry_enabled_by_default: None) -> None: """Make sure all entities are enabled.""" +async def setup_sensor( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Setups the integration for sensor tests.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + async def test_up_down_values( hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test values for up/down sensor.""" - mock_homee.nodes = [build_mock_node("sensors.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) + await setup_sensor(hass, mock_homee, mock_config_entry) assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] - attribute = mock_homee.nodes[0].attributes[27] + attribute = mock_homee.nodes[0].attributes[28] for i in range(1, 5): await async_update_attribute_value(hass, attribute, i) assert ( @@ -60,16 +70,14 @@ async def test_window_position( mock_config_entry: MockConfigEntry, ) -> None: """Test values for window handle position.""" - mock_homee.nodes = [build_mock_node("sensors.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) + await setup_sensor(hass, mock_homee, mock_config_entry) assert ( hass.states.get("sensor.test_multisensor_window_position").state == WINDOW_MAP[0] ) - attribute = mock_homee.nodes[0].attributes[32] + attribute = mock_homee.nodes[0].attributes[33] for i in range(1, 3): await async_update_attribute_value(hass, attribute, i) assert ( @@ -87,26 +95,77 @@ async def test_window_position( ) -async def test_brightness_sensor( +@pytest.mark.parametrize( + ("disabler", "expected_entity", "expected_issue"), + [ + (None, False, False), + (er.RegistryEntryDisabler.USER, True, True), + ], +) +async def test_sensor_deprecation( hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + disabler: er.RegistryEntryDisabler, + expected_entity: bool, + expected_issue: bool, ) -> None: - """Test brightness sensor's lx & klx units and naming of multi-instance sensors.""" - mock_homee.nodes = [build_mock_node("sensors.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) + """Test sensor deprecation issue.""" + entity_uid = f"{HOMEE_ID}-1-9" + entity_id = "test_multisensor_valve_position" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + entity_uid, + suggested_object_id=entity_id, + disabled_by=disabler, + ) - sensor_state = hass.states.get("sensor.test_multisensor_illuminance_1") - assert sensor_state.state == "175.0" - assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX - assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 1" + with patch( + "homeassistant.components.homee.sensor.entity_used_in", return_value=True + ): + await setup_sensor(hass, mock_homee, mock_config_entry) - # Sensor with Homee unit klx - sensor_state = hass.states.get("sensor.test_multisensor_illuminance_2") - assert sensor_state.state == "7000.0" - assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX - assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 2" + assert (entity_registry.async_get(f"sensor.{entity_id}") is None) is expected_entity + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{entity_uid}", + ) + is None + ) is expected_issue + + +async def test_sensor_deprecation_unused_entity( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor deprecation issue.""" + entity_uid = f"{HOMEE_ID}-1-9" + entity_id = "test_multisensor_valve_position" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + entity_uid, + suggested_object_id=entity_id, + disabled_by=None, + ) + + await setup_sensor(hass, mock_homee, mock_config_entry) + + assert entity_registry.async_get(f"sensor.{entity_id}") is not None + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{entity_uid}", + ) + is None + ) async def test_sensor_snapshot( diff --git a/tests/components/homekit/test_diagnostics.py b/tests/components/homekit/test_diagnostics.py index ce3c954c447..912c5953176 100644 --- a/tests/components/homekit/test_diagnostics.py +++ b/tests/components/homekit/test_diagnostics.py @@ -453,7 +453,7 @@ async def test_config_entry_with_trigger_accessory( "iid": 6, "perms": ["pr"], "type": "30", - "value": ANY, + "value": device_id, }, { "format": "string", @@ -484,8 +484,15 @@ async def test_config_entry_with_trigger_accessory( "value": "Ceiling Lights Changed States", }, { - "format": "uint8", + "format": "string", "iid": 11, + "perms": ["pr", "pw", "ev"], + "type": "E3", + "value": "Ceiling Lights Changed States", + }, + { + "format": "uint8", + "iid": 12, "maxValue": 255, "minStep": 1, "minValue": 1, @@ -495,28 +502,28 @@ async def test_config_entry_with_trigger_accessory( }, ], "iid": 8, - "linked": [12], + "linked": [13], "type": "89", }, { "characteristics": [ { "format": "uint8", - "iid": 13, + "iid": 14, "perms": ["pr"], "type": "CD", "valid-values": [0, 1], "value": 1, } ], - "iid": 12, + "iid": 13, "type": "CC", }, { "characteristics": [ { "format": "uint8", - "iid": 15, + "iid": 16, "perms": ["pr", "ev"], "type": "73", "valid-values": [0], @@ -524,14 +531,21 @@ async def test_config_entry_with_trigger_accessory( }, { "format": "string", - "iid": 16, + "iid": 17, "perms": ["pr"], "type": "23", "value": "Ceiling Lights Turned Off", }, + { + "format": "string", + "iid": 18, + "perms": ["pr", "pw", "ev"], + "type": "E3", + "value": "Ceiling Lights Turned Off", + }, { "format": "uint8", - "iid": 17, + "iid": 19, "maxValue": 255, "minStep": 1, "minValue": 1, @@ -540,29 +554,29 @@ async def test_config_entry_with_trigger_accessory( "value": 2, }, ], - "iid": 14, - "linked": [18], + "iid": 15, + "linked": [20], "type": "89", }, { "characteristics": [ { "format": "uint8", - "iid": 19, + "iid": 21, "perms": ["pr"], "type": "CD", "valid-values": [0, 1], "value": 1, } ], - "iid": 18, + "iid": 20, "type": "CC", }, { "characteristics": [ { "format": "uint8", - "iid": 21, + "iid": 23, "perms": ["pr", "ev"], "type": "73", "valid-values": [0], @@ -570,14 +584,21 @@ async def test_config_entry_with_trigger_accessory( }, { "format": "string", - "iid": 22, + "iid": 24, "perms": ["pr"], "type": "23", "value": "Ceiling Lights Turned On", }, + { + "format": "string", + "iid": 25, + "perms": ["pr", "pw", "ev"], + "type": "E3", + "value": "Ceiling Lights Turned On", + }, { "format": "uint8", - "iid": 23, + "iid": 26, "maxValue": 255, "minStep": 1, "minValue": 1, @@ -586,22 +607,22 @@ async def test_config_entry_with_trigger_accessory( "value": 3, }, ], - "iid": 20, - "linked": [24], + "iid": 22, + "linked": [27], "type": "89", }, { "characteristics": [ { "format": "uint8", - "iid": 25, + "iid": 28, "perms": ["pr"], "type": "CD", "valid-values": [0, 1], "value": 1, } ], - "iid": 24, + "iid": 27, "type": "CC", }, ], @@ -626,6 +647,7 @@ async def test_config_entry_with_trigger_accessory( "pairing_id": ANY, "status": 1, } + with ( patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch("homeassistant.components.homekit.HomeKit.async_stop"), diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index c4b1cbe98d8..de5cda71513 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -6,11 +6,13 @@ import pytest from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.cover import CoverEntityFeature +from homeassistant.components.homekit import TYPE_AIR_PURIFIER from homeassistant.components.homekit.accessories import TYPES, get_accessory from homeassistant.components.homekit.const import ( ATTR_INTEGRATION, CONF_FEATURE_LIST, FEATURE_ON_OFF, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -51,6 +53,12 @@ def test_not_supported(caplog: pytest.LogCaptureFixture) -> None: assert "invalid aid" in caplog.records[0].msg +def test_not_supported_sensor(caplog: pytest.LogCaptureFixture) -> None: + """Test if none is returned if entity isn't supported.""" + assert get_accessory(None, None, State("sensor.xyz", "on"), 2, {}) is None + assert "Unsupported sensor type (device_class=None)" in caplog.text + + def test_not_supported_media_player() -> None: """Test if mode isn't supported and if no supported modes.""" # selected mode for entity not supported @@ -350,6 +358,23 @@ def test_type_switches(type_name, entity_id, state, attrs, config) -> None: assert mock_type.called +@pytest.mark.parametrize( + ("type_name", "entity_id", "state", "attrs", "config"), + [ + ("Fan", "fan.test", "on", {}, {}), + ("Fan", "fan.test", "on", {}, {CONF_TYPE: TYPE_FAN}), + ("AirPurifier", "fan.test", "on", {}, {CONF_TYPE: TYPE_AIR_PURIFIER}), + ], +) +def test_type_fans(type_name, entity_id, state, attrs, config) -> None: + """Test if switch types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, config) + assert mock_type.called + + @pytest.mark.parametrize( ("type_name", "entity_id", "state", "attrs"), [ diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 0829c96ce1d..f59c5d2778b 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -21,6 +21,7 @@ from homeassistant.components.homekit import ( STATUS_RUNNING, STATUS_STOPPED, STATUS_WAIT, + TYPE_AIR_PURIFIER, HomeKit, ) from homeassistant.components.homekit.accessories import HomeBridge @@ -51,6 +52,7 @@ from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STARTED, @@ -58,6 +60,7 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_ON, EntityCategory, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -2162,6 +2165,109 @@ async def test_homekit_finds_linked_humidity_sensors( ) +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_finds_linked_air_purifier_sensors( + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HomeKit start method.""" + entry = await async_init_integration(hass) + + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + homekit.driver = hk_driver + homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge") + + config_entry = MockConfigEntry(domain="air_purifier", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + sw_version="0.16.1", + model="Smart Air Purifier", + manufacturer="Home Assistant", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + humidity_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "humidity_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.HUMIDITY, + ) + pm25_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "pm25_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.PM25, + ) + temperature_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "temperature_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.TEMPERATURE, + ) + air_purifier = entity_registry.async_get_or_create( + "fan", "air_purifier", "demo", device_id=device_entry.id + ) + + hass.states.async_set( + humidity_sensor.entity_id, + "42", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + hass.states.async_set( + pm25_sensor.entity_id, + 8, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + ) + hass.states.async_set( + temperature_sensor.entity_id, + 22, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set(air_purifier.entity_id, STATE_ON) + + with ( + patch.object(homekit.bridge, "add_accessory"), + patch(f"{PATH_HOMEKIT}.async_show_setup_message"), + patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, + patch("pyhap.accessory_driver.AccessoryDriver.async_start"), + ): + await homekit.async_start() + await hass.async_block_till_done() + + mock_get_acc.assert_called_with( + hass, + ANY, + ANY, + ANY, + { + "manufacturer": "Home Assistant", + "model": "Smart Air Purifier", + "platform": "air_purifier", + "sw_version": "0.16.1", + "type": TYPE_AIR_PURIFIER, + "linked_humidity_sensor": "sensor.air_purifier_humidity_sensor", + "linked_pm25_sensor": "sensor.air_purifier_pm25_sensor", + "linked_temperature_sensor": "sensor.air_purifier_temperature_sensor", + }, + ) + + @pytest.mark.usefixtures("mock_async_zeroconf") async def test_reload(hass: HomeAssistant) -> None: """Test we can reload from yaml.""" diff --git a/tests/components/homekit/test_type_air_purifiers.py b/tests/components/homekit/test_type_air_purifiers.py new file mode 100644 index 00000000000..acc7838652d --- /dev/null +++ b/tests/components/homekit/test_type_air_purifiers.py @@ -0,0 +1,720 @@ +"""Test different accessory types: Air Purifiers.""" + +from unittest.mock import MagicMock + +from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE +import pytest + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DOMAIN as FAN_DOMAIN, + FanEntityFeature, +) +from homeassistant.components.homekit import ( + CONF_LINKED_HUMIDITY_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, +) +from homeassistant.components.homekit.const import ( + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, + THRESHOLD_FILTER_CHANGE_NEEDED, +) +from homeassistant.components.homekit.type_air_purifiers import ( + FILTER_CHANGE_FILTER, + FILTER_OK, + TARGET_STATE_AUTO, + TARGET_STATE_MANUAL, + AirPurifier, +) +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + UnitOfTemperature, +) +from homeassistant.core import Event, HomeAssistant + +from tests.common import async_mock_service + + +@pytest.mark.parametrize( + ("auto_preset", "preset_modes"), + [ + ("auto", ["sleep", "smart", "auto"]), + ("Auto", ["sleep", "smart", "Auto"]), + ], +) +async def test_fan_auto_manual( + hass: HomeAssistant, + hk_driver, + events: list[Event], + auto_preset: str, + preset_modes: list[str], +) -> None: + """Test switching between Auto and Manual.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: auto_preset, + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.preset_mode_chars["smart"].value == 0 + assert acc.preset_mode_chars["sleep"].value == 0 + assert acc.auto_preset is not None + + # Auto presets are handled as the target air purifier state, so + # not supposed to be exposed as a separate switch + switches = set() + for service in acc.services: + if service.display_name == "Switch": + switches.add(service.unique_id) + + assert len(switches) == len(preset_modes) - 1 + for preset in preset_modes: + if preset != auto_preset: + assert preset in switches + else: + # Auto preset should not be in switches + assert preset not in switches + + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "smart", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + + assert acc.preset_mode_chars["smart"].value == 1 + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + # Set from HomeKit + call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode") + call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage") + char_auto_iid = acc.char_target_air_purifier_state.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_auto_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + assert len(call_set_preset_mode) == 1 + assert call_set_preset_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_preset_mode[0].data[ATTR_PRESET_MODE] == auto_preset + assert len(events) == 1 + assert events[-1].data["service"] == "set_preset_mode" + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_auto_iid, + HAP_REPR_VALUE: 0, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + assert len(call_set_percentage) == 1 + assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id + assert events[-1].data["service"] == "set_percentage" + assert len(events) == 2 + + +async def test_presets_no_auto( + hass: HomeAssistant, + hk_driver, + events: list[Event], +) -> None: + """Test preset without an auto mode.""" + entity_id = "fan.demo" + + preset_modes = ["sleep", "smart"] + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "smart", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.preset_mode_chars["smart"].value == 1 + assert acc.preset_mode_chars["sleep"].value == 0 + assert acc.auto_preset is None + + # Auto presets are handled as the target air purifier state, so + # not supposed to be exposed as a separate switch + switches = set() + for service in acc.services: + if service.display_name == "Switch": + switches.add(service.unique_id) + + assert len(switches) == len(preset_modes) + for preset in preset_modes: + assert preset in switches + + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "sleep", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + + assert acc.preset_mode_chars["smart"].value == 0 + assert acc.preset_mode_chars["sleep"].value == 1 + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + +async def test_air_purifier_single_preset_mode( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test air purifier with a single preset mode.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 42, + ATTR_PRESET_MODE: "auto", + ATTR_PRESET_MODES: ["auto"], + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + + acc.run() + await hass.async_block_till_done() + + # Set from HomeKit + call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode") + call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage") + + char_target_air_purifier_state_iid = acc.char_target_air_purifier_state.to_HAP()[ + HAP_REPR_IID + ] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_air_purifier_state_iid, + HAP_REPR_VALUE: TARGET_STATE_MANUAL, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_set_percentage[0] + assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_percentage[0].data[ATTR_PERCENTAGE] == 42 + assert len(events) == 1 + assert events[-1].data["service"] == "set_percentage" + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_air_purifier_state_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_set_preset_mode[0] + assert call_set_preset_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_preset_mode[0].data[ATTR_PRESET_MODE] == "auto" + assert events[-1].data["service"] == "set_preset_mode" + assert len(events) == 2 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 42, + ATTR_PRESET_MODE: None, + ATTR_PRESET_MODES: ["auto"], + }, + ) + await hass.async_block_till_done() + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + +async def test_expose_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that linked sensors are exposed.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + humidity_entity_id = "sensor.demo_humidity" + hass.states.async_set( + humidity_entity_id, + 50, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + + pm25_entity_id = "sensor.demo_pm25" + hass.states.async_set( + pm25_entity_id, + 10, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + + temperature_entity_id = "sensor.demo_temperature" + hass.states.async_set( + temperature_entity_id, + 25, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_TEMPERATURE_SENSOR: temperature_entity_id, + CONF_LINKED_PM25_SENSOR: pm25_entity_id, + CONF_LINKED_HUMIDITY_SENSOR: humidity_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_humidity_sensor is not None + assert acc.char_current_humidity is not None + assert acc.linked_pm25_sensor is not None + assert acc.char_pm25_density is not None + assert acc.char_air_quality is not None + assert acc.linked_temperature_sensor is not None + assert acc.char_current_temperature is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 50 + assert acc.char_pm25_density.value == 10 + assert acc.char_air_quality.value == 2 + assert acc.char_current_temperature.value == 25 + + # Updated humidity should reflect in HomeKit + broker = MagicMock() + acc.char_current_humidity.broker = broker + hass.states.async_set( + humidity_entity_id, + 60, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + humidity_entity_id, + 60, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert len(broker.mock_calls) == 0 + + # Updated PM2.5 should reflect in HomeKit + broker = MagicMock() + acc.char_pm25_density.broker = broker + acc.char_air_quality.broker = broker + hass.states.async_set( + pm25_entity_id, + 5, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + await hass.async_block_till_done() + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert len(broker.mock_calls) == 4 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + pm25_entity_id, + 5, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert len(broker.mock_calls) == 0 + + # Updated temperature with different unit should reflect in HomeKit + broker = MagicMock() + acc.char_current_temperature.broker = broker + hass.states.async_set( + temperature_entity_id, + 60, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_temperature.value == 15.6 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Updated temperature should reflect in HomeKit + broker = MagicMock() + acc.char_current_temperature.broker = broker + hass.states.async_set( + temperature_entity_id, + 30, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_temperature.value == 30 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + temperature_entity_id, + 30, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_current_temperature.value == 30 + assert len(broker.mock_calls) == 0 + + # Should handle unavailable state, show last known value + hass.states.async_set( + humidity_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + hass.states.async_set( + pm25_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + hass.states.async_set( + temperature_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert acc.char_current_temperature.value == 30 + + # Check that all goes well if we remove the linked sensors + hass.states.async_remove(humidity_entity_id) + hass.states.async_remove(pm25_entity_id) + hass.states.async_remove(temperature_entity_id) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert len(acc.char_current_humidity.broker.mock_calls) == 0 + assert len(acc.char_pm25_density.broker.mock_calls) == 0 + assert len(acc.char_air_quality.broker.mock_calls) == 0 + assert len(acc.char_current_temperature.broker.mock_calls) == 0 + + # HomeKit will show the last known values + assert acc.char_current_humidity.value == 60 + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert acc.char_current_temperature.value == 30 + + +async def test_filter_maintenance_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter level and filter change indicator are exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_change_indicator_entity_id = "binary_sensor.demo_filter_change_indicator" + hass.states.async_set(filter_change_indicator_entity_id, STATE_OFF) + + filter_life_level_entity_id = "sensor.demo_filter_life_level" + hass.states.async_set(filter_life_level_entity_id, 50) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_CHANGE_INDICATION: filter_change_indicator_entity_id, + CONF_LINKED_FILTER_LIFE_LEVEL: filter_life_level_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is not None + assert acc.char_filter_change_indication is not None + assert acc.linked_filter_life_level_sensor is not None + assert acc.char_filter_life_level is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + assert acc.char_filter_life_level.value == 50 + + # Updated filter change indicator should reflect in HomeKit + broker = MagicMock() + acc.char_filter_change_indication.broker = broker + hass.states.async_set(filter_change_indicator_entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + filter_change_indicator_entity_id, STATE_ON, force_update=True + ) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert len(broker.mock_calls) == 0 + + # Updated filter life level should reflect in HomeKit + broker = MagicMock() + acc.char_filter_life_level.broker = broker + hass.states.async_set(filter_life_level_entity_id, 25) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == 25 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set(filter_life_level_entity_id, 25, force_update=True) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == 25 + assert len(broker.mock_calls) == 0 + + # Should handle unavailable state, show last known value + hass.states.async_set(filter_change_indicator_entity_id, STATE_UNAVAILABLE) + hass.states.async_set(filter_life_level_entity_id, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert acc.char_filter_life_level.value == 25 + + # Check that all goes well if we remove the linked sensors + hass.states.async_remove(filter_change_indicator_entity_id) + hass.states.async_remove(filter_life_level_entity_id) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert len(acc.char_filter_change_indication.broker.mock_calls) == 0 + assert len(acc.char_filter_life_level.broker.mock_calls) == 0 + + # HomeKit will show the last known values + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert acc.char_filter_life_level.value == 25 + + +async def test_filter_maintenance_only_change_indicator_sensor( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter change indicator is exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_change_indicator_entity_id = "binary_sensor.demo_filter_change_indicator" + hass.states.async_set(filter_change_indicator_entity_id, STATE_OFF) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_CHANGE_INDICATION: filter_change_indicator_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is not None + assert acc.char_filter_change_indication is not None + assert acc.linked_filter_life_level_sensor is None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + + hass.states.async_set(filter_change_indicator_entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + + +async def test_filter_life_level_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter life level sensor exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_life_level_entity_id = "sensor.demo_filter_life_level" + hass.states.async_set(filter_life_level_entity_id, 50) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_LIFE_LEVEL: filter_life_level_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is None + assert ( + acc.char_filter_change_indication is not None + ) # calculated based on filter life level + assert acc.linked_filter_life_level_sensor is not None + assert acc.char_filter_life_level is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + assert acc.char_filter_life_level.value == 50 + + hass.states.async_set( + filter_life_level_entity_id, THRESHOLD_FILTER_CHANGE_NEEDED - 1 + ) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == THRESHOLD_FILTER_CHANGE_NEEDED - 1 + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index 67392f11f14..e6f81c1729f 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -14,7 +14,13 @@ from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, FanEntityFeature, ) -from homeassistant.components.homekit.const import ATTR_VALUE, PROP_MIN_STEP +from homeassistant.components.homekit.accessories import HomeDriver +from homeassistant.components.homekit.const import ( + ATTR_VALUE, + CHAR_CONFIGURED_NAME, + PROP_MIN_STEP, + SERV_SWITCH, +) from homeassistant.components.homekit.type_fans import Fan from homeassistant.const import ( ATTR_ENTITY_ID, @@ -603,7 +609,7 @@ async def test_fan_restore( async def test_fan_multiple_preset_modes( - hass: HomeAssistant, hk_driver, events: list[Event] + hass: HomeAssistant, hk_driver: HomeDriver, events: list[Event] ) -> None: """Test fan with multiple preset modes.""" entity_id = "fan.demo" @@ -623,6 +629,9 @@ async def test_fan_multiple_preset_modes( assert acc.preset_mode_chars["auto"].value == 1 assert acc.preset_mode_chars["smart"].value == 0 + switch_service = acc.get_service(SERV_SWITCH) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "auto" acc.run() await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 78c35b15790..51d6e65bb1b 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -6,6 +6,7 @@ from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, + CHAR_CONFIGURED_NAME, CHAR_REMOTE_KEY, CONF_FEATURE_LIST, EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, @@ -14,6 +15,7 @@ from homeassistant.components.homekit.const import ( FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, KEY_ARROW_RIGHT, + SERV_SWITCH, ) from homeassistant.components.homekit.type_media_players import ( MediaPlayer, @@ -74,6 +76,10 @@ async def test_media_player_set_state( assert acc.aid == 2 assert acc.category == 8 # Switch + switch_service = acc.get_service(SERV_SWITCH) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "Power" + assert acc.chars[FEATURE_ON_OFF].value is False assert acc.chars[FEATURE_PLAY_PAUSE].value is False assert acc.chars[FEATURE_PLAY_STOP].value is False diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 141141e7f15..3f0f0a3c22b 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -6,12 +6,15 @@ import pytest from homeassistant.components.homekit.const import ( ATTR_VALUE, + CHAR_CONFIGURED_NAME, + SERV_OUTLET, TYPE_FAUCET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_VALVE, ) from homeassistant.components.homekit.type_switches import ( + LawnMower, Outlet, SelectSwitch, Switch, @@ -19,6 +22,13 @@ from homeassistant.components.homekit.type_switches import ( Valve, ValveSwitch, ) +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + SERVICE_DOCK, + SERVICE_START_MOWING, + LawnMowerActivity, + LawnMowerEntityFeature, +) from homeassistant.components.select import ATTR_OPTIONS from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, @@ -383,6 +393,73 @@ async def test_vacuum_set_state_without_returnhome_and_start_support( assert events[-1].data[ATTR_VALUE] is None +async def test_lawn_mower_set_state( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test if Lawn mower accessory and HA are updated accordingly.""" + entity_id = "lawn_mower.mower" + + hass.states.async_set( + entity_id, + None, + { + ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.START_MOWING + }, + ) + await hass.async_block_till_done() + + acc = LawnMower(hass, hk_driver, "LawnMower", entity_id, 2, None) + acc.run() + await hass.async_block_till_done() + assert acc.aid == 2 + assert acc.category == 8 # Switch + + assert acc.char_on.value == 0 + + hass.states.async_set( + entity_id, + LawnMowerActivity.MOWING, + { + ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.START_MOWING + }, + ) + await hass.async_block_till_done() + assert acc.char_on.value == 1 + + hass.states.async_set( + entity_id, + LawnMowerActivity.DOCKED, + { + ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.START_MOWING + }, + ) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, LAWN_MOWER_DOMAIN, SERVICE_START_MOWING) + call_turn_off = async_mock_service(hass, LAWN_MOWER_DOMAIN, SERVICE_DOCK) + + acc.char_on.client_update_value(1) + await hass.async_block_till_done() + assert acc.char_on.value == 1 + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + acc.char_on.client_update_value(0) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None + + async def test_reset_switch( hass: HomeAssistant, hk_driver, events: list[Event] ) -> None: @@ -493,6 +570,10 @@ async def test_input_select_switch( acc.run() await hass.async_block_till_done() + switch_service = acc.get_service(SERV_OUTLET) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "option1" + assert acc.select_chars["option1"].value is True assert acc.select_chars["option2"].value is False assert acc.select_chars["option3"].value is False diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index fc4cfa78ca4..69c347ef55a 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -69,7 +69,6 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, - CONF_TEMPERATURE_UNIT, EVENT_HOMEASSISTANT_START, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -77,6 +76,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, Event, HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.common import async_mock_service @@ -858,6 +858,7 @@ async def test_thermostat_fahrenheit( ) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" + hass.config.units = US_CUSTOMARY_SYSTEM # support_ = True hass.states.async_set( @@ -869,10 +870,7 @@ async def test_thermostat_fahrenheit( }, ) await hass.async_block_till_done() - with patch.object( - hass.config.units, CONF_TEMPERATURE_UNIT, new=UnitOfTemperature.FAHRENHEIT - ): - acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) acc.run() await hass.async_block_till_done() @@ -1786,13 +1784,11 @@ async def test_water_heater_fahrenheit( ) -> None: """Test if accessory and HA are update accordingly.""" entity_id = "water_heater.test" + hass.config.units = US_CUSTOMARY_SYSTEM hass.states.async_set(entity_id, HVACMode.HEAT) await hass.async_block_till_done() - with patch.object( - hass.config.units, CONF_TEMPERATURE_UNIT, new=UnitOfTemperature.FAHRENHEIT - ): - acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) + acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) acc.run() await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index f7415ef5599..87948d589c0 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -3,7 +3,11 @@ from unittest.mock import MagicMock from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.homekit.const import CHAR_PROGRAMMABLE_SWITCH_EVENT +from homeassistant.components.homekit.const import ( + CHAR_CONFIGURED_NAME, + CHAR_PROGRAMMABLE_SWITCH_EVENT, + SERV_STATELESS_PROGRAMMABLE_SWITCH, +) from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -55,6 +59,10 @@ async def test_programmable_switch_button_fires_on_trigger( assert acc.device_id is device_id assert acc.available is True + switch_service = acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "ceiling lights Changed States" + hk_driver.publish.reset_mock() hass.states.async_set("light.ceiling_lights", STATE_ON) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 1da12402a56..66906c72266 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -128,6 +128,7 @@ def test_validate_entity_config() -> None: } }, {"switch.test": {CONF_TYPE: "invalid_type"}}, + {"fan.test": {CONF_TYPE: "invalid_type"}}, ] for conf in configs: diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 4e787f305b6..882d0d60e66 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -4,7 +4,9 @@ from collections.abc import Callable, Generator import datetime from unittest.mock import MagicMock, patch -from aiohomekit.testing import FakeController +from aiohomekit.model import Transport +from aiohomekit.testing import FakeController, FakeDiscovery, FakePairing +from bleak.backends.device import BLEDevice from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -57,3 +59,31 @@ def get_next_aid() -> Generator[Callable[[], int]]: return id_counter return _get_id + + +@pytest.fixture +def fake_ble_discovery() -> Generator[None]: + """Fake BLE discovery.""" + + class FakeBLEDiscovery(FakeDiscovery): + device = BLEDevice( + address="AA:BB:CC:DD:EE:FF", name="TestDevice", rssi=-50, details=() + ) + + with patch("aiohomekit.testing.FakeDiscovery", FakeBLEDiscovery): + yield + + +@pytest.fixture +def fake_ble_pairing() -> Generator[None]: + """Fake BLE pairing.""" + + class FakeBLEPairing(FakePairing): + """Fake BLE pairing.""" + + @property + def transport(self): + return Transport.BLE + + with patch("aiohomekit.testing.FakePairing", FakeBLEPairing): + yield diff --git a/tests/components/homekit_controller/fixtures/ecobee3_lite.json b/tests/components/homekit_controller/fixtures/ecobee3_lite.json new file mode 100644 index 00000000000..0656ed20fdb --- /dev/null +++ b/tests/components/homekit_controller/fixtures/ecobee3_lite.json @@ -0,0 +1,3436 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "ecobee3 lite", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Thermostat", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "4.8.70226", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "34AB8811-AC7F-4340-BAC3-FD6A85F9943B", + "iid": 11, + "perms": ["pr", "hd"], + "format": "string", + "value": "4.1;3fac0fb4", + "maxLen": 64 + }, + { + "type": "00000220-0000-1000-8000-0026BB765291", + "iid": 10, + "perms": ["pr", "hd"], + "format": "data", + "value": "u4qz9YgSXzQ=" + }, + { + "type": "000000A6-0000-1000-8000-0026BB765291", + "iid": 9, + "perms": ["pr", "ev"], + "format": "uint32", + "value": 0, + "description": "Accessory Flags" + } + ] + }, + { + "iid": 30, + "type": "000000A2-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000037-0000-1000-8000-0026BB765291", + "iid": 31, + "perms": ["pr"], + "format": "string", + "value": "1.1.0", + "description": "Version", + "maxLen": 64 + } + ] + }, + { + "iid": 16, + "type": "0000004A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000000F-0000-1000-8000-0026BB765291", + "iid": 17, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Current Heating Cooling State", + "minValue": 0, + "maxValue": 2, + "minStep": 1, + "valid-values": [0, 1, 2] + }, + { + "type": "00000033-0000-1000-8000-0026BB765291", + "iid": 18, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 0, + "description": "Target Heating Cooling State", + "minValue": 0, + "maxValue": 3, + "minStep": 1, + "valid-values": [0, 1, 2, 3] + }, + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 19, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.2, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 40.0, + "minStep": 0.1 + }, + { + "type": "00000035-0000-1000-8000-0026BB765291", + "iid": 20, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 22.2, + "description": "Target Temperature", + "unit": "celsius", + "minValue": 7.2, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "00000036-0000-1000-8000-0026BB765291", + "iid": 21, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 1, + "description": "Temperature Display Units", + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "valid-values": [0, 1] + }, + { + "type": "0000000D-0000-1000-8000-0026BB765291", + "iid": 22, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 25.0, + "description": "Cooling Threshold Temperature", + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "00000012-0000-1000-8000-0026BB765291", + "iid": 23, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 22.2, + "description": "Heating Threshold Temperature", + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "00000010-0000-1000-8000-0026BB765291", + "iid": 24, + "perms": ["pr", "ev"], + "format": "float", + "value": 45.0, + "description": "Current Relative Humidity", + "unit": "percentage", + "minValue": 0, + "maxValue": 100.0, + "minStep": 1.0 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 27, + "perms": ["pr"], + "format": "string", + "value": "Thermostat", + "description": "Name", + "maxLen": 64 + }, + { + "type": "000000BF-0000-1000-8000-0026BB765291", + "iid": 75, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 0, + "description": "Target Fan State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "000000AF-0000-1000-8000-0026BB765291", + "iid": 76, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Current Fan State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "B7DDB9A3-54BB-4572-91D2-F1F5B0510F8C", + "iid": 33, + "perms": ["pr"], + "format": "uint8", + "value": 3, + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "E4489BBC-5227-4569-93E5-B345E3E5508F", + "iid": 34, + "perms": ["pr", "pw"], + "format": "float", + "value": 22.2, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "7D381BAA-20F9-40E5-9BE9-AEB92D4BECEF", + "iid": 35, + "perms": ["pr", "pw"], + "format": "float", + "value": 25.0, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "73AAB542-892A-4439-879A-D2A883724B69", + "iid": 36, + "perms": ["pr", "pw"], + "format": "float", + "value": 17.8, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "5DA985F0-898A-4850-B987-B76C6C78D670", + "iid": 37, + "perms": ["pr", "pw"], + "format": "float", + "value": 25.6, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "05B97374-6DC0-439B-A0FA-CA33F612D425", + "iid": 38, + "perms": ["pr", "pw"], + "format": "float", + "value": 20.0, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "A251F6E7-AC46-4190-9C5D-3D06277BDF9F", + "iid": 39, + "perms": ["pr", "pw"], + "format": "float", + "value": 24.4, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "1B300BC2-CFFC-47FF-89F9-BD6CCF5F2853", + "iid": 40, + "perms": ["pw"], + "format": "uint8", + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "1621F556-1367-443C-AF19-82AF018E99DE", + "iid": 41, + "perms": ["pr", "pw"], + "format": "string", + "value": "2025-04-06T23:30:00-05:00R", + "maxLen": 64 + }, + { + "type": "FA128DE6-9D7D-49A4-B6D8-4E4E234DEE38", + "iid": 48, + "perms": ["pw"], + "format": "bool" + }, + { + "type": "4A6AE4F6-036C-495D-87CC-B3702B437741", + "iid": 49, + "perms": ["pr"], + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 4, + "minStep": 1 + }, + { + "type": "DB7BF261-7042-4194-8BD1-3AA22830AEDD", + "iid": 50, + "perms": ["pr"], + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "41935E3E-B54D-42E9-B8B9-D33C6319F0AF", + "iid": 51, + "perms": ["pr"], + "format": "bool", + "value": false + }, + { + "type": "C35DA3C0-E004-40E3-B153-46655CDD9214", + "iid": 52, + "perms": ["pr", "pw"], + "format": "uint8", + "value": 100, + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "48F62AEC-4171-4B4A-8F0E-1EEB6708B3FB", + "iid": 53, + "perms": ["pr"], + "format": "uint8", + "value": 100, + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "1B1515F2-CC45-409F-991F-C480987F92C3", + "iid": 54, + "perms": ["pr"], + "format": "string", + "value": "The Hive is humming along. You have no pending alerts or reminders.", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4295608971, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Master BR", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Master BR", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 22.4, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Master BR Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Master BR Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 691, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Master BR Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 691, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295608960, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Basement", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Basement", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 20.3, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Basement Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Basement Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 9158, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Basement Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 9158, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295016858, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Living Room", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Living Room", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.0, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Living Room Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Living Room Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Living Room Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295016969, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.6, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4298584118, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1421, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 821, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298649931, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Loft window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Loft window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Loft window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 327, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Loft window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 328, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Loft window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298527970, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Front Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Front Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Front Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1473, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Front Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 873, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Front Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298527962, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Garage Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Garage Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1189, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 888, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360914, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360921, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Deck Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Deck Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 944, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 884, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360712, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1923, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 625, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298568508, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 9060, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 9060, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index a41964d98cc..324040f850f 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -86,7 +86,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -110,7 +112,7 @@ 'original_name': 'Airversa AP2 1808 AirPurifier', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32832', 'unit_of_measurement': None, @@ -120,9 +122,11 @@ 'friendly_name': 'Airversa AP2 1808 AirPurifier', 'percentage': 0, 'percentage_step': 20.0, - 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_mode': 'auto', + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.airversa_ap2_1808_airpurifier', 'state': 'off', @@ -4195,6 +4199,3464 @@ }), ]) # --- +# name: test_snapshots[ecobee3_lite] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295608960', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement Motion', + }), + 'entity_id': 'binary_sensor.basement_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Basement Occupancy', + }), + 'entity_id': 'binary_sensor.basement_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Basement Identify', + }), + 'entity_id': 'button.basement_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Basement Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.basement_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.basement_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Basement Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.basement_temperature', + 'state': '20.3', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298360914', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Basement Window 1', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Basement Window 1 Contact', + }), + 'entity_id': 'binary_sensor.basement_window_1_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement Window 1 Motion', + }), + 'entity_id': 'binary_sensor.basement_window_1_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Basement Window 1 Occupancy', + }), + 'entity_id': 'binary_sensor.basement_window_1_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_window_1_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Basement Window 1 Identify', + }), + 'entity_id': 'button.basement_window_1_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_window_1_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Basement Window 1 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Window 1 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.basement_window_1_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298360921', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Deck Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Deck Door Contact', + }), + 'entity_id': 'binary_sensor.deck_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Deck Door Motion', + }), + 'entity_id': 'binary_sensor.deck_door_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Deck Door Occupancy', + }), + 'entity_id': 'binary_sensor.deck_door_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.deck_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Deck Door Identify', + }), + 'entity_id': 'button.deck_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.deck_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Deck Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Deck Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.deck_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298527970', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Front Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Front Door Contact', + }), + 'entity_id': 'binary_sensor.front_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Front Door Motion', + }), + 'entity_id': 'binary_sensor.front_door_motion', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Front Door Occupancy', + }), + 'entity_id': 'binary_sensor.front_door_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.front_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Front Door Identify', + }), + 'entity_id': 'button.front_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Front Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Front Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.front_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298527962', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Garage Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Garage Door Contact', + }), + 'entity_id': 'binary_sensor.garage_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Garage Door Motion', + }), + 'entity_id': 'binary_sensor.garage_door_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Garage Door Occupancy', + }), + 'entity_id': 'binary_sensor.garage_door_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.garage_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Garage Door Identify', + }), + 'entity_id': 'button.garage_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Garage Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Garage Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.garage_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295016858', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Living Room', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Living Room Motion', + }), + 'entity_id': 'binary_sensor.living_room_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Living Room Occupancy', + }), + 'entity_id': 'binary_sensor.living_room_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Living Room Identify', + }), + 'entity_id': 'button.living_room_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_room_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Living Room Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.living_room_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Living Room Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.living_room_temperature', + 'state': '21.0', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298360712', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Living Room Window 1', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Living Room Window 1 Contact', + }), + 'entity_id': 'binary_sensor.living_room_window_1_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Living Room Window 1 Motion', + }), + 'entity_id': 'binary_sensor.living_room_window_1_motion', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Living Room Window 1 Occupancy', + }), + 'entity_id': 'binary_sensor.living_room_window_1_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_window_1_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Living Room Window 1 Identify', + }), + 'entity_id': 'button.living_room_window_1_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_room_window_1_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Living Room Window 1 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Window 1 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.living_room_window_1_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298649931', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Loft window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Loft window Contact', + }), + 'entity_id': 'binary_sensor.loft_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Loft window Motion', + }), + 'entity_id': 'binary_sensor.loft_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Loft window Occupancy', + }), + 'entity_id': 'binary_sensor.loft_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.loft_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Loft window Identify', + }), + 'entity_id': 'button.loft_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.loft_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Loft window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Loft window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.loft_window_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295608971', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Master BR', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Master BR Motion', + }), + 'entity_id': 'binary_sensor.master_br_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Master BR Occupancy', + }), + 'entity_id': 'binary_sensor.master_br_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_br_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Master BR Identify', + }), + 'entity_id': 'button.master_br_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.master_br_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Master BR Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Master BR Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.master_br_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.master_br_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Master BR Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.master_br_temperature', + 'state': '22.4', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298584118', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Master BR Window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Master BR Window Contact', + }), + 'entity_id': 'binary_sensor.master_br_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Master BR Window Motion', + }), + 'entity_id': 'binary_sensor.master_br_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Master BR Window Occupancy', + }), + 'entity_id': 'binary_sensor.master_br_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_br_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Master BR Window Identify', + }), + 'entity_id': 'button.master_br_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.master_br_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Master BR Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Master BR Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.master_br_window_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3 lite', + 'model_id': None, + 'name': 'Thermostat', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '4.8.70226', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.thermostat_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Clear Hold', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Thermostat Clear Hold', + }), + 'entity_id': 'button.thermostat_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.thermostat_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Thermostat Identify', + }), + 'entity_id': 'button.thermostat_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 45.0, + 'current_temperature': 21.2, + 'fan_mode': 'on', + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'friendly_name': 'Thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'entity_id': 'climate.thermostat', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.thermostat_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Current Mode', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Thermostat Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.thermostat_current_mode', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.thermostat_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Temperature Display Units', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Thermostat Temperature Display Units', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.thermostat_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Thermostat Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.thermostat_current_humidity', + 'state': '45.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Current Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostat Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.thermostat_current_temperature', + 'state': '21.2', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295016969', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Upstairs BR', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Upstairs BR Motion', + }), + 'entity_id': 'binary_sensor.upstairs_br_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Upstairs BR Occupancy', + }), + 'entity_id': 'binary_sensor.upstairs_br_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.upstairs_br_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Upstairs BR Identify', + }), + 'entity_id': 'button.upstairs_br_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.upstairs_br_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Upstairs BR Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs BR Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.upstairs_br_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.upstairs_br_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Upstairs BR Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.upstairs_br_temperature', + 'state': '21.6', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298568508', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Upstairs BR Window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Upstairs BR Window Contact', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Upstairs BR Window Motion', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Upstairs BR Window Occupancy', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.upstairs_br_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Upstairs BR Window Identify', + }), + 'entity_id': 'button.upstairs_br_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.upstairs_br_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Upstairs BR Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs BR Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.upstairs_br_window_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[ecobee3_no_sensors] list([ dict({ @@ -7104,7 +10566,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -7139,7 +10602,8 @@ 'percentage': 66, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.haa_c718b3', @@ -7790,7 +11254,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -7825,7 +11290,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.ceiling_fan', @@ -8000,7 +11466,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -8036,7 +11503,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -8197,7 +11665,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -8221,7 +11691,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_175', 'unit_of_measurement': None, @@ -8233,8 +11703,10 @@ 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.89_living_room', 'state': 'on', @@ -9245,7 +12717,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -9280,7 +12753,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.ceiling_fan', @@ -9455,7 +12929,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -9492,7 +12967,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -9671,7 +13147,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -9708,7 +13185,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -9878,7 +13356,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -9902,7 +13382,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_175', 'unit_of_measurement': None, @@ -9914,8 +13394,10 @@ 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.89_living_room', 'state': 'on', @@ -14352,7 +17834,7 @@ 'original_name': 'LG webOS TV AF80', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48', 'unit_of_measurement': None, @@ -14371,7 +17853,7 @@ 'AV', 'HDMI 4', ]), - 'supported_features': , + 'supported_features': , }), 'entity_id': 'media_player.lg_webos_tv_af80', 'state': 'on', @@ -14509,7 +17991,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -14544,7 +18027,8 @@ 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.caseta_r_wireless_fan_speed_control', @@ -18319,7 +21803,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -18355,7 +21840,8 @@ 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.simpleconnect_fan_06f674_hunter_fan', diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py new file mode 100644 index 00000000000..48d1fc3ebdc --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -0,0 +1,56 @@ +"""Test against characteristics captured from an LG TV.""" + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE_LIST, + MediaPlayerEntityFeature, +) +from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON +from homeassistant.core import HomeAssistant + +from ..common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + assert_devices_and_entities_created, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_lg_tv_setup(hass: HomeAssistant) -> None: + """Test that a LG TV can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "lg_tv.json") + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="LG webOS TV AF80", + model="OLED55B9PUA", + manufacturer="LG Electronics", + sw_version="04.71.04", + hw_version="1", + serial_number="A0000A000000000A", + devices=[], + entities=[], + ), + ) + + state = hass.states.get("media_player.lg_webos_tv_af80") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ + "AirPlay", + "Live TV", + "HDMI 1", + "Sony", + "Apple", + "AV", + "HDMI 4", + ] + features = state.attributes[ATTR_SUPPORTED_FEATURES] + assert features & MediaPlayerEntityFeature.TURN_ON + assert features & MediaPlayerEntityFeature.TURN_OFF + assert features & MediaPlayerEntityFeature.SELECT_SOURCE + assert features & MediaPlayerEntityFeature.PLAY + assert features & MediaPlayerEntityFeature.PAUSE diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 62c73af9977..b119b5f7b80 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -8,6 +8,8 @@ from aiohomekit.model.characteristics import ( CharacteristicsTypes, CurrentFanStateValues, CurrentHeaterCoolerStateValues, + HeatingCoolingCurrentValues, + HeatingCoolingTargetValues, SwingModeValues, TargetHeaterCoolerStateValues, ) @@ -20,6 +22,7 @@ from homeassistant.components.climate import ( SERVICE_SET_HVAC_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + HVACAction, HVACMode, ) from homeassistant.core import HomeAssistant @@ -662,7 +665,7 @@ async def test_hvac_mode_vs_hvac_action( state = await helper.poll_and_get_state() assert state.state == "heat" - assert state.attributes["hvac_action"] == "fan" + assert state.attributes["hvac_action"] == HVACAction.FAN # Simulate that current temperature is below target temp # Heating might be on and hvac_action currently 'heat' @@ -676,7 +679,23 @@ async def test_hvac_mode_vs_hvac_action( state = await helper.poll_and_get_state() assert state.state == "heat" - assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_action"] == HVACAction.HEATING + + # If the fan is active, and the heating is off, the hvac_action should be 'fan' + # and not 'idle' or 'heating' + await helper.async_update( + ServicesTypes.THERMOSTAT, + { + CharacteristicsTypes.FAN_STATE_CURRENT: CurrentFanStateValues.ACTIVE, + CharacteristicsTypes.HEATING_COOLING_CURRENT: HeatingCoolingCurrentValues.IDLE, + CharacteristicsTypes.HEATING_COOLING_TARGET: HeatingCoolingTargetValues.OFF, + CharacteristicsTypes.FAN_STATE_CURRENT: CurrentFanStateValues.ACTIVE, + }, + ) + + state = await helper.poll_and_get_state() + assert state.state == HVACMode.OFF + assert state.attributes["hvac_action"] == HVACAction.FAN async def test_hvac_mode_vs_hvac_action_current_mode_wrong( diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 7ea791f9a1e..00c7bb16259 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -375,9 +375,9 @@ async def test_poll_firmware_version_only_all_watchable_accessory_mode( state = await helper.poll_and_get_state() assert state.state == STATE_OFF assert mock_get_characteristics.call_count == 2 - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} - assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 7)} + # Verify everything is polled + assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 10), (1, 11)} + assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 10), (1, 11)} # Test device goes offline helper.pairing.available = False @@ -429,8 +429,8 @@ async def test_manual_poll_all_chars( ) as mock_get_characteristics: # Initial state is that the light is off await helper.poll_and_get_state() - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} + # Verify poll polls all chars + assert len(mock_get_characteristics.call_args_list[0][0][0]) > 1 # Now do a manual poll to ensure all chars are polled mock_get_characteristics.reset_mock() diff --git a/tests/components/homekit_controller/test_diagnostics.py b/tests/components/homekit_controller/test_diagnostics.py index f79c875385d..e5408aa5e0f 100644 --- a/tests/components/homekit_controller/test_diagnostics.py +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -1,6 +1,6 @@ """Test homekit_controller diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.homekit_controller.const import KNOWN_DEVICES diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index 2c498e1a9c1..e012c1be339 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -47,6 +47,26 @@ def create_fanv2_service(accessory: Accessory) -> None: swing_mode.value = 0 +def create_fanv2_service_with_target_state(accessory: Accessory) -> None: + """Define fan v2 characteristics with target as per HAP spec.""" + service = accessory.add_service(ServicesTypes.FAN_V2) + + target_state = service.add_char(CharacteristicsTypes.FAN_STATE_TARGET) + target_state.value = 0 + + cur_state = service.add_char(CharacteristicsTypes.ACTIVE) + cur_state.value = 0 + + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 + + speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + speed.value = 0 + + swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE) + swing_mode.value = 0 + + def create_fanv2_service_non_standard_rotation_range(accessory: Accessory) -> None: """Define fan v2 with a non-standard rotation range.""" service = accessory.add_service(ServicesTypes.FAN_V2) @@ -93,6 +113,27 @@ def create_fanv2_service_without_rotation_speed(accessory: Accessory) -> None: swing_mode.value = 0 +def create_air_purifier_service(accessory: Accessory) -> None: + """Define air purifier characteristics as per HAP spec.""" + service = accessory.add_service(ServicesTypes.AIR_PURIFIER) + + target_state = service.add_char(CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET) + target_state.value = 0 + + cur_state = service.add_char(CharacteristicsTypes.ACTIVE) + cur_state.value = 0 + + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 + + speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + speed.value = 0 + speed.minStep = 25 + + swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE) + swing_mode.value = 0 + + async def test_fan_read_state( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: @@ -606,6 +647,70 @@ async def test_v2_set_percentage( ) +async def test_fanv2_set_preset_mode( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set preset mode when target state is available.""" + helper = await setup_test_component( + hass, get_next_aid(), create_fanv2_service_with_target_state + ) + + await helper.async_update(ServicesTypes.FAN_V2, {CharacteristicsTypes.ACTIVE: 1}) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.ROTATION_SPEED: 100.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_preset_mode", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.FAN_STATE_TARGET: 1, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.ROTATION_SPEED: 33.0, + CharacteristicsTypes.FAN_STATE_TARGET: 0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.FAN_STATE_TARGET: 1, + }, + ) + + async def test_v2_set_percentage_with_min_step( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: @@ -847,6 +952,281 @@ async def test_v2_set_percentage_non_standard_rotation_range( ) +async def test_air_purifier_turn_on( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we can turn on an air purifier.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 66}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 75.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_off", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + +async def test_air_purifier_turn_off( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we can turn an air purifier fan off.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "turn_off", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_speed( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set air purifier fan speed.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 66}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 75.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 0}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_percentage( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set air purifier fan speed by percentage.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 75}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 75, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 0}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_preset_mode( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set preset mode when target state is available.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 100.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_preset_mode", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 1, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 25.0, + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 1, + }, + ) + + async def test_migrate_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index f74e8ea994e..656978a08a2 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -174,6 +174,7 @@ async def test_offline_device_raises( assert hass.states.get("light.testdevice").state == STATE_OFF +@pytest.mark.usefixtures("fake_ble_discovery") async def test_ble_device_only_checks_is_available( hass: HomeAssistant, get_next_aid: Callable[[], int], controller ) -> None: @@ -242,6 +243,34 @@ async def test_ble_device_only_checks_is_available( assert hass.states.get("light.testdevice").state == STATE_OFF +@pytest.mark.usefixtures("fake_ble_discovery", "fake_ble_pairing") +async def test_ble_device_populates_connections( + hass: HomeAssistant, get_next_aid: Callable[[], int], controller +) -> None: + """Test a BLE device populates connections in the device registry.""" + aid = get_next_aid() + + accessory = Accessory.create_with_info( + aid, "TestDevice", "example.com", "Test", "0001", "0.1" + ) + create_alive_service(accessory) + + await async_setup_component(hass, DOMAIN, {}) + config_entry, _ = await setup_test_accessories_with_controller( + hass, [accessory], controller + ) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + dev_reg = dr.async_get(hass) + assert ( + dev_reg.async_get_device( + identifiers={}, connections={("bluetooth", "AA:BB:CC:DD:EE:FF")} + ) + is not None + ) + + @pytest.mark.parametrize("example", FIXTURES, ids=lambda val: str(val.stem)) async def test_snapshots( hass: HomeAssistant, diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index d1d280ef265..e00dde92a81 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -10,6 +10,11 @@ from aiohomekit.model.characteristics import ( from aiohomekit.model.services import Service, ServicesTypes import pytest +from homeassistant.components.media_player import ( + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -408,3 +413,57 @@ async def test_migrate_unique_id( entity_registry.async_get(media_player_entry.entity_id).unique_id == f"00:00:00:00:00:00_{aid}_8" ) + + +async def test_turn_on(hass: HomeAssistant, get_next_aid: Callable[[], int]) -> None: + """Test that we can turn on a media player.""" + helper = await setup_test_component( + hass, get_next_aid(), create_tv_service_with_target_media_state + ) + + await helper.async_update( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.CURRENT_MEDIA_STATE: 0, + }, + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.ACTIVE: 1, + }, + ) + + +async def test_turn_off(hass: HomeAssistant, get_next_aid: Callable[[], int]) -> None: + """Test that we can turn off a media player.""" + helper = await setup_test_component( + hass, get_next_aid(), create_tv_service_with_target_media_state + ) + + await helper.async_update( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.CURRENT_MEDIA_STATE: 0, + }, + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index c40864c9629..3c8618c66c5 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -1,14 +1,12 @@ """Basic checks for HomeKit sensor.""" from collections.abc import Callable -from unittest.mock import patch -from aiohomekit.model import Accessory, Transport +from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, ThreadStatus from aiohomekit.model.services import Service, ServicesTypes from aiohomekit.protocol.statuscodes import HapStatusCode -from aiohomekit.testing import FakePairing import pytest from homeassistant.components.homekit_controller.sensor import ( @@ -406,34 +404,36 @@ def test_thread_status_to_str() -> None: assert thread_status_to_str(ThreadStatus.DISABLED) == "disabled" -@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +@pytest.mark.usefixtures( + "enable_bluetooth", + "entity_registry_enabled_by_default", + "fake_ble_discovery", + "fake_ble_pairing", +) async def test_rssi_sensor( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: """Test an rssi sensor.""" inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO) - class FakeBLEPairing(FakePairing): - """Fake BLE pairing.""" - - @property - def transport(self): - return Transport.BLE - - with patch("aiohomekit.testing.FakePairing", FakeBLEPairing): - # Any accessory will do for this test, but we need at least - # one or the rssi sensor will not be created - await setup_test_component( - hass, - get_next_aid(), - create_battery_level_sensor, - suffix="battery", - connection="BLE", - ) - assert hass.states.get("sensor.testdevice_signal_strength").state == "-56" + # Any accessory will do for this test, but we need at least + # one or the rssi sensor will not be created + await setup_test_component( + hass, + get_next_aid(), + create_battery_level_sensor, + suffix="battery", + connection="BLE", + ) + assert hass.states.get("sensor.testdevice_signal_strength").state == "-56" -@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +@pytest.mark.usefixtures( + "enable_bluetooth", + "entity_registry_enabled_by_default", + "fake_ble_discovery", + "fake_ble_pairing", +) async def test_migrate_rssi_sensor_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -449,24 +449,16 @@ async def test_migrate_rssi_sensor_unique_id( inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO) - class FakeBLEPairing(FakePairing): - """Fake BLE pairing.""" - - @property - def transport(self): - return Transport.BLE - - with patch("aiohomekit.testing.FakePairing", FakeBLEPairing): - # Any accessory will do for this test, but we need at least - # one or the rssi sensor will not be created - await setup_test_component( - hass, - get_next_aid(), - create_battery_level_sensor, - suffix="battery", - connection="BLE", - ) - assert hass.states.get("sensor.renamed_rssi").state == "-56" + # Any accessory will do for this test, but we need at least + # one or the rssi sensor will not be created + await setup_test_component( + hass, + get_next_aid(), + create_battery_level_sensor, + suffix="battery", + connection="BLE", + ) + assert hass.states.get("sensor.renamed_rssi").state == "-56" assert ( entity_registry.async_get(rssi_sensor.entity_id).unique_id diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index ad3957fea69..bcadf407950 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -1,11 +1,11 @@ """Initializer helpers for HomematicIP fake server.""" -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch -from homematicip.aio.auth import AsyncAuth -from homematicip.aio.connection import AsyncConnection -from homematicip.aio.home import AsyncHome +from homematicip.async_home import AsyncHome +from homematicip.auth import Auth from homematicip.base.enums import WeatherCondition, WeatherDayTime +from homematicip.connection.rest_connection import RestConnection import pytest from homeassistant.components.homematicip_cloud import ( @@ -30,16 +30,14 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 @pytest.fixture(name="mock_connection") -def mock_connection_fixture() -> AsyncConnection: +def mock_connection_fixture() -> RestConnection: """Return a mocked connection.""" - connection = MagicMock(spec=AsyncConnection) + connection = AsyncMock(spec=RestConnection) - def _rest_call_side_effect(path, body=None): + def _rest_call_side_effect(path, body=None, custom_header=None): return path, body - connection._rest_call.side_effect = _rest_call_side_effect - connection.api_call = AsyncMock(return_value=True) - connection.init = AsyncMock(side_effect=True) + connection.async_post.side_effect = _rest_call_side_effect return connection @@ -99,7 +97,8 @@ async def mock_hap_with_service_fixture( mock_hap = await default_mock_hap_factory.async_get_mock_hap() await hmip_async_setup(hass, dummy_config) await hass.async_block_till_done() - hass.data[HMIPC_DOMAIN] = {HAPID: mock_hap} + entry = hass.config_entries.async_entries(HMIPC_DOMAIN)[0] + entry.runtime_data = mock_hap return mock_hap @@ -107,7 +106,7 @@ async def mock_hap_with_service_fixture( def simple_mock_home_fixture(): """Return a simple mocked connection.""" - mock_home = Mock( + mock_home = AsyncMock( spec=AsyncHome, name="Demo", devices=[], @@ -128,6 +127,8 @@ def simple_mock_home_fixture(): dutyCycle=88, connected=True, currentAPVersion="2.0.36", + init_async=AsyncMock(), + get_current_state_async=AsyncMock(), ) with patch( @@ -144,18 +145,15 @@ def mock_connection_init_fixture(): with ( patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.init", - return_value=None, - ), - patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth.init", + "homeassistant.components.homematicip_cloud.hap.AsyncHome.init_async", return_value=None, + new_callable=AsyncMock, ), ): yield @pytest.fixture(name="simple_mock_auth") -def simple_mock_auth_fixture() -> AsyncAuth: +def simple_mock_auth_fixture() -> Auth: """Return a simple AsyncAuth Mock.""" - return Mock(spec=AsyncAuth, pin=HAPPIN, create=True) + return AsyncMock(spec=Auth, pin=HAPPIN, create=True) diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index ff57cd168c9..65f8afe55fa 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8296,6 +8296,130 @@ "serializedGlobalTradeItemNumber": "3014F7110000000000DSDPCB", "type": "DOOR_BELL_CONTACT_INTERFACE", "updateState": "UP_TO_DATE" + }, + "3014F71100000000000SVCTH": { + "availableFirmwareVersion": "1.0.10", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.10", + "firmwareVersionInteger": 65546, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "daliBusState": null, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F71100000000000SVCTH", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 0, + "invertedDisplayColors": null, + "label": "", + "lockJammed": null, + "lowBat": false, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -84, + "rssiPeerValue": null, + "sensorCommunicationError": null, + "sensorError": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDaliBusError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceSensorCommunicationError": false, + "IFeatureDeviceSensorError": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": true, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IOptionalFeatureDefaultLinkedGroup": false, + "IOptionalFeatureDeviceAliveSignalEnabled": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceOperationMode": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDisplayMode": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureInvertedDisplayColors": false, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false, + "IOptionalFeatureOperationDays": false + }, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "actualTemperature": 19.7, + "channelRole": "WEATHER_SENSOR", + "deviceId": "3014F71100000000000SVCTH", + "functionalChannelType": "CLIMATE_SENSOR_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000035"], + "humidity": 36, + "index": 1, + "label": "", + "vaporAmount": 6.098938251390021 + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F71100000000000SVCTH", + "label": "elvshctv", + "lastStatusUpdate": 1744114372880, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 9, + "measuredAttributes": {}, + "modelId": 555, + "modelType": "ELV-SH-CTH", + "oem": "eQ-3", + "permanentlyReachable": false, + "serializedGlobalTradeItemNumber": "3014F71100000000000SVCTH", + "type": "TEMPERATURE_HUMIDITY_SENSOR_COMPACT", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/helper.py b/tests/components/homematicip_cloud/helper.py index 80081123519..946ccc569a4 100644 --- a/tests/components/homematicip_cloud/helper.py +++ b/tests/components/homematicip_cloud/helper.py @@ -4,15 +4,15 @@ import json from typing import Any from unittest.mock import Mock, patch -from homematicip.aio.class_maps import ( +from homematicip.async_home import AsyncHome +from homematicip.base.homematicip_object import HomeMaticIPObject +from homematicip.class_maps import ( TYPE_CLASS_MAP, TYPE_GROUP_MAP, TYPE_SECURITY_EVENT_MAP, ) -from homematicip.aio.device import AsyncDevice -from homematicip.aio.group import AsyncGroup -from homematicip.aio.home import AsyncHome -from homematicip.base.homematicip_object import HomeMaticIPObject +from homematicip.device import Device +from homematicip.group import Group from homematicip.home import Home from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN @@ -49,9 +49,9 @@ def get_and_check_entity_basics( hmip_device = mock_hap.hmip_device_by_entity_id.get(entity_id) if hmip_device: - if isinstance(hmip_device, AsyncDevice): + if isinstance(hmip_device, Device): assert ha_state.attributes[ATTR_IS_GROUP] is False - elif isinstance(hmip_device, AsyncGroup): + elif isinstance(hmip_device, Group): assert ha_state.attributes[ATTR_IS_GROUP] return ha_state, hmip_device @@ -120,7 +120,7 @@ class HomeFactory: await self.hass.async_block_till_done() - hap = self.hass.data[HMIPC_DOMAIN][HAPID] + hap = self.hmip_config_entry.runtime_data mock_home.on_update(hap.async_update) mock_home.on_create(hap.async_create_entity) return hap @@ -174,12 +174,12 @@ class HomeTemplate(Home): def init_home(self): """Init template with json.""" self.init_json_state = self._cleanup_json(json.loads(FIXTURE_DATA)) - self.update_home(json_state=self.init_json_state, clearConfig=True) + self.update_home(json_state=self.init_json_state, clear_config=True) return self - def update_home(self, json_state, clearConfig: bool = False): + def update_home(self, json_state, clear_config: bool = False): """Update home and ensure that mocks are created.""" - result = super().update_home(json_state, clearConfig) + result = super().update_home(json_state, clear_config) self._generate_mocks() return result @@ -193,7 +193,7 @@ class HomeTemplate(Home): self.groups = [_get_mock(group) for group in self.groups] - def download_configuration(self): + async def download_configuration_async(self): """Return the initial json config.""" return self.init_json_state diff --git a/tests/components/homematicip_cloud/test_alarm_control_panel.py b/tests/components/homematicip_cloud/test_alarm_control_panel.py index 094308862f6..df83560b893 100644 --- a/tests/components/homematicip_cloud/test_alarm_control_panel.py +++ b/tests/components/homematicip_cloud/test_alarm_control_panel.py @@ -1,14 +1,9 @@ """Tests for HomematicIP Cloud alarm control panel.""" -from homematicip.aio.home import AsyncHome +from homematicip.async_home import AsyncHome -from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, - AlarmControlPanelState, -) -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN +from homeassistant.components.alarm_control_panel import AlarmControlPanelState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, get_and_check_entity_basics @@ -39,17 +34,6 @@ async def _async_manipulate_security_zones( await hass.async_block_till_done() -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, - ALARM_CONTROL_PANEL_DOMAIN, - {ALARM_CONTROL_PANEL_DOMAIN: {"platform": HMIPC_DOMAIN}}, - ) - - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_alarm_control_panel( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: @@ -73,7 +57,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_away", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (True, True) await _async_manipulate_security_zones( hass, home, internal_active=True, external_active=True @@ -83,7 +67,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_home", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (False, True) await _async_manipulate_security_zones(hass, home, external_active=True) assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_HOME @@ -91,7 +75,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_disarm", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (False, False) await _async_manipulate_security_zones(hass, home) assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED @@ -99,7 +83,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_away", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (True, True) await _async_manipulate_security_zones( hass, home, internal_active=True, external_active=True, alarm_triggered=True @@ -109,7 +93,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_home", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (False, True) await _async_manipulate_security_zones( hass, home, external_active=True, alarm_triggered=True diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index 02e96b10fe8..4f6913cc8e8 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -2,8 +2,6 @@ from homematicip.base.enums import SmokeDetectorAlarmType, WindowState -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.homematicip_cloud.binary_sensor import ( ATTR_ACCELERATION_SENSOR_MODE, ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION, @@ -25,21 +23,10 @@ from homeassistant.components.homematicip_cloud.entity import ( ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, - BINARY_SENSOR_DOMAIN, - {BINARY_SENSOR_DOMAIN: {"platform": HMIPC_DOMAIN}}, - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_home_cloud_connection_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index d4711440288..28d0fca0d80 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -12,7 +12,6 @@ from homeassistant.components.climate import ( ATTR_HVAC_ACTION, ATTR_PRESET_MODE, ATTR_PRESET_MODES, - DOMAIN as CLIMATE_DOMAIN, PRESET_AWAY, PRESET_BOOST, PRESET_ECO, @@ -26,7 +25,6 @@ from homeassistant.components.homematicip_cloud.climate import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.setup import async_setup_component from .helper import ( HAPID, @@ -36,14 +34,6 @@ from .helper import ( ) -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, CLIMATE_DOMAIN, {CLIMATE_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_heating_group_heat( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: @@ -83,7 +73,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_point_temperature" + assert hmip_device.mock_calls[-1][0] == "set_point_temperature_async" assert hmip_device.mock_calls[-1][1] == (22.5,) await async_manipulate_test_data(hass, hmip_device, "actualTemperature", 22.5) ha_state = hass.states.get(entity_id) @@ -96,7 +86,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("MANUAL",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "MANUAL") ha_state = hass.states.get(entity_id) @@ -109,7 +99,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("AUTOMATIC",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "AUTO") ha_state = hass.states.get(entity_id) @@ -122,7 +112,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_boost" + assert hmip_device.mock_calls[-1][0] == "set_boost_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "boostMode", True) ha_state = hass.states.get(entity_id) @@ -135,7 +125,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 11 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (0,) await async_manipulate_test_data(hass, hmip_device, "boostMode", False) ha_state = hass.states.get(entity_id) @@ -176,7 +166,7 @@ async def test_hmip_heating_group_heat( ) assert len(hmip_device.mock_calls) == service_call_counter + 18 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) mock_hap.home.get_functionalHome( @@ -194,7 +184,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 20 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("MANUAL",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "MANUAL") ha_state = hass.states.get(entity_id) @@ -208,7 +198,7 @@ async def test_hmip_heating_group_heat( ) assert len(hmip_device.mock_calls) == service_call_counter + 23 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) hmip_device.activeProfile = hmip_device.profiles[0] await async_manipulate_test_data(hass, hmip_device, "controlMode", "AUTOMATIC") @@ -235,7 +225,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 25 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("ECO",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "ECO") ha_state = hass.states.get(entity_id) @@ -293,7 +283,7 @@ async def test_hmip_heating_group_cool( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("MANUAL",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "MANUAL") ha_state = hass.states.get(entity_id) @@ -306,7 +296,7 @@ async def test_hmip_heating_group_cool( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("AUTOMATIC",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "AUTO") ha_state = hass.states.get(entity_id) @@ -320,7 +310,7 @@ async def test_hmip_heating_group_cool( ) assert len(hmip_device.mock_calls) == service_call_counter + 6 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (4,) hmip_device.activeProfile = hmip_device.profiles[4] @@ -373,7 +363,7 @@ async def test_hmip_heating_group_cool( ) assert len(hmip_device.mock_calls) == service_call_counter + 17 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (4,) @@ -531,7 +521,7 @@ async def test_hmip_climate_services( {"duration": 60, "accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_duration" + assert home.mock_calls[-1][0] == "activate_absence_with_duration_async" assert home.mock_calls[-1][1] == (60,) assert len(home._connection.mock_calls) == 1 @@ -541,7 +531,7 @@ async def test_hmip_climate_services( {"duration": 60}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_duration" + assert home.mock_calls[-1][0] == "activate_absence_with_duration_async" assert home.mock_calls[-1][1] == (60,) assert len(home._connection.mock_calls) == 2 @@ -551,7 +541,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00", "accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_period" + assert home.mock_calls[-1][0] == "activate_absence_with_period_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0),) assert len(home._connection.mock_calls) == 3 @@ -561,7 +551,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00"}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_period" + assert home.mock_calls[-1][0] == "activate_absence_with_period_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0),) assert len(home._connection.mock_calls) == 4 @@ -571,7 +561,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00", "temperature": 18.5, "accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_vacation" + assert home.mock_calls[-1][0] == "activate_vacation_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0), 18.5) assert len(home._connection.mock_calls) == 5 @@ -581,7 +571,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00", "temperature": 18.5}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_vacation" + assert home.mock_calls[-1][0] == "activate_vacation_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0), 18.5) assert len(home._connection.mock_calls) == 6 @@ -591,14 +581,14 @@ async def test_hmip_climate_services( {"accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "deactivate_absence" + assert home.mock_calls[-1][0] == "deactivate_absence_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 7 await hass.services.async_call( "homematicip_cloud", "deactivate_eco_mode", blocking=True ) - assert home.mock_calls[-1][0] == "deactivate_absence" + assert home.mock_calls[-1][0] == "deactivate_absence_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 8 @@ -608,14 +598,14 @@ async def test_hmip_climate_services( {"accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "deactivate_vacation" + assert home.mock_calls[-1][0] == "deactivate_vacation_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 9 await hass.services.async_call( "homematicip_cloud", "deactivate_vacation", blocking=True ) - assert home.mock_calls[-1][0] == "deactivate_vacation" + assert home.mock_calls[-1][0] == "deactivate_vacation_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 10 @@ -646,7 +636,7 @@ async def test_hmip_set_home_cooling_mode( {"accesspoint_id": HAPID, "cooling": False}, blocking=True, ) - assert home.mock_calls[-1][0] == "set_cooling" + assert home.mock_calls[-1][0] == "set_cooling_async" assert home.mock_calls[-1][1] == (False,) assert len(home._connection.mock_calls) == 1 @@ -656,14 +646,14 @@ async def test_hmip_set_home_cooling_mode( {"accesspoint_id": HAPID, "cooling": True}, blocking=True, ) - assert home.mock_calls[-1][0] == "set_cooling" + assert home.mock_calls[-1][0] == "set_cooling_async" assert home.mock_calls[-1][1] assert len(home._connection.mock_calls) == 2 await hass.services.async_call( "homematicip_cloud", "set_home_cooling_mode", blocking=True ) - assert home.mock_calls[-1][0] == "set_cooling" + assert home.mock_calls[-1][0] == "set_cooling_async" assert home.mock_calls[-1][1] assert len(home._connection.mock_calls) == 3 @@ -703,9 +693,9 @@ async def test_hmip_heating_group_services( {"climate_profile_index": 2, "entity_id": "climate.badezimmer"}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) - assert len(hmip_device._connection.mock_calls) == 2 + assert len(hmip_device._connection.mock_calls) == 1 await hass.services.async_call( "homematicip_cloud", @@ -713,6 +703,6 @@ async def test_hmip_heating_group_services( {"climate_profile_index": 2, "entity_id": "all"}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) - assert len(hmip_device._connection.mock_calls) == 4 + assert len(hmip_device._connection.mock_calls) == 2 diff --git a/tests/components/homematicip_cloud/test_cover.py b/tests/components/homematicip_cloud/test_cover.py index bcafa689172..b005090309b 100644 --- a/tests/components/homematicip_cloud/test_cover.py +++ b/tests/components/homematicip_cloud/test_cover.py @@ -5,25 +5,14 @@ from homematicip.base.enums import DoorCommand, DoorState from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION, - DOMAIN as COVER_DOMAIN, CoverState, ) -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, COVER_DOMAIN, {COVER_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_cover_shutter( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: @@ -47,7 +36,7 @@ async def test_hmip_cover_shutter( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) ha_state = hass.states.get(entity_id) @@ -61,7 +50,7 @@ async def test_hmip_cover_shutter( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0.5, 1) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -72,7 +61,7 @@ async def test_hmip_cover_shutter( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 1) ha_state = hass.states.get(entity_id) @@ -83,7 +72,7 @@ async def test_hmip_cover_shutter( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", None) @@ -115,7 +104,7 @@ async def test_hmip_cover_slats( "cover", "open_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 0} await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0) @@ -131,7 +120,7 @@ async def test_hmip_cover_slats( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 0.5} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -143,7 +132,7 @@ async def test_hmip_cover_slats( "cover", "close_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 6 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 1} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1) ha_state = hass.states.get(entity_id) @@ -155,7 +144,7 @@ async def test_hmip_cover_slats( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 8 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", None) @@ -195,7 +184,7 @@ async def test_hmip_multi_cover_slats( "cover", "open_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 0} await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0, channel=4) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0, channel=4) @@ -211,7 +200,7 @@ async def test_hmip_multi_cover_slats( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 0.5} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5, channel=4) ha_state = hass.states.get(entity_id) @@ -223,7 +212,7 @@ async def test_hmip_multi_cover_slats( "cover", "close_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 6 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 1} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1, channel=4) ha_state = hass.states.get(entity_id) @@ -235,7 +224,7 @@ async def test_hmip_multi_cover_slats( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 8 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == (4,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", None, channel=4) @@ -271,7 +260,7 @@ async def test_hmip_blind_module( "cover", "open_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level_async" assert hmip_device.mock_calls[-1][2] == { "primaryShadingLevel": 0.94956, "secondaryShadingLevel": 0, @@ -284,7 +273,7 @@ async def test_hmip_blind_module( ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level_async" assert hmip_device.mock_calls[-1][2] == {"primaryShadingLevel": 0} ha_state = hass.states.get(entity_id) @@ -308,7 +297,7 @@ async def test_hmip_blind_module( ) assert len(hmip_device.mock_calls) == service_call_counter + 8 - assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level_async" assert hmip_device.mock_calls[-1][2] == {"primaryShadingLevel": 0.5} ha_state = hass.states.get(entity_id) assert ha_state.state == CoverState.OPEN @@ -325,7 +314,7 @@ async def test_hmip_blind_module( ) assert len(hmip_device.mock_calls) == service_call_counter + 12 - assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level_async" assert hmip_device.mock_calls[-1][2] == { "primaryShadingLevel": 1, "secondaryShadingLevel": 1, @@ -340,14 +329,14 @@ async def test_hmip_blind_module( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 13 - assert hmip_device.mock_calls[-1][0] == "stop" + assert hmip_device.mock_calls[-1][0] == "stop_async" assert hmip_device.mock_calls[-1][1] == () await hass.services.async_call( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 14 - assert hmip_device.mock_calls[-1][0] == "stop" + assert hmip_device.mock_calls[-1][0] == "stop_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "secondaryShadingLevel", None) @@ -382,7 +371,7 @@ async def test_hmip_garage_door_tormatic( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN) ha_state = hass.states.get(entity_id) @@ -393,7 +382,7 @@ async def test_hmip_garage_door_tormatic( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED) ha_state = hass.states.get(entity_id) @@ -404,7 +393,7 @@ async def test_hmip_garage_door_tormatic( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,) @@ -431,7 +420,7 @@ async def test_hmip_garage_door_hoermann( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN) ha_state = hass.states.get(entity_id) @@ -442,7 +431,7 @@ async def test_hmip_garage_door_hoermann( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED) ha_state = hass.states.get(entity_id) @@ -453,7 +442,7 @@ async def test_hmip_garage_door_hoermann( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,) @@ -478,7 +467,7 @@ async def test_hmip_cover_shutter_group( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) ha_state = hass.states.get(entity_id) @@ -492,7 +481,7 @@ async def test_hmip_cover_shutter_group( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0.5,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -503,7 +492,7 @@ async def test_hmip_cover_shutter_group( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 1) ha_state = hass.states.get(entity_id) @@ -514,7 +503,7 @@ async def test_hmip_cover_shutter_group( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "shutterLevel", None) @@ -553,7 +542,7 @@ async def test_hmip_cover_slats_group( ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][1] == (0,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0) @@ -569,7 +558,7 @@ async def test_hmip_cover_slats_group( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][1] == (0.5,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -581,7 +570,7 @@ async def test_hmip_cover_slats_group( "cover", "close_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1) ha_state = hass.states.get(entity_id) @@ -593,5 +582,5 @@ async def test_hmip_cover_slats_group( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 9 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == () diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 5ec37d8d8f5..abd0e18b368 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -4,18 +4,12 @@ from unittest.mock import patch from homematicip.base.enums import EventType -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.homematicip_cloud.hap import HomematicipHAP from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .helper import ( - HAPID, - HomeFactory, - async_manipulate_test_data, - get_and_check_entity_basics, -) +from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics from tests.common import MockConfigEntry @@ -28,7 +22,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 310 + assert len(mock_hap.hmip_device_by_entity_id) == 325 async def test_hmip_remove_device( @@ -115,7 +109,7 @@ async def test_hmip_add_device( assert len(device_registry.devices) == pre_device_count assert len(entity_registry.entities) == pre_entity_count - new_hap = hass.data[HMIPC_DOMAIN][HAPID] + new_hap = hmip_config_entry.runtime_data assert len(new_hap.hmip_device_by_entity_id) == pre_mapping_count @@ -257,14 +251,14 @@ async def test_hmip_reset_energy_counter_services( {"entity_id": "switch.pc"}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "reset_energy_counter" - assert len(hmip_device._connection.mock_calls) == 2 + assert hmip_device.mock_calls[-1][0] == "reset_energy_counter_async" + assert len(hmip_device._connection.mock_calls) == 1 await hass.services.async_call( "homematicip_cloud", "reset_energy_counter", {"entity_id": "all"}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "reset_energy_counter" - assert len(hmip_device._connection.mock_calls) == 4 + assert hmip_device.mock_calls[-1][0] == "reset_energy_counter_async" + assert len(hmip_device._connection.mock_calls) == 2 async def test_hmip_multi_area_device( diff --git a/tests/components/homematicip_cloud/test_event.py b/tests/components/homematicip_cloud/test_event.py index de615b35808..fcd16ca62d5 100644 --- a/tests/components/homematicip_cloud/test_event.py +++ b/tests/components/homematicip_cloud/test_event.py @@ -35,3 +35,32 @@ async def test_door_bell_event( ha_state = hass.states.get(entity_id) assert ha_state.state != STATE_UNKNOWN + + +async def test_door_bell_event_wrong_event_type( + 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="KEY_PRESS", 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/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index ded1bf88292..13aaa4d83ba 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -2,8 +2,9 @@ from unittest.mock import Mock, patch -from homematicip.aio.auth import AsyncAuth -from homematicip.base.base_connection import HmipConnectionError +from homematicip.auth import Auth +from homematicip.connection.connection_context import ConnectionContext +from homematicip.exceptions.connection_exceptions import HmipConnectionError import pytest from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN @@ -48,13 +49,13 @@ async def test_auth_auth_check_and_register(hass: HomeAssistant) -> None: config = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"} hmip_auth = HomematicipAuth(hass, config) - hmip_auth.auth = Mock(spec=AsyncAuth) + hmip_auth.auth = Mock(spec=Auth) with ( - patch.object(hmip_auth.auth, "isRequestAcknowledged", return_value=True), - patch.object(hmip_auth.auth, "requestAuthToken", return_value="ABC"), + patch.object(hmip_auth.auth, "is_request_acknowledged", return_value=True), + patch.object(hmip_auth.auth, "request_auth_token", return_value="ABC"), patch.object( hmip_auth.auth, - "confirmAuthToken", + "confirm_auth_token", ), ): assert await hmip_auth.async_checkbutton() @@ -65,13 +66,13 @@ async def test_auth_auth_check_and_register_with_exception(hass: HomeAssistant) """Test auth client registration.""" config = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"} hmip_auth = HomematicipAuth(hass, config) - hmip_auth.auth = Mock(spec=AsyncAuth) + hmip_auth.auth = Mock(spec=Auth) with ( patch.object( - hmip_auth.auth, "isRequestAcknowledged", side_effect=HmipConnectionError + hmip_auth.auth, "is_request_acknowledged", side_effect=HmipConnectionError ), patch.object( - hmip_auth.auth, "requestAuthToken", side_effect=HmipConnectionError + hmip_auth.auth, "request_auth_token", side_effect=HmipConnectionError ), ): assert not await hmip_auth.async_checkbutton() @@ -118,14 +119,13 @@ async def test_hap_reset_unloads_entry_if_setup( ) -> None: """Test calling reset while the entry has been setup.""" mock_hap = await default_mock_hap_factory.async_get_mock_hap() - assert hass.data[HMIPC_DOMAIN][HAPID] == mock_hap config_entries = hass.config_entries.async_entries(HMIPC_DOMAIN) assert len(config_entries) == 1 + assert config_entries[0].runtime_data == mock_hap # hap_reset is called during unload await hass.config_entries.async_unload(config_entries[0].entry_id) # entry is unloaded assert config_entries[0].state is ConfigEntryState.NOT_LOADED - assert hass.data[HMIPC_DOMAIN] == {} async def test_hap_create( @@ -135,7 +135,13 @@ async def test_hap_create( hass.config.components.add(HMIPC_DOMAIN) hap = HomematicipHAP(hass, hmip_config_entry) assert hap - with patch.object(hap, "async_connect"): + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), + patch.object(hap, "async_connect"), + ): async with hmip_config_entry.setup_lock: assert await hap.async_setup() @@ -149,15 +155,25 @@ async def test_hap_create_exception( hap = HomematicipHAP(hass, hmip_config_entry) assert hap - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", - side_effect=Exception, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), + patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", + side_effect=Exception, + ), ): assert not await hap.async_setup() with ( patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), + patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", side_effect=HmipConnectionError, ), pytest.raises(ConfigEntryNotReady), @@ -171,9 +187,15 @@ async def test_auth_create(hass: HomeAssistant, simple_mock_auth) -> None: hmip_auth = HomematicipAuth(hass, config) assert hmip_auth - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth", - return_value=simple_mock_auth, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.Auth", + return_value=simple_mock_auth, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert await hmip_auth.async_setup() await hass.async_block_till_done() @@ -184,16 +206,28 @@ async def test_auth_create_exception(hass: HomeAssistant, simple_mock_auth) -> N """Mock AsyncAuth to execute get_auth.""" config = {HMIPC_HAPID: HAPID, HMIPC_PIN: HAPPIN, HMIPC_NAME: "hmip"} hmip_auth = HomematicipAuth(hass, config) - simple_mock_auth.connectionRequest.side_effect = HmipConnectionError + simple_mock_auth.connection_request.side_effect = HmipConnectionError assert hmip_auth - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth", - return_value=simple_mock_auth, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.Auth", + return_value=simple_mock_auth, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert not await hmip_auth.async_setup() - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth", - return_value=simple_mock_auth, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.Auth", + return_value=simple_mock_auth, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert not await hmip_auth.get_auth(hass, HAPID, HAPPIN) diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py index 07c53248d92..172119a556c 100644 --- a/tests/components/homematicip_cloud/test_init.py +++ b/tests/components/homematicip_cloud/test_init.py @@ -2,7 +2,8 @@ from unittest.mock import AsyncMock, Mock, patch -from homematicip.base.base_connection import HmipConnectionError +from homematicip.connection.connection_context import ConnectionContext +from homematicip.exceptions.connection_exceptions import HmipConnectionError from homeassistant.components.homematicip_cloud.const import ( CONF_ACCESSPOINT, @@ -33,8 +34,6 @@ async def test_config_with_accesspoint_passed_to_config_entry( } # no config_entry exists assert len(hass.config_entries.async_entries(HMIPC_DOMAIN)) == 0 - # no acccesspoint exists - assert not hass.data.get(HMIPC_DOMAIN) with patch( "homeassistant.components.homematicip_cloud.hap.HomematicipHAP.async_connect", @@ -52,7 +51,7 @@ async def test_config_with_accesspoint_passed_to_config_entry( "name": "name", } # defined access_point created for config_entry - assert isinstance(hass.data[HMIPC_DOMAIN]["ABC123"], HomematicipHAP) + assert isinstance(config_entries[0].runtime_data, HomematicipHAP) async def test_config_already_registered_not_passed_to_config_entry( @@ -105,13 +104,19 @@ async def test_load_entry_fails_due_to_connection_error( """Test load entry fails due to connection error.""" hmip_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", - side_effect=HmipConnectionError, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", + side_effect=HmipConnectionError, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert await async_setup_component(hass, HMIPC_DOMAIN, {}) - assert hass.data[HMIPC_DOMAIN][hmip_config_entry.unique_id] + assert hmip_config_entry.runtime_data assert hmip_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -123,16 +128,13 @@ async def test_load_entry_fails_due_to_generic_exception( with ( patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", side_effect=Exception, ), - patch( - "homematicip.aio.connection.AsyncConnection.init", - ), ): assert await async_setup_component(hass, HMIPC_DOMAIN, {}) - assert hass.data[HMIPC_DOMAIN][hmip_config_entry.unique_id] + assert hmip_config_entry.runtime_data assert hmip_config_entry.state is ConfigEntryState.SETUP_ERROR @@ -155,14 +157,12 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert mock_hap.return_value.mock_calls[0][0] == "async_setup" - assert hass.data[HMIPC_DOMAIN]["ABC123"] config_entries = hass.config_entries.async_entries(HMIPC_DOMAIN) assert len(config_entries) == 1 + assert config_entries[0].runtime_data assert config_entries[0].state is ConfigEntryState.LOADED await hass.config_entries.async_unload(config_entries[0].entry_id) assert config_entries[0].state is ConfigEntryState.NOT_LOADED - # entry is unloaded - assert hass.data[HMIPC_DOMAIN] == {} async def test_hmip_dump_hap_config_services( @@ -175,7 +175,7 @@ async def test_hmip_dump_hap_config_services( "homematicip_cloud", "dump_hap_config", {"anonymize": True}, blocking=True ) home = mock_hap_with_service.home - assert home.mock_calls[-1][0] == "download_configuration" + assert home.mock_calls[-1][0] == "download_configuration_async" assert home.mock_calls assert write_mock.mock_calls diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index c0717e81e0d..b929bd337cc 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -2,7 +2,6 @@ from homematicip.base.enums import OpticalSignalBehaviour, RGBColorState -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -10,25 +9,15 @@ from homeassistant.components.light import ( ATTR_EFFECT, ATTR_HS_COLOR, ATTR_SUPPORTED_COLOR_MODES, - DOMAIN as LIGHT_DOMAIN, ColorMode, LightEntityFeature, ) from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_light( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: @@ -54,7 +43,7 @@ async def test_hmip_light( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) @@ -68,7 +57,7 @@ async def test_hmip_light( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) @@ -104,7 +93,7 @@ async def test_hmip_notification_light( {"entity_id": entity_id, "brightness_pct": "100", "transition": 100}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time" + assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time_async" assert hmip_device.mock_calls[-1][2] == { "channelIndex": 2, "rgb": "RED", @@ -130,7 +119,7 @@ async def test_hmip_notification_light( {"entity_id": entity_id, "hs_color": hs_color}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time" + assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time_async" assert hmip_device.mock_calls[-1][2] == { "channelIndex": 2, "dimLevel": 0.0392156862745098, @@ -157,7 +146,7 @@ async def test_hmip_notification_light( "light", "turn_off", {"entity_id": entity_id, "transition": 100}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 11 - assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time" + assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time_async" assert hmip_device.mock_calls[-1][2] == { "channelIndex": 2, "dimLevel": 0.0, @@ -294,7 +283,7 @@ async def test_hmip_dimmer( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await hass.services.async_call( @@ -304,7 +293,7 @@ async def test_hmip_dimmer( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1.0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1) ha_state = hass.states.get(entity_id) @@ -318,7 +307,7 @@ async def test_hmip_dimmer( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0) ha_state = hass.states.get(entity_id) @@ -355,7 +344,7 @@ async def test_hmip_light_measuring( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) await async_manipulate_test_data(hass, hmip_device, "currentPowerConsumption", 50) @@ -369,7 +358,7 @@ async def test_hmip_light_measuring( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -400,7 +389,7 @@ async def test_hmip_wired_multi_dimmer( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await hass.services.async_call( @@ -410,7 +399,7 @@ async def test_hmip_wired_multi_dimmer( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=1) ha_state = hass.states.get(entity_id) @@ -424,7 +413,7 @@ async def test_hmip_wired_multi_dimmer( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=1) ha_state = hass.states.get(entity_id) @@ -459,7 +448,7 @@ async def test_hmip_din_rail_dimmer_3_channel1( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await hass.services.async_call( @@ -469,7 +458,7 @@ async def test_hmip_din_rail_dimmer_3_channel1( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=1) ha_state = hass.states.get(entity_id) @@ -483,7 +472,7 @@ async def test_hmip_din_rail_dimmer_3_channel1( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=1) ha_state = hass.states.get(entity_id) @@ -518,7 +507,7 @@ async def test_hmip_din_rail_dimmer_3_channel2( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 2) await hass.services.async_call( @@ -528,7 +517,7 @@ async def test_hmip_din_rail_dimmer_3_channel2( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 2) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=2) ha_state = hass.states.get(entity_id) @@ -542,7 +531,7 @@ async def test_hmip_din_rail_dimmer_3_channel2( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 2) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=2) ha_state = hass.states.get(entity_id) @@ -577,7 +566,7 @@ async def test_hmip_din_rail_dimmer_3_channel3( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 3) await hass.services.async_call( @@ -587,7 +576,7 @@ async def test_hmip_din_rail_dimmer_3_channel3( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 3) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=3) ha_state = hass.states.get(entity_id) @@ -601,7 +590,7 @@ async def test_hmip_din_rail_dimmer_3_channel3( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 3) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=3) ha_state = hass.states.get(entity_id) diff --git a/tests/components/homematicip_cloud/test_lock.py b/tests/components/homematicip_cloud/test_lock.py index cb8a0188639..3805f0f08de 100644 --- a/tests/components/homematicip_cloud/test_lock.py +++ b/tests/components/homematicip_cloud/test_lock.py @@ -5,28 +5,14 @@ from unittest.mock import patch from homematicip.base.enums import LockState as HomematicLockState, MotorState import pytest -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN -from homeassistant.components.lock import ( - DOMAIN as LOCK_DOMAIN, - LockEntityFeature, - LockState, -) +from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, LOCK_DOMAIN, {LOCK_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_doorlockdrive( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: @@ -50,7 +36,7 @@ async def test_hmip_doorlockdrive( {"entity_id": entity_id}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][0] == "set_lock_state_async" assert hmip_device.mock_calls[-1][1] == (HomematicLockState.OPEN,) await hass.services.async_call( @@ -59,7 +45,7 @@ async def test_hmip_doorlockdrive( {"entity_id": entity_id}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][0] == "set_lock_state_async" assert hmip_device.mock_calls[-1][1] == (HomematicLockState.LOCKED,) await hass.services.async_call( @@ -69,7 +55,7 @@ async def test_hmip_doorlockdrive( blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][0] == "set_lock_state_async" assert hmip_device.mock_calls[-1][1] == (HomematicLockState.UNLOCKED,) await async_manipulate_test_data( @@ -96,7 +82,7 @@ async def test_hmip_doorlockdrive_handle_errors( test_devices=[entity_name] ) with patch( - "homematicip.aio.device.AsyncDoorLockDrive.set_lock_state", + "homematicip.device.DoorLockDrive.set_lock_state_async", return_value={ "errorCode": "INVALID_NUMBER_PARAMETER_VALUE", "minValue": 0.0, diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 2dda3116032..3b5773cfa4d 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -2,7 +2,6 @@ from homematicip.base.enums import ValveState -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.homematicip_cloud.entity import ( ATTR_CONFIG_PENDING, ATTR_DEVICE_OVERHEATED, @@ -23,11 +22,7 @@ from homeassistant.components.homematicip_cloud.sensor import ( ATTR_WIND_DIRECTION, ATTR_WIND_DIRECTION_VARIATION, ) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, LIGHT_LUX, @@ -39,19 +34,10 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_accesspoint_status( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: @@ -720,3 +706,42 @@ async def test_hmip_esi_led_energy_counter_usage_high_tariff( ) assert ha_state.state == "23825.748" + + +async def test_hmip_absolute_humidity_sensor( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test absolute humidity sensor (vaporAmount).""" + entity_id = "sensor.elvshctv_absolute_humidity" + entity_name = "elvshctv Absolute Humidity" + device_model = "ELV-SH-CTH" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["elvshctv"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "6098" + + +async def test_hmip_absolute_humidity_sensor_invalid_value( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test absolute humidity sensor with invalid value for vaporAmount.""" + entity_id = "sensor.elvshctv_absolute_humidity" + entity_name = "elvshctv Absolute Humidity" + device_model = "ELV-SH-CTH" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["elvshctv"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + await async_manipulate_test_data(hass, hmip_device, "vaporAmount", None, 1) + ha_state = hass.states.get(entity_id) + + assert ha_state.state == STATE_UNKNOWN diff --git a/tests/components/homematicip_cloud/test_switch.py b/tests/components/homematicip_cloud/test_switch.py index 54cdd632d03..1a728bfecd4 100644 --- a/tests/components/homematicip_cloud/test_switch.py +++ b/tests/components/homematicip_cloud/test_switch.py @@ -1,25 +1,14 @@ """Tests for HomematicIP Cloud switch.""" -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.homematicip_cloud.entity import ( ATTR_GROUP_MEMBER_UNREACHABLE, ) -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, SWITCH_DOMAIN, {SWITCH_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_switch( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: @@ -42,7 +31,7 @@ async def test_hmip_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -52,7 +41,7 @@ async def test_hmip_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -81,7 +70,7 @@ async def test_hmip_switch_input( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -91,7 +80,7 @@ async def test_hmip_switch_input( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -120,7 +109,7 @@ async def test_hmip_switch_measuring( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -130,7 +119,7 @@ async def test_hmip_switch_measuring( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) await async_manipulate_test_data(hass, hmip_device, "currentPowerConsumption", 50) @@ -158,7 +147,7 @@ async def test_hmip_group_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -168,7 +157,7 @@ async def test_hmip_group_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -208,7 +197,7 @@ async def test_hmip_multi_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -218,7 +207,7 @@ async def test_hmip_multi_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -259,7 +248,7 @@ async def test_hmip_wired_multi_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -269,7 +258,7 @@ async def test_hmip_wired_multi_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) diff --git a/tests/components/homematicip_cloud/test_weather.py b/tests/components/homematicip_cloud/test_weather.py index 44df907fcc5..ad97baf485b 100644 --- a/tests/components/homematicip_cloud/test_weather.py +++ b/tests/components/homematicip_cloud/test_weather.py @@ -1,28 +1,17 @@ """Tests for HomematicIP Cloud weather.""" -from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.weather import ( ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, - DOMAIN as WEATHER_DOMAIN, ) from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass: HomeAssistant) -> None: - """Test that we do not set up an access point.""" - assert await async_setup_component( - hass, WEATHER_DOMAIN, {WEATHER_DOMAIN: {"platform": HMIPC_DOMAIN}} - ) - assert not hass.data.get(HMIPC_DOMAIN) - - async def test_hmip_weather_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/honeywell/test_diagnostics.py b/tests/components/honeywell/test_diagnostics.py index 06c41d3d055..a857a7f633f 100644 --- a/tests/components/honeywell/test_diagnostics.py +++ b/tests/components/honeywell/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index e31e630807e..8bf2e66a286 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -18,7 +18,6 @@ from homeassistant.auth.models import User from homeassistant.auth.providers import trusted_networks from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import websocket_api -from homeassistant.components.http import KEY_HASS from homeassistant.components.http.auth import ( CONTENT_USER_NAME, DATA_SIGN_SECRET, @@ -28,13 +27,13 @@ from homeassistant.components.http.auth import ( async_sign_path, async_user_not_allowed_do_auth, ) -from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.forwarded import async_setup_forwarded from homeassistant.components.http.request_context import ( current_request, setup_request_context, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.setup import async_setup_component from . import HTTP_HEADER_HA_AUTH diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 59011de0cfd..51d3e4ed992 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -11,7 +11,6 @@ from aiohttp.web_middlewares import middleware import pytest from homeassistant.components import http -from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.components.http.ban import ( IP_BANS_FILE, KEY_BAN_MANAGER, @@ -22,6 +21,7 @@ from homeassistant.components.http.ban import ( from homeassistant.components.http.view import request_handler_factory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.setup import async_setup_component from tests.common import async_get_persistent_notifications diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index c0256abb25d..0581c7bac2a 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -1,6 +1,5 @@ """Test cors for the HTTP component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus from pathlib import Path from unittest.mock import patch @@ -18,9 +17,8 @@ from aiohttp.test_utils import TestClient import pytest from homeassistant.components.http.cors import setup_cors -from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant -from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS +from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS, HomeAssistantView from homeassistant.setup import async_setup_component from . import HTTP_HEADER_HA_AUTH @@ -56,14 +54,12 @@ async def mock_handler(request): @pytest.fixture -def client( - event_loop: AbstractEventLoop, aiohttp_client: ClientSessionGenerator -) -> TestClient: +async def client(aiohttp_client: ClientSessionGenerator) -> TestClient: """Fixture to set up a web.Application.""" app = web.Application() setup_cors(app, [TRUSTED_ORIGIN]) app[KEY_ALLOW_CONFIGURED_CORS](app.router.add_get("/", mock_handler)) - return event_loop.run_until_complete(aiohttp_client(app)) + return await aiohttp_client(app) async def test_cors_requests(client) -> None: diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 4d96f2267fa..2937e673946 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -22,7 +22,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern -from tests.common import async_fire_time_changed +from tests.common import async_call_logger_set_level, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -505,27 +505,21 @@ async def test_logging( ) ) hass.states.async_set("logging.entity", "hello") - await hass.services.async_call( - "logger", - "set_level", - {"aiohttp.access": "info"}, - blocking=True, - ) - client = await hass_client() - response = await client.get("/api/states/logging.entity") - assert response.status == HTTPStatus.OK + async with async_call_logger_set_level( + "aiohttp.access", "INFO", hass=hass, caplog=caplog + ): + client = await hass_client() + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK - assert "GET /api/states/logging.entity" in caplog.text - caplog.clear() - await hass.services.async_call( - "logger", - "set_level", - {"aiohttp.access": "warning"}, - blocking=True, - ) - response = await client.get("/api/states/logging.entity") - assert response.status == HTTPStatus.OK - assert "GET /api/states/logging.entity" not in caplog.text + assert "GET /api/states/logging.entity" in caplog.text + caplog.clear() + async with async_call_logger_set_level( + "aiohttp.access", "WARNING", hass=hass, caplog=caplog + ): + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK + assert "GET /api/states/logging.entity" not in caplog.text async def test_register_static_paths( diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 7fc6c5ae33f..9fb291c57b4 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -59,7 +59,7 @@ def create_mock_bridge(hass: HomeAssistant, api_version: int = 1) -> Mock: async def async_initialize_bridge(): if bridge.config_entry: - hass.data.setdefault(hue.DOMAIN, {})[bridge.config_entry.entry_id] = bridge + bridge.config_entry.runtime_data = bridge if bridge.api_version == 2: await async_setup_devices(bridge) return True @@ -73,7 +73,7 @@ def create_mock_bridge(hass: HomeAssistant, api_version: int = 1) -> Mock: async def async_reset(): if bridge.config_entry: - hass.data[hue.DOMAIN].pop(bridge.config_entry.entry_id) + delattr(bridge.config_entry, "runtime_data") return True bridge.async_reset = async_reset @@ -254,6 +254,8 @@ async def setup_bridge( with patch("homeassistant.components.hue.HueBridge", return_value=mock_bridge): await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.LOADED + async def setup_platform( hass: HomeAssistant, @@ -271,7 +273,7 @@ async def setup_platform( api_version=mock_bridge.api_version, host=hostname ) mock_bridge.config_entry = config_entry - hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge} + config_entry.runtime_data = {config_entry.entry_id: mock_bridge} # simulate a full setup by manually adding the bridge config entry await setup_bridge(hass, mock_bridge, config_entry) diff --git a/tests/components/hue/test_diagnostics.py b/tests/components/hue/test_diagnostics.py index 49681601ebf..a9171d2a12a 100644 --- a/tests/components/hue/test_diagnostics.py +++ b/tests/components/hue/test_diagnostics.py @@ -3,6 +3,7 @@ from unittest.mock import Mock from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonArrayType from .conftest import setup_platform @@ -21,9 +22,13 @@ async def test_diagnostics_v1( async def test_diagnostics_v2( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_bridge_v2: Mock + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, ) -> None: """Test diagnostics v2.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) mock_bridge_v2.api.get_diagnostics.return_value = {"hello": "world"} await setup_platform(hass, mock_bridge_v2, []) config_entry = hass.config_entries.async_entries("hue")[0] diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 5ce0d78ead9..6b162a22165 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -42,7 +42,7 @@ async def test_setup_with_no_config(hass: HomeAssistant) -> None: assert len(hass.config_entries.flow.async_progress()) == 0 # No configs stored - assert hue.DOMAIN not in hass.data + assert not hass.config_entries.async_entries(hue.DOMAIN) async def test_unload_entry(hass: HomeAssistant, mock_bridge_setup) -> None: @@ -55,15 +55,15 @@ async def test_unload_entry(hass: HomeAssistant, mock_bridge_setup) -> None: assert await async_setup_component(hass, hue.DOMAIN, {}) is True assert len(mock_bridge_setup.mock_calls) == 1 - hass.data[hue.DOMAIN] = {entry.entry_id: mock_bridge_setup} + entry.runtime_data = mock_bridge_setup async def mock_reset(): - hass.data[hue.DOMAIN].pop(entry.entry_id) + delattr(entry, "runtime_data") return True mock_bridge_setup.async_reset = mock_reset assert await hue.async_unload_entry(hass, entry) - assert hue.DOMAIN not in hass.data + assert not hasattr(entry, "runtime_data") async def test_setting_unique_id(hass: HomeAssistant, mock_bridge_setup) -> None: diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index a9fc1e5c70b..2a366f96e53 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -185,7 +185,7 @@ async def setup_bridge(hass: HomeAssistant, mock_bridge_v1: Mock) -> None: ) config_entry.mock_state(hass, ConfigEntryState.LOADED) mock_bridge_v1.config_entry = config_entry - hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge_v1} + config_entry.runtime_data = mock_bridge_v1 await hass.config_entries.async_forward_entry_setups(config_entry, ["light"]) # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index c831d40d261..f4a6fcfba93 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -2,9 +2,14 @@ from unittest.mock import Mock -from homeassistant.components.light import ColorMode +from homeassistant.components.light import ( + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, + ColorMode, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.util.json import JsonArrayType from .conftest import setup_platform @@ -42,8 +47,8 @@ async def test_lights( assert light_1.attributes["min_mireds"] == 153 assert light_1.attributes["max_mireds"] == 500 assert light_1.attributes["dynamics"] == "dynamic_palette" - assert light_1.attributes["effect_list"] == ["None", "candle", "fire"] - assert light_1.attributes["effect"] == "None" + assert light_1.attributes["effect_list"] == ["off", "candle", "fire"] + assert light_1.attributes["effect"] == "off" # test light which supports color temperature only light_2 = hass.states.get("light.hue_light_with_color_temperature_only") @@ -57,7 +62,7 @@ async def test_lights( assert light_2.attributes["min_mireds"] == 153 assert light_2.attributes["max_mireds"] == 454 assert light_2.attributes["dynamics"] == "none" - assert light_2.attributes["effect_list"] == ["None", "candle", "sunrise"] + assert light_2.attributes["effect_list"] == ["off", "candle", "sunrise"] # test light which supports color only light_3 = hass.states.get("light.hue_light_with_color_only") @@ -201,7 +206,7 @@ async def test_light_turn_on_service( await hass.services.async_call( "light", "turn_on", - {"entity_id": test_light_id, "effect": "None"}, + {"entity_id": test_light_id, "effect": "off"}, blocking=True, ) assert len(mock_bridge_v2.mock_requests) == 8 @@ -216,14 +221,14 @@ async def test_light_turn_on_service( await hass.async_block_till_done() test_light = hass.states.get(test_light_id) assert test_light is not None - assert test_light.attributes["effect"] == "None" + assert test_light.attributes["effect"] == "off" # test turn on with useless effect # it should send a effect in the request if the device has no effect active await hass.services.async_call( "light", "turn_on", - {"entity_id": test_light_id, "effect": "None"}, + {"entity_id": test_light_id, "effect": "off"}, blocking=True, ) assert len(mock_bridge_v2.mock_requests) == 9 @@ -639,3 +644,38 @@ async def test_grouped_lights( mock_bridge_v2.mock_requests[index]["json"]["identify"]["action"] == "identify" ) + + +async def test_light_turn_on_service_deprecation( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + issue_registry: ir.IssueRegistry, +) -> None: + """Test calling the turn on service on a light.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + test_light_id = "light.hue_light_with_color_temperature_only" + + await setup_platform(hass, mock_bridge_v2, "light") + + event = { + "id": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "type": "light", + "effects": {"status": "candle"}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # test disable effect + # it should send a request with effect set to "no_effect" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: test_light_id, + ATTR_EFFECT: "None", + }, + blocking=True, + ) + assert mock_bridge_v2.mock_requests[0]["json"]["effects"]["effect"] == "no_effect" diff --git a/tests/components/hue/test_services.py b/tests/components/hue/test_services.py index 26a4cab8261..2fd8379a73a 100644 --- a/tests/components/hue/test_services.py +++ b/tests/components/hue/test_services.py @@ -9,6 +9,7 @@ from homeassistant.components.hue.const import ( CONF_ALLOW_UNREACHABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonArrayType from .conftest import setup_bridge, setup_component @@ -190,6 +191,7 @@ async def test_hue_multi_bridge_activate_scene_all_respond( mock_bridge_v2: Mock, mock_config_entry_v1: MockConfigEntry, mock_config_entry_v2: MockConfigEntry, + v2_resources_test_data: JsonArrayType, ) -> None: """Test that makes multiple bridges successfully activate a scene.""" await setup_component(hass) @@ -198,6 +200,8 @@ async def test_hue_multi_bridge_activate_scene_all_respond( mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) @@ -224,6 +228,7 @@ async def test_hue_multi_bridge_activate_scene_one_responds( mock_bridge_v2: Mock, mock_config_entry_v1: MockConfigEntry, mock_config_entry_v2: MockConfigEntry, + v2_resources_test_data: JsonArrayType, ) -> None: """Test that makes only one bridge successfully activate a scene.""" await setup_component(hass) @@ -232,6 +237,8 @@ async def test_hue_multi_bridge_activate_scene_one_responds( mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) @@ -257,6 +264,7 @@ async def test_hue_multi_bridge_activate_scene_zero_responds( mock_bridge_v2: Mock, mock_config_entry_v1: MockConfigEntry, mock_config_entry_v2: MockConfigEntry, + v2_resources_test_data: JsonArrayType, ) -> None: """Test that makes no bridge successfully activate a scene.""" await setup_component(hass) @@ -264,6 +272,8 @@ async def test_hue_multi_bridge_activate_scene_zero_responds( mock_api_v1.mock_group_responses.append(GROUP_RESPONSE) mock_api_v1.mock_scene_responses.append(SCENE_RESPONSE) + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_bridge(hass, mock_bridge_v1, mock_config_entry_v1) await setup_bridge(hass, mock_bridge_v2, mock_config_entry_v2) diff --git a/tests/components/huisbaasje/test_init.py b/tests/components/huisbaasje/test_init.py index 5f1bcb0094d..245cde5e9af 100644 --- a/tests/components/huisbaasje/test_init.py +++ b/tests/components/huisbaasje/test_init.py @@ -4,24 +4,16 @@ from unittest.mock import patch from energyflip import EnergyFlipException -from homeassistant.components import huisbaasje +from homeassistant.components.huisbaasje.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_data import MOCK_CURRENT_MEASUREMENTS from tests.common import MockConfigEntry -async def test_setup(hass: HomeAssistant) -> None: - """Test for successfully setting up the platform.""" - assert await async_setup_component(hass, huisbaasje.DOMAIN, {}) - await hass.async_block_till_done() - assert huisbaasje.DOMAIN in hass.config.components - - async def test_setup_entry(hass: HomeAssistant) -> None: """Test for successfully setting a config entry.""" with ( @@ -36,10 +28,9 @@ async def test_setup_entry(hass: HomeAssistant) -> None: return_value=MOCK_CURRENT_MEASUREMENTS, ) as mock_current_measurements, ): - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, - domain=huisbaasje.DOMAIN, + domain=DOMAIN, title="userId", data={ CONF_ID: "userId", @@ -56,9 +47,6 @@ async def test_setup_entry(hass: HomeAssistant) -> None: # Assert integration is loaded assert config_entry.state is ConfigEntryState.LOADED - assert huisbaasje.DOMAIN in hass.config.components - assert huisbaasje.DOMAIN in hass.data - assert config_entry.entry_id in hass.data[huisbaasje.DOMAIN] # Assert entities are loaded entities = hass.states.async_entity_ids("sensor") @@ -75,10 +63,9 @@ async def test_setup_entry_error(hass: HomeAssistant) -> None: with patch( "energyflip.EnergyFlip.authenticate", side_effect=EnergyFlipException ) as mock_authenticate: - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, - domain=huisbaasje.DOMAIN, + domain=DOMAIN, title="userId", data={ CONF_ID: "userId", @@ -95,7 +82,7 @@ async def test_setup_entry_error(hass: HomeAssistant) -> None: # Assert integration is loaded with error assert config_entry.state is ConfigEntryState.SETUP_ERROR - assert huisbaasje.DOMAIN not in hass.data + assert DOMAIN not in hass.data # Assert entities are not loaded entities = hass.states.async_entity_ids("sensor") @@ -119,10 +106,9 @@ async def test_unload_entry(hass: HomeAssistant) -> None: return_value=MOCK_CURRENT_MEASUREMENTS, ) as mock_current_measurements, ): - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, - domain=huisbaasje.DOMAIN, + domain=DOMAIN, title="userId", data={ CONF_ID: "userId", diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index 5f5707bdd5d..4302efa98c8 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from homeassistant.components import huisbaasje +from homeassistant.components.huisbaasje.const import DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -40,10 +40,9 @@ async def test_setup_entry(hass: HomeAssistant) -> None: return_value=MOCK_CURRENT_MEASUREMENTS, ) as mock_current_measurements, ): - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, - domain=huisbaasje.DOMAIN, + domain=DOMAIN, title="userId", data={ CONF_ID: "userId", @@ -331,10 +330,9 @@ async def test_setup_entry_absent_measurement(hass: HomeAssistant) -> None: return_value=MOCK_LIMITED_CURRENT_MEASUREMENTS, ) as mock_current_measurements, ): - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, - domain=huisbaasje.DOMAIN, + domain=DOMAIN, title="userId", data={ CONF_ID: "userId", diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 49994e4f3ae..1cd6f9b393e 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -3,10 +3,10 @@ import asyncio from collections.abc import Generator import time -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, create_autospec, patch +from aioautomower.commands import MowerCommands, WorkAreaSettings from aioautomower.model import MowerAttributes -from aioautomower.session import AutomowerSession, _MowerCommands from aioautomower.utils import mower_list_to_dictionary_dataclass from aiohttp import ClientWebSocketResponse import pytest @@ -108,7 +108,9 @@ async def setup_credentials(hass: HomeAssistant) -> None: @pytest.fixture -def mock_automower_client(values) -> Generator[AsyncMock]: +def mock_automower_client( + values: dict[str, MowerAttributes], +) -> Generator[AsyncMock]: """Mock a Husqvarna Automower client.""" async def listen() -> None: @@ -117,37 +119,21 @@ def mock_automower_client(values) -> Generator[AsyncMock]: 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 - - -@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 + autospec=True, + spec_set=True, + ) as mock: + mock_instance = mock.return_value + mock_instance.auth = AsyncMock(side_effect=ClientWebSocketResponse) + mock_instance.get_status = AsyncMock(return_value=values) + mock_instance.start_listening = AsyncMock(side_effect=listen) + mock_instance.commands = create_autospec( + MowerCommands, instance=True, spec_set=True + ) + mock_instance.commands.workarea_settings.return_value = create_autospec( + WorkAreaSettings, + instance=True, + spec_set=True, + ) + yield mock_instance diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 8ab2f96e42f..06e11ec1252 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -176,13 +176,15 @@ ], "statistics": { "cuttingBladeUsageTime": 123, + "downTime": 3600, "numberOfChargingCycles": 1380, "numberOfCollisions": 11396, "totalChargingTime": 4334400, "totalCuttingTime": 4194000, "totalDriveDistance": 1780272, "totalRunningTime": 4564800, - "totalSearchingTime": 370800 + "totalSearchingTime": 370800, + "upTime": 7200 }, "stayOutZones": { "dirty": false, diff --git a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr index a077eb134d4..bac9f187001 100644 --- a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr @@ -94,53 +94,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_snapshot[binary_sensor.test_mower_1_returning_to_dock-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_mower_1_returning_to_dock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Returning to dock', - 'platform': 'husqvarna_automower', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'returning_to_dock', - 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_returning_to_dock', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor_snapshot[binary_sensor.test_mower_1_returning_to_dock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Mower 1 Returning to dock', - }), - 'context': , - 'entity_id': 'binary_sensor.test_mower_1_returning_to_dock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_charging-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -236,50 +189,3 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_returning_to_dock-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_mower_2_returning_to_dock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Returning to dock', - 'platform': 'husqvarna_automower', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'returning_to_dock', - 'unique_id': '1234_returning_to_dock', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_returning_to_dock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Mower 2 Returning to dock', - }), - 'context': , - 'entity_id': 'binary_sensor.test_mower_2_returning_to_dock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 2dab82451a6..d5546b0d2af 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -95,6 +95,7 @@ }), 'statistics': dict({ 'cutting_blade_usage_time': 123, + 'downtime': 3600, 'number_of_charging_cycles': 1380, 'number_of_collisions': 11396, 'total_charging_time': 4334400, @@ -102,6 +103,7 @@ 'total_drive_distance': 1780272, 'total_running_time': 4564800, 'total_searching_time': 370800, + 'uptime': 7200, }), 'stay_out_zones': dict({ 'dirty': False, diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 02a64718276..979d40a53d8 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -65,7 +65,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.test_mower_1_cutting_blade_usage_time', 'has_entity_name': True, 'hidden_by': None, @@ -106,6 +106,64 @@ 'state': '0.034', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_1_downtime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_mower_1_downtime', + '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': 'Downtime', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'downtime', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_downtime', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_downtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Mower 1 Downtime', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_downtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_mower_1_error-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -113,7 +171,6 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -122,13 +179,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -139,24 +194,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -164,13 +213,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -188,7 +233,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -201,6 +245,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -211,9 +256,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -223,13 +265,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -259,6 +294,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'config_entry_id': , @@ -295,7 +337,6 @@ 'device_class': 'enum', 'friendly_name': 'Test Mower 1 Error', 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -304,13 +345,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -321,24 +360,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -346,13 +379,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -370,7 +399,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -383,6 +411,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -393,9 +422,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -405,13 +431,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -441,6 +460,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'context': , @@ -1208,6 +1234,64 @@ 'state': '103.000', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_1_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_mower_1_uptime', + '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': 'Uptime', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_uptime', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Mower 1 Uptime', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_mower_1_work_area-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1333,7 +1417,6 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -1342,13 +1425,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -1359,24 +1440,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -1384,13 +1459,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -1408,7 +1479,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -1421,6 +1491,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -1431,9 +1502,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -1443,13 +1511,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -1479,6 +1540,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'config_entry_id': , @@ -1515,7 +1583,6 @@ 'device_class': 'enum', 'friendly_name': 'Test Mower 2 Error', 'options': list([ - 'no_error', 'alarm_mower_in_motion', 'alarm_mower_lifted', 'alarm_mower_stopped', @@ -1524,13 +1591,11 @@ 'alarm_outside_geofence', 'angular_sensor_problem', 'battery_problem', - 'battery_problem', 'battery_restriction_due_to_ambient_temperature', 'can_error', 'charging_current_too_high', 'charging_station_blocked', 'charging_system_problem', - 'charging_system_problem', 'collision_sensor_defect', 'collision_sensor_error', 'collision_sensor_problem_front', @@ -1541,24 +1606,18 @@ 'connection_changed', 'connection_not_changed', 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', - 'connectivity_problem', 'connectivity_settings_restored', 'cutting_drive_motor_1_defect', 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', - 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', + 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', - 'cutting_system_blocked', 'cutting_system_imbalance_warning', 'cutting_system_major_imbalance', 'destination_not_reachable', @@ -1566,13 +1625,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', - 'error', - 'error_at_power_up', - 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', - 'geofence_problem', 'gps_navigation_problem', 'guide_1_not_found', 'guide_2_not_found', @@ -1590,7 +1645,6 @@ 'lift_sensor_defect', 'lifted', 'limited_cutting_height_range', - 'limited_cutting_height_range', 'loop_sensor_defect', 'loop_sensor_problem_front', 'loop_sensor_problem_left', @@ -1603,6 +1657,7 @@ 'no_accurate_position_from_satellites', 'no_confirmed_position', 'no_drive', + 'no_error', 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', @@ -1613,9 +1668,6 @@ 'safety_function_faulty', 'settings_restored', 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', - 'sim_card_locked', 'sim_card_not_found', 'sim_card_requires_pin', 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', @@ -1625,13 +1677,6 @@ 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', - 'temporary_battery_problem', 'tilt_sensor_problem', 'too_high_discharge_current', 'too_high_internal_current', @@ -1661,6 +1706,13 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', + 'error_at_power_up', + 'error', + 'fatal_error', + 'off', + 'stopped', + 'wait_power_up', + 'wait_updating', ]), }), 'context': , diff --git a/tests/components/husqvarna_automower/test_binary_sensor.py b/tests/components/husqvarna_automower/test_binary_sensor.py index 30c9cc1bdd3..3d40da99dcb 100644 --- a/tests/components/husqvarna_automower/test_binary_sensor.py +++ b/tests/components/husqvarna_automower/test_binary_sensor.py @@ -2,54 +2,16 @@ from unittest.mock import AsyncMock, patch -from aioautomower.model import MowerActivities, MowerAttributes -from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .const import TEST_MOWER_ID -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_binary_sensor_states( - hass: HomeAssistant, - mock_automower_client: AsyncMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - values: dict[str, MowerAttributes], -) -> None: - """Test binary sensor states.""" - await setup_integration(hass, mock_config_entry) - state = hass.states.get("binary_sensor.test_mower_1_charging") - assert state is not None - assert state.state == "off" - state = hass.states.get("binary_sensor.test_mower_1_leaving_dock") - assert state is not None - assert state.state == "off" - state = hass.states.get("binary_sensor.test_mower_1_returning_to_dock") - assert state is not None - assert state.state == "off" - - for activity, entity in ( - (MowerActivities.CHARGING, "test_mower_1_charging"), - (MowerActivities.LEAVING, "test_mower_1_leaving_dock"), - (MowerActivities.GOING_HOME, "test_mower_1_returning_to_dock"), - ): - values[TEST_MOWER_ID].mower.activity = activity - mock_automower_client.get_status.return_value = values - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - state = hass.states.get(f"binary_sensor.{entity}") - assert state.state == "on" +from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index 5bef810150d..1674c356f73 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -7,7 +7,7 @@ from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL @@ -64,8 +64,7 @@ async def test_button_states_and_commands( target={ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mocked_method = getattr(mock_automower_client.commands, "error_confirm") - mocked_method.assert_called_once_with(TEST_MOWER_ID) + mock_automower_client.commands.error_confirm.assert_called_once_with(TEST_MOWER_ID) await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2023-06-05T00:16:00+00:00" @@ -106,8 +105,7 @@ async def test_sync_clock( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mocked_method = mock_automower_client.commands.set_datetime - mocked_method.assert_called_once_with(TEST_MOWER_ID) + mock_automower_client.commands.set_datetime.assert_called_once_with(TEST_MOWER_ID) await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2024-02-29T11:00:00+00:00" diff --git a/tests/components/husqvarna_automower/test_calendar.py b/tests/components/husqvarna_automower/test_calendar.py index 8138b8c139b..8f9a3e6a016 100644 --- a/tests/components/husqvarna_automower/test_calendar.py +++ b/tests/components/husqvarna_automower/test_calendar.py @@ -11,7 +11,7 @@ import zoneinfo from aioautomower.utils import mower_list_to_dictionary_dataclass from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.calendar import ( DOMAIN as CALENDAR_DOMAIN, diff --git a/tests/components/husqvarna_automower/test_device_tracker.py b/tests/components/husqvarna_automower/test_device_tracker.py index 91f5e40b154..3ab5e55f2c7 100644 --- a/tests/components/husqvarna_automower/test_device_tracker.py +++ b/tests/components/husqvarna_automower/test_device_tracker.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index ec1fb7391b4..ecb92bb39cf 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -33,6 +33,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker ADDITIONAL_NUMBER_ENTITIES = 1 ADDITIONAL_SENSOR_ENTITIES = 2 ADDITIONAL_SWITCH_ENTITIES = 1 +NUMBER_OF_ENTITIES_MOWER_2 = 11 async def test_load_unload_entry( @@ -250,7 +251,7 @@ async def test_coordinator_automatic_registry_cleanup( assert ( len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) - == current_entites - 12 + == current_entites - NUMBER_OF_ENTITIES_MOWER_2 ) assert ( len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) @@ -278,7 +279,10 @@ async def test_coordinator_automatic_registry_cleanup( 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(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) + == NUMBER_OF_ENTITIES_MOWER_2 + ) assert ( len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == current_devices - 1 diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index 044989e5cf0..c62cf6653c4 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -21,37 +21,52 @@ from .const import TEST_MOWER_ID from tests.common import MockConfigEntry, async_fire_time_changed -async def test_lawn_mower_states( - hass: HomeAssistant, - mock_automower_client: AsyncMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - values: dict[str, MowerAttributes], -) -> None: - """Test lawn_mower state.""" - await setup_integration(hass, mock_config_entry) - state = hass.states.get("lawn_mower.test_mower_1") - assert state is not None - assert state.state == LawnMowerActivity.DOCKED - - for activity, state, expected_state in ( +@pytest.mark.parametrize( + ("activity", "mower_state", "expected_state"), + [ (MowerActivities.UNKNOWN, MowerStates.PAUSED, LawnMowerActivity.PAUSED), - (MowerActivities.MOWING, MowerStates.NOT_APPLICABLE, LawnMowerActivity.MOWING), + (MowerActivities.MOWING, MowerStates.IN_OPERATION, LawnMowerActivity.MOWING), (MowerActivities.NOT_APPLICABLE, MowerStates.ERROR, LawnMowerActivity.ERROR), ( MowerActivities.GOING_HOME, MowerStates.IN_OPERATION, LawnMowerActivity.RETURNING, ), - ): - values[TEST_MOWER_ID].mower.activity = activity - values[TEST_MOWER_ID].mower.state = state - mock_automower_client.get_status.return_value = values - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - state = hass.states.get("lawn_mower.test_mower_1") - assert state.state == expected_state + ( + MowerActivities.NOT_APPLICABLE, + MowerStates.IN_OPERATION, + LawnMowerActivity.MOWING, + ), + ( + MowerActivities.PARKED_IN_CS, + MowerStates.IN_OPERATION, + LawnMowerActivity.DOCKED, + ), + ], +) +async def test_lawn_mower_states( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], + activity: MowerActivities, + mower_state: MowerStates, + expected_state: LawnMowerActivity, +) -> None: + """Test lawn_mower state.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("lawn_mower.test_mower_1") + assert state is not None + assert state.state == LawnMowerActivity.DOCKED + values[TEST_MOWER_ID].mower.activity = activity + values[TEST_MOWER_ID].mower.state = mower_state + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("lawn_mower.test_mower_1") + assert state.state == expected_state @pytest.mark.parametrize( @@ -80,9 +95,7 @@ async def test_lawn_mower_commands( mocked_method = getattr(mock_automower_client.commands, aioautomower_command) mocked_method.assert_called_once_with(TEST_MOWER_ID) - getattr( - mock_automower_client.commands, aioautomower_command - ).side_effect = ApiError("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -129,8 +142,7 @@ async def test_lawn_mower_service_commands( ) -> None: """Test lawn_mower commands.""" await setup_integration(hass, mock_config_entry) - mocked_method = AsyncMock() - setattr(mock_automower_client.commands, aioautomower_command, mocked_method) + mocked_method = getattr(mock_automower_client.commands, aioautomower_command) await hass.services.async_call( domain=DOMAIN, service=service, @@ -140,9 +152,7 @@ async def test_lawn_mower_service_commands( ) mocked_method.assert_called_once_with(TEST_MOWER_ID, extra_data) - getattr( - mock_automower_client.commands, aioautomower_command - ).side_effect = ApiError("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -183,8 +193,7 @@ async def test_lawn_mower_override_work_area_command( ) -> None: """Test lawn_mower work area override commands.""" await setup_integration(hass, mock_config_entry) - mocked_method = AsyncMock() - setattr(mock_automower_client.commands, aioautomower_command, mocked_method) + mocked_method = getattr(mock_automower_client.commands, aioautomower_command) await hass.services.async_call( domain=DOMAIN, service=service, diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index 55bf5dda7eb..227010e939d 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -7,7 +7,7 @@ from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.const import EXECUTION_TIME_DELAY from homeassistant.const import Platform @@ -68,7 +68,7 @@ async def test_number_workarea_commands( values[TEST_MOWER_ID].work_areas[123456].cutting_height = 75 mock_automower_client.get_status.return_value = values mocked_method = AsyncMock() - setattr(mock_automower_client.commands, "workarea_settings", mocked_method) + mock_automower_client.commands.workarea_settings.return_value = mocked_method await hass.services.async_call( domain="number", service="set_value", @@ -79,12 +79,12 @@ async def test_number_workarea_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, 75, 123456) + mocked_method.cutting_height.assert_called_once_with(cutting_height=75) state = hass.states.get(entity_id) assert state.state is not None assert state.state == "75" - mocked_method.side_effect = ApiError("Test error") + mocked_method.cutting_height.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -96,7 +96,7 @@ async def test_number_workarea_commands( service_data={"value": "75"}, blocking=True, ) - assert len(mocked_method.mock_calls) == 2 + assert mock_automower_client.commands.workarea_settings.call_count == 2 @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/husqvarna_automower/test_select.py b/tests/components/husqvarna_automower/test_select.py index 01e7607735b..f1b855a90a3 100644 --- a/tests/components/husqvarna_automower/test_select.py +++ b/tests/components/husqvarna_automower/test_select.py @@ -74,7 +74,7 @@ async def test_select_commands( blocking=True, ) mocked_method = mock_automower_client.commands.set_headlight_mode - mocked_method.assert_called_once_with(TEST_MOWER_ID, service.upper()) + mocked_method.assert_called_once_with(TEST_MOWER_ID, service) assert len(mocked_method.mock_calls) == 1 mocked_method.side_effect = ApiError("Test error") diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index 08ed5251344..3d4922781b4 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -7,7 +7,7 @@ import zoneinfo from aioautomower.model import MowerAttributes, MowerModes, MowerStates from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN, Platform @@ -110,6 +110,18 @@ async def test_work_area_sensor( state = hass.states.get("sensor.test_mower_1_work_area") assert state.state == "my_lawn" + # Test EPOS mower, which returns work_area_id = 0, when no + # work area is active and has no default work_area_id=0 + values[TEST_MOWER_ID].mower.work_area_id = 0 + del values[TEST_MOWER_ID].work_areas[0] + del values[TEST_MOWER_ID].work_area_dict[0] + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_mower_1_work_area") + assert state.state == "no_work_area_active" + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( diff --git a/tests/components/husqvarna_automower/test_switch.py b/tests/components/husqvarna_automower/test_switch.py index 48903a9630b..d6ca8ff36e2 100644 --- a/tests/components/husqvarna_automower/test_switch.py +++ b/tests/components/husqvarna_automower/test_switch.py @@ -9,7 +9,7 @@ from aioautomower.model import MowerAttributes, MowerModes, Zone from aioautomower.utils import mower_list_to_dictionary_dataclass from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.const import ( DOMAIN, @@ -133,8 +133,7 @@ async def test_stay_out_zone_switch_commands( ) values[TEST_MOWER_ID].stay_out_zones.zones[TEST_ZONE_ID].enabled = boolean mock_automower_client.get_status.return_value = values - mocked_method = AsyncMock() - setattr(mock_automower_client.commands, "switch_stay_out_zone", mocked_method) + mocked_method = mock_automower_client.commands.switch_stay_out_zone await hass.services.async_call( domain=SWITCH_DOMAIN, service=service, @@ -192,7 +191,7 @@ async def test_work_area_switch_commands( values[TEST_MOWER_ID].work_areas[TEST_AREA_ID].enabled = boolean mock_automower_client.get_status.return_value = values mocked_method = AsyncMock() - setattr(mock_automower_client.commands, "workarea_settings", mocked_method) + mock_automower_client.commands.workarea_settings.return_value = mocked_method await hass.services.async_call( domain=SWITCH_DOMAIN, service=service, @@ -202,12 +201,12 @@ async def test_work_area_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_AREA_ID, enabled=boolean) + mocked_method.enabled.assert_called_once_with(enabled=boolean) state = hass.states.get(entity_id) assert state is not None assert state.state == excepted_state - mocked_method.side_effect = ApiError("Test error") + mocked_method.enabled.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/igloohome/conftest.py b/tests/components/igloohome/conftest.py index d630f5af7cb..6c4eb4904ae 100644 --- a/tests/components/igloohome/conftest.py +++ b/tests/components/igloohome/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from igloohome_api import GetDeviceInfoResponse, GetDevicesResponse +from igloohome_api import GetDeviceInfoResponse, GetDevicesResponse, LinkedDevice import pytest from homeassistant.components.igloohome.const import DOMAIN @@ -23,6 +23,28 @@ GET_DEVICE_INFO_RESPONSE_LOCK = GetDeviceInfoResponse( batteryLevel=100, ) +GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK = GetDeviceInfoResponse( + id="001", + type="Bridge", + deviceId="EB1X04eeeeee", + deviceName="Home Bridge", + pairedAt="2024-11-09T12:19:25+00:00", + homeId=[], + linkedDevices=[LinkedDevice(type="Lock", deviceId="OE1X123cbb11")], + batteryLevel=None, +) + +GET_DEVICE_INFO_RESPONSE_BRIDGE_NO_LINKED_DEVICE = GetDeviceInfoResponse( + id="001", + type="Bridge", + deviceId="EB1X04eeeeee", + deviceName="Home Bridge", + pairedAt="2024-11-09T12:19:25+00:00", + homeId=[], + linkedDevices=[], + batteryLevel=None, +) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -66,7 +88,10 @@ def mock_api() -> Generator[AsyncMock]: api = api_mock.return_value api.get_devices.return_value = GetDevicesResponse( nextCursor="", - payload=[GET_DEVICE_INFO_RESPONSE_LOCK], + payload=[ + GET_DEVICE_INFO_RESPONSE_LOCK, + GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK, + ], ) api.get_device_info.return_value = GET_DEVICE_INFO_RESPONSE_LOCK yield api diff --git a/tests/components/igloohome/snapshots/test_lock.ambr b/tests/components/igloohome/snapshots/test_lock.ambr new file mode 100644 index 00000000000..5d94cf27c6b --- /dev/null +++ b/tests/components/igloohome/snapshots/test_lock.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_lock[lock.front_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.front_door', + 'has_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': 'igloohome', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lock_OE1X123cbb11', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[lock.front_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'friendly_name': 'Front Door', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.front_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/igloohome/test_lock.py b/tests/components/igloohome/test_lock.py new file mode 100644 index 00000000000..621f9995190 --- /dev/null +++ b/tests/components/igloohome/test_lock.py @@ -0,0 +1,26 @@ +"""Test lock module for igloohome integration.""" + +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 setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_lock( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test lock entity created.""" + with patch("homeassistant.components.igloohome.PLATFORMS", [Platform.LOCK]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/igloohome/test_sensor.py b/tests/components/igloohome/test_sensor.py index bfc60574450..21ea3efbf8e 100644 --- a/tests/components/igloohome/test_sensor.py +++ b/tests/components/igloohome/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/igloohome/test_utils.py b/tests/components/igloohome/test_utils.py new file mode 100644 index 00000000000..a6262076eed --- /dev/null +++ b/tests/components/igloohome/test_utils.py @@ -0,0 +1,31 @@ +"""Test functions in utils module.""" + +from homeassistant.components.igloohome.utils import get_linked_bridge + +from .conftest import ( + GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK, + GET_DEVICE_INFO_RESPONSE_BRIDGE_NO_LINKED_DEVICE, + GET_DEVICE_INFO_RESPONSE_LOCK, +) + + +def test_get_linked_bridge_expect_bridge_id_returned() -> None: + """Test that get_linked_bridge returns the bridge ID.""" + assert ( + get_linked_bridge( + GET_DEVICE_INFO_RESPONSE_LOCK.deviceId, + [GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK], + ) + == GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK.deviceId + ) + + +def test_get_linked_bridge_expect_none_returned() -> None: + """Test that get_linked_bridge returns None.""" + assert ( + get_linked_bridge( + GET_DEVICE_INFO_RESPONSE_LOCK.deviceId, + [GET_DEVICE_INFO_RESPONSE_BRIDGE_NO_LINKED_DEVICE], + ) + is None + ) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index b86855bd78f..bdd29f7442b 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -726,9 +726,10 @@ async def test_message_data( [ ("{{ subject }}", "Test subject", None), ('{{ "@example.com" in sender }}', True, None), + ('{{ "body" in text }}', True, None), ("{% bad template }}", None, "Error rendering IMAP custom template"), ], - ids=["subject_test", "sender_filter", "template_error"], + ids=["subject_test", "sender_filter", "body_filter", "template_error"], ) async def test_custom_template( hass: HomeAssistant, diff --git a/tests/components/imeon_inverter/__init__.py b/tests/components/imeon_inverter/__init__.py new file mode 100644 index 00000000000..8305be2d901 --- /dev/null +++ b/tests/components/imeon_inverter/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Imeon Inverter 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 Imeon Inverter 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/imeon_inverter/conftest.py b/tests/components/imeon_inverter/conftest.py new file mode 100644 index 00000000000..5d1dacc4e69 --- /dev/null +++ b/tests/components/imeon_inverter/conftest.py @@ -0,0 +1,86 @@ +"""Configuration for the Imeon Inverter integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.imeon_inverter.const import DOMAIN +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_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) + +from tests.common import MockConfigEntry, load_json_object_fixture, patch + +# Sample test data +TEST_USER_INPUT = { + CONF_HOST: "192.168.200.1", + CONF_USERNAME: "user@local", + CONF_PASSWORD: "password", +} + +TEST_SERIAL = "111111111111111" + +TEST_DISCOVER = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://{TEST_USER_INPUT[CONF_HOST]}:8088/imeon.xml", + upnp={ + ATTR_UPNP_MANUFACTURER: "IMEON", + ATTR_UPNP_MODEL_NAME: "IMEON", + ATTR_UPNP_FRIENDLY_NAME: f"IMEON-{TEST_SERIAL}", + ATTR_UPNP_SERIAL: TEST_SERIAL, + ATTR_UPNP_UDN: "uuid:01234567-89ab-cdef-0123-456789abcdef", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Basic:1", + }, +) + + +@pytest.fixture(autouse=True) +def mock_imeon_inverter() -> Generator[MagicMock]: + """Mock data from the device.""" + with ( + patch( + "homeassistant.components.imeon_inverter.coordinator.Inverter", + autospec=True, + ) as inverter_mock, + patch( + "homeassistant.components.imeon_inverter.config_flow.Inverter", + new=inverter_mock, + ), + ): + inverter = inverter_mock.return_value + inverter.__aenter__.return_value = inverter + inverter.login.return_value = True + inverter.get_serial.return_value = TEST_SERIAL + inverter.inverter.get.return_value = {"inverter": "blah", "software": "1.0"} + inverter.storage = load_json_object_fixture("sensor_data.json", DOMAIN) + yield inverter + + +@pytest.fixture +def mock_async_setup_entry() -> Generator[AsyncMock]: + """Fixture for mocking async_setup_entry.""" + with patch( + "homeassistant.components.imeon_inverter.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="Imeon inverter", + domain=DOMAIN, + data=TEST_USER_INPUT, + unique_id=TEST_SERIAL, + ) diff --git a/tests/components/imeon_inverter/fixtures/sensor_data.json b/tests/components/imeon_inverter/fixtures/sensor_data.json new file mode 100644 index 00000000000..566716fe3fa --- /dev/null +++ b/tests/components/imeon_inverter/fixtures/sensor_data.json @@ -0,0 +1,73 @@ +{ + "battery": { + "autonomy": 4.5, + "charge_time": 120, + "power": 2500.0, + "soc": 78.0, + "stored": 10.2 + }, + "grid": { + "current_l1": 12.5, + "current_l2": 10.8, + "current_l3": 11.2, + "frequency": 50.0, + "voltage_l1": 230.0, + "voltage_l2": 229.5, + "voltage_l3": 230.1 + }, + "input": { + "power_l1": 1000.0, + "power_l2": 950.0, + "power_l3": 980.0, + "power_total": 2930.0 + }, + "inverter": { + "charging_current_limit": 50, + "injection_power_limit": 5000.0 + }, + "meter": { + "power": 2000.0, + "power_protocol": 2018.0 + }, + "output": { + "current_l1": 15.0, + "current_l2": 14.5, + "current_l3": 15.2, + "frequency": 49.9, + "power_l1": 1100.0, + "power_l2": 1080.0, + "power_l3": 1120.0, + "power_total": 3300.0, + "voltage_l1": 231.0, + "voltage_l2": 229.8, + "voltage_l3": 230.2 + }, + "pv": { + "consumed": 1500.0, + "injected": 800.0, + "power_1": 1200.0, + "power_2": 1300.0, + "power_total": 2500.0 + }, + "temp": { + "air_temperature": 25.0, + "component_temperature": 45.5 + }, + "monitoring": { + "building_consumption": 3000.0, + "economy_factor": 0.8, + "grid_consumption": 500.0, + "grid_injection": 700.0, + "grid_power_flow": -200.0, + "self_consumption": 85.0, + "self_sufficiency": 90.0, + "solar_production": 2600.0 + }, + "monitoring_minute": { + "building_consumption": 50.0, + "grid_consumption": 8.3, + "grid_injection": 11.7, + "grid_power_flow": -3.4, + "solar_production": 43.3 + } +} diff --git a/tests/components/imeon_inverter/snapshots/test_sensor.ambr b/tests/components/imeon_inverter/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..38f50df5407 --- /dev/null +++ b/tests/components/imeon_inverter/snapshots/test_sensor.ambr @@ -0,0 +1,2689 @@ +# serializer version: 1 +# name: test_sensors[sensor.imeon_inverter_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_air_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': 'Air temperature', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temp_air_temperature', + 'unique_id': '111111111111111_temp_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Imeon inverter Air temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_autonomy', + '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 autonomy', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': '111111111111111_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Imeon inverter Battery autonomy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_charge_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_charge_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': 'Battery charge time', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_charge_time', + 'unique_id': '111111111111111_battery_charge_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_charge_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Imeon inverter Battery charge time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_charge_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_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': 'Battery power', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_power', + 'unique_id': '111111111111111_battery_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Battery power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2500.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_state_of_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_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': 'Battery state of charge', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_soc', + 'unique_id': '111111111111111_battery_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Imeon inverter Battery state of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '78.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_stored-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_battery_stored', + '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 stored', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_stored', + 'unique_id': '111111111111111_battery_stored', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_battery_stored-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Imeon inverter Battery stored', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_battery_stored', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_charging_current_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_charging_current_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': 'Charging current limit', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_charging_current_limit', + 'unique_id': '111111111111111_inverter_charging_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_charging_current_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Charging current limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_charging_current_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_component_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_component_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': 'Component temperature', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temp_component_temperature', + 'unique_id': '111111111111111_temp_component_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_component_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Imeon inverter Component temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_component_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_current_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': 'Grid current L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_current_l1', + 'unique_id': '111111111111111_grid_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Grid current L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_current_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': 'Grid current L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_current_l2', + 'unique_id': '111111111111111_grid_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Grid current L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.8', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_current_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': 'Grid current L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_current_l3', + 'unique_id': '111111111111111_grid_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Grid current L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_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': 'Grid frequency', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_frequency', + 'unique_id': '111111111111111_grid_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Imeon inverter Grid frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_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': 'Grid voltage L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_voltage_l1', + 'unique_id': '111111111111111_grid_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Grid voltage L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_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': 'Grid voltage L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_voltage_l2', + 'unique_id': '111111111111111_grid_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Grid voltage L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_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': 'Grid voltage L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_voltage_l3', + 'unique_id': '111111111111111_grid_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_grid_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Grid voltage L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_grid_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.1', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_injection_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_injection_power_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': 'Injection power limit', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_injection_power_limit', + 'unique_id': '111111111111111_inverter_injection_power_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_injection_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Injection power limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_injection_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_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': 'Input power L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_l1', + 'unique_id': '111111111111111_input_power_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_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': 'Input power L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_l2', + 'unique_id': '111111111111111_input_power_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '950.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_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': 'Input power L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_l3', + 'unique_id': '111111111111111_input_power_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '980.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_input_power_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input power total', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'input_power_total', + 'unique_id': '111111111111111_input_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_input_power_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Input power total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_input_power_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2930.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_meter_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': 'Meter power', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_power', + 'unique_id': '111111111111111_meter_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Meter power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_meter_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', + '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 power protocol', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_power_protocol', + 'unique_id': '111111111111111_meter_power_protocol', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Meter power protocol', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2018.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', + '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': 'Monitoring building consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_building_consumption', + 'unique_id': '111111111111111_monitoring_building_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring building consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3000.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption_minute', + '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': 'Monitoring building consumption (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_building_consumption', + 'unique_id': '111111111111111_monitoring_minute_building_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring building consumption (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', + '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': 'Monitoring economy factor', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_economy_factor', + 'unique_id': '111111111111111_monitoring_economy_factor', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Monitoring economy factor', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.8', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', + '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': 'Monitoring grid consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_grid_consumption', + 'unique_id': '111111111111111_monitoring_grid_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring grid consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '500.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption_minute', + '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': 'Monitoring grid consumption (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_consumption', + 'unique_id': '111111111111111_monitoring_minute_grid_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring grid consumption (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.3', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', + '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': 'Monitoring grid injection', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_grid_injection', + 'unique_id': '111111111111111_monitoring_grid_injection', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring grid injection', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '700.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection_minute', + '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': 'Monitoring grid injection (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_injection', + 'unique_id': '111111111111111_monitoring_minute_grid_injection', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring grid injection (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.7', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', + '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': 'Monitoring grid power flow', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_grid_power_flow', + 'unique_id': '111111111111111_monitoring_grid_power_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring grid power flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-200.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow_minute', + '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': 'Monitoring grid power flow (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_grid_power_flow', + 'unique_id': '111111111111111_monitoring_minute_grid_power_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring grid power flow (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-3.4', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_consumption', + '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': 'Monitoring self-consumption', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_self_consumption', + 'unique_id': '111111111111111_monitoring_self_consumption', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Monitoring self-consumption', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_sufficiency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_sufficiency', + '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': 'Monitoring self-sufficiency', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_self_sufficiency', + 'unique_id': '111111111111111_monitoring_self_sufficiency', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_self_sufficiency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Imeon inverter Monitoring self-sufficiency', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_self_sufficiency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', + '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': 'Monitoring solar production', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_solar_production', + 'unique_id': '111111111111111_monitoring_solar_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter Monitoring solar production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2600.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production_minute', + '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': 'Monitoring solar production (minute)', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monitoring_minute_solar_production', + 'unique_id': '111111111111111_monitoring_minute_solar_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Monitoring solar production (minute)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.3', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_current_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': 'Output current L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_current_l1', + 'unique_id': '111111111111111_output_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Output current L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_current_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': 'Output current L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_current_l2', + 'unique_id': '111111111111111_output_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Output current L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.5', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_current_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': 'Output current L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_current_l3', + 'unique_id': '111111111111111_output_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Imeon inverter Output current L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_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': 'Output frequency', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_frequency', + 'unique_id': '111111111111111_output_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Imeon inverter Output frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_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': 'Output power L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_l1', + 'unique_id': '111111111111111_output_power_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1100.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_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': 'Output power L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_l2', + 'unique_id': '111111111111111_output_power_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1080.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_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': 'Output power L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_l3', + 'unique_id': '111111111111111_output_power_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1120.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_power_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output power total', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_power_total', + 'unique_id': '111111111111111_output_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_power_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter Output power total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_power_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3300.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_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': 'Output voltage L1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_voltage_l1', + 'unique_id': '111111111111111_output_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Output voltage L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_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': 'Output voltage L2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_voltage_l2', + 'unique_id': '111111111111111_output_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Output voltage L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.8', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_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': 'Output voltage L3', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_voltage_l3', + 'unique_id': '111111111111111_output_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_output_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Imeon inverter Output voltage L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_output_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.2', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_consumed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_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': 'PV consumed', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_consumed', + 'unique_id': '111111111111111_pv_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_consumed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter PV consumed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_consumed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_injected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_injected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV injected', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_injected', + 'unique_id': '111111111111111_pv_injected', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_injected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Imeon inverter PV injected', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_injected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '800.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_power_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': 'PV power 1', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_power_1', + 'unique_id': '111111111111111_pv_power_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter PV power 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_power_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1200.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_power_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': 'PV power 2', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_power_2', + 'unique_id': '111111111111111_pv_power_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter PV power 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_power_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1300.0', + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.imeon_inverter_pv_power_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV power total', + 'platform': 'imeon_inverter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pv_power_total', + 'unique_id': '111111111111111_pv_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.imeon_inverter_pv_power_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Imeon inverter PV power total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.imeon_inverter_pv_power_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2500.0', + }) +# --- diff --git a/tests/components/imeon_inverter/test_config_flow.py b/tests/components/imeon_inverter/test_config_flow.py new file mode 100644 index 00000000000..9ebcf3ec80f --- /dev/null +++ b/tests/components/imeon_inverter/test_config_flow.py @@ -0,0 +1,205 @@ +"""Test the Imeon Inverter config flow.""" + +from copy import deepcopy +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.imeon_inverter.const import DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +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_SERIAL + +from .conftest import TEST_DISCOVER, TEST_SERIAL, TEST_USER_INPUT + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_async_setup_entry") + + +async def test_form_valid( + hass: HomeAssistant, + mock_async_setup_entry: AsyncMock, +) -> None: + """Test we get the form and the config is created with the good entries.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Imeon {TEST_SERIAL}" + assert result["data"] == TEST_USER_INPUT + assert result["result"].unique_id == TEST_SERIAL + assert mock_async_setup_entry.call_count == 1 + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_imeon_inverter: MagicMock +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + mock_imeon_inverter.login.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + mock_imeon_inverter.login.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (TimeoutError, "cannot_connect"), + (ValueError("Host invalid"), "invalid_host"), + (ValueError("Route invalid"), "invalid_route"), + (ValueError, "unknown"), + ], +) +async def test_form_exception( + hass: HomeAssistant, + mock_imeon_inverter: MagicMock, + error: Exception, + expected: str, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + mock_imeon_inverter.login.side_effect = error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected} + + mock_imeon_inverter.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_manual_setup_already_exists( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a flow with an existing id aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_get_serial_timeout( + hass: HomeAssistant, mock_imeon_inverter: MagicMock +) -> None: + """Test the timeout error handling of getting the serial number.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + mock_imeon_inverter.get_serial.side_effect = TimeoutError + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_imeon_inverter.get_serial.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_ssdp(hass: HomeAssistant) -> None: + """Test a ssdp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=TEST_DISCOVER, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = TEST_USER_INPUT.copy() + user_input.pop(CONF_HOST) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Imeon {TEST_SERIAL}" + assert result["data"] == TEST_USER_INPUT + + +async def test_ssdp_already_exist( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a ssdp discovery flow with an existing id aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=TEST_DISCOVER, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp_abort(hass: HomeAssistant) -> None: + """Test that a ssdp discovery aborts if serial is unknown.""" + data = deepcopy(TEST_DISCOVER) + data.upnp.pop(ATTR_UPNP_SERIAL, None) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=data, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/imeon_inverter/test_sensor.py b/tests/components/imeon_inverter/test_sensor.py new file mode 100644 index 00000000000..19e912c1c5c --- /dev/null +++ b/tests/components/imeon_inverter/test_sensor.py @@ -0,0 +1,29 @@ +"""Test the Imeon Inverter sensors.""" + +from unittest.mock import MagicMock, 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 setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_imeon_inverter: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Imeon Inverter sensors.""" + with patch( + "homeassistant.components.imeon_inverter.const.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/imgw_pib/test_diagnostics.py b/tests/components/imgw_pib/test_diagnostics.py index 14d4e7a5224..2b2568050f3 100644 --- a/tests/components/imgw_pib/test_diagnostics.py +++ b/tests/components/imgw_pib/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/imgw_pib/test_sensor.py b/tests/components/imgw_pib/test_sensor.py index a1920f38006..cb27f0f9b46 100644 --- a/tests/components/imgw_pib/test_sensor.py +++ b/tests/components/imgw_pib/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from imgw_pib import ApiError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.imgw_pib.const import DOMAIN, UPDATE_INTERVAL from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM diff --git a/tests/components/immich/__init__.py b/tests/components/immich/__init__.py new file mode 100644 index 00000000000..604ab84d68d --- /dev/null +++ b/tests/components/immich/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Immich 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/immich/conftest.py b/tests/components/immich/conftest.py new file mode 100644 index 00000000000..2c9483c3955 --- /dev/null +++ b/tests/components/immich/conftest.py @@ -0,0 +1,136 @@ +"""Common fixtures for the Immich tests.""" + +from collections.abc import AsyncGenerator, Generator +from datetime import datetime +from unittest.mock import AsyncMock, patch + +from aioimmich import ImmichServer, ImmichUsers +from aioimmich.server.models import ( + ImmichServerAbout, + ImmichServerStatistics, + ImmichServerStorage, +) +from aioimmich.users.models import AvatarColor, ImmichUser, UserStatus +import pytest + +from homeassistant.components.immich.const import DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_VERIFY_SSL, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.immich.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, + data={ + CONF_HOST: "localhost", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "api_key", + CONF_VERIFY_SSL: True, + }, + unique_id="e7ef5713-9dab-4bd4-b899-715b0ca4379e", + title="Someone", + ) + + +@pytest.fixture +def mock_immich_server() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichServer) + mock.async_get_about_info.return_value = ImmichServerAbout( + "v1.132.3", + "some_url", + False, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) + mock.async_get_storage_info.return_value = ImmichServerStorage( + "294.2 GiB", + "142.9 GiB", + "136.3 GiB", + 315926315008, + 153400434688, + 146402975744, + 48.56, + ) + mock.async_get_server_statistics.return_value = ImmichServerStatistics( + 27038, 1836, 119525451912, 54291170551, 65234281361 + ) + return mock + + +@pytest.fixture +def mock_immich_user() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichUsers) + mock.async_get_my_user.return_value = ImmichUser( + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "user@immich.local", + "user", + "", + AvatarColor.PRIMARY, + datetime.fromisoformat("2025-05-11T10:07:46.866Z"), + "user", + False, + True, + datetime.fromisoformat("2025-05-11T10:07:46.866Z"), + None, + None, + "", + None, + None, + UserStatus.ACTIVE, + ) + return mock + + +@pytest.fixture +async def mock_immich( + mock_immich_server: AsyncMock, mock_immich_user: AsyncMock +) -> AsyncGenerator[AsyncMock]: + """Mock the Immich API.""" + with ( + patch("homeassistant.components.immich.Immich", autospec=True) as mock_immich, + patch("homeassistant.components.immich.config_flow.Immich", new=mock_immich), + ): + client = mock_immich.return_value + client.server = mock_immich_server + client.users = mock_immich_user + yield client + + +@pytest.fixture +async def mock_non_admin_immich(mock_immich: AsyncMock) -> AsyncMock: + """Mock the Immich API.""" + mock_immich.users.async_get_my_user.return_value.is_admin = False + return mock_immich diff --git a/tests/components/immich/const.py b/tests/components/immich/const.py new file mode 100644 index 00000000000..2779a02be55 --- /dev/null +++ b/tests/components/immich/const.py @@ -0,0 +1,24 @@ +"""Constants for the Immich integration tests.""" + +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_VERIFY_SSL, +) + +MOCK_USER_DATA = { + CONF_URL: "http://localhost", + CONF_API_KEY: "abcdef0123456789", + CONF_VERIFY_SSL: False, +} + +MOCK_CONFIG_ENTRY_DATA = { + CONF_HOST: "localhost", + CONF_API_KEY: "abcdef0123456789", + CONF_PORT: 80, + CONF_SSL: False, + CONF_VERIFY_SSL: False, +} diff --git a/tests/components/immich/snapshots/test_diagnostics.ambr b/tests/components/immich/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..3216de2fabd --- /dev/null +++ b/tests/components/immich/snapshots/test_diagnostics.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'server_about': dict({ + 'build': None, + 'build_image': None, + 'build_image_url': None, + 'build_url': None, + 'exiftool': None, + 'ffmpeg': None, + 'imagemagick': None, + 'libvips': None, + 'licensed': False, + 'nodejs': None, + 'repository': None, + 'repository_url': None, + 'source_commit': None, + 'source_ref': None, + 'source_url': None, + 'version': 'v1.132.3', + 'version_url': 'some_url', + }), + 'server_storage': dict({ + 'disk_available': '136.3 GiB', + 'disk_available_raw': 146402975744, + 'disk_size': '294.2 GiB', + 'disk_size_raw': 315926315008, + 'disk_usage_percentage': 48.56, + 'disk_use': '142.9 GiB', + 'disk_use_raw': 153400434688, + }), + 'server_usage': dict({ + 'photos': 27038, + 'usage': 119525451912, + 'usage_photos': 54291170551, + 'usage_videos': 65234281361, + 'videos': 1836, + }), + }), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'host': '**REDACTED**', + 'port': 80, + 'ssl': False, + 'verify_ssl': True, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'immich', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Someone', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/immich/snapshots/test_sensor.ambr b/tests/components/immich/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..7284f98f681 --- /dev/null +++ b/tests/components/immich/snapshots/test_sensor.ambr @@ -0,0 +1,444 @@ +# serializer version: 1 +# name: test_sensors[sensor.someone_disk_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.someone_disk_available', + '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': 'Disk available', + 'platform': 'immich', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_available', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_available', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.someone_disk_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Someone Disk available', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.someone_disk_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '136.34839630127', + }) +# --- +# name: test_sensors[sensor.someone_disk_size-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.someone_disk_size', + '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': 'Disk size', + 'platform': 'immich', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_size', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.someone_disk_size-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Someone Disk size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.someone_disk_size', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '294.229309082031', + }) +# --- +# name: test_sensors[sensor.someone_disk_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.someone_disk_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': 'Disk usage', + 'platform': 'immich', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_usage', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.someone_disk_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Someone Disk usage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.someone_disk_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.56', + }) +# --- +# name: test_sensors[sensor.someone_disk_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.someone_disk_used', + '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': 'Disk used', + 'platform': 'immich', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_use', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.someone_disk_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Someone Disk used', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.someone_disk_used', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '142.865287780762', + }) +# --- +# name: test_sensors[sensor.someone_disk_used_by_photos-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.someone_disk_used_by_photos', + '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': 'Disk used by photos', + 'platform': 'immich', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'usage_by_photos', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_usage_by_photos', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.someone_disk_used_by_photos-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Someone Disk used by photos', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.someone_disk_used_by_photos', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.5625927364454', + }) +# --- +# name: test_sensors[sensor.someone_disk_used_by_videos-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.someone_disk_used_by_videos', + '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': 'Disk used by videos', + 'platform': 'immich', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'usage_by_videos', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_usage_by_videos', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.someone_disk_used_by_videos-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Someone Disk used by videos', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.someone_disk_used_by_videos', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.754158870317', + }) +# --- +# name: test_sensors[sensor.someone_photos_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.someone_photos_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Photos count', + 'platform': 'immich', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'photos_count', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_photos_count', + 'unit_of_measurement': 'photos', + }) +# --- +# name: test_sensors[sensor.someone_photos_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Someone Photos count', + 'state_class': , + 'unit_of_measurement': 'photos', + }), + 'context': , + 'entity_id': 'sensor.someone_photos_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27038', + }) +# --- +# name: test_sensors[sensor.someone_videos_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.someone_videos_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Videos count', + 'platform': 'immich', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'videos_count', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_videos_count', + 'unit_of_measurement': 'videos', + }) +# --- +# name: test_sensors[sensor.someone_videos_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Someone Videos count', + 'state_class': , + 'unit_of_measurement': 'videos', + }), + 'context': , + 'entity_id': 'sensor.someone_videos_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1836', + }) +# --- diff --git a/tests/components/immich/test_config_flow.py b/tests/components/immich/test_config_flow.py new file mode 100644 index 00000000000..e26cb4df5a1 --- /dev/null +++ b/tests/components/immich/test_config_flow.py @@ -0,0 +1,244 @@ +"""Test the Immich config flow.""" + +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientError +from aioimmich.exceptions import ImmichUnauthorizedError +import pytest + +from homeassistant.components.immich.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_CONFIG_ENTRY_DATA, MOCK_USER_DATA + +from tests.common import MockConfigEntry + + +async def test_step_user( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_immich: Mock +) -> None: + """Test a user initiated config 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"], + MOCK_USER_DATA, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user" + assert result["data"] == MOCK_CONFIG_ENTRY_DATA + assert result["result"].unique_id == "e7ef5713-9dab-4bd4-b899-715b0ca4379e" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + ImmichUnauthorizedError( + { + "message": "Invalid API key", + "error": "Unauthenticated", + "statusCode": 401, + "correlationId": "abcdefg", + } + ), + "invalid_auth", + ), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_step_user_error_handling( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_immich: Mock, + exception: Exception, + error: str, +) -> None: + """Test a user initiated config flow with errors.""" + 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_immich.users.async_get_my_user.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_immich.users.async_get_my_user.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_step_user_invalid_url( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_immich: Mock +) -> None: + """Test a user initiated config flow with errors.""" + 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"], + {**MOCK_USER_DATA, CONF_URL: "hts://invalid"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {CONF_URL: "invalid_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_already_configured( + hass: HomeAssistant, mock_immich: Mock, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by user 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 + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthentication 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"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "other_fake_api_key" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + ImmichUnauthorizedError( + { + "message": "Invalid API key", + "error": "Unauthenticated", + "statusCode": 401, + "correlationId": "abcdefg", + } + ), + "invalid_auth", + ), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_error_handling( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauthentication flow with errors.""" + 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_immich.users.async_get_my_user.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_immich.users.async_get_my_user.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "other_fake_api_key" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow_mismatch( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthentication flow with mis-matching unique 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" + + mock_immich.users.async_get_my_user.return_value.user_id = "other_user_id" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/immich/test_diagnostics.py b/tests/components/immich/test_diagnostics.py new file mode 100644 index 00000000000..67b4bfa01d8 --- /dev/null +++ b/tests/components/immich/test_diagnostics.py @@ -0,0 +1,33 @@ +"""Tests for the Immich integration.""" + +from __future__ import annotations + +from unittest.mock import Mock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +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, + snapshot: SnapshotAssertion, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> 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(exclude=props("created_at", "modified_at", "entry_id")) diff --git a/tests/components/immich/test_sensor.py b/tests/components/immich/test_sensor.py new file mode 100644 index 00000000000..510999f584e --- /dev/null +++ b/tests/components/immich/test_sensor.py @@ -0,0 +1,45 @@ +"""Test the Immich sensor platform.""" + +from unittest.mock import Mock, patch + +import pytest +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 setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Immich sensor platform.""" + + with patch("homeassistant.components.immich.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_admin_sensors( + hass: HomeAssistant, + mock_non_admin_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the integration doesn't create admin sensors if not admin.""" + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.mock_title_photos_count") is None + assert hass.states.get("sensor.mock_title_videos_count") is None + assert hass.states.get("sensor.mock_title_disk_used_by_photos") is None + assert hass.states.get("sensor.mock_title_disk_used_by_videos") is None diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 4536c64349c..9d883502d28 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -10,6 +10,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.bluetooth import BluetoothChange from homeassistant.components.improv_ble.const import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType @@ -21,6 +22,8 @@ from . import ( PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, ) +from tests.common import MockConfigEntry + IMPROV_BLE = "homeassistant.components.improv_ble" @@ -118,6 +121,32 @@ async def test_async_step_user_takes_precedence_over_discovery( assert not hass.config_entries.flow.async_progress(DOMAIN) +async def test_user_setup_removes_ignored_entry(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + ignored_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=IMPROV_BLE_DISCOVERY_INFO.address, + source=SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", + return_value=[NOT_IMPROV_BLE_DISCOVERY_INFO, IMPROV_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"] == {} + + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + # Check the ignored entry is removed + assert not hass.config_entries.async_entries(DOMAIN) + + async def test_bluetooth_step_provisioned_device(hass: HomeAssistant) -> None: """Test bluetooth step when device is already provisioned.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index df3fe3f710b..d435bac81eb 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -17,7 +17,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -84,7 +84,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -151,7 +151,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -218,7 +218,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', '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 e90cc3ac391..e0716324de7 100644 --- a/tests/components/incomfort/test_binary_sensor.py +++ b/tests/components/incomfort/test_binary_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch from incomfortclient import FaultCode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/tests/components/incomfort/test_climate.py b/tests/components/incomfort/test_climate.py index dbcf14e3bd7..a4c97d88e34 100644 --- a/tests/components/incomfort/test_climate.py +++ b/tests/components/incomfort/test_climate.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import climate from homeassistant.components.incomfort.coordinator import InComfortData diff --git a/tests/components/incomfort/test_sensor.py b/tests/components/incomfort/test_sensor.py index df0db39a56c..78e7a52362b 100644 --- a/tests/components/incomfort/test_sensor.py +++ b/tests/components/incomfort/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/tests/components/incomfort/test_water_heater.py b/tests/components/incomfort/test_water_heater.py index 082aecf6d49..35edb134ac9 100644 --- a/tests/components/incomfort/test_water_heater.py +++ b/tests/components/incomfort/test_water_heater.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 01ae0bf8efc..7228f64448b 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -1,8 +1,44 @@ """Tests for the INKBIRD integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_INKBIRD_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice + +from homeassistant.components.bluetooth import MONOTONIC_TIME, BluetoothServiceInfoBleak + + +def _make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, +) -> BluetoothServiceInfoBleak: + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=MONOTONIC_TIME(), + advertisement=None, + connectable=True, + tx_power=tx_power, + ) + + +NOT_INKBIRD_SERVICE_INFO = _make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -12,7 +48,7 @@ NOT_INKBIRD_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -SPS_SERVICE_INFO = BluetoothServiceInfo( +SPS_SERVICE_INFO = _make_bluetooth_service_info( name="sps", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -22,7 +58,19 @@ SPS_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( + +SPS_PASSIVE_SERVICE_INFO = _make_bluetooth_service_info( + name="sps", + address="AA:BB:CC:DD:EE:FF", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + source="local", +) + + +SPS_WITH_CORRUPT_NAME_SERVICE_INFO = _make_bluetooth_service_info( name="XXXXcorruptXXXX", address="AA:BB:CC:DD:EE:FF", rssi=-63, @@ -33,7 +81,7 @@ SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( ) -IBBQ_SERVICE_INFO = BluetoothServiceInfo( +IBBQ_SERVICE_INFO = _make_bluetooth_service_info( name="iBBQ", address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", rssi=-56, @@ -44,3 +92,24 @@ IBBQ_SERVICE_INFO = BluetoothServiceInfo( service_data={}, source="local", ) + + +IAM_T1_SERVICE_INFO = _make_bluetooth_service_info( + name="Ink@IAM-T1", + manufacturer_data={12628: b"AC-6200a13cae\x00\x00"}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + address="62:00:A1:3C:AE:7B", + rssi=-44, + service_data={}, + source="local", +) + +IBS_P02B_SERVICE_INFO = _make_bluetooth_service_info( + name="IBS-P02B", + manufacturer_data={9289: bytes.fromhex("111800656e0100005f00000100000000")}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + address="49:24:11:18:00:65", + rssi=-60, + service_data={}, + source="local", +) diff --git a/tests/components/inkbird/test_config_flow.py b/tests/components/inkbird/test_config_flow.py index 796f57da55b..419bc742479 100644 --- a/tests/components/inkbird/test_config_flow.py +++ b/tests/components/inkbird/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.inkbird.const import DOMAIN +from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -27,7 +27,7 @@ async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "iBBQ AC3D" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "iBBQ-4"} assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" @@ -71,7 +71,7 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "IBS-TH 8105" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"} assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" @@ -101,7 +101,7 @@ async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "IBS-TH 8105" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"} assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" @@ -220,7 +220,7 @@ async def test_async_step_user_takes_precedence_over_discovery( ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "IBS-TH 8105" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"} assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" # Verify the original one was aborted diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 0f3d6497c2b..2a95714df4b 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -1,16 +1,74 @@ """Test the INKBIRD config flow.""" -from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from inkbird_ble import ( + DeviceKey, + INKBIRDBluetoothDeviceData, + SensorDescription, + SensorDeviceInfo, + SensorUpdate, + SensorValue, + Units, +) +from inkbird_ble.parser import Model +from sensor_state_data import SensorDeviceClass + +from homeassistant.components.inkbird.const import ( + CONF_DEVICE_DATA, + CONF_DEVICE_TYPE, + DOMAIN, +) +from homeassistant.components.inkbird.coordinator import FALLBACK_POLL_INTERVAL from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import SPS_SERVICE_INFO, SPS_WITH_CORRUPT_NAME_SERVICE_INFO +from . import ( + IAM_T1_SERVICE_INFO, + IBS_P02B_SERVICE_INFO, + SPS_PASSIVE_SERVICE_INFO, + SPS_SERVICE_INFO, + SPS_WITH_CORRUPT_NAME_SERVICE_INFO, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import inject_bluetooth_service_info +def _make_sensor_update(name: str, humidity: float) -> SensorUpdate: + return SensorUpdate( + title=None, + devices={ + None: SensorDeviceInfo( + name=f"{name} EEFF", + model=name, + manufacturer="INKBIRD", + sw_version=None, + hw_version=None, + ) + }, + entity_descriptions={ + DeviceKey(key="humidity", device_id=None): SensorDescription( + device_key=DeviceKey(key="humidity", device_id=None), + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=Units.PERCENTAGE, + ), + }, + entity_values={ + DeviceKey(key="humidity", device_id=None): SensorValue( + device_key=DeviceKey(key="humidity", device_id=None), + name="Humidity", + native_value=humidity, + ), + }, + ) + + async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( @@ -68,3 +126,171 @@ async def test_device_with_corrupt_name(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_polling_sensor(hass: HomeAssistant) -> None: + """Test setting up a device that needs polling.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AA:BB:CC:DD:EE:FF", + data={CONF_DEVICE_TYPE: "IBS-TH"}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + with patch( + "homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update("IBS-TH", 10.24), + ): + inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "10.24" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-TH EEFF Humidity" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" + + with patch( + "homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update("IBS-TH", 20.24), + ): + async_fire_time_changed(hass, dt_util.utcnow() + FALLBACK_POLL_INTERVAL) + inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "20.24" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_notify_sensor_no_advertisement(hass: HomeAssistant) -> None: + """Test setting up a notify sensor that has no advertisement.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="62:00:A1:3C:AE:7B", + data={CONF_DEVICE_TYPE: "IAM-T1"}, + ) + entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_notify_sensor(hass: HomeAssistant) -> None: + """Test setting up a notify sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="62:00:A1:3C:AE:7B", + data={CONF_DEVICE_TYPE: "IAM-T1"}, + ) + entry.add_to_hass(hass) + inject_bluetooth_service_info(hass, IAM_T1_SERVICE_INFO) + saved_update_callback = None + saved_device_data_changed_callback = None + + class MockINKBIRDBluetoothDeviceData(INKBIRDBluetoothDeviceData): + def __init__( + self, + device_type: Model | str | None = None, + device_data: dict[str, Any] | None = None, + update_callback: Callable[[SensorUpdate], None] | None = None, + device_data_changed_callback: Callable[[dict[str, Any]], None] + | None = None, + ) -> None: + nonlocal saved_update_callback + nonlocal saved_device_data_changed_callback + saved_update_callback = update_callback + saved_device_data_changed_callback = device_data_changed_callback + super().__init__( + device_type=device_type, + device_data=device_data, + update_callback=update_callback, + device_data_changed_callback=device_data_changed_callback, + ) + + mock_client = MagicMock(start_notify=AsyncMock(), disconnect=AsyncMock()) + with ( + patch( + "homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData", + MockINKBIRDBluetoothDeviceData, + ), + patch("inkbird_ble.parser.establish_connection", return_value=mock_client), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert len(hass.states.async_all()) == 0 + + saved_update_callback(_make_sensor_update("IAM-T1", 10.24)) + + assert len(hass.states.async_all()) == 1 + + temp_sensor = hass.states.get("sensor.iam_t1_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "10.24" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IAM-T1 EEFF Humidity" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + assert entry.data[CONF_DEVICE_TYPE] == "IAM-T1" + + saved_device_data_changed_callback({"temp_unit": "F"}) + assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "F"} + + saved_device_data_changed_callback({"temp_unit": "C"}) + assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "C"} + + saved_device_data_changed_callback({"temp_unit": "C"}) + assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "C"} + + +async def test_ibs_p02b_sensors(hass: HomeAssistant) -> None: + """Test setting up creates the sensors for an IBS-P02B.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="49:24:11:18:00:65", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info(hass, IBS_P02B_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 2 + + temp_sensor = hass.states.get("sensor.ibs_p02b_0065_battery") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "95" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-P02B 0065 Battery" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + temp_sensor = hass.states.get("sensor.ibs_p02b_0065_temperature") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "36.6" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-P02B 0065 Temperature" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + # Make sure we remember the device type + # in case the name is corrupted later + assert entry.data[CONF_DEVICE_TYPE] == "IBS-P02B" + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/integration/test_config_flow.py b/tests/components/integration/test_config_flow.py index f8387d85174..37b0760dc03 100644 --- a/tests/components/integration/test_config_flow.py +++ b/tests/components/integration/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import selector -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -67,17 +67,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: assert config_entry.title == "My integration" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize("platform", ["sensor"]) async def test_options(hass: HomeAssistant, platform) -> None: """Test reconfiguring.""" @@ -108,7 +97,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "round") == 1.0 + assert get_schema_suggested_value(schema, "round") == 1.0 source = schema["source"] assert isinstance(source, selector.EntitySelector) diff --git a/tests/components/intellifire/snapshots/test_binary_sensor.ambr b/tests/components/intellifire/snapshots/test_binary_sensor.ambr index afa3c1fa8a9..c2ed8ff17b0 100644 --- a/tests/components/intellifire/snapshots/test_binary_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_binary_sensor.ambr @@ -366,7 +366,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Flame Error', + 'original_name': 'Flame error', 'platform': 'intellifire', 'previous_unique_id': None, 'supported_features': 0, @@ -380,7 +380,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by unpublished Intellifire API', 'device_class': 'problem', - 'friendly_name': 'IntelliFire Flame Error', + 'friendly_name': 'IntelliFire Flame error', }), 'context': , 'entity_id': 'binary_sensor.intellifire_flame_error', diff --git a/tests/components/intellifire/snapshots/test_sensor.ambr b/tests/components/intellifire/snapshots/test_sensor.ambr index 548c8d5a8aa..3826b75a417 100644 --- a/tests/components/intellifire/snapshots/test_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_sensor.ambr @@ -171,7 +171,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Fan Speed', + 'original_name': 'Fan speed', 'platform': 'intellifire', 'previous_unique_id': None, 'supported_features': 0, @@ -184,7 +184,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by unpublished Intellifire API', - 'friendly_name': 'IntelliFire Fan Speed', + 'friendly_name': 'IntelliFire Fan speed', 'state_class': , }), 'context': , diff --git a/tests/components/intellifire/test_binary_sensor.py b/tests/components/intellifire/test_binary_sensor.py index a40f92b84d5..d8bce78263d 100644 --- a/tests/components/intellifire/test_binary_sensor.py +++ b/tests/components/intellifire/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/intellifire/test_climate.py b/tests/components/intellifire/test_climate.py index da1b2864791..6b4ad01f9d6 100644 --- a/tests/components/intellifire/test_climate.py +++ b/tests/components/intellifire/test_climate.py @@ -4,7 +4,7 @@ from unittest.mock import patch from freezegun import freeze_time import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/intellifire/test_sensor.py b/tests/components/intellifire/test_sensor.py index 96e344d77fc..9b5d25c679a 100644 --- a/tests/components/intellifire/test_sensor.py +++ b/tests/components/intellifire/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from freezegun import freeze_time import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 0db9682d0ad..3779930e360 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -2,8 +2,10 @@ import pytest -from homeassistant.components.cover import SERVICE_OPEN_COVER -from homeassistant.components.lock import SERVICE_LOCK +from homeassistant.components.button import SERVICE_PRESS +from homeassistant.components.cover import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER +from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.components.valve import SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -121,41 +123,130 @@ async def test_turn_on_intent(hass: HomeAssistant) -> None: assert call.data == {"entity_id": ["light.test_light"]} -async def test_translated_turn_on_intent( +@pytest.mark.parametrize("domain", ["button", "input_button"]) +async def test_turn_on_intent_button( + hass: HomeAssistant, entity_registry: er.EntityRegistry, domain +) -> None: + """Test HassTurnOn intent on button domains.""" + assert await async_setup_component(hass, "intent", {}) + + button = entity_registry.async_get_or_create(domain, "test", "button_uid") + + hass.states.async_set(button.entity_id, "unknown") + button_service_calls = async_mock_service(hass, domain, SERVICE_PRESS) + + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, "test", "HassTurnOff", {"name": {"value": button.entity_id}} + ) + + await intent.async_handle( + hass, "test", "HassTurnOn", {"name": {"value": button.entity_id}} + ) + + assert len(button_service_calls) == 1 + call = button_service_calls[0] + assert call.domain == domain + assert call.service == SERVICE_PRESS + assert call.data == {"entity_id": button.entity_id} + + +async def test_turn_on_off_intent_valve( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test HassTurnOn intent on domains which don't have the intent.""" - result = await async_setup_component(hass, "homeassistant", {}) - result = await async_setup_component(hass, "intent", {}) - await hass.async_block_till_done() - assert result + """Test HassTurnOn/Off intent on valve domains.""" + assert await async_setup_component(hass, "intent", {}) + + valve = entity_registry.async_get_or_create("valve", "test", "valve_uid") + + hass.states.async_set(valve.entity_id, "closed") + open_calls = async_mock_service(hass, "valve", SERVICE_OPEN_VALVE) + close_calls = async_mock_service(hass, "valve", SERVICE_CLOSE_VALVE) + + await intent.async_handle( + hass, "test", "HassTurnOn", {"name": {"value": valve.entity_id}} + ) + + assert len(open_calls) == 1 + call = open_calls[0] + assert call.domain == "valve" + assert call.service == SERVICE_OPEN_VALVE + assert call.data == {"entity_id": valve.entity_id} + + await intent.async_handle( + hass, "test", "HassTurnOff", {"name": {"value": valve.entity_id}} + ) + + assert len(close_calls) == 1 + call = close_calls[0] + assert call.domain == "valve" + assert call.service == SERVICE_CLOSE_VALVE + assert call.data == {"entity_id": valve.entity_id} + + +async def test_turn_on_off_intent_cover( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test HassTurnOn/Off intent on cover domains.""" + assert await async_setup_component(hass, "intent", {}) cover = entity_registry.async_get_or_create("cover", "test", "cover_uid") - lock = entity_registry.async_get_or_create("lock", "test", "lock_uid") hass.states.async_set(cover.entity_id, "closed") - hass.states.async_set(lock.entity_id, "unlocked") - cover_service_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) - lock_service_calls = async_mock_service(hass, "lock", SERVICE_LOCK) + open_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) + close_calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER) await intent.async_handle( hass, "test", "HassTurnOn", {"name": {"value": cover.entity_id}} ) + + assert len(open_calls) == 1 + call = open_calls[0] + assert call.domain == "cover" + assert call.service == SERVICE_OPEN_COVER + assert call.data == {"entity_id": cover.entity_id} + + await intent.async_handle( + hass, "test", "HassTurnOff", {"name": {"value": cover.entity_id}} + ) + + assert len(close_calls) == 1 + call = close_calls[0] + assert call.domain == "cover" + assert call.service == SERVICE_CLOSE_COVER + assert call.data == {"entity_id": cover.entity_id} + + +async def test_turn_on_off_intent_lock( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test HassTurnOn/Off intent on lock domains.""" + assert await async_setup_component(hass, "intent", {}) + + lock = entity_registry.async_get_or_create("lock", "test", "lock_uid") + + hass.states.async_set(lock.entity_id, "locked") + unlock_calls = async_mock_service(hass, "lock", SERVICE_UNLOCK) + lock_calls = async_mock_service(hass, "lock", SERVICE_LOCK) + await intent.async_handle( hass, "test", "HassTurnOn", {"name": {"value": lock.entity_id}} ) - await hass.async_block_till_done() - assert len(cover_service_calls) == 1 - call = cover_service_calls[0] - assert call.domain == "cover" - assert call.service == "open_cover" - assert call.data == {"entity_id": cover.entity_id} - - assert len(lock_service_calls) == 1 - call = lock_service_calls[0] + assert len(lock_calls) == 1 + call = lock_calls[0] assert call.domain == "lock" - assert call.service == "lock" + assert call.service == SERVICE_LOCK + assert call.data == {"entity_id": lock.entity_id} + + await intent.async_handle( + hass, "test", "HassTurnOff", {"name": {"value": lock.entity_id}} + ) + + assert len(unlock_calls) == 1 + call = unlock_calls[0] + assert call.domain == "lock" + assert call.service == SERVICE_UNLOCK assert call.data == {"entity_id": lock.entity_id} diff --git a/tests/components/intent/test_temperature.py b/tests/components/intent/test_temperature.py new file mode 100644 index 00000000000..622e55fe24a --- /dev/null +++ b/tests/components/intent/test_temperature.py @@ -0,0 +1,609 @@ +"""Test temperature intents.""" + +from collections.abc import Generator +from typing import Any + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import ATTR_DEVICE_CLASS, Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + entity_registry as er, + floor_registry as fr, + intent, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_integration(hass: HomeAssistant) -> None: + """Fixture to set up a mock integration.""" + + 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, [CLIMATE_DOMAIN] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +async def create_mock_platform( + hass: HomeAssistant, + entities: list[ClimateEntity], +) -> MockConfigEntry: + """Create a todo platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{CLIMATE_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() + + return config_entry + + +class MockClimateEntity(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the thermostat temperature.""" + value = kwargs[ATTR_TEMPERATURE] + self._attr_target_temperature = value + + +class MockClimateEntityNoSetTemperature(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + + +async def test_get_temperature( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test HassClimateGetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to different areas: + # climate_1 => living room + # climate_2 => bedroom + # nothing in bathroom + # nothing in office yet + # nothing in attic yet + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + office_area = area_registry.async_create(name="Office") + attic_area = area_registry.async_create(name="Attic") + bathroom_area = area_registry.async_create(name="Bathroom") + + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) + + # Put areas on different floors: + # first floor => living room and office + # 2nd floor => bedroom + # 3rd floor => attic + floor_registry = fr.async_get(hass) + first_floor = floor_registry.async_create("First floor") + living_room_area = area_registry.async_update( + living_room_area.id, floor_id=first_floor.floor_id + ) + office_area = area_registry.async_update( + office_area.id, floor_id=first_floor.floor_id + ) + + second_floor = floor_registry.async_create("Second floor") + bedroom_area = area_registry.async_update( + bedroom_area.id, floor_id=second_floor.floor_id + ) + bathroom_area = area_registry.async_update( + bathroom_area.id, floor_id=second_floor.floor_id + ) + + third_floor = floor_registry.async_create("Third floor") + attic_area = area_registry.async_update( + attic_area.id, floor_id=third_floor.floor_id + ) + + # Add temperature sensors to each area that should *not* be selected + for area in (living_room_area, office_area, bedroom_area, attic_area): + wrong_temperature_entry = entity_registry.async_get_or_create( + "sensor", "test", f"wrong_temperature_{area.id}" + ) + hass.states.async_set( + wrong_temperature_entry.entity_id, + "10.0", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + entity_registry.async_update_entity( + wrong_temperature_entry.entity_id, area_id=area.id + ) + + # Create temperature sensor and assign them to the office/attic + office_temperature_id = "sensor.office_temperature" + attic_temperature_id = "sensor.attic_temperature" + hass.states.async_set( + office_temperature_id, + "15.5", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + office_area = area_registry.async_update( + office_area.id, temperature_entity_id=office_temperature_id + ) + + hass.states.async_set( + attic_temperature_id, + "18.1", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + attic_area = area_registry.async_update( + attic_area.id, temperature_entity_id=attic_temperature_id + ) + + # Multiple climate entities match (error) + with pytest.raises(intent.MatchFailedError) as error: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert ( + error.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + ) + + # Select by area (office_temperature) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": office_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == office_temperature_id + state = response.matched_states[0] + assert state.state == "15.5" + + # Select by preferred area (attic_temperature) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_area_id": {"value": attic_area.id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == attic_temperature_id + state = response.matched_states[0] + assert state.state == "18.1" + + # Select by area (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Check area with no climate entities + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bathroom_area.name}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name is None + assert constraints.area_name == bathroom_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Does not exist"}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME + constraints = error.value.constraints + assert constraints.name == "Does not exist" + assert constraints.area_name is None + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name with area + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name == "Climate 1" + assert constraints.area_name == bedroom_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Select by floor (climate_1) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"floor": {"value": first_floor.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + # Select by preferred area (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_area_id": {"value": bedroom_area.id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Select by preferred floor (climate_1) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_floor_id": {"value": first_floor.floor_id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + +async def test_get_temperature_no_entities( + hass: HomeAssistant, +) -> None: + """Test HassClimateGetTemperature intent with no climate entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + await create_mock_platform(hass, []) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN + + +async def test_not_exposed( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent when entities aren't exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to same area + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity( + climate_2.entity_id, area_id=living_room_area.id + ) + + # Should fail with empty name + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Should fail with empty area + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Expose second, hide first + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the area should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the exposed entity should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_2.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the *unexposed* entity should fail + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_1.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Expose first, hide second + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + + # Wrong area name + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA + + # Neither are exposed + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with area + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with both names + for name in (climate_1.name, climate_2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT diff --git a/tests/components/iometer/__init__.py b/tests/components/iometer/__init__.py index 5c08438925e..19fe2124f1f 100644 --- a/tests/components/iometer/__init__.py +++ b/tests/components/iometer/__init__.py @@ -1 +1,19 @@ """Tests for the IOmeter 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: + """Fixture for setting up the IOmeter platform.""" + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.iometer.PLATFORMS", platforms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/iometer/conftest.py b/tests/components/iometer/conftest.py index ee45021952e..f8139c7c64c 100644 --- a/tests/components/iometer/conftest.py +++ b/tests/components/iometer/conftest.py @@ -54,4 +54,5 @@ def mock_config_entry() -> MockConfigEntry: title="IOmeter-1ISK0000000000", data={CONF_HOST: "10.0.0.2"}, unique_id="658c2b34-2017-45f2-a12b-731235f8bb97", + entry_id="01JQ6G5395176MAAWKAAPEZHV6", ) diff --git a/tests/components/iometer/snapshots/test_binary_sensor.ambr b/tests/components/iometer/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..38aab735a14 --- /dev/null +++ b/tests/components/iometer/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_attachment_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_attachment_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': 'Core attachment status', + 'platform': 'iometer', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'attachment_status', + 'unique_id': '01JQ6G5395176MAAWKAAPEZHV6_attachment_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_attachment_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'IOmeter-1ISK0000000000 Core attachment status', + }), + 'context': , + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_attachment_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_bridge_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_bridge_connection_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': 'Core/Bridge connection status', + 'platform': 'iometer', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'connection_status', + 'unique_id': '01JQ6G5395176MAAWKAAPEZHV6_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_bridge_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'IOmeter-1ISK0000000000 Core/Bridge connection status', + }), + 'context': , + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_bridge_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/iometer/test_binary_sensor.py b/tests/components/iometer/test_binary_sensor.py new file mode 100644 index 00000000000..e007084567e --- /dev/null +++ b/tests/components/iometer/test_binary_sensor.py @@ -0,0 +1,135 @@ +"""Test the IOmeter binary sensors.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, 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, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_iometer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test binary sensors.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_connection_status_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_iometer_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection status sensor.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_bridge_connection_status" + ).state + == STATE_ON + ) + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_iometer_client.get_current_status.return_value.device.core.connection_status = "disconnected" + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_bridge_connection_status" + ).state + == STATE_OFF + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_attachment_status_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_iometer_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection status sensor.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_ON + ) + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_iometer_client.get_current_status.return_value.device.core.attachment_status = "detached" + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_OFF + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_attachment_status_sensors_unkown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_iometer_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection status sensor.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_ON + ) + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_iometer_client.get_current_status.return_value.device.core.attachment_status = None + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_UNKNOWN + ) diff --git a/tests/components/iometer/test_init.py b/tests/components/iometer/test_init.py new file mode 100644 index 00000000000..9d8eadc5079 --- /dev/null +++ b/tests/components/iometer/test_init.py @@ -0,0 +1,44 @@ +"""Tests for the AirGradient integration.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.iometer.const import DOMAIN +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_fire_time_changed + + +async def test_new_firmware_version( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device registry integration.""" + # await setup_integration(hass, mock_config_entry) + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry.sw_version == "build-58/build-65" + mock_iometer_client.get_current_status.return_value.device.core.version = "build-62" + mock_iometer_client.get_current_status.return_value.device.bridge.version = ( + "build-69" + ) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry.sw_version == "build-62/build-69" diff --git a/tests/components/iotawatt/conftest.py b/tests/components/iotawatt/conftest.py index 9380154b53e..3b30783494e 100644 --- a/tests/components/iotawatt/conftest.py +++ b/tests/components/iotawatt/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.components.iotawatt import DOMAIN +from homeassistant.components.iotawatt.const import DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/ipma/conftest.py b/tests/components/ipma/conftest.py index 8f2a017dcb8..caf49f594fb 100644 --- a/tests/components/ipma/conftest.py +++ b/tests/components/ipma/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.ipma import DOMAIN +from homeassistant.components.ipma.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant diff --git a/tests/components/ipma/test_init.py b/tests/components/ipma/test_init.py index 7967b97dd23..4a0314a0d9a 100644 --- a/tests/components/ipma/test_init.py +++ b/tests/components/ipma/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pyipma import IPMAException -from homeassistant.components.ipma import DOMAIN +from homeassistant.components.ipma.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE from homeassistant.core import HomeAssistant diff --git a/tests/components/ipp/test_diagnostics.py b/tests/components/ipp/test_diagnostics.py index d78f066d788..3bd1fbc2e3e 100644 --- a/tests/components/ipp/test_diagnostics.py +++ b/tests/components/ipp/test_diagnostics.py @@ -1,7 +1,7 @@ """Tests for the diagnostics data provided by the Internet Printing Protocol (IPP) integration.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/iqvia/test_config_flow.py b/tests/components/iqvia/test_config_flow.py index 22f473a3fb5..9a973ebe49c 100644 --- a/tests/components/iqvia/test_config_flow.py +++ b/tests/components/iqvia/test_config_flow.py @@ -4,7 +4,7 @@ from typing import Any import pytest -from homeassistant.components.iqvia import CONF_ZIP_CODE, DOMAIN +from homeassistant.components.iqvia.const import CONF_ZIP_CODE, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/iqvia/test_diagnostics.py b/tests/components/iqvia/test_diagnostics.py index 9d5639c311c..dc3d0cb8557 100644 --- a/tests/components/iqvia/test_diagnostics.py +++ b/tests/components/iqvia/test_diagnostics.py @@ -1,6 +1,6 @@ """Test IQVIA diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index 63c7d129987..479ee2fde7b 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -20,6 +20,7 @@ from pynecil import ( ScrollSpeed, SettingsDataResponse, TempUnit, + TipType, ) import pytest @@ -158,17 +159,19 @@ def mock_ironosupdate() -> Generator[AsyncMock]: @pytest.fixture def mock_pynecil() -> Generator[AsyncMock]: """Mock Pynecil library.""" - with patch( - "homeassistant.components.iron_os.Pynecil", autospec=True - ) as mock_client: + with ( + patch("homeassistant.components.iron_os.Pynecil", autospec=True) as mock_client, + patch("homeassistant.components.iron_os.config_flow.Pynecil", new=mock_client), + ): client = mock_client.return_value client.get_device_info.return_value = DeviceInfoResponse( - build="v2.22", + build="v2.23", device_id="c0ffeeC0", address="c0:ff:ee:c0:ff:ee", device_sn="0000c0ffeec0ffee", name=DEFAULT_NAME, + is_synced=True, ) client.get_settings.return_value = SettingsDataResponse( sleep_temp=150, @@ -205,6 +208,8 @@ def mock_pynecil() -> Generator[AsyncMock]: display_invert=True, calibrate_cjc=True, usb_pd_mode=True, + hall_sleep_time=5, + tip_type=TipType.PINE_SHORT, ) client.get_live_data.return_value = LiveDataResponse( live_temp=298, @@ -222,4 +227,6 @@ def mock_pynecil() -> Generator[AsyncMock]: operating_mode=OperatingMode.SOLDERING, estimated_power=24.8, ) + client._client = AsyncMock() + client._client.return_value.is_connected = True yield client diff --git a/tests/components/iron_os/snapshots/test_diagnostics.ambr b/tests/components/iron_os/snapshots/test_diagnostics.ambr index f8db1262254..d377b531560 100644 --- a/tests/components/iron_os/snapshots/test_diagnostics.ambr +++ b/tests/components/iron_os/snapshots/test_diagnostics.ambr @@ -6,7 +6,7 @@ }), '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)", + 'repr': "DeviceInfoResponse(build='v2.23', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=True)", }), 'live_data': dict({ '__type': "", diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index 62fcd120201..b2ec7a70a92 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -226,6 +226,63 @@ 'state': '7', }) # --- +# name: test_state[number.pinecil_hall_sensor_sleep_timeout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_hall_sensor_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': 'Hall sensor sleep timeout', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_hall_effect_sleep_time', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_hall_sensor_sleep_timeout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Hall sensor sleep timeout', + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_hall_sensor_sleep_timeout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- # name: test_state[number.pinecil_keep_awake_pulse_delay-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/iron_os/snapshots/test_select.ambr b/tests/components/iron_os/snapshots/test_select.ambr index 10aacc838df..540cab234a5 100644 --- a/tests/components/iron_os/snapshots/test_select.ambr +++ b/tests/components/iron_os/snapshots/test_select.ambr @@ -250,6 +250,7 @@ 'options': list([ 'off', 'on', + 'safe', ]), }), 'config_entry_id': , @@ -287,6 +288,7 @@ 'options': list([ 'off', 'on', + 'safe', ]), }), 'context': , @@ -415,6 +417,66 @@ 'state': 'fast', }) # --- +# name: test_state[select.pinecil_soldering_tip_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'ts100_long', + 'pine_short', + 'pts200', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_soldering_tip_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': 'Soldering tip type', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_tip_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_soldering_tip_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Soldering tip type', + 'options': list([ + 'auto', + 'ts100_long', + 'pine_short', + 'pts200', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_soldering_tip_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pine_short', + }) +# --- # name: test_state[select.pinecil_start_up_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/iron_os/snapshots/test_update.ambr b/tests/components/iron_os/snapshots/test_update.ambr index f2db3246158..fcd7196a70c 100644 --- a/tests/components/iron_os/snapshots/test_update.ambr +++ b/tests/components/iron_os/snapshots/test_update.ambr @@ -45,7 +45,7 @@ 'entity_picture': 'https://brands.home-assistant.io/_/iron_os/icon.png', 'friendly_name': 'Pinecil Firmware', 'in_progress': False, - 'installed_version': 'v2.22', + 'installed_version': 'v2.23', 'latest_version': 'v2.22', 'release_summary': None, 'release_url': 'https://github.com/Ralim/IronOS/releases/tag/v2.22', diff --git a/tests/components/iron_os/test_config_flow.py b/tests/components/iron_os/test_config_flow.py index 88bef117c26..ba3e7f4b230 100644 --- a/tests/components/iron_os/test_config_flow.py +++ b/tests/components/iron_os/test_config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock +from pynecil import CommunicationError import pytest from homeassistant.components.iron_os import DOMAIN @@ -16,7 +17,7 @@ from .conftest import DEFAULT_NAME, PINECIL_SERVICE_INFO, USER_INPUT from tests.common import MockConfigEntry -@pytest.mark.usefixtures("discovery") +@pytest.mark.usefixtures("discovery", "mock_pynecil") async def test_async_step_user( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: @@ -34,10 +35,52 @@ async def test_async_step_user( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == DEFAULT_NAME assert result["data"] == {} + assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee" assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CommunicationError, "cannot_connect"), + (Exception, "unknown"), + ], +) @pytest.mark.usefixtures("discovery") +async def test_async_step_user_errors( + hass: HomeAssistant, + mock_pynecil: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test the user config flow errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + mock_pynecil.connect.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pynecil.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"] == DEFAULT_NAME + assert result["data"] == {} + assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee" + + +@pytest.mark.usefixtures("discovery", "mock_pynecil") async def test_async_step_user_device_added_between_steps( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: @@ -73,6 +116,7 @@ async def test_form_no_device_discovered( assert result["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_pynecil") async def test_async_step_bluetooth(hass: HomeAssistant) -> None: """Test discovery via bluetooth.""" result = await hass.config_entries.flow.async_init( @@ -92,6 +136,49 @@ async def test_async_step_bluetooth(hass: HomeAssistant) -> None: assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee" +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (CommunicationError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_async_step_bluetooth_errors( + hass: HomeAssistant, + mock_pynecil: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test discovery via bluetooth errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=PINECIL_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + mock_pynecil.connect.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pynecil.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"] == DEFAULT_NAME + assert result["data"] == {} + assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee" + + +@pytest.mark.usefixtures("mock_pynecil") async def test_async_step_bluetooth_devices_already_setup( hass: HomeAssistant, config_entry: AsyncMock ) -> None: @@ -108,7 +195,7 @@ async def test_async_step_bluetooth_devices_already_setup( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("discovery") +@pytest.mark.usefixtures("discovery", "mock_pynecil") async def test_async_step_user_setup_replaces_igonored_device( hass: HomeAssistant, config_entry_ignored: AsyncMock ) -> None: diff --git a/tests/components/iron_os/test_init.py b/tests/components/iron_os/test_init.py index 4749e1b6199..6adc0b778f0 100644 --- a/tests/components/iron_os/test_init.py +++ b/tests/components/iron_os/test_init.py @@ -4,12 +4,16 @@ from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from pynecil import CommunicationError +from pynecil import CommunicationError, DeviceInfoResponse import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH + +from .conftest import DEFAULT_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -33,41 +37,6 @@ async def test_setup_and_unload( assert config_entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.usefixtures("ble_device") -async def test_update_data_config_entry_not_ready( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_pynecil: AsyncMock, -) -> None: - """Test config entry not ready.""" - mock_pynecil.get_live_data.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() - - assert config_entry.state is ConfigEntryState.SETUP_RETRY - - -@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, @@ -89,3 +58,79 @@ async def test_settings_exception( assert (state := hass.states.get("number.pinecil_boost_temperature")) assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_v223_entities_not_loaded( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, +) -> None: + """Test the new entities in IronOS v2.23 are not loaded on smaller versions.""" + + mock_pynecil.get_device_info.return_value = DeviceInfoResponse( + build="v2.22", + device_id="c0ffeeC0", + address="c0:ff:ee:c0:ff:ee", + device_sn="0000c0ffeec0ffee", + name=DEFAULT_NAME, + ) + 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("number.pinecil_hall_sensor_sleep_timeout") is None + assert hass.states.get("select.pinecil_soldering_tip_type") is None + assert ( + state := hass.states.get("select.pinecil_power_delivery_3_1_epr") + ) is not None + + assert len(state.attributes["options"]) == 2 + + +@pytest.mark.usefixtures("ble_device") +async def test_device_info_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device info gets updated.""" + + mock_pynecil.get_device_info.return_value = DeviceInfoResponse() + 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 + + device = device_registry.async_get_device( + connections={(CONNECTION_BLUETOOTH, config_entry.unique_id)} + ) + assert device + assert device.sw_version is None + assert device.serial_number is None + + mock_pynecil.get_device_info.return_value = DeviceInfoResponse( + build="v2.22", + device_id="c0ffeeC0", + address="c0:ff:ee:c0:ff:ee", + device_sn="0000c0ffeec0ffee", + name=DEFAULT_NAME, + ) + + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(CONNECTION_BLUETOOTH, config_entry.unique_id)} + ) + assert device + assert device.sw_version == "v2.22" + assert device.serial_number == "0000c0ffeec0ffee (ID:c0ffeeC0)" diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py index bdec922a88c..9a4ba53f338 100644 --- a/tests/components/iron_os/test_number.py +++ b/tests/components/iron_os/test_number.py @@ -138,6 +138,12 @@ async def test_state( ("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), + ( + "number.pinecil_hall_sensor_sleep_timeout", + CharSetting.HALL_SLEEP_TIME, + 60, + 60, + ), ], ) @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 index 8cc848dd4cb..5590bfc2ba6 100644 --- a/tests/components/iron_os/test_select.py +++ b/tests/components/iron_os/test_select.py @@ -16,6 +16,7 @@ from pynecil import ( ScreenOrientationMode, ScrollSpeed, TempUnit, + TipType, USBPDMode, ) import pytest @@ -111,6 +112,11 @@ async def test_state( "on", (CharSetting.USB_PD_MODE, USBPDMode.ON), ), + ( + "select.pinecil_soldering_tip_type", + "auto", + (CharSetting.TIP_TYPE, TipType.AUTO), + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") diff --git a/tests/components/iron_os/test_sensor.py b/tests/components/iron_os/test_sensor.py index fec111c5799..da77cb7958d 100644 --- a/tests/components/iron_os/test_sensor.py +++ b/tests/components/iron_os/test_sensor.py @@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from pynecil import CommunicationError, LiveDataResponse +from pynecil import LiveDataResponse import pytest from syrupy.assertion import SnapshotAssertion @@ -62,7 +62,7 @@ async def test_sensors_unavailable( assert config_entry.state is ConfigEntryState.LOADED - mock_pynecil.get_live_data.side_effect = CommunicationError + mock_pynecil.is_connected = False freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/iron_os/test_update.py b/tests/components/iron_os/test_update.py index 47f3197da0e..137d42a5d51 100644 --- a/tests/components/iron_os/test_update.py +++ b/tests/components/iron_os/test_update.py @@ -3,16 +3,17 @@ from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, patch -from pynecil import UpdateException +from pynecil import CommunicationError, UpdateException import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.update import ATTR_INSTALLED_VERSION from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, mock_restore_cache, snapshot_platform from tests.typing import WebSocketGenerator @@ -75,3 +76,34 @@ async def test_update_unavailable( state = hass.states.get("update.pinecil_firmware") assert state is not None assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("ble_device") +async def test_update_restore_last_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, +) -> None: + """Test update entity restore last state.""" + + mock_pynecil.get_device_info.side_effect = CommunicationError + mock_restore_cache( + hass, + ( + State( + "update.pinecil_firmware", + STATE_ON, + attributes={ATTR_INSTALLED_VERSION: "v2.21"}, + ), + ), + ) + 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 + + state = hass.states.get("update.pinecil_firmware") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.21" diff --git a/tests/components/israel_rail/test_sensor.py b/tests/components/israel_rail/test_sensor.py index 85b7328742f..08aed2bbc21 100644 --- a/tests/components/israel_rail/test_sensor.py +++ b/tests/components/israel_rail/test_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant diff --git a/tests/components/ista_ecotrend/conftest.py b/tests/components/ista_ecotrend/conftest.py index 7edf2e4717b..58977c99b59 100644 --- a/tests/components/ista_ecotrend/conftest.py +++ b/tests/components/ista_ecotrend/conftest.py @@ -80,7 +80,7 @@ def mock_ista() -> Generator[MagicMock]: "26e93f1a-c828-11ea-87d0-0242ac130003", "eaf5c5c8-889f-4a3c-b68c-e9a676505762", ] - client.get_consumption_data = get_consumption_data + client.get_consumption_data.side_effect = get_consumption_data yield client diff --git a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c9f5e72ae1f --- /dev/null +++ b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr @@ -0,0 +1,205 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + '26e93f1a-c828-11ea-87d0-0242ac130003': dict({ + 'consumptionUnitId': '26e93f1a-c828-11ea-87d0-0242ac130003', + 'consumptions': list([ + dict({ + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '38,0', + 'type': 'heating', + 'value': '35', + }), + dict({ + 'additionalValue': '57,0', + 'type': 'warmwater', + 'value': '1,0', + }), + dict({ + 'type': 'water', + 'value': '5,0', + }), + ]), + }), + dict({ + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '113,0', + 'type': 'heating', + 'value': '104', + }), + dict({ + 'additionalValue': '61,1', + 'type': 'warmwater', + 'value': '1,1', + }), + dict({ + 'type': 'water', + 'value': '6,8', + }), + ]), + }), + ]), + 'costs': list([ + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 21, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 3, + }), + ]), + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + }), + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 62, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 2, + }), + ]), + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + }), + ]), + }), + 'eaf5c5c8-889f-4a3c-b68c-e9a676505762': dict({ + 'consumptionUnitId': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', + 'consumptions': list([ + dict({ + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '38,0', + 'type': 'heating', + 'value': '35', + }), + dict({ + 'additionalValue': '57,0', + 'type': 'warmwater', + 'value': '1,0', + }), + dict({ + 'type': 'water', + 'value': '5,0', + }), + ]), + }), + dict({ + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + 'readings': list([ + dict({ + 'additionalValue': '113,0', + 'type': 'heating', + 'value': '104', + }), + dict({ + 'additionalValue': '61,1', + 'type': 'warmwater', + 'value': '1,1', + }), + dict({ + 'type': 'water', + 'value': '6,8', + }), + ]), + }), + ]), + 'costs': list([ + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 21, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 3, + }), + ]), + 'date': dict({ + 'month': 5, + 'year': 2024, + }), + }), + dict({ + 'costsByEnergyType': list([ + dict({ + 'type': 'heating', + 'value': 62, + }), + dict({ + 'type': 'warmwater', + 'value': 7, + }), + dict({ + 'type': 'water', + 'value': 2, + }), + ]), + 'date': dict({ + 'month': 4, + 'year': 2024, + }), + }), + ]), + }), + }), + 'details': dict({ + '26e93f1a-c828-11ea-87d0-0242ac130003': dict({ + 'address': dict({ + 'houseNumber': '**REDACTED**', + 'street': '**REDACTED**', + }), + 'id': '26e93f1a-c828-11ea-87d0-0242ac130003', + }), + 'eaf5c5c8-889f-4a3c-b68c-e9a676505762': dict({ + 'address': dict({ + 'houseNumber': '**REDACTED**', + 'street': '**REDACTED**', + }), + 'id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', + }), + }), + }) +# --- diff --git a/tests/components/ista_ecotrend/test_config_flow.py b/tests/components/ista_ecotrend/test_config_flow.py index d6c88c51c99..094ff17fb7f 100644 --- a/tests/components/ista_ecotrend/test_config_flow.py +++ b/tests/components/ista_ecotrend/test_config_flow.py @@ -11,6 +11,8 @@ 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 + @pytest.mark.usefixtures("mock_ista") async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: @@ -47,14 +49,14 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: (IndexError, "unknown"), ], ) -async def test_form_invalid_auth( +async def test_form_error_and_recover( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_ista: MagicMock, side_effect: Exception, error_text: str, ) -> None: - """Test we handle invalid auth.""" + """Test config flow error and recover.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -89,10 +91,10 @@ async def test_form_invalid_auth( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_ista") async def test_reauth( hass: HomeAssistant, - ista_config_entry: AsyncMock, - mock_ista: MagicMock, + ista_config_entry: MockConfigEntry, ) -> None: """Test reauth flow.""" @@ -131,12 +133,12 @@ async def test_reauth( ) async def test_reauth_error_and_recover( hass: HomeAssistant, - ista_config_entry: AsyncMock, + ista_config_entry: MockConfigEntry, mock_ista: MagicMock, side_effect: Exception, error_text: str, ) -> None: - """Test reauth flow.""" + """Test reauth flow error and recover.""" ista_config_entry.add_to_hass(hass) @@ -174,3 +176,186 @@ async def test_reauth_error_and_recover( CONF_PASSWORD: "new-password", } assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_ista") +async def test_form_already_configured( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, +) -> None: + """Test we abort form login when entry is already configured.""" + + ista_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_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_ista") +async def test_flow_reauth_unique_id_mismatch(hass: HomeAssistant) -> None: + """Test reauth flow unique id mismatch.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + unique_id="42243134-21f6-40a2-a79f-e417a3a12104", + ) + + 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"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + 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("mock_ista") +async def test_reconfigure( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + + ista_config_entry.add_to_hass(hass) + + result = await ista_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_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert ista_config_entry.data == { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (LoginError(None), "invalid_auth"), + (ServerError, "cannot_connect"), + (IndexError, "unknown"), + ], +) +async def test_reconfigure_error_and_recover( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + mock_ista: MagicMock, + side_effect: Exception, + error_text: str, +) -> None: + """Test reconfigure flow error and recover.""" + + ista_config_entry.add_to_hass(hass) + + result = await ista_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_ista.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_text} + + mock_ista.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert ista_config_entry.data == { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_ista") +async def test_flow_reconfigure_unique_id_mismatch(hass: HomeAssistant) -> None: + """Test reconfigure flow unique id mismatch.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + unique_id="42243134-21f6-40a2-a79f-e417a3a12104", + ) + + 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"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + 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 diff --git a/tests/components/ista_ecotrend/test_diagnostics.py b/tests/components/ista_ecotrend/test_diagnostics.py new file mode 100644 index 00000000000..83e28b0b7f8 --- /dev/null +++ b/tests/components/ista_ecotrend/test_diagnostics.py @@ -0,0 +1,27 @@ +"""Tests for ista EcoTrend diagnostics platform .""" + +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("mock_ista") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ista_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, ista_config_entry) + == snapshot + ) diff --git a/tests/components/ista_ecotrend/test_init.py b/tests/components/ista_ecotrend/test_init.py index a15e4577252..b73232a7d74 100644 --- a/tests/components/ista_ecotrend/test_init.py +++ b/tests/components/ista_ecotrend/test_init.py @@ -1,11 +1,12 @@ """Test the ista EcoTrend init.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pyecotrend_ista import KeycloakError, LoginError, ParserError, ServerError import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.ista_ecotrend.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -60,7 +61,7 @@ async def test_config_entry_auth_failed( mock_ista: MagicMock, side_effect: Exception, ) -> None: - """Test config entry not ready.""" + """Test config entry auth failed.""" mock_ista.login.side_effect = side_effect ista_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(ista_config_entry.entry_id) @@ -88,3 +89,49 @@ async def test_device_registry( device_registry, ista_config_entry.entry_id ): assert device == snapshot + + +async def test_update_failed( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + mock_ista: MagicMock, +) -> None: + """Test coordinator update failed.""" + + with patch( + "homeassistant.components.ista_ecotrend.PLATFORMS", + [], + ): + mock_ista.get_consumption_data.side_effect = ServerError + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_auth_failed( + hass: HomeAssistant, ista_config_entry: MockConfigEntry, mock_ista: MagicMock +) -> None: + """Test coordinator auth failed and reauth flow started.""" + with patch( + "homeassistant.components.ista_ecotrend.PLATFORMS", + [], + ): + mock_ista.get_consumption_data.side_effect = LoginError + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_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") == ista_config_entry.entry_id diff --git a/tests/components/ista_ecotrend/test_statistics.py b/tests/components/ista_ecotrend/test_statistics.py index aa4f71037c4..b5f419437c5 100644 --- a/tests/components/ista_ecotrend/test_statistics.py +++ b/tests/components/ista_ecotrend/test_statistics.py @@ -84,3 +84,61 @@ async def test_statistics_import( assert stats[statistic_id] == snapshot(name=f"{statistic_id}_3months") assert len(stats[statistic_id]) == 3 + + +@pytest.mark.usefixtures("recorder_mock", "mock_ista") +async def test_remove( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, +) -> None: + """Test remove config entry and clear statistics.""" + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.LOADED + await async_wait_recording_done(hass) + + assert await hass.async_add_executor_job( + statistics_during_period, + hass, + datetime.datetime.fromtimestamp(0, tz=datetime.UTC), + None, + {"ista_ecotrend:bahnhofsstr_1a_heating"}, + "month", + None, + {"state", "sum"}, + ) + + assert await hass.config_entries.async_unload(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.NOT_LOADED + await async_wait_recording_done(hass) + + assert await hass.async_add_executor_job( + statistics_during_period, + hass, + datetime.datetime.fromtimestamp(0, tz=datetime.UTC), + None, + {"ista_ecotrend:bahnhofsstr_1a_heating"}, + "month", + None, + {"state", "sum"}, + ) + + assert await hass.config_entries.async_remove(ista_config_entry.entry_id) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + assert not await hass.async_add_executor_job( + statistics_during_period, + hass, + datetime.datetime.fromtimestamp(0, tz=datetime.UTC), + None, + {"ista_ecotrend:bahnhofsstr_1a_heating"}, + "month", + None, + {"state", "sum"}, + ) diff --git a/tests/components/isy994/test_system_health.py b/tests/components/isy994/test_system_health.py index 5f472189513..0a6e4b403b8 100644 --- a/tests/components/isy994/test_system_health.py +++ b/tests/components/isy994/test_system_health.py @@ -6,6 +6,7 @@ from unittest.mock import Mock from aiohttp import ClientError from homeassistant.components.isy994.const import DOMAIN, ISY_URL_POSTFIX +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -30,12 +31,14 @@ async def test_system_health( assert await async_setup_component(hass, "system_health", {}) await hass.async_block_till_done() - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, entry_id=MOCK_ENTRY_ID, data={CONF_HOST: f"http://{MOCK_HOSTNAME}"}, unique_id=MOCK_UUID, - ).add_to_hass(hass) + state=ConfigEntryState.LOADED, + ) + entry.add_to_hass(hass) isy_data = Mock( root=Mock( @@ -46,7 +49,7 @@ async def test_system_health( ), ) ) - hass.data[DOMAIN] = {MOCK_ENTRY_ID: isy_data} + entry.runtime_data = isy_data info = await get_system_health_info(hass, DOMAIN) @@ -70,12 +73,14 @@ async def test_system_health_failed_connect( assert await async_setup_component(hass, "system_health", {}) await hass.async_block_till_done() - MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, entry_id=MOCK_ENTRY_ID, data={CONF_HOST: f"http://{MOCK_HOSTNAME}"}, unique_id=MOCK_UUID, - ).add_to_hass(hass) + state=ConfigEntryState.LOADED, + ) + entry.add_to_hass(hass) isy_data = Mock( root=Mock( @@ -86,7 +91,7 @@ async def test_system_health_failed_connect( ), ) ) - hass.data[DOMAIN] = {MOCK_ENTRY_ID: isy_data} + entry.runtime_data = isy_data info = await get_system_health_info(hass, DOMAIN) diff --git a/tests/components/jellyfin/fixtures/get-media-folders.json b/tests/components/jellyfin/fixtures/get-media-folders.json index ff87751a9da..f6b5c1e8d78 100644 --- a/tests/components/jellyfin/fixtures/get-media-folders.json +++ b/tests/components/jellyfin/fixtures/get-media-folders.json @@ -302,8 +302,6 @@ "Album": "string", "CollectionType": "tvshows", "DisplayOrder": "string", - "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", - "AlbumPrimaryImageTag": "string", "SeriesPrimaryImageTag": "string", "AlbumArtist": "string", "AlbumArtists": [ diff --git a/tests/components/jellyfin/fixtures/sessions.json b/tests/components/jellyfin/fixtures/sessions.json index 00a1f5265db..db2b691dff0 100644 --- a/tests/components/jellyfin/fixtures/sessions.json +++ b/tests/components/jellyfin/fixtures/sessions.json @@ -4346,6 +4346,7 @@ ], "Album": "ALBUM", "AlbumId": "ALBUM-UUID", + "AlbumPrimaryImageTag": "ALBUM-PRIMARY-IMAGE-TAG", "AlbumArtist": "Album Artist", "AlbumArtists": [ { "Name": "Album Artist", "Id": "9a65b2c222ddb34e51f5cae360fad3a1" } diff --git a/tests/components/jellyfin/fixtures/user-items-parent-id.json b/tests/components/jellyfin/fixtures/user-items-parent-id.json index 2e06c30894c..cd0232894bc 100644 --- a/tests/components/jellyfin/fixtures/user-items-parent-id.json +++ b/tests/components/jellyfin/fixtures/user-items-parent-id.json @@ -302,8 +302,6 @@ "Album": "string", "CollectionType": "string", "DisplayOrder": "string", - "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", - "AlbumPrimaryImageTag": "string", "SeriesPrimaryImageTag": "string", "AlbumArtist": "string", "AlbumArtists": [ diff --git a/tests/components/jellyfin/snapshots/test_diagnostics.ambr b/tests/components/jellyfin/snapshots/test_diagnostics.ambr index c992628f034..9d73ee6397c 100644 --- a/tests/components/jellyfin/snapshots/test_diagnostics.ambr +++ b/tests/components/jellyfin/snapshots/test_diagnostics.ambr @@ -1707,6 +1707,7 @@ }), ]), 'AlbumId': 'ALBUM-UUID', + 'AlbumPrimaryImageTag': 'ALBUM-PRIMARY-IMAGE-TAG', 'ArtistItems': list([ dict({ 'Id': '1d864900526d9a9513b489f1cc28f8ca', diff --git a/tests/components/jellyfin/snapshots/test_media_source.ambr b/tests/components/jellyfin/snapshots/test_media_source.ambr index 6f46aaf3f9b..12398f16b8f 100644 --- a/tests/components/jellyfin/snapshots/test_media_source.ambr +++ b/tests/components/jellyfin/snapshots/test_media_source.ambr @@ -15,6 +15,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -31,6 +32,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -47,6 +49,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -63,6 +66,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -85,6 +89,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -101,6 +106,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -117,6 +123,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', @@ -133,6 +140,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children': None, 'children_media_class': None, 'domain': 'jellyfin', diff --git a/tests/components/jellyfin/test_diagnostics.py b/tests/components/jellyfin/test_diagnostics.py index bd34e3a8e31..822d8dbc5bb 100644 --- a/tests/components/jellyfin/test_diagnostics.py +++ b/tests/components/jellyfin/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Jellyfin diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index 3263639a32f..404fdc801ee 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -27,6 +27,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_ICON, ) @@ -124,6 +125,10 @@ async def test_media_player_music( assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) is None assert state.attributes.get(ATTR_MEDIA_SEASON) is None assert state.attributes.get(ATTR_MEDIA_EPISODE) is None + assert ( + state.attributes.get(ATTR_ENTITY_PICTURE) + == "http://localhost/Items/ALBUM-UUID/Images/Primary.jpg" + ) entry = entity_registry.async_get(state.entity_id) assert entry @@ -274,6 +279,7 @@ async def test_browse_media( "media_content_id": "COLLECTION-FOLDER-UUID", "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": "http://localhost/Items/c22fd826-17fc-44f4-9b04-1eb3e8fb9173/Images/Backdrop.jpg", "children_media_class": None, } @@ -302,6 +308,7 @@ async def test_browse_media( "media_content_id": "EPISODE-UUID", "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": "http://localhost/Items/c22fd826-17fc-44f4-9b04-1eb3e8fb9173/Images/Backdrop.jpg", "children_media_class": None, } diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index ba0a2b4835e..d6928c189e8 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -1,59 +1 @@ """Tests for the jewish_calendar component.""" - -from collections import namedtuple -from datetime import datetime - -from freezegun import freeze_time as alter_time # noqa: F401 - -from homeassistant.components import jewish_calendar -from homeassistant.util import dt as dt_util - -_LatLng = namedtuple("_LatLng", ["lat", "lng"]) # noqa: PYI024 - -HDATE_DEFAULT_ALTITUDE = 754 -NYC_LATLNG = _LatLng(40.7128, -74.0060) -JERUSALEM_LATLNG = _LatLng(31.778, 35.235) - - -def make_nyc_test_params(dtime, results, havdalah_offset=0): - """Make test params for NYC.""" - if isinstance(results, dict): - time_zone = dt_util.get_time_zone("America/New_York") - results = { - key: value.replace(tzinfo=time_zone) - if isinstance(value, datetime) - else value - for key, value in results.items() - } - return ( - dtime, - jewish_calendar.DEFAULT_CANDLE_LIGHT, - havdalah_offset, - True, - "America/New_York", - NYC_LATLNG.lat, - NYC_LATLNG.lng, - results, - ) - - -def make_jerusalem_test_params(dtime, results, havdalah_offset=0): - """Make test params for Jerusalem.""" - if isinstance(results, dict): - time_zone = dt_util.get_time_zone("Asia/Jerusalem") - results = { - key: value.replace(tzinfo=time_zone) - if isinstance(value, datetime) - else value - for key, value in results.items() - } - return ( - dtime, - jewish_calendar.DEFAULT_CANDLE_LIGHT, - havdalah_offset, - False, - "Asia/Jerusalem", - JERUSALEM_LATLNG.lat, - JERUSALEM_LATLNG.lng, - results, - ) diff --git a/tests/components/jewish_calendar/conftest.py b/tests/components/jewish_calendar/conftest.py index 97909291f27..5cd7ad34085 100644 --- a/tests/components/jewish_calendar/conftest.py +++ b/tests/components/jewish_calendar/conftest.py @@ -1,22 +1,40 @@ """Common fixtures for the jewish_calendar tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator, Iterable +import datetime as dt +from typing import NamedTuple from unittest.mock import AsyncMock, patch +from freezegun import freeze_time +from hdate.translator import set_language import pytest -from homeassistant.components.jewish_calendar.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.jewish_calendar.const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.const import CONF_LANGUAGE, CONF_TIME_ZONE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return the default mocked config entry.""" - return MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - ) +class _LocationData(NamedTuple): + timezone: str + diaspora: bool + lat: float + lng: float + candle_lighting: int + + +LOCATIONS = { + "Jerusalem": _LocationData("Asia/Jerusalem", False, 31.7683, 35.2137, 40), + "New York": _LocationData("America/New_York", True, 40.7128, -74.006, 18), +} @pytest.fixture @@ -26,3 +44,120 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.jewish_calendar.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def location_data(request: pytest.FixtureRequest) -> _LocationData | None: + """Return data based on location name.""" + if not hasattr(request, "param"): + return None + + return LOCATIONS[request.param] + + +@pytest.fixture +def tz_info(hass: HomeAssistant, location_data: _LocationData | None) -> dt.tzinfo: + """Return time zone info.""" + if location_data is None: + return dt_util.get_time_zone(hass.config.time_zone) + return dt_util.get_time_zone(location_data.timezone) + + +@pytest.fixture(name="test_time") +def _test_time( + request: pytest.FixtureRequest, tz_info: dt.tzinfo +) -> dt.datetime | None: + """Return localized test time based.""" + if not hasattr(request, "param"): + return None + + return request.param.replace(tzinfo=tz_info) + + +@pytest.fixture +def results( + request: pytest.FixtureRequest, tz_info: dt.tzinfo, language: str +) -> Iterable: + """Return localized results.""" + if not hasattr(request, "param"): + return None + + # If results are generated, by using the HDate library, we need to set the language + set_language(language) + + if isinstance(request.param, dict): + result = { + key: value.replace(tzinfo=tz_info) + if isinstance(value, dt.datetime) + else value + for key, value in request.param.items() + } + if "attr" in result and isinstance(result["attr"], dict): + result["attr"] = { + key: value() if callable(value) else value + for key, value in result["attr"].items() + } + return result + return request.param + + +@pytest.fixture +def havdalah_offset() -> int | None: + """Return None if default havdalah offset is not specified.""" + return None + + +@pytest.fixture +def language() -> str: + """Return default language value, unless language is parametrized.""" + return "en" + + +@pytest.fixture(autouse=True) +async def setup_hass(hass: HomeAssistant, location_data: _LocationData | None) -> None: + """Set up Home Assistant for testing the jewish_calendar integration.""" + + if location_data: + await hass.config.async_set_time_zone(location_data.timezone) + hass.config.latitude = location_data.lat + hass.config.longitude = location_data.lng + + +@pytest.fixture +def config_entry( + location_data: _LocationData | None, + language: str, + havdalah_offset: int | None, +) -> MockConfigEntry: + """Set up the jewish_calendar integration for testing.""" + param_data = {} + param_options = {} + + if location_data: + param_data = { + CONF_DIASPORA: location_data.diaspora, + CONF_TIME_ZONE: location_data.timezone, + } + param_options[CONF_CANDLE_LIGHT_MINUTES] = location_data.candle_lighting + + if havdalah_offset: + param_options[CONF_HAVDALAH_OFFSET_MINUTES] = havdalah_offset + + return MockConfigEntry( + title=DEFAULT_NAME, + domain=DOMAIN, + data={CONF_LANGUAGE: language, **param_data}, + options=param_options, + ) + + +@pytest.fixture +async def setup_at_time( + test_time: dt.datetime, hass: HomeAssistant, config_entry: MockConfigEntry +) -> AsyncGenerator[None]: + """Set up the jewish_calendar integration at a specific time.""" + with freeze_time(test_time): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + yield diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 5cfaaedfc72..46f5fdfcc7d 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -1,300 +1,145 @@ """The tests for the Jewish calendar binary sensors.""" from datetime import datetime as dt, timedelta -import logging +from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.jewish_calendar.const import ( - CONF_CANDLE_LIGHT_MINUTES, - CONF_DIASPORA, - CONF_HAVDALAH_OFFSET_MINUTES, - DEFAULT_NAME, - DOMAIN, -) -from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util - -from . import alter_time, make_jerusalem_test_params, make_nyc_test_params - -from tests.common import MockConfigEntry, async_fire_time_changed - -_LOGGER = logging.getLogger(__name__) +from tests.common import async_fire_time_changed MELACHA_PARAMS = [ - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 16, 0), - { - "state": STATE_ON, - "update": dt(2018, 9, 1, 20, 14), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 9, 1, 20, 14), "new_state": STATE_OFF}, + id="currently_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 20, 21), - { - "state": STATE_OFF, - "update": dt(2018, 9, 2, 6, 21), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 2, 6, 21), "new_state": STATE_OFF}, + id="after_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 7, 13, 1), - { - "state": STATE_OFF, - "update": dt(2018, 9, 7, 19, 4), - "new_state": STATE_ON, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 7, 19, 4), "new_state": STATE_ON}, + id="friday_upcoming_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 8, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 9, 9, 6, 27), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 9, 6, 27), "new_state": STATE_OFF}, + id="upcoming_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 9, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 10, 6, 28), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 9, 10, 6, 28), "new_state": STATE_ON}, + id="currently_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 10, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 11, 6, 29), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 9, 11, 6, 29), "new_state": STATE_ON}, + id="second_day_rosh_hashana_night", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 11, 11, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 11, 19, 57), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 9, 11, 19, 57), "new_state": STATE_OFF}, + id="second_day_rosh_hashana_day", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 29, 16, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 29, 19, 25), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 9, 29, 19, 25), "new_state": STATE_OFF}, + id="currently_shabbat_chol_hamoed", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 29, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 9, 30, 6, 48), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 48), "new_state": STATE_OFF}, + id="upcoming_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 30, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 10, 1, 6, 49), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 10, 1, 6, 49), "new_state": STATE_ON}, + id="currently_first_day_of_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 10, 1, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 10, 2, 6, 50), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 10, 2, 6, 50), "new_state": STATE_ON}, + id="currently_second_day_of_two_day_yomtov_in_diaspora", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 9, 29, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 9, 30, 6, 29), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 29), "new_state": STATE_OFF}, + id="upcoming_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 10, 1, 11, 25), - { - "state": STATE_ON, - "update": dt(2018, 10, 1, 19, 2), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 10, 1, 19, 2), "new_state": STATE_OFF}, + id="currently_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 10, 1, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 10, 2, 6, 31), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 10, 2, 6, 31), "new_state": STATE_OFF}, + id="after_one_day_yom_tov_in_israel", ), ] -MELACHA_TEST_IDS = [ - "currently_first_shabbat", - "after_first_shabbat", - "friday_upcoming_shabbat", - "upcoming_rosh_hashana", - "currently_rosh_hashana", - "second_day_rosh_hashana_night", - "second_day_rosh_hashana_day", - "currently_shabbat_chol_hamoed", - "upcoming_two_day_yomtov_in_diaspora", - "currently_first_day_of_two_day_yomtov_in_diaspora", - "currently_second_day_of_two_day_yomtov_in_diaspora", - "upcoming_one_day_yom_tov_in_israel", - "currently_one_day_yom_tov_in_israel", - "after_one_day_yom_tov_in_israel", -] - @pytest.mark.parametrize( - ( - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ), - MELACHA_PARAMS, - ids=MELACHA_TEST_IDS, + ("location_data", "test_time", "results"), MELACHA_PARAMS, indirect=True ) +@pytest.mark.usefixtures("setup_at_time") async def test_issur_melacha_sensor( - hass: HomeAssistant, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: dict[str, Any] ) -> None: """Test Issur Melacha sensor output.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) + sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect" + assert hass.states.get(sensor_id).state == results["state"] - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with alter_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: "english", - CONF_DIASPORA: diaspora, - CONF_CANDLE_LIGHT_MINUTES: candle_lighting, - CONF_HAVDALAH_OFFSET_MINUTES: havdalah, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result["state"] - ) - - with alter_time(result["update"]): - async_fire_time_changed(hass, result["update"]) - await hass.async_block_till_done() - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result["new_state"] - ) + freezer.move_to(results["update"]) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(sensor_id).state == results["new_state"] @pytest.mark.parametrize( - ( - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ), + ("location_data", "test_time", "results"), [ - make_nyc_test_params( - dt(2020, 10, 23, 17, 44, 59, 999999), [STATE_OFF, STATE_ON] - ), - make_nyc_test_params( - dt(2020, 10, 24, 18, 42, 59, 999999), [STATE_ON, STATE_OFF] - ), + ("New York", dt(2020, 10, 23, 17, 44, 59, 999999), [STATE_OFF, STATE_ON]), + ("New York", dt(2020, 10, 24, 18, 42, 59, 999999), [STATE_ON, STATE_OFF]), ], ids=["before_candle_lighting", "before_havdalah"], + indirect=True, ) +@pytest.mark.usefixtures("setup_at_time") async def test_issur_melacha_sensor_update( - hass: HomeAssistant, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: list[str] ) -> None: """Test Issur Melacha sensor output.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) + sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect" + assert hass.states.get(sensor_id).state == results[0] - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with alter_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: "english", - CONF_DIASPORA: diaspora, - CONF_CANDLE_LIGHT_MINUTES: candle_lighting, - CONF_HAVDALAH_OFFSET_MINUTES: havdalah, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result[0] - ) - - test_time += timedelta(microseconds=1) - with alter_time(test_time): - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result[1] - ) + freezer.tick(timedelta(microseconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(sensor_id).state == results[1] async def test_no_discovery_info( diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index e00fe41749f..7a8b6b8df1e 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -57,10 +57,10 @@ async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No async def test_single_instance_allowed( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + config_entry: MockConfigEntry, ) -> None: """Test we abort if already setup.""" - mock_config_entry.add_to_hass(hass) + config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -70,11 +70,11 @@ async def test_single_instance_allowed( assert result.get("reason") == "single_instance_allowed" -async def test_options(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: +async def test_options(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test updating options.""" - mock_config_entry.add_to_hass(hass) + 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_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -95,16 +95,16 @@ async def test_options(hass: HomeAssistant, mock_config_entry: MockConfigEntry) async def test_options_reconfigure( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test that updating the options of the Jewish Calendar integration triggers a value update.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert CONF_CANDLE_LIGHT_MINUTES not in mock_config_entry.options + assert CONF_CANDLE_LIGHT_MINUTES not in config_entry.options # Update the CONF_CANDLE_LIGHT_MINUTES option to a new value - result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ @@ -114,21 +114,17 @@ async def test_options_reconfigure( assert result["result"] # The value of the "upcoming_shabbat_candle_lighting" sensor should be the new value - assert ( - mock_config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1 - ) + assert config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1 -async def test_reconfigure( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: +async def test_reconfigure(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test starting a reconfigure flow.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # init user flow - result = await mock_config_entry.start_reconfigure_flow(hass) + result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" @@ -141,4 +137,4 @@ async def test_reconfigure( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert mock_config_entry.data[CONF_DIASPORA] is not DEFAULT_DIASPORA + assert config_entry.data[CONF_DIASPORA] is not DEFAULT_DIASPORA diff --git a/tests/components/jewish_calendar/test_init.py b/tests/components/jewish_calendar/test_init.py index cb982afec0f..88ba1334210 100644 --- a/tests/components/jewish_calendar/test_init.py +++ b/tests/components/jewish_calendar/test_init.py @@ -1 +1,44 @@ """Tests for the Jewish Calendar component's init.""" + +import pytest + +from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("old_key", "new_key"), + [ + ("first_light", "alot_hashachar"), + ("sunset", "shkia"), + ("havdalah", "havdalah"), # Test no change + ], +) +async def test_migrate_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + old_key: str, + new_key: str, +) -> None: + """Test unique id migration.""" + config_entry.add_to_hass(hass) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=f"{config_entry.entry_id}-{old_key}", + config_entry=config_entry, + ) + assert entity.unique_id.endswith(f"-{old_key}") + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{config_entry.entry_id}-{new_key}" diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index aac0f583b05..9364fcda40c 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -1,700 +1,549 @@ """The tests for the Jewish calendar sensors.""" -from datetime import datetime as dt, timedelta +from datetime import datetime as dt +from typing import Any -from hdate import htables +from hdate.holidays import HolidayDatabase +from hdate.parasha import Parasha import pytest -from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.jewish_calendar.const import ( - CONF_CANDLE_LIGHT_MINUTES, - CONF_DIASPORA, - CONF_HAVDALAH_OFFSET_MINUTES, - DEFAULT_NAME, - DOMAIN, -) -from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM +from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import alter_time, make_jerusalem_test_params, make_nyc_test_params - -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry -async def test_jewish_calendar_min_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("language", ["en", "he"]) +async def test_min_config(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test minimum jewish calendar configuration.""" - entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN, data={}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("sensor.jewish_calendar_date") is not None - - -async def test_jewish_calendar_hebrew(hass: HomeAssistant) -> None: - """Test jewish calendar sensor with language set to hebrew.""" - entry = MockConfigEntry( - title=DEFAULT_NAME, domain=DOMAIN, data={"language": "hebrew"} - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert hass.states.get("sensor.jewish_calendar_date") is not None TEST_PARAMS = [ - ( + pytest.param( + "Jerusalem", dt(2018, 9, 3), - "UTC", - 31.778, - 35.235, - "english", + {"state": "23 Elul 5778"}, + "en", "date", - False, - "23 Elul 5778", - None, + id="date_output", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 3), - "UTC", - 31.778, - 35.235, - "hebrew", + {"state": 'כ"ג אלול ה\' תשע"ח'}, + "he", "date", - False, - 'כ"ג אלול ה\' תשע"ח', - None, + id="date_output_hebrew", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 10), - "UTC", - 31.778, - 35.235, - "hebrew", + {"state": "א' ראש השנה"}, + "he", "holiday", - False, - "א' ראש השנה", - None, + id="holiday", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 10), - "UTC", - 31.778, - 35.235, - "english", - "holiday", - False, - "Rosh Hashana I", { - "device_class": "enum", - "friendly_name": "Jewish Calendar Holiday", - "icon": "mdi:calendar-star", - "id": "rosh_hashana_i", - "type": "YOM_TOV", - "type_id": 1, - "options": htables.get_all_holidays("english"), + "state": "Rosh Hashana I", + "attr": { + "device_class": "enum", + "friendly_name": "Jewish Calendar Holiday", + "icon": "mdi:calendar-star", + "id": "rosh_hashana_i", + "type": "YOM_TOV", + "options": lambda: HolidayDatabase(False).get_all_names(), + }, }, + "en", + "holiday", + id="holiday_english", ), - ( + pytest.param( + "Jerusalem", dt(2024, 12, 31), - "UTC", - 31.778, - 35.235, - "english", + { + "state": "Chanukah, Rosh Chodesh", + "attr": { + "device_class": "enum", + "friendly_name": "Jewish Calendar Holiday", + "icon": "mdi:calendar-star", + "id": "chanukah, rosh_chodesh", + "type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH", + "options": lambda: HolidayDatabase(False).get_all_names(), + }, + }, + "en", "holiday", - False, - "Chanukah, Rosh Chodesh", + id="holiday_multiple", + ), + pytest.param( + "Jerusalem", + dt(2018, 9, 8), { - "device_class": "enum", - "friendly_name": "Jewish Calendar Holiday", - "icon": "mdi:calendar-star", - "id": "chanukah, rosh_chodesh", - "type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH", - "type_id": "4, 10", - "options": htables.get_all_holidays("english"), + "state": "נצבים", + "attr": { + "device_class": "enum", + "friendly_name": "Jewish Calendar Weekly Torah portion", + "icon": "mdi:book-open-variant", + "options": [str(p) for p in Parasha], + }, }, + "he", + "weekly_torah_portion", + id="torah_portion", ), - ( + pytest.param( + "New York", dt(2018, 9, 8), - "UTC", - 31.778, - 35.235, - "hebrew", - "parshat_hashavua", - False, - "נצבים", - { - "device_class": "enum", - "friendly_name": "Jewish Calendar Parshat Hashavua", - "icon": "mdi:book-open-variant", - "options": [p.hebrew for p in htables.PARASHAOT], - }, + {"state": dt(2018, 9, 8, 19, 47)}, + "he", + "nightfall_t_set_hakochavim", + id="first_stars_ny", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 8), - "America/New_York", - 40.7128, - -74.0060, - "hebrew", - "t_set_hakochavim", - True, - dt(2018, 9, 8, 19, 45), - None, + {"state": dt(2018, 9, 8, 19, 21)}, + "he", + "nightfall_t_set_hakochavim", + id="first_stars_jerusalem", ), - ( - dt(2018, 9, 8), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", - "t_set_hakochavim", - False, - dt(2018, 9, 8, 19, 19), - None, - ), - ( + pytest.param( + "Jerusalem", dt(2018, 10, 14), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", - "parshat_hashavua", - False, - "לך לך", - None, + {"state": "לך לך"}, + "he", + "weekly_torah_portion", + id="torah_portion_weekday", ), - ( + pytest.param( + "Jerusalem", dt(2018, 10, 14, 17, 0, 0), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", + {"state": "ה' מרחשוון ה' תשע\"ט"}, + "he", "date", - False, - "ה' מרחשוון ה' תשע\"ט", - None, + id="date_before_sunset", ), - ( + pytest.param( + "Jerusalem", dt(2018, 10, 14, 19, 0, 0), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", - "date", - False, - "ו' מרחשוון ה' תשע\"ט", { - "hebrew_year": 5779, - "hebrew_month_name": "מרחשוון", - "hebrew_day": 6, - "icon": "mdi:star-david", - "friendly_name": "Jewish Calendar Date", + "state": "ו' מרחשוון ה' תשע\"ט", + "attr": { + "hebrew_year": "5779", + "hebrew_month_name": "מרחשוון", + "hebrew_day": "6", + "icon": "mdi:star-david", + "friendly_name": "Jewish Calendar Date", + }, }, + "he", + "date", + id="date_after_sunset", ), ] -TEST_IDS = [ - "date_output", - "date_output_hebrew", - "holiday", - "holiday_english", - "holiday_multiple", - "torah_reading", - "first_stars_ny", - "first_stars_jerusalem", - "torah_reading_weekday", - "date_before_sunset", - "date_after_sunset", -] - @pytest.mark.parametrize( - ( - "now", - "tzname", - "latitude", - "longitude", - "language", - "sensor", - "diaspora", - "result", - "attrs", - ), + ("location_data", "test_time", "results", "language", "sensor"), TEST_PARAMS, - ids=TEST_IDS, + indirect=["location_data", "test_time", "results"], ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") async def test_jewish_calendar_sensor( - hass: HomeAssistant, - now, - tzname, - latitude, - longitude, - language, - sensor, - diaspora, - result, - attrs, + hass: HomeAssistant, results: dict[str, Any], sensor: str ) -> None: """Test Jewish calendar sensor output.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) - - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with alter_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: language, - CONF_DIASPORA: diaspora, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = dt_util.utcnow() + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - result = ( - dt_util.as_utc(result.replace(tzinfo=time_zone)).isoformat() - if isinstance(result, dt) - else result - ) + result = results["state"] + if isinstance(result, dt): + result = dt_util.as_utc(result).isoformat() sensor_object = hass.states.get(f"sensor.jewish_calendar_{sensor}") assert sensor_object.state == result - if attrs: + if attrs := results.get("attr"): assert sensor_object.attributes == attrs SHABBAT_PARAMS = [ - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 16, 0), { - "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_havdalah": dt(2018, 9, 1, 20, 10), - "english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 10), - "english_parshat_hashavua": "Ki Tavo", - "hebrew_parshat_hashavua": "כי תבוא", + "en_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_havdalah": dt(2018, 9, 1, 20, 10), + "en_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 10), + "en_weekly_torah_portion": "Ki Tavo", + "he_weekly_torah_portion": "כי תבוא", }, + None, + id="currently_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 16, 0), { - "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_havdalah": dt(2018, 9, 1, 20, 18), - "english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 18), - "english_parshat_hashavua": "Ki Tavo", - "hebrew_parshat_hashavua": "כי תבוא", + "en_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_havdalah": dt(2018, 9, 1, 20, 18), + "en_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 18), + "en_weekly_torah_portion": "Ki Tavo", + "he_weekly_torah_portion": "כי תבוא", }, - havdalah_offset=50, + 50, # Havdalah offset + id="currently_first_shabbat_with_havdalah_offset", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 20, 0), { - "english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 10), - "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), - "english_upcoming_havdalah": dt(2018, 9, 1, 20, 10), - "english_parshat_hashavua": "Ki Tavo", - "hebrew_parshat_hashavua": "כי תבוא", + "en_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 10), + "en_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), + "en_upcoming_havdalah": dt(2018, 9, 1, 20, 10), + "en_weekly_torah_portion": "Ki Tavo", + "he_weekly_torah_portion": "כי תבוא", }, + None, + id="currently_first_shabbat_bein_hashmashot_lagging_date", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 20, 21), { - "english_upcoming_candle_lighting": dt(2018, 9, 7, 19), - "english_upcoming_havdalah": dt(2018, 9, 8, 19, 58), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 8, 19, 58), - "english_parshat_hashavua": "Nitzavim", - "hebrew_parshat_hashavua": "נצבים", + "en_upcoming_candle_lighting": dt(2018, 9, 7, 19), + "en_upcoming_havdalah": dt(2018, 9, 8, 19, 58), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 8, 19, 58), + "en_weekly_torah_portion": "Nitzavim", + "he_weekly_torah_portion": "נצבים", }, + None, + id="after_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 7, 13, 1), { - "english_upcoming_candle_lighting": dt(2018, 9, 7, 19), - "english_upcoming_havdalah": dt(2018, 9, 8, 19, 58), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 8, 19, 58), - "english_parshat_hashavua": "Nitzavim", - "hebrew_parshat_hashavua": "נצבים", + "en_upcoming_candle_lighting": dt(2018, 9, 7, 19), + "en_upcoming_havdalah": dt(2018, 9, 8, 19, 58), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 8, 19, 58), + "en_weekly_torah_portion": "Nitzavim", + "he_weekly_torah_portion": "נצבים", }, + None, + id="friday_upcoming_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 8, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), - "english_upcoming_havdalah": dt(2018, 9, 11, 19, 53), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), - "english_parshat_hashavua": "Vayeilech", - "hebrew_parshat_hashavua": "וילך", - "english_holiday": "Erev Rosh Hashana", - "hebrew_holiday": "ערב ראש השנה", + "en_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), + "en_upcoming_havdalah": dt(2018, 9, 11, 19, 53), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), + "en_weekly_torah_portion": "Vayeilech", + "he_weekly_torah_portion": "וילך", + "en_holiday": "Erev Rosh Hashana", + "he_holiday": "ערב ראש השנה", }, + None, + id="upcoming_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 9, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), - "english_upcoming_havdalah": dt(2018, 9, 11, 19, 53), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), - "english_parshat_hashavua": "Vayeilech", - "hebrew_parshat_hashavua": "וילך", - "english_holiday": "Rosh Hashana I", - "hebrew_holiday": "א' ראש השנה", + "en_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), + "en_upcoming_havdalah": dt(2018, 9, 11, 19, 53), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), + "en_weekly_torah_portion": "Vayeilech", + "he_weekly_torah_portion": "וילך", + "en_holiday": "Rosh Hashana I", + "he_holiday": "א' ראש השנה", }, + None, + id="currently_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 10, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), - "english_upcoming_havdalah": dt(2018, 9, 11, 19, 53), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), - "english_parshat_hashavua": "Vayeilech", - "hebrew_parshat_hashavua": "וילך", - "english_holiday": "Rosh Hashana II", - "hebrew_holiday": "ב' ראש השנה", + "en_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), + "en_upcoming_havdalah": dt(2018, 9, 11, 19, 53), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 48), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 46), + "en_weekly_torah_portion": "Vayeilech", + "he_weekly_torah_portion": "וילך", + "en_holiday": "Rosh Hashana II", + "he_holiday": "ב' ראש השנה", }, + None, + id="second_day_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 28, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 28, 18, 25), - "english_upcoming_havdalah": dt(2018, 9, 29, 19, 22), - "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 28, 18, 25), - "english_upcoming_shabbat_havdalah": dt(2018, 9, 29, 19, 22), - "english_parshat_hashavua": "none", - "hebrew_parshat_hashavua": "none", + "en_upcoming_candle_lighting": dt(2018, 9, 28, 18, 25), + "en_upcoming_havdalah": dt(2018, 9, 29, 19, 22), + "en_upcoming_shabbat_candle_lighting": dt(2018, 9, 28, 18, 25), + "en_upcoming_shabbat_havdalah": dt(2018, 9, 29, 19, 22), + "en_weekly_torah_portion": "none", + "he_weekly_torah_portion": "none", }, + None, + id="currently_shabbat_chol_hamoed", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 29, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), - "english_upcoming_havdalah": dt(2018, 10, 2, 19, 17), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Hoshana Raba", - "hebrew_holiday": "הושענא רבה", + "en_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), + "en_upcoming_havdalah": dt(2018, 10, 2, 19, 17), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), + "en_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", + "en_holiday": "Hoshana Raba", + "he_holiday": "הושענא רבה", }, + None, + id="upcoming_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 30, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), - "english_upcoming_havdalah": dt(2018, 10, 2, 19, 17), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Shmini Atzeret", - "hebrew_holiday": "שמיני עצרת", + "en_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), + "en_upcoming_havdalah": dt(2018, 10, 2, 19, 17), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), + "en_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", + "en_holiday": "Shmini Atzeret", + "he_holiday": "שמיני עצרת", }, + None, + id="currently_first_day_of_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 10, 1, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), - "english_upcoming_havdalah": dt(2018, 10, 2, 19, 17), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Simchat Torah", - "hebrew_holiday": "שמחת תורה", + "en_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), + "en_upcoming_havdalah": dt(2018, 10, 2, 19, 17), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 13), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 11), + "en_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", + "en_holiday": "Simchat Torah", + "he_holiday": "שמחת תורה", }, + None, + id="currently_second_day_of_two_day_yomtov_in_diaspora", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 9, 29, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 7), - "english_upcoming_havdalah": dt(2018, 10, 1, 19, 1), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 1), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Hoshana Raba", - "hebrew_holiday": "הושענא רבה", + "en_upcoming_candle_lighting": dt(2018, 9, 30, 17, 46), + "en_upcoming_havdalah": dt(2018, 10, 1, 19, 1), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), + "en_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", + "en_holiday": "Hoshana Raba", + "he_holiday": "הושענא רבה", }, + None, + id="upcoming_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 9, 30, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 7), - "english_upcoming_havdalah": dt(2018, 10, 1, 19, 1), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 1), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Shmini Atzeret", - "hebrew_holiday": "שמיני עצרת", + "en_upcoming_candle_lighting": dt(2018, 9, 30, 17, 46), + "en_upcoming_havdalah": dt(2018, 10, 1, 19, 1), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), + "en_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", + "en_holiday": "Shmini Atzeret, Simchat Torah", + "he_holiday": "שמיני עצרת, שמחת תורה", }, + None, + id="currently_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 10, 1, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 10, 5, 18, 1), - "english_upcoming_havdalah": dt(2018, 10, 6, 18, 54), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 1), - "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), - "english_parshat_hashavua": "Bereshit", - "hebrew_parshat_hashavua": "בראשית", + "en_upcoming_candle_lighting": dt(2018, 10, 5, 17, 39), + "en_upcoming_havdalah": dt(2018, 10, 6, 18, 54), + "en_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), + "en_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), + "en_weekly_torah_portion": "Bereshit", + "he_weekly_torah_portion": "בראשית", }, + None, + id="after_one_day_yom_tov_in_israel", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2016, 6, 11, 8, 25), { - "english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), - "english_upcoming_havdalah": dt(2016, 6, 13, 21, 19), - "english_upcoming_shabbat_candle_lighting": dt(2016, 6, 10, 20, 9), - "english_upcoming_shabbat_havdalah": "unknown", - "english_parshat_hashavua": "Bamidbar", - "hebrew_parshat_hashavua": "במדבר", - "english_holiday": "Erev Shavuot", - "hebrew_holiday": "ערב שבועות", + "en_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), + "en_upcoming_havdalah": dt(2016, 6, 13, 21, 19), + "en_upcoming_shabbat_candle_lighting": dt(2016, 6, 10, 20, 9), + "en_upcoming_shabbat_havdalah": "unknown", + "en_weekly_torah_portion": "Bamidbar", + "he_weekly_torah_portion": "במדבר", + "en_holiday": "Erev Shavuot", + "he_holiday": "ערב שבועות", }, + None, + id="currently_first_day_of_three_day_type1_yomtov_in_diaspora", # Type 1 = Sat/Sun/Mon ), - make_nyc_test_params( + pytest.param( + "New York", dt(2016, 6, 12, 8, 25), { - "english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), - "english_upcoming_havdalah": dt(2016, 6, 13, 21, 19), - "english_upcoming_shabbat_candle_lighting": dt(2016, 6, 17, 20, 12), - "english_upcoming_shabbat_havdalah": dt(2016, 6, 18, 21, 21), - "english_parshat_hashavua": "Nasso", - "hebrew_parshat_hashavua": "נשא", - "english_holiday": "Shavuot", - "hebrew_holiday": "שבועות", + "en_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), + "en_upcoming_havdalah": dt(2016, 6, 13, 21, 19), + "en_upcoming_shabbat_candle_lighting": dt(2016, 6, 17, 20, 12), + "en_upcoming_shabbat_havdalah": dt(2016, 6, 18, 21, 21), + "en_weekly_torah_portion": "Nasso", + "he_weekly_torah_portion": "נשא", + "en_holiday": "Shavuot", + "he_holiday": "שבועות", }, + None, + id="currently_second_day_of_three_day_type1_yomtov_in_diaspora", # Type 1 = Sat/Sun/Mon ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2017, 9, 21, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 20), - "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 12), - "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), - "english_parshat_hashavua": "Ha'Azinu", - "hebrew_parshat_hashavua": "האזינו", - "english_holiday": "Rosh Hashana I", - "hebrew_holiday": "א' ראש השנה", + "en_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), + "en_upcoming_havdalah": dt(2017, 9, 23, 19, 11), + "en_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), + "en_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), + "en_weekly_torah_portion": "Ha'Azinu", + "he_weekly_torah_portion": "האזינו", + "en_holiday": "Rosh Hashana I", + "he_holiday": "א' ראש השנה", }, + None, + id="currently_first_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2017, 9, 22, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 20), - "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 12), - "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), - "english_parshat_hashavua": "Ha'Azinu", - "hebrew_parshat_hashavua": "האזינו", - "english_holiday": "Rosh Hashana II", - "hebrew_holiday": "ב' ראש השנה", + "en_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), + "en_upcoming_havdalah": dt(2017, 9, 23, 19, 11), + "en_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), + "en_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), + "en_weekly_torah_portion": "Ha'Azinu", + "he_weekly_torah_portion": "האזינו", + "en_holiday": "Rosh Hashana II", + "he_holiday": "ב' ראש השנה", }, + None, + id="currently_second_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2017, 9, 23, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 20), - "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 12), - "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), - "english_parshat_hashavua": "Ha'Azinu", - "hebrew_parshat_hashavua": "האזינו", - "english_holiday": "", - "hebrew_holiday": "", + "en_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), + "en_upcoming_havdalah": dt(2017, 9, 23, 19, 11), + "en_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), + "en_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), + "en_weekly_torah_portion": "Ha'Azinu", + "he_weekly_torah_portion": "האזינו", + "en_holiday": "", + "he_holiday": "", }, + None, + id="currently_third_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat ), ] -SHABBAT_TEST_IDS = [ - "currently_first_shabbat", - "currently_first_shabbat_with_havdalah_offset", - "currently_first_shabbat_bein_hashmashot_lagging_date", - "after_first_shabbat", - "friday_upcoming_shabbat", - "upcoming_rosh_hashana", - "currently_rosh_hashana", - "second_day_rosh_hashana", - "currently_shabbat_chol_hamoed", - "upcoming_two_day_yomtov_in_diaspora", - "currently_first_day_of_two_day_yomtov_in_diaspora", - "currently_second_day_of_two_day_yomtov_in_diaspora", - "upcoming_one_day_yom_tov_in_israel", - "currently_one_day_yom_tov_in_israel", - "after_one_day_yom_tov_in_israel", - # Type 1 = Sat/Sun/Mon - "currently_first_day_of_three_day_type1_yomtov_in_diaspora", - "currently_second_day_of_three_day_type1_yomtov_in_diaspora", - # Type 2 = Thurs/Fri/Sat - "currently_first_day_of_three_day_type2_yomtov_in_israel", - "currently_second_day_of_three_day_type2_yomtov_in_israel", - "currently_third_day_of_three_day_type2_yomtov_in_israel", -] - -@pytest.mark.parametrize("language", ["english", "hebrew"]) +@pytest.mark.parametrize("language", ["en", "he"]) @pytest.mark.parametrize( - ( - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ), + ("location_data", "test_time", "results", "havdalah_offset"), SHABBAT_PARAMS, - ids=SHABBAT_TEST_IDS, + indirect=("location_data", "test_time", "results"), ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") async def test_shabbat_times_sensor( - hass: HomeAssistant, - language, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, + hass: HomeAssistant, results: dict[str, Any], language: str ) -> None: """Test sensor output for upcoming shabbat/yomtov times.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) - - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with alter_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: language, - CONF_DIASPORA: diaspora, - }, - options={ - CONF_CANDLE_LIGHT_MINUTES: candle_lighting, - CONF_HAVDALAH_OFFSET_MINUTES: havdalah, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = dt_util.utcnow() + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - for sensor_type, result_value in result.items(): + for sensor_type, result_value in results.items(): if not sensor_type.startswith(language): continue sensor_type = sensor_type.replace(f"{language}_", "") - result_value = ( - dt_util.as_utc(result_value).isoformat() - if isinstance(result_value, dt) - else result_value - ) + if isinstance(result_value, dt): + result_value = dt_util.as_utc(result_value).isoformat() assert hass.states.get(f"sensor.jewish_calendar_{sensor_type}").state == str( result_value ), f"Value for {sensor_type}" -OMER_PARAMS = [ - (dt(2019, 4, 21, 0), "1"), - (dt(2019, 4, 21, 23), "2"), - (dt(2019, 5, 23, 0), "33"), - (dt(2019, 6, 8, 0), "49"), - (dt(2019, 6, 9, 0), "0"), - (dt(2019, 1, 1, 0), "0"), -] -OMER_TEST_IDS = [ - "first_day_of_omer", - "first_day_of_omer_after_tzeit", - "lag_baomer", - "last_day_of_omer", - "shavuot_no_omer", - "jan_1st_no_omer", -] - - -@pytest.mark.parametrize(("test_time", "result"), OMER_PARAMS, ids=OMER_TEST_IDS) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_omer_sensor(hass: HomeAssistant, test_time, result) -> None: +@pytest.mark.parametrize( + ("test_time", "results"), + [ + pytest.param(dt(2019, 4, 21, 0), "1", id="first_day_of_omer"), + pytest.param(dt(2019, 4, 21, 23), "2", id="first_day_of_omer_after_tzeit"), + pytest.param(dt(2019, 5, 23, 0), "33", id="lag_baomer"), + pytest.param(dt(2019, 6, 8, 0), "49", id="last_day_of_omer"), + pytest.param(dt(2019, 6, 9, 0), "0", id="shavuot_no_omer"), + pytest.param(dt(2019, 1, 1, 0), "0", id="jan_1st_no_omer"), + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") +async def test_omer_sensor(hass: HomeAssistant, results: str) -> None: """Test Omer Count sensor output.""" - test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) - - with alter_time(test_time): - entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = dt_util.utcnow() + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get("sensor.jewish_calendar_day_of_the_omer").state == result + assert hass.states.get("sensor.jewish_calendar_day_of_the_omer").state == results -DAFYOMI_PARAMS = [ - (dt(2014, 4, 28, 0), "Beitzah 29"), - (dt(2020, 1, 4, 0), "Niddah 73"), - (dt(2020, 1, 5, 0), "Berachos 2"), - (dt(2020, 3, 7, 0), "Berachos 64"), - (dt(2020, 3, 8, 0), "Shabbos 2"), -] -DAFYOMI_TEST_IDS = [ - "randomly_picked_date", - "end_of_cycle13", - "start_of_cycle14", - "cycle14_end_of_berachos", - "cycle14_start_of_shabbos", -] - - -@pytest.mark.parametrize(("test_time", "result"), DAFYOMI_PARAMS, ids=DAFYOMI_TEST_IDS) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_dafyomi_sensor(hass: HomeAssistant, test_time, result) -> None: +@pytest.mark.parametrize( + ("test_time", "results"), + [ + pytest.param(dt(2014, 4, 28, 0), "Beitzah 29", id="randomly_picked_date"), + pytest.param(dt(2020, 1, 4, 0), "Niddah 73", id="end_of_cycle13"), + pytest.param(dt(2020, 1, 5, 0), "Berachos 2", id="start_of_cycle14"), + pytest.param(dt(2020, 3, 7, 0), "Berachos 64", id="cycle14_end_of_berachos"), + pytest.param(dt(2020, 3, 8, 0), "Shabbos 2", id="cycle14_start_of_shabbos"), + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") +async def test_dafyomi_sensor(hass: HomeAssistant, results: str) -> None: """Test Daf Yomi sensor output.""" - test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) - - with alter_time(test_time): - entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = dt_util.utcnow() + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == result + assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == results async def test_no_discovery_info( diff --git a/tests/components/jewish_calendar/test_service.py b/tests/components/jewish_calendar/test_service.py new file mode 100644 index 00000000000..4b3f31d11d4 --- /dev/null +++ b/tests/components/jewish_calendar/test_service.py @@ -0,0 +1,87 @@ +"""Test jewish calendar service.""" + +import datetime as dt + +import pytest + +from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + ("test_time", "service_data", "expected"), + [ + pytest.param( + dt.datetime(2025, 3, 20, 21, 0), + { + "date": dt.date(2025, 3, 20), + "nusach": "sfarad", + "language": "he", + "after_sunset": False, + }, + "", + id="no_blessing", + ), + pytest.param( + dt.datetime(2025, 3, 20, 21, 0), + { + "date": dt.date(2025, 5, 20), + "nusach": "ashkenaz", + "language": "he", + "after_sunset": False, + }, + "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים בעומר", + id="ahskenaz-hebrew", + ), + pytest.param( + dt.datetime(2025, 3, 20, 21, 0), + { + "date": dt.date(2025, 5, 20), + "nusach": "sfarad", + "language": "en", + "after_sunset": True, + }, + "Today is the thirty-eighth day, which are five weeks and three days of the Omer", + id="sefarad-english-after-sunset", + ), + pytest.param( + dt.datetime(2025, 3, 20, 21, 0), + { + "date": dt.date(2025, 5, 20), + "nusach": "sfarad", + "language": "en", + "after_sunset": False, + }, + "Today is the thirty-seventh day, which are five weeks and two days of the Omer", + id="sefarad-english-before-sunset", + ), + pytest.param( + dt.datetime(2025, 5, 20, 21, 0), + {"nusach": "sfarad", "language": "en"}, + "Today is the thirty-eighth day, which are five weeks and three days of the Omer", + id="sefarad-english-after-sunset-without-date", + ), + pytest.param( + dt.datetime(2025, 5, 20, 6, 0), + {"nusach": "sfarad"}, + "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים לעומר", + id="sefarad-english-before-sunset-without-date", + ), + ], + indirect=["test_time"], +) +@pytest.mark.usefixtures("setup_at_time") +async def test_get_omer_blessing( + hass: HomeAssistant, service_data: dict[str, str | dt.date | bool], expected: str +) -> None: + """Test get omer blessing.""" + + result = await hass.services.async_call( + DOMAIN, + "count_omer", + service_data, + blocking=True, + return_response=True, + ) + + assert result["message"] == expected diff --git a/tests/components/kitchen_sink/snapshots/test_init.ambr b/tests/components/kitchen_sink/snapshots/test_init.ambr index b91131eb2b0..fe22f19fb7a 100644 --- a/tests/components/kitchen_sink/snapshots/test_init.ambr +++ b/tests/components/kitchen_sink/snapshots/test_init.ambr @@ -48,5 +48,15 @@ 'type': 'no_state', }), ]), + 'sensor.statistics_issues_issue_5': list([ + dict({ + 'data': dict({ + 'metadata_mean_type': 1, + 'state_mean_type': 2, + 'statistic_id': 'sensor.statistics_issues_issue_5', + }), + 'type': 'mean_type_changed', + }), + ]), }) # --- diff --git a/tests/components/kitchen_sink/snapshots/test_sensor.ambr b/tests/components/kitchen_sink/snapshots/test_sensor.ambr index 7b433c40170..6cd9aa2e855 100644 --- a/tests/components/kitchen_sink/snapshots/test_sensor.ambr +++ b/tests/components/kitchen_sink/snapshots/test_sensor.ambr @@ -29,6 +29,20 @@ 'last_updated': , 'state': '1500', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_direction', + 'friendly_name': 'Statistics issues Issue 5', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Statistics issues Issue 1', @@ -99,6 +113,20 @@ 'last_updated': , 'state': '1500', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_direction', + 'friendly_name': 'Statistics issues Issue 5', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Sensor test', diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index 1eea1c8036b..88bacc2cb0b 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -96,6 +96,15 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options_1" + section_marker, section_schema = list(result["data_schema"].schema.items())[0] + assert section_marker == "section_1" + section_schema_markers = list(section_schema.schema.schema) + assert len(section_schema_markers) == 2 + assert section_schema_markers[0] == "bool" + assert section_schema_markers[0].description is None + assert section_schema_markers[1] == "int" + assert section_schema_markers[1].description == {"suggested_value": 10} + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"section_1": {"bool": True, "int": 15}}, diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 50518f89107..526801aecfa 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.statistics import ( + StatisticMeanType, async_add_external_statistics, get_last_statistics, list_statistic_ids, @@ -45,6 +46,7 @@ async def test_demo_statistics(hass: HomeAssistant) -> None: assert { "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": "Outdoor temperature", "source": DOMAIN, @@ -55,6 +57,7 @@ async def test_demo_statistics(hass: HomeAssistant) -> None: assert { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Energy consumption 1", "source": DOMAIN, diff --git a/tests/components/knocki/__init__.py b/tests/components/knocki/__init__.py index 4ebf6b0dd01..3de1e80d9e4 100644 --- a/tests/components/knocki/__init__.py +++ b/tests/components/knocki/__init__.py @@ -10,3 +10,4 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) 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/knocki/test_config_flow.py b/tests/components/knocki/test_config_flow.py index 188175035da..4affbd2a197 100644 --- a/tests/components/knocki/test_config_flow.py +++ b/tests/components/knocki/test_config_flow.py @@ -6,13 +6,23 @@ from knocki import KnockiConnectionError, KnockiInvalidAuthError import pytest from homeassistant.components.knocki.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import 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 . import setup_integration from tests.common import MockConfigEntry +DHCP_DISCOVERY = DhcpServiceInfo( + ip="1.1.1.1", + hostname="KNC1-W-00000214", + macaddress="aa:bb:cc:dd:ee:ff", +) + async def test_full_flow( hass: HomeAssistant, @@ -111,3 +121,66 @@ async def test_exceptions( }, ) assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_dhcp( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test 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 not result["errors"] + + 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["result"].unique_id == "test-id" + + +async def test_dhcp_mac( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test updating the mac address in the DHCP discovery.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, "KNC1-W-00000214")}) + assert device + assert device.connections == set() + + 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" + + device = device_registry.async_get_device(identifiers={(DOMAIN, "KNC1-W-00000214")}) + assert device + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + + +async def test_dhcp_already_setup( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery with already setup device.""" + mock_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" diff --git a/tests/components/knocki/test_event.py b/tests/components/knocki/test_event.py index 4f639e08773..27d8b93bf64 100644 --- a/tests/components/knocki/test_event.py +++ b/tests/components/knocki/test_event.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from knocki import Event, EventType, Trigger, TriggerDetails import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.knocki.const import DOMAIN from homeassistant.const import STATE_UNKNOWN diff --git a/tests/components/knx/fixtures/config_store_cover.json b/tests/components/knx/fixtures/config_store_cover.json new file mode 100644 index 00000000000..6ec8dcc90fa --- /dev/null +++ b/tests/components/knx/fixtures/config_store_cover.json @@ -0,0 +1,82 @@ +{ + "version": 1, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "cover": { + "knx_es_01JQNM9A9G03952ZH0GDF51HB6": { + "entity": { + "name": "minimal", + "entity_category": null, + "device_info": null + }, + "knx": { + "ga_up_down": { + "write": "1/0/1", + "passive": [] + }, + "travelling_time_down": 25.0, + "travelling_time_up": 25.0, + "sync_state": true + } + }, + "knx_es_01JQNQVEB7WT3MYCX61RK361F8": { + "entity": { + "name": "position_only", + "entity_category": null, + "device_info": null + }, + "knx": { + "ga_position_set": { + "write": "2/0/1", + "passive": [] + }, + "ga_position_state": { + "state": "2/0/0", + "passive": [] + }, + "invert_position": true, + "travelling_time_up": 25.0, + "travelling_time_down": 25.0, + "sync_state": true + } + }, + "knx_es_01JQNQSDS4ZW96TX27S2NT3FYQ": { + "entity": { + "name": "tiltable", + "entity_category": null, + "device_info": null + }, + "knx": { + "ga_up_down": { + "write": "3/0/1", + "passive": [] + }, + "ga_stop": { + "write": "3/0/2", + "passive": [] + }, + "ga_position_set": { + "write": "3/1/1", + "passive": [] + }, + "ga_position_state": { + "state": "3/1/0", + "passive": [] + }, + "ga_angle": { + "write": "3/2/1", + "state": "3/2/0", + "passive": [] + }, + "travelling_time_down": 16.0, + "travelling_time_up": 16.0, + "invert_angle": true, + "sync_state": true + } + } + } + } + } +} diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 3e4c9408542..6ebe8192f69 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1033,7 +1033,7 @@ async def test_form_with_automatic_connection_handling( async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: - """Return flow in secure_tunnelling menu step.""" + """Return flow in secure_tunnel menu step.""" gateway = _gateway_descriptor( "192.168.0.1", 3675, @@ -1082,7 +1082,7 @@ async def test_get_secure_menu_step_manual_tunnelling( request_description_mock: MagicMock, hass: HomeAssistant, ) -> None: - """Test flow reaches secure_tunnellinn menu step from manual tunnelling configuration.""" + """Test flow reaches secure_tunnellinn menu step from manual tunneling configuration.""" gateway = _gateway_descriptor( "192.168.0.1", 3675, @@ -1129,7 +1129,7 @@ async def test_get_secure_menu_step_manual_tunnelling( async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> None: - """Test configure tunnelling secure keys manually.""" + """Test configure tunneling secure keys manually.""" menu_step = await _get_menu_step_secure_tunnel(hass) result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py index 0604b575c5b..2bb568ceb13 100644 --- a/tests/components/knx/test_cover.py +++ b/tests/components/knx/test_cover.py @@ -1,10 +1,15 @@ """Test KNX cover.""" -from homeassistant.components.cover import CoverState +from typing import Any + +import pytest + +from homeassistant.components.cover import CoverEntityFeature, CoverState from homeassistant.components.knx.schema import CoverSchema -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import async_capture_events @@ -160,3 +165,103 @@ async def test_cover_tilt_move_short(hass: HomeAssistant, knx: KNXTestKit) -> No "cover", "open_cover_tilt", target={"entity_id": "cover.test"}, blocking=True ) await knx.assert_write("1/0/1", 0) + + +@pytest.mark.parametrize( + ("knx_data", "read_responses", "initial_state", "supported_features"), + [ + ( + { + "ga_up_down": {"write": "1/0/1"}, + "sync_state": True, + }, + {}, + STATE_UNKNOWN, + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + ), + ( + { + "ga_position_set": {"write": "2/0/1"}, + "ga_position_state": {"state": "2/0/0"}, + "sync_state": True, + }, + {"2/0/0": (0x00,)}, + CoverState.OPEN, + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION, + ), + ( + { + "ga_up_down": {"write": "3/0/1", "passive": []}, + "ga_stop": {"write": "3/0/2", "passive": []}, + "ga_position_set": {"write": "3/1/1", "passive": []}, + "ga_position_state": {"state": "3/1/0", "passive": []}, + "ga_angle": {"write": "3/2/1", "state": "3/2/0", "passive": []}, + "travelling_time_down": 16.0, + "travelling_time_up": 16.0, + "invert_angle": True, + "sync_state": True, + }, + {"3/1/0": (0x00,), "3/2/0": (0x00,)}, + CoverState.OPEN, + CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.STOP + | CoverEntityFeature.STOP_TILT, + ), + ], +) +async def test_cover_ui_create( + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_data: dict[str, Any], + read_responses: dict[str, int | tuple[int]], + initial_state: str, + supported_features: int, +) -> None: + """Test creating a cover.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.COVER, + entity_data={"name": "test"}, + knx_data=knx_data, + ) + # created entity sends read-request to KNX bus + for ga, value in read_responses.items(): + await knx.assert_read(ga, response=value, ignore_order=True) + knx.assert_state("cover.test", initial_state, supported_features=supported_features) + + +async def test_cover_ui_load(knx: KNXTestKit) -> None: + """Test loading a cover from storage.""" + await knx.setup_integration(config_store_fixture="config_store_cover.json") + + await knx.assert_read("2/0/0", response=(0xFF,), ignore_order=True) + await knx.assert_read("3/1/0", response=(0xFF,), ignore_order=True) + await knx.assert_read("3/2/0", response=(0xFF,), ignore_order=True) + + knx.assert_state( + "cover.minimal", + STATE_UNKNOWN, + supported_features=CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + ) + knx.assert_state( + "cover.position_only", + CoverState.OPEN, + supported_features=CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION, + ) + knx.assert_state( + "cover.tiltable", + CoverState.CLOSED, + supported_features=CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.STOP + | CoverEntityFeature.STOP_TILT, + ) diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index 6d4bf7e6007..3f8bc805855 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from homeassistant.components.knx.const import ( diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index 0fd790a3e33..f7a3f4e94f2 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -348,19 +348,20 @@ async def test_expose_conversion_exception( ) -@freeze_time("2022-1-7 9:13:14") +@freeze_time("2022-1-7 9:13:14") # UTC -> +1h = Vienna in winter (9 -> 0xA) @pytest.mark.parametrize( ("time_type", "raw"), [ - ("time", (0xA9, 0x0D, 0x0E)), # localtime includes day of week + ("time", (0xAA, 0x0D, 0x0E)), # localtime includes day of week ("date", (0x07, 0x01, 0x16)), - ("datetime", (0x7A, 0x1, 0x7, 0xA9, 0xD, 0xE, 0x20, 0xC0)), + ("datetime", (0x7A, 0x1, 0x7, 0xAA, 0xD, 0xE, 0x20, 0xC0)), ], ) async def test_expose_with_date( hass: HomeAssistant, knx: KNXTestKit, time_type: str, raw: tuple[int, ...] ) -> None: """Test an expose with a date.""" + await hass.config.async_set_time_zone("Europe/Vienna") await knx.setup_integration( { CONF_KNX_EXPOSE: { diff --git a/tests/components/knx/test_knx_selectors.py b/tests/components/knx/test_knx_selectors.py index 7b2f09af84b..12acf691c08 100644 --- a/tests/components/knx/test_knx_selectors.py +++ b/tests/components/knx/test_knx_selectors.py @@ -14,11 +14,49 @@ INVALID = "invalid" @pytest.mark.parametrize( ("selector_config", "data", "expected"), [ + # empty data is invalid ( {}, {}, - {"write": None, "state": None, "passive": []}, + {INVALID: "At least one group address must be set"}, ), + ( + {"write": False}, + {}, + {INVALID: "At least one group address must be set"}, + ), + ( + {"passive": False}, + {}, + {INVALID: "At least one group address must be set"}, + ), + ( + {"write": False, "state": False, "passive": False}, + {}, + {INVALID: "At least one group address must be set"}, + ), + # stale data is invalid + ( + {"write": False}, + {"write": "1/2/3"}, + {INVALID: "At least one group address must be set"}, + ), + ( + {"write": False}, + {"passive": []}, + {INVALID: "At least one group address must be set"}, + ), + ( + {"state": False}, + {"write": None}, + {INVALID: "At least one group address must be set"}, + ), + ( + {"passive": False}, + {"passive": ["1/2/3"]}, + {INVALID: "At least one group address must be set"}, + ), + # valid data ( {}, {"write": "1/2/3"}, @@ -39,11 +77,6 @@ INVALID = "invalid" {"write": "1", "state": 2, "passive": ["1/2/3"]}, {"write": "1", "state": 2, "passive": ["1/2/3"]}, ), - ( - {"write": False}, - {"write": "1/2/3"}, - {"state": None, "passive": []}, - ), ( {"write": False}, {"state": "1/2/3"}, @@ -54,11 +87,6 @@ INVALID = "invalid" {"passive": ["1/2/3"]}, {"state": None, "passive": ["1/2/3"]}, ), - ( - {"passive": False}, - {"passive": ["1/2/3"]}, - {"write": None, "state": None}, - ), ( {"passive": False}, {"write": "1/2/3"}, @@ -68,12 +96,12 @@ INVALID = "invalid" ( {"write_required": True}, {}, - INVALID, + {INVALID: r"required key not provided*"}, ), ( {"state_required": True}, {}, - INVALID, + {INVALID: r"required key not provided*"}, ), ( {"write_required": True}, @@ -88,18 +116,18 @@ INVALID = "invalid" ( {"write_required": True}, {"state": "1/2/3"}, - INVALID, + {INVALID: r"required key not provided*"}, ), ( {"state_required": True}, {"write": "1/2/3"}, - INVALID, + {INVALID: r"required key not provided*"}, ), # dpt key ( {"dpt": ColorTempModes}, {"write": "1/2/3"}, - INVALID, + {INVALID: r"required key not provided*"}, ), ( {"dpt": ColorTempModes}, @@ -109,19 +137,19 @@ INVALID = "invalid" ( {"dpt": ColorTempModes}, {"write": "1/2/3", "state": None, "passive": [], "dpt": "invalid"}, - INVALID, + {INVALID: r"value must be one of ['5.001', '7.600', '9']*"}, ), ], ) def test_ga_selector( selector_config: dict[str, Any], data: dict[str, Any], - expected: str | dict[str, Any], + expected: dict[str, Any], ) -> None: """Test GASelector.""" selector = GASelector(**selector_config) - if expected == INVALID: - with pytest.raises(vol.Invalid): + if INVALID in expected: + with pytest.raises(vol.Invalid, match=expected[INVALID]): selector(data) else: result = selector(data) diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index bd9b9ad278d..b4e7ffc0695 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -8,6 +8,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.kostal_plenticore.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -74,7 +75,7 @@ async def test_form_g1( return_value={"scb:network": {"Hostname": "scb"}} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -86,15 +87,15 @@ async def test_form_g1( mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") mock_apiclient.__aenter__.assert_called_once() mock_apiclient.__aexit__.assert_called_once() - mock_apiclient.login.assert_called_once_with("test-password") + mock_apiclient.login.assert_called_once_with("test-password", service_code=None) mock_apiclient.get_settings.assert_called_once() mock_apiclient.get_setting_values.assert_called_once_with( "scb:network", "Hostname" ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "scb" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "scb" + assert result["data"] == { "host": "1.1.1.1", "password": "test-password", } @@ -140,7 +141,7 @@ async def test_form_g2( return_value={"scb:network": {"Network:Hostname": "scb"}} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -152,21 +153,91 @@ async def test_form_g2( mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") mock_apiclient.__aenter__.assert_called_once() mock_apiclient.__aexit__.assert_called_once() - mock_apiclient.login.assert_called_once_with("test-password") + mock_apiclient.login.assert_called_once_with("test-password", service_code=None) mock_apiclient.get_settings.assert_called_once() mock_apiclient.get_setting_values.assert_called_once_with( "scb:network", "Network:Hostname" ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "scb" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "scb" + assert result["data"] == { "host": "1.1.1.1", "password": "test-password", } assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_g2_with_service_code( + hass: HomeAssistant, + mock_apiclient_class: type[ApiClient], + mock_apiclient: ApiClient, +) -> None: + """Test the config flow for G2 models with a Service Code.""" + + 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.kostal_plenticore.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + # mock of the context manager instance + mock_apiclient.login = AsyncMock() + mock_apiclient.get_settings = AsyncMock( + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Network:Hostname", + type="string", + ), + ] + } + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={"scb:network": {"Network:Hostname": "scb"}} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + "service_code": "test-service-code", + }, + ) + await hass.async_block_till_done() + + mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") + mock_apiclient.__aenter__.assert_called_once() + mock_apiclient.__aexit__.assert_called_once() + mock_apiclient.login.assert_called_once_with( + "test-password", service_code="test-service-code" + ) + mock_apiclient.get_settings.assert_called_once() + mock_apiclient.get_setting_values.assert_called_once_with( + "scb:network", "Network:Hostname" + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "scb" + assert result["data"] == { + "host": "1.1.1.1", + "password": "test-password", + "service_code": "test-service-code", + } + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -189,7 +260,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: mock_api_class.return_value = mock_api - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -197,8 +268,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"password": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"password": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -223,7 +294,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: mock_api_class.return_value = mock_api - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -231,8 +302,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"host": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"host": "cannot_connect"} async def test_form_unexpected_error(hass: HomeAssistant) -> None: @@ -257,7 +328,7 @@ async def test_form_unexpected_error(hass: HomeAssistant) -> None: mock_api_class.return_value = mock_api - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -265,8 +336,8 @@ async def test_form_unexpected_error(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} async def test_already_configured(hass: HomeAssistant) -> None: @@ -281,7 +352,7 @@ async def test_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": "1.1.1.1", @@ -289,5 +360,197 @@ async def test_already_configured(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reconfigure( + hass: HomeAssistant, + mock_apiclient_class: type[ApiClient], + mock_apiclient: ApiClient, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the config flow for G1 models.""" + + 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 of the context manager instance + mock_apiclient.login = AsyncMock() + mock_apiclient.get_settings = AsyncMock( + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ), + ] + } + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={"scb:network": {"Hostname": "scb"}} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") + mock_apiclient.__aenter__.assert_called_once() + mock_apiclient.__aexit__.assert_called_once() + mock_apiclient.login.assert_called_once_with("test-password", service_code=None) + mock_apiclient.get_settings.assert_called_once() + mock_apiclient.get_setting_values.assert_called_once_with("scb:network", "Hostname") + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data[CONF_HOST] == "1.1.1.1" + assert mock_config_entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_invalid_auth( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle invalid auth while reconfiguring.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.kostal_plenticore.config_flow.ApiClient" + ) as mock_api_class: + # mock of the context manager instance + mock_api_ctx = MagicMock() + mock_api_ctx.login = AsyncMock( + side_effect=AuthenticationException(404, "invalid user"), + ) + + # mock of the return instance of ApiClient + mock_api = MagicMock() + mock_api.__aenter__.return_value = mock_api_ctx + mock_api.__aexit__.return_value = None + + mock_api_class.return_value = mock_api + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"password": "invalid_auth"} + + +async def test_reconfigure_cannot_connect( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle cannot connect error.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.kostal_plenticore.config_flow.ApiClient" + ) as mock_api_class: + # mock of the context manager instance + mock_api_ctx = MagicMock() + mock_api_ctx.login = AsyncMock( + side_effect=TimeoutError(), + ) + + # mock of the return instance of ApiClient + mock_api = MagicMock() + mock_api.__aenter__.return_value = mock_api_ctx + mock_api.__aexit__.return_value = None + + mock_api_class.return_value = mock_api + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"host": "cannot_connect"} + + +async def test_reconfigure_unexpected_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle unexpected error.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.kostal_plenticore.config_flow.ApiClient" + ) as mock_api_class: + # mock of the context manager instance + mock_api_ctx = MagicMock() + mock_api_ctx.login = AsyncMock( + side_effect=Exception(), + ) + + # mock of the return instance of ApiClient + mock_api = MagicMock() + mock_api.__aenter__.return_value = mock_api_ctx + mock_api.__aexit__.return_value = None + + mock_api_class.return_value = mock_api + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_reconfigure_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle already configured error.""" + mock_config_entry.add_to_hass(hass) + MockConfigEntry( + domain="kostal_plenticore", + data={CONF_HOST: "1.1.1.1", CONF_PASSWORD: "foobar"}, + unique_id="112233445566", + ).add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/kulersky/test_config_flow.py b/tests/components/kulersky/test_config_flow.py index a2f3949bd07..7615e94d2f0 100644 --- a/tests/components/kulersky/test_config_flow.py +++ b/tests/components/kulersky/test_config_flow.py @@ -1,105 +1,182 @@ """Test the Kuler Sky config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, Mock, patch import pykulersky -from homeassistant import config_entries +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.kulersky.config_flow import DOMAIN +from homeassistant.config_entries import ( + SOURCE_BLUETOOTH, + SOURCE_INTEGRATION_DISCOVERY, + SOURCE_USER, +) +from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device -async def test_flow_success(hass: HomeAssistant) -> None: - """Test we get the form.""" +KULERSKY_SERVICE_INFO = BluetoothServiceInfoBleak( + name="KulerLight", + manufacturer_data={}, + service_data={}, + service_uuids=["8d96a001-0002-64c2-0001-9acc4838521c"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="KulerLight", + manufacturer_data={}, + service_data={}, + service_uuids=["8d96a001-0002-64c2-0001-9acc4838521c"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "KulerLight"), + time=0, + connectable=True, + tx_power=-127, +) + +async def test_bluetooth_discovery(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=KULERSKY_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "user" - light = MagicMock(spec=pykulersky.Light) - light.address = "AA:BB:CC:11:22:33" - light.name = "Bedroom" - with ( - patch( - "homeassistant.components.kulersky.config_flow.pykulersky.discover", - return_value=[light], - ), - patch( - "homeassistant.components.kulersky.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( + with patch("pykulersky.Light", Mock(return_value=AsyncMock())): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {}, + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Kuler Sky" - assert result2["data"] == {} - - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "KulerLight (EEFF)" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } -async def test_flow_no_devices_found(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_integration_discovery(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" + with patch( + "homeassistant.components.kulersky.config_flow.async_last_service_info", + return_value=KULERSKY_SERVICE_INFO, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "KulerLight (EEFF)" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } + + +async def test_integration_discovery_no_last_service_info(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "AA:BB:CC:DD:EE:FF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } + + +async def test_user_setup(hass: HomeAssistant) -> None: + """Test the user manually setting up the integration.""" + with patch( + "homeassistant.components.kulersky.config_flow.async_discovered_service_info", + return_value=[ + KULERSKY_SERVICE_INFO, + KULERSKY_SERVICE_INFO, + ], # Pass twice to test duplicate logic + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("pykulersky.Light", Mock(return_value=AsyncMock())): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "KulerLight (EEFF)" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } + + +async def test_user_setup_no_devices(hass: HomeAssistant) -> None: + """Test the user manually setting up the integration.""" + with patch( + "homeassistant.components.kulersky.config_flow.async_discovered_service_info", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test a connection error trying to set up.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=KULERSKY_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "user" - with ( - patch( - "homeassistant.components.kulersky.config_flow.pykulersky.discover", - return_value=[], - ), - patch( - "homeassistant.components.kulersky.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( + with patch("pykulersky.Light", Mock(side_effect=pykulersky.PykulerskyException)): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {}, + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "no_devices_found" - assert len(mock_setup_entry.mock_calls) == 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" -async def test_flow_exceptions_caught(hass: HomeAssistant) -> None: - """Test we get the form.""" - +async def test_unexpected_error(hass: HomeAssistant) -> None: + """Test an unexpected error trying to set up.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=KULERSKY_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "user" - with ( - patch( - "homeassistant.components.kulersky.config_flow.pykulersky.discover", - side_effect=pykulersky.PykulerskyException("TEST"), - ), - patch( - "homeassistant.components.kulersky.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( + with patch("pykulersky.Light", Mock(side_effect=Exception)): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {}, + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "no_devices_found" - assert len(mock_setup_entry.mock_calls) == 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "unknown" diff --git a/tests/components/kulersky/test_init.py b/tests/components/kulersky/test_init.py new file mode 100644 index 00000000000..54c5f146a61 --- /dev/null +++ b/tests/components/kulersky/test_init.py @@ -0,0 +1,65 @@ +"""Tests for init methods.""" + +from homeassistant.components.kulersky.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_migrate_entry( + hass: HomeAssistant, +) -> None: + """Test migrate config entry from v1 to v2.""" + + mock_config_entry_v1 = MockConfigEntry( + version=1, + domain=DOMAIN, + title="KulerSky", + ) + + mock_config_entry_v1.add_to_hass(hass) + + dev_reg = dr.async_get(hass) + # Create device registry entries for old integration + dev_reg.async_get_or_create( + config_entry_id=mock_config_entry_v1.entry_id, + identifiers={(DOMAIN, "AA:BB:CC:11:22:33")}, + name="KuLight 1", + ) + dev_reg.async_get_or_create( + config_entry_id=mock_config_entry_v1.entry_id, + identifiers={(DOMAIN, "AA:BB:CC:44:55:66")}, + name="KuLight 2", + ) + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry_v1.version == 2 + assert mock_config_entry_v1.unique_id == "AA:BB:CC:11:22:33" + assert mock_config_entry_v1.data == { + CONF_ADDRESS: "AA:BB:CC:11:22:33", + } + + +async def test_migrate_entry_no_devices_found( + hass: HomeAssistant, +) -> None: + """Test migrate config entry from v1 to v2.""" + + mock_config_entry_v1 = MockConfigEntry( + version=1, + domain=DOMAIN, + title="KulerSky", + ) + + mock_config_entry_v1.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.state is ConfigEntryState.MIGRATION_ERROR + assert mock_config_entry_v1.version == 1 diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index 230a2562282..bde60579af7 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -1,16 +1,13 @@ """Test the Kuler Sky lights.""" -from collections.abc import AsyncGenerator -from unittest.mock import MagicMock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch +from bleak.backends.device import BLEDevice import pykulersky import pytest -from homeassistant.components.kulersky.const import ( - DATA_ADDRESSES, - DATA_DISCOVERY_SUBSCRIPTION, - DOMAIN, -) +from homeassistant.components.kulersky.const import DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -26,6 +23,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, + CONF_ADDRESS, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -37,26 +35,43 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.fixture +def mock_ble_device() -> Generator[MagicMock]: + """Mock BLEDevice.""" + with patch( + "homeassistant.components.kulersky.async_ble_device_from_address", + return_value=BLEDevice( + address="AA:BB:CC:11:22:33", name="Bedroom", rssi=-50, details={} + ), + ) as ble_device: + yield ble_device + + @pytest.fixture async def mock_entry() -> MockConfigEntry: """Create a mock light entity.""" - return MockConfigEntry(domain=DOMAIN) + return MockConfigEntry( + domain=DOMAIN, + data={CONF_ADDRESS: "AA:BB:CC:11:22:33"}, + title="Bedroom", + version=2, + ) @pytest.fixture async def mock_light( - hass: HomeAssistant, mock_entry: MockConfigEntry -) -> AsyncGenerator[MagicMock]: - """Create a mock light entity.""" - - light = MagicMock(spec=pykulersky.Light) + hass: HomeAssistant, mock_entry: MockConfigEntry, mock_ble_device: MagicMock +) -> Generator[AsyncMock]: + """Mock pykulersky light.""" + light = AsyncMock() light.address = "AA:BB:CC:11:22:33" light.name = "Bedroom" light.connect.return_value = True light.get_color.return_value = (0, 0, 0, 0) + with patch( - "homeassistant.components.kulersky.light.pykulersky.discover", - return_value=[light], + "pykulersky.Light", + return_value=light, ): mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) @@ -67,7 +82,7 @@ async def mock_light( yield light -async def test_init(hass: HomeAssistant, mock_light: MagicMock) -> None: +async def test_init(hass: HomeAssistant, mock_light: AsyncMock) -> None: """Test platform setup.""" state = hass.states.get("light.bedroom") assert state.state == STATE_OFF @@ -83,24 +98,14 @@ async def test_init(hass: HomeAssistant, mock_light: MagicMock) -> None: ATTR_RGBW_COLOR: None, } - with patch.object(hass.loop, "stop"): - await hass.async_stop() - await hass.async_block_till_done() - - assert mock_light.disconnect.called - async def test_remove_entry( hass: HomeAssistant, mock_light: MagicMock, mock_entry: MockConfigEntry ) -> None: """Test platform setup.""" - assert hass.data[DOMAIN][DATA_ADDRESSES] == {"AA:BB:CC:11:22:33"} - assert DATA_DISCOVERY_SUBSCRIPTION in hass.data[DOMAIN] - await hass.config_entries.async_remove(mock_entry.entry_id) assert mock_light.disconnect.called - assert DOMAIN not in hass.data async def test_remove_entry_exceptions_caught( diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py index f6ca0fe40df..80493aa83c9 100644 --- a/tests/components/lamarzocco/__init__.py +++ b/tests/components/lamarzocco/__init__.py @@ -1,6 +1,6 @@ """Mock inputs for tests.""" -from pylamarzocco.const import MachineModel +from pylamarzocco.const import ModelName from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -19,10 +19,10 @@ PASSWORD_SELECTION = { USER_INPUT = PASSWORD_SELECTION | {CONF_USERNAME: "username"} SERIAL_DICT = { - MachineModel.GS3_AV: "GS012345", - MachineModel.GS3_MP: "GS012345", - MachineModel.LINEA_MICRA: "MR012345", - MachineModel.LINEA_MINI: "LM012345", + ModelName.GS3_AV: "GS012345", + ModelName.GS3_MP: "GS012345", + ModelName.LINEA_MICRA: "MR012345", + ModelName.LINEA_MINI: "LM012345", } WAKE_UP_SLEEP_ENTRY_IDS = ["Os2OswX", "aXFz5bJ"] @@ -37,15 +37,13 @@ async def async_init_integration( await hass.async_block_till_done() -def get_bluetooth_service_info( - model: MachineModel, serial: str -) -> BluetoothServiceInfo: +def get_bluetooth_service_info(model: ModelName, serial: str) -> BluetoothServiceInfo: """Return a mocked BluetoothServiceInfo.""" - if model in (MachineModel.GS3_AV, MachineModel.GS3_MP): + if model in (ModelName.GS3_AV, ModelName.GS3_MP): name = f"GS3_{serial}" - elif model == MachineModel.LINEA_MINI: + elif model == ModelName.LINEA_MINI: name = f"MINI_{serial}" - elif model == MachineModel.LINEA_MICRA: + elif model == ModelName.LINEA_MICRA: name = f"MICRA_{serial}" return BluetoothServiceInfo( name=name, diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index 658e0dd96bc..ccfea1243bc 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -1,37 +1,26 @@ """Lamarzocco session fixtures.""" from collections.abc import Generator -import json -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch from bleak.backends.device import BLEDevice -from pylamarzocco.const import FirmwareType, MachineModel, SteamLevel -from pylamarzocco.devices.machine import LaMarzoccoMachine -from pylamarzocco.models import LaMarzoccoDeviceInfo +from pylamarzocco.const import ModelName +from pylamarzocco.models import ( + Thing, + ThingDashboardConfig, + ThingSchedulingSettings, + ThingSettings, + ThingStatistics, +) import pytest from homeassistant.components.lamarzocco.const import DOMAIN -from homeassistant.const import ( - CONF_ADDRESS, - CONF_HOST, - CONF_MODEL, - CONF_NAME, - CONF_TOKEN, -) +from homeassistant.const import CONF_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from . import SERIAL_DICT, USER_INPUT, async_init_integration -from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.lamarzocco.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -42,33 +31,11 @@ def mock_config_entry( return MockConfigEntry( title="My LaMarzocco", domain=DOMAIN, - version=2, + version=3, data=USER_INPUT | { - CONF_MODEL: mock_lamarzocco.model, CONF_ADDRESS: "00:00:00:00:00:00", - CONF_HOST: "host", CONF_TOKEN: "token", - CONF_NAME: "GS3", - }, - unique_id=mock_lamarzocco.serial_number, - ) - - -@pytest.fixture -def mock_config_entry_no_local_connection( - hass: HomeAssistant, mock_lamarzocco: MagicMock -) -> MockConfigEntry: - """Return the default mocked config entry.""" - return MockConfigEntry( - title="My LaMarzocco", - domain=DOMAIN, - version=2, - data=USER_INPUT - | { - CONF_MODEL: mock_lamarzocco.model, - CONF_TOKEN: "token", - CONF_NAME: "GS3", }, unique_id=mock_lamarzocco.serial_number, ) @@ -85,26 +52,13 @@ async def init_integration( @pytest.fixture -def device_fixture() -> MachineModel: +def device_fixture() -> ModelName: """Return the device fixture for a specific device.""" - return MachineModel.GS3_AV + return ModelName.GS3_AV -@pytest.fixture -def mock_device_info(device_fixture: MachineModel) -> LaMarzoccoDeviceInfo: - """Return a mocked La Marzocco device info.""" - return LaMarzoccoDeviceInfo( - model=device_fixture, - serial_number=SERIAL_DICT[device_fixture], - name="GS3", - communication_key="token", - ) - - -@pytest.fixture -def mock_cloud_client( - mock_device_info: LaMarzoccoDeviceInfo, -) -> Generator[MagicMock]: +@pytest.fixture(autouse=True) +def mock_cloud_client() -> Generator[MagicMock]: """Return a mocked LM cloud client.""" with ( patch( @@ -117,54 +71,50 @@ def mock_cloud_client( ), ): client = cloud_client.return_value - client.get_customer_fleet.return_value = { - mock_device_info.serial_number: mock_device_info - } + client.list_things.return_value = [ + Thing.from_dict(load_json_object_fixture("thing.json", DOMAIN)) + ] + client.get_thing_settings.return_value = ThingSettings.from_dict( + load_json_object_fixture("settings.json", DOMAIN) + ) yield client @pytest.fixture -def mock_lamarzocco(device_fixture: MachineModel) -> Generator[MagicMock]: +def mock_lamarzocco(device_fixture: ModelName) -> Generator[MagicMock]: """Return a mocked LM client.""" - model = device_fixture - serial_number = SERIAL_DICT[model] - - dummy_machine = LaMarzoccoMachine( - model=model, - serial_number=serial_number, - name=serial_number, - ) - if device_fixture == MachineModel.LINEA_MINI: + if device_fixture == ModelName.LINEA_MINI: config = load_json_object_fixture("config_mini.json", DOMAIN) + elif device_fixture == ModelName.LINEA_MICRA: + config = load_json_object_fixture("config_micra.json", DOMAIN) else: - config = load_json_object_fixture("config.json", DOMAIN) - statistics = json.loads(load_fixture("statistics.json", DOMAIN)) - - dummy_machine.parse_config(config) - dummy_machine.parse_statistics(statistics) + config = load_json_object_fixture("config_gs3.json", DOMAIN) + schedule = load_json_object_fixture("schedule.json", DOMAIN) + settings = load_json_object_fixture("settings.json", DOMAIN) + statistics = load_json_object_fixture("statistics.json", DOMAIN) with ( patch( "homeassistant.components.lamarzocco.LaMarzoccoMachine", autospec=True, - ) as lamarzocco_mock, + ) as machine_mock_init, ): - lamarzocco = lamarzocco_mock.return_value + machine_mock = machine_mock_init.return_value - lamarzocco.name = dummy_machine.name - lamarzocco.model = dummy_machine.model - lamarzocco.serial_number = dummy_machine.serial_number - lamarzocco.full_model_name = dummy_machine.full_model_name - lamarzocco.config = dummy_machine.config - lamarzocco.statistics = dummy_machine.statistics - lamarzocco.firmware = dummy_machine.firmware - lamarzocco.steam_level = SteamLevel.LEVEL_1 - - lamarzocco.firmware[FirmwareType.GATEWAY].latest_version = "v3.5-rc3" - lamarzocco.firmware[FirmwareType.MACHINE].latest_version = "1.55" - - yield lamarzocco + machine_mock.serial_number = SERIAL_DICT[device_fixture] + machine_mock.dashboard = ThingDashboardConfig.from_dict(config) + machine_mock.schedule = ThingSchedulingSettings.from_dict(schedule) + machine_mock.settings = ThingSettings.from_dict(settings) + machine_mock.statistics = ThingStatistics.from_dict(statistics) + machine_mock.dashboard.model_name = device_fixture + machine_mock.to_dict.return_value = { + "serial_number": machine_mock.serial_number, + "dashboard": machine_mock.dashboard.to_dict(), + "schedule": machine_mock.schedule.to_dict(), + "settings": machine_mock.settings.to_dict(), + } + yield machine_mock @pytest.fixture(autouse=True) @@ -178,3 +128,13 @@ def mock_ble_device() -> BLEDevice: return BLEDevice( "00:00:00:00:00:00", "GS_GS012345", details={"path": "path"}, rssi=50 ) + + +@pytest.fixture +def mock_websocket_terminated() -> Generator[bool]: + """Mock websocket terminated.""" + with patch( + "homeassistant.components.lamarzocco.coordinator.LaMarzoccoUpdateCoordinator.websocket_terminated", + new=False, + ) as mock_websocket_terminated: + yield mock_websocket_terminated diff --git a/tests/components/lamarzocco/fixtures/config.json b/tests/components/lamarzocco/fixtures/config.json deleted file mode 100644 index ea6e2ee76b8..00000000000 --- a/tests/components/lamarzocco/fixtures/config.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "version": "v1", - "preinfusionModesAvailable": ["ByDoseType"], - "machineCapabilities": [ - { - "family": "GS3AV", - "groupsNumber": 1, - "coffeeBoilersNumber": 1, - "hasCupWarmer": false, - "steamBoilersNumber": 1, - "teaDosesNumber": 1, - "machineModes": ["BrewingMode", "StandBy"], - "schedulingType": "weeklyScheduling" - } - ], - "machine_sn": "Sn01239157", - "machine_hw": "2", - "isPlumbedIn": true, - "isBackFlushEnabled": false, - "standByTime": 0, - "smartStandBy": { - "enabled": true, - "minutes": 10, - "mode": "LastBrewing" - }, - "tankStatus": true, - "groupCapabilities": [ - { - "capabilities": { - "groupType": "AV_Group", - "groupNumber": "Group1", - "boilerId": "CoffeeBoiler1", - "hasScale": false, - "hasFlowmeter": true, - "numberOfDoses": 4 - }, - "doses": [ - { - "groupNumber": "Group1", - "doseIndex": "DoseA", - "doseType": "PulsesType", - "stopTarget": 135 - }, - { - "groupNumber": "Group1", - "doseIndex": "DoseB", - "doseType": "PulsesType", - "stopTarget": 97 - }, - { - "groupNumber": "Group1", - "doseIndex": "DoseC", - "doseType": "PulsesType", - "stopTarget": 108 - }, - { - "groupNumber": "Group1", - "doseIndex": "DoseD", - "doseType": "PulsesType", - "stopTarget": 121 - } - ], - "doseMode": { - "groupNumber": "Group1", - "brewingType": "PulsesType" - } - } - ], - "machineMode": "BrewingMode", - "teaDoses": { - "DoseA": { - "doseIndex": "DoseA", - "stopTarget": 8 - } - }, - "boilers": [ - { - "id": "SteamBoiler", - "isEnabled": true, - "target": 123.90000152587891, - "current": 123.80000305175781 - }, - { - "id": "CoffeeBoiler1", - "isEnabled": true, - "target": 95, - "current": 96.5 - } - ], - "boilerTargetTemperature": { - "SteamBoiler": 123.90000152587891, - "CoffeeBoiler1": 95 - }, - "preinfusionMode": { - "Group1": { - "groupNumber": "Group1", - "preinfusionStyle": "PreinfusionByDoseType" - } - }, - "preinfusionSettings": { - "mode": "TypeB", - "Group1": [ - { - "groupNumber": "Group1", - "doseType": "DoseA", - "preWetTime": 0.5, - "preWetHoldTime": 1 - }, - { - "groupNumber": "Group1", - "doseType": "DoseB", - "preWetTime": 0.5, - "preWetHoldTime": 1 - }, - { - "groupNumber": "Group1", - "doseType": "DoseC", - "preWetTime": 3.2999999523162842, - "preWetHoldTime": 3.2999999523162842 - }, - { - "groupNumber": "Group1", - "doseType": "DoseD", - "preWetTime": 2, - "preWetHoldTime": 2 - } - ] - }, - "wakeUpSleepEntries": [ - { - "days": [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday" - ], - "enabled": true, - "id": "Os2OswX", - "steam": true, - "timeOff": "24:0", - "timeOn": "22:0" - }, - { - "days": ["sunday"], - "enabled": true, - "id": "aXFz5bJ", - "steam": true, - "timeOff": "7:30", - "timeOn": "7:0" - } - ], - "clock": "1901-07-08T10:29:00", - "firmwareVersions": [ - { - "name": "machine_firmware", - "fw_version": "1.40" - }, - { - "name": "gateway_firmware", - "fw_version": "v3.1-rc4" - } - ] -} diff --git a/tests/components/lamarzocco/fixtures/config_gs3.json b/tests/components/lamarzocco/fixtures/config_gs3.json new file mode 100644 index 00000000000..80f535328d5 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/config_gs3.json @@ -0,0 +1,377 @@ +{ + "serialNumber": "GS012345", + "type": "CoffeeMachine", + "name": "GS012345", + "location": "HOME", + "modelCode": "GS3AV", + "modelName": "GS3AV", + "connected": true, + "connectionDate": 1742489087479, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/gs3av/gs3av-1.png", + "bleAuthToken": null, + "widgets": [ + { + "code": "CMMachineStatus", + "index": 1, + "output": { + "status": "PoweredOn", + "availableModes": ["BrewingMode", "StandBy"], + "mode": "BrewingMode", + "nextStatus": { + "status": "StandBy", + "startTime": 1742857195332 + }, + "brewingStartTime": 1746641060000 + }, + "tutorialUrl": null + }, + { + "code": "CMCoffeeBoiler", + "index": 1, + "output": { + "status": "Ready", + "enabled": true, + "enabledSupported": false, + "targetTemperature": 95.0, + "targetTemperatureMin": 80, + "targetTemperatureMax": 110, + "targetTemperatureStep": 0.1, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerTemperature", + "index": 1, + "output": { + "status": "Off", + "enabled": true, + "enabledSupported": true, + "targetTemperature": 123.9, + "targetTemperatureSupported": true, + "targetTemperatureMin": 95, + "targetTemperatureMax": 140, + "targetTemperatureStep": 0.1, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMGroupDoses", + "index": 1, + "output": { + "mirrorWithGroup1Supported": false, + "mirrorWithGroup1": null, + "mirrorWithGroup1NotEffective": false, + "availableModes": ["PulsesType"], + "mode": "PulsesType", + "profile": null, + "doses": { + "PulsesType": [ + { + "doseIndex": "DoseA", + "dose": 126.0, + "doseMin": 0, + "doseMax": 9999, + "doseStep": 1 + }, + { + "doseIndex": "DoseB", + "dose": 126.0, + "doseMin": 0, + "doseMax": 9999, + "doseStep": 1 + }, + { + "doseIndex": "DoseC", + "dose": 160.0, + "doseMin": 0, + "doseMax": 9999, + "doseStep": 1 + }, + { + "doseIndex": "DoseD", + "dose": 77.0, + "doseMin": 0, + "doseMax": 9999, + "doseStep": 1 + } + ] + }, + "continuousDoseSupported": false, + "continuousDose": null, + "brewingPressureSupported": false, + "brewingPressure": null + }, + "tutorialUrl": null + }, + { + "code": "CMPreBrewing", + "index": 1, + "output": { + "availableModes": ["PreBrewing", "PreInfusion", "Disabled"], + "mode": "PreInfusion", + "times": { + "PreBrewing": [ + { + "doseIndex": "DoseA", + "seconds": { + "In": 0.5, + "Out": 1.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 10, + "Out": 10 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseB", + "seconds": { + "In": 0.5, + "Out": 1.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 10, + "Out": 10 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseC", + "seconds": { + "In": 3.3, + "Out": 3.3 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 10, + "Out": 10 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseD", + "seconds": { + "In": 2.0, + "Out": 2.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 10, + "Out": 10 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + } + ], + "PreInfusion": [ + { + "doseIndex": "DoseA", + "seconds": { + "In": 0.0, + "Out": 4.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 25, + "Out": 25 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseB", + "seconds": { + "In": 0.0, + "Out": 4.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 25, + "Out": 25 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseC", + "seconds": { + "In": 0.0, + "Out": 4.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 25, + "Out": 25 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + }, + { + "doseIndex": "DoseD", + "seconds": { + "In": 0.0, + "Out": 4.0 + }, + "secondsMin": { + "In": 0, + "Out": 0 + }, + "secondsMax": { + "In": 25, + "Out": 25 + }, + "secondsStep": { + "In": 0.1, + "Out": 0.1 + } + } + ] + }, + "doseIndexSupported": true + }, + "tutorialUrl": "https://www.lamarzocco.com/it/en/app/support/brewing-features/#gs3-av-linea-micra-linea-mini-home" + }, + { + "code": "CMHotWaterDose", + "index": 1, + "output": { + "enabledSupported": false, + "enabled": true, + "doses": [ + { + "doseIndex": "DoseA", + "dose": 8.0, + "doseMin": 0, + "doseMax": 90, + "doseStep": 1 + } + ] + }, + "tutorialUrl": null + }, + { + "code": "CMBackFlush", + "index": 1, + "output": { + "lastCleaningStartTime": 1743236747166, + "status": "Off" + }, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/cleaning-and-backflush/#gs3-av" + } + ], + "invalidWidgets": [ + { + "code": "CMMachineGroupStatus", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerLevel", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreExtraction", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusionEnable", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusion", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brewing-features/#commercial" + }, + { + "code": "CMBrewByWeightDoses", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brew-by-weight" + }, + { + "code": "CMCupWarmer", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMAutoFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMRinseFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMNoWater", + "index": 1, + "output": null, + "tutorialUrl": null + } + ], + "runningCommands": [] +} diff --git a/tests/components/lamarzocco/fixtures/config_micra.json b/tests/components/lamarzocco/fixtures/config_micra.json new file mode 100644 index 00000000000..64345c93682 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/config_micra.json @@ -0,0 +1,237 @@ +{ + "serialNumber": "MR012345", + "type": "CoffeeMachine", + "name": "MR012345", + "location": null, + "modelCode": "LINEAMICRA", + "modelName": "LINEA MICRA", + "connected": true, + "connectionDate": 1742526019892, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png", + "bleAuthToken": null, + "widgets": [ + { + "code": "CMMachineStatus", + "index": 1, + "output": { + "status": "StandBy", + "availableModes": ["BrewingMode", "StandBy"], + "mode": "StandBy", + "nextStatus": null, + "brewingStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMCoffeeBoiler", + "index": 1, + "output": { + "status": "StandBy", + "enabled": true, + "enabledSupported": false, + "targetTemperature": 94.0, + "targetTemperatureMin": 80, + "targetTemperatureMax": 100, + "targetTemperatureStep": 0.1, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerLevel", + "index": 1, + "output": { + "status": "StandBy", + "enabled": true, + "enabledSupported": true, + "targetLevel": "Level3", + "targetLevelSupported": true, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMPreExtraction", + "index": 1, + "output": { + "availableModes": ["PreBrewing", "PreInfusion", "Disabled"], + "mode": "PreInfusion", + "times": { + "In": { + "seconds": 0.0, + "secondsMin": { + "PreBrewing": 2, + "PreInfusion": 2 + }, + "secondsMax": { + "PreBrewing": 9, + "PreInfusion": 9 + }, + "secondsStep": { + "PreBrewing": 0.1, + "PreInfusion": 0.1 + } + }, + "Out": { + "seconds": 4.0, + "secondsMin": { + "PreBrewing": 1, + "PreInfusion": 1 + }, + "secondsMax": { + "PreBrewing": 9, + "PreInfusion": 25 + }, + "secondsStep": { + "PreBrewing": 0.1, + "PreInfusion": 0.1 + } + } + } + }, + "tutorialUrl": null + }, + { + "code": "CMPreBrewing", + "index": 1, + "output": { + "availableModes": ["PreBrewing", "PreInfusion", "Disabled"], + "mode": "PreInfusion", + "times": { + "PreInfusion": [ + { + "doseIndex": "ByGroup", + "seconds": { + "Out": 4.0, + "In": 0.0 + }, + "secondsMin": { + "Out": 1, + "In": 1 + }, + "secondsMax": { + "Out": 25, + "In": 25 + }, + "secondsStep": { + "Out": 0.1, + "In": 0.1 + } + } + ], + "PreBrewing": [ + { + "doseIndex": "ByGroup", + "seconds": { + "Out": 5.0, + "In": 5.0 + }, + "secondsMin": { + "Out": 1, + "In": 1 + }, + "secondsMax": { + "Out": 9, + "In": 9 + }, + "secondsStep": { + "Out": 0.1, + "In": 0.1 + } + } + ] + }, + "doseIndexSupported": false + }, + "tutorialUrl": "https://www.lamarzocco.com/it/en/app/support/brewing-features/#gs3-av-linea-micra-linea-mini-home" + }, + { + "code": "CMBackFlush", + "index": 1, + "output": { + "lastCleaningStartTime": null, + "status": "Off" + }, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/cleaning-and-backflush/#linea-micra" + } + ], + "invalidWidgets": [ + { + "code": "CMMachineGroupStatus", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerTemperature", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMGroupDoses", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusionEnable", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusion", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brewing-features/#commercial" + }, + { + "code": "CMBrewByWeightDoses", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brew-by-weight" + }, + { + "code": "CMCupWarmer", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMHotWaterDose", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMAutoFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMRinseFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMNoWater", + "index": 1, + "output": null, + "tutorialUrl": null + } + ], + "runningCommands": [] +} diff --git a/tests/components/lamarzocco/fixtures/config_mini.json b/tests/components/lamarzocco/fixtures/config_mini.json index 22533a94872..a5a285800e9 100644 --- a/tests/components/lamarzocco/fixtures/config_mini.json +++ b/tests/components/lamarzocco/fixtures/config_mini.json @@ -1,116 +1,284 @@ { - "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": [ + "serialNumber": "LM012345", + "type": "CoffeeMachine", + "name": "LM012345", + "location": null, + "modelCode": "LINEAMINI", + "modelName": "LINEA MINI", + "connected": true, + "connectionDate": 1742683649814, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": true, + "coffeeStation": { + "id": "a59cd870-dc75-428f-b73e-e5a247c6db73", + "name": "My coffee station", + "coffeeMachine": { + "serialNumber": "LM012345", + "type": "CoffeeMachine", + "name": null, + "location": null, + "modelCode": "LINEAMINI", + "modelName": "LINEA MINI", + "connected": true, + "connectionDate": 1742683649814, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": true, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/list/lineamini/lineamini-1-c-nero_op.png", + "bleAuthToken": null + }, + "grinders": [], + "accessories": [ { - "groupNumber": "Group1", - "doseType": "DoseA", - "preWetTime": 2, - "preWetHoldTime": 3 + "type": "ScaleAcaiaLunar", + "name": "LMZ-123A12", + "connected": false, + "batteryLevel": null, + "imageUrl": null } ] }, - "wakeUpSleepEntries": [ + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamini/lineamini-1-c-nero_op.png", + "bleAuthToken": null, + "widgets": [ { - "id": "T6aLl42", - "days": [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday" - ], - "steam": false, - "enabled": false, - "timeOn": "24:0", - "timeOff": "24:0" + "code": "CMMachineStatus", + "index": 1, + "output": { + "status": "StandBy", + "availableModes": ["BrewingMode", "StandBy"], + "mode": "StandBy", + "nextStatus": null, + "brewingStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMCoffeeBoiler", + "index": 1, + "output": { + "status": "StandBy", + "enabled": true, + "enabledSupported": false, + "targetTemperature": 90.0, + "targetTemperatureMin": 80, + "targetTemperatureMax": 100, + "targetTemperatureStep": 0.1, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerTemperature", + "index": 1, + "output": { + "status": "Off", + "enabled": false, + "enabledSupported": true, + "targetTemperature": 0.0, + "targetTemperatureSupported": false, + "targetTemperatureMin": 95, + "targetTemperatureMax": 140, + "targetTemperatureStep": 0.1, + "readyStartTime": null + }, + "tutorialUrl": null + }, + { + "code": "CMPreExtraction", + "index": 1, + "output": { + "availableModes": ["PreBrewing", "Disabled"], + "mode": "Disabled", + "times": { + "In": { + "seconds": 2.0, + "secondsMin": { + "PreBrewing": 2, + "PreInfusion": 2 + }, + "secondsMax": { + "PreBrewing": 9, + "PreInfusion": 9 + }, + "secondsStep": { + "PreBrewing": 0.1, + "PreInfusion": 0.1 + } + }, + "Out": { + "seconds": 3.0, + "secondsMin": { + "PreBrewing": 1, + "PreInfusion": 1 + }, + "secondsMax": { + "PreBrewing": 9, + "PreInfusion": 25 + }, + "secondsStep": { + "PreBrewing": 0.1, + "PreInfusion": 0.1 + } + } + } + }, + "tutorialUrl": null + }, + { + "code": "CMPreBrewing", + "index": 1, + "output": { + "availableModes": ["PreBrewing", "Disabled"], + "mode": "Disabled", + "times": { + "PreBrewing": [ + { + "doseIndex": "ByGroup", + "seconds": { + "Out": 3.0, + "In": 2.0 + }, + "secondsMin": { + "Out": 1, + "In": 1 + }, + "secondsMax": { + "Out": 9, + "In": 9 + }, + "secondsStep": { + "Out": 0.1, + "In": 0.1 + } + } + ] + }, + "doseIndexSupported": false + }, + "tutorialUrl": "https://www.lamarzocco.com/it/en/app/support/brewing-features/#gs3-av-linea-micra-linea-mini-home" + }, + { + "code": "CMBrewByWeightDoses", + "index": 1, + "output": { + "scaleConnected": false, + "availableModes": ["Continuous"], + "mode": "Continuous", + "doses": { + "Dose1": { + "dose": 34.5, + "doseMin": 5, + "doseMax": 100, + "doseStep": 0.1 + }, + "Dose2": { + "dose": 17.5, + "doseMin": 5, + "doseMax": 100, + "doseStep": 0.1 + } + } + }, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brew-by-weight" + }, + { + "code": "CMBackFlush", + "index": 1, + "output": { + "lastCleaningStartTime": 1742731776135, + "status": "Off" + }, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/cleaning-and-backflush/#linea-mini" + }, + { + "code": "ThingScale", + "index": 2, + "output": { + "name": "LMZ-123A12", + "connected": false, + "batteryLevel": 0.0, + "calibrationRequired": false + }, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/training-scale-location/#linea-mini" } ], - "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" } - ] + "invalidWidgets": [ + { + "code": "CMMachineGroupStatus", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamBoilerLevel", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMGroupDoses", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusionEnable", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMPreInfusion", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/brewing-features/#commercial" + }, + { + "code": "CMCupWarmer", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMHotWaterDose", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMAutoFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMRinseFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMSteamFlush", + "index": 1, + "output": null, + "tutorialUrl": null + }, + { + "code": "CMNoWater", + "index": 1, + "output": { + "allarm": false + }, + "tutorialUrl": null + }, + { + "code": "ThingScale", + "index": 1, + "output": null, + "tutorialUrl": "http://lamarzocco.com/it/en/app/support/training-scale-location/#linea-mini" + } + ], + "runningCommands": [] } diff --git a/tests/components/lamarzocco/fixtures/schedule.json b/tests/components/lamarzocco/fixtures/schedule.json new file mode 100644 index 00000000000..1767503f5b9 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/schedule.json @@ -0,0 +1,61 @@ +{ + "serialNumber": "MR123456", + "type": "CoffeeMachine", + "name": "MR123456", + "location": null, + "modelCode": "LINEAMICRA", + "modelName": "LINEA MICRA", + "connected": true, + "connectionDate": 1742526019892, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png", + "bleAuthToken": null, + "smartWakeUpSleepSupported": true, + "smartWakeUpSleep": { + "smartStandByEnabled": true, + "smartStandByMinutes": 10, + "smartStandByMinutesMin": 1, + "smartStandByMinutesMax": 30, + "smartStandByMinutesStep": 1, + "smartStandByAfter": "PowerOn", + "schedules": [ + { + "id": "Os2OswX", + "enabled": true, + "onTimeMinutes": 1320, + "offTimeMinutes": 1440, + "days": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ], + "steamBoiler": true + }, + { + "id": "aXFz5bJ", + "enabled": true, + "onTimeMinutes": 420, + "offTimeMinutes": 450, + "days": ["Sunday"], + "steamBoiler": false + } + ] + }, + "smartWakeUpSleepTutorialUrl": "https://www.lamarzocco.com/it/en/app/support/scheduling/#gs3-linea-micra-linea-mini-home", + "weeklySupported": false, + "weekly": null, + "weeklyTutorialUrl": "https://www.lamarzocco.com/it/en/app/support/scheduling/#linea-classic-s", + "autoOnOffSupported": false, + "autoOnOff": null, + "autoOnOffTutorialUrl": "https://www.lamarzocco.com/it/en/app/support/scheduling/#gb5-s-x-kb90-linea-pb-pbx-strada-s-x-commercial", + "autoStandBySupported": false, + "autoStandBy": null, + "autoStandByTutorialUrl": null +} diff --git a/tests/components/lamarzocco/fixtures/settings.json b/tests/components/lamarzocco/fixtures/settings.json new file mode 100644 index 00000000000..a2bd27febb2 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/settings.json @@ -0,0 +1,50 @@ +{ + "serialNumber": "MR123456", + "type": "CoffeeMachine", + "name": "MR123456", + "location": null, + "modelCode": "LINEAMICRA", + "modelName": "LINEA MICRA", + "connected": true, + "connectionDate": 1742526019892, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png", + "bleAuthToken": null, + "actualFirmwares": [ + { + "type": "Gateway", + "buildVersion": "v5.0.9", + "changeLog": "What’s new in this version:\n\n* New La Marzocco compatibility\n* Improved connectivity\n* Improved pairing process\n* Improved statistics\n* Boilers heating time\n* Last backflush date (GS3 MP excluded)\n* Automatic gateway updates option", + "thingModelCode": "LineaMicra", + "status": "ToUpdate", + "availableUpdate": { + "type": "Gateway", + "buildVersion": "v5.0.10", + "changeLog": "What’s new in this version:\n\n* fixed an issue that could cause the machine powers up outside scheduled time\n* minor improvements", + "thingModelCode": "LineaMicra" + } + }, + { + "type": "Machine", + "buildVersion": "v1.17", + "changeLog": null, + "thingModelCode": "LineaMicra", + "status": "Updated", + "availableUpdate": null + } + ], + "wifiSsid": "MyWifi", + "wifiRssi": -51, + "plumbInSupported": true, + "isPlumbedIn": true, + "cropsterSupported": false, + "cropsterActive": null, + "hemroSupported": false, + "hemroActive": null, + "factoryResetSupported": true, + "autoUpdateSupported": true, + "autoUpdate": false +} diff --git a/tests/components/lamarzocco/fixtures/statistics.json b/tests/components/lamarzocco/fixtures/statistics.json index c82d02cc7c1..0c333457d69 100644 --- a/tests/components/lamarzocco/fixtures/statistics.json +++ b/tests/components/lamarzocco/fixtures/statistics.json @@ -1,26 +1,183 @@ -[ - { - "count": 1047, - "coffeeType": 0 - }, - { - "count": 560, - "coffeeType": 1 - }, - { - "count": 468, - "coffeeType": 2 - }, - { - "count": 312, - "coffeeType": 3 - }, - { - "count": 2252, - "coffeeType": 4 - }, - { - "coffeeType": -1, - "count": 1740 - } -] +{ + "serialNumber": "MR123456", + "type": "CoffeeMachine", + "name": "MR123456", + "location": null, + "modelCode": "LINEAMICRA", + "modelName": "LINEA MICRA", + "connected": true, + "connectionDate": 1742526019892, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png", + "bleAuthToken": null, + "firmwares": null, + "selectedWidgetCodes": ["COFFEE_AND_FLUSH_TREND", "LAST_COFFEE"], + "allWidgetCodes": ["LAST_COFFEE", "COFFEE_AND_FLUSH_TREND"], + "selectedWidgets": [ + { + "code": "COFFEE_AND_FLUSH_TREND", + "index": 1, + "output": { + "days": 7, + "timezone": "Europe/Berlin", + "coffees": [ + { "timestamp": 1741993200000, "value": 2 }, + { "timestamp": 1742079600000, "value": 2 }, + { "timestamp": 1742166000000, "value": 2 }, + { "timestamp": 1742252400000, "value": 2 }, + { "timestamp": 1742338800000, "value": 4 }, + { "timestamp": 1742425200000, "value": 3 }, + { "timestamp": 1742511600000, "value": 1 } + ], + "flushes": [ + { "timestamp": 1741993200000, "value": 1 }, + { "timestamp": 1742079600000, "value": 1 }, + { "timestamp": 1742166000000, "value": 0 }, + { "timestamp": 1742252400000, "value": 0 }, + { "timestamp": 1742338800000, "value": 4 }, + { "timestamp": 1742425200000, "value": 2 }, + { "timestamp": 1742511600000, "value": 1 } + ] + } + }, + { + "code": "LAST_COFFEE", + "index": 1, + "output": { + "lastCoffees": [ + { + "time": 1742535679203, + "extractionSeconds": 30.44, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742489827722, + "extractionSeconds": 10.8, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742448826919, + "extractionSeconds": 12.457, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742448702812, + "extractionSeconds": 23.504, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742396255439, + "extractionSeconds": 16.031, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742396142154, + "extractionSeconds": 27.413, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742364379903, + "extractionSeconds": 14.182, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742364235304, + "extractionSeconds": 23.228, + "doseMode": "Continuous", + "doseIndex": "Continuous", + "doseValue": null, + "doseValueNumerator": null + }, + { + "time": 1742277098548, + "extractionSeconds": 12.98, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742277006774, + "extractionSeconds": 26.99, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742190219197, + "extractionSeconds": 11.069, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742190123385, + "extractionSeconds": 35.472, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742106228119, + "extractionSeconds": 11.494, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742106147433, + "extractionSeconds": 39.915, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + }, + { + "time": 1742017890205, + "extractionSeconds": 13.891, + "doseMode": "PulsesType", + "doseIndex": "DoseA", + "doseValue": 0, + "doseValueNumerator": null + } + ] + } + }, + { + "code": "COFFEE_AND_FLUSH_COUNTER", + "index": 1, + "output": { + "totalCoffee": 1620, + "totalFlush": 1366 + } + } + ] +} diff --git a/tests/components/lamarzocco/fixtures/thing.json b/tests/components/lamarzocco/fixtures/thing.json new file mode 100644 index 00000000000..4265ad9ed8d --- /dev/null +++ b/tests/components/lamarzocco/fixtures/thing.json @@ -0,0 +1,16 @@ +{ + "serialNumber": "GS012345", + "type": "CoffeeMachine", + "name": "GS012345", + "location": "HOME", + "modelCode": "GS3AV", + "modelName": "GS3AV", + "connected": true, + "connectionDate": 1742489087479, + "offlineMode": false, + "requireFirmwareUpdate": false, + "availableFirmwareUpdate": false, + "coffeeStation": null, + "imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/gs3av/gs3av-1.png", + "bleAuthToken": null +} diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr index 6cd4e8cd5ae..0e772fb9653 100644 --- a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -143,21 +143,7 @@ '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 +# name: test_binary_sensors[binary_sensor.gs012345_websocket_connected-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -170,7 +156,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.lmz_123a45_connectivity', + 'entity_id': 'binary_sensor.gs012345_websocket_connected', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -182,12 +168,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Connectivity', + 'original_name': 'WebSocket connected', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'LM012345_connected', + 'translation_key': 'websocket_connected', + 'unique_id': 'GS012345_websocket_connected', 'unit_of_measurement': None, }) # --- +# name: test_binary_sensors[binary_sensor.gs012345_websocket_connected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'GS012345 WebSocket connected', + }), + 'context': , + 'entity_id': 'binary_sensor.gs012345_websocket_connected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index b1d8140b2ce..9dcef0fe0f0 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -1,111 +1,773 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'config': dict({ - 'backflush_enabled': False, - 'bbw_settings': None, - 'boilers': dict({ - 'CoffeeBoiler1': dict({ - 'current_temperature': 96.5, - 'enabled': True, - 'target_temperature': 95, - }), - 'SteamBoiler': dict({ - 'current_temperature': 123.80000305175781, - 'enabled': True, - 'target_temperature': 123.9000015258789, - }), - }), - 'brew_active': False, - 'brew_active_duration': 0, - 'dose_hot_water': 8, - 'doses': dict({ - '1': 135, - '2': 97, - '3': 108, - '4': 121, - }), - 'plumbed_in': True, - 'prebrew_configuration': dict({ - '1': dict({ - 'off_time': 1, - 'on_time': 0.5, - }), - '2': dict({ - 'off_time': 1, - 'on_time': 0.5, - }), - '3': dict({ - 'off_time': 3.299999952316284, - 'on_time': 3.299999952316284, - }), - '4': dict({ - 'off_time': 2, - 'on_time': 2, - }), - }), - 'prebrew_mode': 'TypeB', - 'scale': None, - 'smart_standby': dict({ - 'enabled': True, - 'minutes': 10, - 'mode': 'LastBrewing', - }), - 'turned_on': True, - 'wake_up_sleep_entries': dict({ - 'Os2OswX': dict({ - 'days': list([ - 'monday', - 'tuesday', - 'wednesday', - 'thursday', - 'friday', - 'saturday', - 'sunday', - ]), - 'enabled': True, - 'entry_id': 'Os2OswX', - 'steam': True, - 'time_off': '24:0', - 'time_on': '22:0', - }), - 'aXFz5bJ': dict({ - 'days': list([ - 'sunday', - ]), - 'enabled': True, - 'entry_id': 'aXFz5bJ', - 'steam': True, - 'time_off': '7:30', - 'time_on': '7:0', - }), - }), - 'water_contact': True, + 'bluetooth_available': dict({ + 'mac': False, + 'options_enabled': True, + 'token': True, }), - 'firmware': list([ - dict({ - 'machine': dict({ - 'current_version': '1.40', - 'latest_version': '1.55', + 'device': dict({ + 'dashboard': dict({ + 'available_firmware_update': False, + 'ble_auth_token': None, + 'coffee_station': None, + 'config': dict({ + 'CMBackFlush': dict({ + 'last_cleaning_start_time': '2025-03-29T08:25:47.166000+00:00', + 'status': 'Off', + }), + 'CMCoffeeBoiler': dict({ + 'enabled': True, + 'enabled_supported': False, + 'ready_start_time': None, + 'status': 'Ready', + 'target_temperature': 95.0, + 'target_temperature_max': 110, + 'target_temperature_min': 80, + 'target_temperature_step': 0.1, + }), + 'CMGroupDoses': dict({ + 'available_modes': list([ + 'PulsesType', + ]), + 'brewing_pressure': None, + 'brewing_pressure_supported': False, + 'continuous_dose': None, + 'continuous_dose_supported': False, + 'doses': dict({ + 'pulses_type': list([ + dict({ + 'dose': 126.0, + 'dose_index': 'DoseA', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 126.0, + 'dose_index': 'DoseB', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 160.0, + 'dose_index': 'DoseC', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 77.0, + 'dose_index': 'DoseD', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + ]), + }), + 'mirror_with_group_1': None, + 'mirror_with_group_1_not_effective': False, + 'mirror_with_group_1_supported': False, + 'mode': 'PulsesType', + 'profile': None, + }), + 'CMHotWaterDose': dict({ + 'doses': list([ + dict({ + 'dose': 8.0, + 'dose_index': 'DoseA', + 'dose_max': 90.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + ]), + 'enabled': True, + 'enabled_supported': False, + }), + 'CMMachineStatus': dict({ + 'available_modes': list([ + 'BrewingMode', + 'StandBy', + ]), + 'brewing_start_time': '2025-05-07T18:04:20+00:00', + 'mode': 'BrewingMode', + 'next_status': dict({ + 'start_time': '2025-03-24T22:59:55.332000+00:00', + 'status': 'StandBy', + }), + 'status': 'PoweredOn', + }), + 'CMPreBrewing': dict({ + 'available_modes': list([ + 'PreBrewing', + 'PreInfusion', + 'Disabled', + ]), + 'dose_index_supported': True, + 'mode': 'PreInfusion', + 'times': dict({ + 'pre_brewing': list([ + dict({ + 'dose_index': 'DoseA', + 'seconds': dict({ + 'In': 0.5, + 'Out': 1.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseB', + 'seconds': dict({ + 'In': 0.5, + 'Out': 1.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseC', + 'seconds': dict({ + 'In': 3.3, + 'Out': 3.3, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseD', + 'seconds': dict({ + 'In': 2.0, + 'Out': 2.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + ]), + 'pre_infusion': list([ + dict({ + 'dose_index': 'DoseA', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseB', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseC', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseD', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + ]), + }), + }), + 'CMSteamBoilerTemperature': dict({ + 'enabled': True, + 'enabled_supported': True, + 'ready_start_time': None, + 'status': 'Off', + 'target_temperature': 123.9, + 'target_temperature_max': 140, + 'target_temperature_min': 95, + 'target_temperature_step': 0.1, + 'target_temperature_supported': True, + }), }), + 'connected': True, + 'connection_date': '2025-03-20T16:44:47.479000+00:00', + 'image_url': 'https://lion.lamarzocco.io/img/thing-model/detail/gs3av/gs3av-1.png', + 'location': 'HOME', + 'model_code': 'GS3AV', + 'model_name': 'GS3 AV', + 'name': 'GS012345', + 'offline_mode': False, + 'require_firmware_update': False, + 'serial_number': '**REDACTED**', + 'type': 'CoffeeMachine', + 'widgets': list([ + dict({ + 'code': 'CMMachineStatus', + 'index': 1, + 'output': dict({ + 'available_modes': list([ + 'BrewingMode', + 'StandBy', + ]), + 'brewing_start_time': '2025-05-07T18:04:20+00:00', + 'mode': 'BrewingMode', + 'next_status': dict({ + 'start_time': '2025-03-24T22:59:55.332000+00:00', + 'status': 'StandBy', + }), + 'status': 'PoweredOn', + }), + }), + dict({ + 'code': 'CMCoffeeBoiler', + 'index': 1, + 'output': dict({ + 'enabled': True, + 'enabled_supported': False, + 'ready_start_time': None, + 'status': 'Ready', + 'target_temperature': 95.0, + 'target_temperature_max': 110, + 'target_temperature_min': 80, + 'target_temperature_step': 0.1, + }), + }), + dict({ + 'code': 'CMSteamBoilerTemperature', + 'index': 1, + 'output': dict({ + 'enabled': True, + 'enabled_supported': True, + 'ready_start_time': None, + 'status': 'Off', + 'target_temperature': 123.9, + 'target_temperature_max': 140, + 'target_temperature_min': 95, + 'target_temperature_step': 0.1, + 'target_temperature_supported': True, + }), + }), + dict({ + 'code': 'CMGroupDoses', + 'index': 1, + 'output': dict({ + 'available_modes': list([ + 'PulsesType', + ]), + 'brewing_pressure': None, + 'brewing_pressure_supported': False, + 'continuous_dose': None, + 'continuous_dose_supported': False, + 'doses': dict({ + 'pulses_type': list([ + dict({ + 'dose': 126.0, + 'dose_index': 'DoseA', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 126.0, + 'dose_index': 'DoseB', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 160.0, + 'dose_index': 'DoseC', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + dict({ + 'dose': 77.0, + 'dose_index': 'DoseD', + 'dose_max': 9999.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + ]), + }), + 'mirror_with_group_1': None, + 'mirror_with_group_1_not_effective': False, + 'mirror_with_group_1_supported': False, + 'mode': 'PulsesType', + 'profile': None, + }), + }), + dict({ + 'code': 'CMPreBrewing', + 'index': 1, + 'output': dict({ + 'available_modes': list([ + 'PreBrewing', + 'PreInfusion', + 'Disabled', + ]), + 'dose_index_supported': True, + 'mode': 'PreInfusion', + 'times': dict({ + 'pre_brewing': list([ + dict({ + 'dose_index': 'DoseA', + 'seconds': dict({ + 'In': 0.5, + 'Out': 1.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseB', + 'seconds': dict({ + 'In': 0.5, + 'Out': 1.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseC', + 'seconds': dict({ + 'In': 3.3, + 'Out': 3.3, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseD', + 'seconds': dict({ + 'In': 2.0, + 'Out': 2.0, + }), + 'seconds_max': dict({ + 'In': 10.0, + 'Out': 10.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + ]), + 'pre_infusion': list([ + dict({ + 'dose_index': 'DoseA', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseB', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseC', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + dict({ + 'dose_index': 'DoseD', + 'seconds': dict({ + 'In': 0.0, + 'Out': 4.0, + }), + 'seconds_max': dict({ + 'In': 25.0, + 'Out': 25.0, + }), + 'seconds_min': dict({ + 'In': 0.0, + 'Out': 0.0, + }), + 'seconds_step': dict({ + 'In': 0.1, + 'Out': 0.1, + }), + }), + ]), + }), + }), + }), + dict({ + 'code': 'CMHotWaterDose', + 'index': 1, + 'output': dict({ + 'doses': list([ + dict({ + 'dose': 8.0, + 'dose_index': 'DoseA', + 'dose_max': 90.0, + 'dose_min': 0.0, + 'dose_step': 1, + }), + ]), + 'enabled': True, + 'enabled_supported': False, + }), + }), + dict({ + 'code': 'CMBackFlush', + 'index': 1, + 'output': dict({ + 'last_cleaning_start_time': '2025-03-29T08:25:47.166000+00:00', + 'status': 'Off', + }), + }), + ]), }), - dict({ - 'gateway': dict({ - 'current_version': 'v3.1-rc4', - 'latest_version': 'v3.5-rc3', + 'schedule': dict({ + 'available_firmware_update': False, + 'ble_auth_token': None, + 'coffee_station': None, + 'connected': True, + 'connection_date': '2025-03-21T03:00:19.892000+00:00', + 'image_url': 'https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png', + 'location': None, + 'model_code': 'LINEAMICRA', + 'model_name': 'Linea Micra', + 'name': 'MR123456', + 'offline_mode': False, + 'require_firmware_update': False, + 'serial_number': '**REDACTED**', + 'smart_wake_up_sleep': dict({ + 'schedules': list([ + dict({ + 'days': list([ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]), + 'enabled': True, + 'id': 'Os2OswX', + 'offTimeMinutes': 1440, + 'onTimeMinutes': 1320, + 'steamBoiler': True, + }), + dict({ + 'days': list([ + 'Sunday', + ]), + 'enabled': True, + 'id': 'aXFz5bJ', + 'offTimeMinutes': 450, + 'onTimeMinutes': 420, + 'steamBoiler': False, + }), + ]), + 'schedules_dict': dict({ + 'Os2OswX': dict({ + 'days': list([ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]), + 'enabled': True, + 'id': 'Os2OswX', + 'offTimeMinutes': 1440, + 'onTimeMinutes': 1320, + 'steamBoiler': True, + }), + 'aXFz5bJ': dict({ + 'days': list([ + 'Sunday', + ]), + 'enabled': True, + 'id': 'aXFz5bJ', + 'offTimeMinutes': 450, + 'onTimeMinutes': 420, + 'steamBoiler': False, + }), + }), + 'smart_stand_by_after': 'PowerOn', + 'smart_stand_by_enabled': True, + 'smart_stand_by_minutes': 10, + 'smart_stand_by_minutes_max': 30, + 'smart_stand_by_minutes_min': 1, + 'smart_stand_by_minutes_step': 1, }), + 'smart_wake_up_sleep_supported': True, + 'type': 'CoffeeMachine', }), - ]), - 'model': 'GS3 AV', - 'statistics': dict({ - 'continous': 2252, - 'drink_stats': dict({ - '1': 1047, - '2': 560, - '3': 468, - '4': 312, + 'serial_number': '**REDACTED**', + 'settings': dict({ + 'actual_firmwares': list([ + dict({ + 'available_update': dict({ + 'build_version': 'v5.0.10', + 'change_log': ''' + What’s new in this version: + + * fixed an issue that could cause the machine powers up outside scheduled time + * minor improvements + ''', + 'thing_model_code': 'LineaMicra', + 'type': 'Gateway', + }), + 'build_version': 'v5.0.9', + 'change_log': ''' + What’s new in this version: + + * New La Marzocco compatibility + * Improved connectivity + * Improved pairing process + * Improved statistics + * Boilers heating time + * Last backflush date (GS3 MP excluded) + * Automatic gateway updates option + ''', + 'status': 'ToUpdate', + 'thing_model_code': 'LineaMicra', + 'type': 'Gateway', + }), + dict({ + 'available_update': None, + 'build_version': 'v1.17', + 'change_log': 'None', + 'status': 'Updated', + 'thing_model_code': 'LineaMicra', + 'type': 'Machine', + }), + ]), + 'auto_update': False, + 'auto_update_supported': True, + 'available_firmware_update': False, + 'ble_auth_token': None, + 'coffee_station': None, + 'connected': True, + 'connection_date': '2025-03-21T03:00:19.892000+00:00', + 'cropster_active': False, + 'cropster_supported': False, + 'factory_reset_supported': True, + 'firmwares': dict({ + 'Gateway': dict({ + 'available_update': dict({ + 'build_version': 'v5.0.10', + 'change_log': ''' + What’s new in this version: + + * fixed an issue that could cause the machine powers up outside scheduled time + * minor improvements + ''', + 'thing_model_code': 'LineaMicra', + 'type': 'Gateway', + }), + 'build_version': 'v5.0.9', + 'change_log': ''' + What’s new in this version: + + * New La Marzocco compatibility + * Improved connectivity + * Improved pairing process + * Improved statistics + * Boilers heating time + * Last backflush date (GS3 MP excluded) + * Automatic gateway updates option + ''', + 'status': 'ToUpdate', + 'thing_model_code': 'LineaMicra', + 'type': 'Gateway', + }), + 'Machine': dict({ + 'available_update': None, + 'build_version': 'v1.17', + 'change_log': 'None', + 'status': 'Updated', + 'thing_model_code': 'LineaMicra', + 'type': 'Machine', + }), + }), + 'hemro_active': False, + 'hemro_supported': False, + 'image_url': 'https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png', + 'is_plumbed_in': True, + 'location': None, + 'model_code': 'LINEAMICRA', + 'model_name': 'Linea Micra', + 'name': 'MR123456', + 'offline_mode': False, + 'plumb_in_supported': True, + 'require_firmware_update': False, + 'serial_number': '**REDACTED**', + 'type': 'CoffeeMachine', + 'wifi_rssi': -51, + 'wifi_ssid': 'MyWifi', }), - 'total_flushes': 1740, }), }) # --- diff --git a/tests/components/lamarzocco/snapshots/test_init.ambr b/tests/components/lamarzocco/snapshots/test_init.ambr index 4c210136bd2..18b2fd0fbc3 100644 --- a/tests/components/lamarzocco/snapshots/test_init.ambr +++ b/tests/components/lamarzocco/snapshots/test_init.ambr @@ -29,47 +29,14 @@ 'labels': set({ }), 'manufacturer': 'La Marzocco', - 'model': , - 'model_id': , + 'model': 'GS3 AV', + 'model_id': 'GS3AV', 'name': 'GS012345', 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GS012345', 'suggested_area': None, - 'sw_version': '1.40', + 'sw_version': 'v1.17', 'via_device_id': None, }) # --- -# name: test_scale_device[Linea Mini] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - '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 0748c9384a9..8f59ce4a6fa 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_general_numbers[coffee_target_temperature-94-set_temp-kwargs0] +# name: test_general_numbers[coffee_target_temperature-94-set_coffee_target_temperature-kwargs0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -15,10 +15,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '95', + 'state': '95.0', }) # --- -# name: test_general_numbers[coffee_target_temperature-94-set_temp-kwargs0].1 +# name: test_general_numbers[coffee_target_temperature-94-set_coffee_target_temperature-kwargs0].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -63,9 +63,9 @@ 'device_class': 'duration', 'friendly_name': 'GS012345 Smart standby time', 'max': 240, - 'min': 10, + 'min': 0, 'mode': , - 'step': 10, + 'step': 1, 'unit_of_measurement': , }), 'context': , @@ -83,9 +83,9 @@ 'area_id': None, 'capabilities': dict({ 'max': 240, - 'min': 10, + 'min': 0, 'mode': , - 'step': 10, + 'step': 1, }), 'config_entry_id': , 'config_subentry_id': , @@ -115,623 +115,33 @@ 'unit_of_measurement': , }) # --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 AV] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'GS012345 Steam target temperature', - 'max': 131, - 'min': 126, - 'mode': , - 'step': 1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_steam_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '123.900001525879', - }) -# --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 AV].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 131, - 'min': 126, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': None, - 'entity_id': 'number.gs012345_steam_target_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': 'Steam target temperature', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'steam_temp', - 'unique_id': 'GS012345_steam_temp', - 'unit_of_measurement': , - }) -# --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 MP] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'GS012345 Steam target temperature', - 'max': 131, - 'min': 126, - 'mode': , - 'step': 1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_steam_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '123.900001525879', - }) -# --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 MP].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 131, - 'min': 126, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': None, - 'entity_id': 'number.gs012345_steam_target_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': 'Steam target temperature', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'steam_temp', - 'unique_id': 'GS012345_steam_temp', - 'unit_of_measurement': , - }) -# --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 AV] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Tea water duration', - 'max': 30, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_tea_water_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8', - }) -# --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 AV].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 30, - 'min': 0, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': None, - 'entity_id': 'number.gs012345_tea_water_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': 'Tea water duration', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'tea_water_duration', - 'unique_id': 'GS012345_tea_water_duration', - 'unit_of_measurement': , - }) -# --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 MP] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Tea water duration', - 'max': 30, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_tea_water_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8', - }) -# --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 MP].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 30, - 'min': 0, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': None, - 'entity_id': 'number.gs012345_tea_water_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': 'Tea water duration', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'tea_water_duration', - 'unique_id': 'GS012345_tea_water_duration', - 'unit_of_measurement': , - }) -# --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Dose Key 1', - 'max': 999, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': 'ticks', - }), - 'context': , - 'entity_id': 'number.gs012345_dose_key_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '135', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Dose Key 2', - 'max': 999, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': 'ticks', - }), - 'context': , - 'entity_id': 'number.gs012345_dose_key_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '97', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Dose Key 3', - 'max': 999, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': 'ticks', - }), - 'context': , - 'entity_id': 'number.gs012345_dose_key_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '108', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS012345_dose_key_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Dose Key 4', - 'max': 999, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': 'ticks', - }), - 'context': , - 'entity_id': 'number.gs012345_dose_key_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '121', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew off time Key 1', - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_off_time_key_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew off time Key 2', - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_off_time_key_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew off time Key 3', - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_off_time_key_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.29999995231628', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew off time Key 4', - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_off_time_key_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew on time Key 1', - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_on_time_key_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew on time Key 2', - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_on_time_key_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew on time Key 3', - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_on_time_key_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.29999995231628', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Prebrew on time Key 4', - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_prebrew_on_time_key_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Preinfusion time Key 1', - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_preinfusion_time_key_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Preinfusion time Key 2', - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_preinfusion_time_key_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Preinfusion time Key 3', - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_preinfusion_time_key_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.29999995231628', - }) -# --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Preinfusion time Key 4', - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.gs012345_preinfusion_time_key_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2', - }) -# --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'LM012345 Prebrew off time', - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.lm012345_prebrew_off_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3', - }) -# --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 10, - 'min': 1, - 'mode': , - 'step': 0.1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.lm012345_prebrew_off_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': 'Prebrew off time', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'prebrew_off', - 'unique_id': 'LM012345_prebrew_off', - 'unit_of_measurement': , - }) -# --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Micra] +# name: test_prebrew_off[Linea Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'MR012345 Prebrew off time', 'max': 10, - 'min': 1, + 'min': 0, 'mode': , 'step': 0.1, - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'number.mr012345_prebrew_off_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '5.0', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Micra].1 +# name: test_prebrew_off[Linea Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'max': 10, - 'min': 1, + 'min': 0, 'mode': , 'step': 0.1, }), @@ -758,96 +168,38 @@ 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'prebrew_off', + 'translation_key': 'prebrew_time_off', 'unique_id': 'MR012345_prebrew_off', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'LM012345 Prebrew on time', - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.lm012345_prebrew_on_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3', - }) -# --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 10, - 'min': 2, - 'mode': , - 'step': 0.1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.lm012345_prebrew_on_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': 'Prebrew on time', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'prebrew_on', - 'unique_id': 'LM012345_prebrew_on', - 'unit_of_measurement': , - }) -# --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Micra] +# name: test_prebrew_on[Linea Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'MR012345 Prebrew on time', 'max': 10, - 'min': 2, + 'min': 0, 'mode': , 'step': 0.1, - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'number.mr012345_prebrew_on_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '5.0', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Micra].1 +# name: test_prebrew_on[Linea Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'max': 10, - 'min': 2, + 'min': 0, 'mode': , 'step': 0.1, }), @@ -874,76 +226,18 @@ 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'prebrew_on', + 'translation_key': 'prebrew_time_on', 'unique_id': 'MR012345_prebrew_on', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Linea Mini] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'LM012345 Preinfusion time', - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.lm012345_preinfusion_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3', - }) -# --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Linea Mini].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 29, - 'min': 2, - 'mode': , - 'step': 0.1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.lm012345_preinfusion_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': 'Preinfusion time', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'preinfusion_off', - 'unique_id': 'LM012345_preinfusion_off', - 'unit_of_measurement': , - }) -# --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Micra] +# name: test_preinfusion[Linea Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'MR012345 Preinfusion time', - 'max': 29, - 'min': 2, + 'max': 10, + 'min': 0, 'mode': , 'step': 0.1, 'unit_of_measurement': , @@ -953,17 +247,17 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '4.0', }) # --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Micra].1 +# name: test_preinfusion[Linea Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'max': 29, - 'min': 2, + 'max': 10, + 'min': 0, 'mode': , 'step': 0.1, }), @@ -990,120 +284,8 @@ 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'preinfusion_off', + 'translation_key': 'preinfusion_time', 'unique_id': 'MR012345_preinfusion_off', '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': , - 'config_subentry_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': , - 'config_subentry_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 2e88688652a..218b0092a49 100644 --- a/tests/components/lamarzocco/snapshots/test_select.ambr +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -1,60 +1,4 @@ # 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': , - 'config_subentry_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({ @@ -113,6 +57,64 @@ 'unit_of_measurement': None, }) # --- +# name: test_pre_brew_infusion_select[Linea Micra] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MR012345 Prebrew/-infusion mode', + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'context': , + 'entity_id': 'select.mr012345_prebrew_infusion_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'preinfusion', + }) +# --- +# name: test_pre_brew_infusion_select[Linea Micra].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mr012345_prebrew_infusion_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': 'Prebrew/-infusion mode', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'prebrew_infusion_select', + 'unique_id': 'MR012345_prebrew_infusion_select', + 'unit_of_measurement': None, + }) +# --- # name: test_pre_brew_infusion_select[Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -128,7 +130,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'preinfusion', + 'state': 'disabled', }) # --- # name: test_pre_brew_infusion_select[Linea Mini].1 @@ -171,64 +173,6 @@ 'unit_of_measurement': None, }) # --- -# name: test_pre_brew_infusion_select[Micra] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'MR012345 Prebrew/-infusion mode', - 'options': list([ - 'disabled', - 'prebrew', - 'preinfusion', - ]), - }), - 'context': , - 'entity_id': 'select.mr012345_prebrew_infusion_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'preinfusion', - }) -# --- -# name: test_pre_brew_infusion_select[Micra].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'disabled', - 'prebrew', - 'preinfusion', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.mr012345_prebrew_infusion_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': 'Prebrew/-infusion mode', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'prebrew_infusion_select', - 'unique_id': 'MR012345_prebrew_infusion_select', - 'unit_of_measurement': None, - }) -# --- # name: test_smart_standby_mode StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -243,7 +187,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'last_brewing', + 'state': 'power_on', }) # --- # name: test_smart_standby_mode.1 @@ -285,7 +229,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_steam_boiler_level[Micra] +# name: test_steam_boiler_level[Linea Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'MR012345 Steam level', @@ -300,10 +244,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '3', }) # --- -# name: test_steam_boiler_level[Micra].1 +# name: test_steam_boiler_level[Linea Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 996dff93433..15eda23c094 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -1,64 +1,10 @@ # serializer version: 1 -# 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 +# name: test_sensors[sensor.gs012345_brewing_start_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_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_coffees_made_key_1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -66,7 +12,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs012345_coffees_made_key_1', + 'entity_id': 'sensor.gs012345_brewing_start_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -76,40 +22,37 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Coffees made Key 1', + 'original_name': 'Brewing start time', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'drink_stats_coffee_key', - 'unique_id': 'GS012345_drink_stats_coffee_key_key1', - 'unit_of_measurement': 'coffees', + 'translation_key': 'brewing_start_time', + 'unique_id': 'GS012345_brewing_start_time', + 'unit_of_measurement': None, }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_1-state] +# name: test_sensors[sensor.gs012345_brewing_start_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Coffees made Key 1', - 'state_class': , - 'unit_of_measurement': 'coffees', + 'device_class': 'timestamp', + 'friendly_name': 'GS012345 Brewing start time', }), 'context': , - 'entity_id': 'sensor.gs012345_coffees_made_key_1', + 'entity_id': 'sensor.gs012345_brewing_start_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1047', + 'state': '2025-05-07T18:04:20+00:00', }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_2-entry] +# name: test_sensors[sensor.gs012345_coffee_boiler_ready_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -117,7 +60,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs012345_coffees_made_key_2', + 'entity_id': 'sensor.gs012345_coffee_boiler_ready_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -127,40 +70,37 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Coffees made Key 2', + 'original_name': 'Coffee boiler ready time', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'drink_stats_coffee_key', - 'unique_id': 'GS012345_drink_stats_coffee_key_key2', - 'unit_of_measurement': 'coffees', + 'translation_key': 'coffee_boiler_ready_time', + 'unique_id': 'GS012345_coffee_boiler_ready_time', + 'unit_of_measurement': None, }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_2-state] +# name: test_sensors[sensor.gs012345_coffee_boiler_ready_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Coffees made Key 2', - 'state_class': , - 'unit_of_measurement': 'coffees', + 'device_class': 'timestamp', + 'friendly_name': 'GS012345 Coffee boiler ready time', }), 'context': , - 'entity_id': 'sensor.gs012345_coffees_made_key_2', + 'entity_id': 'sensor.gs012345_coffee_boiler_ready_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '560', + 'state': 'unknown', }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_3-entry] +# name: test_sensors[sensor.gs012345_last_cleaning_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -168,7 +108,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs012345_coffees_made_key_3', + 'entity_id': 'sensor.gs012345_last_cleaning_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -178,40 +118,37 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Coffees made Key 3', + 'original_name': 'Last cleaning time', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'drink_stats_coffee_key', - 'unique_id': 'GS012345_drink_stats_coffee_key_key3', - 'unit_of_measurement': 'coffees', + 'translation_key': 'last_cleaning_time', + 'unique_id': 'GS012345_last_cleaning_time', + 'unit_of_measurement': None, }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_3-state] +# name: test_sensors[sensor.gs012345_last_cleaning_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Coffees made Key 3', - 'state_class': , - 'unit_of_measurement': 'coffees', + 'device_class': 'timestamp', + 'friendly_name': 'GS012345 Last cleaning time', }), 'context': , - 'entity_id': 'sensor.gs012345_coffees_made_key_3', + 'entity_id': 'sensor.gs012345_last_cleaning_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '468', + 'state': '2025-03-29T08:25:47+00:00', }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_4-entry] +# name: test_sensors[sensor.gs012345_steam_boiler_ready_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -219,7 +156,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs012345_coffees_made_key_4', + 'entity_id': 'sensor.gs012345_steam_boiler_ready_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -229,192 +166,29 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Coffees made Key 4', + 'original_name': 'Steam boiler ready time', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'drink_stats_coffee_key', - 'unique_id': 'GS012345_drink_stats_coffee_key_key4', - 'unit_of_measurement': 'coffees', + 'translation_key': 'steam_boiler_ready_time', + 'unique_id': 'GS012345_steam_boiler_ready_time', + 'unit_of_measurement': None, }) # --- -# name: test_sensors[sensor.gs012345_coffees_made_key_4-state] +# name: test_sensors[sensor.gs012345_steam_boiler_ready_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Coffees made Key 4', - 'state_class': , - 'unit_of_measurement': 'coffees', + 'device_class': 'timestamp', + 'friendly_name': 'GS012345 Steam boiler ready time', }), 'context': , - 'entity_id': 'sensor.gs012345_coffees_made_key_4', + 'entity_id': 'sensor.gs012345_steam_boiler_ready_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '312', - }) -# --- -# name: test_sensors[sensor.gs012345_current_coffee_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.gs012345_current_coffee_temperature', - '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': 'Current coffee temperature', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'current_temp_coffee', - 'unique_id': 'GS012345_current_temp_coffee', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.gs012345_current_coffee_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'GS012345 Current coffee temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.gs012345_current_coffee_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '96.5', - }) -# --- -# name: test_sensors[sensor.gs012345_current_steam_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.gs012345_current_steam_temperature', - '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': 'Current steam temperature', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'current_temp_steam', - 'unique_id': 'GS012345_current_temp_steam', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.gs012345_current_steam_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'GS012345 Current steam temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.gs012345_current_steam_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '123.800003051758', - }) -# --- -# name: test_sensors[sensor.gs012345_shot_timer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.gs012345_shot_timer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Shot timer', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'shot_timer', - 'unique_id': 'GS012345_shot_timer', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.gs012345_shot_timer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'GS012345 Shot timer', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.gs012345_shot_timer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.gs012345_total_coffees_made-entry] @@ -448,7 +222,7 @@ 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'drink_stats_coffee', + 'translation_key': 'total_coffees_made', 'unique_id': 'GS012345_drink_stats_coffee', 'unit_of_measurement': 'coffees', }) @@ -465,10 +239,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2387', + 'state': '1620', }) # --- -# name: test_sensors[sensor.gs012345_total_flushes_made-entry] +# name: test_sensors[sensor.gs012345_total_flushes_done-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -483,7 +257,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.gs012345_total_flushes_made', + 'entity_id': 'sensor.gs012345_total_flushes_done', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -495,27 +269,27 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Total flushes made', + 'original_name': 'Total flushes done', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'drink_stats_flushing', + 'translation_key': 'total_flushes_done', 'unique_id': 'GS012345_drink_stats_flushing', 'unit_of_measurement': 'flushes', }) # --- -# name: test_sensors[sensor.gs012345_total_flushes_made-state] +# name: test_sensors[sensor.gs012345_total_flushes_done-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS012345 Total flushes made', + 'friendly_name': 'GS012345 Total flushes done', 'state_class': , 'unit_of_measurement': 'flushes', }), 'context': , - 'entity_id': 'sensor.gs012345_total_flushes_made', + 'entity_id': 'sensor.gs012345_total_flushes_done', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1740', + 'state': '1366', }) # --- diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr index 17d0528c3d8..508d0d36911 100644 --- a/tests/components/lamarzocco/snapshots/test_update.ambr +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -27,7 +27,7 @@ 'original_name': 'Gateway firmware', 'platform': 'lamarzocco', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'gateway_firmware', 'unique_id': 'GS012345_gateway_firmware', 'unit_of_measurement': None, @@ -42,12 +42,12 @@ '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', + 'installed_version': 'v5.0.9', + 'latest_version': 'v5.0.10', 'release_summary': None, 'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/', 'skipped_version': None, - 'supported_features': , + 'supported_features': , 'title': None, 'update_percentage': None, }), @@ -87,7 +87,7 @@ 'original_name': 'Machine firmware', 'platform': 'lamarzocco', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'machine_firmware', 'unique_id': 'GS012345_machine_firmware', 'unit_of_measurement': None, @@ -102,12 +102,12 @@ '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', + 'installed_version': 'v1.17', + 'latest_version': 'v1.17', 'release_summary': None, 'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/', 'skipped_version': None, - 'supported_features': , + 'supported_features': , 'title': None, 'update_percentage': None, }), @@ -116,6 +116,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py index d50d0ad9f84..ef8c7e17d97 100644 --- a/tests/components/lamarzocco/test_binary_sensor.py +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -1,14 +1,13 @@ """Tests for La Marzocco binary sensors.""" +from collections.abc import Generator from datetime import timedelta 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 syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant @@ -18,10 +17,12 @@ from . import async_init_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +pytestmark = pytest.mark.usefixtures("mock_websocket_terminated") + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( hass: HomeAssistant, - mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, @@ -35,16 +36,14 @@ async def test_binary_sensors( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_brew_active_does_not_exists( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry_no_local_connection: MockConfigEntry, -) -> None: - """Test the La Marzocco currently_making_coffee doesn't exist if host not set.""" - - await async_init_integration(hass, mock_config_entry_no_local_connection) - state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_brewing_active") - assert state is None +@pytest.fixture(autouse=True) +def mock_websocket_terminated() -> Generator[bool]: + """Mock websocket terminated.""" + with patch( + "homeassistant.components.lamarzocco.coordinator.LaMarzoccoUpdateCoordinator.websocket_terminated", + new=False, + ) as mock_websocket_terminated: + yield mock_websocket_terminated async def test_brew_active_unavailable( @@ -52,9 +51,9 @@ async def test_brew_active_unavailable( mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test the La Marzocco currently_making_coffee becomes unavailable.""" + """Test the La Marzocco brew active becomes unavailable.""" - mock_lamarzocco.websocket_connected = False + mock_lamarzocco.websocket.connected = False await async_init_integration(hass, mock_config_entry) state = hass.states.get( f"binary_sensor.{mock_lamarzocco.serial_number}_brewing_active" @@ -79,7 +78,8 @@ async def test_sensor_going_unavailable( assert state assert state.state != STATE_UNAVAILABLE - mock_lamarzocco.get_config.side_effect = RequestNotSuccessful("") + mock_lamarzocco.websocket.connected = False + mock_lamarzocco.get_dashboard.side_effect = RequestNotSuccessful("") freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -87,68 +87,3 @@ 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_button.py b/tests/components/lamarzocco/test_button.py index 61b7ba77c22..2272829965b 100644 --- a/tests/components/lamarzocco/test_button.py +++ b/tests/components/lamarzocco/test_button.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from pylamarzocco.exceptions import RequestNotSuccessful import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID diff --git a/tests/components/lamarzocco/test_calendar.py b/tests/components/lamarzocco/test_calendar.py index dd590a20db1..8824de6d3f4 100644 --- a/tests/components/lamarzocco/test_calendar.py +++ b/tests/components/lamarzocco/test_calendar.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.calendar import ( DOMAIN as CALENDAR_DOMAIN, @@ -127,7 +127,12 @@ async def test_no_calendar_events_global_disable( wake_up_sleep_entry_id = WAKE_UP_SLEEP_ENTRY_IDS[0] - mock_lamarzocco.config.wake_up_sleep_entries[wake_up_sleep_entry_id].enabled = False + wake_up_sleep_entry = mock_lamarzocco.schedule.smart_wake_up_sleep.schedules_dict[ + wake_up_sleep_entry_id + ] + + assert wake_up_sleep_entry + wake_up_sleep_entry.enabled = False test_time = datetime(2024, 1, 12, 11, tzinfo=dt_util.get_default_time_zone()) freezer.move_to(test_time) diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 02ade8f2b9c..38cdc10d8ab 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -1,11 +1,11 @@ """Test the La Marzocco config flow.""" from collections.abc import Generator +from copy import deepcopy from unittest.mock import AsyncMock, MagicMock, patch -from pylamarzocco.const import MachineModel +from pylamarzocco.const import ModelName from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE @@ -15,18 +15,12 @@ from homeassistant.config_entries import ( SOURCE_DHCP, SOURCE_USER, ConfigEntryState, + ConfigFlowResult, ) -from homeassistant.const import ( - CONF_ADDRESS, - CONF_HOST, - CONF_MAC, - CONF_MODEL, - CONF_NAME, - CONF_PASSWORD, - CONF_TOKEN, -) +from homeassistant.const import CONF_ADDRESS, CONF_MAC, CONF_PASSWORD, CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import USER_INPUT, async_init_integration, get_bluetooth_service_info @@ -34,9 +28,18 @@ from . import USER_INPUT, async_init_integration, get_bluetooth_service_info from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.lamarzocco.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + async def __do_successful_user_step( - hass: HomeAssistant, result: FlowResult, mock_cloud_client: MagicMock -) -> FlowResult: + hass: HomeAssistant, result: ConfigFlowResult, mock_cloud_client: MagicMock +) -> ConfigFlowResult: """Successfully configure the user step.""" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -50,40 +53,28 @@ async def __do_successful_user_step( async def __do_sucessful_machine_selection_step( - hass: HomeAssistant, result2: FlowResult, mock_device_info: LaMarzoccoDeviceInfo + hass: HomeAssistant, result2: ConfigFlowResult ) -> None: """Successfully configure the machine selection step.""" - with patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=True, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_device_info.serial_number, - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_MACHINE: "GS012345"}, + ) - assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "GS3" - assert result3["data"] == { + assert result["title"] == "GS012345" + assert result["data"] == { **USER_INPUT, - CONF_HOST: "192.168.1.1", - CONF_MODEL: mock_device_info.model, - CONF_NAME: mock_device_info.name, - CONF_TOKEN: mock_device_info.communication_key, + CONF_TOKEN: None, } + assert result["result"].unique_id == "GS012345" async def test_form( hass: HomeAssistant, mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -93,14 +84,12 @@ async def test_form( assert result["errors"] == {} assert result["step_id"] == "user" - result2 = await __do_successful_user_step(hass, result, mock_cloud_client) - await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) + result = await __do_successful_user_step(hass, result, mock_cloud_client) + await __do_sucessful_machine_selection_step(hass, result) async def test_form_abort_already_configured( hass: HomeAssistant, - mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, mock_config_entry: MockConfigEntry, ) -> None: """Test we abort if already configured.""" @@ -112,139 +101,89 @@ async def test_form_abort_already_configured( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( + 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["step_id"] == "machine_selection" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "machine_selection" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_device_info.serial_number, + CONF_MACHINE: "GS012345", }, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (AuthFail(""), "invalid_auth"), + (RequestNotSuccessful(""), "cannot_connect"), + ], +) async def test_form_invalid_auth( hass: HomeAssistant, - mock_device_info: LaMarzoccoDeviceInfo, mock_cloud_client: MagicMock, - mock_setup_entry: Generator[AsyncMock], + side_effect: Exception, + error: str, ) -> None: """Test invalid auth error.""" - mock_cloud_client.get_customer_fleet.side_effect = AuthFail("") + mock_cloud_client.list_things.side_effect = side_effect result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 - - # test recovery from failure - mock_cloud_client.get_customer_fleet.side_effect = None - result2 = await __do_successful_user_step(hass, result, mock_cloud_client) - await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) - - -async def test_form_invalid_host( - hass: HomeAssistant, - mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], -) -> None: - """Test invalid auth error.""" - 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"], - USER_INPUT, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "machine_selection" - - with patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=False, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_device_info.serial_number, - }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"host": "cannot_connect"} - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 + assert result["errors"] == {"base": error} + assert len(mock_cloud_client.list_things.mock_calls) == 1 # test recovery from failure - await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) + mock_cloud_client.list_things.side_effect = None + result = await __do_successful_user_step(hass, result, mock_cloud_client) + await __do_sucessful_machine_selection_step(hass, result) -async def test_form_cannot_connect( +async def test_form_no_machines( hass: HomeAssistant, mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], ) -> None: - """Test cannot connect error.""" + """Test we don't have any devices.""" - mock_cloud_client.get_customer_fleet.return_value = {} + original_return = mock_cloud_client.list_things.return_value + mock_cloud_client.list_things.return_value = [] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "no_machines"} - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 - - mock_cloud_client.get_customer_fleet.side_effect = RequestNotSuccessful("") - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 2 + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_machines"} + assert len(mock_cloud_client.list_things.mock_calls) == 1 # test recovery from failure - mock_cloud_client.get_customer_fleet.side_effect = None - mock_cloud_client.get_customer_fleet.return_value = { - mock_device_info.serial_number: mock_device_info - } - result2 = await __do_successful_user_step(hass, result, mock_cloud_client) - await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) + mock_cloud_client.list_things.return_value = original_return + + result = await __do_successful_user_step(hass, result, mock_cloud_client) + await __do_sucessful_machine_selection_step(hass, result) async def test_reauth_flow( @@ -261,15 +200,15 @@ async def test_reauth_flow( 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_PASSWORD: "new_password"}, ) - assert result2["type"] is FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT await hass.async_block_till_done() - assert result2["reason"] == "reauth_successful" - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 + assert result["reason"] == "reauth_successful" + assert len(mock_cloud_client.list_things.mock_calls) == 1 assert mock_config_entry.data[CONF_PASSWORD] == "new_password" @@ -277,8 +216,6 @@ async def test_reconfigure_flow( hass: HomeAssistant, mock_cloud_client: MagicMock, mock_config_entry: MockConfigEntry, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Testing reconfgure flow.""" mock_config_entry.add_to_hass(hass) @@ -288,40 +225,33 @@ async def test_reconfigure_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - result2 = await __do_successful_user_step(hass, result, mock_cloud_client) - service_info = get_bluetooth_service_info( - mock_device_info.model, mock_device_info.serial_number - ) + result = await __do_successful_user_step(hass, result, mock_cloud_client) + service_info = get_bluetooth_service_info(ModelName.GS3_MP, "GS012345") with ( - patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=True, - ), patch( "homeassistant.components.lamarzocco.config_flow.async_discovered_service_info", return_value=[service_info], ), ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_device_info.serial_number, + CONF_MACHINE: "GS012345", }, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "bluetooth_selection" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_selection" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_MAC: service_info.address}, ) - assert result4["type"] is FlowResultType.ABORT - assert result4["reason"] == "reconfigure_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.title == "My LaMarzocco" assert mock_config_entry.data == { @@ -330,16 +260,72 @@ async def test_reconfigure_flow( } +@pytest.mark.parametrize( + "discovered", + [ + [], + [ + BluetoothServiceInfo( + name="SomeDevice", + address="aa:bb:cc:dd:ee:ff", + rssi=-63, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", + ) + ], + ], +) +async def test_reconfigure_flow_no_machines( + hass: HomeAssistant, + mock_cloud_client: MagicMock, + mock_config_entry: MockConfigEntry, + discovered: list[BluetoothServiceInfo], +) -> None: + """Testing reconfgure flow.""" + mock_config_entry.add_to_hass(hass) + + data = deepcopy(dict(mock_config_entry.data)) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await __do_successful_user_step(hass, result, mock_cloud_client) + + with ( + patch( + "homeassistant.components.lamarzocco.config_flow.async_discovered_service_info", + return_value=discovered, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_MACHINE: "GS012345", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert mock_config_entry.title == "My LaMarzocco" + assert CONF_MAC not in mock_config_entry.data + assert dict(mock_config_entry.data) == data + + async def test_bluetooth_discovery( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_cloud_client: MagicMock, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test bluetooth discovery.""" service_info = get_bluetooth_service_info( - mock_lamarzocco.model, mock_lamarzocco.serial_number + ModelName.GS3_MP, mock_lamarzocco.serial_number ) + mock_cloud_client.list_things.return_value[0].ble_auth_token = "dummyToken" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info ) @@ -347,52 +333,30 @@ async def test_bluetooth_discovery( 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, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "machine_selection" + assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 - with patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=True, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - - assert result3["title"] == "GS3" - assert result3["data"] == { + assert result["title"] == "GS012345" + assert result["data"] == { **USER_INPUT, - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_lamarzocco.serial_number, - CONF_NAME: "GS3", CONF_MAC: "aa:bb:cc:dd:ee:ff", - CONF_MODEL: mock_lamarzocco.model, - CONF_TOKEN: "token", + CONF_TOKEN: "dummyToken", } 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 + ModelName.GS3_MP, mock_lamarzocco.serial_number ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info @@ -405,12 +369,10 @@ async def test_bluetooth_discovery_errors( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test bluetooth discovery errors.""" service_info = get_bluetooth_service_info( - mock_lamarzocco.model, mock_lamarzocco.serial_number + ModelName.GS3_MP, mock_lamarzocco.serial_number ) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -421,62 +383,36 @@ async def test_bluetooth_discovery_errors( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - mock_cloud_client.get_customer_fleet.return_value = {"GS98765", ""} - result2 = await hass.config_entries.flow.async_configure( + original_return = deepcopy(mock_cloud_client.list_things.return_value) + mock_cloud_client.list_things.return_value[0].serial_number = "GS98765" + + result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "machine_not_found"} - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "machine_not_found"} + assert len(mock_cloud_client.list_things.mock_calls) == 1 - mock_cloud_client.get_customer_fleet.return_value = { - mock_device_info.serial_number: mock_device_info - } - result2 = await hass.config_entries.flow.async_configure( + mock_cloud_client.list_things.return_value = original_return + result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "machine_selection" - assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 2 - with patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=True, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - }, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY - assert result3["type"] is FlowResultType.CREATE_ENTRY - - assert result3["title"] == "GS3" - assert result3["data"] == { + assert result["title"] == "GS012345" + assert result["data"] == { **USER_INPUT, - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_lamarzocco.serial_number, - CONF_NAME: "GS3", CONF_MAC: "aa:bb:cc:dd:ee:ff", - CONF_MODEL: mock_lamarzocco.model, - CONF_TOKEN: "token", + CONF_TOKEN: None, } -@pytest.mark.parametrize( - "device_fixture", - [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI, MachineModel.GS3_AV], -) async def test_dhcp_discovery( hass: HomeAssistant, mock_lamarzocco: MagicMock, - mock_cloud_client: MagicMock, - mock_device_info: LaMarzoccoDeviceInfo, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test dhcp discovery.""" @@ -493,30 +429,20 @@ async def test_dhcp_discovery( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", - return_value=True, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { - **USER_INPUT, - CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", - CONF_HOST: "192.168.1.42", - CONF_MACHINE: mock_lamarzocco.serial_number, - CONF_MODEL: mock_device_info.model, - CONF_NAME: mock_device_info.name, - CONF_TOKEN: mock_device_info.communication_key, - } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + **USER_INPUT, + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_TOKEN: None, + } 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.""" @@ -537,11 +463,9 @@ async def test_dhcp_discovery_abort_on_hostname_changed( async def test_dhcp_already_configured_and_update( hass: HomeAssistant, mock_lamarzocco: MagicMock, - mock_cloud_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test discovered IP address change.""" - old_ip = mock_config_entry.data[CONF_HOST] old_address = mock_config_entry.data[CONF_ADDRESS] mock_config_entry.add_to_hass(hass) @@ -557,18 +481,13 @@ async def test_dhcp_already_configured_and_update( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert mock_config_entry.data[CONF_HOST] != old_ip - assert mock_config_entry.data[CONF_HOST] == "192.168.1.42" - assert mock_config_entry.data[CONF_ADDRESS] != old_address assert mock_config_entry.data[CONF_ADDRESS] == "aa:bb:cc:dd:ee:ff" async def test_options_flow( hass: HomeAssistant, - mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, - mock_setup_entry: Generator[AsyncMock], ) -> None: """Test options flow.""" await async_init_integration(hass, mock_config_entry) @@ -579,7 +498,7 @@ async def test_options_flow( 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_USE_BLUETOOTH: False, @@ -587,7 +506,7 @@ async def test_options_flow( ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { CONF_USE_BLUETOOTH: False, } diff --git a/tests/components/lamarzocco/test_diagnostics.py b/tests/components/lamarzocco/test_diagnostics.py index 762b33cc696..7aa0edcd0ad 100644 --- a/tests/components/lamarzocco/test_diagnostics.py +++ b/tests/components/lamarzocco/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the La Marzocco integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 09ebc462952..1e56e540e2a 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -1,18 +1,18 @@ """Test initialization of lamarzocco.""" -from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch -from freezegun.api import FrozenDateTimeFactory -from pylamarzocco.const import FirmwareType, MachineModel +from pylamarzocco.const import FirmwareType, ModelName from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.models import WebSocketDetails import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( + CONF_ADDRESS, CONF_HOST, CONF_MAC, CONF_MODEL, @@ -29,13 +29,12 @@ from homeassistant.helpers import ( from . import USER_INPUT, async_init_integration, get_bluetooth_service_info -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_lamarzocco: MagicMock, ) -> None: """Test loading and unloading the integration.""" await async_init_integration(hass, mock_config_entry) @@ -54,25 +53,50 @@ async def test_config_entry_not_ready( mock_lamarzocco: MagicMock, ) -> None: """Test the La Marzocco configuration entry not ready.""" - mock_lamarzocco.get_config.side_effect = RequestNotSuccessful("") + mock_lamarzocco.websocket.connected = False + mock_lamarzocco.get_dashboard.side_effect = RequestNotSuccessful("") await async_init_integration(hass, mock_config_entry) - assert len(mock_lamarzocco.get_config.mock_calls) == 1 + assert len(mock_lamarzocco.get_dashboard.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (AuthFail(""), ConfigEntryState.SETUP_ERROR), + (RequestNotSuccessful(""), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_get_settings_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cloud_client: MagicMock, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test error during initial settings get.""" + mock_cloud_client.get_thing_settings.side_effect = side_effect + + await async_init_integration(hass, mock_config_entry) + + assert len(mock_cloud_client.get_thing_settings.mock_calls) == 1 + assert mock_config_entry.state is expected_state + + async def test_invalid_auth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lamarzocco: MagicMock, ) -> None: """Test auth error during setup.""" - mock_lamarzocco.get_config.side_effect = AuthFail("") + mock_lamarzocco.websocket.connected = False + mock_lamarzocco.get_dashboard.side_effect = AuthFail("") 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 + assert len(mock_lamarzocco.get_dashboard.mock_calls) == 1 flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -86,37 +110,52 @@ async def test_invalid_auth( assert flow["context"].get("entry_id") == mock_config_entry.entry_id -async def test_v1_migration( +async def test_v1_migration_fails( hass: HomeAssistant, - mock_cloud_client: MagicMock, mock_lamarzocco: MagicMock, ) -> None: """Test v1 -> v2 Migration.""" - common_data = { - **USER_INPUT, - CONF_HOST: "host", - CONF_MAC: "aa:bb:cc:dd:ee:ff", - } entry_v1 = MockConfigEntry( domain=DOMAIN, version=1, unique_id=mock_lamarzocco.serial_number, - data={ - **common_data, - CONF_MACHINE: mock_lamarzocco.serial_number, - }, + data={}, ) entry_v1.add_to_hass(hass) await hass.config_entries.async_setup(entry_v1.entry_id) await hass.async_block_till_done() - assert entry_v1.version == 2 - assert dict(entry_v1.data) == { - **common_data, - CONF_NAME: "GS3", - CONF_MODEL: mock_lamarzocco.model, - CONF_TOKEN: "token", + assert entry_v1.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_v2_migration( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Test v2 -> v3 Migration.""" + + entry_v2 = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=mock_lamarzocco.serial_number, + data={ + **USER_INPUT, + CONF_HOST: "192.168.1.24", + CONF_NAME: "La Marzocco", + CONF_MODEL: ModelName.GS3_MP.value, + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + ) + entry_v2.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry_v2.entry_id) + assert entry_v2.state is ConfigEntryState.LOADED + assert entry_v2.version == 3 + assert dict(entry_v2.data) == { + **USER_INPUT, + CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_TOKEN: None, } @@ -128,28 +167,28 @@ async def test_migration_errors( ) -> None: """Test errors during migration.""" - mock_cloud_client.get_customer_fleet.side_effect = RequestNotSuccessful("Error") + mock_cloud_client.list_things.side_effect = RequestNotSuccessful("Error") - entry_v1 = MockConfigEntry( + entry_v2 = MockConfigEntry( domain=DOMAIN, - version=1, + version=2, unique_id=mock_lamarzocco.serial_number, data={ **USER_INPUT, CONF_MACHINE: mock_lamarzocco.serial_number, }, ) - entry_v1.add_to_hass(hass) + entry_v2.add_to_hass(hass) - assert not await hass.config_entries.async_setup(entry_v1.entry_id) - assert entry_v1.state is ConfigEntryState.MIGRATION_ERROR + assert not await hass.config_entries.async_setup(entry_v2.entry_id) + assert entry_v2.state is ConfigEntryState.MIGRATION_ERROR async def test_config_flow_entry_migration_downgrade( hass: HomeAssistant, ) -> None: """Test that config entry fails setup if the version is from the future.""" - entry = MockConfigEntry(domain=DOMAIN, version=3) + entry = MockConfigEntry(domain=DOMAIN, version=4) entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(entry.entry_id) @@ -159,26 +198,32 @@ async def test_bluetooth_is_set_from_discovery( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, ) -> None: """Check we can fill a device from discovery info.""" service_info = get_bluetooth_service_info( - mock_lamarzocco.model, mock_lamarzocco.serial_number + ModelName.GS3_MP, mock_lamarzocco.serial_number ) + mock_cloud_client.get_thing_settings.return_value.ble_auth_token = "token" with ( patch( "homeassistant.components.lamarzocco.async_discovered_service_info", return_value=[service_info], ) as discovery, - patch("homeassistant.components.lamarzocco.LaMarzoccoMachine") as init_device, + patch( + "homeassistant.components.lamarzocco.LaMarzoccoMachine" + ) as mock_machine_class, ): + mock_machine_class.return_value = mock_lamarzocco await async_init_integration(hass, mock_config_entry) discovery.assert_called_once() - init_device.assert_called_once() - _, kwargs = init_device.call_args + assert mock_machine_class.call_count == 1 + _, kwargs = mock_machine_class.call_args assert kwargs["bluetooth_client"] is not None - assert mock_config_entry.data[CONF_NAME] == service_info.name + assert mock_config_entry.data[CONF_MAC] == service_info.address + assert mock_config_entry.data[CONF_TOKEN] == "token" async def test_websocket_closed_on_unload( @@ -187,34 +232,37 @@ async def test_websocket_closed_on_unload( mock_lamarzocco: MagicMock, ) -> None: """Test the websocket is closed on unload.""" - with patch( - "homeassistant.components.lamarzocco.LaMarzoccoLocalClient", - autospec=True, - ) as local_client: - client = local_client.return_value - client.websocket = AsyncMock() + mock_disconnect_callback = AsyncMock() + mock_websocket = MagicMock() + mock_websocket.closed = True - await async_init_integration(hass, mock_config_entry) - mock_lamarzocco.websocket_connect.assert_called_once() + mock_lamarzocco.websocket = WebSocketDetails( + mock_websocket, mock_disconnect_callback + ) - client.websocket.closed = False - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - client.websocket.close.assert_called_once() + await async_init_integration(hass, mock_config_entry) + mock_lamarzocco.connect_dashboard_websocket.assert_called_once() + mock_websocket.closed = False + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_disconnect_callback.assert_called_once() @pytest.mark.parametrize( - ("version", "issue_exists"), [("v3.5-rc6", False), ("v3.3-rc4", True)] + ("version", "issue_exists"), [("v3.5-rc6", True), ("v5.0.9", False)] ) async def test_gateway_version_issue( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, version: str, issue_exists: bool, ) -> None: """Make sure we get the issue for certain gateway firmware versions.""" - mock_lamarzocco.firmware[FirmwareType.GATEWAY].current_version = version + mock_cloud_client.get_thing_settings.return_value.firmwares[ + FirmwareType.GATEWAY + ].build_version = version await async_init_integration(hass, mock_config_entry) @@ -226,18 +274,30 @@ async def test_gateway_version_issue( 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.""" - + mock_config_entry = MockConfigEntry( + title="My LaMarzocco", + domain=DOMAIN, + version=3, + data=USER_INPUT + | { + CONF_ADDRESS: "00:00:00:00:00:00", + CONF_TOKEN: "token", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + unique_id=mock_lamarzocco.serial_number, + ) 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"}, + data={ + **mock_config_entry.data, + }, ) state = hass.states.get(f"switch.{mock_lamarzocco.serial_number}") @@ -250,49 +310,3 @@ async def test_device( 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 65c5e264f22..b36f2944f4a 100644 --- a/tests/components/lamarzocco/test_number.py +++ b/tests/components/lamarzocco/test_number.py @@ -1,35 +1,31 @@ """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, - MachineModel, - PhysicalKey, - PrebrewMode, + ModelName, + PreExtractionMode, + SmartStandByType, + WidgetType, ) from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoScale import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion 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 +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 . import async_init_integration -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry @pytest.mark.parametrize( @@ -38,14 +34,14 @@ from tests.common import MockConfigEntry, async_fire_time_changed ( "coffee_target_temperature", 94, - "set_temp", - {"boiler": BoilerType.COFFEE, "temperature": 94}, + "set_coffee_target_temperature", + {"temperature": 94}, ), ( "smart_standby_time", 23, "set_smart_standby", - {"enabled": True, "mode": "LastBrewing", "minutes": 23}, + {"enabled": True, "mode": SmartStandByType.POWER_ON, "minutes": 23}, ), ], ) @@ -94,38 +90,22 @@ async def test_general_numbers( mock_func.assert_called_once_with(**kwargs) -@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV, MachineModel.GS3_MP]) -@pytest.mark.parametrize( - ("entity_name", "value", "func_name", "kwargs"), - [ - ( - "steam_target_temperature", - 131, - "set_temp", - {"boiler": BoilerType.STEAM, "temperature": 131}, - ), - ("tea_water_duration", 15, "set_dose_tea_water", {"dose": 15}), - ], -) -async def test_gs3_exclusive( +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA]) +async def test_preinfusion( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, - entity_name: str, - value: float, - func_name: str, - kwargs: dict[str, float], ) -> None: - """Test exclusive entities for GS3 AV/MP.""" + """Test preinfusion number.""" + await async_init_integration(hass, mock_config_entry) serial_number = mock_lamarzocco.serial_number + entity_id = f"number.{serial_number}_preinfusion_time" - func = getattr(mock_lamarzocco, func_name) + state = hass.states.get(entity_id) - state = hass.states.get(f"number.{serial_number}_{entity_name}") assert state assert state == snapshot @@ -134,97 +114,49 @@ async def test_gs3_exclusive( assert entry.device_id assert entry == snapshot - device = device_registry.async_get(entry.device_id) - assert device - # service call await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}", - ATTR_VALUE: value, + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 5.3, }, blocking=True, ) - assert len(func.mock_calls) == 1 - func.assert_called_once_with(**kwargs) + mock_lamarzocco.set_pre_extraction_times.assert_called_once_with( + seconds_off=5.3, + seconds_on=0, + ) -@pytest.mark.parametrize( - "device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI] -) -async def test_gs3_exclusive_none( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure GS3 exclusive is None for unsupported models.""" - await async_init_integration(hass, mock_config_entry) - ENTITIES = ("steam_target_temperature", "tea_water_duration") - - serial_number = mock_lamarzocco.serial_number - for entity in ENTITIES: - state = hass.states.get(f"number.{serial_number}_{entity}") - assert state is None - - -@pytest.mark.parametrize( - "device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI] -) -@pytest.mark.parametrize( - ("entity_name", "function_name", "prebrew_mode", "value", "kwargs"), - [ - ( - "prebrew_off_time", - "set_prebrew_time", - PrebrewMode.PREBREW, - 6, - {"prebrew_off_time": 6.0, "key": PhysicalKey.A}, - ), - ( - "prebrew_on_time", - "set_prebrew_time", - PrebrewMode.PREBREW, - 6, - {"prebrew_on_time": 6.0, "key": PhysicalKey.A}, - ), - ( - "preinfusion_time", - "set_preinfusion_time", - PrebrewMode.PREINFUSION, - 7, - {"preinfusion_time": 7.0, "key": PhysicalKey.A}, - ), - ], -) -async def test_pre_brew_infusion_numbers( +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA]) +async def test_prebrew_on( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, - entity_name: str, - function_name: str, - prebrew_mode: PrebrewMode, - value: float, - kwargs: dict[str, float], ) -> None: - """Test the La Marzocco prebrew/-infusion sensors.""" + """Test prebrew on number.""" + + mock_lamarzocco.dashboard.config[ + WidgetType.CM_PRE_BREWING + ].mode = PreExtractionMode.PREBREWING - mock_lamarzocco.config.prebrew_mode = prebrew_mode await async_init_integration(hass, mock_config_entry) - serial_number = mock_lamarzocco.serial_number + entity_id = f"number.{serial_number}_prebrew_on_time" - state = hass.states.get(f"number.{serial_number}_{entity_name}") + state = hass.states.get(entity_id) assert state assert state == snapshot entry = entity_registry.async_get(state.entity_id) assert entry + assert entry.device_id assert entry == snapshot # service call @@ -232,178 +164,64 @@ async def test_pre_brew_infusion_numbers( NUMBER_DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}", - ATTR_VALUE: value, + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 5.3, }, blocking=True, ) - function = getattr(mock_lamarzocco, function_name) - function.assert_called_once_with(**kwargs) - - -@pytest.mark.parametrize( - "device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI] -) -@pytest.mark.parametrize( - ("prebrew_mode", "entity", "unavailable"), - [ - ( - PrebrewMode.PREBREW, - ("prebrew_off_time", "prebrew_on_time"), - ("preinfusion_time",), - ), - ( - PrebrewMode.PREINFUSION, - ("preinfusion_time",), - ("prebrew_off_time", "prebrew_on_time"), - ), - ], -) -async def test_pre_brew_infusion_numbers_unavailable( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - prebrew_mode: PrebrewMode, - entity: tuple[str, ...], - unavailable: tuple[str, ...], -) -> None: - """Test entities are unavailable depending on selected state.""" - - mock_lamarzocco.config.prebrew_mode = prebrew_mode - await async_init_integration(hass, mock_config_entry) - - serial_number = mock_lamarzocco.serial_number - for entity_name in entity: - state = hass.states.get(f"number.{serial_number}_{entity_name}") - assert state - assert state.state != STATE_UNAVAILABLE - - for entity_name in unavailable: - state = hass.states.get(f"number.{serial_number}_{entity_name}") - assert state - assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV]) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize( - ("entity_name", "value", "prebrew_mode", "function_name", "kwargs"), - [ - ( - "prebrew_off_time", - 6, - PrebrewMode.PREBREW, - "set_prebrew_time", - {"prebrew_off_time": 6.0}, - ), - ( - "prebrew_on_time", - 6, - PrebrewMode.PREBREW, - "set_prebrew_time", - {"prebrew_on_time": 6.0}, - ), - ( - "preinfusion_time", - 7, - PrebrewMode.PREINFUSION, - "set_preinfusion_time", - {"preinfusion_time": 7.0}, - ), - ("dose", 6, PrebrewMode.DISABLED, "set_dose", {"dose": 6}), - ], -) -async def test_pre_brew_infusion_key_numbers( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, - entity_name: str, - value: float, - prebrew_mode: PrebrewMode, - function_name: str, - kwargs: dict[str, float], -) -> None: - """Test the La Marzocco number sensors for GS3AV model.""" - - mock_lamarzocco.config.prebrew_mode = prebrew_mode - await async_init_integration(hass, mock_config_entry) - - serial_number = mock_lamarzocco.serial_number - - func = getattr(mock_lamarzocco, function_name) - - state = hass.states.get(f"number.{serial_number}_{entity_name}") - assert state is None - - for key in PhysicalKey: - state = hass.states.get(f"number.{serial_number}_{entity_name}_key_{key}") - assert state - assert state == snapshot(name=f"{serial_number}_{entity_name}_key_{key}-state") - - # service call - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}_key_{key}", - ATTR_VALUE: value, - }, - blocking=True, - ) - - kwargs["key"] = key - - assert len(func.mock_calls) == key.value - func.assert_called_with(**kwargs) - - -@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV]) -async def test_disabled_entites( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the La Marzocco prebrew/-infusion sensors for GS3AV model.""" - await async_init_integration(hass, mock_config_entry) - ENTITIES = ( - "prebrew_off_time", - "prebrew_on_time", - "preinfusion_time", - "set_dose", + mock_lamarzocco.set_pre_extraction_times.assert_called_once_with( + seconds_on=5.3, + seconds_off=mock_lamarzocco.dashboard.config[WidgetType.CM_PRE_BREWING] + .times.pre_brewing[0] + .seconds.seconds_out, ) - serial_number = mock_lamarzocco.serial_number - for entity_name in ENTITIES: - for key in PhysicalKey: - state = hass.states.get(f"number.{serial_number}_{entity_name}_key_{key}") - assert state is None - - -@pytest.mark.parametrize( - "device_fixture", - [MachineModel.GS3_MP, MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI], -) -async def test_not_existing_key_entities( +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA]) +async def test_prebrew_off( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Assert not existing key entities.""" + """Test prebrew off number.""" + mock_lamarzocco.dashboard.config[ + WidgetType.CM_PRE_BREWING + ].mode = PreExtractionMode.PREBREWING + await async_init_integration(hass, mock_config_entry) serial_number = mock_lamarzocco.serial_number + entity_id = f"number.{serial_number}_prebrew_off_time" - for entity in ( - "prebrew_off_time", - "prebrew_on_time", - "preinfusion_time", - "set_dose", - ): - for key in range(1, KEYS_PER_MODEL[MachineModel.GS3_AV] + 1): - state = hass.states.get(f"number.{serial_number}_{entity}_key_{key}") - assert state is None + state = hass.states.get(entity_id) + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 7, + }, + blocking=True, + ) + + mock_lamarzocco.set_pre_extraction_times.assert_called_once_with( + seconds_off=7, + seconds_on=mock_lamarzocco.dashboard.config[WidgetType.CM_PRE_BREWING] + .times.pre_brewing[0] + .seconds.seconds_in, + ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -419,7 +237,9 @@ async def test_number_error( state = hass.states.get(f"number.{serial_number}_coffee_target_temperature") assert state - mock_lamarzocco.set_temp.side_effect = RequestNotSuccessful("Boom") + mock_lamarzocco.set_coffee_target_temperature.side_effect = RequestNotSuccessful( + "Boom" + ) with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( NUMBER_DOMAIN, @@ -431,107 +251,3 @@ async def test_number_error( blocking=True, ) assert exc_info.value.translation_key == "number_exception" - - state = hass.states.get(f"number.{serial_number}_dose_key_1") - assert state - - mock_lamarzocco.set_dose.side_effect = RequestNotSuccessful("Boom") - with pytest.raises(HomeAssistantError) as exc_info: - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: f"number.{serial_number}_dose_key_1", - ATTR_VALUE: 99, - }, - 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 3bfb579e6d4..845eda69d5b 100644 --- a/tests/components/lamarzocco/test_select.py +++ b/tests/components/lamarzocco/test_select.py @@ -1,20 +1,16 @@ """Tests for the La Marzocco select entities.""" -from datetime import timedelta from unittest.mock import MagicMock -from freezegun.api import FrozenDateTimeFactory from pylamarzocco.const import ( - MachineModel, - PhysicalKey, - PrebrewMode, - SmartStandbyMode, - SteamLevel, + ModelName, + PreExtractionMode, + SmartStandByType, + SteamTargetLevel, ) from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import LaMarzoccoScale import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, @@ -26,15 +22,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -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]) +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA]) async def test_steam_boiler_level( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -65,12 +57,14 @@ async def test_steam_boiler_level( blocking=True, ) - mock_lamarzocco.set_steam_level.assert_called_once_with(level=SteamLevel.LEVEL_2) + mock_lamarzocco.set_steam_level.assert_called_once_with( + level=SteamTargetLevel.LEVEL_2 + ) @pytest.mark.parametrize( "device_fixture", - [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MINI], + [ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI], ) async def test_steam_boiler_level_none( hass: HomeAssistant, @@ -86,7 +80,7 @@ async def test_steam_boiler_level_none( @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "device_fixture", - [MachineModel.LINEA_MICRA, MachineModel.GS3_AV, MachineModel.LINEA_MINI], + [ModelName.LINEA_MICRA, ModelName.GS3_AV, ModelName.LINEA_MINI], ) async def test_pre_brew_infusion_select( hass: HomeAssistant, @@ -118,19 +112,21 @@ async def test_pre_brew_infusion_select( blocking=True, ) - mock_lamarzocco.set_prebrew_mode.assert_called_once_with(mode=PrebrewMode.PREBREW) + mock_lamarzocco.set_pre_extraction_mode.assert_called_once_with( + mode=PreExtractionMode.PREBREWING + ) @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "device_fixture", - [MachineModel.GS3_MP], + [ModelName.GS3_MP], ) async def test_pre_brew_infusion_select_none( hass: HomeAssistant, mock_lamarzocco: MagicMock, ) -> None: - """Ensure the La Marzocco Steam Level Select is not created for non-Micra models.""" + """Ensure GS3 MP has no prebrew models.""" serial_number = mock_lamarzocco.serial_number state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode") @@ -162,13 +158,13 @@ async def test_smart_standby_mode( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: f"select.{serial_number}_smart_standby_mode", - ATTR_OPTION: "power_on", + ATTR_OPTION: "last_brewing", }, blocking=True, ) mock_lamarzocco.set_smart_standby.assert_called_once_with( - enabled=True, mode=SmartStandbyMode.POWER_ON, minutes=10 + enabled=True, mode=SmartStandByType.LAST_BREW, minutes=10 ) @@ -183,7 +179,7 @@ async def test_select_errors( state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode") assert state - mock_lamarzocco.set_prebrew_mode.side_effect = RequestNotSuccessful("Boom") + mock_lamarzocco.set_pre_extraction_mode.side_effect = RequestNotSuccessful("Boom") # Test setting invalid option with pytest.raises(HomeAssistantError) as exc_info: @@ -197,77 +193,3 @@ 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 43a0826d551..183d3f2daa6 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -1,29 +1,27 @@ """Tests for La Marzocco sensors.""" -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 +from pylamarzocco.const import ModelName import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import 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, snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.usefixtures("mock_websocket_terminated") -@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, mock_lamarzocco: MagicMock, - entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test the La Marzocco sensors.""" @@ -33,106 +31,24 @@ async def test_sensors( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_shot_timer_not_exists( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry_no_local_connection: MockConfigEntry, -) -> None: - """Test the La Marzocco shot timer doesn't exist if host not set.""" - - await async_init_integration(hass, mock_config_entry_no_local_connection) - state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") - assert state is None - - -async def test_shot_timer_unavailable( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the La Marzocco brew_active becomes unavailable.""" - - mock_lamarzocco.websocket_connected = False - await async_init_integration(hass, mock_config_entry) - state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") - assert state - assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) -async def test_no_steam_linea_mini( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure Linea Mini has no steam temp.""" - await async_init_integration(hass, mock_config_entry) - - 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( +@pytest.mark.parametrize( + "device_fixture", + [ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI, ModelName.LINEA_MICRA], +) +async def test_steam_ready_entity_for_all_machines( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, ) -> None: - """Test the scale battery sensor.""" + """Test the La Marzocco steam ready sensor for all machines.""" + + serial_number = mock_lamarzocco.serial_number await async_init_integration(hass, mock_config_entry) - state = hass.states.get("sensor.lmz_123a45_battery") + state = hass.states.get(f"sensor.{serial_number}_steam_boiler_ready_time") + 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 d8370ad8575..0f1c4fd6ebb 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -3,9 +3,10 @@ from typing import Any from unittest.mock import MagicMock, patch +from pylamarzocco.const import SmartStandByType from pylamarzocco.exceptions import RequestNotSuccessful import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, @@ -24,7 +25,6 @@ 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, @@ -47,7 +47,7 @@ async def test_switches( ( "_smart_standby_enabled", "set_smart_standby", - {"mode": "LastBrewing", "minutes": 10}, + {"mode": SmartStandByType.POWER_ON, "minutes": 10}, ), ], ) @@ -124,12 +124,15 @@ async def test_auto_on_off_switches( blocking=True, ) - wake_up_sleep_entry = mock_lamarzocco.config.wake_up_sleep_entries[ - wake_up_sleep_entry_id - ] + wake_up_sleep_entry = ( + mock_lamarzocco.schedule.smart_wake_up_sleep.schedules_dict[ + wake_up_sleep_entry_id + ] + ) + assert wake_up_sleep_entry wake_up_sleep_entry.enabled = False - mock_lamarzocco.set_wake_up_sleep.assert_called_with(wake_up_sleep_entry) + mock_lamarzocco.set_wakeup_schedule.assert_called_with(wake_up_sleep_entry) await hass.services.async_call( SWITCH_DOMAIN, @@ -140,7 +143,7 @@ async def test_auto_on_off_switches( blocking=True, ) wake_up_sleep_entry.enabled = True - mock_lamarzocco.set_wake_up_sleep.assert_called_with(wake_up_sleep_entry) + mock_lamarzocco.set_wakeup_schedule.assert_called_with(wake_up_sleep_entry) async def test_switch_exceptions( @@ -183,7 +186,7 @@ async def test_switch_exceptions( state = hass.states.get(f"switch.{serial_number}_auto_on_off_os2oswx") assert state - mock_lamarzocco.set_wake_up_sleep.side_effect = RequestNotSuccessful("Boom") + mock_lamarzocco.set_wakeup_schedule.side_effect = RequestNotSuccessful("Boom") with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( SWITCH_DOMAIN, diff --git a/tests/components/lamarzocco/test_update.py b/tests/components/lamarzocco/test_update.py index 4089ffa297a..46e466a3acc 100644 --- a/tests/components/lamarzocco/test_update.py +++ b/tests/components/lamarzocco/test_update.py @@ -1,11 +1,18 @@ """Tests for the La Marzocco Update Entities.""" -from unittest.mock import MagicMock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch -from pylamarzocco.const import FirmwareType +from pylamarzocco.const import ( + FirmwareType, + UpdateCommandStatus, + UpdateProgressInfo, + UpdateStatus, +) from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.models import UpdateDetails import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL from homeassistant.const import ATTR_ENTITY_ID, Platform @@ -16,11 +23,21 @@ from homeassistant.helpers import entity_registry as er from . import async_init_integration from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def mock_sleep() -> Generator[AsyncMock]: + """Mock asyncio.sleep.""" + with patch( + "homeassistant.components.lamarzocco.update.asyncio.sleep", + return_value=AsyncMock(), + ) as mock_sleep: + yield mock_sleep async def test_update( hass: HomeAssistant, - mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, @@ -31,67 +48,115 @@ async def test_update( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - ("entity_name", "component"), - [ - ("machine_firmware", FirmwareType.MACHINE), - ("gateway_firmware", FirmwareType.GATEWAY), - ], -) -async def test_update_entites( +async def test_update_process( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, - entity_name: str, - component: FirmwareType, + hass_ws_client: WebSocketGenerator, ) -> None: """Test the La Marzocco update entities.""" serial_number = mock_lamarzocco.serial_number + mock_lamarzocco.get_firmware.side_effect = [ + UpdateDetails( + status=UpdateStatus.TO_UPDATE, + command_status=UpdateCommandStatus.IN_PROGRESS, + progress_info=UpdateProgressInfo.STARTING_PROCESS, + progress_percentage=0, + ), + UpdateDetails( + status=UpdateStatus.UPDATED, + command_status=None, + progress_info=None, + progress_percentage=None, + ), + ] + await async_init_integration(hass, mock_config_entry) + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": f"update.{serial_number}_gateway_firmware", + } + ) + result = await client.receive_json() + assert ( + mock_lamarzocco.settings.firmwares[ + FirmwareType.GATEWAY + ].available_update.change_log + in result["result"] + ) + await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: f"update.{serial_number}_{entity_name}", + ATTR_ENTITY_ID: f"update.{serial_number}_gateway_firmware", }, blocking=True, ) - mock_lamarzocco.update_firmware.assert_called_once_with(component) + mock_lamarzocco.update_firmware.assert_called_once_with() -@pytest.mark.parametrize( - ("attr", "value"), - [ - ("side_effect", RequestNotSuccessful("Boom")), - ("return_value", False), - ], -) 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") + state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_gateway_firmware") assert state - setattr(mock_lamarzocco.update_firmware, attr, value) + mock_lamarzocco.update_firmware.side_effect = RequestNotSuccessful("Boom") with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_machine_firmware", + ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_gateway_firmware", + }, + blocking=True, + ) + assert exc_info.value.translation_key == "update_failed" + + +async def test_update_times_out( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error during update.""" + mock_lamarzocco.get_firmware.return_value = UpdateDetails( + status=UpdateStatus.TO_UPDATE, + command_status=UpdateCommandStatus.IN_PROGRESS, + progress_info=UpdateProgressInfo.STARTING_PROCESS, + progress_percentage=0, + ) + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_gateway_firmware") + assert state + + with ( + patch("homeassistant.components.lamarzocco.update.MAX_UPDATE_WAIT", 0), + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_gateway_firmware", }, blocking=True, ) diff --git a/tests/components/lametric/test_diagnostics.py b/tests/components/lametric/test_diagnostics.py index e1fcbafcb73..8f42682ccfc 100644 --- a/tests/components/lametric/test_diagnostics.py +++ b/tests/components/lametric/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the LaMetric integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 1578c67432d..60373fa6c94 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest import serial -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from ultraheat_api.response import HeatMeterResponse from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index d8dee472946..e588cc7b952 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -88,7 +88,7 @@ def create_config_entry( title = entry_data[CONF_HOST] return MockConfigEntry( - entry_id=fixture_filename, + entry_id=fixture_filename.replace(".", "_"), domain=DOMAIN, title=title, data=entry_data, diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index 068b8757707..5ded11d619a 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -27,7 +27,6 @@ { "address": [0, 7, false], "name": "Light_Output1", - "resource": "output1", "domain": "light", "domain_data": { "output": "OUTPUT1", @@ -38,7 +37,6 @@ { "address": [0, 7, false], "name": "Light_Output2", - "resource": "output2", "domain": "light", "domain_data": { "output": "OUTPUT2", @@ -49,7 +47,6 @@ { "address": [0, 7, false], "name": "Light_Relay1", - "resource": "relay1", "domain": "light", "domain_data": { "output": "RELAY1", @@ -60,7 +57,6 @@ { "address": [0, 7, false], "name": "Switch_Output1", - "resource": "output1", "domain": "switch", "domain_data": { "output": "OUTPUT1" @@ -69,7 +65,6 @@ { "address": [0, 7, false], "name": "Switch_Output2", - "resource": "output2", "domain": "switch", "domain_data": { "output": "OUTPUT2" @@ -78,7 +73,6 @@ { "address": [0, 7, false], "name": "Switch_Relay1", - "resource": "relay1", "domain": "switch", "domain_data": { "output": "RELAY1" @@ -87,7 +81,6 @@ { "address": [0, 7, false], "name": "Switch_Relay2", - "resource": "relay2", "domain": "switch", "domain_data": { "output": "RELAY2" @@ -96,7 +89,6 @@ { "address": [0, 7, false], "name": "Switch_Regulator1", - "resource": "r1varsetpoint", "domain": "switch", "domain_data": { "output": "R1VARSETPOINT" @@ -105,7 +97,6 @@ { "address": [0, 7, false], "name": "Switch_KeyLock1", - "resource": "a1", "domain": "switch", "domain_data": { "output": "A1" @@ -114,7 +105,6 @@ { "address": [0, 5, true], "name": "Switch_Group5", - "resource": "relay1", "domain": "switch", "domain_data": { "output": "RELAY1" @@ -123,7 +113,6 @@ { "address": [0, 7, false], "name": "Cover_Outputs", - "resource": "outputs", "domain": "cover", "domain_data": { "motor": "OUTPUTS", @@ -133,22 +122,44 @@ { "address": [0, 7, false], "name": "Cover_Relays", - "resource": "motor1", "domain": "cover", "domain_data": { "motor": "MOTOR1", - "reverse_time": "RT1200" + "reverse_time": "RT1200", + "positioning_mode": "NONE" + } + }, + { + "address": [0, 7, false], + "name": "Cover_Relays_BS4", + "resource": "motor2", + "domain": "cover", + "domain_data": { + "motor": "MOTOR2", + "reverse_time": "RT1200", + "positioning_mode": "BS4" + } + }, + { + "address": [0, 7, false], + "name": "Cover_Relays_Module", + "resource": "motor3", + "domain": "cover", + "domain_data": { + "motor": "MOTOR3", + "reverse_time": "RT1200", + "positioning_mode": "MODULE" } }, { "address": [0, 7, false], "name": "Climate1", - "resource": "var1.r1varsetpoint", "domain": "climate", "domain_data": { "source": "VAR1", "setpoint": "R1VARSETPOINT", "lockable": true, + "target_value_locked": -1, "min_temp": 0.0, "max_temp": 40.0, "unit_of_measurement": "°C" @@ -157,7 +168,6 @@ { "address": [0, 7, false], "name": "Romantic", - "resource": "0.0", "domain": "scene", "domain_data": { "register": 0, @@ -169,7 +179,6 @@ { "address": [0, 7, false], "name": "Romantic Transition", - "resource": "0.1", "domain": "scene", "domain_data": { "register": 0, @@ -181,7 +190,6 @@ { "address": [0, 7, false], "name": "Sensor_LockRegulator1", - "resource": "r1varsetpoint", "domain": "binary_sensor", "domain_data": { "source": "R1VARSETPOINT" @@ -190,7 +198,6 @@ { "address": [0, 7, false], "name": "Binary_Sensor1", - "resource": "binsensor1", "domain": "binary_sensor", "domain_data": { "source": "BINSENSOR1" @@ -199,7 +206,6 @@ { "address": [0, 7, false], "name": "Sensor_KeyLock", - "resource": "a5", "domain": "binary_sensor", "domain_data": { "source": "A5" @@ -208,7 +214,6 @@ { "address": [0, 7, false], "name": "Sensor_Var1", - "resource": "var1", "domain": "sensor", "domain_data": { "source": "VAR1", @@ -218,7 +223,6 @@ { "address": [0, 7, false], "name": "Sensor_Setpoint1", - "resource": "r1varsetpoint", "domain": "sensor", "domain_data": { "source": "R1VARSETPOINT", @@ -228,7 +232,6 @@ { "address": [0, 7, false], "name": "Sensor_Led6", - "resource": "led6", "domain": "sensor", "domain_data": { "source": "LED6", @@ -238,7 +241,6 @@ { "address": [0, 7, false], "name": "Sensor_LogicOp1", - "resource": "logicop1", "domain": "sensor", "domain_data": { "source": "LOGICOP1", diff --git a/tests/components/lcn/fixtures/config_entry_pchk_v2_1.json b/tests/components/lcn/fixtures/config_entry_pchk_v2_1.json new file mode 100644 index 00000000000..3b4938b8600 --- /dev/null +++ b/tests/components/lcn/fixtures/config_entry_pchk_v2_1.json @@ -0,0 +1,96 @@ +{ + "host": "pchk", + "ip_address": "192.168.2.41", + "port": 4114, + "username": "lcn", + "password": "lcn", + "sk_num_tries": 0, + "dim_mode": "STEPS200", + "acknowledge": false, + "devices": [ + { + "address": [0, 7, false], + "name": "TestModule", + "hardware_serial": -1, + "software_serial": -1, + "hardware_type": -1 + } + ], + "entities": [ + { + "address": [0, 7, false], + "name": "Light_Output1", + "resource": "output1", + "domain": "light", + "domain_data": { + "output": "OUTPUT1", + "dimmable": true, + "transition": 5.0 + } + }, + { + "address": [0, 7, false], + "name": "Switch_Relay1", + "resource": "relay1", + "domain": "switch", + "domain_data": { + "output": "RELAY1" + } + }, + { + "address": [0, 7, false], + "name": "Cover_Relays", + "resource": "motor1", + "domain": "cover", + "domain_data": { + "motor": "MOTOR1", + "reverse_time": "RT1200" + } + }, + { + "address": [0, 7, false], + "name": "Climate1", + "resource": "var1.r1varsetpoint", + "domain": "climate", + "domain_data": { + "source": "VAR1", + "setpoint": "R1VARSETPOINT", + "lockable": true, + "min_temp": 0.0, + "max_temp": 40.0, + "unit_of_measurement": "°C" + } + }, + { + "address": [0, 7, false], + "name": "Romantic", + "resource": "0.0", + "domain": "scene", + "domain_data": { + "register": 0, + "scene": 0, + "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], + "transition": 0.0 + } + }, + { + "address": [0, 7, false], + "name": "Binary_Sensor1", + "resource": "binsensor1", + "domain": "binary_sensor", + "domain_data": { + "source": "BINSENSOR1" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Var1", + "resource": "var1", + "domain": "sensor", + "domain_data": { + "source": "VAR1", + "unit_of_measurement": "°C" + } + } + ] +} diff --git a/tests/components/lcn/snapshots/test_binary_sensor.ambr b/tests/components/lcn/snapshots/test_binary_sensor.ambr index d2d697569d1..e3f7c9ab404 100644 --- a/tests/components/lcn/snapshots/test_binary_sensor.ambr +++ b/tests/components/lcn/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_binary_sensor[binary_sensor.binary_sensor1-entry] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_binary_sensor1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.binary_sensor1', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.testmodule_binary_sensor1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -29,24 +29,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-binsensor1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-binsensor1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.binary_sensor1-state] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_binary_sensor1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Binary_Sensor1', + 'friendly_name': 'TestModule Binary_Sensor1', }), 'context': , - 'entity_id': 'binary_sensor.binary_sensor1', + 'entity_id': 'binary_sensor.testmodule_binary_sensor1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_keylock-entry] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_keylock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,8 +59,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.sensor_keylock', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.testmodule_sensor_keylock', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -76,24 +76,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-a5', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-a5', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_keylock-state] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_keylock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sensor_KeyLock', + 'friendly_name': 'TestModule Sensor_KeyLock', }), 'context': , - 'entity_id': 'binary_sensor.sensor_keylock', + 'entity_id': 'binary_sensor.testmodule_sensor_keylock', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_lockregulator1-entry] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_lockregulator1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -106,8 +106,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.sensor_lockregulator1', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.testmodule_sensor_lockregulator1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -123,17 +123,17 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_lockregulator1-state] +# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_lockregulator1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sensor_LockRegulator1', + 'friendly_name': 'TestModule Sensor_LockRegulator1', }), 'context': , - 'entity_id': 'binary_sensor.sensor_lockregulator1', + 'entity_id': 'binary_sensor.testmodule_sensor_lockregulator1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_climate.ambr b/tests/components/lcn/snapshots/test_climate.ambr index 81745ca8515..7393a9a8421 100644 --- a/tests/components/lcn/snapshots/test_climate.ambr +++ b/tests/components/lcn/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_climate[climate.climate1-entry] +# name: test_setup_lcn_climate[climate.testmodule_climate1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -19,8 +19,8 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.climate1', - 'has_entity_name': False, + 'entity_id': 'climate.testmodule_climate1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -36,15 +36,15 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-var1.r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_climate[climate.climate1-state] +# name: test_setup_lcn_climate[climate.testmodule_climate1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': None, - 'friendly_name': 'Climate1', + 'friendly_name': 'TestModule Climate1', 'hvac_modes': list([ , , @@ -55,7 +55,7 @@ 'temperature': None, }), 'context': , - 'entity_id': 'climate.climate1', + 'entity_id': 'climate.testmodule_climate1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_cover.ambr b/tests/components/lcn/snapshots/test_cover.ambr index d399626537d..722261f1432 100644 --- a/tests/components/lcn/snapshots/test_cover.ambr +++ b/tests/components/lcn/snapshots/test_cover.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_cover[cover.cover_outputs-entry] +# name: test_setup_lcn_cover[cover.testmodule_cover_outputs-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'cover', 'entity_category': None, - 'entity_id': 'cover.cover_outputs', - 'has_entity_name': False, + 'entity_id': 'cover.testmodule_cover_outputs', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -29,26 +29,26 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-outputs', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-outputs', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_cover[cover.cover_outputs-state] +# name: test_setup_lcn_cover[cover.testmodule_cover_outputs-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'assumed_state': True, - 'friendly_name': 'Cover_Outputs', + 'friendly_name': 'TestModule Cover_Outputs', 'supported_features': , }), 'context': , - 'entity_id': 'cover.cover_outputs', + 'entity_id': 'cover.testmodule_cover_outputs', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'open', }) # --- -# name: test_setup_lcn_cover[cover.cover_relays-entry] +# name: test_setup_lcn_cover[cover.testmodule_cover_relays-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -61,8 +61,8 @@ 'disabled_by': None, 'domain': 'cover', 'entity_category': None, - 'entity_id': 'cover.cover_relays', - 'has_entity_name': False, + 'entity_id': 'cover.testmodule_cover_relays', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -78,19 +78,117 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-motor1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-motor1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_cover[cover.cover_relays-state] +# name: test_setup_lcn_cover[cover.testmodule_cover_relays-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'assumed_state': True, - 'friendly_name': 'Cover_Relays', + 'friendly_name': 'TestModule Cover_Relays', 'supported_features': , }), 'context': , - 'entity_id': 'cover.cover_relays', + 'entity_id': 'cover.testmodule_cover_relays', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_setup_lcn_cover[cover.testmodule_cover_relays_bs4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.testmodule_cover_relays_bs4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cover_Relays_BS4', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk_json-m000007-motor2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_cover[cover.testmodule_cover_relays_bs4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'friendly_name': 'TestModule Cover_Relays_BS4', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.testmodule_cover_relays_bs4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_setup_lcn_cover[cover.testmodule_cover_relays_module-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.testmodule_cover_relays_module', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cover_Relays_Module', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk_json-m000007-motor3', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_cover[cover.testmodule_cover_relays_module-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'friendly_name': 'TestModule Cover_Relays_Module', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.testmodule_cover_relays_module', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_init.ambr b/tests/components/lcn/snapshots/test_init.ambr index ea6267aaa0b..8d7a858cf16 100644 --- a/tests/components/lcn/snapshots/test_init.ambr +++ b/tests/components/lcn/snapshots/test_init.ambr @@ -30,7 +30,6 @@ 'transition': 5.0, }), 'name': 'Light_Output1', - 'resource': 'output1', }), ]), 'host': 'pchk', @@ -72,7 +71,6 @@ 'transition': 5.0, }), 'name': 'Light_Output1', - 'resource': 'output1', }), dict({ 'address': tuple( @@ -87,7 +85,6 @@ 'transition': 0.0, }), 'name': 'Light_Output2', - 'resource': 'output2', }), dict({ 'address': tuple( @@ -107,7 +104,6 @@ 'transition': 0.0, }), 'name': 'Romantic', - 'resource': '0.0', }), dict({ 'address': tuple( @@ -127,7 +123,134 @@ 'transition': 10.0, }), 'name': 'Romantic Transition', - 'resource': '0.1', + }), + ]), + 'host': 'pchk', + 'ip_address': '192.168.2.41', + 'password': 'lcn', + 'port': 4114, + 'sk_num_tries': 0, + 'username': 'lcn', + }) +# --- +# name: test_migrate_2_1 + dict({ + 'acknowledge': False, + 'devices': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'hardware_serial': -1, + 'hardware_type': -1, + 'name': 'TestModule', + 'software_serial': -1, + }), + ]), + 'dim_mode': 'STEPS200', + 'entities': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'light', + 'domain_data': dict({ + 'dimmable': True, + 'output': 'OUTPUT1', + 'transition': 5.0, + }), + 'name': 'Light_Output1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'switch', + 'domain_data': dict({ + 'output': 'RELAY1', + }), + 'name': 'Switch_Relay1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'cover', + 'domain_data': dict({ + 'motor': 'MOTOR1', + 'reverse_time': 'RT1200', + }), + 'name': 'Cover_Relays', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'climate', + 'domain_data': dict({ + 'lockable': True, + 'max_temp': 40.0, + 'min_temp': 0.0, + 'setpoint': 'R1VARSETPOINT', + 'source': 'VAR1', + 'target_value_locked': -1, + 'unit_of_measurement': '°C', + }), + 'name': 'Climate1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'scene', + 'domain_data': dict({ + 'outputs': list([ + 'OUTPUT1', + 'OUTPUT2', + 'RELAY1', + ]), + 'register': 0, + 'scene': 0, + 'transition': 0.0, + }), + 'name': 'Romantic', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'binary_sensor', + 'domain_data': dict({ + 'source': 'BINSENSOR1', + }), + 'name': 'Binary_Sensor1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'sensor', + 'domain_data': dict({ + 'source': 'VAR1', + 'unit_of_measurement': '°C', + }), + 'name': 'Sensor_Var1', }), ]), 'host': 'pchk', diff --git a/tests/components/lcn/snapshots/test_light.ambr b/tests/components/lcn/snapshots/test_light.ambr index 638cddc15cd..0a9086d1efb 100644 --- a/tests/components/lcn/snapshots/test_light.ambr +++ b/tests/components/lcn/snapshots/test_light.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_light[light.light_output1-entry] +# name: test_setup_lcn_light[light.testmodule_light_output1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -16,8 +16,8 @@ 'disabled_by': None, 'domain': 'light', 'entity_category': None, - 'entity_id': 'light.light_output1', - 'has_entity_name': False, + 'entity_id': 'light.testmodule_light_output1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -33,30 +33,30 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_light[light.light_output1-state] +# name: test_setup_lcn_light[light.testmodule_light_output1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'brightness': None, 'color_mode': None, - 'friendly_name': 'Light_Output1', + 'friendly_name': 'TestModule Light_Output1', 'supported_color_modes': list([ , ]), 'supported_features': , }), 'context': , - 'entity_id': 'light.light_output1', + 'entity_id': 'light.testmodule_light_output1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_light[light.light_output2-entry] +# name: test_setup_lcn_light[light.testmodule_light_output2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -73,8 +73,8 @@ 'disabled_by': None, 'domain': 'light', 'entity_category': None, - 'entity_id': 'light.light_output2', - 'has_entity_name': False, + 'entity_id': 'light.testmodule_light_output2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -90,29 +90,29 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output2', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output2', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_light[light.light_output2-state] +# name: test_setup_lcn_light[light.testmodule_light_output2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'color_mode': None, - 'friendly_name': 'Light_Output2', + 'friendly_name': 'TestModule Light_Output2', 'supported_color_modes': list([ , ]), 'supported_features': , }), 'context': , - 'entity_id': 'light.light_output2', + 'entity_id': 'light.testmodule_light_output2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_light[light.light_relay1-entry] +# name: test_setup_lcn_light[light.testmodule_light_relay1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -129,8 +129,8 @@ 'disabled_by': None, 'domain': 'light', 'entity_category': None, - 'entity_id': 'light.light_relay1', - 'has_entity_name': False, + 'entity_id': 'light.testmodule_light_relay1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -146,22 +146,22 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_light[light.light_relay1-state] +# name: test_setup_lcn_light[light.testmodule_light_relay1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'color_mode': None, - 'friendly_name': 'Light_Relay1', + 'friendly_name': 'TestModule Light_Relay1', 'supported_color_modes': list([ , ]), 'supported_features': , }), 'context': , - 'entity_id': 'light.light_relay1', + 'entity_id': 'light.testmodule_light_relay1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_scene.ambr b/tests/components/lcn/snapshots/test_scene.ambr index a5576158621..9196e7d8ae0 100644 --- a/tests/components/lcn/snapshots/test_scene.ambr +++ b/tests/components/lcn/snapshots/test_scene.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_scene[scene.romantic-entry] +# name: test_setup_lcn_scene[scene.testmodule_romantic-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'scene', 'entity_category': None, - 'entity_id': 'scene.romantic', - 'has_entity_name': False, + 'entity_id': 'scene.testmodule_romantic', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -29,24 +29,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-0.0', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-00', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_scene[scene.romantic-state] +# name: test_setup_lcn_scene[scene.testmodule_romantic-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Romantic', + 'friendly_name': 'TestModule Romantic', }), 'context': , - 'entity_id': 'scene.romantic', + 'entity_id': 'scene.testmodule_romantic', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_scene[scene.romantic_transition-entry] +# name: test_setup_lcn_scene[scene.testmodule_romantic_transition-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,8 +59,8 @@ 'disabled_by': None, 'domain': 'scene', 'entity_category': None, - 'entity_id': 'scene.romantic_transition', - 'has_entity_name': False, + 'entity_id': 'scene.testmodule_romantic_transition', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -76,17 +76,17 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-0.1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-01', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_scene[scene.romantic_transition-state] +# name: test_setup_lcn_scene[scene.testmodule_romantic_transition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Romantic Transition', + 'friendly_name': 'TestModule Romantic Transition', }), 'context': , - 'entity_id': 'scene.romantic_transition', + 'entity_id': 'scene.testmodule_romantic_transition', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_sensor.ambr b/tests/components/lcn/snapshots/test_sensor.ambr index f8d57ed8904..60586a45058 100644 --- a/tests/components/lcn/snapshots/test_sensor.ambr +++ b/tests/components/lcn/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_sensor[sensor.sensor_led6-entry] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_led6-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.sensor_led6', - 'has_entity_name': False, + 'entity_id': 'sensor.testmodule_sensor_led6', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -29,24 +29,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-led6', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-led6', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_led6-state] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_led6-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sensor_Led6', + 'friendly_name': 'TestModule Sensor_Led6', }), 'context': , - 'entity_id': 'sensor.sensor_led6', + 'entity_id': 'sensor.testmodule_sensor_led6', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_logicop1-entry] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_logicop1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,8 +59,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.sensor_logicop1', - 'has_entity_name': False, + 'entity_id': 'sensor.testmodule_sensor_logicop1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -76,24 +76,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-logicop1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-logicop1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_logicop1-state] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_logicop1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sensor_LogicOp1', + 'friendly_name': 'TestModule Sensor_LogicOp1', }), 'context': , - 'entity_id': 'sensor.sensor_logicop1', + 'entity_id': 'sensor.testmodule_sensor_logicop1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_setpoint1-entry] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_setpoint1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -106,8 +106,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.sensor_setpoint1', - 'has_entity_name': False, + 'entity_id': 'sensor.testmodule_sensor_setpoint1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -123,26 +123,26 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': , }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_setpoint1-state] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_setpoint1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Sensor_Setpoint1', + 'friendly_name': 'TestModule Sensor_Setpoint1', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.sensor_setpoint1', + 'entity_id': 'sensor.testmodule_sensor_setpoint1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_var1-entry] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_var1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -155,8 +155,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.sensor_var1', - 'has_entity_name': False, + 'entity_id': 'sensor.testmodule_sensor_var1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -172,19 +172,19 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-var1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-var1', 'unit_of_measurement': , }) # --- -# name: test_setup_lcn_sensor[sensor.sensor_var1-state] +# name: test_setup_lcn_sensor[sensor.testmodule_sensor_var1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Sensor_Var1', + 'friendly_name': 'TestModule Sensor_Var1', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.sensor_var1', + 'entity_id': 'sensor.testmodule_sensor_var1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/snapshots/test_switch.ambr b/tests/components/lcn/snapshots/test_switch.ambr index bc69b0ed483..b37dd3303db 100644 --- a/tests/components/lcn/snapshots/test_switch.ambr +++ b/tests/components/lcn/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_lcn_switch[switch.switch_group5-entry] +# name: test_setup_lcn_switch[switch.testgroup_switch_group5-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_group5', - 'has_entity_name': False, + 'entity_id': 'switch.testgroup_switch_group5', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -29,24 +29,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-g000005-relay1', + 'unique_id': 'lcn/config_entry_pchk_json-g000005-relay1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_group5-state] +# name: test_setup_lcn_switch[switch.testgroup_switch_group5-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Group5', + 'friendly_name': 'TestGroup Switch_Group5', }), 'context': , - 'entity_id': 'switch.switch_group5', + 'entity_id': 'switch.testgroup_switch_group5', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_keylock1-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_keylock1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,8 +59,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_keylock1', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_keylock1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -76,24 +76,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-a1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-a1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_keylock1-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_keylock1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_KeyLock1', + 'friendly_name': 'TestModule Switch_KeyLock1', }), 'context': , - 'entity_id': 'switch.switch_keylock1', + 'entity_id': 'switch.testmodule_switch_keylock1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_output1-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_output1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -106,8 +106,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_output1', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_output1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -123,24 +123,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_output1-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_output1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Output1', + 'friendly_name': 'TestModule Switch_Output1', }), 'context': , - 'entity_id': 'switch.switch_output1', + 'entity_id': 'switch.testmodule_switch_output1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_output2-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_output2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -153,8 +153,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_output2', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_output2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -170,24 +170,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-output2', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-output2', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_output2-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_output2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Output2', + 'friendly_name': 'TestModule Switch_Output2', }), 'context': , - 'entity_id': 'switch.switch_output2', + 'entity_id': 'switch.testmodule_switch_output2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_regulator1-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_regulator1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -200,8 +200,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_regulator1', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_regulator1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -217,24 +217,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-r1varsetpoint', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_regulator1-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_regulator1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Regulator1', + 'friendly_name': 'TestModule Switch_Regulator1', }), 'context': , - 'entity_id': 'switch.switch_regulator1', + 'entity_id': 'switch.testmodule_switch_regulator1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_relay1-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_relay1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -247,8 +247,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_relay1', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_relay1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -264,24 +264,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay1', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay1', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_relay1-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_relay1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Relay1', + 'friendly_name': 'TestModule Switch_Relay1', }), 'context': , - 'entity_id': 'switch.switch_relay1', + 'entity_id': 'switch.testmodule_switch_relay1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_setup_lcn_switch[switch.switch_relay2-entry] +# name: test_setup_lcn_switch[switch.testmodule_switch_relay2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -294,8 +294,8 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.switch_relay2', - 'has_entity_name': False, + 'entity_id': 'switch.testmodule_switch_relay2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -311,17 +311,17 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay2', + 'unique_id': 'lcn/config_entry_pchk_json-m000007-relay2', 'unit_of_measurement': None, }) # --- -# name: test_setup_lcn_switch[switch.switch_relay2-state] +# name: test_setup_lcn_switch[switch.testmodule_switch_relay2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Switch_Relay2', + 'friendly_name': 'TestModule Switch_Relay2', }), 'context': , - 'entity_id': 'switch.switch_relay2', + 'entity_id': 'switch.testmodule_switch_relay2', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index 7d636f546c4..7e828dbc588 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -22,9 +22,9 @@ from .conftest import MockConfigEntry, init_integration from tests.common import snapshot_platform -BINARY_SENSOR_LOCKREGULATOR1 = "binary_sensor.sensor_lockregulator1" -BINARY_SENSOR_SENSOR1 = "binary_sensor.binary_sensor1" -BINARY_SENSOR_KEYLOCK = "binary_sensor.sensor_keylock" +BINARY_SENSOR_LOCKREGULATOR1 = "binary_sensor.testmodule_sensor_lockregulator1" +BINARY_SENSOR_SENSOR1 = "binary_sensor.testmodule_binary_sensor1" +BINARY_SENSOR_KEYLOCK = "binary_sensor.testmodule_sensor_keylock" async def test_setup_lcn_binary_sensor( @@ -140,7 +140,11 @@ async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) @pytest.mark.parametrize( - "entity_id", ["binary_sensor.sensor_lockregulator1", "binary_sensor.sensor_keylock"] + "entity_id", + [ + "binary_sensor.testmodule_sensor_lockregulator1", + "binary_sensor.testmodule_sensor_keylock", + ], ) async def test_create_issue( hass: HomeAssistant, diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index 7bac7cc9e81..ceb6f9524d1 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -52,7 +52,7 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - await init_integration(hass, entry) with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") state.state = HVACMode.OFF # command failed @@ -61,13 +61,16 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.HEAT}, + { + ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, blocking=True, ) lock_regulator.assert_awaited_with(0, False) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state != HVACMode.HEAT @@ -78,13 +81,16 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.HEAT}, + { + ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, blocking=True, ) lock_regulator.assert_awaited_with(0, False) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state == HVACMode.HEAT @@ -94,7 +100,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> await init_integration(hass, entry) with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") state.state = HVACMode.HEAT # command failed @@ -103,13 +109,16 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.OFF}, + { + ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_HVAC_MODE: HVACMode.OFF, + }, blocking=True, ) lock_regulator.assert_awaited_with(0, True, -1) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state != HVACMode.OFF @@ -120,13 +129,16 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.OFF}, + { + ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_HVAC_MODE: HVACMode.OFF, + }, blocking=True, ) lock_regulator.assert_awaited_with(0, True, -1) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state == HVACMode.OFF @@ -136,7 +148,7 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N await init_integration(hass, entry) with patch.object(MockModuleConnection, "var_abs") as var_abs: - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") state.state = HVACMode.HEAT # wrong temperature set via service call with high/low attributes @@ -147,7 +159,7 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.climate1", + ATTR_ENTITY_ID: "climate.testmodule_climate1", ATTR_TARGET_TEMP_LOW: 24.5, ATTR_TARGET_TEMP_HIGH: 25.5, }, @@ -163,13 +175,13 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_TEMPERATURE: 25.5}, + {ATTR_ENTITY_ID: "climate.testmodule_climate1", ATTR_TEMPERATURE: 25.5}, blocking=True, ) var_abs.assert_awaited_with(Var.R1VARSETPOINT, 25.5, VarUnit.CELSIUS) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.attributes[ATTR_TEMPERATURE] != 25.5 @@ -180,13 +192,13 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.climate1", ATTR_TEMPERATURE: 25.5}, + {ATTR_ENTITY_ID: "climate.testmodule_climate1", ATTR_TEMPERATURE: 25.5}, blocking=True, ) var_abs.assert_awaited_with(Var.R1VARSETPOINT, 25.5, VarUnit.CELSIUS) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.attributes[ATTR_TEMPERATURE] == 25.5 @@ -207,7 +219,7 @@ async def test_pushed_current_temperature_status_change( await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state == HVACMode.HEAT assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.5 @@ -230,7 +242,7 @@ async def test_pushed_setpoint_status_change( await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state == HVACMode.HEAT assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None @@ -253,7 +265,7 @@ async def test_pushed_lock_status_change( await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state is not None assert state.state == HVACMode.OFF assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None @@ -272,7 +284,7 @@ async def test_pushed_wrong_input( await device_connection.async_process_input(Unknown("input")) await hass.async_block_till_done() - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None assert state.attributes[ATTR_TEMPERATURE] is None @@ -285,5 +297,5 @@ async def test_unload_config_entry( await init_integration(hass, entry) await hass.config_entries.async_unload(entry.entry_id) - state = hass.states.get("climate.climate1") + state = hass.states.get("climate.testmodule_climate1") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index 478f2c0949e..ef99a19dee4 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -94,8 +94,8 @@ async def test_step_user_existing_host( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config_data ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {CONF_BASE: "already_configured"} + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.parametrize( diff --git a/tests/components/lcn/test_cover.py b/tests/components/lcn/test_cover.py index ff4311b6687..1ac4ea6f664 100644 --- a/tests/components/lcn/test_cover.py +++ b/tests/components/lcn/test_cover.py @@ -2,17 +2,29 @@ from unittest.mock import patch -from pypck.inputs import ModStatusOutput, ModStatusRelays +from pypck.inputs import ( + ModStatusMotorPositionBS4, + ModStatusMotorPositionModule, + ModStatusOutput, + ModStatusRelays, +) from pypck.lcn_addr import LcnAddr -from pypck.lcn_defs import MotorReverseTime, MotorStateModifier +from pypck.lcn_defs import MotorPositioningMode, MotorReverseTime, MotorStateModifier +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.cover import DOMAIN as DOMAIN_COVER, CoverState +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as DOMAIN_COVER, + CoverState, +) from homeassistant.components.lcn.helpers import get_device_connection from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, STATE_UNAVAILABLE, Platform, @@ -24,8 +36,10 @@ from .conftest import MockConfigEntry, MockModuleConnection, init_integration from tests.common import snapshot_platform -COVER_OUTPUTS = "cover.cover_outputs" -COVER_RELAYS = "cover.cover_relays" +COVER_OUTPUTS = "cover.testmodule_cover_outputs" +COVER_RELAYS = "cover.testmodule_cover_relays" +COVER_RELAYS_BS4 = "cover.testmodule_cover_relays_bs4" +COVER_RELAYS_MODULE = "cover.testmodule_cover_relays_module" async def test_setup_lcn_cover( @@ -46,13 +60,13 @@ async def test_outputs_open(hass: HomeAssistant, entry: MockConfigEntry) -> None await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_outputs" - ) as control_motors_outputs: + MockModuleConnection, "control_motor_outputs" + ) as control_motor_outputs: state = hass.states.get(COVER_OUTPUTS) state.state = CoverState.CLOSED # command failed - control_motors_outputs.return_value = False + control_motor_outputs.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -61,7 +75,7 @@ async def test_outputs_open(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_outputs.assert_awaited_with( + control_motor_outputs.assert_awaited_with( MotorStateModifier.UP, MotorReverseTime.RT1200 ) @@ -70,8 +84,8 @@ async def test_outputs_open(hass: HomeAssistant, entry: MockConfigEntry) -> None assert state.state != CoverState.OPENING # command success - control_motors_outputs.reset_mock(return_value=True) - control_motors_outputs.return_value = True + control_motor_outputs.reset_mock(return_value=True) + control_motor_outputs.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -80,7 +94,7 @@ async def test_outputs_open(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_outputs.assert_awaited_with( + control_motor_outputs.assert_awaited_with( MotorStateModifier.UP, MotorReverseTime.RT1200 ) @@ -94,13 +108,13 @@ async def test_outputs_close(hass: HomeAssistant, entry: MockConfigEntry) -> Non await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_outputs" - ) as control_motors_outputs: + MockModuleConnection, "control_motor_outputs" + ) as control_motor_outputs: state = hass.states.get(COVER_OUTPUTS) state.state = CoverState.OPEN # command failed - control_motors_outputs.return_value = False + control_motor_outputs.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -109,7 +123,7 @@ async def test_outputs_close(hass: HomeAssistant, entry: MockConfigEntry) -> Non blocking=True, ) - control_motors_outputs.assert_awaited_with( + control_motor_outputs.assert_awaited_with( MotorStateModifier.DOWN, MotorReverseTime.RT1200 ) @@ -118,8 +132,8 @@ async def test_outputs_close(hass: HomeAssistant, entry: MockConfigEntry) -> Non assert state.state != CoverState.CLOSING # command success - control_motors_outputs.reset_mock(return_value=True) - control_motors_outputs.return_value = True + control_motor_outputs.reset_mock(return_value=True) + control_motor_outputs.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -128,7 +142,7 @@ async def test_outputs_close(hass: HomeAssistant, entry: MockConfigEntry) -> Non blocking=True, ) - control_motors_outputs.assert_awaited_with( + control_motor_outputs.assert_awaited_with( MotorStateModifier.DOWN, MotorReverseTime.RT1200 ) @@ -142,13 +156,13 @@ async def test_outputs_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_outputs" - ) as control_motors_outputs: + MockModuleConnection, "control_motor_outputs" + ) as control_motor_outputs: state = hass.states.get(COVER_OUTPUTS) state.state = CoverState.CLOSING # command failed - control_motors_outputs.return_value = False + control_motor_outputs.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -157,15 +171,15 @@ async def test_outputs_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) + control_motor_outputs.assert_awaited_with(MotorStateModifier.STOP) state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == CoverState.CLOSING # command success - control_motors_outputs.reset_mock(return_value=True) - control_motors_outputs.return_value = True + control_motor_outputs.reset_mock(return_value=True) + control_motor_outputs.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -174,7 +188,7 @@ async def test_outputs_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) + control_motor_outputs.assert_awaited_with(MotorStateModifier.STOP) state = hass.states.get(COVER_OUTPUTS) assert state is not None @@ -186,16 +200,13 @@ async def test_relays_open(hass: HomeAssistant, entry: MockConfigEntry) -> None: await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_relays" - ) as control_motors_relays: - states = [MotorStateModifier.NOCHANGE] * 4 - states[0] = MotorStateModifier.UP - + MockModuleConnection, "control_motor_relays" + ) as control_motor_relays: state = hass.states.get(COVER_RELAYS) state.state = CoverState.CLOSED # command failed - control_motors_relays.return_value = False + control_motor_relays.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -204,15 +215,17 @@ async def test_relays_open(hass: HomeAssistant, entry: MockConfigEntry) -> None: blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.UP, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state != CoverState.OPENING # command success - control_motors_relays.reset_mock(return_value=True) - control_motors_relays.return_value = True + control_motor_relays.reset_mock(return_value=True) + control_motor_relays.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -221,7 +234,9 @@ async def test_relays_open(hass: HomeAssistant, entry: MockConfigEntry) -> None: blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.UP, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None @@ -233,16 +248,13 @@ async def test_relays_close(hass: HomeAssistant, entry: MockConfigEntry) -> None await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_relays" - ) as control_motors_relays: - states = [MotorStateModifier.NOCHANGE] * 4 - states[0] = MotorStateModifier.DOWN - + MockModuleConnection, "control_motor_relays" + ) as control_motor_relays: state = hass.states.get(COVER_RELAYS) state.state = CoverState.OPEN # command failed - control_motors_relays.return_value = False + control_motor_relays.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -251,15 +263,17 @@ async def test_relays_close(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.DOWN, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state != CoverState.CLOSING # command success - control_motors_relays.reset_mock(return_value=True) - control_motors_relays.return_value = True + control_motor_relays.reset_mock(return_value=True) + control_motor_relays.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -268,7 +282,9 @@ async def test_relays_close(hass: HomeAssistant, entry: MockConfigEntry) -> None blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.DOWN, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None @@ -280,16 +296,13 @@ async def test_relays_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None: await init_integration(hass, entry) with patch.object( - MockModuleConnection, "control_motors_relays" - ) as control_motors_relays: - states = [MotorStateModifier.NOCHANGE] * 4 - states[0] = MotorStateModifier.STOP - + MockModuleConnection, "control_motor_relays" + ) as control_motor_relays: state = hass.states.get(COVER_RELAYS) state.state = CoverState.CLOSING # command failed - control_motors_relays.return_value = False + control_motor_relays.return_value = False await hass.services.async_call( DOMAIN_COVER, @@ -298,15 +311,17 @@ async def test_relays_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None: blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.STOP, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == CoverState.CLOSING # command success - control_motors_relays.reset_mock(return_value=True) - control_motors_relays.return_value = True + control_motor_relays.reset_mock(return_value=True) + control_motor_relays.return_value = True await hass.services.async_call( DOMAIN_COVER, @@ -315,13 +330,74 @@ async def test_relays_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None: blocking=True, ) - control_motors_relays.assert_awaited_with(states) + control_motor_relays.assert_awaited_with( + 0, MotorStateModifier.STOP, MotorPositioningMode.NONE + ) state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state not in (CoverState.CLOSING, CoverState.OPENING) +@pytest.mark.parametrize( + ("entity_id", "motor", "positioning_mode"), + [ + (COVER_RELAYS_BS4, 1, MotorPositioningMode.BS4), + (COVER_RELAYS_MODULE, 2, MotorPositioningMode.MODULE), + ], +) +async def test_relays_set_position( + hass: HomeAssistant, + entry: MockConfigEntry, + entity_id: str, + motor: int, + positioning_mode: MotorPositioningMode, +) -> None: + """Test the relays cover moves to position.""" + await init_integration(hass, entry) + + with patch.object( + MockModuleConnection, "control_motor_relays_position" + ) as control_motor_relays_position: + state = hass.states.get(entity_id) + state.state = CoverState.CLOSED + + # command failed + control_motor_relays_position.return_value = False + + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + + control_motor_relays_position.assert_awaited_with( + motor, 50, mode=positioning_mode + ) + + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + + # command success + control_motor_relays_position.reset_mock(return_value=True) + control_motor_relays_position.return_value = True + + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + + control_motor_relays_position.assert_awaited_with( + motor, 50, mode=positioning_mode + ) + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + + async def test_pushed_outputs_status_change( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -372,8 +448,9 @@ async def test_pushed_relays_status_change( address = LcnAddr(0, 7, False) states = [False] * 8 - state = hass.states.get(COVER_RELAYS) - state.state = CoverState.CLOSED + for entity_id in (COVER_RELAYS, COVER_RELAYS_BS4, COVER_RELAYS_MODULE): + state = hass.states.get(entity_id) + state.state = CoverState.CLOSED # push status "open" states[0:2] = [True, False] @@ -405,6 +482,26 @@ async def test_pushed_relays_status_change( assert state is not None assert state.state == CoverState.CLOSING + # push status "set position" via BS4 + inp = ModStatusMotorPositionBS4(address, 1, 50) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(COVER_RELAYS_BS4) + assert state is not None + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + # push status "set position" via MODULE + inp = ModStatusMotorPositionModule(address, 2, 75) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(COVER_RELAYS_MODULE) + assert state is not None + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 75 + async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the cover is removed when the config entry is unloaded.""" diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index ef3c2d3cb66..5634449bf22 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -138,15 +138,12 @@ async def test_async_entry_reload_on_host_event_received( async def test_migrate_1_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test migration config entry.""" entry_v1_1 = create_config_entry("pchk_v1_1", version=(1, 1)) - entry_v1_1.add_to_hass(hass) - - await hass.config_entries.async_setup(entry_v1_1.entry_id) - await hass.async_block_till_done() + await init_integration(hass, entry_v1_1) entry_migrated = hass.config_entries.async_get_entry(entry_v1_1.entry_id) assert entry_migrated.state is ConfigEntryState.LOADED - assert entry_migrated.version == 2 + assert entry_migrated.version == 3 assert entry_migrated.minor_version == 1 assert entry_migrated.data == snapshot @@ -155,14 +152,51 @@ async def test_migrate_1_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> async def test_migrate_1_2(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test migration config entry.""" entry_v1_2 = create_config_entry("pchk_v1_2", version=(1, 2)) - entry_v1_2.add_to_hass(hass) - - await hass.config_entries.async_setup(entry_v1_2.entry_id) - await hass.async_block_till_done() + await init_integration(hass, entry_v1_2) entry_migrated = hass.config_entries.async_get_entry(entry_v1_2.entry_id) assert entry_migrated.state is ConfigEntryState.LOADED - assert entry_migrated.version == 2 + assert entry_migrated.version == 3 assert entry_migrated.minor_version == 1 assert entry_migrated.data == snapshot + + +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +async def test_migrate_2_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test migration config entry.""" + entry_v2_1 = create_config_entry("pchk_v2_1", version=(2, 1)) + await init_integration(hass, entry_v2_1) + + entry_migrated = hass.config_entries.async_get_entry(entry_v2_1.entry_id) + assert entry_migrated.state is ConfigEntryState.LOADED + assert entry_migrated.version == 3 + assert entry_migrated.minor_version == 1 + assert entry_migrated.data == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "replace"), + [ + ("climate.testmodule_climate1", ("-r1varsetpoint", "-var1.r1varsetpoint")), + ("scene.testmodule_romantic", ("-00", "-0.0")), + ], +) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +async def test_entity_migration_on_2_1( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id, replace +) -> None: + """Test entity.unique_id migration on config_entry migration from 2.1.""" + entry_v2_1 = create_config_entry("pchk_v2_1", version=(2, 1)) + await init_integration(hass, entry_v2_1) + + migrated_unique_id = entity_registry.async_get(entity_id).unique_id + old_unique_id = migrated_unique_id.replace(*replace) + entity_registry.async_update_entity(entity_id, new_unique_id=old_unique_id) + assert entity_registry.async_get(entity_id).unique_id == old_unique_id + + await hass.config_entries.async_unload(entry_v2_1.entry_id) + + entry_v2_1 = create_config_entry("pchk_v2_1", version=(2, 1)) + await init_integration(hass, entry_v2_1) + assert entity_registry.async_get(entity_id).unique_id == migrated_unique_id diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index 4251d997724..00c2341631e 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -29,9 +29,9 @@ from .conftest import MockConfigEntry, MockModuleConnection, init_integration from tests.common import snapshot_platform -LIGHT_OUTPUT1 = "light.light_output1" -LIGHT_OUTPUT2 = "light.light_output2" -LIGHT_RELAY1 = "light.light_relay1" +LIGHT_OUTPUT1 = "light.testmodule_light_output1" +LIGHT_OUTPUT2 = "light.testmodule_light_output2" +LIGHT_RELAY1 = "light.testmodule_light_relay1" async def test_setup_lcn_light( diff --git a/tests/components/lcn/test_scene.py b/tests/components/lcn/test_scene.py index 27e7864df41..aaf17f292c1 100644 --- a/tests/components/lcn/test_scene.py +++ b/tests/components/lcn/test_scene.py @@ -43,11 +43,11 @@ async def test_scene_activate( await hass.services.async_call( DOMAIN_SCENE, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "scene.romantic"}, + {ATTR_ENTITY_ID: "scene.testmodule_romantic"}, blocking=True, ) - state = hass.states.get("scene.romantic") + state = hass.states.get("scene.testmodule_romantic") assert state is not None activate_scene.assert_awaited_with( @@ -60,5 +60,5 @@ async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) await init_integration(hass, entry) await hass.config_entries.async_unload(entry.entry_id) - state = hass.states.get("scene.romantic") + state = hass.states.get("scene.testmodule_romantic") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_sensor.py b/tests/components/lcn/test_sensor.py index 18335f4b073..85f5b62bf91 100644 --- a/tests/components/lcn/test_sensor.py +++ b/tests/components/lcn/test_sensor.py @@ -16,10 +16,10 @@ from .conftest import MockConfigEntry, init_integration from tests.common import snapshot_platform -SENSOR_VAR1 = "sensor.sensor_var1" -SENSOR_SETPOINT1 = "sensor.sensor_setpoint1" -SENSOR_LED6 = "sensor.sensor_led6" -SENSOR_LOGICOP1 = "sensor.sensor_logicop1" +SENSOR_VAR1 = "sensor.testmodule_sensor_var1" +SENSOR_SETPOINT1 = "sensor.testmodule_sensor_setpoint1" +SENSOR_LED6 = "sensor.testmodule_sensor_led6" +SENSOR_LOGICOP1 = "sensor.testmodule_sensor_logicop1" async def test_setup_lcn_sensor( diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index c9eda40fdba..cdc8e9671c0 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -24,14 +24,12 @@ from homeassistant.components.lcn.const import ( ) 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 -from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import ( @@ -42,20 +40,9 @@ from .conftest import ( ) -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", {}) @@ -66,7 +53,7 @@ async def test_service_output_abs( DOMAIN, LcnService.OUTPUT_ABS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_OUTPUT: "output1", CONF_BRIGHTNESS: 100, CONF_TRANSITION: 5, @@ -77,11 +64,9 @@ async def test_service_output_abs( dim_output.assert_awaited_with(0, 100, 9) -@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", {}) @@ -92,7 +77,7 @@ async def test_service_output_rel( DOMAIN, LcnService.OUTPUT_REL, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_OUTPUT: "output1", CONF_BRIGHTNESS: 25, }, @@ -102,11 +87,9 @@ async def test_service_output_rel( rel_output.assert_awaited_with(0, 25) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_output_toggle( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test output_toggle service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -117,7 +100,7 @@ async def test_service_output_toggle( DOMAIN, LcnService.OUTPUT_TOGGLE, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_OUTPUT: "output1", CONF_TRANSITION: 5, }, @@ -127,11 +110,9 @@ async def test_service_output_toggle( toggle_output.assert_awaited_with(0, 9) -@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", {}) @@ -141,7 +122,10 @@ async def test_service_relays( await hass.services.async_call( DOMAIN, LcnService.RELAYS, - {**device_config(hass, entry, config_type), CONF_STATE: "0011TT--"}, + { + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, + CONF_STATE: "0011TT--", + }, blocking=True, ) @@ -151,11 +135,9 @@ async def test_service_relays( control_relays.assert_awaited_with(relay_states) -@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", {}) @@ -166,7 +148,7 @@ async def test_service_led( DOMAIN, LcnService.LED, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_LED: "led6", CONF_STATE: "blink", }, @@ -179,11 +161,9 @@ async def test_service_led( control_led.assert_awaited_with(led, led_state) -@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", {}) @@ -194,7 +174,7 @@ async def test_service_var_abs( DOMAIN, LcnService.VAR_ABS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_VARIABLE: "var1", CONF_VALUE: 75, CONF_UNIT_OF_MEASUREMENT: "%", @@ -207,11 +187,9 @@ async def test_service_var_abs( ) -@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", {}) @@ -222,7 +200,7 @@ async def test_service_var_rel( DOMAIN, LcnService.VAR_REL, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_VARIABLE: "var1", CONF_VALUE: 10, CONF_UNIT_OF_MEASUREMENT: "%", @@ -239,11 +217,9 @@ async def test_service_var_rel( ) -@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", {}) @@ -253,18 +229,19 @@ async def test_service_var_reset( await hass.services.async_call( DOMAIN, LcnService.VAR_RESET, - {**device_config(hass, entry, config_type), CONF_VARIABLE: "var1"}, + { + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, + CONF_VARIABLE: "var1", + }, blocking=True, ) var_reset.assert_awaited_with(pypck.lcn_defs.Var["VAR1"]) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_lock_regulator( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test lock_regulator service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -275,7 +252,7 @@ async def test_service_lock_regulator( DOMAIN, LcnService.LOCK_REGULATOR, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_SETPOINT: "r1varsetpoint", CONF_STATE: True, }, @@ -285,11 +262,9 @@ async def test_service_lock_regulator( lock_regulator.assert_awaited_with(0, True) -@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", {}) @@ -300,7 +275,7 @@ async def test_service_send_keys( DOMAIN, LcnService.SEND_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_KEYS: "a1a5d8", CONF_STATE: "hit", }, @@ -315,11 +290,9 @@ async def test_service_send_keys( send_keys.assert_awaited_with(keys, pypck.lcn_defs.SendKeyCommand["HIT"]) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_send_keys_hit_deferred( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test send_keys (hit_deferred) service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -338,7 +311,7 @@ async def test_service_send_keys_hit_deferred( DOMAIN, LcnService.SEND_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_KEYS: "a1a5d8", CONF_TIME: 5, CONF_TIME_UNIT: "s", @@ -361,7 +334,7 @@ async def test_service_send_keys_hit_deferred( DOMAIN, LcnService.SEND_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_KEYS: "a1a5d8", CONF_STATE: "make", CONF_TIME: 5, @@ -371,11 +344,9 @@ async def test_service_send_keys_hit_deferred( ) -@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", {}) @@ -386,7 +357,7 @@ async def test_service_lock_keys( DOMAIN, LcnService.LOCK_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_TABLE: "a", CONF_STATE: "0011TT--", }, @@ -399,11 +370,9 @@ async def test_service_lock_keys( lock_keys.assert_awaited_with(0, lock_states) -@pytest.mark.parametrize("config_type", [CONF_ADDRESS, CONF_DEVICE_ID]) async def test_service_lock_keys_tab_a_temporary( hass: HomeAssistant, entry: MockConfigEntry, - config_type: str, ) -> None: """Test lock_keys (tab_a_temporary) service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -417,7 +386,7 @@ async def test_service_lock_keys_tab_a_temporary( DOMAIN, LcnService.LOCK_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_STATE: "0011TT--", CONF_TIME: 10, CONF_TIME_UNIT: "s", @@ -443,7 +412,7 @@ async def test_service_lock_keys_tab_a_temporary( DOMAIN, LcnService.LOCK_KEYS, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_TABLE: "b", CONF_STATE: "0011TT--", CONF_TIME: 10, @@ -453,11 +422,9 @@ async def test_service_lock_keys_tab_a_temporary( ) -@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", {}) @@ -468,7 +435,7 @@ async def test_service_dyn_text( DOMAIN, LcnService.DYN_TEXT, { - **device_config(hass, entry, config_type), + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, CONF_ROW: 1, CONF_TEXT: "text in row 1", }, @@ -478,11 +445,9 @@ async def test_service_dyn_text( dyn_text.assert_awaited_with(0, "text in row 1") -@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", {}) @@ -492,43 +457,11 @@ async def test_service_pck( await hass.services.async_call( DOMAIN, LcnService.PCK, - {**device_config(hass, entry, config_type), CONF_PCK: "PIN4"}, + { + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, + CONF_PCK: "PIN4", + }, blocking=True, ) pck.assert_awaited_with("PIN4") - - -async def test_service_called_with_invalid_host_id( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test service was called with non existing host id.""" - await async_setup_component(hass, "persistent_notification", {}) - await init_integration(hass, entry) - - with patch.object(MockModuleConnection, "pck") as pck, pytest.raises(ValueError): - await hass.services.async_call( - DOMAIN, - LcnService.PCK, - {CONF_ADDRESS: "foobar.s0.m7", CONF_PCK: "PIN4"}, - blocking=True, - ) - - 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/lcn/test_switch.py b/tests/components/lcn/test_switch.py index 15b156aac43..0c0067c8875 100644 --- a/tests/components/lcn/test_switch.py +++ b/tests/components/lcn/test_switch.py @@ -30,12 +30,12 @@ from .conftest import MockConfigEntry, MockModuleConnection, init_integration from tests.common import snapshot_platform -SWITCH_OUTPUT1 = "switch.switch_output1" -SWITCH_OUTPUT2 = "switch.switch_output2" -SWITCH_RELAY1 = "switch.switch_relay1" -SWITCH_RELAY2 = "switch.switch_relay2" -SWITCH_REGULATOR1 = "switch.switch_regulator1" -SWITCH_KEYLOCKK1 = "switch.switch_keylock1" +SWITCH_OUTPUT1 = "switch.testmodule_switch_output1" +SWITCH_OUTPUT2 = "switch.testmodule_switch_output2" +SWITCH_RELAY1 = "switch.testmodule_switch_relay1" +SWITCH_RELAY2 = "switch.testmodule_switch_relay2" +SWITCH_REGULATOR1 = "switch.testmodule_switch_regulator1" +SWITCH_KEYLOCKK1 = "switch.testmodule_switch_keylock1" async def test_setup_lcn_switch( diff --git a/tests/components/lcn/test_websocket.py b/tests/components/lcn/test_websocket.py index 2c5fff89e19..02bf6b4c546 100644 --- a/tests/components/lcn/test_websocket.py +++ b/tests/components/lcn/test_websocket.py @@ -7,14 +7,13 @@ import pytest from homeassistant.components.lcn import AddressType from homeassistant.components.lcn.const import CONF_DOMAIN_DATA -from homeassistant.components.lcn.helpers import get_device_config, get_resource +from homeassistant.components.lcn.helpers import get_device_config from homeassistant.const import ( CONF_ADDRESS, CONF_DEVICES, CONF_DOMAIN, CONF_ENTITIES, CONF_NAME, - CONF_RESOURCE, CONF_TYPE, ) from homeassistant.core import HomeAssistant @@ -52,7 +51,7 @@ ENTITIES_DELETE_PAYLOAD = { "entry_id": "", CONF_ADDRESS: (0, 7, False), CONF_DOMAIN: "switch", - CONF_RESOURCE: "relay1", + CONF_DOMAIN_DATA: {"output": "RELAY1"}, } @@ -184,18 +183,14 @@ async def test_lcn_entities_add_command( for key in (CONF_ADDRESS, CONF_NAME, CONF_DOMAIN, CONF_DOMAIN_DATA) } - resource = get_resource( - ENTITIES_ADD_PAYLOAD[CONF_DOMAIN], ENTITIES_ADD_PAYLOAD[CONF_DOMAIN_DATA] - ).lower() - - assert {**entity_config, CONF_RESOURCE: resource} not in entry.data[CONF_ENTITIES] + assert entity_config not in entry.data[CONF_ENTITIES] await client.send_json_auto_id({**ENTITIES_ADD_PAYLOAD, "entry_id": entry.entry_id}) res = await client.receive_json() assert res["success"], res - assert {**entity_config, CONF_RESOURCE: resource} in entry.data[CONF_ENTITIES] + assert entity_config in entry.data[CONF_ENTITIES] async def test_lcn_entities_delete_command( @@ -213,7 +208,8 @@ async def test_lcn_entities_delete_command( for entity in entry.data[CONF_ENTITIES] if entity[CONF_ADDRESS] == ENTITIES_DELETE_PAYLOAD[CONF_ADDRESS] and entity[CONF_DOMAIN] == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN] - and entity[CONF_RESOURCE] == ENTITIES_DELETE_PAYLOAD[CONF_RESOURCE] + and entity[CONF_DOMAIN_DATA] + == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN_DATA] ] ) == 1 @@ -233,7 +229,8 @@ async def test_lcn_entities_delete_command( for entity in entry.data[CONF_ENTITIES] if entity[CONF_ADDRESS] == ENTITIES_DELETE_PAYLOAD[CONF_ADDRESS] and entity[CONF_DOMAIN] == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN] - and entity[CONF_RESOURCE] == ENTITIES_DELETE_PAYLOAD[CONF_RESOURCE] + and entity[CONF_DOMAIN_DATA] + == ENTITIES_DELETE_PAYLOAD[CONF_DOMAIN_DATA] ] ) == 0 diff --git a/tests/components/leaone/__init__.py b/tests/components/leaone/__init__.py index 3d62314fd9a..befc0a81028 100644 --- a/tests/components/leaone/__init__.py +++ b/tests/components/leaone/__init__.py @@ -1,8 +1,48 @@ """Tests for the Leaone integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -SCALE_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +SCALE_SERVICE_INFO = make_bluetooth_service_info( name="", address="5F:5A:5C:52:D3:94", rssi=-63, @@ -11,7 +51,7 @@ SCALE_SERVICE_INFO = BluetoothServiceInfo( service_data={}, source="local", ) -SCALE_SERVICE_INFO_2 = BluetoothServiceInfo( +SCALE_SERVICE_INFO_2 = make_bluetooth_service_info( name="", address="5F:5A:5C:52:D3:94", rssi=-63, @@ -23,7 +63,7 @@ SCALE_SERVICE_INFO_2 = BluetoothServiceInfo( service_data={}, source="local", ) -SCALE_SERVICE_INFO_3 = BluetoothServiceInfo( +SCALE_SERVICE_INFO_3 = make_bluetooth_service_info( name="", address="5F:5A:5C:52:D3:94", rssi=-63, diff --git a/tests/components/lektrico/snapshots/test_binary_sensor.ambr b/tests/components/lektrico/snapshots/test_binary_sensor.ambr index b365ff84187..7d812c0fc67 100644 --- a/tests/components/lektrico/snapshots/test_binary_sensor.ambr +++ b/tests/components/lektrico/snapshots/test_binary_sensor.ambr @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Ev diode short', + 'original_name': 'EV diode short', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -37,7 +37,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': '1p7k_500006 Ev diode short', + 'friendly_name': '1p7k_500006 EV diode short', }), 'context': , 'entity_id': 'binary_sensor.1p7k_500006_ev_diode_short', @@ -72,7 +72,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Ev error', + 'original_name': 'EV error', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -85,7 +85,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': '1p7k_500006 Ev error', + 'friendly_name': '1p7k_500006 EV error', }), 'context': , 'entity_id': 'binary_sensor.1p7k_500006_ev_error', @@ -312,7 +312,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Rcd error', + 'original_name': 'RCD error', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -325,7 +325,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': '1p7k_500006 Rcd error', + 'friendly_name': '1p7k_500006 RCD error', }), 'context': , 'entity_id': 'binary_sensor.1p7k_500006_rcd_error', diff --git a/tests/components/lektrico/snapshots/test_number.ambr b/tests/components/lektrico/snapshots/test_number.ambr index 57cf40567e7..368479cdd06 100644 --- a/tests/components/lektrico/snapshots/test_number.ambr +++ b/tests/components/lektrico/snapshots/test_number.ambr @@ -86,7 +86,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Led brightness', + 'original_name': 'LED brightness', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -98,7 +98,7 @@ # name: test_all_entities[number.1p7k_500006_led_brightness-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '1p7k_500006 Led brightness', + 'friendly_name': '1p7k_500006 LED brightness', 'max': 100, 'min': 0, 'mode': , diff --git a/tests/components/lektrico/test_binary_sensor.py b/tests/components/lektrico/test_binary_sensor.py index d49eac6cc23..05947ec1cda 100644 --- a/tests/components/lektrico/test_binary_sensor.py +++ b/tests/components/lektrico/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_button.py b/tests/components/lektrico/test_button.py index 7bd77848d21..65d85ec1250 100644 --- a/tests/components/lektrico/test_button.py +++ b/tests/components/lektrico/test_button.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_init.py b/tests/components/lektrico/test_init.py index 93068ffe531..996c4fed527 100644 --- a/tests/components/lektrico/test_init.py +++ b/tests/components/lektrico/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lektrico.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_number.py b/tests/components/lektrico/test_number.py index ade6515ca72..3250ac6af91 100644 --- a/tests/components/lektrico/test_number.py +++ b/tests/components/lektrico/test_number.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_select.py b/tests/components/lektrico/test_select.py index cb09c47535e..367517c59aa 100644 --- a/tests/components/lektrico/test_select.py +++ b/tests/components/lektrico/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_sensor.py b/tests/components/lektrico/test_sensor.py index 27be7ff1c11..d3c6d464b9b 100644 --- a/tests/components/lektrico/test_sensor.py +++ b/tests/components/lektrico/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lektrico/test_switch.py b/tests/components/lektrico/test_switch.py index cfa693d9e44..6b038a250b4 100644 --- a/tests/components/lektrico/test_switch.py +++ b/tests/components/lektrico/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/letpot/test_binary_sensor.py b/tests/components/letpot/test_binary_sensor.py index 03ce1bee1a5..43565914072 100644 --- a/tests/components/letpot/test_binary_sensor.py +++ b/tests/components/letpot/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/letpot/test_sensor.py b/tests/components/letpot/test_sensor.py index a527d062ca7..3ed4c6d9308 100644 --- a/tests/components/letpot/test_sensor.py +++ b/tests/components/letpot/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py index 0ba1f556bc9..7eeafd78291 100644 --- a/tests/components/letpot/test_switch.py +++ b/tests/components/letpot/test_switch.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch from letpot.exceptions import LetPotConnectionException, LetPotException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( SERVICE_TOGGLE, diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py index e65ea4532e1..dba51ce8497 100644 --- a/tests/components/letpot/test_time.py +++ b/tests/components/letpot/test_time.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from letpot.exceptions import LetPotConnectionException, LetPotException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.time import SERVICE_SET_VALUE from homeassistant.const import Platform diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index 05cb3164137..2eaddf1a83b 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -68,7 +68,7 @@ def mock_uuid() -> Generator[AsyncMock]: @pytest.fixture -def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: +def mock_config_thinq_api() -> Generator[AsyncMock]: """Mock a thinq api.""" with ( patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api, @@ -77,6 +77,26 @@ def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: new=mock_api, ), ): + thinq_api = mock_api.return_value + thinq_api.async_get_device_list.return_value = ["air_conditioner"] + yield thinq_api + + +@pytest.fixture +def mock_invalid_thinq_api(mock_config_thinq_api: AsyncMock) -> AsyncMock: + """Mock an invalid thinq api.""" + mock_config_thinq_api.async_get_device_list = AsyncMock( + side_effect=ThinQAPIException( + code="1309", message="Not allowed api call", headers=None + ) + ) + return mock_config_thinq_api + + +@pytest.fixture +def mock_thinq_api(mock_thinq_mqtt_client: None) -> Generator[AsyncMock]: + """Mock a thinq api.""" + with patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api: thinq_api = mock_api.return_value thinq_api.async_get_device_list.return_value = [ load_json_object_fixture("air_conditioner/device.json", DOMAIN) @@ -91,20 +111,11 @@ def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: @pytest.fixture -def mock_thinq_mqtt_client() -> Generator[AsyncMock]: - """Mock a thinq api.""" +def mock_thinq_mqtt_client() -> Generator[None]: + """Mock a thinq mqtt client.""" with patch( - "homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", autospec=True - ) as mock_api: - yield mock_api - - -@pytest.fixture -def mock_invalid_thinq_api(mock_thinq_api: AsyncMock) -> AsyncMock: - """Mock an invalid thinq api.""" - mock_thinq_api.async_get_device_list = AsyncMock( - side_effect=ThinQAPIException( - code="1309", message="Not allowed api call", headers=None - ) - ) - return mock_thinq_api + "homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", + autospec=True, + return_value=True, + ): + yield diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index db57e824487..111d49a2ef3 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -15,8 +15,8 @@ , , ]), - 'max_temp': 30, - 'min_temp': 18, + 'max_temp': 86, + 'min_temp': 64, 'preset_modes': list([ 'air_clean', ]), @@ -28,7 +28,7 @@ 'on', 'off', ]), - 'target_temp_step': 1, + 'target_temp_step': 2, }), 'config_entry_id': , 'config_subentry_id': , @@ -62,7 +62,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_humidity': 40, - 'current_temperature': 25, + 'current_temperature': 77, 'fan_mode': 'mid', 'fan_modes': list([ 'low', @@ -75,8 +75,8 @@ , , ]), - 'max_temp': 30, - 'min_temp': 18, + 'max_temp': 86, + 'min_temp': 64, 'preset_mode': None, 'preset_modes': list([ 'air_clean', @@ -94,8 +94,8 @@ ]), 'target_temp_high': None, 'target_temp_low': None, - 'target_temp_step': 1, - 'temperature': 19, + 'target_temp_step': 2, + 'temperature': 66, }), 'context': , 'entity_id': 'climate.test_air_conditioner', diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index 24ed3ad230d..c79331dd638 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -3,11 +3,12 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import setup_integration @@ -23,6 +24,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" + hass.config.units = US_CUSTOMARY_SYSTEM with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index 8c5afb4dac7..d1530ed29cd 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry async def test_config_flow( hass: HomeAssistant, - mock_thinq_api: AsyncMock, + mock_config_thinq_api: AsyncMock, mock_uuid: AsyncMock, mock_setup_entry: AsyncMock, ) -> None: @@ -37,11 +37,12 @@ async def test_config_flow( CONF_CONNECT_CLIENT_ID: MOCK_CONNECT_CLIENT_ID, } - mock_thinq_api.async_get_device_list.assert_called_once() + mock_config_thinq_api.async_get_device_list.assert_called_once() async def test_config_flow_invalid_pat( - hass: HomeAssistant, mock_invalid_thinq_api: AsyncMock + hass: HomeAssistant, + mock_invalid_thinq_api: AsyncMock, ) -> None: """Test that an thinq flow should be aborted with an invalid PAT.""" result = await hass.config_entries.flow.async_init( @@ -55,7 +56,9 @@ async def test_config_flow_invalid_pat( async def test_config_flow_already_configured( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_thinq_api: AsyncMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_thinq_api: AsyncMock, ) -> None: """Test that thinq flow should be aborted when already configured.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/lg_thinq/test_event.py b/tests/components/lg_thinq/test_event.py index bea758cb943..398af1e8aad 100644 --- a/tests/components/lg_thinq/test_event.py +++ b/tests/components/lg_thinq/test_event.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lg_thinq/test_init.py b/tests/components/lg_thinq/test_init.py index 7da7e79fec0..d4c14e2e0c0 100644 --- a/tests/components/lg_thinq/test_init.py +++ b/tests/components/lg_thinq/test_init.py @@ -1,10 +1,14 @@ """Tests for the LG ThinQ integration.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry @@ -14,9 +18,11 @@ async def test_load_unload_entry( mock_config_entry: MockConfigEntry, ) -> None: """Test load and unload entry.""" - 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() + with patch( + "homeassistant.components.lg_thinq.ThinQMQTT.async_connect", + return_value=True, + ): + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -24,3 +30,20 @@ async def test_load_unload_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize("exception", [AttributeError(), TypeError(), ValueError()]) +async def test_config_not_ready( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test for setup failure exception occurred.""" + with patch( + "homeassistant.components.lg_thinq.ThinQMQTT.async_connect", + side_effect=exception, + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/lg_thinq/test_number.py b/tests/components/lg_thinq/test_number.py index e578e4eba7a..7c37ba3f5e0 100644 --- a/tests/components/lg_thinq/test_number.py +++ b/tests/components/lg_thinq/test_number.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py index e1f1a7ed93d..e2c8e122eea 100644 --- a/tests/components/lg_thinq/test_sensor.py +++ b/tests/components/lg_thinq/test_sensor.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 5bc17ea3e24..014e3ec8c35 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -21,7 +21,6 @@ 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 from homeassistant.util import color as color_util @@ -959,21 +958,6 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None: _, data = entity1.last_call("turn_on") assert data["brightness"] == 40 # 50 - 10 - await hass.services.async_call( - "light", - "turn_on", - { - "entity_id": [entity0.entity_id, entity1.entity_id], - "brightness_step_pct": 10, - }, - blocking=True, - ) - - _, data = entity0.last_call("turn_on") - assert data["brightness"] == 116 # 90 + (255 * 0.10) - _, data = entity1.last_call("turn_on") - assert data["brightness"] == 66 # 40 + (255 * 0.10) - await hass.services.async_call( "light", "turn_on", @@ -984,7 +968,49 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None: blocking=True, ) - assert entity0.state == "off" # 126 - 126; brightness is 0, light should turn off + assert entity0.state == "off" # 40 - 126; brightness is 0, light should turn off + + +async def test_light_brightness_step_pct(hass: HomeAssistant) -> None: + """Test that percentage based brightness steps work as expected.""" + entity = MockLight("Test_0", STATE_ON) + + setup_test_component_platform(hass, light.DOMAIN, [entity]) + + entity.supported_features = light.SUPPORT_BRIGHTNESS + # Set color modes to none to trigger backwards compatibility in LightEntity + entity.supported_color_modes = None + entity.color_mode = None + entity.brightness = 255 + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.attributes["brightness"] == 255 # 100% + + def reduce_brightness_by_ten_percent(): + return hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [entity.entity_id], + "brightness_step_pct": -10, + }, + blocking=True, + ) + + await reduce_brightness_by_ten_percent() + _, data = entity.last_call("turn_on") + assert round(data["brightness"] / 2.55) == 90 # 100% - 10% = 90% + + await reduce_brightness_by_ten_percent() + _, data = entity.last_call("turn_on") + assert round(data["brightness"] / 2.55) == 80 # 90% - 10% = 80% + + await reduce_brightness_by_ten_percent() + _, data = entity.last_call("turn_on") + assert round(data["brightness"] / 2.55) == 70 # 80% - 10% = 70% @pytest.mark.usefixtures("enable_custom_integrations") @@ -2846,7 +2872,6 @@ def test_report_invalid_color_modes( ], 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, diff --git a/tests/components/linear_garage_door/test_cover.py b/tests/components/linear_garage_door/test_cover.py index be5ae8f35f7..caa590f3b3a 100644 --- a/tests/components/linear_garage_door/test_cover.py +++ b/tests/components/linear_garage_door/test_cover.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, diff --git a/tests/components/linear_garage_door/test_diagnostics.py b/tests/components/linear_garage_door/test_diagnostics.py index a00feed43ff..f51bb0a366c 100644 --- a/tests/components/linear_garage_door/test_diagnostics.py +++ b/tests/components/linear_garage_door/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/linear_garage_door/test_light.py b/tests/components/linear_garage_door/test_light.py index 351ddad813a..d462130dc91 100644 --- a/tests/components/linear_garage_door/test_light.py +++ b/tests/components/linear_garage_door/test_light.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, diff --git a/tests/components/linkplay/test_diagnostics.py b/tests/components/linkplay/test_diagnostics.py index de60b7ecb3a..332359b9769 100644 --- a/tests/components/linkplay/test_diagnostics.py +++ b/tests/components/linkplay/test_diagnostics.py @@ -5,7 +5,7 @@ from unittest.mock import patch from linkplay.bridge import LinkPlayMultiroom from linkplay.consts import API_ENDPOINT from linkplay.endpoint import LinkPlayApiEndpoint -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.linkplay.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index d22c4b2ec49..a6058c75bca 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Pet, Robot from pylitterbot.exceptions import InvalidCommandException +from pylitterbot.robot.litterrobot4 import HopperStatus import pytest from homeassistant.core import HomeAssistant @@ -84,6 +85,15 @@ def mock_account_with_litterrobot_4() -> MagicMock: return create_mock_account(v4=True) +@pytest.fixture +def mock_account_with_litterhopper() -> MagicMock: + """Mock account with LitterHopper attached to Litter-Robot 4.""" + return create_mock_account( + robot_data={"hopperStatus": HopperStatus.ENABLED, "isHopperRemoved": False}, + v4=True, + ) + + @pytest.fixture def mock_account_with_feederrobot() -> MagicMock: """Mock account with Feeder-Robot.""" diff --git a/tests/components/litterrobot/test_binary_sensor.py b/tests/components/litterrobot/test_binary_sensor.py index 3fe72aef7e3..a8da7e53d9f 100644 --- a/tests/components/litterrobot/test_binary_sensor.py +++ b/tests/components/litterrobot/test_binary_sensor.py @@ -30,3 +30,18 @@ async def test_binary_sensors( state = hass.states.get("binary_sensor.test_power_status") assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.PLUG assert state.state == "on" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_litterhopper_binary_sensors( + hass: HomeAssistant, + mock_account_with_litterhopper: MagicMock, +) -> None: + """Tests LitterHopper-specific binary sensors.""" + await setup_integration(hass, mock_account_with_litterhopper, BINARY_SENSOR_DOMAIN) + + state = hass.states.get("binary_sensor.test_hopper_connected") + assert state.state == "on" + assert ( + state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.CONNECTIVITY + ) diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index e290d96fcf4..bbc6274e56b 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -114,3 +114,12 @@ async def test_pet_weight_sensor( sensor = hass.states.get("sensor.kitty_weight") assert sensor.state == "9.1" assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS + + +async def test_litterhopper_sensor( + hass: HomeAssistant, mock_account_with_litterhopper: MagicMock +) -> None: + """Tests LitterHopper sensors.""" + await setup_integration(hass, mock_account_with_litterhopper, PLATFORM_DOMAIN) + sensor = hass.states.get("sensor.test_hopper_status") + assert sensor.state == "enabled" diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index 0eb48aa3060..6b7e505fa26 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -13,11 +13,8 @@ from homeassistant.components.local_file.const import ( ) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ATTR_ENTITY_ID, CONF_FILE_PATH -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component -from homeassistant.util import slugify from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -212,76 +209,3 @@ async def test_update_file_path( service_data, blocking=True, ) - - -async def test_import_from_yaml_success( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test import.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=True)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "local_file", - "file_path": "mock.file", - } - }, - ) - await hass.async_block_till_done() - - assert hass.config_entries.async_has_entries(DOMAIN) - state = hass.states.get("camera.config_test") - assert state.attributes.get("file_path") == "mock.file" - - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" - ) - assert issue - assert issue.translation_key == "deprecated_yaml" - - -async def test_import_from_yaml_fails( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test import fails due to not accessible file.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=False)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "local_file", - "file_path": "mock.file", - } - }, - ) - await hass.async_block_till_done() - - assert not hass.config_entries.async_has_entries(DOMAIN) - assert not hass.states.get("camera.config_test") - - issue = issue_registry.async_get_issue( - DOMAIN, f"no_access_path_{slugify('mock.file')}" - ) - assert issue - assert issue.translation_key == "no_access_path" diff --git a/tests/components/local_file/test_config_flow.py b/tests/components/local_file/test_config_flow.py index dda9d606107..d828c947d0d 100644 --- a/tests/components/local_file/test_config_flow.py +++ b/tests/components/local_file/test_config_flow.py @@ -175,61 +175,3 @@ async def test_entry_already_exist( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_import(hass: HomeAssistant) -> None: - """Test import.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=True)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "name": DEFAULT_NAME, - "file_path": "mock/path.jpg", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["version"] == 1 - assert result["options"] == { - CONF_NAME: DEFAULT_NAME, - CONF_FILE_PATH: "mock/path.jpg", - } - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_import_already_exist( - hass: HomeAssistant, loaded_entry: MockConfigEntry -) -> None: - """Test import abort existing entry.""" - - with ( - patch("os.path.isfile", Mock(return_value=True)), - patch("os.access", Mock(return_value=True)), - patch( - "homeassistant.components.local_file.camera.mimetypes.guess_type", - Mock(return_value=(None, None)), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: DEFAULT_NAME, - CONF_FILE_PATH: "mock.file", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/logger/test_init.py b/tests/components/logger/test_init.py index 24e58a77226..53b8b72b385 100644 --- a/tests/components/logger/test_init.py +++ b/tests/components/logger/test_init.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import async_call_logger_set_level, async_fire_time_changed HASS_NS = "unused.homeassistant" COMPONENTS_NS = f"{HASS_NS}.components" @@ -73,28 +73,27 @@ async def test_log_filtering( msg_test(filter_logger, True, "format string shouldfilter%s", "not") # Filtering should work even if log level is modified - await hass.services.async_call( - "logger", - "set_level", - {"test.filter": "warning"}, - blocking=True, - ) - assert filter_logger.getEffectiveLevel() == logging.WARNING - msg_test( - filter_logger, - False, - "this line containing shouldfilterall should still be filtered", - ) + async with async_call_logger_set_level( + "test.filter", "WARNING", hass=hass, caplog=caplog + ): + assert filter_logger.getEffectiveLevel() == logging.WARNING + msg_test( + filter_logger, + False, + "this line containing shouldfilterall should still be filtered", + ) - # Filtering should be scoped to a service - msg_test( - filter_logger, True, "this line containing otherfilterer should not be filtered" - ) - msg_test( - logging.getLogger("test.other_filter"), - False, - "this line containing otherfilterer SHOULD be filtered", - ) + # Filtering should be scoped to a service + msg_test( + filter_logger, + True, + "this line containing otherfilterer should not be filtered", + ) + msg_test( + logging.getLogger("test.other_filter"), + False, + "this line containing otherfilterer SHOULD be filtered", + ) async def test_setting_level(hass: HomeAssistant) -> None: diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index 5bc280535f9..debe26576bd 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -4,7 +4,7 @@ import logging from unittest.mock import patch from homeassistant import loader -from homeassistant.components.logger.helpers import async_get_domain_config +from homeassistant.components.logger.helpers import DATA_LOGGER from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -31,7 +31,6 @@ async def test_integration_log_info( assert msg["type"] == TYPE_RESULT assert msg["success"] assert {"domain": "http", "level": logging.DEBUG} in msg["result"] - assert {"domain": "websocket_api", "level": logging.DEBUG} in msg["result"] async def test_integration_log_level_logger_not_loaded( @@ -77,7 +76,7 @@ async def test_integration_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG } @@ -127,7 +126,7 @@ async def test_custom_integration_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.hue": logging.DEBUG, "custom_components.hue": logging.DEBUG, "some_other_logger": logging.DEBUG, @@ -183,7 +182,7 @@ async def test_module_log_level( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG, "homeassistant.components.other_component": logging.WARNING, } @@ -200,7 +199,7 @@ async def test_module_log_level_override( {"logger": {"logs": {"homeassistant.components.websocket_api": "warning"}}}, ) - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.WARNING } @@ -219,7 +218,7 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.ERROR } @@ -238,7 +237,7 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.DEBUG } @@ -257,6 +256,6 @@ async def test_module_log_level_override( assert msg["type"] == TYPE_RESULT assert msg["success"] - assert async_get_domain_config(hass).overrides == { + assert hass.data[DATA_LOGGER].overrides == { "homeassistant.components.websocket_api": logging.NOTSET } diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index f35f7369f93..4c7cc96504b 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -13,6 +13,16 @@ 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.""" @@ -23,6 +33,15 @@ 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, @@ -41,6 +60,45 @@ async def test_create_dashboards_when_onboarded( 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"}} + + @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") async def test_hass_data_compatibility( diff --git a/tests/components/lutron/test_config_flow.py b/tests/components/lutron/test_config_flow.py index 47b2a4891cf..df861fafffe 100644 --- a/tests/components/lutron/test_config_flow.py +++ b/tests/components/lutron/test_config_flow.py @@ -6,11 +6,11 @@ from urllib.error import HTTPError import pytest -from homeassistant.components.lutron.const import DOMAIN +from homeassistant.components.lutron.const import CONF_DEFAULT_DIMMER_LEVEL, 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 homeassistant.data_entry_flow import FlowResultType, InvalidData from tests.common import MockConfigEntry @@ -146,3 +146,41 @@ MOCK_DATA_IMPORT = { CONF_USERNAME: "lutron", CONF_PASSWORD: "integration", } + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_DATA_STEP, + unique_id="12345678901", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Try to set an out of range dimmer level (260) + out_of_range_level = 260 + + # The voluptuous validation will raise an exception before the handler processes it + with pytest.raises(InvalidData): + await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_DEFAULT_DIMMER_LEVEL: out_of_range_level}, + ) + + # Now try with a valid value + valid_level = 100 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_DEFAULT_DIMMER_LEVEL: valid_level}, + ) + + # Verify that the flow finishes successfully with the valid value + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_DEFAULT_DIMMER_LEVEL: valid_level} diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index b27d30ac31f..5f146cd988a 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -1,5 +1,8 @@ """Tests for the Lutron Caseta integration.""" +import asyncio +from collections.abc import Callable +from typing import Any from unittest.mock import patch from homeassistant.components.lutron_caseta import DOMAIN @@ -84,25 +87,12 @@ _LEAP_DEVICE_TYPES = { } -async def async_setup_integration(hass: HomeAssistant, mock_bridge) -> MockConfigEntry: - """Set up a mock bridge.""" - mock_entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA) - mock_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls" - ) as create_tls: - create_tls.return_value = mock_bridge(can_connect=True) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - return mock_entry - - class MockBridge: """Mock Lutron bridge that emulates configured connected status.""" - def __init__(self, can_connect=True) -> None: + def __init__(self, can_connect=True, timeout_on_connect=False) -> None: """Initialize MockBridge instance with configured mock connectivity.""" + self.timeout_on_connect = timeout_on_connect self.can_connect = can_connect self.is_currently_connected = False self.areas = self.load_areas() @@ -113,6 +103,8 @@ class MockBridge: async def connect(self): """Connect the mock bridge.""" + if self.timeout_on_connect: + await asyncio.Event().wait() # wait forever if self.can_connect: self.is_currently_connected = True @@ -320,3 +312,43 @@ class MockBridge: async def close(self): """Close the mock bridge connection.""" self.is_currently_connected = False + + +def make_mock_entry() -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA) + + +async def async_setup_integration( + hass: HomeAssistant, + mock_bridge: MockBridge, + config_entry_id: str | None = None, + can_connect: bool = True, + timeout_during_connect: bool = False, + timeout_during_configure: bool = False, +) -> MockConfigEntry: + """Set up a mock bridge.""" + if config_entry_id is None: + mock_entry = make_mock_entry() + mock_entry.add_to_hass(hass) + config_entry_id = mock_entry.entry_id + else: + mock_entry = hass.config_entries.async_get_entry(config_entry_id) + + def create_tls_factory( + *args: Any, on_connect_callback: Callable[[], None], **kwargs: Any + ) -> None: + """Return a mock bridge.""" + if not timeout_during_connect: + on_connect_callback() + return mock_bridge( + can_connect=can_connect, timeout_on_connect=timeout_during_configure + ) + + with patch( + "homeassistant.components.lutron_caseta.Smartbridge.create_tls", + create_tls_factory, + ): + await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + return mock_entry diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index 1ab45bf7582..001bf86ad54 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -1,7 +1,5 @@ """The tests for Lutron Caséta device triggers.""" -from unittest.mock import patch - import pytest from pytest_unordered import unordered @@ -37,7 +35,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from . import MockBridge +from . import MockBridge, async_setup_integration from tests.common import MockConfigEntry, async_get_device_automations @@ -112,12 +110,7 @@ async def _async_setup_lutron_with_picos(hass: HomeAssistant) -> str: ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) return config_entry.entry_id @@ -487,9 +480,7 @@ async def test_if_fires_on_button_event_late_setup( }, ) - with patch("homeassistant.components.lutron_caseta.Smartbridge.create_tls"): - await hass.config_entries.async_setup(config_entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry_id) message = { ATTR_SERIAL: device.get("serial"), diff --git a/tests/components/lutron_caseta/test_diagnostics.py b/tests/components/lutron_caseta/test_diagnostics.py index 5c7d20da208..45229918578 100644 --- a/tests/components/lutron_caseta/test_diagnostics.py +++ b/tests/components/lutron_caseta/test_diagnostics.py @@ -1,6 +1,6 @@ """Test the Lutron Caseta diagnostics.""" -from unittest.mock import ANY, patch +from unittest.mock import ANY from homeassistant.components.lutron_caseta import DOMAIN from homeassistant.components.lutron_caseta.const import ( @@ -11,7 +11,7 @@ from homeassistant.components.lutron_caseta.const import ( from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import MockBridge +from . import MockBridge, async_setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -34,12 +34,7 @@ async def test_diagnostics( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag == { diff --git a/tests/components/lutron_caseta/test_init.py b/tests/components/lutron_caseta/test_init.py new file mode 100644 index 00000000000..7e509acbf62 --- /dev/null +++ b/tests/components/lutron_caseta/test_init.py @@ -0,0 +1,54 @@ +"""Tests for the Lutron Caseta integration.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components import lutron_caseta +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import MockBridge, async_setup_integration, make_mock_entry + + +@pytest.mark.parametrize( + ("constant", "message", "timeout_during_connect", "timeout_during_configure"), + [ + ("CONNECT_TIMEOUT", "Timed out on connect", True, False), + ("CONFIGURE_TIMEOUT", "Timed out on configure", False, True), + ], +) +async def test_timeout_during_setup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + constant: str, + message: str, + timeout_during_connect: bool, + timeout_during_configure: bool, +) -> None: + """Test a timeout during setup.""" + mock_entry = make_mock_entry() + mock_entry.add_to_hass(hass) + with patch.object(lutron_caseta, constant, 0.001): + await async_setup_integration( + hass, + MockBridge, + config_entry_id=mock_entry.entry_id, + timeout_during_connect=timeout_during_connect, + timeout_during_configure=timeout_during_configure, + ) + assert mock_entry.state is ConfigEntryState.SETUP_RETRY + assert f"{message} for 1.1.1.1" in caplog.text + + +async def test_cannot_connect( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test failing to connect.""" + mock_entry = make_mock_entry() + mock_entry.add_to_hass(hass) + await async_setup_integration( + hass, MockBridge, config_entry_id=mock_entry.entry_id, can_connect=False + ) + assert mock_entry.state is ConfigEntryState.SETUP_RETRY + assert "Connection failed to 1.1.1.1" in caplog.text diff --git a/tests/components/lutron_caseta/test_logbook.py b/tests/components/lutron_caseta/test_logbook.py index 9a58838d65c..8b4a3e00fa9 100644 --- a/tests/components/lutron_caseta/test_logbook.py +++ b/tests/components/lutron_caseta/test_logbook.py @@ -1,7 +1,5 @@ """The tests for lutron caseta logbook.""" -from unittest.mock import patch - from homeassistant.components.lutron_caseta.const import ( ATTR_ACTION, ATTR_AREA_NAME, @@ -43,13 +41,7 @@ async def test_humanify_lutron_caseta_button_event(hass: HomeAssistant) -> None: unique_id="abc", ) config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) await hass.async_block_till_done() @@ -104,15 +96,10 @@ async def test_humanify_lutron_caseta_button_event_integration_not_loaded( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() for device in device_registry.devices.values(): if device.config_entries == {config_entry.entry_id}: diff --git a/tests/components/madvr/test_binary_sensor.py b/tests/components/madvr/test_binary_sensor.py index 9ddbc7b3afe..6db0471b338 100644 --- a/tests/components/madvr/test_binary_sensor.py +++ b/tests/components/madvr/test_binary_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/madvr/test_diagnostics.py b/tests/components/madvr/test_diagnostics.py index 453eaba8d94..4e355e82612 100644 --- a/tests/components/madvr/test_diagnostics.py +++ b/tests/components/madvr/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.const import Platform diff --git a/tests/components/madvr/test_remote.py b/tests/components/madvr/test_remote.py index 1ddbacdb6e9..e91c206bdd5 100644 --- a/tests/components/madvr/test_remote.py +++ b/tests/components/madvr/test_remote.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.remote import ( DOMAIN as REMOTE_DOMAIN, diff --git a/tests/components/madvr/test_sensor.py b/tests/components/madvr/test_sensor.py index dd1722913f2..029f32d552d 100644 --- a/tests/components/madvr/test_sensor.py +++ b/tests/components/madvr/test_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.madvr.sensor import get_temperature from homeassistant.const import STATE_UNKNOWN, Platform diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 0ad27cde29b..25231c15a32 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -155,7 +155,7 @@ async def test_service_say_http_error( await retrieve_media( hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) mock_speak.assert_called_once() diff --git a/tests/components/mastodon/conftest.py b/tests/components/mastodon/conftest.py index ac23141be55..d8979083de9 100644 --- a/tests/components/mastodon/conftest.py +++ b/tests/components/mastodon/conftest.py @@ -3,12 +3,13 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from mastodon.Mastodon import Account, InstanceV2 import pytest from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry, load_fixture @pytest.fixture @@ -31,9 +32,11 @@ def mock_mastodon_client() -> Generator[AsyncMock]: ) as mock_client, ): client = mock_client.return_value - client.instance.return_value = load_json_object_fixture("instance.json", DOMAIN) - client.account_verify_credentials.return_value = load_json_object_fixture( - "account_verify_credentials.json", DOMAIN + client.instance.return_value = InstanceV2.from_json( + load_fixture("instance.json", DOMAIN) + ) + client.account_verify_credentials.return_value = Account.from_json( + load_fixture("account_verify_credentials.json", DOMAIN) ) client.status_post.return_value = None yield client diff --git a/tests/components/mastodon/fixtures/account_verify_credentials.json b/tests/components/mastodon/fixtures/account_verify_credentials.json index 401caa121ae..7806d280ab9 100644 --- a/tests/components/mastodon/fixtures/account_verify_credentials.json +++ b/tests/components/mastodon/fixtures/account_verify_credentials.json @@ -1,78 +1,60 @@ { - "id": "14715", - "username": "trwnh", - "acct": "trwnh", - "display_name": "infinite love ⴳ", - "locked": false, - "bot": false, - "created_at": "2016-11-24T10:02:12.085Z", - "note": "

i have approximate knowledge of many things. perpetual student. (nb/ace/they)

xmpp/email: a@trwnh.com
https://trwnh.com
help me live: https://liberapay.com/at or https://paypal.me/trwnh

- my triggers are moths and glitter
- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
- dm me if i did something wrong, so i can improve
- purest person on fedi, do not lewd in my presence
- #1 ami cole fan account

:fatyoshi:

", - "url": "https://mastodon.social/@trwnh", - "avatar": "https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png", - "avatar_static": "https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png", - "header": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", - "header_static": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", - "followers_count": 821, - "following_count": 178, - "statuses_count": 33120, - "last_status_at": "2019-11-24T15:49:42.251Z", - "source": { - "privacy": "public", - "sensitive": false, - "language": "", - "note": "i have approximate knowledge of many things. perpetual student. (nb/ace/they)\r\n\r\nxmpp/email: a@trwnh.com\r\nhttps://trwnh.com\r\nhelp me live: https://liberapay.com/at or https://paypal.me/trwnh\r\n\r\n- my triggers are moths and glitter\r\n- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise\r\n- dm me if i did something wrong, so i can improve\r\n- purest person on fedi, do not lewd in my presence\r\n- #1 ami cole fan account\r\n\r\n:fatyoshi:", + "_mastopy_version": "2.0.0", + "_mastopy_type": "Account", + "_mastopy_data": { + "id": "14715", + "username": "trwnh", + "acct": "trwnh", + "display_name": "infinite love \u2d33", + "discoverable": true, + "group": false, + "locked": false, + "created_at": "2016-11-24T00:00:00+00:00", + "following_count": 328, + "followers_count": 3169, + "statuses_count": 69523, + "note": "

i have approximate knowledge of many things. perpetual student. (nb/ace/they)

xmpp/email: a@trwnh.com
https://trwnh.com
help me live:
- https://donate.stripe.com/4gwcPCaMpcQ19RC4gg
- https://liberapay.com/trwnh

notes:
- my triggers are moths and glitter
- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
- dm me if i did something wrong, so i can improve
- purest person on fedi, do not lewd in my presence

", + "url": "https://mastodon.social/@trwnh", + "uri": "https://mastodon.social/users/trwnh", + "avatar": "https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png", + "header": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", + "avatar_static": "https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png", + "header_static": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", + "moved_to_account": null, + "suspended": null, + "limited": null, + "bot": true, "fields": [ { "name": "Website", - "value": "https://trwnh.com", + "value": "https://trwnh.com", "verified_at": "2019-08-29T04:14:55.571+00:00" }, { - "name": "Sponsor", - "value": "https://liberapay.com/at", - "verified_at": "2019-11-15T10:06:15.557+00:00" + "name": "Portfolio", + "value": "https://abdullahtarawneh.com", + "verified_at": "2021-02-11T20:34:13.574+00:00" }, { "name": "Fan of:", - "value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", + "value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", "verified_at": null }, { - "name": "Main topics:", - "value": "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!", + "name": "What to expect:", + "value": "talking about various things i find interesting, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people! and to spill my thoughts.", "verified_at": null } ], - "follow_requests_count": 0 - }, - "emojis": [ - { - "shortcode": "fatyoshi", - "url": "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png", - "static_url": "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png", - "visible_in_picker": true - } - ], - "fields": [ - { - "name": "Website", - "value": "https://trwnh.com", - "verified_at": "2019-08-29T04:14:55.571+00:00" - }, - { - "name": "Sponsor", - "value": "https://liberapay.com/at", - "verified_at": "2019-11-15T10:06:15.557+00:00" - }, - { - "name": "Fan of:", - "value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", - "verified_at": null - }, - { - "name": "Main topics:", - "value": "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!", - "verified_at": null - } - ] + "emojis": [], + "last_status_at": "2025-03-04T00:00:00", + "noindex": false, + "roles": [], + "role": null, + "source": null, + "mute_expires_at": null, + "indexable": false, + "hide_collections": true, + "memorial": null + } } diff --git a/tests/components/mastodon/fixtures/instance.json b/tests/components/mastodon/fixtures/instance.json index b0e904e80ef..2e3dfe2d46d 100644 --- a/tests/components/mastodon/fixtures/instance.json +++ b/tests/components/mastodon/fixtures/instance.json @@ -1,147 +1,18 @@ { - "domain": "mastodon.social", - "title": "Mastodon", - "version": "4.0.0rc1", - "source_url": "https://github.com/mastodon/mastodon", - "description": "The original server operated by the Mastodon gGmbH non-profit", - "usage": { - "users": { - "active_month": 123122 + "_mastopy_version": "2.0.0", + "_mastopy_type": "InstanceV2", + "_mastopy_data": { + "uri": "mastodon.social", + "domain": "mastodon.social", + "title": "Mastodon", + "version": "4.4.0-nightly.2025-02-07", + "source_url": "https://github.com/mastodon/mastodon", + + "description": "The original server operated by the Mastodon gGmbH non-profit", + "usage": { + "users": { + "active_month": 380143 + } } - }, - "thumbnail": { - "url": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", - "blurhash": "UeKUpFxuo~R%0nW;WCnhF6RjaJt757oJodS$", - "versions": { - "@1x": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", - "@2x": "https://files.mastodon.social/site_uploads/files/000/000/001/@2x/57c12f441d083cde.png" - } - }, - "languages": ["en"], - "configuration": { - "urls": { - "streaming": "wss://mastodon.social" - }, - "vapid": { - "public_key": "BCkMmVdKDnKYwzVCDC99Iuc9GvId-x7-kKtuHnLgfF98ENiZp_aj-UNthbCdI70DqN1zUVis-x0Wrot2sBagkMc=" - }, - "accounts": { - "max_featured_tags": 10, - "max_pinned_statuses": 4 - }, - "statuses": { - "max_characters": 500, - "max_media_attachments": 4, - "characters_reserved_per_url": 23 - }, - "media_attachments": { - "supported_mime_types": [ - "image/jpeg", - "image/png", - "image/gif", - "image/heic", - "image/heif", - "image/webp", - "video/webm", - "video/mp4", - "video/quicktime", - "video/ogg", - "audio/wave", - "audio/wav", - "audio/x-wav", - "audio/x-pn-wave", - "audio/vnd.wave", - "audio/ogg", - "audio/vorbis", - "audio/mpeg", - "audio/mp3", - "audio/webm", - "audio/flac", - "audio/aac", - "audio/m4a", - "audio/x-m4a", - "audio/mp4", - "audio/3gpp", - "video/x-ms-asf" - ], - "image_size_limit": 10485760, - "image_matrix_limit": 16777216, - "video_size_limit": 41943040, - "video_frame_rate_limit": 60, - "video_matrix_limit": 2304000 - }, - "polls": { - "max_options": 4, - "max_characters_per_option": 50, - "min_expiration": 300, - "max_expiration": 2629746 - }, - "translation": { - "enabled": true - } - }, - "registrations": { - "enabled": false, - "approval_required": false, - "message": null - }, - "contact": { - "email": "staff@mastodon.social", - "account": { - "id": "1", - "username": "Gargron", - "acct": "Gargron", - "display_name": "Eugen 💀", - "locked": false, - "bot": false, - "discoverable": true, - "group": false, - "created_at": "2016-03-16T00:00:00.000Z", - "note": "

Founder, CEO and lead developer @Mastodon, Germany.

", - "url": "https://mastodon.social/@Gargron", - "avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg", - "avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg", - "header": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", - "header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", - "followers_count": 133026, - "following_count": 311, - "statuses_count": 72605, - "last_status_at": "2022-10-31", - "noindex": false, - "emojis": [], - "fields": [ - { - "name": "Patreon", - "value": "https://www.patreon.com/mastodon", - "verified_at": null - } - ] - } - }, - "rules": [ - { - "id": "1", - "text": "Sexually explicit or violent media must be marked as sensitive when posting" - }, - { - "id": "2", - "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism" - }, - { - "id": "3", - "text": "No incitement of violence or promotion of violent ideologies" - }, - { - "id": "4", - "text": "No harassment, dogpiling or doxxing of other users" - }, - { - "id": "5", - "text": "No content illegal in Germany" - }, - { - "id": "7", - "text": "Do not share intentionally false or misleading information" - } - ] + } } diff --git a/tests/components/mastodon/snapshots/test_diagnostics.ambr b/tests/components/mastodon/snapshots/test_diagnostics.ambr index 982ecee7ee2..9198410f066 100644 --- a/tests/components/mastodon/snapshots/test_diagnostics.ambr +++ b/tests/components/mastodon/snapshots/test_diagnostics.ambr @@ -3,245 +3,82 @@ dict({ 'account': dict({ 'acct': 'trwnh', - 'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png', - 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png', - 'bot': False, - 'created_at': '2016-11-24T10:02:12.085Z', + 'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'bot': True, + 'created_at': '2016-11-24T00:00:00+00:00', + 'discoverable': True, 'display_name': 'infinite love ⴳ', 'emojis': list([ - dict({ - 'shortcode': 'fatyoshi', - 'static_url': 'https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png', - 'url': 'https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png', - 'visible_in_picker': True, - }), ]), 'fields': list([ dict({ 'name': 'Website', - 'value': 'trwnh.com', + 'value': 'trwnh.com', 'verified_at': '2019-08-29T04:14:55.571+00:00', }), dict({ - 'name': 'Sponsor', - 'value': 'liberapay.com/at', - 'verified_at': '2019-11-15T10:06:15.557+00:00', + 'name': 'Portfolio', + 'value': 'abdullahtarawneh.com', + 'verified_at': '2021-02-11T20:34:13.574+00:00', }), dict({ 'name': 'Fan of:', - 'value': 'Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)', + 'value': 'Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)', 'verified_at': None, }), dict({ - 'name': 'Main topics:', - 'value': 'systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!', + 'name': 'What to expect:', + 'value': 'talking about various things i find interesting, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people! and to spill my thoughts.', 'verified_at': None, }), ]), - 'followers_count': 821, - 'following_count': 178, + 'followers_count': 3169, + 'following_count': 328, + 'group': False, 'header': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', 'header_static': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', + 'hide_collections': True, 'id': '14715', - 'last_status_at': '2019-11-24T15:49:42.251Z', + 'indexable': False, + 'last_status_at': '2025-03-04T00:00:00', + 'limited': None, 'locked': False, - 'note': '

i have approximate knowledge of many things. perpetual student. (nb/ace/they)

xmpp/email: a@trwnh.com
trwnh.com
help me live: liberapay.com/at or paypal.me/trwnh

- my triggers are moths and glitter
- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
- dm me if i did something wrong, so i can improve
- purest person on fedi, do not lewd in my presence
- #1 ami cole fan account

:fatyoshi:

', - 'source': dict({ - 'fields': list([ - dict({ - 'name': 'Website', - 'value': 'https://trwnh.com', - 'verified_at': '2019-08-29T04:14:55.571+00:00', - }), - dict({ - 'name': 'Sponsor', - 'value': 'https://liberapay.com/at', - 'verified_at': '2019-11-15T10:06:15.557+00:00', - }), - dict({ - 'name': 'Fan of:', - 'value': "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", - 'verified_at': None, - }), - dict({ - 'name': 'Main topics:', - 'value': "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!", - 'verified_at': None, - }), - ]), - 'follow_requests_count': 0, - 'language': '', - 'note': ''' - i have approximate knowledge of many things. perpetual student. (nb/ace/they) - - xmpp/email: a@trwnh.com - https://trwnh.com - help me live: https://liberapay.com/at or https://paypal.me/trwnh - - - my triggers are moths and glitter - - i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise - - dm me if i did something wrong, so i can improve - - purest person on fedi, do not lewd in my presence - - #1 ami cole fan account - - :fatyoshi: - ''', - 'privacy': 'public', - 'sensitive': False, - }), - 'statuses_count': 33120, + 'memorial': None, + 'moved_to_account': None, + 'mute_expires_at': None, + 'noindex': False, + 'note': '

i have approximate knowledge of many things. perpetual student. (nb/ace/they)

xmpp/email: a@trwnh.com
trwnh.com
help me live:
- donate.stripe.com/4gwcPCaMpcQ1
- liberapay.com/trwnh

notes:
- my triggers are moths and glitter
- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
- dm me if i did something wrong, so i can improve
- purest person on fedi, do not lewd in my presence

', + 'role': None, + 'roles': list([ + ]), + 'source': None, + 'statuses_count': 69523, + 'suspended': None, + 'uri': 'https://mastodon.social/users/trwnh', 'url': 'https://mastodon.social/@trwnh', 'username': 'trwnh', }), 'instance': dict({ - 'configuration': dict({ - 'accounts': dict({ - 'max_featured_tags': 10, - 'max_pinned_statuses': 4, - }), - 'media_attachments': dict({ - 'image_matrix_limit': 16777216, - 'image_size_limit': 10485760, - 'supported_mime_types': list([ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/heic', - 'image/heif', - 'image/webp', - 'video/webm', - 'video/mp4', - 'video/quicktime', - 'video/ogg', - 'audio/wave', - 'audio/wav', - 'audio/x-wav', - 'audio/x-pn-wave', - 'audio/vnd.wave', - 'audio/ogg', - 'audio/vorbis', - 'audio/mpeg', - 'audio/mp3', - 'audio/webm', - 'audio/flac', - 'audio/aac', - 'audio/m4a', - 'audio/x-m4a', - 'audio/mp4', - 'audio/3gpp', - 'video/x-ms-asf', - ]), - 'video_frame_rate_limit': 60, - 'video_matrix_limit': 2304000, - 'video_size_limit': 41943040, - }), - 'polls': dict({ - 'max_characters_per_option': 50, - 'max_expiration': 2629746, - 'max_options': 4, - 'min_expiration': 300, - }), - 'statuses': dict({ - 'characters_reserved_per_url': 23, - 'max_characters': 500, - 'max_media_attachments': 4, - }), - 'translation': dict({ - 'enabled': True, - }), - 'urls': dict({ - 'streaming': 'wss://mastodon.social', - }), - 'vapid': dict({ - 'public_key': 'BCkMmVdKDnKYwzVCDC99Iuc9GvId-x7-kKtuHnLgfF98ENiZp_aj-UNthbCdI70DqN1zUVis-x0Wrot2sBagkMc=', - }), - }), - 'contact': dict({ - 'account': dict({ - 'acct': 'Gargron', - 'avatar': 'https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg', - 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg', - 'bot': False, - 'created_at': '2016-03-16T00:00:00.000Z', - 'discoverable': True, - 'display_name': 'Eugen 💀', - 'emojis': list([ - ]), - 'fields': list([ - dict({ - 'name': 'Patreon', - 'value': 'patreon.com/mastodon', - 'verified_at': None, - }), - ]), - 'followers_count': 133026, - 'following_count': 311, - 'group': False, - 'header': 'https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg', - 'header_static': 'https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg', - 'id': '1', - 'last_status_at': '2022-10-31', - 'locked': False, - 'noindex': False, - 'note': '

Founder, CEO and lead developer @Mastodon, Germany.

', - 'statuses_count': 72605, - 'url': 'https://mastodon.social/@Gargron', - 'username': 'Gargron', - }), - 'email': 'staff@mastodon.social', - }), + 'api_versions': None, + 'configuration': None, + 'contact': None, 'description': 'The original server operated by the Mastodon gGmbH non-profit', 'domain': 'mastodon.social', - 'languages': list([ - 'en', - ]), - 'registrations': dict({ - 'approval_required': False, - 'enabled': False, - 'message': None, - }), - 'rules': list([ - dict({ - 'id': '1', - 'text': 'Sexually explicit or violent media must be marked as sensitive when posting', - }), - dict({ - 'id': '2', - 'text': 'No racism, sexism, homophobia, transphobia, xenophobia, or casteism', - }), - dict({ - 'id': '3', - 'text': 'No incitement of violence or promotion of violent ideologies', - }), - dict({ - 'id': '4', - 'text': 'No harassment, dogpiling or doxxing of other users', - }), - dict({ - 'id': '5', - 'text': 'No content illegal in Germany', - }), - dict({ - 'id': '7', - 'text': 'Do not share intentionally false or misleading information', - }), - ]), + 'icon': None, + 'languages': None, + 'registrations': None, + 'rules': None, 'source_url': 'https://github.com/mastodon/mastodon', - 'thumbnail': dict({ - 'blurhash': 'UeKUpFxuo~R%0nW;WCnhF6RjaJt757oJodS$', - 'url': 'https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png', - 'versions': dict({ - '@1x': 'https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png', - '@2x': 'https://files.mastodon.social/site_uploads/files/000/000/001/@2x/57c12f441d083cde.png', - }), - }), + 'thumbnail': None, 'title': 'Mastodon', + 'uri': 'mastodon.social', 'usage': dict({ 'users': dict({ - 'active_month': 123122, + 'active_month': 380143, }), }), - 'version': '4.0.0rc1', + 'version': '4.4.0-nightly.2025-02-07', }), }) # --- diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr index 28157b9e6eb..46fb4c1d4e0 100644 --- a/tests/components/mastodon/snapshots/test_init.ambr +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -28,7 +28,7 @@ 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': '4.0.0rc1', + 'sw_version': '4.4.0-nightly.2025-02-07', 'via_device_id': None, }) # --- diff --git a/tests/components/mastodon/snapshots/test_sensor.ambr b/tests/components/mastodon/snapshots/test_sensor.ambr index 22ac2671c36..40986210454 100644 --- a/tests/components/mastodon/snapshots/test_sensor.ambr +++ b/tests/components/mastodon/snapshots/test_sensor.ambr @@ -47,7 +47,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '821', + 'state': '3169', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_following-entry] @@ -98,7 +98,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '178', + 'state': '328', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_posts-entry] @@ -149,6 +149,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '33120', + 'state': '69523', }) # --- diff --git a/tests/components/mastodon/test_diagnostics.py b/tests/components/mastodon/test_diagnostics.py index c2de15d1a51..531543ee65d 100644 --- a/tests/components/mastodon/test_diagnostics.py +++ b/tests/components/mastodon/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index 4dafa9a8e5b..f51d39f8687 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, Mock, patch -from mastodon.Mastodon import MastodonAPIError +from mastodon.Mastodon import MastodonAPIError, MediaAttachment import pytest from homeassistant.components.mastodon.const import ( @@ -106,7 +106,9 @@ async def test_service_post( with ( patch.object(hass.config, "is_allowed_path", return_value=True), - patch.object(mock_mastodon_client, "media_post", return_value={"id": "1"}), + patch.object( + mock_mastodon_client, "media_post", return_value=MediaAttachment(id="1") + ), ): await hass.services.async_call( DOMAIN, @@ -163,7 +165,7 @@ async def test_post_service_failed( await hass.async_block_till_done() hass.config.is_allowed_path = Mock(return_value=True) - mock_mastodon_client.media_post.return_value = {"id": "1"} + mock_mastodon_client.media_post.return_value = MediaAttachment(id="1") mock_mastodon_client.status_post.side_effect = MastodonAPIError diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index 519b4c4027d..18c4760e473 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -10,7 +10,7 @@ from unittest.mock import MagicMock from matter_server.client.models.node import MatterNode from matter_server.common.helpers.util import dataclass_from_dict from matter_server.common.models import EventType, MatterNodeData -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index d7429f6087d..7da9a28484e 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -43,6 +43,7 @@ async def matter_client_fixture() -> AsyncGenerator[MagicMock]: pytest.fail("Listen was not cancelled!") client.connect = AsyncMock(side_effect=connect) + client.check_node_update = AsyncMock(return_value=None) client.start_listening = AsyncMock(side_effect=listen) client.server_info = ServerInfoMessage( fabric_id=MOCK_FABRIC_ID, @@ -76,6 +77,7 @@ async def integration_fixture( "air_purifier", "air_quality_sensor", "color_temperature_light", + "cooktop", "dimmable_light", "dimmable_plugin_unit", "door_lock", @@ -91,9 +93,11 @@ async def integration_fixture( "generic_switch", "generic_switch_multi", "humidity_sensor", + "laundry_dryer", "leak_sensor", "light_sensor", "microwave_oven", + "mounted_dimmable_load_control_fixture", "multi_endpoint_light", "occupancy_sensor", "on_off_plugin_unit", @@ -101,11 +105,17 @@ async def integration_fixture( "onoff_light_alt_name", "onoff_light_no_name", "onoff_light_with_levelcontrol_present", + "oven", "pressure_sensor", + "pump", "room_airconditioner", "silabs_dishwasher", + "silabs_evse_charging", "silabs_laundrywasher", + "silabs_refrigerator", + "silabs_water_heater", "smoke_detector", + "solar_power", "switch_unit", "temperature_sensor", "thermostat", diff --git a/tests/components/matter/fixtures/nodes/cooktop.json b/tests/components/matter/fixtures/nodes/cooktop.json new file mode 100644 index 00000000000..f32322b6cb7 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/cooktop.json @@ -0,0 +1,308 @@ +{ + "node_id": 3, + "date_commissioned": "2025-04-29T15:54:11.963738", + "last_interview": "2025-04-29T15:54:11.963750", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [29, 31, 40, 43, 45, 48, 49, 51, 54, 60, 62, 63], + "0/29/2": [], + "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, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 1, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "Mock", + "0/40/2": 65521, + "0/40/3": "Mock Cooktop", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "8854D258EF79CBAE", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "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, 17, 18, 19, 21, + 22, 24, 65532, 65533, 65528, 65529, 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/45/0": 0, + "0/45/1": [0, 1, 2], + "0/45/65532": 1, + "0/45/65533": 2, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkIN/v6b", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZAP/YMcX0yMLQ==", + "KgEOCgKzOZC/O1Ew1WvS4A==", + "/oAAAAAAAADml3Ozl7GZug==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 23, + "0/51/3": 0, + "0/51/4": 0, + "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, 65532, 65533, 65528, 65529, 65531 + ], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65532, 65533, 65528, 65529, + 65531 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 1, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRAxgkBwEkCAEwCUEE1B4lA2AYRzpeBC9EizUv1FilsHNIEbFdH0c0o1NCiMMsdkxMJ/MnyXholb/76NUBLrq0tFMXYMa8TjIcHh915zcKNQEoARgkAgE2AwQCBAEYMAQUgfoxJi2HOriuKa6K2cbtp49/SYIwBRRqGquZZYwbDAaOinVVrS9sWTozoBgwC0DCxbisQiHwqDX9s2aGsCUz+6/8evG3EOMGOU0tG1DuXY4kd5TTxmIAjk51GwIszElOMBsfQV5ZAB1KbSKgaUrwGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyvr+z4yBxEDoiyCFg+i408LqC3j0UMvTszBv1051g2EMrAzBkj+0RZFsSl3eQ3D2c7mTcH6GERtlk4BqGvC1qDcKNQEpARgkAmAwBBRqGquZZYwbDAaOinVVrS9sWTozoDAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQCIuoikQZU9LkDKw7dcTVVXBDlTyBol3w070PIIw8BbaQD5qCeIv/3cI5/X5sAYTmemRq0ZPMjAw1dsN+wodzm8Y", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BIPshBqc9a7nNK00eRrviEzHfe/cfATY9VngqKv17+uAUpy3XujhZBjkAQyhYAaSKxVzSfVttY4FVQkpXIHZFlA=", + "2": 4939, + "3": 2, + "4": 3, + "5": "Maison", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEg+yEGpz1ruc0rTR5Gu+ITMd979x8BNj1WeCoq/Xv64BSnLde6OFkGOQBDKFgBpIrFXNJ9W21jgVVCSlcgdkWUDcKNQEpARgkAmAwBBRPkvAMbwLEubfgETM7L7icezGlHzAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQIKyooBXllxj1uo4Zn4CBbZqECNdO3wwzlhl7ZEygrWa04gBa5rVqgg+JahrvXD6HPHu4XldWIULtqTCPPIm4OsY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "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, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/6/0": true, + "1/6/65532": 4, + "1/6/65533": 6, + "1/6/65528": [], + "1/6/65529": [0], + "1/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 120, + "1": 1 + } + ], + "1/29/1": [3, 6, 29], + "1/29/2": [], + "1/29/3": [2], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "2/6/0": true, + "2/6/65532": 4, + "2/6/65533": 6, + "2/6/65528": [], + "2/6/65529": [0], + "2/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "2/29/0": [ + { + "0": 119, + "1": 1 + } + ], + "2/29/1": [6, 29, 86, 1026], + "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, 65532, 65533, 65528, 65529, 65531], + "2/86/4": 1, + "2/86/5": ["Low", "Medium", "High"], + "2/86/65532": 2, + "2/86/65533": 1, + "2/86/65528": [], + "2/86/65529": [0], + "2/86/65531": [4, 5, 65532, 65533, 65528, 65529, 65531], + "2/1026/0": 18000, + "2/1026/1": null, + "2/1026/2": null, + "2/1026/65532": 0, + "2/1026/65533": 4, + "2/1026/65528": [], + "2/1026/65529": [], + "2/1026/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/generic_switch_multi.json b/tests/components/matter/fixtures/nodes/generic_switch_multi.json index 8923198c31e..4055c9dc336 100644 --- a/tests/components/matter/fixtures/nodes/generic_switch_multi.json +++ b/tests/components/matter/fixtures/nodes/generic_switch_multi.json @@ -72,7 +72,6 @@ "1/59/0": 2, "1/59/65533": 1, "1/59/1": 0, - "1/59/2": 2, "1/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/59/65532": 30, "1/59/65528": [], @@ -102,7 +101,7 @@ "2/59/0": 2, "2/59/65533": 1, "2/59/1": 0, - "2/59/2": 2, + "2/59/2": 4, "2/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "2/59/65532": 30, "2/59/65528": [], diff --git a/tests/components/matter/fixtures/nodes/laundry_dryer.json b/tests/components/matter/fixtures/nodes/laundry_dryer.json new file mode 100644 index 00000000000..a74bca934a0 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/laundry_dryer.json @@ -0,0 +1,307 @@ +{ + "node_id": 8, + "date_commissioned": "2025-05-01T11:45:46.203438", + "last_interview": "2025-05-01T11:45:46.203452", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 45, 48, 49, 51, 54, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Laundrydryer", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "8A7EFAF22659A7C6", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65533": 5, + "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, 17, 18, 19, 21, + 22, 24, 65532, 65533, 65528, 65529, 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/45/0": 0, + "0/45/65532": 1, + "0/45/65533": 2, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/2": 0, + "0/49/3": 0, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkIH8Iu2", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZD1j4HmibD6Yw==", + "KgEOCgKzOZC/O1Ew1WvS4A==", + "/oAAAAAAAADml3Ozl7GZug==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 11, + "0/51/3": 0, + "0/51/4": 0, + "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, 65532, 65533, 65528, 65529, 65531 + ], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65532, 65533, 65528, 65529, + 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRCBgkBwEkCAEwCUEEuBSQYARV1MtZ/zTYCZDFAchE6gYPl8EQsnZ/zBOFY/+CRpZdiSIJdKySB6kixHqnFG5AlLLuN0kV2p3RgtFNhDcKNQEoARgkAgE2AwQCBAEYMAQUHBnbZ0B6X2b4Hrmm7ND49lbGb4MwBRRqGquZZYwbDAaOinVVrS9sWTozoBgwC0AYHLmEMzw4m5K4nFJO6x8PB5xwkHJ0QtPgowB2/HYdTyR+MIPJRQfiPZB2WSzaDQpkMj+niAV9X59mKSwTntitGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyvr+z4yBxEDoiyCFg+i408LqC3j0UMvTszBv1051g2EMrAzBkj+0RZFsSl3eQ3D2c7mTcH6GERtlk4BqGvC1qDcKNQEpARgkAmAwBBRqGquZZYwbDAaOinVVrS9sWTozoDAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQCIuoikQZU9LkDKw7dcTVVXBDlTyBol3w070PIIw8BbaQD5qCeIv/3cI5/X5sAYTmemRq0ZPMjAw1dsN+wodzm8Y", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BIPshBqc9a7nNK00eRrviEzHfe/cfATY9VngqKv17+uAUpy3XujhZBjkAQyhYAaSKxVzSfVttY4FVQkpXIHZFlA=", + "2": 4939, + "3": 2, + "4": 8, + "5": "ha-freebox", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEg+yEGpz1ruc0rTR5Gu+ITMd979x8BNj1WeCoq/Xv64BSnLde6OFkGOQBDKFgBpIrFXNJ9W21jgVVCSlcgdkWUDcKNQEpARgkAmAwBBRPkvAMbwLEubfgETM7L7icezGlHzAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQIKyooBXllxj1uo4Zn4CBbZqECNdO3wwzlhl7ZEygrWa04gBa5rVqgg+JahrvXD6HPHu4XldWIULtqTCPPIm4OsY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "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, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 6, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/6/0": false, + "1/6/65532": 2, + "1/6/65533": 6, + "1/6/65528": [], + "1/6/65529": [0, 1, 2], + "1/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 124, + "1": 1 + } + ], + "1/29/1": [3, 6, 29, 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, 65532, 65533, 65528, 65529, 65531], + "1/86/4": 0, + "1/86/5": ["Low", "Medium", "High"], + "1/86/65532": 2, + "1/86/65533": 1, + "1/86/65528": [], + "1/86/65529": [0], + "1/86/65531": [4, 5, 65532, 65533, 65528, 65529, 65531], + "1/96/0": ["pre-soak", "rinse", "spin"], + "1/96/1": 0, + "1/96/2": null, + "1/96/3": [ + { + "0": 0 + }, + { + "0": 1 + }, + { + "0": 2 + }, + { + "0": 3 + } + ], + "1/96/4": 1, + "1/96/5": { + "0": 0 + }, + "1/96/65532": 0, + "1/96/65533": 3, + "1/96/65528": [4], + "1/96/65529": [0, 1, 2, 3], + "1/96/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/microwave_oven.json b/tests/components/matter/fixtures/nodes/microwave_oven.json index ed0a4accd6a..bbba8b12e25 100644 --- a/tests/components/matter/fixtures/nodes/microwave_oven.json +++ b/tests/components/matter/fixtures/nodes/microwave_oven.json @@ -368,6 +368,8 @@ "1/95/3": 20, "1/95/4": 90, "1/95/5": 10, + "1/95/6": [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000], + "1/95/7": 9, "1/95/8": 1000, "1/95/65532": 5, "1/95/65533": 1, @@ -395,7 +397,7 @@ "1/96/5": { "0": 0 }, - "1/96/65532": 0, + "1/96/65532": 2, "1/96/65533": 2, "1/96/65528": [4], "1/96/65529": [0, 1, 2, 3], diff --git a/tests/components/matter/fixtures/nodes/mounted_dimmable_load_control_fixture.json b/tests/components/matter/fixtures/nodes/mounted_dimmable_load_control_fixture.json new file mode 100644 index 00000000000..b19b97bc41c --- /dev/null +++ b/tests/components/matter/fixtures/nodes/mounted_dimmable_load_control_fixture.json @@ -0,0 +1,308 @@ +{ + "node_id": 14, + "date_commissioned": "2025-05-02T08:15:29.450054", + "last_interview": "2025-05-02T08:15:29.450072", + "interview_version": 6, + "available": false, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [29, 31, 40, 43, 48, 49, 50, 51, 52, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Mounted dimmable load control", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/18": "53AB7717C13D0DD2", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65533": 5, + "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, + 24, 65532, 65533, 65528, 65529, 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkK7ybsD", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZBQ8P5SEgahQg==", + "KgEOCgKzOZC/O1Ew1WvS4A==", + "/oAAAAAAAADml3Ozl7GZug==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 13, + "0/51/3": 0, + "0/51/4": 0, + "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, 65532, 65533, 65528, 65529, 65531 + ], + "0/52/0": [ + { + "0": 2673, + "1": "2673" + }, + { + "0": 2672, + "1": "2672" + }, + { + "0": 2671, + "1": "2671" + }, + { + "0": 2670, + "1": "2670" + }, + { + "0": 2669, + "1": "2669" + }, + { + "0": 2668, + "1": "2668" + }, + { + "0": 2667, + "1": "2667" + } + ], + "0/52/1": 830464, + "0/52/2": 635904, + "0/52/3": 635904, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "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, 2], + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRDhgkBwEkCAEwCUEEdWlSeMU0X1DnfNwpCgYjMQOf/XgYW1AbAJCiYwSvbm6/9kZ1C97E9ah0h3vtKD4jZIQBDQGv3e1ffCuw2OlDuTcKNQEoARgkAgE2AwQCBAEYMAQUP1MVmuztpdJEPcw9p/9X9qok6iAwBRRqGquZZYwbDAaOinVVrS9sWTozoBgwC0BAw6CB9ukgfW1LKZHsr2h6G2JAQWjUPNaWQrFAgWA7GAbgY2wdsppjUJ6kXIOyO5Ci/vlQHI2NE6woRbS+6QOuGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyvr+z4yBxEDoiyCFg+i408LqC3j0UMvTszBv1051g2EMrAzBkj+0RZFsSl3eQ3D2c7mTcH6GERtlk4BqGvC1qDcKNQEpARgkAmAwBBRqGquZZYwbDAaOinVVrS9sWTozoDAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQCIuoikQZU9LkDKw7dcTVVXBDlTyBol3w070PIIw8BbaQD5qCeIv/3cI5/X5sAYTmemRq0ZPMjAw1dsN+wodzm8Y", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BIPshBqc9a7nNK00eRrviEzHfe/cfATY9VngqKv17+uAUpy3XujhZBjkAQyhYAaSKxVzSfVttY4FVQkpXIHZFlA=", + "2": 4939, + "3": 2, + "4": 14, + "5": "ha-freebox", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEg+yEGpz1ruc0rTR5Gu+ITMd979x8BNj1WeCoq/Xv64BSnLde6OFkGOQBDKFgBpIrFXNJ9W21jgVVCSlcgdkWUDcKNQEpARgkAmAwBBRPkvAMbwLEubfgETM7L7icezGlHzAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQIKyooBXllxj1uo4Zn4CBbZqECNdO3wwzlhl7ZEygrWa04gBa5rVqgg+JahrvXD6HPHu4XldWIULtqTCPPIm4OsY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "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, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 6, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65532, 65533, 65528, 65529, 65531], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": 0, + "1/6/65532": 1, + "1/6/65533": 6, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65532, 65533, 65528, 65529, 65531 + ], + "1/8/0": 254, + "1/8/1": 0, + "1/8/15": 0, + "1/8/17": 0, + "1/8/16384": 0, + "1/8/65532": 3, + "1/8/65533": 6, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [0, 1, 15, 17, 16384, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 272, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29], + "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, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/oven.json b/tests/components/matter/fixtures/nodes/oven.json new file mode 100644 index 00000000000..6e325146f83 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/oven.json @@ -0,0 +1,484 @@ +{ + "node_id": 2, + "date_commissioned": "2025-04-29T15:37:55.171819", + "last_interview": "2025-04-29T15:37:55.171832", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [29, 31, 40, 43, 45, 48, 49, 51, 54, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1, 2, 3, 4], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 1, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Oven", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "EB38EF759DAA4DB8", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65533": 5, + "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, 17, 18, 19, 21, + 22, 24, 65532, 65533, 65528, 65529, 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/45/0": 0, + "0/45/65532": 1, + "0/45/65533": 2, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkIN/v6b", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZAP/YMcX0yMLQ==", + "KgEOCgKzOZC/O1Ew1WvS4A==", + "/oAAAAAAAADml3Ozl7GZug==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 26, + "0/51/3": 0, + "0/51/4": 0, + "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, 65532, 65533, 65528, 65529, 65531 + ], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65532, 65533, 65528, 65529, + 65531 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 1, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRAhgkBwEkCAEwCUEE3mWlRgzQdFFY8sclYjEv0uyAYGfTqVozOb5xR/ypUesqyIwaR1bqY6K4D2+zUx+FBvbRBBUj0PBwJ32cvUm+LTcKNQEoARgkAgE2AwQCBAEYMAQUnKark4iAc32+X9hGHNDon32qhdowBRRqGquZZYwbDAaOinVVrS9sWTozoBgwC0ABtt37m0318llNw7RtRoGFeHD4OxuGHNRS7JT28Oy0H4dNXb4Nu+xyQEK5zVri/QSUK3doq/PD8G0h33Ix4oOLGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyvr+z4yBxEDoiyCFg+i408LqC3j0UMvTszBv1051g2EMrAzBkj+0RZFsSl3eQ3D2c7mTcH6GERtlk4BqGvC1qDcKNQEpARgkAmAwBBRqGquZZYwbDAaOinVVrS9sWTozoDAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQCIuoikQZU9LkDKw7dcTVVXBDlTyBol3w070PIIw8BbaQD5qCeIv/3cI5/X5sAYTmemRq0ZPMjAw1dsN+wodzm8Y", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BIPshBqc9a7nNK00eRrviEzHfe/cfATY9VngqKv17+uAUpy3XujhZBjkAQyhYAaSKxVzSfVttY4FVQkpXIHZFlA=", + "2": 4939, + "3": 2, + "4": 2, + "5": "Maison", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEg+yEGpz1ruc0rTR5Gu+ITMd979x8BNj1WeCoq/Xv64BSnLde6OFkGOQBDKFgBpIrFXNJ9W21jgVVCSlcgdkWUDcKNQEpARgkAmAwBBRPkvAMbwLEubfgETM7L7icezGlHzAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQIKyooBXllxj1uo4Zn4CBbZqECNdO3wwzlhl7ZEygrWa04gBa5rVqgg+JahrvXD6HPHu4XldWIULtqTCPPIm4OsY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "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, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 6, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 123, + "1": 2 + } + ], + "1/29/1": [3, 29], + "1/29/2": [], + "1/29/3": [2, 3], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "2/29/0": [ + { + "0": 113, + "1": 3 + } + ], + "2/29/1": [29, 72, 73, 86, 1026], + "2/29/2": [], + "2/29/3": [], + "2/29/4": [ + { + "0": null, + "1": 8, + "2": 2 + } + ], + "2/29/65532": 1, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "2/72/0": ["pre-heating", "pre-heated", "cooling down"], + "2/72/1": 0, + "2/72/2": null, + "2/72/3": [ + { + "0": 0 + }, + { + "0": 1 + }, + { + "0": 3 + } + ], + "2/72/4": 1, + "2/72/5": { + "0": 0 + }, + "2/72/65532": 0, + "2/72/65533": 2, + "2/72/65528": [4], + "2/72/65529": [1, 2], + "2/72/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531], + "2/73/0": [ + { + "0": "Bake", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Convection", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Grill", + "1": 2, + "2": [ + { + "1": 16386 + } + ] + }, + { + "0": "Roast", + "1": 3, + "2": [ + { + "1": 16387 + } + ] + }, + { + "0": "Clean", + "1": 4, + "2": [ + { + "1": 16388 + } + ] + }, + { + "0": "Convection Bake", + "1": 5, + "2": [ + { + "1": 16389 + } + ] + }, + { + "0": "Convection Roast", + "1": 6, + "2": [ + { + "1": 16390 + } + ] + }, + { + "0": "Warming", + "1": 7, + "2": [ + { + "1": 16391 + } + ] + }, + { + "0": "Proofing", + "1": 8, + "2": [ + { + "1": 16392 + } + ] + } + ], + "2/73/1": 0, + "2/73/65532": 0, + "2/73/65533": 2, + "2/73/65528": [1], + "2/73/65529": [0], + "2/73/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "2/86/0": 7600, + "2/86/1": 7600, + "2/86/2": 28800, + "2/86/65532": 1, + "2/86/65533": 1, + "2/86/65528": [], + "2/86/65529": [0], + "2/86/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "2/1026/0": 6555, + "2/1026/1": 3000, + "2/1026/2": 30000, + "2/1026/65532": 0, + "2/1026/65533": 4, + "2/1026/65528": [], + "2/1026/65529": [], + "2/1026/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "3/3/0": 0, + "3/3/1": 0, + "3/3/65532": 0, + "3/3/65533": 6, + "3/3/65528": [], + "3/3/65529": [0], + "3/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "3/6/0": false, + "3/6/65532": 4, + "3/6/65533": 6, + "3/6/65528": [], + "3/6/65529": [0], + "3/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "3/29/0": [ + { + "0": 120, + "1": 1 + } + ], + "3/29/1": [3, 6, 29], + "3/29/2": [], + "3/29/3": [4], + "3/29/65532": 0, + "3/29/65533": 2, + "3/29/65528": [], + "3/29/65529": [], + "3/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "4/6/0": false, + "4/6/65532": 4, + "4/6/65533": 6, + "4/6/65528": [], + "4/6/65529": [0], + "4/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "4/29/0": [ + { + "0": 119, + "1": 1 + } + ], + "4/29/1": [6, 29, 86, 1026], + "4/29/2": [], + "4/29/3": [], + "4/29/4": [ + { + "0": null, + "1": 8, + "2": 0 + } + ], + "4/29/65532": 1, + "4/29/65533": 2, + "4/29/65528": [], + "4/29/65529": [], + "4/29/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "4/86/4": 0, + "4/86/5": ["Low", "Medium", "High"], + "4/86/65532": 2, + "4/86/65533": 1, + "4/86/65528": [], + "4/86/65529": [0], + "4/86/65531": [4, 5, 65532, 65533, 65528, 65529, 65531], + "4/1026/0": 0, + "4/1026/1": null, + "4/1026/2": null, + "4/1026/65532": 0, + "4/1026/65533": 4, + "4/1026/65528": [], + "4/1026/65529": [], + "4/1026/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/pump.json b/tests/components/matter/fixtures/nodes/pump.json new file mode 100644 index 00000000000..39579f4448c --- /dev/null +++ b/tests/components/matter/fixtures/nodes/pump.json @@ -0,0 +1,271 @@ +{ + "node_id": 3, + "date_commissioned": "2025-05-09T15:45:16.457511", + "last_interview": "2025-05-09T15:49:41.414681", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 45, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Pump", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/18": "C7C87250EABB7BC8", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65533": 5, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 18, 19, 21, 22, 24, 65532, 65533, 65528, + 65529, 65531 + ], + "0/45/65532": 0, + "0/45/65533": 2, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/2": 0, + "0/49/3": 0, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkLWHXRl", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZARgk66TFlR1w==", + "KgEOCgKzOZC/O1Ew1WvS4A==", + "/oAAAAAAAADml3Ozl7GZug==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 282, + "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, 8, 65532, 65533, 65528, 65529, 65531], + "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, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRAxgkBwEkCAEwCUEE3Z+JMyIjVAtmzqwEaVxp1V6SNzKfmJT0691W905Zr2Sv2fSCu0OMmvZAt1ih58GZj9MTRYM4Up3sJF481rks+zcKNQEoARgkAgE2AwQCBAEYMAQUjivV8lU5bIctgqrN/Mb2xBPB6XwwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0CrPeCxivaBtn7q7Pcj7JvVWdN2JAZ+lVlL08Uix9hjOCShJntfL6j+LFRKPQ1elgp2E3DO/jvkSAEFmAzXp8zOGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=", + "2": 4939, + "3": 2, + "4": 3, + "5": "ha-freebox", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "0/62/65528": [1, 3, 5, 8, 14], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531], + "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, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 6, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/6/0": true, + "1/6/65532": 0, + "1/6/65533": 5, + "1/6/65528": [], + "1/6/65529": [0, 1, 2], + "1/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "1/8/0": 254, + "1/8/15": 0, + "1/8/17": 0, + "1/8/65532": 0, + "1/8/65533": 6, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [0, 15, 17, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 771, + "1": 1 + } + ], + "1/29/1": [3, 6, 8, 29, 512, 1026, 1027, 1028], + "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, 65532, 65533, 65528, 65529, 65531], + "1/512/0": 32767, + "1/512/1": 65534, + "1/512/2": 65534, + "1/512/16": 5, + "1/512/17": 0, + "1/512/18": 5, + "1/512/19": null, + "1/512/20": 1000, + "1/512/32": 0, + "1/512/33": 5, + "1/512/65532": 0, + "1/512/65533": 6, + "1/512/65528": [], + "1/512/65529": [], + "1/512/65531": [ + 0, 1, 2, 16, 17, 18, 19, 20, 32, 33, 65532, 65533, 65528, 65529, 65531 + ], + "1/1026/0": 6000, + "1/1026/1": -27315, + "1/1026/2": 32767, + "1/1026/65532": 0, + "1/1026/65533": 6, + "1/1026/65528": [], + "1/1026/65529": [], + "1/1026/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "1/1027/0": 100, + "1/1027/1": -32767, + "1/1027/2": 32767, + "1/1027/65532": 0, + "1/1027/65533": 6, + "1/1027/65528": [], + "1/1027/65529": [], + "1/1027/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "1/1028/0": 50, + "1/1028/1": 0, + "1/1028/2": 65534, + "1/1028/65532": 0, + "1/1028/65533": 6, + "1/1028/65528": [], + "1/1028/65529": [], + "1/1028/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/silabs_evse_charging.json b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json new file mode 100644 index 00000000000..3188ba81ad6 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json @@ -0,0 +1,580 @@ +{ + "node_id": 23, + "date_commissioned": "2024-12-17T18:14:53.210190", + "last_interview": "2024-12-17T18:14:53.211611", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 44, 45, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "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": 1 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "evse", + "0/40/4": 32769, + "0/40/5": "evse", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/15": "TEST_SN", + "0/40/18": "evse", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17039360, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 21, 22, 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/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "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": "ZW5zMzM=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/8": [0], + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkI9NTnB", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBpw=="], + "6": [ + "KgEOCgKzOZCNB+q+Uz0I9w==", + "KgEOCgKzOZC/O1Ew1WvS4A==", + "/oAAAAAAAADml3Ozl7GZug==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 10129, + "0/51/8": true, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 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, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRFxgkBwEkCAEwCUEECp4PASYUFk/DwQqGNBikYdiBRDJZbrfF4AYK8Y9jOeIpx7Xy+giJhmTpAVZ662hwszsFDGULGY/owXtMrqTxEDcKNQEoARgkAgE2AwQCBAEYMAQUqBmxO16fPQhbf33Gb2XwQ+NkXpswBRTx8+4bdkuqlxInfB5LXkhRBBvS2hgwC0A8aefsLm663Vuy+TkSvn/oLhRqt2phrG+i5aM5o15xiWDjnNVdUYpT09+K0mgVoMdFuFsmoWQxQh6jahaFJzUgGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEGp55xGRB0FBQ3Yw7ayQSzVtYA0BtCJFm9vRRcdr+nk0cuGX6zrUowSYOO/qiRBEACcCNNSqKh+DpRm2uVLOtaDcKNQEpARgkAmAwBBTx8+4bdkuqlxInfB5LXkhRBBvS2jAFFIxTG68U5WQVsk8AtvSQyeK3KLqPGDALQIw/6q5ILMNdOMcSif8HNbEgpjBeaBMfUpzOJFCRPM16sv1xiq3mALZj0u+iG8lUJEvDJOFKPoBvsOubwIwRgAQY", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BMeyHMXjJpVWF9saehBu7pZLTwdopKZTl5JdhU0/ozZ/sk1paVFE1U8OtuZqM/S/4W/fnkCnUrQ/Xcs7Ddy0hPE=", + "2": 65521, + "3": 1, + "4": 23, + "5": "HA_test", + "254": 1 + }, + { + "1": "BBF47gm4BEBA6LXQluAHjn6P3+MZKrhuMcJligg1xcBM7X++F7GsZFh4hYAhdmD9HHwhtZxH2c85aAzbpikViwI=", + "2": 65521, + "3": 1, + "4": 100, + "5": "", + "254": 2 + } + ], + "0/62/2": 16, + "0/62/3": 2, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEx7IcxeMmlVYX2xp6EG7ulktPB2ikplOXkl2FTT+jNn+yTWlpUUTVTw625moz9L/hb9+eQKdStD9dyzsN3LSE8TcKNQEpARgkAmAwBBSMUxuvFOVkFbJPALb0kMnityi6jzAFFIxTG68U5WQVsk8AtvSQyeK3KLqPGDALQPBVUg+OBUWl1pe/k55ZigAZl3lfBP1Qd5zQP4AUB45mNTzdli8DRCj+h7cIs3JHQQPlUaRvG5xUoBZ+C7Gg2sQY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEEXjuCbgEQEDotdCW4AeOfo/f4xkquG4xwmWKCDXFwEztf74XsaxkWHiFgCF2YP0cfCG1nEfZzzloDNumKRWLAjcKNQEpARgkAmAwBBQD3rx0jOdkiCPt06hxW7Z2jJBPXTAFFAPevHSM52SII+3TqHFbtnaMkE9dGDALQL+L3Zc6En6Ionk6WIz+lM50iwOEzTi9VwyYQRUdtO99T8jRX52+Olh6zcUtWQuYO2XYiH2OZ8lM4guqqnS8U4UY" + ], + "0/62/5": 1, + "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], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 1293, + "1": 1 + }, + { + "0": 1292, + "1": 1 + }, + { + "0": 1296, + "1": 1 + }, + { + "0": 17, + "1": 1 + } + ], + "1/29/1": [3, 29, 47, 144, 145, 152, 153, 156, 157, 159], + "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/47/0": 1, + "1/47/1": 0, + "1/47/2": "Primary Mains Power", + "1/47/5": 0, + "1/47/7": 230000, + "1/47/8": 32000, + "1/47/31": [1], + "1/47/65532": 1, + "1/47/65533": 3, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [0, 1, 2, 5, 7, 8, 31, 65528, 65529, 65531, 65532, 65533], + "1/144/0": 2, + "1/144/1": 3, + "1/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 + } + ] + } + ], + "1/144/3": [], + "1/144/4": null, + "1/144/5": null, + "1/144/6": null, + "1/144/7": null, + "1/144/8": null, + "1/144/9": null, + "1/144/10": null, + "1/144/11": null, + "1/144/12": null, + "1/144/13": null, + "1/144/14": null, + "1/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "1/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "1/144/17": null, + "1/144/18": null, + "1/144/65532": 31, + "1/144/65533": 1, + "1/144/65528": [], + "1/144/65529": [], + "1/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 + ], + "1/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 1000000000000000, + "4": [ + { + "0": 98440650424323, + "1": 98442759724168, + "2": 0, + "3": 0, + "5": 140728898420739, + "6": 98440650424355 + } + ] + }, + "1/145/1": null, + "1/145/2": null, + "1/145/3": null, + "1/145/4": null, + "1/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "1/145/65532": 15, + "1/145/65533": 1, + "1/145/65528": [], + "1/145/65529": [], + "1/145/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "1/152/0": 0, + "1/152/1": false, + "1/152/2": 1, + "1/152/3": 1200000, + "1/152/4": 7600000, + "1/152/5": null, + "1/152/6": null, + "1/152/7": 0, + "1/152/65532": 123, + "1/152/65533": 4, + "1/152/65528": [], + "1/152/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/152/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "1/153/0": 3, + "1/153/1": 1, + "1/153/2": 0, + "1/153/3": null, + "1/153/5": 32000, + "1/153/6": 2000, + "1/153/7": 30000, + "1/153/9": 32000, + "1/153/10": 600, + "1/153/35": null, + "1/153/36": null, + "1/153/37": null, + "1/153/38": null, + "1/153/39": null, + "1/153/64": 2, + "1/153/65": 0, + "1/153/66": 0, + "1/153/65532": 9, + "1/153/65533": 3, + "1/153/65528": [0], + "1/153/65529": [1, 2, 5, 6, 7, 4], + "1/153/65531": [ + 0, 1, 2, 3, 5, 6, 7, 9, 10, 35, 36, 37, 38, 39, 64, 65, 66, 65528, 65529, + 65531, 65532, 65533 + ], + "1/156/65532": 1, + "1/156/65533": 1, + "1/156/65528": [], + "1/156/65529": [], + "1/156/65531": [65528, 65529, 65531, 65532, 65533], + "1/157/0": [ + { + "0": "Manual", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Auto-scheduled", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Solar", + "1": 2, + "2": [ + { + "1": 16386 + } + ] + }, + { + "0": "Auto-scheduled with Solar charging", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16386 + } + ] + } + ], + "1/157/1": 1, + "1/157/65532": 0, + "1/157/65533": 2, + "1/157/65528": [1], + "1/157/65529": [0], + "1/157/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/159/0": [ + { + "0": "No energy management (forecast only)", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Device optimizes (no local or grid control)", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Optimized within building", + "1": 2, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + } + ] + }, + { + "0": "Optimized for grid", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16387 + } + ] + }, + { + "0": "Optimized for grid and building", + "1": 4, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + }, + { + "1": 16387 + } + ] + } + ], + "1/159/1": 3, + "1/159/65532": 0, + "1/159/65533": 2, + "1/159/65528": [1], + "1/159/65529": [0], + "1/159/65531": [0, 1, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/silabs_refrigerator.json b/tests/components/matter/fixtures/nodes/silabs_refrigerator.json new file mode 100644 index 00000000000..e4e04ac6ca1 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_refrigerator.json @@ -0,0 +1,534 @@ +{ + "node_id": 58, + "date_commissioned": "2024-12-23T10:42:11.104085", + "last_interview": "2024-12-23T10:42:11.104098", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1, 2, 3], + "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": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "Refrigerator", + "0/40/4": 32782, + "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/17": true, + "0/40/18": "3F67EB015C2A0D0E", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17039360, + "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, 17, 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/42/65531": [0, 1, 2, 3, 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": 5, + "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/51/0": [ + { + "0": "MyHome36", + "1": true, + "2": null, + "3": null, + "4": "spIfNquw4AU=", + "5": [], + "6": [ + "/U8h7+VkAADWDI9VgtWoMw==", + "/QANuACgAAAAAAD//gBEbQ==", + "/QANuACgAACT8m5dNLdrXA==", + "/oAAAAAAAACwkh82q7DgBQ==" + ], + "7": 4 + } + ], + "0/51/1": 1, + "0/51/2": 141, + "0/51/3": 0, + "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/53/0": 25, + "0/53/1": 4, + "0/53/2": "MyHome36", + "0/53/3": 4660, + "0/53/4": 12054125955590472924, + "0/53/5": "QP0ADbgAoAAA", + "0/53/7": [ + { + "0": 4222415899952472931, + "1": 8, + "2": 24576, + "3": 151026, + "4": 21588, + "5": 3, + "6": -71, + "7": -71, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17459145101989614194, + "1": 3, + "2": 26624, + "3": 485082, + "4": 21597, + "5": 3, + "6": -38, + "7": -39, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8241705229565301122, + "1": 18, + "2": 57344, + "3": 276088, + "4": 22218, + "5": 3, + "6": -52, + "7": -47, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 0, + "1": 3072, + "2": 3, + "3": 17, + "4": 2, + "5": 0, + "6": 0, + "7": 142, + "8": true, + "9": false + }, + { + "0": 0, + "1": 17408, + "2": 17, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 142, + "8": true, + "9": false + }, + { + "0": 4222415899952472931, + "1": 24576, + "2": 24, + "3": 17, + "4": 1, + "5": 3, + "6": 2, + "7": 9, + "8": true, + "9": true + }, + { + "0": 17459145101989614194, + "1": 26624, + "2": 26, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 3, + "8": true, + "9": true + }, + { + "0": 0, + "1": 41984, + "2": 41, + "3": 17, + "4": 1, + "5": 0, + "6": 0, + "7": 25, + "8": true, + "9": false + }, + { + "0": 0, + "1": 43008, + "2": 42, + "3": 17, + "4": 2, + "5": 0, + "6": 0, + "7": 44, + "8": true, + "9": false + }, + { + "0": 0, + "1": 53248, + "2": 52, + "3": 17, + "4": 1, + "5": 0, + "6": 0, + "7": 142, + "8": true, + "9": false + }, + { + "0": 0, + "1": 55296, + "2": 54, + "3": 17, + "4": 1, + "5": 0, + "6": 0, + "7": 34, + "8": true, + "9": false + }, + { + "0": 8241705229565301122, + "1": 57344, + "2": 56, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 18, + "8": true, + "9": true + } + ], + "0/53/9": 574987064, + "0/53/10": 68, + "0/53/11": 103, + "0/53/12": 223, + "0/53/13": 26, + "0/53/59": { + "0": 672, + "1": 143 + }, + "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": 0, + "0/53/65533": 2, + "0/53/65528": [], + "0/53/65529": [], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 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, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQROhgkBwEkCAEwCUEExxLSpAQ5YJUVxH4v83Guzd2imtKrSMm2ADzJvNu3KGxkTF64CkFtfnORTwJmEpVfWDHJCNXRVQz0hJzXCM54nzcKNQEoARgkAgE2AwQCBAEYMAQUTE8wRXsn1uG3FSVnXrmgueY73FYwBRRT9HTfU5Nds+HA8j+/MRP+0pVyIxgwC0Dl506KGNd+m9BX72z6nm68F8SRkuJEvza7BQyg23LqfODl5ZWm8SnVH6GeN2j5TzbBIt31YApS2aNomn6YJ2YGGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyT62Yt4qMI+MorlmQ/Hxh2CpLetznVknlAbhvYAwTexpSxp9GnhR09SrcUhz3mOb0eZa2TylqcnPBhHJ2Ih2RTcKNQEpARgkAmAwBBRT9HTfU5Nds+HA8j+/MRP+0pVyIzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQI/Kc38hQyK7AkT7/pN4hiYW3LoWKT3NA43+ssMJoVpDcaZ989GXBQKIbHKbBEXzUQ1J8wfL7l2pL0Z8Lso9JwgY", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BIrruNo7r0gX6j6lq1dDi5zeK3jxcTavjt2o4adCCSCYtbxOakfb7C3GXqgV4LzulFSinbewmYkdqFBHqm5pxvU=", + "2": 4939, + "3": 2, + "4": 58, + "5": "", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBP2G+CskBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQIrLt7Uq3S9HEe7apdzYSR+j3BLWNXSTLWD4YbrdyYLpm6xqHDV/NPARcIp4skZdtz91WwFBDfuS4jO5aVoER1sY", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEiuu42juvSBfqPqWrV0OLnN4rePFxNq+O3ajhp0IJIJi1vE5qR9vsLcZeqBXgvO6UVKKdt7CZiR2oUEeqbmnG9TcKNQEpARgkAmAwBBTjAjvCZO2QpJyarhRj7T8yYjarAzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQE7hTxTRg92QOxwA1hK3xv8DaxvxL71r6ZHcNRzug9wNnonJ+NC84SFKvKDxwcBxHYqFdIyDiDgwJNTQIBgasmIY" + ], + "0/62/5": 3, + "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], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/65532": 0, + "1/6/65533": 6, + "1/6/65528": [], + "1/6/65529": [0, 1, 2], + "1/6/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 112, + "1": 1 + } + ], + "1/29/1": [3, 6, 29, 82, 87], + "1/29/2": [], + "1/29/3": [2, 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/82/0": [ + { + "0": "Normal", + "1": 0, + "2": [ + { + "1": 0 + } + ] + }, + { + "0": "Rapid Cool", + "1": 1, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Rapid Freeze", + "1": 2, + "2": [ + { + "1": 7 + }, + { + "1": 16385 + }, + { + "1": 0 + } + ] + } + ], + "1/82/1": 0, + "1/82/65532": 1, + "1/82/65533": 2, + "1/82/65528": [1], + "1/82/65529": [0], + "1/82/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/87/0": 1, + "1/87/2": 0, + "1/87/3": 1, + "1/87/65532": 0, + "1/87/65533": 1, + "1/87/65528": [], + "1/87/65529": [], + "1/87/65531": [0, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 113, + "1": 1 + } + ], + "2/29/1": [29, 86], + "2/29/2": [], + "2/29/3": [], + "2/29/4": [ + { + "0": null, + "1": 65, + "2": 1 + } + ], + "2/29/65532": 1, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/86/0": -1800, + "2/86/1": -1800, + "2/86/2": -1500, + "2/86/3": 100, + "2/86/65532": 1, + "2/86/65533": 1, + "2/86/65528": [], + "2/86/65529": [0], + "2/86/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "3/29/0": [ + { + "0": 113, + "1": 1 + } + ], + "3/29/1": [29, 86], + "3/29/2": [], + "3/29/3": [], + "3/29/4": [ + { + "0": null, + "1": 65, + "2": 0 + } + ], + "3/29/65532": 1, + "3/29/65533": 2, + "3/29/65528": [], + "3/29/65529": [], + "3/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "3/86/0": 400, + "3/86/1": 100, + "3/86/2": 400, + "3/86/3": 100, + "3/86/65532": 1, + "3/86/65533": 1, + "3/86/65528": [], + "3/86/65529": [0], + "3/86/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/silabs_water_heater.json b/tests/components/matter/fixtures/nodes/silabs_water_heater.json new file mode 100644 index 00000000000..7b764f3b3f1 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_water_heater.json @@ -0,0 +1,534 @@ +{ + "node_id": 25, + "date_commissioned": "2024-11-21T20:21:44.371473", + "last_interview": "2024-11-21T20:21:44.371503", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 44, 45, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [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": 3 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "Water Heater", + "0/40/4": 32773, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "v1.3-fix-energy-man-app-comp-2d92654525-dirty", + "0/40/15": "", + "0/40/18": "1868F000380F300B", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17039360, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 21, 22, 65528, 65529, 65531, + 65532, 65533 + ], + "0/43/0": "", + "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/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [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": 2, + "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/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/8": [0], + "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, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "MyHome", + "1": true, + "2": null, + "3": null, + "4": "0ln4A+M/qdU=", + "5": [], + "6": [ + "/QANuACgAAAAAAD//gCEAA==", + "/akBUIsgAADu+RflBK+awg==", + "/QANuACgAACOGElK6AMfiw==", + "/oAAAAAAAADQWfgD4z+p1Q==" + ], + "7": 4 + } + ], + "0/51/1": 2, + "0/51/2": 970, + "0/51/8": true, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 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, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRLBgkBwEkCAEwCUEET/Kg7i1M+NQnTtjldQKCfg81STfZkuBWKlnUUolYjkKNUkOEGf/CAMckg3BH/vbbS8wbC17pWG8EvB7D6RSUfDcKNQEoARgkAgE2AwQCBAEYMAQUBAW4lb/V1fEJebN5Z4UTmE5XrEowBRRv4WHQKIysaFy3b/zkFJmrjWlt7hgwC0Cl0ZjooRQMxjnO0liVKSiIwY+sl0S34aMXNR/PAU89ZqTlHJocegee54S4ajdVZsj1LMV6YWQA3GNw61sC79aFGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEERIK+dKrh7jNjamMZKV9Ir5gyKBMyce881JnXvjjdrJI3B3OjB6DbhqXvpgk96gZam85WxwGWrRlJEjVl2YQu6DcKNQEpARgkAmAwBBRv4WHQKIysaFy3b/zkFJmrjWlt7jAFFLsR9bzzqpjG9Z5aOkD8b8KMO7AQGDALQAK1q01Umn5ER39/84eai6HfZDKTNsGsuLyhIfpQa6XZQXenGbFDeenDLy8zv5NOLtwu8b44Zv0IrqONItfZqOMY", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BNI+NL43G+mbJrQUfyNKwd2SHwAPJT3lgk8Ru5z0mzaXqXtfF8C4nYRSBypr7WVg2dx5dzDPTQQfiwGZQhav3nY=", + "2": 4939, + "3": 2, + "4": 44, + "5": "HA_test", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "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" + ], + "0/62/5": 3, + "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], + "2/3/0": 0, + "2/3/1": 0, + "2/3/65532": 0, + "2/3/65533": 5, + "2/3/65528": [], + "2/3/65529": [0], + "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 1293, + "1": 1 + }, + { + "0": 1296, + "1": 1 + }, + { + "0": 1295, + "1": 1 + } + ], + "2/29/1": [3, 29, 144, 145, 148, 152, 156, 158, 159], + "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": 0, + "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": [], + "2/144/4": 230000, + "2/144/5": 100, + "2/144/6": null, + "2/144/7": null, + "2/144/8": 23000, + "2/144/9": null, + "2/144/10": null, + "2/144/11": null, + "2/144/12": null, + "2/144/13": null, + "2/144/14": 50, + "2/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/17": null, + "2/144/18": null, + "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": 0 + } + ] + }, + "2/145/1": null, + "2/145/2": null, + "2/145/3": null, + "2/145/4": null, + "2/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "2/145/65532": 15, + "2/145/65533": 1, + "2/145/65528": [], + "2/145/65529": [], + "2/145/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "2/148/0": 1, + "2/148/1": 0, + "2/148/2": 200, + "2/148/3": 4000000, + "2/148/4": 40, + "2/148/5": 0, + "2/148/65532": 3, + "2/148/65533": 2, + "2/148/65528": [], + "2/148/65529": [0, 1], + "2/148/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "2/152/0": 2, + "2/152/1": false, + "2/152/2": 1, + "2/152/3": 1200000, + "2/152/4": 7600000, + "2/152/5": null, + "2/152/6": null, + "2/152/7": 0, + "2/152/65532": 123, + "2/152/65533": 4, + "2/152/65528": [], + "2/152/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "2/152/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "2/156/65532": 1, + "2/156/65533": 1, + "2/156/65528": [], + "2/156/65529": [], + "2/156/65531": [65528, 65529, 65531, 65532, 65533], + "2/158/0": [ + { + "0": "Off", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Manual", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Timed", + "1": 2, + "2": [ + { + "1": 16386 + } + ] + } + ], + "2/158/1": 1, + "2/158/65532": 0, + "2/158/65533": 1, + "2/158/65528": [1], + "2/158/65529": [0], + "2/158/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/159/0": [ + { + "0": "No energy management (forecast only)", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Device optimizes (no local or grid control)", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Optimized within building", + "1": 2, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + } + ] + }, + { + "0": "Optimized for grid", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16387 + } + ] + }, + { + "0": "Optimized for grid and building", + "1": 4, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + }, + { + "1": 16387 + } + ] + } + ], + "2/159/1": 0, + "2/159/65532": 0, + "2/159/65533": 2, + "2/159/65528": [1], + "2/159/65529": [0], + "2/159/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/513/0": 5000, + "2/513/3": 4000, + "2/513/4": 6500, + "2/513/18": 6500, + "2/513/21": 4000, + "2/513/22": 6500, + "2/513/27": 2, + "2/513/28": 4, + "2/513/65532": 1, + "2/513/65533": 7, + "2/513/65528": [], + "2/513/65529": [0], + "2/513/65531": [0, 27, 28, 65528, 65529, 65531, 65532, 65533] + }, + "2/513/0": 5000, + "2/513/3": 4000, + "2/513/4": 6500, + "2/513/18": 6500, + "2/513/21": 4000, + "2/513/22": 6500, + "2/513/27": 2, + "2/513/28": 4, + "2/513/65532": 1, + "2/513/65533": 7, + "2/513/65528": [], + "2/513/65529": [0], + "2/513/65531": [0, 27, 28, 65528, 65529, 65531, 65532, 65533], + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/solar_power.json b/tests/components/matter/fixtures/nodes/solar_power.json new file mode 100644 index 00000000000..4b7c4af5b43 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/solar_power.json @@ -0,0 +1,334 @@ +{ + "node_id": 1, + "date_commissioned": "2025-04-26T13:59:01.038380", + "last_interview": "2025-04-26T13:59:01.038432", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 48, 49, 50, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/40/0": 19, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "SolarPower", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/18": "693B7500B6407671", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65532": 0, + "0/40/65533": 5, + "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, + 24, 65532, 65533, 65528, 65529, 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/2": 0, + "0/49/3": 0, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkLqrfVa", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBqA=="], + "6": [ + "KgEOCgKzOZDZEOyJQB4D1w==", + "KgEOCgKzOZC/O1Ew1WvS4A==", + "/oAAAAAAAADml3Ozl7GZug==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 37, + "0/51/3": 0, + "0/51/4": 0, + "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, 65532, 65533, 65528, 65529, 65531 + ], + "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, 65532, 65533, 65528, 65529, 65531], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRARgkBwEkCAEwCUEEr/7Cv/8E0M1xlXrJsFennQiNL1eZk89SD0aQBqwBRM75xTNqokuHgKtObf8DW464ZlD9Pq++SURJv0WmvN2xPTcKNQEoARgkAgE2AwQCBAEYMAQUlHJKPttZOtq8Ane2vBQeAtYL97YwBRRqGquZZYwbDAaOinVVrS9sWTozoBgwC0AlmKJvIDcTdn2P6Bbc8PSdI08AqnQJRxpiogLNN1M05l0HJgGpE8G8h2W9yWuSvbeVulclJ+TLvzjafmQLWFPVGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyvr+z4yBxEDoiyCFg+i408LqC3j0UMvTszBv1051g2EMrAzBkj+0RZFsSl3eQ3D2c7mTcH6GERtlk4BqGvC1qDcKNQEpARgkAmAwBBRqGquZZYwbDAaOinVVrS9sWTozoDAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQCIuoikQZU9LkDKw7dcTVVXBDlTyBol3w070PIIw8BbaQD5qCeIv/3cI5/X5sAYTmemRq0ZPMjAw1dsN+wodzm8Y", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BIPshBqc9a7nNK00eRrviEzHfe/cfATY9VngqKv17+uAUpy3XujhZBjkAQyhYAaSKxVzSfVttY4FVQkpXIHZFlA=", + "2": 4939, + "3": 2, + "4": 1, + "5": "Maison", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEg+yEGpz1ruc0rTR5Gu+ITMd979x8BNj1WeCoq/Xv64BSnLde6OFkGOQBDKFgBpIrFXNJ9W21jgVVCSlcgdkWUDcKNQEpARgkAmAwBBRPkvAMbwLEubfgETM7L7icezGlHzAFFE+S8AxvAsS5t+ARMzsvuJx7MaUfGDALQIKyooBXllxj1uo4Zn4CBbZqECNdO3wwzlhl7ZEygrWa04gBa5rVqgg+JahrvXD6HPHu4XldWIULtqTCPPIm4OsY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 2, + "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, 65532, 65533, 65528, 65529, 65531], + "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, 65532, 65533, 65528, 65529, 65531], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 1296, + "1": 1 + }, + { + "0": 17, + "1": 1 + }, + { + "0": 23, + "1": 1 + } + ], + "1/29/1": [3, 29, 47, 144, 145, 156], + "1/29/2": [], + "1/29/3": [], + "1/29/4": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "1/47/0": 0, + "1/47/1": 0, + "1/47/2": "", + "1/47/31": [], + "1/47/65532": 1, + "1/47/65533": 1, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [0, 1, 2, 31, 65532, 65533, 65528, 65529, 65531], + "1/144/0": 1, + "1/144/1": 3, + "1/144/2": [ + { + "0": 5, + "1": true, + "2": 0, + "3": 5000000, + "4": [ + { + "0": 0, + "1": 5000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": 0, + "3": 24000, + "4": [ + { + "0": 0, + "1": 24000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": 0, + "3": 300000, + "4": [ + { + "0": 0, + "1": 300000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "1/144/4": 234899, + "1/144/5": -3620, + "1/144/8": -850000, + "1/144/65532": 1, + "1/144/65533": 1, + "1/144/65528": [], + "1/144/65529": [], + "1/144/65531": [0, 1, 2, 4, 5, 8, 65532, 65533, 65528, 65529, 65531], + "1/145/0": null, + "1/145/2": { + "0": 42279000 + }, + "1/145/65532": 3, + "1/145/65533": 1, + "1/145/65528": [], + "1/145/65529": [], + "1/145/65531": [0, 65532, 65533, 65528, 65529, 65531], + "1/156/65532": 1, + "1/156/65533": 1, + "1/156/65528": [], + "1/156/65529": [], + "1/156/65531": [65532, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index c8de905d03f..feca62ffa31 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -383,6 +383,197 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_charging_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': 'Charging status', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_charging_status', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingStatusSensor-153-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'evse Charging status', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_plug_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvsePlugStateSensor-153-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'evse Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.evse_supply_charging_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': 'Supply charging state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_supply_charging_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseSupplyStateSensor-153-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'evse Supply charging state', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_supply_charging_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_water_heater][binary_sensor.water_heater_boost_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.water_heater_boost_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': 'Boost state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boost_state', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementBoostStateSensor-148-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_water_heater][binary_sensor.water_heater_boost_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Heater Boost state', + }), + 'context': , + 'entity_id': 'binary_sensor.water_heater_boost_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[smoke_detector][binary_sensor.smoke_sensor_battery_alert-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 448136eeed2..fe8ddb11aa9 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -573,6 +573,194 @@ 'state': 'unknown', }) # --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_pause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.mock_laundrydryer_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-0000000000000008-MatterNodeDevice-1-OperationalStatePauseButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_pause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Laundrydryer Pause', + }), + 'context': , + 'entity_id': 'button.mock_laundrydryer_pause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_resume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.mock_laundrydryer_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-0000000000000008-MatterNodeDevice-1-OperationalStateResumeButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_resume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Laundrydryer Resume', + }), + 'context': , + 'entity_id': 'button.mock_laundrydryer_resume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.mock_laundrydryer_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-0000000000000008-MatterNodeDevice-1-OperationalStateStartButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Laundrydryer Start', + }), + 'context': , + 'entity_id': 'button.mock_laundrydryer_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.mock_laundrydryer_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-0000000000000008-MatterNodeDevice-1-OperationalStateStopButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[laundry_dryer][button.mock_laundrydryer_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Laundrydryer Stop', + }), + 'context': , + 'entity_id': 'button.mock_laundrydryer_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[microwave_oven][button.microwave_oven_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1618,6 +1806,54 @@ 'state': 'unknown', }) # --- +# name: test_buttons[silabs_refrigerator][button.refrigerator_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.refrigerator_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-000000000000003A-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_refrigerator][button.refrigerator_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Refrigerator Identify', + }), + 'context': , + 'entity_id': 'button.refrigerator_identify', + '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_event.ambr b/tests/components/matter/snapshots/test_event.ambr index b0ddfaed8bf..153f5751f14 100644 --- a/tests/components/matter/snapshots/test_event.ambr +++ b/tests/components/matter/snapshots/test_event.ambr @@ -132,6 +132,8 @@ 'event_types': list([ 'multi_press_1', 'multi_press_2', + 'multi_press_3', + 'multi_press_4', 'long_press', 'long_release', ]), @@ -172,6 +174,8 @@ 'event_types': list([ 'multi_press_1', 'multi_press_2', + 'multi_press_3', + 'multi_press_4', 'long_press', 'long_release', ]), diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index dc35f6f2a69..eb0a12bfc4d 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -401,8 +401,8 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 25, - 'min': -25, + 'max': 50, + 'min': -50, 'mode': , 'step': 0.5, }), @@ -439,8 +439,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Eve Thermo Temperature offset', - 'max': 25, - 'min': -25, + 'max': 50, + 'min': -50, 'mode': , 'step': 0.5, 'unit_of_measurement': , @@ -483,7 +483,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Altitude above Sea Level', + 'original_name': 'Altitude above sea level', 'platform': 'matter', 'previous_unique_id': None, 'supported_features': 0, @@ -496,7 +496,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', - 'friendly_name': 'Eve Weather Altitude above Sea Level', + 'friendly_name': 'Eve Weather Altitude above sea level', 'max': 9000, 'min': 0, 'mode': , @@ -567,6 +567,62 @@ 'state': '255', }) # --- +# name: test_numbers[mounted_dimmable_load_control_fixture][number.mock_mounted_dimmable_load_control_on_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_mounted_dimmable_load_control_on_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': 'On level', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'on_level', + 'unique_id': '00000000000004D2-000000000000000E-MatterNodeDevice-1-on_level-8-17', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[mounted_dimmable_load_control_fixture][number.mock_mounted_dimmable_load_control_on_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Mounted dimmable load control On level', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_mounted_dimmable_load_control_on_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_numbers[multi_endpoint_light][number.inovelli_off_transition_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1644,3 +1700,59 @@ 'state': '0.0', }) # --- +# name: test_numbers[pump][number.mock_pump_on_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_pump_on_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': 'On level', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'on_level', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-on_level-8-17', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[pump][number.mock_pump_on_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Pump On level', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_pump_on_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 772ee297e13..713f0b25f45 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -117,6 +117,64 @@ 'state': 'previous', }) # --- +# name: test_selects[cooktop][select.mock_cooktop_temperature_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_cooktop_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-0000000000000003-MatterNodeDevice-2-TemperatureControlSelectedTemperatureLevel-86-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[cooktop][select.mock_cooktop_temperature_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Cooktop Temperature level', + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'context': , + 'entity_id': 'select.mock_cooktop_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Medium', + }) +# --- # name: test_selects[dimmable_light][select.mock_dimmable_light_led_color-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -729,6 +787,124 @@ 'state': 'previous', }) # --- +# name: test_selects[laundry_dryer][select.mock_laundrydryer_temperature_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_laundrydryer_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-0000000000000008-MatterNodeDevice-1-TemperatureControlSelectedTemperatureLevel-86-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[laundry_dryer][select.mock_laundrydryer_temperature_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Laundrydryer Temperature level', + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'context': , + 'entity_id': 'select.mock_laundrydryer_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Low', + }) +# --- +# name: test_selects[mounted_dimmable_load_control_fixture][select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup', + 'has_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-on behavior on startup', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'startup_on_off', + 'unique_id': '00000000000004D2-000000000000000E-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[mounted_dimmable_load_control_fixture][select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Mounted dimmable load control Power-on behavior on startup', + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'context': , + 'entity_id': 'select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_selects[multi_endpoint_light][select.inovelli_dimming_edge-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1543,6 +1719,256 @@ 'state': 'previous', }) # --- +# name: test_selects[oven][select.mock_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Bake', + 'Convection', + 'Grill', + 'Roast', + 'Clean', + 'Convection Bake', + 'Convection Roast', + 'Warming', + 'Proofing', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_oven_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-0000000000000002-MatterNodeDevice-2-MatterOvenMode-73-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[oven][select.mock_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Oven Mode', + 'options': list([ + 'Bake', + 'Convection', + 'Grill', + 'Roast', + 'Clean', + 'Convection Bake', + 'Convection Roast', + 'Warming', + 'Proofing', + ]), + }), + 'context': , + 'entity_id': 'select.mock_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Bake', + }) +# --- +# name: test_selects[oven][select.mock_oven_temperature_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mock_oven_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-0000000000000002-MatterNodeDevice-4-TemperatureControlSelectedTemperatureLevel-86-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[oven][select.mock_oven_temperature_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Oven Temperature level', + 'options': list([ + 'Low', + 'Medium', + 'High', + ]), + }), + 'context': , + 'entity_id': 'select.mock_oven_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Low', + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_energy_management_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.evse_energy_management_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': 'Energy management mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_energy_management_mode', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-MatterDeviceEnergyManagementMode-159-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_energy_management_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Energy management mode', + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'context': , + 'entity_id': 'select.evse_energy_management_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Optimized for grid', + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Manual', + 'Auto-scheduled', + 'Solar', + 'Auto-scheduled with Solar charging', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.evse_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-0000000000000017-MatterNodeDevice-1-MatterEnergyEvseMode-157-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Mode', + 'options': list([ + 'Manual', + 'Auto-scheduled', + 'Solar', + 'Auto-scheduled with Solar charging', + ]), + }), + 'context': , + 'entity_id': 'select.evse_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Auto-scheduled', + }) +# --- # name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1717,6 +2143,126 @@ 'state': 'Colors', }) # --- +# name: test_selects[silabs_refrigerator][select.refrigerator_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Normal', + 'Rapid Cool', + 'Rapid Freeze', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.refrigerator_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-000000000000003A-MatterNodeDevice-1-MatterRefrigeratorAndTemperatureControlledCabinetMode-82-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_refrigerator][select.refrigerator_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Mode', + 'options': list([ + 'Normal', + 'Rapid Cool', + 'Rapid Freeze', + ]), + }), + 'context': , + 'entity_id': 'select.refrigerator_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Normal', + }) +# --- +# name: test_selects[silabs_water_heater][select.water_heater_energy_management_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.water_heater_energy_management_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': 'Energy management mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_energy_management_mode', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-MatterDeviceEnergyManagementMode-159-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_water_heater][select.water_heater_energy_management_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Heater Energy management mode', + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'context': , + 'entity_id': 'select.water_heater_energy_management_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'No energy management (forecast only)', + }) +# --- # name: test_selects[switch_unit][select.mock_switchunit_power_on_behavior_on_startup-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 9caa84bbf96..454e6e67a4c 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -686,7 +686,7 @@ 'state': '20.0', }) # --- -# name: test_sensors[air_purifier][sensor.air_purifier_vocs-entry] +# name: test_sensors[air_purifier][sensor.air_purifier_volatile_organic_compounds_parts-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -701,7 +701,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.air_purifier_vocs', + 'entity_id': 'sensor.air_purifier_volatile_organic_compounds_parts', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -713,7 +713,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'VOCs', + 'original_name': 'Volatile organic compounds parts', 'platform': 'matter', 'previous_unique_id': None, 'supported_features': 0, @@ -722,16 +722,16 @@ 'unit_of_measurement': 'ppm', }) # --- -# name: test_sensors[air_purifier][sensor.air_purifier_vocs-state] +# name: test_sensors[air_purifier][sensor.air_purifier_volatile_organic_compounds_parts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'volatile_organic_compounds_parts', - 'friendly_name': 'Air Purifier VOCs', + 'friendly_name': 'Air Purifier Volatile organic compounds parts', 'state_class': , 'unit_of_measurement': 'ppm', }), 'context': , - 'entity_id': 'sensor.air_purifier_vocs', + 'entity_id': 'sensor.air_purifier_volatile_organic_compounds_parts', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1167,7 +1167,7 @@ 'state': '20.08', }) # --- -# name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_vocs-entry] +# name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_volatile_organic_compounds_parts-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1182,7 +1182,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_vocs', + 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_volatile_organic_compounds_parts', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1194,7 +1194,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'VOCs', + 'original_name': 'Volatile organic compounds parts', 'platform': 'matter', 'previous_unique_id': None, 'supported_features': 0, @@ -1203,22 +1203,74 @@ 'unit_of_measurement': 'ppm', }) # --- -# name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_vocs-state] +# name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_volatile_organic_compounds_parts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'volatile_organic_compounds_parts', - 'friendly_name': 'lightfi-aq1-air-quality-sensor VOCs', + 'friendly_name': 'lightfi-aq1-air-quality-sensor Volatile organic compounds parts', 'state_class': , 'unit_of_measurement': 'ppm', }), 'context': , - 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_vocs', + 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_volatile_organic_compounds_parts', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '189.0', }) # --- +# name: test_sensors[cooktop][sensor.mock_cooktop_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_cooktop_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': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-2-TemperatureSensor-1026-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[cooktop][sensor.mock_cooktop_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Cooktop Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_cooktop_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '180.0', + }) +# --- # name: test_sensors[eve_contact_sensor][sensor.eve_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2354,6 +2406,126 @@ 'state': '0.0', }) # --- +# name: test_sensors[laundry_dryer][sensor.mock_laundrydryer_current_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_laundrydryer_current_phase', + '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', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-OperationalStateCurrentPhase-96-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[laundry_dryer][sensor.mock_laundrydryer_current_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Laundrydryer Current phase', + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_laundrydryer_current_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pre-soak', + }) +# --- +# name: test_sensors[laundry_dryer][sensor.mock_laundrydryer_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_laundrydryer_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-0000000000000008-MatterNodeDevice-1-OperationalState-96-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[laundry_dryer][sensor.mock_laundrydryer_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Laundrydryer Operational state', + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_laundrydryer_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- # name: test_sensors[light_sensor][sensor.mock_light_sensor_illuminance-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2467,6 +2639,228 @@ 'state': 'stopped', }) # --- +# name: test_sensors[oven][sensor.mock_oven_current_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pre-heating', + 'pre-heated', + 'cooling down', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_oven_current_phase', + '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', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-2-OvenCavityOperationalStateCurrentPhase-72-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_current_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Oven Current phase', + 'options': list([ + 'pre-heating', + 'pre-heated', + 'cooling down', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_oven_current_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pre-heating', + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'error', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_oven_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-0000000000000002-MatterNodeDevice-2-OvenCavityOperationalState-72-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Oven Operational state', + 'options': list([ + 'stopped', + 'running', + 'error', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_oven_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_temperature_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_oven_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': 'Temperature (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-2-TemperatureSensor-1026-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_temperature_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Oven Temperature (2)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_oven_temperature_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.55', + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_temperature_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_oven_temperature_4', + '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 (4)', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-4-TemperatureSensor-1026-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[oven][sensor.mock_oven_temperature_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Oven Temperature (4)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_oven_temperature_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[pressure_sensor][sensor.mock_pressure_sensor_pressure-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2519,6 +2913,161 @@ 'state': '0.0', }) # --- +# name: test_sensors[pump][sensor.mock_pump_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_pump_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': 'Flow', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flow', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-FlowSensor-1028-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Pump Flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_pump_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_pump_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': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-PressureSensor-1027-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Mock Pump Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_pump_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_pump_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': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-TemperatureSensor-1026-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[pump][sensor.mock_pump_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Pump Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_pump_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- # name: test_sensors[room_airconditioner][sensor.room_airconditioner_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2866,6 +3415,323 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_evse_charging][sensor.evse_circuit_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_circuit_capacity', + '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': 'Circuit capacity', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_circuit_capacity', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseCircuitCapacity-153-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_circuit_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Circuit capacity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_circuit_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'meter_failure', + 'over_voltage', + 'under_voltage', + 'over_current', + 'contact_wet_failure', + 'contact_dry_failure', + 'power_loss', + 'power_quality', + 'pilot_short_circuit', + 'emergency_stop', + 'ev_disconnected', + 'wrong_power_supply', + 'live_neutral_swap', + 'over_temperature', + 'other', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_fault_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': 'Fault state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_fault_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseFaultState-153-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'evse Fault state', + 'options': list([ + 'no_error', + 'meter_failure', + 'over_voltage', + 'under_voltage', + 'over_current', + 'contact_wet_failure', + 'contact_dry_failure', + 'power_loss', + 'power_quality', + 'pilot_short_circuit', + 'emergency_stop', + 'ev_disconnected', + 'wrong_power_supply', + 'live_neutral_swap', + 'over_temperature', + 'other', + ]), + }), + 'context': , + 'entity_id': 'sensor.evse_fault_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_max_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_max_charge_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': 'Max charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_max_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMaximumChargeCurrent-153-7', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_max_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Max charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_max_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_min_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_min_charge_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': 'Min charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_min_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMinimumChargeCurrent-153-6', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_min_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Min charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_min_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_user_max_charge_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': 'User max charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_user_max_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseUserMaximumChargeCurrent-153-9', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse User max charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_user_max_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.0', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3218,6 +4084,341 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_heater_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-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Water Heater Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_heater_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_hot_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_heater_hot_water_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': 'Hot water level', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tank_percentage', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementTankPercentage-148-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_hot_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Heater Hot water level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.water_heater_hot_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_heater_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-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementWatt-144-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Water Heater Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_heater_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.0', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_required_heating_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_heater_required_heating_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': 'Required heating energy', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_heat_required', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementEstimatedHeatRequired-148-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_required_heating_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Water Heater Required heating energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_heater_required_heating_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_tank_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_heater_tank_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': 'Tank volume', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tank_volume', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementTankVolume-148-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_tank_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Water Heater Tank volume', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_heater_tank_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_heater_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-0000000000000019-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Water Heater Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_heater_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- # name: test_sensors[smoke_detector][sensor.smoke_sensor_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3372,6 +4573,238 @@ 'state': '0.000', }) # --- +# name: test_sensors[solar_power][sensor.solarpower_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solarpower_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-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SolarPower Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarpower_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-3.62', + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_energy_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solarpower_energy_exported', + '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 exported', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-ElectricalEnergyMeasurementCumulativeEnergyExported-145-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_energy_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarPower Energy exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarpower_energy_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.279', + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solarpower_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-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementWatt-144-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarPower Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarpower_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-850.0', + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solarpower_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-0000000000000001-MatterNodeDevice-1-ElectricalPowerMeasurementVoltage-144-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_power][sensor.solarpower_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SolarPower Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarpower_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '234.899', + }) +# --- # name: test_sensors[temperature_sensor][sensor.mock_temperature_sensor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index ebf43117846..08a3e0290c8 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -1,4 +1,100 @@ # serializer version: 1 +# name: test_switches[cooktop][switch.mock_cooktop_power_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_cooktop_power_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 (1)', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[cooktop][switch.mock_cooktop_power_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Cooktop Power (1)', + }), + 'context': , + 'entity_id': 'switch.mock_cooktop_power_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[cooktop][switch.mock_cooktop_power_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_cooktop_power_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 (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-2-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[cooktop][switch.mock_cooktop_power_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Cooktop Power (2)', + }), + 'context': , + 'entity_id': 'switch.mock_cooktop_power_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[door_lock][switch.mock_door_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -238,6 +334,102 @@ 'state': 'off', }) # --- +# name: test_switches[laundry_dryer][switch.mock_laundrydryer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_laundrydryer_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': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[laundry_dryer][switch.mock_laundrydryer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Laundrydryer Power', + }), + 'context': , + 'entity_id': 'switch.mock_laundrydryer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[mounted_dimmable_load_control_fixture][switch.mock_mounted_dimmable_load_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_mounted_dimmable_load_control', + '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': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000000E-MatterNodeDevice-1-MatterSwitch-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[mounted_dimmable_load_control_fixture][switch.mock_mounted_dimmable_load_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Mock Mounted dimmable load control', + }), + 'context': , + 'entity_id': 'switch.mock_mounted_dimmable_load_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_switches[on_off_plugin_unit][switch.mock_onoffpluginunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -286,6 +478,150 @@ 'state': 'off', }) # --- +# name: test_switches[oven][switch.mock_oven_power_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_oven_power_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 (3)', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-3-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[oven][switch.mock_oven_power_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Oven Power (3)', + }), + 'context': , + 'entity_id': 'switch.mock_oven_power_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[oven][switch.mock_oven_power_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_oven_power_4', + '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 (4)', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-4-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[oven][switch.mock_oven_power_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Oven Power (4)', + }), + 'context': , + 'entity_id': 'switch.mock_oven_power_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[pump][switch.mock_pump_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_pump_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': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[pump][switch.mock_pump_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Pump Power', + }), + 'context': , + 'entity_id': 'switch.mock_pump_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[room_airconditioner][switch.room_airconditioner_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -334,6 +670,101 @@ 'state': 'off', }) # --- +# name: test_switches[silabs_evse_charging][switch.evse_enable_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.evse_enable_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': 'Enable charging', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_charging_switch', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingSwitch-153-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[silabs_evse_charging][switch.evse_enable_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Enable charging', + }), + 'context': , + 'entity_id': 'switch.evse_enable_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[silabs_refrigerator][switch.refrigerator_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.refrigerator_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': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00000000000004D2-000000000000003A-MatterNodeDevice-1-MatterPowerToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[silabs_refrigerator][switch.refrigerator_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Refrigerator Power', + }), + 'context': , + 'entity_id': 'switch.refrigerator_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[switch_unit][switch.mock_switchunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_water_heater.ambr b/tests/components/matter/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..fcf9a7665fd --- /dev/null +++ b/tests/components/matter/snapshots/test_water_heater.ambr @@ -0,0 +1,69 @@ +# serializer version: 1 +# name: test_water_heaters[silabs_water_heater][water_heater.water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 65, + 'min_temp': 40, + 'operation_list': list([ + 'eco', + 'high_demand', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.water_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': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-MatterWaterHeater-513-18', + 'unit_of_measurement': None, + }) +# --- +# name: test_water_heaters[silabs_water_heater][water_heater.water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 50, + 'friendly_name': 'Water Heater', + 'max_temp': 65, + 'min_temp': 40, + 'operation_list': list([ + 'eco', + 'high_demand', + 'off', + ]), + 'operation_mode': 'eco', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 65, + }), + 'context': , + 'entity_id': 'water_heater.water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'eco', + }) +# --- diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index cddee975ac8..bea9c1ad237 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -6,7 +6,7 @@ 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 +from syrupy.assertion import SnapshotAssertion from homeassistant.components.matter.binary_sensor import ( DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS, @@ -147,3 +147,73 @@ async def test_optional_sensor_from_featuremap( ) state = hass.states.get(entity_id) assert state is None + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + # Test StateEnum value with binary_sensor.evse_charging_status + entity_id = "binary_sensor.evse_charging_status" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to PluggedInDemand state + set_node_attribute(matter_node, 1, 153, 0, 2) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/0", 2) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + # Test StateEnum value with binary_sensor.evse_plug + entity_id = "binary_sensor.evse_plug" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to NotPluggedIn state + set_node_attribute(matter_node, 1, 153, 0, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/0", 0) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + # Test SupplyStateEnum value with binary_sensor.evse_supply_charging + entity_id = "binary_sensor.evse_supply_charging_state" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to Disabled state + set_node_attribute(matter_node, 1, 153, 1, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/1", 0) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water heater sensor.""" + # BoostState + state = hass.states.get("binary_sensor.water_heater_boost_state") + assert state + assert state.state == "off" + + set_node_attribute(matter_node, 2, 148, 5, 1) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.water_heater_boost_state") + assert state + assert state.state == "on" diff --git a/tests/components/matter/test_button.py b/tests/components/matter/test_button.py index cbf62dd80c7..2af2d40cb74 100644 --- a/tests/components/matter/test_button.py +++ b/tests/components/matter/test_button.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 037ec4e7626..7761d5d27da 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -6,7 +6,7 @@ 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 +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ClimateEntityFeature, HVACAction, HVACMode from homeassistant.const import Platform diff --git a/tests/components/matter/test_cover.py b/tests/components/matter/test_cover.py index 224aabd9082..cdf7f6300be 100644 --- a/tests/components/matter/test_cover.py +++ b/tests/components/matter/test_cover.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import CoverEntityFeature, CoverState from homeassistant.const import Platform diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py index f3a318c4e8b..8098d4dd639 100644 --- a/tests/components/matter/test_event.py +++ b/tests/components/matter/test_event.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock from matter_server.client.models.node import MatterNode from matter_server.common.models import EventType, MatterNodeEvent import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES from homeassistant.const import Platform @@ -36,7 +36,7 @@ async def test_generic_switch_node( assert state assert state.state == "unknown" assert state.name == "Mock Generic Switch Button" - # check event_types from featuremap 30 + # check event_types from featuremap 14 (0b1110) assert state.attributes[ATTR_EVENT_TYPES] == [ "initial_press", "short_release", @@ -76,7 +76,7 @@ async def test_generic_switch_multi_node( assert state_button_1.state == "unknown" # name should be 'DeviceName Button (1)' due to the label set to just '1' assert state_button_1.name == "Mock Generic Switch Button (1)" - # check event_types from featuremap 14 + # check event_types from featuremap 30 (0b11110) and MultiPressMax unset (default 2) assert state_button_1.attributes[ATTR_EVENT_TYPES] == [ "multi_press_1", "multi_press_2", @@ -84,11 +84,20 @@ async def test_generic_switch_multi_node( "long_release", ] # check button 2 - state_button_1 = hass.states.get("event.mock_generic_switch_fancy_button") - assert state_button_1 - assert state_button_1.state == "unknown" + state_button_2 = hass.states.get("event.mock_generic_switch_fancy_button") + assert state_button_2 + assert state_button_2.state == "unknown" # name should be 'DeviceName Fancy Button' due to the label set to 'Fancy Button' - assert state_button_1.name == "Mock Generic Switch Fancy Button" + assert state_button_2.name == "Mock Generic Switch Fancy Button" + # check event_types from featuremap 30 (0b11110) and MultiPressMax 4 + assert state_button_2.attributes[ATTR_EVENT_TYPES] == [ + "multi_press_1", + "multi_press_2", + "multi_press_3", + "multi_press_4", + "long_press", + "long_release", + ] # trigger firing a multi press event await trigger_subscription_callback( diff --git a/tests/components/matter/test_fan.py b/tests/components/matter/test_fan.py index 6ed95b0ecc2..6c3acd1978d 100644 --- a/tests/components/matter/test_fan.py +++ b/tests/components/matter/test_fan.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, call from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ( ATTR_DIRECTION, diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index c49b47c9106..b600ededa6e 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ColorMode from homeassistant.const import Platform diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index bb03b296fc6..ab3995e6771 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ATTR_CODE, STATE_UNKNOWN, Platform diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 2a4eea1c324..c94b92dbc46 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -7,7 +7,7 @@ from matter_server.common import custom_clusters from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 2403b4b1623..71999873135 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -6,7 +6,7 @@ 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 +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 251aab73e3b..868c73a1dff 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant @@ -399,3 +399,115 @@ async def test_list_sensor( state = hass.states.get("sensor.laundrywasher_current_phase") assert state assert state.state == "rinse" + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + # EnergyEvseFaultState + state = hass.states.get("sensor.evse_fault_state") + assert state + assert state.state == "no_error" + + set_node_attribute(matter_node, 1, 153, 2, 4) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_fault_state") + assert state + assert state.state == "over_current" + + # EnergyEvseCircuitCapacity + state = hass.states.get("sensor.evse_circuit_capacity") + assert state + assert state.state == "32.0" + + set_node_attribute(matter_node, 1, 153, 5, 63000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_circuit_capacity") + assert state + assert state.state == "63.0" + + # EnergyEvseMinimumChargeCurrent + state = hass.states.get("sensor.evse_min_charge_current") + assert state + assert state.state == "2.0" + + set_node_attribute(matter_node, 1, 153, 6, 5000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_min_charge_current") + assert state + assert state.state == "5.0" + + # EnergyEvseMaximumChargeCurrent + state = hass.states.get("sensor.evse_max_charge_current") + assert state + assert state.state == "30.0" + + set_node_attribute(matter_node, 1, 153, 7, 20000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_max_charge_current") + assert state + assert state.state == "20.0" + + # EnergyEvseUserMaximumChargeCurrent + state = hass.states.get("sensor.evse_user_max_charge_current") + assert state + assert state.state == "32.0" + + set_node_attribute(matter_node, 1, 153, 9, 63000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_user_max_charge_current") + assert state + assert state.state == "63.0" + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water heater sensor.""" + # TankVolume + state = hass.states.get("sensor.water_heater_tank_volume") + assert state + assert state.state == "200" + + set_node_attribute(matter_node, 2, 148, 2, 100) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.water_heater_tank_volume") + assert state + assert state.state == "100" + + # EstimatedHeatRequired + state = hass.states.get("sensor.water_heater_required_heating_energy") + assert state + assert state.state == "4.0" + + set_node_attribute(matter_node, 2, 148, 3, 1000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.water_heater_required_heating_energy") + assert state + assert state.state == "1.0" + + # TankPercentage + state = hass.states.get("sensor.water_heater_hot_water_level") + assert state + assert state.state == "40" + + set_node_attribute(matter_node, 2, 148, 4, 50) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.water_heater_hot_water_level") + assert state + assert state.state == "50" diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index e82848fcc3a..ecb65e625d9 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -3,11 +3,12 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -188,3 +189,46 @@ async def test_matter_exception_on_command( }, blocking=True, ) + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + state = hass.states.get("switch.evse_enable_charging") + assert state + assert state.state == "on" + # test switch service + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.evse_enable_charging"}, + 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.EnergyEvse.Commands.Disable(), + timed_request_timeout_ms=3000, + ) + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.evse_enable_charging"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.EnergyEvse.Commands.EnableCharging( + chargingEnabledUntil=NullValue, + minimumChargeCurrent=0, + maximumChargeCurrent=0, + ), + timed_request_timeout_ms=3000, + ) diff --git a/tests/components/matter/test_update.py b/tests/components/matter/test_update.py index 92576fa69e2..b39edd156b8 100644 --- a/tests/components/matter/test_update.py +++ b/tests/components/matter/test_update.py @@ -86,7 +86,7 @@ async def test_update_entity( matter_node: MatterNode, ) -> None: """Test update entity exists and update check got made.""" - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_OFF @@ -101,7 +101,7 @@ async def test_update_check_service( matter_node: MatterNode, ) -> None: """Test check device update through service call.""" - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "v1.0" @@ -124,14 +124,14 @@ async def test_update_check_service( HA_DOMAIN, SERVICE_UPDATE_ENTITY, { - ATTR_ENTITY_ID: "update.mock_dimmable_light", + ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware", }, blocking=True, ) assert matter_client.check_node_update.call_count == 2 - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes.get("latest_version") == "v2.0" @@ -150,7 +150,7 @@ async def test_update_install( freezer: FrozenDateTimeFactory, ) -> None: """Test device update with Matter attribute changes influence progress.""" - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "v1.0" @@ -173,7 +173,7 @@ async def test_update_install( assert matter_client.check_node_update.call_count == 2 - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes.get("latest_version") == "v2.0" @@ -186,7 +186,7 @@ async def test_update_install( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: "update.mock_dimmable_light", + ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware", }, blocking=True, ) @@ -199,7 +199,7 @@ async def test_update_install( ) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes["in_progress"] is True @@ -213,7 +213,7 @@ async def test_update_install( ) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes["in_progress"] is True @@ -239,7 +239,7 @@ async def test_update_install( ) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "v2.0" @@ -254,7 +254,7 @@ async def test_update_install_failure( freezer: FrozenDateTimeFactory, ) -> None: """Test update entity service call errors.""" - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "v1.0" @@ -277,7 +277,7 @@ async def test_update_install_failure( assert matter_client.check_node_update.call_count == 2 - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes.get("latest_version") == "v2.0" @@ -293,7 +293,7 @@ async def test_update_install_failure( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: "update.mock_dimmable_light", + ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware", ATTR_VERSION: "v3.0", }, blocking=True, @@ -306,7 +306,7 @@ async def test_update_install_failure( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: "update.mock_dimmable_light", + ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware", ATTR_VERSION: "v3.0", }, blocking=True, @@ -323,7 +323,7 @@ async def test_update_state_save_and_restore( freezer: FrozenDateTimeFactory, ) -> None: """Test latest update information is retained across reload/restart.""" - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "v1.0" @@ -336,7 +336,7 @@ async def test_update_state_save_and_restore( assert matter_client.check_node_update.call_count == 2 - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes.get("latest_version") == "v2.0" @@ -345,7 +345,7 @@ async def test_update_state_save_and_restore( assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] - assert state["entity_id"] == "update.mock_dimmable_light" + assert state["entity_id"] == "update.mock_dimmable_light_firmware" extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] # Check that the extra data has the format we expect. @@ -376,7 +376,7 @@ async def test_update_state_restore( ( ( State( - "update.mock_dimmable_light", + "update.mock_dimmable_light_firmware", STATE_ON, { "auto_update": False, @@ -393,7 +393,7 @@ async def test_update_state_restore( assert check_node_update.call_count == 0 - state = hass.states.get("update.mock_dimmable_light") + state = hass.states.get("update.mock_dimmable_light_firmware") assert state assert state.state == STATE_ON assert state.attributes.get("latest_version") == "v2.0" @@ -402,7 +402,7 @@ async def test_update_state_restore( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: "update.mock_dimmable_light", + ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware", }, blocking=True, ) diff --git a/tests/components/matter/test_vacuum.py b/tests/components/matter/test_vacuum.py index 1b33f6a2fe2..5bd90ee1109 100644 --- a/tests/components/matter/test_vacuum.py +++ b/tests/components/matter/test_vacuum.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/test_valve.py b/tests/components/matter/test_valve.py index 9c4429dda65..36ab34cb64e 100644 --- a/tests/components/matter/test_valve.py +++ b/tests/components/matter/test_valve.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/test_water_heater.py b/tests/components/matter/test_water_heater.py new file mode 100644 index 00000000000..2785dc9c778 --- /dev/null +++ b/tests/components/matter/test_water_heater.py @@ -0,0 +1,246 @@ +"""Test Matter sensors.""" + +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.assertion import SnapshotAssertion + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_HIGH_DEMAND, + STATE_OFF, + WaterHeaterEntityFeature, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ( + set_node_attribute, + snapshot_matter_entities, + trigger_subscription_callback, +) + + +@pytest.mark.usefixtures("matter_devices") +async def test_water_heaters( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test water heaters.""" + snapshot_matter_entities(hass, entity_registry, snapshot, Platform.WATER_HEATER) + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water heater entity.""" + state = hass.states.get("water_heater.water_heater") + assert state + assert state.attributes["min_temp"] == 40 + assert state.attributes["max_temp"] == 65 + assert state.attributes["temperature"] == 65 + assert state.attributes["operation_list"] == ["eco", "high_demand", "off"] + assert state.state == STATE_ECO + + # test supported features correctly parsed + mask = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE + ) + assert state.attributes["supported_features"] & mask == mask + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater_set_temperature( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water_heater set temperature service.""" + # test single-setpoint temperature adjustment when eco mode is active + state = hass.states.get("water_heater.water_heater") + + assert state + assert state.state == STATE_ECO + await hass.services.async_call( + "water_heater", + "set_temperature", + { + "entity_id": "water_heater.water_heater", + "temperature": 52, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path="2/513/18", + value=5200, + ) + matter_client.write_attribute.reset_mock() + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +@pytest.mark.parametrize( + ("operation_mode", "matter_attribute_value"), + [(STATE_OFF, 0), (STATE_ECO, 4), (STATE_HIGH_DEMAND, 4)], +) +async def test_water_heater_set_operation_mode( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, + operation_mode: str, + matter_attribute_value: int, +) -> None: + """Test water_heater set operation mode service.""" + state = hass.states.get("water_heater.water_heater") + assert state + + # test change mode to each operation_mode + await hass.services.async_call( + "water_heater", + "set_operation_mode", + { + "entity_id": "water_heater.water_heater", + "operation_mode": operation_mode, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=2, + attribute=clusters.Thermostat.Attributes.SystemMode, + ), + value=matter_attribute_value, + ) + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater_boostmode( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water_heater set operation mode service.""" + # Boost 1h (3600s) + boost_info: type[ + clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct + ] = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(duration=3600) + state = hass.states.get("water_heater.water_heater") + assert state + + # enable water_heater boostmode + await hass.services.async_call( + "water_heater", + "set_operation_mode", + { + "entity_id": "water_heater.water_heater", + "operation_mode": STATE_HIGH_DEMAND, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=2, + attribute=clusters.Thermostat.Attributes.SystemMode, + ), + value=4, + ) + 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=2, + command=clusters.WaterHeaterManagement.Commands.Boost(boostInfo=boost_info), + ) + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_update_from_water_heater( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test enable boost from water heater device side.""" + entity_id = "water_heater.water_heater" + + # confirm initial BoostState (as stored in the fixture) + state = hass.states.get(entity_id) + assert state + + # confirm thermostat state is 'high_demand' by setting the BoostState to 1 + set_node_attribute(matter_node, 2, 148, 5, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_HIGH_DEMAND + + # confirm thermostat state is 'eco' by setting the BoostState to 0 + set_node_attribute(matter_node, 2, 148, 5, 0) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ECO + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_water_heater_turn_on_off( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test water_heater set turn_off/turn_on.""" + state = hass.states.get("water_heater.water_heater") + assert state + + # turn_off water_heater + await hass.services.async_call( + "water_heater", + "turn_off", + { + "entity_id": "water_heater.water_heater", + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=2, + attribute=clusters.Thermostat.Attributes.SystemMode, + ), + value=0, + ) + + matter_client.write_attribute.reset_mock() + + # turn_on water_heater + await hass.services.async_call( + "water_heater", + "turn_on", + { + "entity_id": "water_heater.water_heater", + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=2, + attribute=clusters.Thermostat.Attributes.SystemMode, + ), + value=4, + ) diff --git a/tests/components/mcp/conftest.py b/tests/components/mcp/conftest.py index d86603a12ed..b6d6958d3d9 100644 --- a/tests/components/mcp/conftest.py +++ b/tests/components/mcp/conftest.py @@ -1,17 +1,34 @@ """Common fixtures for the Model Context Protocol tests.""" from collections.abc import Generator +import datetime from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.mcp.const import DOMAIN -from homeassistant.const import CONF_URL +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.mcp.const import ( + CONF_ACCESS_TOKEN, + CONF_AUTHORIZATION_URL, + CONF_TOKEN_URL, + DOMAIN, +) +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry TEST_API_NAME = "Memory Server" +MCP_SERVER_URL = "http://1.1.1.1:8080/sse" +CLIENT_ID = "test-client-id" +CLIENT_SECRET = "test-client-secret" +AUTH_DOMAIN = "some-auth-domain" +OAUTH_AUTHORIZE_URL = "https://example-auth-server.com/authorize-path" +OAUTH_TOKEN_URL = "https://example-auth-server.com/token-path" @pytest.fixture @@ -29,6 +46,7 @@ def mock_mcp_client() -> Generator[AsyncMock]: with ( patch("homeassistant.components.mcp.coordinator.sse_client"), patch("homeassistant.components.mcp.coordinator.ClientSession") as mock_session, + patch("homeassistant.components.mcp.coordinator.TIMEOUT", 1), ): yield mock_session.return_value.__aenter__ @@ -43,3 +61,47 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: ) config_entry.add_to_hass(hass) return config_entry + + +@pytest.fixture(name="credential") +async def mock_credential(hass: HomeAssistant) -> None: + """Fixture that provides the ClientCredential for the test.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + AUTH_DOMAIN, + ) + + +@pytest.fixture(name="config_entry_token_expiration") +def mock_config_entry_token_expiration() -> datetime.datetime: + """Fixture to mock the token expiration.""" + return datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) + + +@pytest.fixture(name="config_entry_with_auth") +def mock_config_entry_with_auth( + hass: HomeAssistant, + config_entry_token_expiration: datetime.datetime, +) -> MockConfigEntry: + """Fixture to load the integration with authentication.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=AUTH_DOMAIN, + data={ + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, + CONF_TOKEN_URL: OAUTH_TOKEN_URL, + CONF_TOKEN: { + CONF_ACCESS_TOKEN: "test-access-token", + "refresh_token": "test-refresh-token", + "expires_at": config_entry_token_expiration.timestamp(), + }, + }, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 29733e653a6..426b3267195 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -1,20 +1,70 @@ """Test the Model Context Protocol config flow.""" +import json from typing import Any from unittest.mock import AsyncMock, Mock import httpx import pytest +import respx from homeassistant import config_entries -from homeassistant.components.mcp.const import DOMAIN -from homeassistant.const import CONF_URL +from homeassistant.components.mcp.const import ( + CONF_AUTHORIZATION_URL, + CONF_TOKEN_URL, + DOMAIN, +) +from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow -from .conftest import TEST_API_NAME +from .conftest import ( + AUTH_DOMAIN, + CLIENT_ID, + MCP_SERVER_URL, + OAUTH_AUTHORIZE_URL, + OAUTH_TOKEN_URL, + TEST_API_NAME, +) from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +MCP_SERVER_BASE_URL = "http://1.1.1.1:8080" +OAUTH_DISCOVERY_ENDPOINT = ( + f"{MCP_SERVER_BASE_URL}/.well-known/oauth-authorization-server" +) +OAUTH_SERVER_METADATA_RESPONSE = httpx.Response( + status_code=200, + text=json.dumps( + { + "authorization_endpoint": OAUTH_AUTHORIZE_URL, + "token_endpoint": OAUTH_TOKEN_URL, + } + ), +) +CALLBACK_PATH = "/auth/external/callback" +OAUTH_CALLBACK_URL = f"https://example.com{CALLBACK_PATH}" +OAUTH_CODE = "abcd" +OAUTH_TOKEN_PAYLOAD = { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, +} + + +def encode_state(hass: HomeAssistant, flow_id: str) -> str: + """Encode the OAuth JWT.""" + return config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": flow_id, + "redirect_uri": OAUTH_CALLBACK_URL, + }, + ) async def test_form( @@ -34,15 +84,19 @@ async def test_form( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } + # Config entry does not have a unique id + assert result["result"] + assert result["result"].unique_id is None + assert len(mock_setup_entry.mock_calls) == 1 @@ -73,7 +127,7 @@ async def test_form_mcp_client_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) @@ -89,50 +143,18 @@ async def test_form_mcp_client_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - ("side_effect", "expected_error"), - [ - ( - httpx.HTTPStatusError("", request=None, response=httpx.Response(401)), - "invalid_auth", - ), - ], -) -async def test_form_mcp_client_error_abort( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_mcp_client: Mock, - side_effect: Exception, - expected_error: str, -) -> None: - """Test we handle different client library errors that end with an abort.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - mock_mcp_client.side_effect = side_effect - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: "http://1.1.1.1/sse", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == expected_error - - @pytest.mark.parametrize( "user_input", [ @@ -165,14 +187,14 @@ async def test_input_form_validation_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_API_NAME assert result["data"] == { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, } assert len(mock_setup_entry.mock_calls) == 1 @@ -183,7 +205,7 @@ async def test_unique_url( """Test that the same url cannot be configured twice.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={CONF_URL: "http://1.1.1.1/sse"}, + data={CONF_URL: MCP_SERVER_URL}, title=TEST_API_NAME, ) config_entry.add_to_hass(hass) @@ -201,7 +223,7 @@ async def test_unique_url( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) @@ -226,9 +248,409 @@ async def test_server_missing_capbilities( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_URL: "http://1.1.1.1/sse", + CONF_URL: MCP_SERVER_URL, }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "missing_capabilities" + + +@respx.mock +async def test_oauth_discovery_flow_without_credentials( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, +) -> None: + """Test for an OAuth discoveryflow for an MCP server where the user has not yet entered credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + + # The config flow will abort and the user will be taken to the application credentials UI + # to enter their credentials. + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_credentials" + + +async def perform_oauth_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + result: config_entries.ConfigFlowResult, + authorize_url: str = OAUTH_AUTHORIZE_URL, + token_url: str = OAUTH_TOKEN_URL, +) -> config_entries.ConfigFlowResult: + """Perform the common steps of the OAuth flow. + + Expects to be called from the step where the user selects credentials. + """ + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": OAUTH_CALLBACK_URL, + }, + ) + assert result["url"] == ( + f"{authorize_url}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={OAUTH_CALLBACK_URL}" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"{CALLBACK_PATH}?code={OAUTH_CODE}&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + token_url, + json=OAUTH_TOKEN_PAYLOAD, + ) + + return result + + +@pytest.mark.parametrize( + ("oauth_server_metadata_response", "expected_authorize_url", "expected_token_url"), + [ + (OAUTH_SERVER_METADATA_RESPONSE, OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL), + ( + httpx.Response( + status_code=200, + text=json.dumps( + { + "authorization_endpoint": "/authorize-path", + "token_endpoint": "/token-path", + } + ), + ), + f"{MCP_SERVER_BASE_URL}/authorize-path", + f"{MCP_SERVER_BASE_URL}/token-path", + ), + ( + httpx.Response(status_code=404), + f"{MCP_SERVER_BASE_URL}/authorize", + f"{MCP_SERVER_BASE_URL}/token", + ), + ], + ids=( + "discovery", + "relative_paths", + "no_discovery_metadata", + ), +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + oauth_server_metadata_response: httpx.Response, + expected_authorize_url: str, + expected_token_url: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=oauth_server_metadata_response + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + authorize_url=expected_authorize_url, + token_url=expected_token_url, + ) + + # Client now accepts credentials + mock_mcp_client.side_effect = None + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + data = result["data"] + token = data.pop(CONF_TOKEN) + assert data == { + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: expected_authorize_url, + CONF_TOKEN_URL: expected_token_url, + } + assert token + token.pop("expires_at") + assert token == OAUTH_TOKEN_PAYLOAD + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_oauth_discovery_failure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + side_effect: Exception, + expected_error: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock(side_effect=side_effect) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow_server_failure_abort( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, + side_effect: Exception, + expected_error: str, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + ) + + # Client fails with an error + mock_mcp_client.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_authentication_flow_server_missing_tool_capabilities( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # MCP Server returns 401 indicating the client needs to authenticate + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + # Prepare the OAuth Server metadata + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: MCP_SERVER_URL, + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + ) + + # Client can now authenticate + mock_mcp_client.side_effect = None + + response = Mock() + response.serverInfo.name = TEST_API_NAME + response.capabilities.tools = None + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_capabilities" + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + config_entry_with_auth: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test for an OAuth authentication flow for an MCP server.""" + config_entry_with_auth.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + result = await perform_oauth_flow(hass, aioclient_mock, hass_client_no_auth, result) + + # Verify we can connect to the server + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry_with_auth.unique_id == AUTH_DOMAIN + assert config_entry_with_auth.title == TEST_API_NAME + data = {**config_entry_with_auth.data} + token = data.pop(CONF_TOKEN) + assert data == { + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, + CONF_TOKEN_URL: OAUTH_TOKEN_URL, + } + assert token + token.pop("expires_at") + assert token == OAUTH_TOKEN_PAYLOAD + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 460df2c5785..045fb99e181 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -76,17 +76,45 @@ async def test_init( assert config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("side_effect"), + [ + (httpx.TimeoutException("Some timeout")), + (httpx.HTTPStatusError("", request=None, response=httpx.Response(500))), + (httpx.HTTPStatusError("", request=None, response=httpx.Response(401))), + (httpx.HTTPError("Some HTTP error")), + ], +) async def test_mcp_server_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, + side_effect: Exception, ) -> None: """Test the integration fails to setup if the server fails initialization.""" + mock_mcp_client.side_effect = side_effect + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_mcp_server_authentication_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test the integration fails to setup if the server fails authentication.""" mock_mcp_client.side_effect = httpx.HTTPStatusError( - "", request=None, response=httpx.Response(500) + "Authentication required", request=None, response=httpx.Response(401) ) - with patch("homeassistant.components.mcp.coordinator.TIMEOUT", 1): - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.state is ConfigEntryState.SETUP_RETRY + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" async def test_list_tools_failure( diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py index 5ec67fb6ce3..b5e25d9fe50 100644 --- a/tests/components/mcp_server/conftest.py +++ b/tests/components/mcp_server/conftest.py @@ -5,9 +5,10 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.mcp_server.const import DOMAIN, LLM_API +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 @@ -21,13 +22,19 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture(name="llm_hass_api") +def llm_hass_api_fixture() -> str: + """Fixture for the config entry llm_hass_api.""" + return llm.LLM_API_ASSIST + + @pytest.fixture(name="config_entry") -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: +def mock_config_entry(hass: HomeAssistant, llm_hass_api: str) -> MockConfigEntry: """Fixture to load the integration.""" config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_LLM_HASS_API: LLM_API, + CONF_LLM_HASS_API: llm_hass_api, }, ) config_entry.add_to_hass(hass) diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 905bfaa11d7..61cd1a4dd02 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -16,6 +16,7 @@ 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.const import STATELESS_LLM_API 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 @@ -24,6 +25,7 @@ from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, + llm, ) from homeassistant.setup import async_setup_component @@ -297,6 +299,7 @@ async def mcp_session( yield session +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_mcp_tools_list( hass: HomeAssistant, setup_integration: None, @@ -312,13 +315,14 @@ async def test_mcp_tools_list( # 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.description is not None assert tool.inputSchema assert tool.inputSchema.get("type") == "object" properties = tool.inputSchema.get("properties") assert properties.get("name") == {"type": "string"} +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_mcp_tool_call( hass: HomeAssistant, setup_integration: None, @@ -371,6 +375,7 @@ async def test_mcp_tool_call_failed( assert "Error calling tool" in result.content[0].text +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_prompt_list( hass: HomeAssistant, setup_integration: None, @@ -384,13 +389,11 @@ async def test_prompt_list( assert len(result.prompts) == 1 prompt = result.prompts[0] - assert prompt.name == "Stateless Assist" - assert ( - prompt.description - == "Default prompt for the Home Assistant LLM API Stateless Assist" - ) + assert prompt.name == "Assist" + assert prompt.description == "Default prompt for Home Assistant Assist API" +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_prompt_get( hass: HomeAssistant, setup_integration: None, @@ -400,12 +403,9 @@ async def test_prompt_get( """Test the get prompt endpoint.""" async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: - result = await session.get_prompt(name="Stateless Assist") + result = await session.get_prompt(name="Assist") - assert ( - result.description - == "Default prompt for the Home Assistant LLM API Stateless Assist" - ) + assert result.description == "Default prompt for Home Assistant Assist API" assert len(result.messages) == 1 assert result.messages[0].role == "assistant" assert result.messages[0].content.type == "text" diff --git a/tests/components/mealie/test_diagnostics.py b/tests/components/mealie/test_diagnostics.py index 88680da9784..43434d31107 100644 --- a/tests/components/mealie/test_diagnostics.py +++ b/tests/components/mealie/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/mealie/test_init.py b/tests/components/mealie/test_init.py index a45a67801df..7581363dee4 100644 --- a/tests/components/mealie/test_init.py +++ b/tests/components/mealie/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from aiomealie import About, MealieAuthenticationError, MealieConnectionError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.mealie.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 63668379490..57c55159bdc 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -11,7 +11,7 @@ from aiomealie import ( ) from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.mealie.const import ( ATTR_CONFIG_ENTRY_ID, diff --git a/tests/components/media_extractor/test_init.py b/tests/components/media_extractor/test_init.py index 21fab6f875c..aa554720786 100644 --- a/tests/components/media_extractor/test_init.py +++ b/tests/components/media_extractor/test_init.py @@ -6,7 +6,7 @@ from typing import Any from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from yt_dlp import DownloadError from homeassistant.components.media_extractor.const import ( diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 680603c097d..3ab79db73e1 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -69,6 +69,10 @@ class SimpleMediaPlayer(mp.MediaPlayerEntity): """Put device in standby.""" self._state = STATE_STANDBY + def idle(self): + """Put device in idle.""" + self._state = STATE_IDLE + class ExtendedMediaPlayer(SimpleMediaPlayer): """Media player test class.""" @@ -92,7 +96,7 @@ class ExtendedMediaPlayer(SimpleMediaPlayer): def toggle(self): """Toggle the power on the media player.""" - if self._state in [STATE_OFF, STATE_IDLE, STATE_STANDBY]: + if self._state in [STATE_OFF, STATE_STANDBY]: self._state = STATE_ON else: self._state = STATE_OFF @@ -187,3 +191,7 @@ async def test_toggle(player) -> None: assert player.state == STATE_STANDBY await player.async_toggle() assert player.state == STATE_ON + player.idle() + assert player.state == STATE_IDLE + await player.async_toggle() + assert player.state == STATE_OFF diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 1878d7372f6..090ea9f27e2 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -12,13 +12,20 @@ from homeassistant.components import media_player from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_FILTER_CLASSES, + ATTR_MEDIA_SEARCH_QUERY, BrowseMedia, MediaClass, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, + SearchMedia, + SearchMediaQuery, +) +from homeassistant.components.media_player.const import ( + SERVICE_BROWSE_MEDIA, + SERVICE_SEARCH_MEDIA, ) -from homeassistant.components.media_player.const import SERVICE_BROWSE_MEDIA from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant @@ -47,6 +54,7 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s not in [ MediaPlayerEntityFeature.MEDIA_ANNOUNCE, MediaPlayerEntityFeature.MEDIA_ENQUEUE, + MediaPlayerEntityFeature.SEARCH_MEDIA, ] ] @@ -315,6 +323,7 @@ async def test_media_browse( "media_content_id": "mock-id", "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": None, "thumbnail": None, "not_shown": 0, @@ -411,6 +420,119 @@ async def test_media_browse_service(hass: HomeAssistant) -> None: assert browse_res.children[1].media_content_type == "album" +async def test_media_search( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test browsing media.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.demo.media_player.DemoSearchPlayer.async_search_media", + return_value=SearchMedia( + result=[ + BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="mock-id", + media_content_type="mock-type", + title="Mock Title", + can_play=False, + can_expand=True, + ) + ] + ), + ) as mock_search_media: + await client.send_json( + { + "id": 7, + "type": "media_player/search_media", + "entity_id": "media_player.search", + "media_content_type": "album", + "media_content_id": "abcd", + "search_query": "query", + "media_filter_classes": ["album"], + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"]["result"] == [ + { + "title": "Mock Title", + "media_class": "directory", + "media_content_type": "mock-type", + "media_content_id": "mock-id", + "children_media_class": None, + "can_play": False, + "can_expand": True, + "can_search": False, + "thumbnail": None, + "not_shown": 0, + "children": [], + } + ] + assert mock_search_media.mock_calls[0].kwargs["query"] == SearchMediaQuery( + search_query="query", + media_content_type="album", + media_content_id="abcd", + media_filter_classes={MediaClass.ALBUM}, + ) + + +async def test_media_search_service(hass: HomeAssistant) -> None: + """Test browsing media.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + expected = [ + BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="mock-id", + media_content_type="mock-type", + title="Mock Title", + can_play=False, + can_expand=True, + children=[], + ) + ] + + with patch( + "homeassistant.components.demo.media_player.DemoSearchPlayer.async_search_media", + return_value=SearchMedia(result=expected), + ) as mock_search_media: + result = await hass.services.async_call( + "media_player", + SERVICE_SEARCH_MEDIA, + { + ATTR_ENTITY_ID: "media_player.search", + ATTR_MEDIA_CONTENT_TYPE: "album", + ATTR_MEDIA_CONTENT_ID: "title=Album*", + ATTR_MEDIA_SEARCH_QUERY: "query", + ATTR_MEDIA_FILTER_CLASSES: ["album"], + }, + blocking=True, + return_response=True, + ) + + search_res: SearchMedia = result["media_player.search"] + assert search_res.version == 1 + assert search_res.result == expected + assert mock_search_media.mock_calls[0].kwargs["query"] == SearchMediaQuery( + search_query="query", + media_content_type="album", + media_content_id="title=Album*", + media_filter_classes={MediaClass.ALBUM}, + ) + + async def test_group_members_available_when_off(hass: HomeAssistant) -> None: """Test that group_members are still available when media_player is off.""" await async_setup_component( diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index 9ddf50d04f4..6429d6889c0 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -8,7 +8,13 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_PLAY_MEDIA, + SERVICE_SEARCH_MEDIA, SERVICE_VOLUME_SET, + BrowseMedia, + MediaClass, + MediaType, + SearchMedia, intent as media_player_intent, ) from homeassistant.components.media_player.const import MediaPlayerEntityFeature @@ -19,6 +25,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, entity_registry as er, @@ -104,19 +111,6 @@ async def test_unpause_media_player_intent(hass: HomeAssistant) -> None: assert call.service == SERVICE_MEDIA_PLAY assert call.data == {"entity_id": entity_id} - # Test if not paused - hass.states.async_set( - entity_id, - STATE_PLAYING, - ) - - with pytest.raises(intent.MatchFailedError): - response = await intent.async_handle( - hass, - "test", - media_player_intent.INTENT_MEDIA_UNPAUSE, - ) - async def test_next_media_player_intent(hass: HomeAssistant) -> None: """Test HassMediaNext intent for media players.""" @@ -245,17 +239,6 @@ async def test_volume_media_player_intent(hass: HomeAssistant) -> None: assert call.service == SERVICE_VOLUME_SET assert call.data == {"entity_id": entity_id, "volume_level": 0.5} - # Test if not playing - hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) - - with pytest.raises(intent.MatchFailedError): - response = await intent.async_handle( - hass, - "test", - media_player_intent.INTENT_SET_VOLUME, - {"volume_level": {"value": 50}}, - ) - # Test feature not supported hass.states.async_set( entity_id, @@ -659,3 +642,154 @@ async def test_manual_pause_unpause( assert response.response_type == intent.IntentResponseType.ACTION_DONE assert len(calls) == 1 assert calls[0].data == {"entity_id": device_2.entity_id} + + +async def test_search_and_play_media_player_intent(hass: HomeAssistant) -> None: + """Test HassMediaSearchAndPlay intent for media players.""" + await media_player_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_media_player" + attributes = { + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.SEARCH_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA + } + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + + # Test successful search and play + search_result_item = BrowseMedia( + title="Test Track", + media_class=MediaClass.MUSIC, + media_content_type=MediaType.MUSIC, + media_content_id="library/artist/123/album/456/track/789", + can_play=True, + can_expand=False, + ) + + # Mock service calls + search_results = [search_result_item] + search_calls = async_mock_service( + hass, + DOMAIN, + SERVICE_SEARCH_MEDIA, + response={entity_id: SearchMedia(result=search_results)}, + ) + play_calls = async_mock_service(hass, DOMAIN, SERVICE_PLAY_MEDIA) + + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "test query"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + # Response should contain a "media" slot with the matched item. + assert not response.speech + media = response.speech_slots.get("media") + assert isinstance(media, BrowseMedia) + assert media.title == "Test Track" + + assert len(search_calls) == 1 + search_call = search_calls[0] + assert search_call.domain == DOMAIN + assert search_call.service == SERVICE_SEARCH_MEDIA + assert search_call.data == { + "entity_id": entity_id, + "search_query": "test query", + } + + assert len(play_calls) == 1 + play_call = play_calls[0] + assert play_call.domain == DOMAIN + assert play_call.service == SERVICE_PLAY_MEDIA + assert play_call.data == { + "entity_id": entity_id, + "media_content_id": search_result_item.media_content_id, + "media_content_type": search_result_item.media_content_type, + } + + # Test no search results + search_results.clear() + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "another query"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + # A search failure is indicated by no "media" slot in the response. + assert not response.speech + assert "media" not in response.speech_slots + assert len(search_calls) == 2 # Search was called again + assert len(play_calls) == 1 # Play was not called again + + # Test feature not supported + hass.states.async_set( + entity_id, + STATE_IDLE, + attributes={}, + ) + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "test query"}}, + ) + + # Test feature not supported (missing SEARCH_MEDIA) + hass.states.async_set( + entity_id, + STATE_IDLE, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PLAY_MEDIA}, + ) + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "test query"}}, + ) + + # Test play media service errors + search_results.append(search_result_item) + hass.states.async_set( + entity_id, + STATE_IDLE, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.SEARCH_MEDIA}, + ) + + async_mock_service( + hass, + DOMAIN, + SERVICE_PLAY_MEDIA, + raise_exception=HomeAssistantError("Play failed"), + ) + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "play error query"}}, + ) + + # Test search service error + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + async_mock_service( + hass, + DOMAIN, + SERVICE_SEARCH_MEDIA, + raise_exception=HomeAssistantError("Search failed"), + ) + with pytest.raises(intent.IntentHandleError, match="Error searching media"): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "error query"}}, + ) diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index c37e418020b..2c2952068ee 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -114,7 +114,6 @@ async def test_async_resolve_media(hass: HomeAssistant) -> None: assert media.mime_type == "audio/mpeg" -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_async_resolve_media_no_entity( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/melcloud/test_diagnostics.py b/tests/components/melcloud/test_diagnostics.py index 32ec94a54d1..e1c498e8704 100644 --- a/tests/components/melcloud/test_diagnostics.py +++ b/tests/components/melcloud/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.melcloud.const import DOMAIN diff --git a/tests/components/melissa/test_climate.py b/tests/components/melissa/test_climate.py index b305d629a91..c93f741413d 100644 --- a/tests/components/melissa/test_climate.py +++ b/tests/components/melissa/test_climate.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, diff --git a/tests/components/meraki/test_device_tracker.py b/tests/components/meraki/test_device_tracker.py index 139396a0689..c187ca8ce75 100644 --- a/tests/components/meraki/test_device_tracker.py +++ b/tests/components/meraki/test_device_tracker.py @@ -1,6 +1,5 @@ """The tests the for Meraki device tracker.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json @@ -22,31 +21,25 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def meraki_client( - event_loop: AbstractEventLoop, +async def meraki_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Meraki mock client.""" - loop = event_loop + assert await async_setup_component( + hass, + device_tracker.DOMAIN, + { + device_tracker.DOMAIN: { + CONF_PLATFORM: "meraki", + CONF_VALIDATOR: "validator", + CONF_SECRET: "secret", + } + }, + ) + await hass.async_block_till_done() - async def setup_and_wait(): - result = await async_setup_component( - hass, - device_tracker.DOMAIN, - { - device_tracker.DOMAIN: { - CONF_PLATFORM: "meraki", - CONF_VALIDATOR: "validator", - CONF_SECRET: "secret", - } - }, - ) - await hass.async_block_till_done() - return result - - assert loop.run_until_complete(setup_and_wait()) - return loop.run_until_complete(hass_client()) + return await hass_client() async def test_invalid_or_missing_data( diff --git a/tests/components/meteo_france/conftest.py b/tests/components/meteo_france/conftest.py index eb28ec0a838..82b220e331e 100644 --- a/tests/components/meteo_france/conftest.py +++ b/tests/components/meteo_france/conftest.py @@ -24,8 +24,8 @@ def patch_requests(): mock_data.get_rain.return_value = Rain( load_json_object_fixture("raw_rain.json", DOMAIN) ) - mock_data.get_warning_current_phenomenoms.return_value = CurrentPhenomenons( - load_json_object_fixture("raw_warning_current_phenomenoms.json", DOMAIN) + mock_data.get_warning_current_phenomenons.return_value = CurrentPhenomenons( + load_json_object_fixture("raw_warning_current_phenomenons.json", DOMAIN) ) yield mock_data diff --git a/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json b/tests/components/meteo_france/fixtures/raw_warning_current_phenomenons.json similarity index 100% rename from tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json rename to tests/components/meteo_france/fixtures/raw_warning_current_phenomenons.json diff --git a/tests/components/meteo_france/snapshots/test_weather.ambr b/tests/components/meteo_france/snapshots/test_weather.ambr index 7c64ee86671..d5e03c95de2 100644 --- a/tests/components/meteo_france/snapshots/test_weather.ambr +++ b/tests/components/meteo_france/snapshots/test_weather.ambr @@ -47,6 +47,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 200, + 'wind_gust_speed': 64.8, 'wind_speed': 28.8, 'wind_speed_unit': , }), diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index e10ec589113..38f1318a683 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -366,7 +366,7 @@ async def test_service_say_error( await retrieve_media( hass, hass_client, service_calls[1].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(mock_tts.mock_calls) == 2 diff --git a/tests/components/miele/__init__.py b/tests/components/miele/__init__.py new file mode 100644 index 00000000000..b0278defa8e --- /dev/null +++ b/tests/components/miele/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Miele 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/miele/conftest.py b/tests/components/miele/conftest.py new file mode 100644 index 00000000000..8e3b5628ed4 --- /dev/null +++ b/tests/components/miele/conftest.py @@ -0,0 +1,159 @@ +"""Test helpers for Miele.""" + +from collections.abc import AsyncGenerator, Generator +import time +from unittest.mock import AsyncMock, MagicMock, patch + +from pymiele import MieleAction, MieleDevices +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.miele.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import CLIENT_ID, CLIENT_SECRET + +from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> float: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + minor_version=1, + domain=DOMAIN, + title="Miele test", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "Fake_token", + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + }, + entry_id="miele_test", + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + CLIENT_ID, + CLIENT_SECRET, + ), + DOMAIN, + ) + + +# Fixture group for device API endpoint. + + +@pytest.fixture(scope="package") +def load_device_file() -> str: + """Fixture for loading device file.""" + return "4_devices.json" + + +@pytest.fixture +def device_fixture(load_device_file: str) -> MieleDevices: + """Fixture for device.""" + return load_json_object_fixture(load_device_file, DOMAIN) + + +@pytest.fixture(scope="package") +def load_action_file() -> str: + """Fixture for loading action file.""" + return "action_washing_machine.json" + + +@pytest.fixture +def action_fixture(load_action_file: str) -> MieleAction: + """Fixture for action.""" + return load_json_object_fixture(load_action_file, DOMAIN) + + +@pytest.fixture(scope="package") +def load_programs_file() -> str: + """Fixture for loading programs file.""" + return "programs_washing_machine.json" + + +@pytest.fixture +def programs_fixture(load_programs_file: str) -> list[dict]: + """Fixture for available programs.""" + return load_fixture(load_programs_file, DOMAIN) + + +@pytest.fixture +def mock_miele_client( + device_fixture, + action_fixture, + programs_fixture, +) -> Generator[MagicMock]: + """Mock a Miele client.""" + + with patch( + "homeassistant.components.miele.AsyncConfigEntryAuth", + autospec=True, + ) as mock_client: + client = mock_client.return_value + + client.get_devices.return_value = device_fixture + client.get_actions.return_value = action_fixture + client.get_programs.return_value = programs_fixture + + yield client + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture for platforms.""" + return [] + + +@pytest.fixture +async def setup_platform( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms, +) -> AsyncGenerator[None]: + """Set up one or all platforms.""" + + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield mock_config_entry + + +@pytest.fixture +async def access_token(hass: HomeAssistant) -> str: + """Return a valid access token.""" + return "mock-access-token" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.miele.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/miele/const.py b/tests/components/miele/const.py new file mode 100644 index 00000000000..fdc709229d2 --- /dev/null +++ b/tests/components/miele/const.py @@ -0,0 +1,5 @@ +"""Constants for miele tests.""" + +CLIENT_ID = "12345" +CLIENT_SECRET = "67890" +UNIQUE_ID = "uid" diff --git a/tests/components/miele/fixtures/4_devices.json b/tests/components/miele/fixtures/4_devices.json new file mode 100644 index 00000000000..b63c60ff4d3 --- /dev/null +++ b/tests/components/miele/fixtures/4_devices.json @@ -0,0 +1,470 @@ +{ + "Dummy_Appliance_1": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 20, + "value_localized": "Freezer" + }, + "deviceName": "", + "protocolVersion": 201, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_1", + "fabIndex": "21", + "techType": "FNS 28463 E ed/", + "matNumber": "10805070", + "swids": ["4497"] + }, + "xkmIdentLabel": { + "techType": "EK042", + "releaseVersion": "31.17" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -1800, + "value_localized": -18, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": -1800, + "value_localized": -18, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_2": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 19, + "value_localized": "Refrigerator" + }, + "deviceName": "", + "protocolVersion": 201, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_2", + "fabIndex": "17", + "techType": "KS 28423 D ed/c", + "matNumber": "10804770", + "swids": ["4497"] + }, + "xkmIdentLabel": { + "techType": "EK042", + "releaseVersion": "31.17" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 400, + "value_localized": 4, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": 400, + "value_localized": 4, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_3": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 1, + "value_localized": "Washing machine" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_3", + "fabIndex": "44", + "techType": "WCI870", + "matNumber": "11387290", + "swids": [ + "5975", + "20456", + "25213", + "25191", + "25446", + "25205", + "25447", + "25319" + ] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": true, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": { + "currentWaterConsumption": { + "unit": "l", + "value": 0.0 + }, + "currentEnergyConsumption": { + "unit": "kWh", + "value": 0.0 + }, + "waterForecast": 0.0, + "energyForecast": 0.1 + }, + "batteryLevel": null + } + }, + "DummyAppliance_18": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 18, + "value_localized": "Cooker Hood" + }, + "deviceName": "", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "", + "fabIndex": "64", + "techType": "Fläkt", + "matNumber": "", + "swids": ["", "", "", "<...>"] + }, + "xkmIdentLabel": { + "techType": "EK039W", + "releaseVersion": "02.72" + } + }, + "state": { + "ProgramID": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 4608, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": 2, + "light": 1, + "elapsedTime": {}, + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": 0, + "value_localized": "0", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/5_devices.json b/tests/components/miele/fixtures/5_devices.json new file mode 100644 index 00000000000..113babbd3f7 --- /dev/null +++ b/tests/components/miele/fixtures/5_devices.json @@ -0,0 +1,652 @@ +{ + "Dummy_Appliance_1": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 20, + "value_localized": "Freezer" + }, + "deviceName": "", + "protocolVersion": 201, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_1", + "fabIndex": "21", + "techType": "FNS 28463 E ed/", + "matNumber": "10805070", + "swids": ["4497"] + }, + "xkmIdentLabel": { + "techType": "EK042", + "releaseVersion": "31.17" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [], + "targetTemperature": [ + { + "value_raw": -1800, + "value_localized": -18, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": -1800, + "value_localized": -18, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_2": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 19, + "value_localized": "Refrigerator" + }, + "deviceName": "", + "protocolVersion": 201, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_2", + "fabIndex": "17", + "techType": "KS 28423 D ed/c", + "matNumber": "10804770", + "swids": ["4497"] + }, + "xkmIdentLabel": { + "techType": "EK042", + "releaseVersion": "31.17" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 400, + "value_localized": 4, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": 400, + "value_localized": 4, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_3": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 1, + "value_localized": "Washing machine" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_3", + "fabIndex": "44", + "techType": "WCI870", + "matNumber": "11387290", + "swids": [ + "5975", + "20456", + "25213", + "25191", + "25446", + "25205", + "25447", + "25319" + ] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": true, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "DummyAppliance_18": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 18, + "value_localized": "Cooker Hood" + }, + "deviceName": "", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "", + "fabIndex": "64", + "techType": "Fläkt", + "matNumber": "", + "swids": ["", "", "", "<...>"] + }, + "xkmIdentLabel": { + "techType": "EK039W", + "releaseVersion": "02.72" + } + }, + "state": { + "ProgramID": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 4608, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": 2, + "light": 1, + "elapsedTime": {}, + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": 0, + "value_localized": "0", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_4": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "", + "fabIndex": "00", + "techType": "H7264B", + "matNumber": "", + "swids": ["swid00"] + }, + "xkmIdentLabel": { "techType": "EK057", "releaseVersion": "08.21" } + }, + "state": { + "ProgramID": { + "value_raw": 13, + "value_localized": "Fan plus", + "key_localized": "Program name" + }, + "status": { + "value_raw": 3, + "value_localized": "Programmed", + "key_localized": "status" + }, + "programType": { + "value_raw": 1, + "value_localized": "Own program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { "value_raw": 18000, "value_localized": "180.0", "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } + ], + "coreTargetTemperature": [ + { "value_raw": 7500, "value_localized": "75.0", "unit": "Celsius" } + ], + "coreTemperature": [ + { "value_raw": 5200, "value_localized": "52.0", "unit": "Celsius" } + ], + "temperature": [ + { + "value_raw": 17500, + "value_localized": "175.0", + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": 2, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_5": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 7, + "value_localized": "Dishwasher" + }, + "deviceName": "", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "", + "fabIndex": "64", + "techType": "G6865-W", + "matNumber": "", + "swids": ["", "", "", "<...>"] + }, + "xkmIdentLabel": { "techType": "EK039W", "releaseVersion": "02.72" } + }, + "state": { + "ProgramID": { + "value_raw": 38, + "value_localized": "QuickPowerWash", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 2, + "value_localized": "Automatic programme", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 1799, + "value_localized": "Drying", + "key_localized": "Program phase" + }, + "remainingTime": [0, 15], + "startTime": [0, 0], + "targetTemperature": [ + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } + ], + "temperature": [ + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 59], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": { + "currentWaterConsumption": { + "unit": "l", + "value": 12 + }, + "currentEnergyConsumption": { + "unit": "kWh", + "value": 1.4 + }, + "waterForecast": 0.2, + "energyForecast": 0.1 + }, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/action_freezer.json b/tests/components/miele/fixtures/action_freezer.json new file mode 100644 index 00000000000..1d6e8832bae --- /dev/null +++ b/tests/components/miele/fixtures/action_freezer.json @@ -0,0 +1,21 @@ +{ + "processAction": [4], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": -28, + "max": -14 + } + ], + "deviceName": true, + "powerOn": false, + "powerOff": false, + "colors": [], + "modes": [1], + "runOnTime": [] +} diff --git a/tests/components/miele/fixtures/action_fridge.json b/tests/components/miele/fixtures/action_fridge.json new file mode 100644 index 00000000000..9bfc7810a41 --- /dev/null +++ b/tests/components/miele/fixtures/action_fridge.json @@ -0,0 +1,21 @@ +{ + "processAction": [6], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": 1, + "max": 9 + } + ], + "deviceName": true, + "powerOn": false, + "powerOff": false, + "colors": [], + "modes": [1], + "runOnTime": [] +} diff --git a/tests/components/miele/fixtures/action_washing_machine.json b/tests/components/miele/fixtures/action_washing_machine.json new file mode 100644 index 00000000000..363d3ae6c63 --- /dev/null +++ b/tests/components/miele/fixtures/action_washing_machine.json @@ -0,0 +1,21 @@ +{ + "processAction": [1, 2, 3], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": -28, + "max": -14 + } + ], + "deviceName": true, + "powerOn": true, + "powerOff": false, + "colors": [], + "modes": [], + "runOnTime": [] +} diff --git a/tests/components/miele/fixtures/fan_devices.json b/tests/components/miele/fixtures/fan_devices.json new file mode 100644 index 00000000000..9904f6f5faa --- /dev/null +++ b/tests/components/miele/fixtures/fan_devices.json @@ -0,0 +1,338 @@ +{ + "DummyAppliance_18": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 18, + "value_localized": "Cooker Hood" + }, + "deviceName": "", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "", + "fabIndex": "64", + "techType": "Fläkt", + "matNumber": "", + "swids": ["", "", "", "<...>"] + }, + "xkmIdentLabel": { + "techType": "EK039W", + "releaseVersion": "02.72" + } + }, + "state": { + "ProgramID": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 4608, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": 2, + "light": 1, + "elapsedTime": {}, + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": 0, + "value_localized": "0", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "DummyAppliance_74": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 74, + "value_localized": "Hob w extraction" + }, + "deviceName": "", + "protocolVersion": 203, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KMDA7634", + "matNumber": "", + "swids": ["000"] + }, + "xkmIdentLabel": { + "techType": "EK039W", + "releaseVersion": "02.72" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } + ], + "temperature": [ + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" }, + { "value_raw": -32768, "value_localized": null, "unit": "Celsius" } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": "", + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": 1, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [ + { + "value_raw": 0, + "value_localized": 0, + "key_localized": "Power level" + }, + { + "value_raw": 3, + "value_localized": 2, + "key_localized": "Power level" + }, + { + "value_raw": 7, + "value_localized": 4, + "key_localized": "Power level" + }, + { + "value_raw": 15, + "value_localized": 8, + "key_localized": "Power level" + }, + { + "value_raw": 117, + "value_localized": 10, + "key_localized": "Power level" + } + ], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "DummyAppliance_74_off": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 74, + "value_localized": "Hob with vapour extraction" + }, + "deviceName": "", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KMDA7473", + "matNumber": "", + "swids": ["000"] + }, + "xkmIdentLabel": { + "techType": "EK039W", + "releaseVersion": "02.80" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": false, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/hob.json b/tests/components/miele/fixtures/hob.json new file mode 100644 index 00000000000..f86c6a0044f --- /dev/null +++ b/tests/components/miele/fixtures/hob.json @@ -0,0 +1,168 @@ +{ + "DummyAppliance_hob_w_extr": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 74, + "value_localized": "Hob with vapour extraction" + }, + "deviceName": "KDMA7774 | APP2-2", + "protocolVersion": 2, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KMDA7774-1 R01", + "matNumber": "10974770", + "swids": [ + "4088", + "20269", + "25122", + "4194", + "20270", + "25077", + "4194", + "20270", + "25077", + "4215", + "20270", + "25134", + "4438", + "20314", + "25128" + ] + }, + "xkmIdentLabel": { + "techType": "EK039W", + "releaseVersion": "02.72" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [ + { + "value_raw": 0, + "value_localized": 0, + "key_localized": "Power level" + }, + { + "value_raw": 110, + "value_localized": 2, + "key_localized": "Power level" + }, + { + "value_raw": 8, + "value_localized": 4, + "key_localized": "Power level" + }, + { + "value_raw": 15, + "value_localized": 8, + "key_localized": "Power level" + }, + { + "value_raw": 117, + "value_localized": 10, + "key_localized": "Power level" + } + ], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/programs_washing_machine.json b/tests/components/miele/fixtures/programs_washing_machine.json new file mode 100644 index 00000000000..a3c16ece8e6 --- /dev/null +++ b/tests/components/miele/fixtures/programs_washing_machine.json @@ -0,0 +1,117 @@ +[ + { + "programId": 146, + "program": "QuickPowerWash", + "parameters": {} + }, + { + "programId": 123, + "program": "Dark garments / Denim", + "parameters": {} + }, + { + "programId": 190, + "program": "ECO 40-60 ", + "parameters": {} + }, + { + "programId": 27, + "program": "Proofing", + "parameters": {} + }, + { + "programId": 23, + "program": "Shirts", + "parameters": {} + }, + { + "programId": 9, + "program": "Silks ", + "parameters": {} + }, + { + "programId": 8, + "program": "Woollens ", + "parameters": {} + }, + { + "programId": 4, + "program": "Delicates", + "parameters": {} + }, + { + "programId": 3, + "program": "Minimum iron", + "parameters": {} + }, + { + "programId": 1, + "program": "Cottons", + "parameters": {} + }, + { + "programId": 69, + "program": "Cottons hygiene", + "parameters": {} + }, + { + "programId": 37, + "program": "Outerwear", + "parameters": {} + }, + { + "programId": 122, + "program": "Express 20", + "parameters": {} + }, + { + "programId": 29, + "program": "Sportswear", + "parameters": {} + }, + { + "programId": 31, + "program": "Automatic plus", + "parameters": {} + }, + { + "programId": 39, + "program": "Pillows", + "parameters": {} + }, + { + "programId": 22, + "program": "Curtains", + "parameters": {} + }, + { + "programId": 129, + "program": "Down filled items", + "parameters": {} + }, + { + "programId": 53, + "program": "First wash", + "parameters": {} + }, + { + "programId": 95, + "program": "Down duvets", + "parameters": {} + }, + { + "programId": 52, + "program": "Separate rinse / Starch", + "parameters": {} + }, + { + "programId": 21, + "program": "Drain / Spin", + "parameters": {} + }, + { + "programId": 91, + "program": "Clean machine", + "parameters": {} + } +] diff --git a/tests/components/miele/fixtures/vacuum_device.json b/tests/components/miele/fixtures/vacuum_device.json new file mode 100644 index 00000000000..5aa402a3493 --- /dev/null +++ b/tests/components/miele/fixtures/vacuum_device.json @@ -0,0 +1,82 @@ +{ + "Dummy_Vacuum_1": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 23, + "value_localized": "Robot vacuum cleaner" + }, + "deviceName": "", + "protocolVersion": 0, + "deviceIdentLabel": { + "fabNumber": "161173909", + "fabIndex": "32", + "techType": "RX3", + "matNumber": "11686510", + "swids": ["", "", "", "<...>"] + }, + "xkmIdentLabel": { + "techType": "", + "releaseVersion": "" + } + }, + "state": { + "ProgramID": { + "value_raw": 1, + "value_localized": "Auto", + "key_localized": "Program name" + }, + "status": { + "value_raw": 2, + "value_localized": "On", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 5889, + "value_localized": "in the base station", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [], + "temperature": [], + "coreTargetTemperature": [], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": 65 + } + } +} diff --git a/tests/components/miele/snapshots/test_binary_sensor.ambr b/tests/components/miele/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..9f5b886b0ba --- /dev/null +++ b/tests/components/miele/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1093 @@ +# serializer version: 1 +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.freezer_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_1-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Freezer Door', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.freezer_mobile_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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'Dummy_Appliance_1-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Freezer Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.freezer_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'Dummy_Appliance_1-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Freezer Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.freezer_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_1-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Freezer Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.freezer_remote_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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'Dummy_Appliance_1-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Freezer Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.freezer_smart_grid', + 'has_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 grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'Dummy_Appliance_1-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.freezer_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Freezer Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.freezer_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hood_mobile_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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'DummyAppliance_18-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.hood_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hood_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'DummyAppliance_18-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Hood Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.hood_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hood_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_18-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Hood Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.hood_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hood_remote_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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'DummyAppliance_18-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.hood_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.hood_smart_grid', + 'has_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 grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'DummyAppliance_18-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.hood_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.hood_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_2-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.refrigerator_mobile_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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'Dummy_Appliance_2-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.refrigerator_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'Dummy_Appliance_2-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Refrigerator Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.refrigerator_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_2-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Refrigerator Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.refrigerator_remote_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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'Dummy_Appliance_2-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.refrigerator_smart_grid', + 'has_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 grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'Dummy_Appliance_2-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washing_machine_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_3-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Washing machine Door', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_mobile_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.washing_machine_mobile_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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'Dummy_Appliance_3-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_notification_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.washing_machine_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'Dummy_Appliance_3-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Washing machine Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.washing_machine_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_3-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Washing machine Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.washing_machine_remote_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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'Dummy_Appliance_3-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_smart_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.washing_machine_smart_grid', + 'has_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 grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'Dummy_Appliance_3-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.washing_machine_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/miele/snapshots/test_button.ambr b/tests/components/miele/snapshots/test_button.ambr new file mode 100644 index 00000000000..b4f5ea5685a --- /dev/null +++ b/tests/components/miele/snapshots/test_button.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_button_states[platforms0][button.hood_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.hood_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_18-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.hood_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Stop', + }), + 'context': , + 'entity_id': 'button.hood_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_pause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.washing_machine_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': 'Dummy_Appliance_3-pause', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_pause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Pause', + }), + 'context': , + 'entity_id': 'button.washing_machine_pause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.washing_machine_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'Dummy_Appliance_3-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Start', + }), + 'context': , + 'entity_id': 'button.washing_machine_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.washing_machine_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'Dummy_Appliance_3-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.washing_machine_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Stop', + }), + 'context': , + 'entity_id': 'button.washing_machine_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/miele/snapshots/test_climate.ambr b/tests/components/miele/snapshots/test_climate.ambr new file mode 100644 index 00000000000..85f7bf212f5 --- /dev/null +++ b/tests/components/miele/snapshots/test_climate.ambr @@ -0,0 +1,127 @@ +# serializer version: 1 +# name: test_climate_states[platforms0-freezer][climate.freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.freezer', + 'has_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': 'miele', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'freezer', + 'unique_id': 'Dummy_Appliance_1-thermostat-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states[platforms0-freezer][climate.freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': -18, + 'friendly_name': 'Freezer', + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': -18, + }), + 'context': , + 'entity_id': 'climate.freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states[platforms0-freezer][climate.refrigerator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.refrigerator', + 'has_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': 'miele', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'refrigerator', + 'unique_id': 'Dummy_Appliance_2-thermostat-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states[platforms0-freezer][climate.refrigerator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 4, + 'friendly_name': 'Refrigerator', + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.refrigerator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/miele/snapshots/test_diagnostics.ambr b/tests/components/miele/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..92e312f8d73 --- /dev/null +++ b/tests/components/miele/snapshots/test_diagnostics.ambr @@ -0,0 +1,854 @@ +# serializer version: 1 +# name: test_diagnostics_config_entry + dict({ + 'config_entry_data': dict({ + 'auth_implementation': 'miele', + 'token': dict({ + 'access_token': '**REDACTED**', + 'expires_in': 86399, + 'refresh_token': '**REDACTED**', + 'token_type': 'Bearer', + }), + }), + 'miele_data': dict({ + 'actions': dict({ + '**REDACTED_019aa577ad1c330d': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + dict({ + 'max': -14, + 'min': -28, + 'zone': 1, + }), + ]), + 'ventilationStep': list([ + ]), + }), + '**REDACTED_4b870e84d3e80013': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + dict({ + 'max': -14, + 'min': -28, + 'zone': 1, + }), + ]), + 'ventilationStep': list([ + ]), + }), + '**REDACTED_57d53e72806e88b4': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + dict({ + 'max': -14, + 'min': -28, + 'zone': 1, + }), + ]), + 'ventilationStep': list([ + ]), + }), + '**REDACTED_c9fe55cdf70786ca': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + dict({ + 'max': -14, + 'min': -28, + 'zone': 1, + }), + ]), + 'ventilationStep': list([ + ]), + }), + }), + 'devices': dict({ + '**REDACTED_019aa577ad1c330d': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '17', + 'fabNumber': '**REDACTED**', + 'matNumber': '10804770', + 'swids': list([ + '4497', + ]), + 'techType': 'KS 28423 D ed/c', + }), + 'deviceName': '', + 'protocolVersion': 201, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Refrigerator', + 'value_raw': 19, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '31.17', + 'techType': 'EK042', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': '', + 'value_raw': 0, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + ]), + 'coreTemperature': list([ + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': list([ + ]), + 'light': None, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': '', + 'value_raw': 0, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': '', + 'value_raw': 0, + }), + 'remainingTime': list([ + 0, + 0, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': False, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'In use', + 'value_raw': 5, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 4, + 'value_raw': 400, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 4, + 'value_raw': 400, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), + '**REDACTED_4b870e84d3e80013': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '64', + 'fabNumber': '**REDACTED**', + 'matNumber': '', + 'swids': list([ + '', + '', + '', + '<...>', + ]), + 'techType': 'Fläkt', + }), + 'deviceName': '', + 'protocolVersion': 2, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Cooker Hood', + 'value_raw': 18, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '02.72', + 'techType': 'EK039W', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': 'Off', + 'value_raw': 1, + }), + 'ambientLight': 2, + 'batteryLevel': None, + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': dict({ + }), + 'light': 1, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': '', + 'value_raw': 4608, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': 'Program', + 'value_raw': 0, + }), + 'remainingTime': list([ + 0, + 0, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': False, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'Off', + 'value_raw': 1, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '0', + 'value_raw': 0, + }), + }), + }), + '**REDACTED_57d53e72806e88b4': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '21', + 'fabNumber': '**REDACTED**', + 'matNumber': '10805070', + 'swids': list([ + '4497', + ]), + 'techType': 'FNS 28463 E ed/', + }), + 'deviceName': '', + 'protocolVersion': 201, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Freezer', + 'value_raw': 20, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '31.17', + 'techType': 'EK042', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': '', + 'value_raw': 0, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + ]), + 'coreTemperature': list([ + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': list([ + ]), + 'light': None, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': '', + 'value_raw': 0, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': '', + 'value_raw': 0, + }), + 'remainingTime': list([ + 0, + 0, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': False, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'In use', + 'value_raw': 5, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': -18, + 'value_raw': -1800, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': -18, + 'value_raw': -1800, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), + '**REDACTED_c9fe55cdf70786ca': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '44', + 'fabNumber': '**REDACTED**', + 'matNumber': '11387290', + 'swids': list([ + '5975', + '20456', + '25213', + '25191', + '25446', + '25205', + '25447', + '25319', + ]), + 'techType': 'WCI870', + }), + 'deviceName': '', + 'protocolVersion': 4, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Washing machine', + 'value_raw': 1, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '08.32', + 'techType': 'EK057', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': '', + 'value_raw': 0, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'coreTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': dict({ + 'currentEnergyConsumption': dict({ + 'unit': 'kWh', + 'value': 0.0, + }), + 'currentWaterConsumption': dict({ + 'unit': 'l', + 'value': 0.0, + }), + 'energyForecast': 0.1, + 'waterForecast': 0.0, + }), + 'elapsedTime': list([ + 0, + 0, + ]), + 'light': None, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': '', + 'value_raw': 0, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': '', + 'value_raw': 0, + }), + 'remainingTime': list([ + 0, + 0, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': False, + 'smartGrid': False, + }), + 'signalDoor': True, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'Off', + 'value_raw': 1, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), + }), + 'missing_code_warnings': list([ + 'None', + ]), + }), + }) +# --- +# name: test_diagnostics_device + dict({ + 'data': dict({ + 'auth_implementation': 'miele', + 'token': dict({ + 'access_token': '**REDACTED**', + 'expires_in': 86399, + 'refresh_token': '**REDACTED**', + 'token_type': 'Bearer', + }), + }), + 'info': dict({ + 'manufacturer': 'Miele', + 'model': 'FNS 28463 E ed/', + }), + 'miele_data': dict({ + 'actions': dict({ + '**REDACTED_57d53e72806e88b4': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + dict({ + 'max': -14, + 'min': -28, + 'zone': 1, + }), + ]), + 'ventilationStep': list([ + ]), + }), + }), + 'devices': dict({ + '**REDACTED_57d53e72806e88b4': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '21', + 'fabNumber': '**REDACTED**', + 'matNumber': '10805070', + 'swids': list([ + '4497', + ]), + 'techType': 'FNS 28463 E ed/', + }), + 'deviceName': '', + 'protocolVersion': 201, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Freezer', + 'value_raw': 20, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '31.17', + 'techType': 'EK042', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': '', + 'value_raw': 0, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + ]), + 'coreTemperature': list([ + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': list([ + ]), + 'light': None, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': '', + 'value_raw': 0, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': '', + 'value_raw': 0, + }), + 'remainingTime': list([ + 0, + 0, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': False, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'In use', + 'value_raw': 5, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': -18, + 'value_raw': -1800, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': -18, + 'value_raw': -1800, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), + }), + 'missing_code_warnings': list([ + 'None', + ]), + 'programs': 'Not implemented', + }), + }) +# --- diff --git a/tests/components/miele/snapshots/test_fan.ambr b/tests/components/miele/snapshots/test_fan.ambr new file mode 100644 index 00000000000..595d4463462 --- /dev/null +++ b/tests/components/miele/snapshots/test_fan.ambr @@ -0,0 +1,153 @@ +# serializer version: 1 +# name: test_fan_states[fan_devices.json-platforms0][fan.hob_with_extraction_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hob_with_extraction_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan', + 'unique_id': 'DummyAppliance_74-fan_readonly', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hob_with_extraction_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hob with extraction Fan', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hob_with_extraction_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hob_with_extraction_fan_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hob_with_extraction_fan_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': 'Fan', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan', + 'unique_id': 'DummyAppliance_74_off-fan_readonly', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hob_with_extraction_fan_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hob with extraction Fan', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hob_with_extraction_fan_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hood_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hood_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'fan', + 'unique_id': 'DummyAppliance_18-fan', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_states[fan_devices.json-platforms0][fan.hood_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hood_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/miele/snapshots/test_init.ambr b/tests/components/miele/snapshots/test_init.ambr new file mode 100644 index 00000000000..eee976ab09f --- /dev/null +++ b/tests/components/miele/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'EK042', + 'id': , + 'identifiers': set({ + tuple( + 'miele', + 'Dummy_Appliance_1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Miele', + 'model': 'FNS 28463 E ed/', + 'model_id': None, + 'name': 'Freezer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'Dummy_Appliance_1', + 'suggested_area': None, + 'sw_version': '31.17', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/miele/snapshots/test_light.ambr b/tests/components/miele/snapshots/test_light.ambr new file mode 100644 index 00000000000..128b642d7a0 --- /dev/null +++ b/tests/components/miele/snapshots/test_light.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_light_states[platforms0][light.hood_ambient_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hood_ambient_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': 'Ambient light', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ambient_light', + 'unique_id': 'DummyAppliance_18-ambient_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states[platforms0][light.hood_ambient_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Hood Ambient light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.hood_ambient_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_light_states[platforms0][light.hood_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hood_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': 'Light', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'DummyAppliance_18-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states[platforms0][light.hood_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Hood Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.hood_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..aadcdb1118d --- /dev/null +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -0,0 +1,1736 @@ +# serializer version: 1 +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pot-steam-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_hob_w_extr-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction', + 'icon': 'mdi:pot-steam-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_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': 'Plate 1', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_hob_w_extr-state_plate_step-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 1', + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_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': 'Plate 2', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_hob_w_extr-state_plate_step-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 2', + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '110', + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_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': 'Plate 3', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_hob_w_extr-state_plate_step-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 3', + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 4', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_hob_w_extr-state_plate_step-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 4', + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_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': 'Plate 5', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_hob_w_extr-state_plate_step-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 5', + 'options': list([ + '0', + '110', + '220', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '117', + '118', + '217', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '117', + }) +# --- +# name: test_sensor_states[platforms0][sensor.freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.freezer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fridge-industrial-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_1-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Freezer', + 'icon': 'mdi:fridge-industrial-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states[platforms0][sensor.freezer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.freezer_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_1-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Freezer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.hood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:turbine', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_18-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.hood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hood', + 'icon': 'mdi:turbine', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.hood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fridge-industrial-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_2-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Refrigerator', + 'icon': 'mdi:fridge-industrial-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.refrigerator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_2-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:washing-machine', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_3-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing machine', + 'icon': 'mdi:washing-machine', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_elapsed_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_elapsed_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': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'Dummy_Appliance_3-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Washing machine Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_energy_consumption', + '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 consumption', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumption', + 'unique_id': 'Dummy_Appliance_3-current_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing machine Energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_energy_forecast-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_energy_forecast', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Energy forecast', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_forecast', + 'unique_id': 'Dummy_Appliance_3-energy_forecast', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_energy_forecast-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Energy forecast', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_forecast', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_plus', + 'clean_machine', + 'cool_air', + 'cottons', + 'cottons_eco', + 'cottons_hygiene', + 'curtains', + 'dark_garments', + 'delicates', + 'denim', + 'down_duvets', + 'down_filled_items', + 'drain_spin', + 'eco_40_60', + 'express_20', + 'first_wash', + 'freshen_up', + 'minimum_iron', + 'no_program', + 'outerwear', + 'pillows', + 'proofing', + 'quick_power_wash', + 'rinse', + 'rinse_out_lint', + 'separate_rinse_starch', + 'shirts', + 'silks', + 'sportswear', + 'starch', + 'steam_care', + 'trainers', + 'warm_air', + 'woollens', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'Dummy_Appliance_3-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing machine Program', + 'options': list([ + 'automatic_plus', + 'clean_machine', + 'cool_air', + 'cottons', + 'cottons_eco', + 'cottons_hygiene', + 'curtains', + 'dark_garments', + 'delicates', + 'denim', + 'down_duvets', + 'down_filled_items', + 'drain_spin', + 'eco_40_60', + 'express_20', + 'first_wash', + 'freshen_up', + 'minimum_iron', + 'no_program', + 'outerwear', + 'pillows', + 'proofing', + 'quick_power_wash', + 'rinse', + 'rinse_out_lint', + 'separate_rinse_starch', + 'shirts', + 'silks', + 'sportswear', + 'starch', + 'steam_care', + 'trainers', + 'warm_air', + 'woollens', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_program', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'anti_crease', + 'cleaning', + 'cooling_down', + 'disinfecting', + 'drain', + 'drying', + 'finished', + 'freshen_up_and_moisten', + 'hygiene', + 'main_wash', + 'not_running', + 'pre_wash', + 'rinse', + 'rinse_hold', + 'soak', + 'spin', + 'starch_stop', + 'steam_smoothing', + 'venting', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_program_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program phase', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'program_phase', + 'unique_id': 'Dummy_Appliance_3-state_program_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing machine Program phase', + 'options': list([ + 'anti_crease', + 'cleaning', + 'cooling_down', + 'disinfecting', + 'drain', + 'drying', + 'finished', + 'freshen_up_and_moisten', + 'hygiene', + 'main_wash', + 'not_running', + 'pre_wash', + 'rinse', + 'rinse_hold', + 'soak', + 'spin', + 'starch_stop', + 'steam_smoothing', + 'venting', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_program_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_running', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'Dummy_Appliance_3-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing machine Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal_operation_mode', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_remaining_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': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'Dummy_Appliance_3-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Washing machine Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_spin_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_spin_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': 'Spin speed', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spin_speed', + 'unique_id': 'Dummy_Appliance_3-state_spinning_speed', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_spin_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Spin speed', + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_spin_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_start_in-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_start_in', + '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': 'Start in', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'Dummy_Appliance_3-state_start_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_start_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Washing machine Start in', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_start_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_water_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_water_consumption', + '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 consumption', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_consumption', + 'unique_id': 'Dummy_Appliance_3-current_water_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_water_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Washing machine Water consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_water_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_water_forecast-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_water_forecast', + 'has_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 forecast', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_forecast', + 'unique_id': 'Dummy_Appliance_3-water_forecast', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_water_forecast-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Water forecast', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_water_forecast', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/miele/snapshots/test_switch.ambr b/tests/components/miele/snapshots/test_switch.ambr new file mode 100644 index 00000000000..b7f49f84eed --- /dev/null +++ b/tests/components/miele/snapshots/test_switch.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_switch_states[platforms0][switch.freezer_superfreezing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.freezer_superfreezing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Superfreezing', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'superfreezing', + 'unique_id': 'Dummy_Appliance_1-superfreezing', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.freezer_superfreezing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Freezer Superfreezing', + }), + 'context': , + 'entity_id': 'switch.freezer_superfreezing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.hood_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.hood_power', + 'has_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', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_18-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.hood_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hood Power', + }), + 'context': , + 'entity_id': 'switch.hood_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.refrigerator_supercooling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.refrigerator_supercooling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Supercooling', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supercooling', + 'unique_id': 'Dummy_Appliance_2-supercooling', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.refrigerator_supercooling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Supercooling', + }), + 'context': , + 'entity_id': 'switch.refrigerator_supercooling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_states[platforms0][switch.washing_machine_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.washing_machine_power', + 'has_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', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'Dummy_Appliance_3-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing machine Power', + }), + 'context': , + 'entity_id': 'switch.washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/miele/snapshots/test_vacuum.ambr b/tests/components/miele/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..8147b56282d --- /dev/null +++ b/tests/components/miele/snapshots/test_vacuum.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_sensor_states[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_speed_list': list([ + 'normal', + 'turbo', + 'silent', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.robot_vacuum_cleaner', + 'has_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': 'miele', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vacuum', + 'unique_id': 'Dummy_Vacuum_1-vacuum', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_icon': 'mdi:battery-60', + 'battery_level': 65, + 'fan_speed': 'normal', + 'fan_speed_list': list([ + 'normal', + 'turbo', + 'silent', + ]), + 'friendly_name': 'Robot vacuum cleaner', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.robot_vacuum_cleaner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cleaning', + }) +# --- diff --git a/tests/components/miele/test_binary_sensor.py b/tests/components/miele/test_binary_sensor.py new file mode 100644 index 00000000000..db44ea554a4 --- /dev/null +++ b/tests/components/miele/test_binary_sensor.py @@ -0,0 +1,26 @@ +"""Tests for miele binary sensor module.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test binary sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) diff --git a/tests/components/miele/test_button.py b/tests/components/miele/test_button.py new file mode 100644 index 00000000000..d3cfb2af999 --- /dev/null +++ b/tests/components/miele/test_button.py @@ -0,0 +1,65 @@ +"""Tests for Miele button module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +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 +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 = BUTTON_DOMAIN +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) + +ENTITY_ID = "button.washing_machine_start" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test button entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_press( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: MockConfigEntry, +) -> None: + """Test button press.""" + + await hass.services.async_call( + TEST_PLATFORM, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once_with( + "Dummy_Appliance_3", {"processAction": 1} + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: MockConfigEntry, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.send_action.side_effect = ClientResponseError("test", "Test") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() diff --git a/tests/components/miele/test_climate.py b/tests/components/miele/test_climate.py new file mode 100644 index 00000000000..bff55311f4b --- /dev/null +++ b/tests/components/miele/test_climate.py @@ -0,0 +1,78 @@ +"""Tests for miele climate module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +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 = CLIMATE_DOMAIN +pytestmark = [ + pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]), + pytest.mark.parametrize( + "load_action_file", + ["action_freezer.json"], + ids=[ + "freezer", + ], + ), +] + +ENTITY_ID = "climate.freezer" +SERVICE_SET_TEMPERATURE = "set_temperature" + + +async def test_climate_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test climate entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +async def test_set_target( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: MockConfigEntry, +) -> None: + """Test the climate can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: -17}, + blocking=True, + ) + mock_miele_client.set_target_temperature.assert_called_once_with( + "Dummy_Appliance_1", -17.0, 1 + ) + + +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: MockConfigEntry, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.set_target_temperature.side_effect = ClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: -17}, + blocking=True, + ) + mock_miele_client.set_target_temperature.assert_called_once() diff --git a/tests/components/miele/test_config_flow.py b/tests/components/miele/test_config_flow.py new file mode 100644 index 00000000000..78478bc0e9d --- /dev/null +++ b/tests/components/miele/test_config_flow.py @@ -0,0 +1,266 @@ +"""Test the Miele config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock + +from pymiele import OAUTH2_AUTHORIZE, OAUTH2_TOKEN +import pytest + +from homeassistant.components.miele.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 .const import CLIENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +REDIRECT_URL = "https://example.com/auth/external/callback" + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: Generator[AsyncMock], +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + 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}" + "&vg=sv-SE" + ) + + 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": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") is FlowResultType.EXTERNAL_STEP + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_reauth_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + access_token: str, + expires_at: float, +) -> None: + """Test reauth step with correct params.""" + + CURRENT_TOKEN = { + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "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, + ) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + 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}" + "&vg=sv-SE" + ) + + 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", + }, + ) + + 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 len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_reconfigure_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + access_token: str, + expires_at: float, +) -> None: + """Test reconfigure step with correct params.""" + + CURRENT_TOKEN = { + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "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, + ) + 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}" + "&vg=sv-SE" + ) + + 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", + }, + ) + + 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") == "reconfigure_successful" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_zeroconf_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: Generator[AsyncMock], +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF} + ) + 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}" + "&vg=sv-SE" + ) + + 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": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + assert result.get("type") is FlowResultType.EXTERNAL_STEP + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = result["result"] + assert config_entry.domain == "miele" diff --git a/tests/components/miele/test_diagnostics.py b/tests/components/miele/test_diagnostics.py new file mode 100644 index 00000000000..e613a4e512e --- /dev/null +++ b/tests/components/miele/test_diagnostics.py @@ -0,0 +1,69 @@ +"""Tests for the diagnostics data provided by the miele integration.""" + +from collections.abc import Generator +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import paths + +from homeassistant.components.miele.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics_config_entry( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_miele_client: Generator[MagicMock], + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + + await setup_integration(hass, mock_config_entry) + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot( + exclude=paths( + "config_entry_data.token.expires_at", + "miele_test.entry_id", + ) + ) + + +async def test_diagnostics_device( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: DeviceRegistry, + mock_miele_client: Generator[MagicMock], + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for device.""" + + TEST_DEVICE = "Dummy_Appliance_1" + + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, TEST_DEVICE)}) + assert device_entry is not None + + result = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device_entry + ) + assert result == snapshot( + exclude=paths( + "data.token.expires_at", + "miele_test.entry_id", + ) + ) diff --git a/tests/components/miele/test_fan.py b/tests/components/miele/test_fan.py new file mode 100644 index 00000000000..47c7c4fb8ec --- /dev/null +++ b/tests/components/miele/test_fan.py @@ -0,0 +1,114 @@ +"""Tests for miele fan module.""" + +from typing import Any +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import ATTR_PERCENTAGE, DOMAIN as FAN_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +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 = FAN_DOMAIN +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) + +ENTITY_ID = "fan.hood_fan" + + +@pytest.mark.parametrize("load_device_file", ["fan_devices.json"]) +async def test_fan_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test fan entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["fan_devices.json"]) +@pytest.mark.parametrize( + ("service", "expected_argument"), + [ + (SERVICE_TURN_ON, {"powerOn": True}), + (SERVICE_TURN_OFF, {"powerOff": True}), + ], +) +async def test_fan_control( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: MockConfigEntry, + service: str, + expected_argument: dict[str, Any], +) -> None: + """Test the fan can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_miele_client.send_action.assert_called_once_with( + "DummyAppliance_18", expected_argument + ) + + +@pytest.mark.parametrize( + ("service", "percentage", "expected_argument"), + [ + ("set_percentage", 0, {"powerOff": True}), + ("set_percentage", 20, {"ventilationStep": 1}), + ("set_percentage", 100, {"ventilationStep": 4}), + ], +) +async def test_fan_set_speed( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: MockConfigEntry, + service: str, + percentage: int, + expected_argument: dict[str, Any], +) -> None: + """Test the fan can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, + service, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: percentage}, + blocking=True, + ) + mock_miele_client.send_action.assert_called_once_with( + "DummyAppliance_18", expected_argument + ) + + +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_TURN_ON), + (SERVICE_TURN_OFF), + ], +) +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: MockConfigEntry, + service: str, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.send_action.side_effect = ClientResponseError("test", "Test") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() diff --git a/tests/components/miele/test_init.py b/tests/components/miele/test_init.py new file mode 100644 index 00000000000..dae3d5ef79c --- /dev/null +++ b/tests/components/miele/test_init.py @@ -0,0 +1,210 @@ +"""Tests for init module.""" + +from datetime import timedelta +import http +import time +from unittest.mock import MagicMock + +from aiohttp import ClientConnectionError +from freezegun.api import FrozenDateTimeFactory +from pymiele import OAUTH2_TOKEN +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.miele.const import DOMAIN +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 tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = mock_config_entry + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["unauthorized", "internal_server_error"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + status=status, + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_connection_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> 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 ConfigEntryState.SETUP_RETRY + + +async def test_devices_multiple_created_count( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that multiple devices are created.""" + await setup_integration(hass, mock_config_entry) + + assert len(device_registry.devices) == 4 + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_miele_client: MagicMock, + 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, "Dummy_Appliance_1")} + ) + assert device_entry is not None + assert device_entry == snapshot + + +async def test_device_remove_devices( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + mock_miele_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, + "Dummy_Appliance_1", + ) + }, + ) + 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"] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_setup_all_platforms( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + load_device_file: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that all platforms can be set up.""" + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("binary_sensor.freezer_door").state == "off" + assert hass.states.get("binary_sensor.hood_problem").state == "off" + + assert ( + hass.states.get("button.washing_machine_start").object_id + == "washing_machine_start" + ) + + assert hass.states.get("climate.freezer").state == "cool" + assert hass.states.get("light.hood_light").state == "on" + + assert hass.states.get("sensor.freezer_temperature").state == "-18.0" + assert hass.states.get("sensor.washing_machine").state == "off" + + assert hass.states.get("switch.washing_machine_power").state == "off" + + # Add two devices and let the clock tick for 130 seconds + freezer.tick(timedelta(seconds=130)) + mock_miele_client.get_devices.return_value = load_json_object_fixture( + "5_devices.json", DOMAIN + ) + + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(device_registry.devices) == 6 + + # Check a sample sensor for each new device + assert hass.states.get("sensor.dishwasher").state == "in_use" + assert hass.states.get("sensor.oven_temperature").state == "175.0" diff --git a/tests/components/miele/test_light.py b/tests/components/miele/test_light.py new file mode 100644 index 00000000000..c0cae688c1c --- /dev/null +++ b/tests/components/miele/test_light.py @@ -0,0 +1,79 @@ +"""Tests for miele light module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +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 = LIGHT_DOMAIN +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) + +ENTITY_ID = "light.hood_light" + + +async def test_light_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test light entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize( + ("service", "light_state"), + [ + (SERVICE_TURN_ON, 1), + (SERVICE_TURN_OFF, 2), + ], +) +async def test_light_toggle( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: MockConfigEntry, + service: str, + light_state: int, +) -> None: + """Test the light can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once_with( + "DummyAppliance_18", {"light": light_state} + ) + + +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_TURN_ON), + (SERVICE_TURN_OFF), + ], +) +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: MockConfigEntry, + service: str, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.send_action.side_effect = ClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py new file mode 100644 index 00000000000..7beb2fec8f1 --- /dev/null +++ b/tests/components/miele/test_sensor.py @@ -0,0 +1,41 @@ +"""Tests for miele sensor module.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["hob.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hob_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) diff --git a/tests/components/miele/test_switch.py b/tests/components/miele/test_switch.py new file mode 100644 index 00000000000..d60708c24e1 --- /dev/null +++ b/tests/components/miele/test_switch.py @@ -0,0 +1,94 @@ +"""Tests for miele switch module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientError +import pytest +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 +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 = SWITCH_DOMAIN +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) + +ENTITY_ID = "switch.freezer_superfreezing" + + +async def test_switch_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test switch entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize( + ("entity"), + [ + (ENTITY_ID), + ("switch.refrigerator_supercooling"), + ("switch.washing_machine_power"), + ], +) +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_TURN_ON), + (SERVICE_TURN_OFF), + ], +) +async def test_switching( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: MockConfigEntry, + service: str, + entity: str, +) -> None: + """Test the switch can be turned on/off.""" + + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: entity}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() + + +@pytest.mark.parametrize( + ("entity"), + [ + (ENTITY_ID), + ("switch.refrigerator_supercooling"), + ("switch.washing_machine_power"), + ], +) +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_TURN_ON), + (SERVICE_TURN_OFF), + ], +) +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: MockConfigEntry, + service: str, + entity: str, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.send_action.side_effect = ClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: entity}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() diff --git a/tests/components/miele/test_vacuum.py b/tests/components/miele/test_vacuum.py new file mode 100644 index 00000000000..f1f0ae22930 --- /dev/null +++ b/tests/components/miele/test_vacuum.py @@ -0,0 +1,119 @@ +"""Tests for miele vacuum module.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.miele.const import PROCESS_ACTION, PROGRAM_ID +from homeassistant.components.vacuum import ( + ATTR_FAN_SPEED, + DOMAIN as VACUUM_DOMAIN, + SERVICE_CLEAN_SPOT, + SERVICE_PAUSE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + SERVICE_STOP, +) +from homeassistant.const import ATTR_ENTITY_ID +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 = VACUUM_DOMAIN +ENTITY_ID = "vacuum.robot_vacuum_cleaner" + +pytestmark = [ + pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]), + pytest.mark.parametrize("load_device_file", ["vacuum_device.json"]), +] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test vacuum entity setup.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "action_command", "vacuum_power"), + [ + (SERVICE_START, PROCESS_ACTION, 1), + (SERVICE_STOP, PROCESS_ACTION, 2), + (SERVICE_PAUSE, PROCESS_ACTION, 3), + (SERVICE_CLEAN_SPOT, PROGRAM_ID, 2), + ], +) +async def test_vacuum_program( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, + vacuum_power: int | str, + action_command: str, +) -> None: + """Test the vacuum can be controlled.""" + + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once_with( + "Dummy_Vacuum_1", {action_command: vacuum_power} + ) + + +@pytest.mark.parametrize( + ("fan_speed", "expected"), [("normal", 1), ("turbo", 3), ("silent", 4)] +) +async def test_vacuum_fan_speed( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + fan_speed: str, + expected: int, +) -> None: + """Test the vacuum can be controlled.""" + + await hass.services.async_call( + TEST_PLATFORM, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_SPEED: fan_speed}, + blocking=True, + ) + mock_miele_client.send_action.assert_called_once_with( + "Dummy_Vacuum_1", {"programId": expected} + ) + + +@pytest.mark.parametrize( + ("service"), + [ + (SERVICE_START), + (SERVICE_STOP), + ], +) +async def test_api_failure( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + service: str, +) -> None: + """Test handling of exception from API.""" + mock_miele_client.send_action.side_effect = ClientResponseError("test", "Test") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + mock_miele_client.send_action.assert_called_once() diff --git a/tests/components/mill/conftest.py b/tests/components/mill/conftest.py new file mode 100644 index 00000000000..28b2e58057b --- /dev/null +++ b/tests/components/mill/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the mill tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.mill.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/mill/test_config_flow.py b/tests/components/mill/test_config_flow.py index 832aaef3b19..2bff9ba15e1 100644 --- a/tests/components/mill/test_config_flow.py +++ b/tests/components/mill/test_config_flow.py @@ -1,17 +1,24 @@ """Tests for Mill config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant import config_entries from homeassistant.components.mill.const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL +from homeassistant.components.recorder import Recorder from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_show_config_form(hass: HomeAssistant) -> None: + +async def test_show_config_form( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test show configuration form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -21,7 +28,9 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_create_entry(hass: HomeAssistant) -> None: +async def test_create_entry( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test create entry from user input.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -56,7 +65,9 @@ async def test_create_entry(hass: HomeAssistant) -> None: } -async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: +async def test_flow_entry_already_exists( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test user input for config_entry that already exists.""" test_data = { @@ -96,7 +107,9 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_connection_error(hass: HomeAssistant) -> None: +async def test_connection_error( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -125,7 +138,9 @@ async def test_connection_error(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_local_create_entry(hass: HomeAssistant) -> None: +async def test_local_create_entry( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test create entry from user input.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -165,7 +180,9 @@ async def test_local_create_entry(hass: HomeAssistant) -> None: assert result["data"] == test_data -async def test_local_flow_entry_already_exists(hass: HomeAssistant) -> None: +async def test_local_flow_entry_already_exists( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test user input for config_entry that already exists.""" test_data = { @@ -215,7 +232,9 @@ async def test_local_flow_entry_already_exists(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_local_connection_error(hass: HomeAssistant) -> None: +async def test_local_connection_error( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test connection error.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/mill/test_coordinator.py b/tests/components/mill/test_coordinator.py new file mode 100644 index 00000000000..a2a3bd57b65 --- /dev/null +++ b/tests/components/mill/test_coordinator.py @@ -0,0 +1,225 @@ +"""Test adding external statistics from Mill.""" + +from unittest.mock import AsyncMock + +from mill import Heater, Mill, Sensor + +from homeassistant.components.mill.const import DOMAIN +from homeassistant.components.mill.coordinator import MillHistoricDataUpdateCoordinator +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.statistics import statistics_during_period +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.components.recorder.common import async_wait_recording_done + + +async def test_mill_historic_data(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Heater(name="heater_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 1 + assert len(stats[statistic_id]) == 3 + _sum = 0 + for stat in stats[statistic_id]: + start = dt_util.utc_from_timestamp(stat["start"]) + assert start in data + assert stat["state"] == data[start] + assert stat["last_reset"] is None + + _sum += data[start] + assert stat["sum"] == _sum + + data2 = { + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4.5, + dt_util.parse_datetime("2024-12-03T03:00:00+01:00"): 5, + dt_util.parse_datetime("2024-12-03T04:00:00+01:00"): 6, + dt_util.parse_datetime("2024-12-03T05:00:00+01:00"): 7, + } + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data2) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + assert len(stats) == 1 + assert len(stats[statistic_id]) == 6 + _sum = 0 + for stat in stats[statistic_id]: + start = dt_util.utc_from_timestamp(stat["start"]) + val = data2.get(start) if start in data2 else data.get(start) + assert val is not None + assert stat["state"] == val + assert stat["last_reset"] is None + + _sum += val + assert stat["sum"] == _sum + + +async def test_mill_historic_data_no_heater( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Sensor(name="sensor_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 0 + + +async def test_mill_historic_data_no_data( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("2024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Heater(name="heater_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + assert len(stats) == 1 + assert len(stats[statistic_id]) == 3 + + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=None) + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 1 + assert len(stats[statistic_id]) == 3 + + +async def test_mill_historic_data_invalid_data( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test historic data from Mill.""" + + data = { + dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): None, + dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, + dt_util.parse_datetime("3024-12-03T02:00:00+01:00"): 4, + } + + mill_data_connection = Mill("", "", websession=AsyncMock()) + mill_data_connection.fetch_heater_and_sensor_data = AsyncMock(return_value=None) + mill_data_connection.devices = {"dev_id": Heater(name="heater_name")} + mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) + + statistic_id = f"{DOMAIN}:energy_dev_id" + + coordinator = MillHistoricDataUpdateCoordinator( + hass, mill_data_connection=mill_data_connection + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + next(iter(data)), + None, + {statistic_id}, + "hour", + None, + {"start", "state", "mean", "min", "max", "last_reset", "sum"}, + ) + + assert len(stats) == 1 + assert len(stats[statistic_id]) == 1 diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index a47e6422bf8..97b40d10d18 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -4,6 +4,7 @@ import asyncio from unittest.mock import patch from homeassistant.components import mill +from homeassistant.components.recorder import Recorder from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -11,7 +12,9 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def test_setup_with_cloud_config(hass: HomeAssistant) -> None: +async def test_setup_with_cloud_config( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of cloud config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -31,7 +34,9 @@ async def test_setup_with_cloud_config(hass: HomeAssistant) -> None: assert len(mock_connect.mock_calls) == 1 -async def test_setup_with_cloud_config_fails(hass: HomeAssistant) -> None: +async def test_setup_with_cloud_config_fails( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of cloud config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -47,7 +52,9 @@ async def test_setup_with_cloud_config_fails(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_with_cloud_config_times_out(hass: HomeAssistant) -> None: +async def test_setup_with_cloud_config_times_out( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of cloud config will retry if timed out.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -63,7 +70,9 @@ async def test_setup_with_cloud_config_times_out(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_with_old_cloud_config(hass: HomeAssistant) -> None: +async def test_setup_with_old_cloud_config( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of old cloud config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -82,7 +91,9 @@ async def test_setup_with_old_cloud_config(hass: HomeAssistant) -> None: assert len(mock_connect.mock_calls) == 1 -async def test_setup_with_local_config(hass: HomeAssistant) -> None: +async def test_setup_with_local_config( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test setup of local config.""" entry = MockConfigEntry( domain=mill.DOMAIN, @@ -119,7 +130,7 @@ async def test_setup_with_local_config(hass: HomeAssistant) -> None: assert len(mock_connect.mock_calls) == 1 -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test removing mill client.""" entry = MockConfigEntry( domain=mill.DOMAIN, diff --git a/tests/components/min_max/test_config_flow.py b/tests/components/min_max/test_config_flow.py index 93f8426e428..a9db7cab904 100644 --- a/tests/components/min_max/test_config_flow.py +++ b/tests/components/min_max/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.min_max.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -55,17 +55,6 @@ async def test_config_flow(hass: HomeAssistant, platform: str) -> None: assert config_entry.title == "My min_max" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.parametrize("platform", ["sensor"]) async def test_options(hass: HomeAssistant, platform: str) -> None: """Test reconfiguring.""" @@ -96,9 +85,9 @@ async def test_options(hass: HomeAssistant, platform: str) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "entity_ids") == input_sensors1 - assert get_suggested(schema, "round_digits") == 0 - assert get_suggested(schema, "type") == "min" + assert get_schema_suggested_value(schema, "entity_ids") == input_sensors1 + assert get_schema_suggested_value(schema, "round_digits") == 0 + assert get_schema_suggested_value(schema, "type") == "min" result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py index 77537a5e8e4..c87644961f2 100644 --- a/tests/components/minecraft_server/test_binary_sensor.py +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -7,7 +7,7 @@ from freezegun.api import FrozenDateTimeFactory from mcstatus import BedrockServer, JavaServer from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant diff --git a/tests/components/minecraft_server/test_diagnostics.py b/tests/components/minecraft_server/test_diagnostics.py index e72d0c5f8db..800af79e51c 100644 --- a/tests/components/minecraft_server/test_diagnostics.py +++ b/tests/components/minecraft_server/test_diagnostics.py @@ -5,7 +5,7 @@ from unittest.mock import patch from mcstatus import BedrockServer, JavaServer from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py index a4cea239f7a..3502184df86 100644 --- a/tests/components/minecraft_server/test_sensor.py +++ b/tests/components/minecraft_server/test_sensor.py @@ -7,7 +7,7 @@ from freezegun.api import FrozenDateTimeFactory from mcstatus import BedrockServer, JavaServer from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index dda5f369ad5..b071caebd16 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1081,6 +1081,7 @@ async def test_webhook_handle_conversation_process( }, }, "conversation_id": None, + "continue_conversation": False, } diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 3c30efe9dce..54d4c5f6666 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -5,6 +5,7 @@ import pytest from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_FAN_MODES, + ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, ATTR_SWING_MODE, @@ -31,6 +32,7 @@ from homeassistant.components.climate import ( SWING_OFF, SWING_ON, SWING_VERTICAL, + HVACAction, HVACMode, ) from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY @@ -47,6 +49,16 @@ from homeassistant.components.modbus.const import ( CONF_FAN_MODE_REGISTER, CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -224,6 +236,43 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 12, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_WRITE_REGISTERS: True, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_OFF: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_COOL: 2, + CONF_HVAC_MODE_HEAT_COOL: 3, + CONF_HVAC_MODE_DRY: 4, + CONF_HVAC_MODE_FAN_ONLY: 5, + CONF_HVAC_MODE_AUTO: 6, + }, + }, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 14, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_COOLING: 0, + CONF_HVAC_ACTION_DEFROSTING: 1, + CONF_HVAC_ACTION_DRYING: 2, + CONF_HVAC_ACTION_FAN: 3, + CONF_HVAC_ACTION_HEATING: 4, + CONF_HVAC_ACTION_IDLE: 5, + CONF_HVAC_ACTION_OFF: 6, + CONF_HVAC_ACTION_PREHEATING: 7, + }, + }, + } + ], + }, ], ) async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: @@ -745,6 +794,95 @@ async def test_hvac_onoff_coil_update( assert state.state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_IDLE: 0, + CONF_HVAC_ACTION_HEATING: 1, + }, + }, + }, + ] + }, + HVACAction.HEATING, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_COOLING: 0, + CONF_HVAC_ACTION_HEATING: 1, + }, + }, + }, + ] + }, + HVACAction.COOLING, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_OFF: 0, + CONF_HVAC_ACTION_DRYING: 1, + }, + }, + }, + ] + }, + HVACAction.DRYING, + [0x01], + ), + ], +) +async def test_service_climate_action_update( + hass: HomeAssistant, mock_modbus_ha, result, register_words +) -> None: + """Test HVAC action updates.""" + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).attributes[ATTR_HVAC_ACTION] == result + + @pytest.mark.parametrize( ("do_config", "result", "register_words"), [ diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 745249ff866..56b6d0ef3b4 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -4,12 +4,18 @@ from pymodbus.exceptions import ModbusException import pytest from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + DOMAIN as LIGHT_DOMAIN, +) from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_BRIGHTNESS_REGISTER, + CONF_COLOR_TEMP_REGISTER, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_STATE_OFF, @@ -217,7 +223,23 @@ async def test_all_light(hass: HomeAssistant, mock_do_cycle, expected) -> None: @pytest.mark.parametrize( "mock_test_state", - [(State(ENTITY_ID, STATE_ON),)], + [ + ( + State( + ENTITY_ID, + STATE_ON, + { + ATTR_BRIGHTNESS: 128, + ATTR_COLOR_TEMP_KELVIN: 4000, + }, + ), + State( + ENTITY_ID2, + STATE_ON, + {}, + ), + ) + ], indirect=True, ) @pytest.mark.parametrize( @@ -229,16 +251,35 @@ async def test_all_light(hass: HomeAssistant, mock_do_cycle, expected) -> None: CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_SCAN_INTERVAL: 0, - } + CONF_BRIGHTNESS_REGISTER: 1, + CONF_COLOR_TEMP_REGISTER: 2, + }, + { + CONF_NAME: f"{TEST_ENTITY_NAME} 2", + CONF_ADDRESS: 1235, + CONF_SCAN_INTERVAL: 0, + }, ] - }, + } ], ) async def test_restore_state_light( hass: HomeAssistant, mock_test_state, mock_modbus ) -> None: - """Run test for sensor restore state.""" - assert hass.states.get(ENTITY_ID).state == mock_test_state[0].state + """Test Modbus Light restore state with brightness and color_temp.""" + + state_1 = hass.states.get(ENTITY_ID) + state_2 = hass.states.get(ENTITY_ID2) + + assert state_1.state == STATE_ON + assert state_1.attributes.get(ATTR_BRIGHTNESS) == mock_test_state[0].attributes.get( + ATTR_BRIGHTNESS + ) + assert state_1.attributes.get(ATTR_COLOR_TEMP_KELVIN) == mock_test_state[ + 0 + ].attributes.get(ATTR_COLOR_TEMP_KELVIN) + + assert state_2.state == STATE_ON @pytest.mark.parametrize( @@ -271,7 +312,6 @@ async def test_light_service_turn( """Run test for service turn_on/turn_off.""" assert MODBUS_DOMAIN in hass.config.components - assert hass.states.get(ENTITY_ID).state == STATE_OFF await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data={ATTR_ENTITY_ID: ENTITY_ID} @@ -307,21 +347,143 @@ async def test_light_service_turn( @pytest.mark.parametrize( - "do_config", + ("do_config", "service_data", "expected_calls"), [ - { - CONF_LIGHTS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_VERIFY: {}, - } - ] - }, + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, + CONF_BRIGHTNESS_REGISTER: 1, + CONF_COLOR_TEMP_REGISTER: 2, + } + ] + }, + {ATTR_BRIGHTNESS: 128, ATTR_COLOR_TEMP_KELVIN: 2000}, + [(1, 50), (2, 0)], + ), + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, + CONF_BRIGHTNESS_REGISTER: 1, + } + ] + }, + {ATTR_BRIGHTNESS: 256}, + [(1, 100)], + ), + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, + CONF_COLOR_TEMP_REGISTER: 2, + } + ] + }, + {ATTR_BRIGHTNESS: 128, ATTR_COLOR_TEMP_KELVIN: 3000}, + [(2, 20)], + ), ], ) -async def test_service_light_update(hass: HomeAssistant, mock_modbus_ha) -> None: +async def test_color_temp_brightness_light( + hass: HomeAssistant, + mock_modbus_ha, + service_data, + expected_calls, +) -> None: + """Test Modbus Light color temperature and brightness.""" + assert hass.states.get(ENTITY_ID).state == STATE_OFF + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: ENTITY_ID, **service_data}, + blocking=True, + ) + assert hass.states.get(ENTITY_ID).state == STATE_ON + calls = mock_modbus_ha.write_register.call_args_list + for expected_register, expected_value in expected_calls: + assert any( + call.args[0] == expected_register and call.kwargs["value"] == expected_value + for call in calls + ), ( + f"Expected register {expected_register} with value {expected_value} not found in calls {calls}" + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + +@pytest.mark.parametrize( + ( + "do_config", + "input_output_values", + ), + [ + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_BRIGHTNESS_REGISTER: 1, + CONF_COLOR_TEMP_REGISTER: 2, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + }, + ] + }, + [([100, 0], 255, 7000)], + ), + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_BRIGHTNESS_REGISTER: 1, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + }, + ] + }, + [([100, None], 255, None), ([0, None], 0, None)], + ), + ( + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_WRITE_TYPE: CALL_TYPE_COIL, + CONF_VERIFY: {}, + }, + ] + }, + [([None, None], None, None)], + ), + ], +) +async def test_service_light_update( + hass: HomeAssistant, + mock_modbus_ha, + input_output_values, +) -> None: """Run test for service homeassistant.update_entity.""" await hass.services.async_call( HOMEASSISTANT_DOMAIN, @@ -338,6 +500,31 @@ async def test_service_light_update(hass: HomeAssistant, mock_modbus_ha) -> None blocking=True, ) assert hass.states.get(ENTITY_ID).state == STATE_ON + for ( + register_values, + expected_brightness, + expected_color_temp, + ) in input_output_values: + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_values) + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + { + ATTR_ENTITY_ID: ENTITY_ID, + }, + blocking=True, + ) + assert ( + expected_brightness is None + or hass.states.get(ENTITY_ID).attributes.get(ATTR_BRIGHTNESS) + == expected_brightness + ) + assert ( + expected_color_temp is None + or hass.states.get(ENTITY_ID).attributes.get(ATTR_COLOR_TEMP_KELVIN) + == expected_color_temp + ) + assert hass async def test_no_discovery_info_light( diff --git a/tests/components/modern_forms/test_diagnostics.py b/tests/components/modern_forms/test_diagnostics.py index 9eb2e4efa94..10a4c8385fa 100644 --- a/tests/components/modern_forms/test_diagnostics.py +++ b/tests/components/modern_forms/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the Modern Forms diagnostics platform.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/moehlenhoff_alpha2/__init__.py b/tests/components/moehlenhoff_alpha2/__init__.py index 50087794560..90d6d88fedc 100644 --- a/tests/components/moehlenhoff_alpha2/__init__.py +++ b/tests/components/moehlenhoff_alpha2/__init__.py @@ -19,7 +19,7 @@ async def mock_update_data(self): for _type in ("HEATAREA", "HEATCTRL", "IODEVICE"): if not isinstance(data["Devices"]["Device"][_type], list): data["Devices"]["Device"][_type] = [data["Devices"]["Device"][_type]] - self.static_data = data + self._static_data = data async def init_integration(hass: HomeAssistant) -> MockConfigEntry: diff --git a/tests/components/moehlenhoff_alpha2/test_binary_sensor.py b/tests/components/moehlenhoff_alpha2/test_binary_sensor.py index e650e9f9ba6..f9fbe60fb44 100644 --- a/tests/components/moehlenhoff_alpha2/test_binary_sensor.py +++ b/tests/components/moehlenhoff_alpha2/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/moehlenhoff_alpha2/test_button.py b/tests/components/moehlenhoff_alpha2/test_button.py index d4465746d53..09ffd1134ea 100644 --- a/tests/components/moehlenhoff_alpha2/test_button.py +++ b/tests/components/moehlenhoff_alpha2/test_button.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/moehlenhoff_alpha2/test_climate.py b/tests/components/moehlenhoff_alpha2/test_climate.py index a32f2b5bd4f..a9e46167693 100644 --- a/tests/components/moehlenhoff_alpha2/test_climate.py +++ b/tests/components/moehlenhoff_alpha2/test_climate.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/moehlenhoff_alpha2/test_sensor.py b/tests/components/moehlenhoff_alpha2/test_sensor.py index 931c744faea..6f89d8ce306 100644 --- a/tests/components/moehlenhoff_alpha2/test_sensor.py +++ b/tests/components/moehlenhoff_alpha2/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/mold_indicator/test_config_flow.py b/tests/components/mold_indicator/test_config_flow.py index bb8362b5e0d..aca6e37ff92 100644 --- a/tests/components/mold_indicator/test_config_flow.py +++ b/tests/components/mold_indicator/test_config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.mold_indicator.const import ( diff --git a/tests/components/monarch_money/test_sensor.py b/tests/components/monarch_money/test_sensor.py index aac1eaefb2d..1fe1b8cdb12 100644 --- a/tests/components/monarch_money/test_sensor.py +++ b/tests/components/monarch_money/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/monzo/test_sensor.py b/tests/components/monzo/test_sensor.py index a57466fdbd4..c4b55d11c36 100644 --- a/tests/components/monzo/test_sensor.py +++ b/tests/components/monzo/test_sensor.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from monzopy import InvalidMonzoAPIResponseError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.monzo.const import DOMAIN from homeassistant.components.monzo.sensor import ( diff --git a/tests/components/motionblinds_ble/test_diagnostics.py b/tests/components/motionblinds_ble/test_diagnostics.py index 878d2caa326..6d041a2df8b 100644 --- a/tests/components/motionblinds_ble/test_diagnostics.py +++ b/tests/components/motionblinds_ble/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Motionblinds Bluetooth diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/motioneye/test_media_source.py b/tests/components/motioneye/test_media_source.py index f8a750d50da..c650e2ac59d 100644 --- a/tests/components/motioneye/test_media_source.py +++ b/tests/components/motioneye/test_media_source.py @@ -104,6 +104,7 @@ async def test_async_browse_media_success( "media_content_id": "media-source://motioneye", "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "directory", "thumbnail": None, "children": [ @@ -116,6 +117,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "children_media_class": "directory", } @@ -132,6 +134,7 @@ async def test_async_browse_media_success( "media_content_id": "media-source://motioneye/74565ad414754616000674c87bdc876c", "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "directory", "thumbnail": None, "children": [ @@ -145,6 +148,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "children_media_class": "directory", } @@ -164,6 +168,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "directory", "thumbnail": None, "children": [ @@ -177,6 +182,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "children_media_class": "video", }, @@ -190,6 +196,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "children_media_class": "image", }, @@ -212,6 +219,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "video", "thumbnail": None, "children": [ @@ -225,6 +233,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "thumbnail": None, "children_media_class": "directory", } @@ -247,6 +256,7 @@ async def test_async_browse_media_success( ), "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "video", "thumbnail": None, "children": [ @@ -261,6 +271,7 @@ async def test_async_browse_media_success( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": "http://movie", "children_media_class": None, }, @@ -275,6 +286,7 @@ async def test_async_browse_media_success( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": "http://movie", "children_media_class": None, }, @@ -289,6 +301,7 @@ async def test_async_browse_media_success( ), "can_play": True, "can_expand": False, + "can_search": False, "thumbnail": "http://movie", "children_media_class": None, }, @@ -327,6 +340,7 @@ async def test_async_browse_media_images_success( ), "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "image", "thumbnail": None, "children": [ @@ -341,6 +355,7 @@ async def test_async_browse_media_images_success( ), "can_play": False, "can_expand": False, + "can_search": False, "thumbnail": "http://image", "children_media_class": None, } @@ -487,6 +502,7 @@ async def test_async_resolve_media_failure( ), "can_play": False, "can_expand": True, + "can_search": False, "children_media_class": "video", "thumbnail": None, "children": [], diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index 3b97c8aa7fe..b56b2c92678 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -7,6 +7,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" PORT = 23 +MAC = bytes.fromhex("c4dd57f8a55f") TVM_ZEROCONF_SERVICE_TYPE = "_tvm._tcp.local." diff --git a/tests/components/motionmount/conftest.py b/tests/components/motionmount/conftest.py index 49f624b5266..795495f4457 100644 --- a/tests/components/motionmount/conftest.py +++ b/tests/components/motionmount/conftest.py @@ -6,9 +6,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.motionmount.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT -from . import HOST, PORT, ZEROCONF_MAC, ZEROCONF_NAME +from . import HOST, MAC, PORT, ZEROCONF_MAC, ZEROCONF_NAME from tests.common import MockConfigEntry @@ -24,6 +24,17 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_with_pin() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=ZEROCONF_NAME, + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_PIN: 1234}, + unique_id=ZEROCONF_MAC, + ) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" @@ -34,12 +45,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_motionmount_config_flow() -> Generator[MagicMock]: +def mock_motionmount() -> Generator[MagicMock]: """Return a mocked MotionMount config flow.""" with patch( - "homeassistant.components.motionmount.config_flow.motionmount.MotionMount", + "homeassistant.components.motionmount.motionmount.MotionMount", autospec=True, ) as motionmount_mock: client = motionmount_mock.return_value + client.name = ZEROCONF_NAME + client.mac = MAC yield client diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py index 1fa2715595d..f6c5e8d8cc3 100644 --- a/tests/components/motionmount/test_config_flow.py +++ b/tests/components/motionmount/test_config_flow.py @@ -35,10 +35,10 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_user_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() user_input = MOCK_USER_INPUT.copy() @@ -54,10 +54,10 @@ async def test_user_connection_error( async def test_user_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when an invalid hostname is provided.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() user_input = MOCK_USER_INPUT.copy() @@ -73,10 +73,10 @@ async def test_user_connection_error_invalid_hostname( async def test_user_timeout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() user_input = MOCK_USER_INPUT.copy() @@ -92,10 +92,10 @@ async def test_user_timeout_error( async def test_user_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() user_input = MOCK_USER_INPUT.copy() @@ -111,13 +111,11 @@ async def test_user_not_connected_error( async def test_user_response_error_single_device_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") user_input = MOCK_USER_INPUT.copy() @@ -139,11 +137,11 @@ async def test_user_response_error_single_device_new_ce_old_pro( async def test_user_response_error_single_device_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -167,13 +165,13 @@ async def test_user_response_error_single_device_new_ce_new_pro( async def test_user_response_error_multi_device_new_ce_new_pro( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there are multiple devices.""" mock_config_entry.add_to_hass(hass) - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -190,14 +188,12 @@ async def test_user_response_error_multi_device_new_ce_new_pro( async def test_user_response_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) user_input = MOCK_USER_INPUT.copy() @@ -211,12 +207,8 @@ async def test_user_response_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -236,10 +228,10 @@ async def test_user_response_authentication_needed( async def test_zeroconf_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -255,10 +247,10 @@ async def test_zeroconf_connection_error( async def test_zeroconf_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -274,10 +266,10 @@ async def test_zeroconf_connection_error_invalid_hostname( async def test_zeroconf_timout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -293,10 +285,10 @@ async def test_zeroconf_timout_error( async def test_zeroconf_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -312,12 +304,10 @@ async def test_zeroconf_not_connected_error( async def test_show_zeroconf_form_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) result = await hass.config_entries.flow.async_init( @@ -348,10 +338,10 @@ async def test_show_zeroconf_form_new_ce_old_pro( async def test_show_zeroconf_form_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -383,7 +373,7 @@ async def test_show_zeroconf_form_new_ce_new_pro( async def test_zeroconf_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test we abort zeroconf flow if device already configured.""" mock_config_entry.add_to_hass(hass) @@ -402,13 +392,11 @@ async def test_zeroconf_device_exists_abort( async def test_zeroconf_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -421,12 +409,8 @@ async def test_zeroconf_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -448,17 +432,13 @@ async def test_zeroconf_authentication_needed( async def test_authentication_incorrect_then_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -483,9 +463,7 @@ async def test_authentication_incorrect_then_correct_pin( assert result["errors"][CONF_PIN] == CONF_PIN # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -505,18 +483,14 @@ async def test_authentication_incorrect_then_correct_pin( async def test_authentication_first_incorrect_pin_to_backoff( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - side_effect=[True, 1] - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(side_effect=[True, 1]) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -532,7 +506,7 @@ async def test_authentication_first_incorrect_pin_to_backoff( user_input=MOCK_PIN_INPUT.copy(), ) - assert mock_motionmount_config_flow.authenticate.called + assert mock_motionmount.authenticate.called assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backoff" @@ -541,12 +515,8 @@ async def test_authentication_first_incorrect_pin_to_backoff( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -567,16 +537,14 @@ async def test_authentication_first_incorrect_pin_to_backoff( async def test_authentication_multiple_incorrect_pins( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) user_input = MOCK_USER_INPUT.copy() @@ -602,12 +570,8 @@ async def test_authentication_multiple_incorrect_pins( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -628,16 +592,14 @@ async def test_authentication_multiple_incorrect_pins( async def test_authentication_show_backoff_when_still_running( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -671,12 +633,8 @@ async def test_authentication_show_backoff_when_still_running( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -697,17 +655,13 @@ async def test_authentication_show_backoff_when_still_running( async def test_authentication_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -720,9 +674,7 @@ async def test_authentication_correct_pin( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -741,11 +693,11 @@ async def test_authentication_correct_pin( async def test_full_user_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full manual user flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -773,11 +725,11 @@ async def test_full_user_flow_implementation( async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full zeroconf flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -808,7 +760,7 @@ async def test_full_zeroconf_flow_implementation( async def test_full_reauth_flow_implementation( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test reauthentication.""" mock_config_entry.add_to_hass(hass) @@ -824,12 +776,8 @@ async def test_full_reauth_flow_implementation( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), diff --git a/tests/components/motionmount/test_entity.py b/tests/components/motionmount/test_entity.py new file mode 100644 index 00000000000..e335c3a913b --- /dev/null +++ b/tests/components/motionmount/test_entity.py @@ -0,0 +1,47 @@ +"""Tests for the MotionMount Entity base.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import format_mac + +from . import ZEROCONF_NAME + +from tests.common import MockConfigEntry + + +async def test_entity_rename( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = True + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == ZEROCONF_NAME + + # Simulate the user changed the name of the device + mock_motionmount.name = "Blub" + + for callback in mock_motionmount.add_listener.call_args_list: + callback[0][0]() + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == "Blub" diff --git a/tests/components/motionmount/test_init.py b/tests/components/motionmount/test_init.py new file mode 100644 index 00000000000..e307945d0d0 --- /dev/null +++ b/tests/components/motionmount/test_init.py @@ -0,0 +1,129 @@ +"""Tests for the MotionMount init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.motionmount import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + + +async def test_setup_entry_with_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_without_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x00" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_failed_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.connect.side_effect = TimeoutError() + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_wrong_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x01" + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_no_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config_entry.async_get_active_flows(hass, sources={SOURCE_REAUTH})) + + +async def test_setup_entry_wrong_pin( + hass: HomeAssistant, + mock_config_entry_with_pin: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry_with_pin.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup( + mock_config_entry_with_pin.entry_id + ) + + assert mock_config_entry_with_pin.state is ConfigEntryState.SETUP_ERROR + assert any( + mock_config_entry_with_pin.async_get_active_flows(hass, sources={SOURCE_REAUTH}) + ) + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Test entries are unloaded correctly.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_motionmount.disconnect.call_count == 1 diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py index 0320e62d640..0132860727f 100644 --- a/tests/components/motionmount/test_sensor.py +++ b/tests/components/motionmount/test_sensor.py @@ -7,12 +7,10 @@ import pytest from homeassistant.core import HomeAssistant -from . import ZEROCONF_NAME +from . import MAC, ZEROCONF_NAME from tests.common import MockConfigEntry -MAC = bytes.fromhex("c4dd57f8a55f") - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/common.py similarity index 90% rename from tests/components/mqtt/test_common.py rename to tests/components/mqtt/common.py index 3bb8657e2f2..9bf1c236de6 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/common.py @@ -66,6 +66,204 @@ DEFAULT_CONFIG_DEVICE_INFO_MAC = { "configuration_url": "http://example.com", } +MOCK_SUBENTRY_BINARY_SENSOR_COMPONENT = { + "5b06357ef8654e8d9c54cee5bb0e939b": { + "platform": "binary_sensor", + "name": "Hatch", + "device_class": "door", + "state_topic": "test-topic", + "payload_on": "ON", + "payload_off": "OFF", + "expire_after": 1200, + "off_delay": 5, + "value_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/5b06357ef8654e8d9c54cee5bb0e939b", + }, +} +MOCK_SUBENTRY_BUTTON_COMPONENT = { + "365d05e6607c4dfb8ae915cff71a954b": { + "platform": "button", + "name": "Restart", + "device_class": "restart", + "command_topic": "test-topic", + "payload_press": "PRESS", + "command_template": "{{ value }}", + "retain": False, + "entity_picture": "https://example.com/365d05e6607c4dfb8ae915cff71a954b", + }, +} +MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { + "363a7ecad6be4a19b939a016ea93e994": { + "platform": "notify", + "name": "Milkman alert", + "command_topic": "test-topic", + "command_template": "{{ value }}", + "entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994", + "retain": False, + }, +} +MOCK_SUBENTRY_NOTIFY_COMPONENT2 = { + "6494827dac294fa0827c54b02459d309": { + "platform": "notify", + "name": "The second notifier", + "command_topic": "test-topic2", + "entity_picture": "https://example.com/6494827dac294fa0827c54b02459d309", + }, +} +MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = { + "5269352dd9534c908d22812ea5d714cd": { + "platform": "notify", + "name": None, + "command_topic": "test-topic", + "command_template": "{{ value }}", + "entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd", + "retain": False, + }, +} + +MOCK_SUBENTRY_SENSOR_COMPONENT = { + "e9261f6feed443e7b7d5f3fbe2a47412": { + "platform": "sensor", + "name": "Energy", + "device_class": "enum", + "state_topic": "test-topic", + "options": ["low", "medium", "high"], + "expire_after": 30, + "value_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", + }, +} +MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS = { + "a0f85790a95d4889924602effff06b6e": { + "platform": "sensor", + "name": "Energy", + "state_class": "measurement", + "state_topic": "test-topic", + "entity_picture": "https://example.com/a0f85790a95d4889924602effff06b6e", + }, +} +MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = { + "e9261f6feed443e7b7d5f3fbe2a47412": { + "platform": "sensor", + "name": "Energy", + "state_class": "total", + "last_reset_value_template": "{{ value_json.value }}", + "state_topic": "test-topic", + "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", + }, +} +MOCK_SUBENTRY_SWITCH_COMPONENT = { + "3faf1318016c46c5aea26707eeb6f12e": { + "platform": "switch", + "name": "Outlet", + "device_class": "outlet", + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{ value }}", + "value_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/3faf1318016c46c5aea26707eeb6f12e", + "optimistic": True, + }, +} + +MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { + "8131babc5e8d4f44b82e0761d39091a2": { + "platform": "light", + "name": "Basic light", + "on_command_type": "last", + "optimistic": True, + "payload_off": "OFF", + "payload_on": "ON", + "command_topic": "test-topic", + "schema": "basic", + "state_topic": "test-topic", + "color_temp_kelvin": True, + "state_value_template": "{{ value_json.value }}", + "brightness_scale": 255, + "max_kelvin": 6535, + "min_kelvin": 2000, + "white_scale": 255, + "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", + }, +} +MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { + "b10b531e15244425a74bb0abb1e9d2c6": { + "platform": "notify", + "name": "Test", + "command_topic": "bad#topic", + }, +} + +MOCK_SUBENTRY_AVAILABILITY_DATA = { + "availability": { + "availability_topic": "test/availability", + "availability_template": "{{ value_json.availability }}", + "payload_available": "online", + "payload_not_available": "offline", + } +} + +MOCK_SUBENTRY_DEVICE_DATA = { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", +} + +MOCK_NOTIFY_SUBENTRY_DATA_MULTI = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2, +} | MOCK_SUBENTRY_AVAILABILITY_DATA + +MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}}, + "components": MOCK_SUBENTRY_BINARY_SENSOR_COMPONENT, +} +MOCK_BUTTON_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}}, + "components": MOCK_SUBENTRY_BUTTON_COMPONENT, +} +MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, +} +MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, +} +MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT, +} +MOCK_SENSOR_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT, +} +MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS, +} +MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET, +} +MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_SWITCH_COMPONENT, +} +MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA, +} +MOCK_SUBENTRY_DATA_SET_MIX = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 + | MOCK_SUBENTRY_NOTIFY_COMPONENT2 + | MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT + | MOCK_SUBENTRY_SWITCH_COMPONENT, +} | MOCK_SUBENTRY_AVAILABILITY_DATA _SENTINEL = object() DISCOVERY_COUNT = sum(len(discovery_topic) for discovery_topic in MQTT.values()) @@ -1677,7 +1875,6 @@ async def help_test_entity_icon_and_entity_picture( mqtt_mock_entry: MqttMockHAClientGenerator, domain: str, config: ConfigType, - default_entity_picture: str | None = None, ) -> None: """Test entity picture and icon.""" await mqtt_mock_entry() @@ -1697,7 +1894,7 @@ async def help_test_entity_icon_and_entity_picture( state = hass.states.get(entity_id) assert entity_id is not None and state assert state.attributes.get("icon") is None - assert state.attributes.get("entity_picture") == default_entity_picture + assert state.attributes.get("entity_picture") is None # Discover an entity with an entity picture set unique_id = "veryunique2" @@ -1724,7 +1921,7 @@ async def help_test_entity_icon_and_entity_picture( state = hass.states.get(entity_id) assert entity_id is not None and state assert state.attributes.get("icon") == "mdi:emoji-happy-outline" - assert state.attributes.get("entity_picture") == default_entity_picture + assert state.attributes.get("entity_picture") is None async def help_test_publishing_with_custom_encoding( diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index b46829650f6..9241106496b 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 8809f2201f2..169e1ab4c6b 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index f147b33c88b..f99c48a440f 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -10,7 +10,7 @@ from homeassistant.components import button, mqtt from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index cda536dc19e..b5971adcb92 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -11,7 +11,7 @@ from homeassistant.components import camera, mqtt from homeassistant.components.mqtt.camera import MQTT_CAMERA_ATTRIBUTES_BLOCKED from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 9d5401fd437..c2cce3d1344 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -27,8 +27,8 @@ from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util.dt import utcnow +from .common import help_all_subscribe_calls from .conftest import ENTRY_DEFAULT_BIRTH_MESSAGE -from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, @@ -1556,6 +1556,42 @@ async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( assert insecure_check["insecure"] == insecure_param +@pytest.mark.parametrize( + ("mqtt_config_entry_data", "client_id"), + [ + ( + { + mqtt.CONF_BROKER: "mock-broker", + "client_id": "random01234random0124", + }, + "random01234random0124", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + }, + None, + ), + ], +) +async def test_client_id_is_set( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + client_id: str | None, +) -> None: + """Test setup defaults for tls.""" + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as async_client_mock: + await mqtt_mock_entry() + await hass.async_block_till_done() + assert async_client_mock.call_count == 1 + call_params: dict[str, Any] = async_client_mock.call_args[1] + assert "client_id" in call_params + assert client_id is None or client_id == call_params["client_id"] + assert call_params["client_id"] is not None + + @pytest.mark.parametrize( "mqtt_config_entry_data", [ diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 3760b0226f5..fd0b95f2b13 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -33,11 +33,16 @@ from homeassistant.components.mqtt.climate import ( MQTT_CLIMATE_ATTRIBUTES_BLOCKED, VALUE_TEMPLATE_KEYS, ) -from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, @@ -1823,7 +1828,7 @@ async def test_temperature_unit( @pytest.mark.parametrize( - ("hass_config", "temperature_unit", "initial", "min", "max", "current"), + ("hass_config", "units", "initial", "min", "max", "current"), [ ( help_custom_config( @@ -1836,7 +1841,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.CELSIUS, + METRIC_SYSTEM, DEFAULT_INITIAL_TEMPERATURE, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, @@ -1854,7 +1859,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.CELSIUS, + METRIC_SYSTEM, 20.5, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, @@ -1871,24 +1876,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.KELVIN, - 294, - 280, - 308, - 298, - ), - ( - help_custom_config( - climate.DOMAIN, - DEFAULT_CONFIG, - ( - { - "temperature_unit": "F", - "current_temperature_topic": "current_temperature", - }, - ), - ), - UnitOfTemperature.FAHRENHEIT, + US_CUSTOMARY_SYSTEM, 70, 45, 95, @@ -1899,25 +1887,25 @@ async def test_temperature_unit( async def test_alt_temperature_unit( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - temperature_unit: UnitOfTemperature, + units: UnitSystem, initial: float, min: float, max: float, current: float, ) -> None: """Test deriving the systems temperature unit.""" - with patch.object(hass.config.units, "temperature_unit", temperature_unit): - await mqtt_mock_entry() + hass.config.units = units + await mqtt_mock_entry() - state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("temperature") == initial - assert state.attributes.get("min_temp") == min - assert state.attributes.get("max_temp") == max + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("temperature") == initial + assert state.attributes.get("min_temp") == min + assert state.attributes.get("max_temp") == max - async_fire_mqtt_message(hass, "current_temperature", "77") + async_fire_mqtt_message(hass, "current_temperature", "77") - state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("current_temperature") == current + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("current_temperature") == current async def test_setting_attribute_via_mqtt_json_message( diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index de70fd32763..4cfc416c3c9 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2,6 +2,7 @@ from collections.abc import Generator, Iterator from contextlib import contextmanager +from copy import deepcopy from pathlib import Path from ssl import SSLError from typing import Any @@ -17,6 +18,8 @@ from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED +from homeassistant.components.mqtt.util import learn_more_url +from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( CONF_CLIENT_ID, CONF_PASSWORD, @@ -26,9 +29,23 @@ from homeassistant.const import ( ) 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.hassio import HassioServiceInfo -from tests.common import MockConfigEntry, MockMqttReasonCode +from .common import ( + MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE, + MOCK_BUTTON_SUBENTRY_DATA_SINGLE, + MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, + MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, + MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, + MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, +) + +from tests.common import MockConfigEntry, MockMqttReasonCode, get_schema_suggested_value from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient ADD_ON_DISCOVERY_INFO = { @@ -40,8 +57,47 @@ ADD_ON_DISCOVERY_INFO = { "protocol": "3.1.1", "ssl": False, } -MOCK_CLIENT_CERT = b"## mock client certificate file ##" -MOCK_CLIENT_KEY = b"## mock key file ##" + +MOCK_CA_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock CA certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_GENERIC_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock generic certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_CA_CERT_DER = b"## mock DER formatted CA certificate file ##\n" +MOCK_CLIENT_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock client certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_CLIENT_CERT_DER = b"## mock DER formatted client certificate file ##\n" +MOCK_CLIENT_KEY = ( + b"-----BEGIN PRIVATE KEY-----\n" + b"## mock client key file ##" + b"\n-----END PRIVATE KEY-----" +) +MOCK_EC_CLIENT_KEY = ( + b"-----BEGIN EC PRIVATE KEY-----\n" + b"## mock client key file ##" + b"\n-----END EC PRIVATE KEY-----" +) +MOCK_RSA_CLIENT_KEY = ( + b"-----BEGIN RSA PRIVATE KEY-----\n" + b"## mock client key file ##" + b"\n-----END RSA PRIVATE KEY-----" +) +MOCK_ENCRYPTED_CLIENT_KEY = ( + b"-----BEGIN ENCRYPTED PRIVATE KEY-----\n" + b"## mock client key file ##\n" + b"-----END ENCRYPTED PRIVATE KEY-----" +) +MOCK_CLIENT_KEY_DER = b"## mock DER formatted key file ##\n" +MOCK_ENCRYPTED_CLIENT_KEY_DER = b"## mock DER formatted encrypted key file ##\n" + MOCK_ENTRY_DATA = { mqtt.CONF_BROKER: "test-broker", @@ -96,21 +152,39 @@ def mock_client_key_check_fail() -> Generator[MagicMock]: @pytest.fixture -def mock_ssl_context() -> Generator[dict[str, MagicMock]]: +def mock_context_client_key() -> bytes: + """Mock the client key in the moched ssl context.""" + return MOCK_CLIENT_KEY + + +@pytest.fixture +def mock_ssl_context(mock_context_client_key: bytes) -> Generator[dict[str, MagicMock]]: """Mock the SSL context used to load the cert chain and to load verify locations.""" with ( patch("homeassistant.components.mqtt.config_flow.SSLContext") as mock_context, patch( "homeassistant.components.mqtt.config_flow.load_pem_private_key" - ) as mock_key_check, + ) as mock_pem_key_check, + patch( + "homeassistant.components.mqtt.config_flow.load_der_private_key" + ) as mock_der_key_check, patch( "homeassistant.components.mqtt.config_flow.load_pem_x509_certificate" - ) as mock_cert_check, + ) as mock_pem_cert_check, + patch( + "homeassistant.components.mqtt.config_flow.load_der_x509_certificate" + ) as mock_der_cert_check, ): + mock_pem_key_check().private_bytes.return_value = mock_context_client_key + mock_pem_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT + mock_der_key_check().private_bytes.return_value = mock_context_client_key + mock_der_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT yield { "context": mock_context, - "load_pem_x509_certificate": mock_cert_check, - "load_pem_private_key": mock_key_check, + "load_der_private_key": mock_der_key_check, + "load_der_x509_certificate": mock_der_cert_check, + "load_pem_private_key": mock_pem_key_check, + "load_pem_x509_certificate": mock_pem_cert_check, } @@ -180,9 +254,31 @@ def mock_try_connection_time_out() -> Generator[MagicMock]: yield mock_client() +@pytest.fixture +def mock_ca_cert() -> bytes: + """Mock the CA certificate.""" + return MOCK_CA_CERT + + +@pytest.fixture +def mock_client_cert() -> bytes: + """Mock the client certificate.""" + return MOCK_CLIENT_CERT + + +@pytest.fixture +def mock_client_key() -> bytes: + """Mock the client key.""" + return MOCK_CLIENT_KEY + + @pytest.fixture def mock_process_uploaded_file( - tmp_path: Path, mock_temp_dir: str + tmp_path: Path, + mock_ca_cert: bytes, + mock_client_cert: bytes, + mock_client_key: bytes, + mock_temp_dir: str, ) -> Generator[MagicMock]: """Mock upload certificate files.""" file_id_ca = str(uuid4()) @@ -195,15 +291,15 @@ def mock_process_uploaded_file( ) -> Iterator[Path | None]: if file_id == file_id_ca: with open(tmp_path / "ca.crt", "wb") as cafile: - cafile.write(b"## mock CA certificate file ##") + cafile.write(mock_ca_cert) yield tmp_path / "ca.crt" elif file_id == file_id_cert: with open(tmp_path / "client.crt", "wb") as certfile: - certfile.write(b"## mock client certificate file ##") + certfile.write(mock_client_cert) yield tmp_path / "client.crt" elif file_id == file_id_key: with open(tmp_path / "client.key", "wb") as keyfile: - keyfile.write(b"## mock key file ##") + keyfile.write(mock_client_key) yield tmp_path / "client.key" else: pytest.fail(f"Unexpected file_id: {file_id}") @@ -1024,12 +1120,37 @@ async def test_option_flow( assert yaml_mock.await_count +@pytest.mark.parametrize( + ("mock_ca_cert", "mock_client_cert", "mock_client_key", "client_key_password"), + [ + (MOCK_GENERIC_CERT, MOCK_GENERIC_CERT, MOCK_CLIENT_KEY, ""), + ( + MOCK_GENERIC_CERT, + MOCK_GENERIC_CERT, + MOCK_ENCRYPTED_CLIENT_KEY, + "very*secret", + ), + (MOCK_CA_CERT_DER, MOCK_CLIENT_CERT_DER, MOCK_CLIENT_KEY_DER, ""), + ( + MOCK_CA_CERT_DER, + MOCK_CLIENT_CERT_DER, + MOCK_ENCRYPTED_CLIENT_KEY_DER, + "very*secret", + ), + ], + ids=[ + "pem_certs_private_key_no_password", + "pem_certs_private_key_with_password", + "der_certs_private_key_no_password", + "der_certs_private_key_with_password", + ], +) @pytest.mark.parametrize( "test_error", [ "bad_certificate", "bad_client_cert", - "bad_client_key", + "client_key_error", "bad_client_cert_key", "invalid_inclusion", None, @@ -1042,31 +1163,54 @@ async def test_bad_certificate( mock_ssl_context: dict[str, MagicMock], mock_process_uploaded_file: MagicMock, test_error: str | None, + client_key_password: str, + mock_ca_cert: bytes, ) -> None: """Test bad certificate tests.""" + + def _side_effect_on_client_cert(data: bytes) -> MagicMock: + """Raise on client cert only. + + The function is called twice, once for the CA chain + and once for the client cert. We only want to raise on a client cert. + """ + if data == MOCK_CLIENT_CERT_DER: + raise ValueError + mock_certificate_side_effect = MagicMock() + mock_certificate_side_effect().public_bytes.return_value = MOCK_GENERIC_CERT + return mock_certificate_side_effect + # Mock certificate files file_id = mock_process_uploaded_file.file_id + set_ca_cert = "custom" + set_client_cert = True + tls_insecure = False test_input = { mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345, mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], - "set_ca_cert": True, + "client_key_password": client_key_password, + "set_ca_cert": set_ca_cert, "set_client_cert": True, } - set_client_cert = True - set_ca_cert = "custom" - tls_insecure = False if test_error == "bad_certificate": # CA chain is not loading mock_ssl_context["context"]().load_verify_locations.side_effect = SSLError + # Fail on the CA cert if DER encoded + mock_ssl_context["load_der_x509_certificate"].side_effect = ValueError elif test_error == "bad_client_cert": # Client certificate is invalid mock_ssl_context["load_pem_x509_certificate"].side_effect = ValueError - elif test_error == "bad_client_key": + # Fail on the client cert if DER encoded + mock_ssl_context[ + "load_der_x509_certificate" + ].side_effect = _side_effect_on_client_cert + elif test_error == "client_key_error": # Client key file is invalid mock_ssl_context["load_pem_private_key"].side_effect = ValueError + mock_ssl_context["load_der_private_key"].side_effect = ValueError elif test_error == "bad_client_cert_key": # Client key file file and certificate do not pair mock_ssl_context["context"]().load_cert_chain.side_effect = SSLError @@ -1312,19 +1456,6 @@ def get_default(schema: vol.Schema, key: str) -> Any | None: return None -def get_suggested(schema: vol.Schema, key: str) -> Any | None: - """Get suggested value for key in voluptuous schema.""" - for schema_key in schema: # type:ignore[attr-defined] - if schema_key == key: - if ( - schema_key.description is None - or "suggested_value" not in schema_key.description - ): - return None - return schema_key.description["suggested_value"] - return None - - @pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_option_flow_default_suggested_values( hass: HomeAssistant, @@ -1379,7 +1510,7 @@ async def test_option_flow_default_suggested_values( 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 + assert get_schema_suggested_value(result["data_schema"].schema, key) == value result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -1415,7 +1546,7 @@ async def test_option_flow_default_suggested_values( 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 + assert get_schema_suggested_value(result["data_schema"].schema, key) == value result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -1827,9 +1958,15 @@ async def test_options_bad_will_message_fails( } +@pytest.mark.parametrize( + "mock_context_client_key", + [MOCK_CLIENT_KEY, MOCK_EC_CLIENT_KEY, MOCK_RSA_CLIENT_KEY], +) @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 + hass: HomeAssistant, + mock_try_connection_success: MqttMockPahoClient, + mock_context_client_key: bytes, ) -> None: """Test config flow with advanced parameters from config.""" config_entry = MockConfigEntry( @@ -1849,7 +1986,7 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_CERTIFICATE: "auto", mqtt.CONF_TLS_INSECURE: True, mqtt.CONF_CLIENT_CERT: MOCK_CLIENT_CERT.decode(encoding="utf-8)"), - mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: mock_context_client_key.decode(encoding="utf-8"), mqtt.CONF_WS_PATH: "/path/", mqtt.CONF_WS_HEADERS: {"h1": "v1", "h2": "v2"}, mqtt.CONF_KEEPALIVE: 30, @@ -1891,7 +2028,7 @@ async def test_try_connection_with_advanced_parameters( for k, v in defaults.items(): assert get_default(result["data_schema"].schema, k) == v for k, v in suggested.items(): - assert get_suggested(result["data_schema"].schema, k) == v + assert get_schema_suggested_value(result["data_schema"].schema, k) == v # test we can change username and password mock_try_connection_success.reset_mock() @@ -1922,13 +2059,34 @@ async def test_try_connection_with_advanced_parameters( # check if tls_insecure_set is called assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,) - # check if the ca certificate settings were not set during connection test - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "certfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_CERT) - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "keyfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_KEY) + def read_file(path: Path) -> bytes: + with open(path, mode="rb") as file: + return file.read() + + # check if the client certificate settings saved + client_cert_path = await hass.async_add_executor_job( + mqtt.util.get_file_path, mqtt.CONF_CLIENT_CERT + ) + assert ( + mock_try_connection_success.tls_set.mock_calls[0].kwargs["certfile"] + == client_cert_path + ) + assert ( + await hass.async_add_executor_job(read_file, client_cert_path) + == MOCK_CLIENT_CERT + ) + + client_key_path = await hass.async_add_executor_job( + mqtt.util.get_file_path, mqtt.CONF_CLIENT_KEY + ) + assert ( + mock_try_connection_success.tls_set.mock_calls[0].kwargs["keyfile"] + == client_key_path + ) + assert ( + await hass.async_add_executor_job(read_file, client_key_path) + == mock_context_client_key + ) # check if websockets options are set assert mock_try_connection_success.ws_set_options.mock_calls[0][1] == ( @@ -2078,8 +2236,8 @@ async def test_setup_with_advanced_settings( CONF_USERNAME: "user", CONF_PASSWORD: "secret", mqtt.CONF_KEEPALIVE: 30, - mqtt.CONF_CLIENT_CERT: "## mock client certificate file ##", - mqtt.CONF_CLIENT_KEY: "## mock key file ##", + mqtt.CONF_CLIENT_CERT: MOCK_CLIENT_CERT.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), "tls_insecure": True, mqtt.CONF_TRANSPORT: "websockets", mqtt.CONF_WS_PATH: "/custom_path/", @@ -2091,6 +2249,155 @@ async def test_setup_with_advanced_settings( } +@pytest.mark.usefixtures("mock_ssl_context") +@pytest.mark.parametrize( + ("mock_ca_cert", "mock_client_cert", "mock_client_key", "client_key_password"), + [ + (MOCK_GENERIC_CERT, MOCK_GENERIC_CERT, MOCK_CLIENT_KEY, ""), + ( + MOCK_GENERIC_CERT, + MOCK_GENERIC_CERT, + MOCK_ENCRYPTED_CLIENT_KEY, + "very*secret", + ), + (MOCK_CA_CERT_DER, MOCK_CLIENT_CERT_DER, MOCK_CLIENT_KEY_DER, ""), + ( + MOCK_CA_CERT_DER, + MOCK_CLIENT_CERT_DER, + MOCK_ENCRYPTED_CLIENT_KEY_DER, + "very*secret", + ), + ], + ids=[ + "pem_certs_private_key_no_password", + "pem_certs_private_key_with_password", + "der_certs_private_key_no_password", + "der_certs_private_key_with_password", + ], +) +async def test_setup_with_certificates( + hass: HomeAssistant, + mock_try_connection: MagicMock, + mock_process_uploaded_file: MagicMock, + client_key_password: str, +) -> None: + """Test config flow setup with PEM and DER encoded certificates.""" + file_id = mock_process_uploaded_file.file_id + + 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, + data={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 1234, + }, + ) + + mock_try_connection.return_value = 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.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + "advanced_options": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_TRANSPORT] + assert mqtt.CONF_CLIENT_CERT not in result["data_schema"].schema + 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.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "custom", + "set_client_cert": True, + mqtt.CONF_TLS_INSECURE: False, + CONF_PROTOCOL: "3.1.1", + mqtt.CONF_TRANSPORT: "tcp", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema["client_key_password"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_CERTIFICATE] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_CERT] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_KEY] + assert result["data_schema"].schema[mqtt.CONF_TRANSPORT] + + # third iteration, advanced settings with client cert and key and CA certificate + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "custom", + "set_client_cert": True, + "client_key_password": client_key_password, + mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], + mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], + mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], + mqtt.CONF_TLS_INSECURE: False, + mqtt.CONF_TRANSPORT: "tcp", + }, + ) + + 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: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + mqtt.CONF_CLIENT_CERT: MOCK_GENERIC_CERT.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), + "tls_insecure": False, + mqtt.CONF_TRANSPORT: "tcp", + mqtt.CONF_CERTIFICATE: MOCK_GENERIC_CERT.decode(encoding="utf-8"), + } + + @pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") async def test_change_websockets_transport_to_tcp( hass: HomeAssistant, mock_try_connection: MagicMock @@ -2338,3 +2645,1316 @@ async def test_migrate_of_incompatible_config_entry( await mqtt_mock_entry() assert config_entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR + + +@pytest.mark.parametrize( + ( + "config_subentries_data", + "mock_device_user_input", + "mock_entity_user_input", + "mock_entity_details_user_input", + "mock_entity_details_failed_user_input", + "mock_mqtt_user_input", + "mock_failed_mqtt_user_input", + "entity_name", + ), + [ + ( + MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 2}}, + {"name": "Hatch"}, + {"device_class": "door"}, + (), + { + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "advanced_settings": {"expire_after": 1200, "off_delay": 5}, + }, + ( + ( + {"state_topic": "test-topic#invalid"}, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Hatch", + ), + ( + MOCK_BUTTON_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 2}}, + {"name": "Restart"}, + {"device_class": "restart"}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "payload_press": "PRESS", + "retain": False, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ), + "Milk notifier Restart", + ), + ( + MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, + {"name": "Milkman alert"}, + None, + None, + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "retain": False, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ), + "Milk notifier Milkman alert", + ), + ( + MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {}, + None, + None, + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "retain": False, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ), + "Milk notifier", + ), + ( + MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Energy"}, + {"device_class": "enum", "options": ["low", "medium", "high"]}, + ( + ( + { + "device_class": "energy", + "unit_of_measurement": "ppm", + }, + {"unit_of_measurement": "invalid_uom"}, + ), + # Trigger options to be shown on the form + ( + {"device_class": "enum"}, + {"options": "options_with_enum_device_class"}, + ), + # Test options are only allowed with device_class enum + ( + { + "device_class": "energy", + "options": ["less", "more"], + }, + { + "device_class": "options_device_class_enum", + "unit_of_measurement": "uom_required_for_device_class", + }, + ), + # Include options again to allow flow with valid data + ( + {"device_class": "enum"}, + {"options": "options_with_enum_device_class"}, + ), + ( + { + "device_class": "enum", + "state_class": "measurement", + "options": ["less", "more"], + }, + {"options": "options_not_allowed_with_state_class_or_uom"}, + ), + ), + { + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "advanced_settings": {"expire_after": 30}, + }, + ( + ( + {"state_topic": "test-topic#invalid"}, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Energy", + ), + ( + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Energy"}, + { + "state_class": "measurement", + }, + (), + { + "state_topic": "test-topic", + }, + (), + "Milk notifier Energy", + ), + ( + MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Outlet"}, + {"device_class": "outlet"}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "optimistic": True, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Outlet", + ), + ( + MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, + {"name": "Basic light"}, + {}, + {}, + { + "command_topic": "test-topic", + "state_topic": "test-topic", + "state_value_template": "{{ value_json.value }}", + "optimistic": True, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ( + { + "command_topic": "test-topic", + "light_brightness_settings": { + "brightness_command_topic": "test-topic#invalid" + }, + }, + {"light_brightness_settings": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "advanced_settings": {"max_kelvin": 2000, "min_kelvin": 2000}, + }, + { + "advanced_settings": "max_below_min_kelvin", + }, + ), + ), + "Milk notifier Basic light", + ), + ], + ids=[ + "binary_sensor", + "button", + "notify_with_entity_name", + "notify_no_entity_name", + "sensor_options", + "sensor_total", + "switch", + "light_basic_kelvin", + ], +) +async def test_subentry_configflow( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + config_subentries_data: dict[str, Any], + mock_device_user_input: dict[str, Any], + mock_entity_user_input: dict[str, Any], + mock_entity_details_user_input: dict[str, Any], + mock_entity_details_failed_user_input: tuple[ + tuple[dict[str, Any], dict[str, str]], + ], + mock_mqtt_user_input: dict[str, Any], + mock_failed_mqtt_user_input: tuple[tuple[dict[str, Any], dict[str, str]],], + entity_name: str, +) -> None: + """Test the subentry ConfigFlow.""" + device_name = mock_device_user_input["name"] + component = next(iter(config_subentries_data["components"].values())) + + await mqtt_mock_entry() + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "device"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + + # Test the URL validation + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "name": device_name, + "configuration_url": "http:/badurl.example.com", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + assert result["errors"]["configuration_url"] == "invalid_url" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=mock_device_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + assert result["errors"] == {} + + # Process entity flow (initial step) + + # Test the entity picture URL validation + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "platform": component["platform"], + "entity_picture": "invalid url", + } + | mock_entity_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # Try again with valid data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "platform": component["platform"], + "entity_picture": component["entity_picture"], + } + | mock_entity_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["description_placeholders"] == { + "mqtt_device": device_name, + "platform": component["platform"], + "entity": entity_name, + "url": learn_more_url(component["platform"]), + } + + # Process extra step if the platform supports it + if mock_entity_details_user_input is not None: + # Extra entity details flow step + assert result["step_id"] == "entity_platform_config" + + # First test validators if set of test + for failed_user_input, failed_errors in mock_entity_details_failed_user_input: + # Test an invalid entity details user input case + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=failed_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == failed_errors + + # Now try again with valid data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=mock_entity_details_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["description_placeholders"] == { + "mqtt_device": device_name, + "platform": component["platform"], + "entity": entity_name, + "url": learn_more_url(component["platform"]), + } + else: + # No details form step + assert result["step_id"] == "mqtt_platform_config" + + # Process mqtt platform config flow + # Test an invalid mqtt user input case + for failed_user_input, failed_errors in mock_failed_mqtt_user_input: + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=failed_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == failed_errors + + # Try again with a valid configuration + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], user_input=mock_mqtt_user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == device_name + + subentry_component = next( + iter(next(iter(config_entry.subentries.values())).data["components"].values()) + ) + assert subentry_component == next( + iter(config_subentries_data["components"].values()) + ) + + subentry_device_data = next(iter(config_entry.subentries.values())).data["device"] + for option, value in mock_device_user_input.items(): + assert subentry_device_data[option] == value + + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], + ids=["notify"], +) +async def test_subentry_reconfigure_remove_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the subentry ConfigFlow reconfigure removing an entity.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + object_list = list(components) + component_list = list(components.values()) + entity_name_0 = ( + f"{device.name} {component_list[0]['name']} ({component_list[0]['platform']})" + ) + entity_name_1 = ( + f"{device.name} {component_list[1]['name']} ({component_list[1]['platform']})" + ) + + for key, component in components.items(): + unique_entity_id = f"{subentry_id}_{key}" + entity_id = entity_registry.async_get_entity_id( + domain=component["platform"], + platform=mqtt.DOMAIN, + unique_id=unique_entity_id, + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we have the option to delete one entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + "availability", + ] + + # assert we can delete an entity + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "delete_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "delete_entity" + assert result["data_schema"].schema["component"].config["options"] == [ + {"value": object_list[0], "label": entity_name_0}, + {"value": object_list[1], "label": entity_name_1}, + ] + # remove notify_the_second_notifier + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "component": object_list[1], + }, + ) + + # assert menu options, we have only one item left, we cannot delete it + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + "availability", + "save_changes", + ] + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # check if the second entity was removed from the subentry and entity registry + unique_entity_id = f"{subentry_id}_{object_list[1]}" + entity_id = entity_registry.async_get_entity_id( + domain=components[object_list[1]]["platform"], + platform=mqtt.DOMAIN, + unique_id=unique_entity_id, + ) + assert entity_id is None + new_components = deepcopy(dict(subentry.data))["components"] + assert object_list[0] in new_components + assert object_list[1] not in new_components + + +@pytest.mark.parametrize( + ("mqtt_config_subentries_data", "user_input_mqtt"), + [ + ( + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ), + {"command_topic": "test-topic2-updated"}, + ) + ], + ids=["notify"], +) +async def test_subentry_reconfigure_edit_entity_multi_entitites( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_mqtt: dict[str, Any], +) -> None: + """Test the subentry ConfigFlow reconfigure with multi entities.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + object_list = list(components) + component_list = list(components.values()) + entity_name_0 = ( + f"{device.name} {component_list[0]['name']} ({component_list[0]['platform']})" + ) + entity_name_1 = ( + f"{device.name} {component_list[1]['name']} ({component_list[1]['platform']})" + ) + + for key in components: + unique_entity_id = f"{subentry_id}_{key}" + entity_id = entity_registry.async_get_entity_id( + domain="notify", platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we have the option to delete one entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + "availability", + ] + + # assert we can update an entity + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "update_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "update_entity" + assert result["data_schema"].schema["component"].config["options"] == [ + {"value": object_list[0], "label": entity_name_0}, + {"value": object_list[1], "label": entity_name_1}, + ] + # select second entity + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "component": object_list[1], + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the common entity data with changed entity_picture + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "entity_picture": "https://example.com", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific entity data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have our components + new_components = deepcopy(dict(subentry.data))["components"] + + # Check the second component was updated + assert new_components[object_list[0]] == components[object_list[0]] + for key, value in user_input_mqtt.items(): + assert new_components[object_list[1]][key] == value + + +@pytest.mark.parametrize( + ( + "mqtt_config_subentries_data", + "user_input_platform_config_validation", + "user_input_platform_config", + "user_input_mqtt", + "component_data", + "removed_options", + ), + [ + ( + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + (), + None, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "retain": True, + }, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "retain": True, + }, + {"entity_picture"}, + ), + ( + ( + ConfigSubentryData( + data=MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + ( + ( + { + "device_class": "battery", + "options": [], + "state_class": "measurement", + "unit_of_measurement": "invalid", + }, + # Allow to accept options are being removed + { + "device_class": "options_device_class_enum", + "options": "options_not_allowed_with_state_class_or_uom", + "unit_of_measurement": "invalid_uom", + }, + ), + ), + { + "device_class": "battery", + "state_class": "measurement", + "unit_of_measurement": "%", + "advanced_settings": {"suggested_display_precision": 1}, + }, + { + "state_topic": "test-topic1-updated", + "value_template": "{{ value_json.value }}", + }, + { + "state_topic": "test-topic1-updated", + "value_template": "{{ value_json.value }}", + }, + {"options", "expire_after", "entity_picture"}, + ), + ( + ( + ConfigSubentryData( + data=MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + None, + None, + { + "command_topic": "test-topic1-updated", + "state_topic": "test-topic1-updated", + "light_brightness_settings": { + "brightness_command_template": "{{ value_json.value }}" + }, + }, + { + "command_topic": "test-topic1-updated", + "state_topic": "test-topic1-updated", + "brightness_command_template": "{{ value_json.value }}", + }, + {"optimistic", "state_value_template", "entity_picture"}, + ), + ], + ids=["notify", "sensor", "light_basic"], +) +async def test_subentry_reconfigure_edit_entity_single_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_platform_config_validation: tuple[ + tuple[dict[str, Any], dict[str, str] | None], ... + ] + | None, + user_input_platform_config: dict[str, Any] | None, + user_input_mqtt: dict[str, Any], + component_data: dict[str, Any], + removed_options: tuple[str, ...], +) -> None: + """Test the subentry ConfigFlow reconfigure with single entity.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for the subentry component + # Check we have "notify_milkman_alert" in our mock data + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 1 + + component_id, component = next(iter(components.items())) + + unique_entity_id = f"{subentry_id}_{component_id}" + entity_id = entity_registry.async_get_entity_id( + domain=component["platform"], platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we do not have the option to delete an entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + "availability", + ] + + # assert we can update the entity, there is no select step + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "update_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the new common entity data, reset entity_picture + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.FORM + + if user_input_platform_config is None: + # Skip entity flow step + assert result["step_id"] == "mqtt_platform_config" + else: + # Additional entity flow step + assert result["step_id"] == "entity_platform_config" + for entity_validation_config, errors in user_input_platform_config_validation: + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=entity_validation_config, + ) + assert result["step_id"] == "entity_platform_config" + assert result.get("errors") == errors + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_platform_config, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific entity data, + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have out components + new_components = deepcopy(dict(subentry.data))["components"] + assert len(new_components) == 1 + + # Check our update was successful + assert "entity_picture" not in new_components[component_id] + + # Check the second component was updated + for key, value in component_data.items(): + assert new_components[component_id][key] == value + + assert set(component) - set(new_components[component_id]) == removed_options + + +@pytest.mark.parametrize( + ( + "mqtt_config_subentries_data", + "user_input_entity_details", + "user_input_mqtt", + "filtered_out_fields", + ), + [ + ( + ( + ConfigSubentryData( + data=MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE, + subentry_type="device", + title="Mock subentry", + ), + ), + { + "state_class": "measurement", + }, + { + "state_topic": "test-topic", + }, + ("last_reset_value_template",), + ), + ], + ids=["sensor_last_reset_template"], +) +async def test_subentry_reconfigure_edit_entity_reset_fields( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_entity_details: dict[str, Any], + user_input_mqtt: dict[str, Any], + filtered_out_fields: tuple[str, ...], +) -> None: + """Test the subentry ConfigFlow reconfigure resets filtered out fields.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for the subentry component + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 1 + + component_id, component = next(iter(components.items())) + for field in filtered_out_fields: + assert field in component + + unique_entity_id = f"{subentry_id}_{component_id}" + entity_id = entity_registry.async_get_entity_id( + domain=component["platform"], platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we do not have the option to delete an entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + "availability", + ] + + # assert we can update the entity, there is no select step + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "update_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the new common entity data, reset entity_picture + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity_platform_config" + + # submit the new entity platform config + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_entity_details, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific mqtt data, + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have out components + new_components = deepcopy(dict(subentry.data))["components"] + assert len(new_components) == 1 + + # Check our update was successful + assert "entity_picture" not in new_components[component_id] + + # Check the second component was updated + for key, value in user_input_mqtt.items(): + assert new_components[component_id][key] == value + + # Check field are filtered out correctly + for field in filtered_out_fields: + assert field not in new_components[component_id] + + +@pytest.mark.parametrize( + ("mqtt_config_subentries_data", "user_input_entity", "user_input_mqtt"), + [ + ( + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + { + "platform": "notify", + "name": "The second notifier", + "entity_picture": "https://example.com", + }, + { + "command_topic": "test-topic2", + }, + ) + ], + ids=["notify_notify"], +) +async def test_subentry_reconfigure_add_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_entity: dict[str, Any], + user_input_mqtt: dict[str, Any], +) -> None: + """Test the subentry ConfigFlow reconfigure and add an entity.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for the subentry component + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 1 + component_id_1, component1 = next(iter(components.items())) + unique_entity_id = f"{subentry_id}_{component_id_1}" + entity_id = entity_registry.async_get_entity_id( + domain=component1["platform"], platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we do not have the option to delete an entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + "availability", + ] + + # assert we can update the entity, there is no select step + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the new common entity data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_entity, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific entity data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # Finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have out components + new_components = deepcopy(dict(subentry.data))["components"] + assert len(new_components) == 2 + + component_id_2 = next(iter(set(new_components) - {component_id_1})) + + # Check our new entity was added correctly + expected_component_config = user_input_entity | user_input_mqtt + for key, value in expected_component_config.items(): + assert new_components[component_id_2][key] == value + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_subentry_reconfigure_update_device_properties( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the subentry ConfigFlow reconfigure and update device properties.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + + # Assert initial data + device = deepcopy(dict(subentry.data))["device"] + assert device["name"] == "Milk notifier" + assert device["sw_version"] == "1.0" + assert device["hw_version"] == "2.1 rev a" + assert device["model"] == "Model XL" + assert device["model_id"] == "mn002" + + # assert menu options, we have the option to delete one entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + "availability", + ] + + # assert we can update the device properties + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "device"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + + # Update the device details + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "name": "Beer notifier", + "sw_version": "1.1", + "model": "Beer bottle XL", + "model_id": "bn003", + "configuration_url": "https://example.com", + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check our device was updated + device = deepcopy(dict(subentry.data))["device"] + assert device["name"] == "Beer notifier" + assert "hw_version" not in device + assert device["model"] == "Beer bottle XL" + assert device["model_id"] == "bn003" + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_subentry_reconfigure_availablity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the subentry ConfigFlow reconfigure and update device properties.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + + expected_availability = { + "availability_topic": "test/availability", + "availability_template": "{{ value_json.availability }}", + "payload_available": "online", + "payload_not_available": "offline", + } + assert subentry.data.get("availability") == expected_availability + + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we can set the availability config + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "availability"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "availability" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "availability_topic": "test/new_availability#invalid_topic", + "payload_available": "1", + "payload_not_available": "0", + }, + ) + assert result["errors"] == {"availability_topic": "invalid_subscribe_topic"} + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "availability_topic": "test/new_availability", + "payload_available": "1", + "payload_not_available": "0", + }, + ) + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check the availability was updated + expected_availability = { + "availability_topic": "test/new_availability", + "payload_available": "1", + "payload_not_available": "0", + } + assert subentry.data.get("availability") == expected_availability + + # Assert we can reset the availability config + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "availability"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "availability" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "payload_available": "1", + "payload_not_available": "0", + }, + ) + + # Finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check the availability was updated + assert subentry.data.get("availability") == { + "payload_available": "1", + "payload_not_available": "0", + } diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index ee74b78be81..81530758de7 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -37,6 +37,7 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, SERVICE_TOGGLE, SERVICE_TOGGLE_COVER_TILT, STATE_CLOSED, @@ -45,7 +46,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, @@ -936,6 +937,63 @@ async def test_send_stop_cover_command( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("hass_config", "payload_stop"), + [ + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "tilt_command_topic": "tilt-command-topic", + "payload_stop_tilt": "TILT_STOP", + "qos": 2, + } + } + }, + "TILT_STOP", + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "tilt_command_topic": "tilt-command-topic", + "qos": 2, + } + } + }, + "STOP", + ), + ], +) +async def test_send_stop_tilt_command( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + payload_stop: str, +) -> None: + """Test the sending of stop_cover_tilt.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "tilt-command-topic", payload_stop, 2, False + ) + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + @pytest.mark.parametrize( "hass_config", [ diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 00e88860299..cd87ce9717a 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -12,7 +12,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 .test_common import ( +from .common import ( help_custom_config, help_test_reloadable, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -450,16 +450,82 @@ async def test_setting_device_tracker_location_via_lat_lon_message( assert state.attributes["latitude"] == 50.1 assert state.attributes["longitude"] == -2.1 assert state.attributes["gps_accuracy"] == 0 + assert state.attributes["source_type"] == "gps" assert state.state == STATE_NOT_HOME + # incomplete coordinates results in unknown state async_fire_mqtt_message(hass, "attributes-topic", '{"longitude": -117.22743}') state = hass.states.get("device_tracker.test") - assert state.attributes["longitude"] == -117.22743 + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.attributes["source_type"] == "gps" assert state.state == STATE_UNKNOWN async_fire_mqtt_message(hass, "attributes-topic", '{"latitude":32.87336}') state = hass.states.get("device_tracker.test") - assert state.attributes["latitude"] == 32.87336 + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.attributes["source_type"] == "gps" + assert state.state == STATE_UNKNOWN + + # invalid coordinates results in unknown state + async_fire_mqtt_message( + hass, "attributes-topic", '{"longitude": -117.22743, "latitude":null}' + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.attributes["source_type"] == "gps" + assert state.state == STATE_UNKNOWN + + # Test number validation + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": "32.87336","longitude": "-117.22743", "gps_accuracy": "1.5", "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert "gps_accuracy" not in state.attributes + # assert source_type is overridden by discovery + assert state.attributes["source_type"] == "router" + assert state.state == STATE_UNKNOWN + + # Test with invalid GPS accuracy should default to 0, + # but location updates as expected + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": 32.871234,"longitude": -117.21234, "gps_accuracy": "invalid", "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert state.state == STATE_NOT_HOME + assert state.attributes["latitude"] == 32.871234 + assert state.attributes["longitude"] == -117.21234 + assert state.attributes["gps_accuracy"] == 0 + assert state.attributes["source_type"] == "router" + + # Test with invalid latitude + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": null,"longitude": "-117.22743", "gps_accuracy": 1, "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes + assert state.state == STATE_UNKNOWN + + # Test with invalid longitude + async_fire_mqtt_message( + hass, + "attributes-topic", + '{"latitude": 32.87336,"longitude": "unknown", "gps_accuracy": 1, "source_type": "router"}', + ) + state = hass.states.get("device_tracker.test") + assert "latitude" not in state.attributes + assert "longitude" not in state.attributes assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 5cdfb14a5cf..ecf922e54a1 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.setup import async_setup_component -from .test_common import help_test_unload_config_entry +from .common import help_test_unload_config_entry from tests.common import async_fire_mqtt_message, async_get_device_automations from tests.typing import MqttMockHAClientGenerator, WebSocketGenerator diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 47c3a1e1988..ee559ef4235 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -46,8 +46,8 @@ from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from homeassistant.setup import async_setup_component from homeassistant.util.signal_type import SignalTypeFormat +from .common import help_all_subscribe_calls, help_test_unload_config_entry from .conftest import ENTRY_DEFAULT_BIRTH_MESSAGE -from .test_common import help_all_subscribe_calls, help_test_unload_config_entry from .test_tag import DEFAULT_TAG_ID, DEFAULT_TAG_SCAN from tests.common import ( @@ -388,23 +388,181 @@ async def test_only_valid_components( assert not mock_dispatcher_send.called -async def test_correct_config_discovery( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +@pytest.mark.parametrize( + ("discovery_topic", "discovery_hash"), + [ + ("homeassistant/binary_sensor/bla/config", ("binary_sensor", "bla")), + ("homeassistant/binary_sensor/node/bla/config", ("binary_sensor", "node bla")), + ], + ids=["without_node", "with_node"], +) +async def test_correct_config_discovery_component( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + discovery_topic: str, + discovery_hash: tuple[str, str], ) -> None: """Test sending in correct JSON.""" await mqtt_mock_entry() + config_init = { + "name": "Beer", + "state_topic": "test-topic", + "unique_id": "bla001", + "device": {"identifiers": "0AFFD2", "name": "test_device1"}, + "o": {"name": "foobar"}, + } async_fire_mqtt_message( hass, - "homeassistant/binary_sensor/bla/config", - '{ "name": "Beer", "state_topic": "test-topic" }', + discovery_topic, + json.dumps(config_init), ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") + state = hass.states.get("binary_sensor.test_device1_beer") assert state is not None - assert state.name == "Beer" - assert ("binary_sensor", "bla") in hass.data["mqtt"].discovery_already_discovered + assert state.name == "test_device1 Beer" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device1" + + # Update the device and component + config_update = { + "name": "Milk", + "state_topic": "test-topic", + "unique_id": "bla001", + "device": {"identifiers": "0AFFD2", "name": "test_device2"}, + "o": {"name": "foobar"}, + } + async_fire_mqtt_message( + hass, + discovery_topic, + json.dumps(config_update), + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is not None + assert state.name == "test_device2 Milk" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device2" + + # Remove the device and component + async_fire_mqtt_message( + hass, + discovery_topic, + "", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is None + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is None + + +@pytest.mark.parametrize( + ("discovery_topic", "discovery_hash"), + [ + ("homeassistant/device/some_id/config", ("binary_sensor", "some_id bla")), + ( + "homeassistant/device/node_id/some_id/config", + ("binary_sensor", "some_id node_id bla"), + ), + ], + ids=["without_node", "with_node"], +) +async def test_correct_config_discovery_device( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + discovery_topic: str, + discovery_hash: tuple[str, str], +) -> None: + """Test sending in correct JSON.""" + await mqtt_mock_entry() + config_init = { + "cmps": { + "bla": { + "platform": "binary_sensor", + "name": "Beer", + "state_topic": "test-topic", + "unique_id": "bla001", + }, + }, + "device": {"identifiers": "0AFFD2", "name": "test_device1"}, + "o": {"name": "foobar"}, + } + async_fire_mqtt_message( + hass, + discovery_topic, + json.dumps(config_init), + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is not None + assert state.name == "test_device1 Beer" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device1" + + # Update the device and component + config_update = { + "cmps": { + "bla": { + "platform": "binary_sensor", + "name": "Milk", + "state_topic": "test-topic", + "unique_id": "bla001", + }, + }, + "device": {"identifiers": "0AFFD2", "name": "test_device2"}, + "o": {"name": "foobar"}, + } + async_fire_mqtt_message( + hass, + discovery_topic, + json.dumps(config_update), + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is not None + assert state.name == "test_device2 Milk" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device2" + + # Remove the device and component + async_fire_mqtt_message( + hass, + discovery_topic, + "", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is None + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is None @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index 41049ed0887..a7f00a1d1a8 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -13,7 +13,7 @@ from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 6c8afe8c1b4..36b5032e282 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 20ca89181eb..435531182ed 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_image.py b/tests/components/mqtt/test_image.py index 6f0eb8edf49..9b64a8836a0 100644 --- a/tests/components/mqtt/test_image.py +++ b/tests/components/mqtt/test_image.py @@ -14,7 +14,7 @@ from homeassistant.components import image, mqtt from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_lawn_mower.py b/tests/components/mqtt/test_lawn_mower.py index 0bef4196ef2..c58402c4f5c 100644 --- a/tests/components/mqtt/test_lawn_mower.py +++ b/tests/components/mqtt/test_lawn_mower.py @@ -19,7 +19,7 @@ from homeassistant.components.mqtt.lawn_mower import MQTT_LAWN_MOWER_ATTRIBUTES_ from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index f8c66a3de1d..a8be259c1c9 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -210,7 +210,7 @@ from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7ddd04a09a6..7f7f32c4e43 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -102,7 +102,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.util.json import json_loads -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, @@ -330,7 +330,9 @@ async def test_no_color_brightness_color_temp_if_no_topics( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.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 @@ -361,6 +363,136 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "supported_color_modes": ["brightness"], + } + } + }, + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "brightness": True, + } + } + }, + ], +) +async def test_brightness_only( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test brightness only light. + + There are two possible configurations for brightness only light: + 1) Set up "brightness" as supported color mode. + 2) Set "brightness" flag to true. + """ + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == [ + light.ColorMode.BRIGHTNESS + ] + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.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 + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "brightness": 50}') + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") == 50 + 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 + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "supported_color_modes": ["color_temp"], + } + } + }, + ], +) +async def test_color_temp_only( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test a light that only support color_temp as supported color mode.""" + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == [ + light.ColorMode.COLOR_TEMP + ] + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.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 + + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"ON", "color_mode": "color_temp", "color_temp": 250, "brightness": 50}', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 206, 166) + assert state.attributes.get("brightness") == 50 + assert state.attributes.get("color_temp_kelvin") == 4000 + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") == (0.42, 0.365) + assert state.attributes.get("hs_color") == (26.812, 34.87) + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + + @pytest.mark.parametrize( "hass_config", [ @@ -451,6 +583,104 @@ async def test_controlling_state_color_temp_kelvin( assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color +@pytest.mark.parametrize( + ("hass_config", "expected_features"), + [ + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + } + } + }, + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": True, + "transition": True, + } + } + }, + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": True, + "transition": False, + } + } + }, + light.LightEntityFeature.FLASH, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": False, + "transition": True, + } + } + }, + light.LightEntityFeature.TRANSITION, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": False, + "transition": False, + } + } + }, + light.LightEntityFeature(0), + ), + ], + ids=[ + "default", + "explicit_on", + "flash_only", + "transition_only", + "no_flash_not_transition", + ], +) +async def test_flash_and_transition_feature_flags( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_features: light.LightEntityFeature, +) -> None: + """Test for no RGB, brightness, color temp, effector XY.""" + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features + + @pytest.mark.parametrize( "hass_config", [ @@ -471,9 +701,11 @@ async def test_controlling_state_via_topic( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features assert state.attributes.get("brightness") is None assert state.attributes.get("color_mode") is None assert state.attributes.get("color_temp_kelvin") is None @@ -669,9 +901,11 @@ async def test_sending_mqtt_commands_and_optimistic( state = hass.states.get("light.test") assert state.state == STATE_ON expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features assert state.attributes.get("brightness") == 95 assert state.attributes.get("color_mode") == "rgb" assert state.attributes.get("color_temp_kelvin") is None @@ -1327,9 +1561,11 @@ async def test_effect( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features await common.async_turn_on(hass, "light.test") @@ -1393,8 +1629,10 @@ async def test_flash_short_and_long( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features await common.async_turn_on(hass, "light.test", flash="short") @@ -1456,8 +1694,10 @@ async def test_transition( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features await common.async_turn_on(hass, "light.test", transition=15) mqtt_mock.async_publish.assert_called_once_with( @@ -1636,8 +1876,10 @@ async def test_invalid_values( 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_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is 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 diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 568d86f8bd9..e2cc801e97d 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -45,7 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, @@ -1545,3 +1545,109 @@ async def test_rgb_value_template_fails( "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" in caplog.text ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "effect_list": ["rainbow", "colorloop"], + "state_topic": "test-topic", + "state_template": "{{ value_json.state }}", + "brightness_template": "{{ value_json.brightness }}", + "color_temp_template": "{{ value_json.color_temp }}", + "red_template": "{{ value_json.color.red }}", + "green_template": "{{ value_json.color.green }}", + "blue_template": "{{ value_json.color.blue }}", + "effect_template": "{{ value_json.effect }}", + }, + ), + ) + ], +) +async def test_state_templates_ignore_missing_values( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test that rendering of MQTT value template ignores missing values.""" + await mqtt_mock_entry() + + # turn on the light + async_fire_mqtt_message(hass, "test-topic", '{"state": "on"}') + state = hass.states.get("light.test") + 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_kelvin") is None + assert state.attributes.get("effect") is None + + # update brightness and color temperature (with no state) + async_fire_mqtt_message( + hass, "test-topic", '{"brightness": 255, "color_temp": 145}' + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == ( + 246, + 244, + 255, + ) # temp converted to color + assert state.attributes.get("brightness") == 255 + 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") == ( + 251.249, + 4.253, + ) # temp converted to color + + # update color + async_fire_mqtt_message( + hass, "test-topic", '{"color": {"red": 255, "green": 128, "blue": 64}}' + ) + state = hass.states.get("light.test") + 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_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") is None + + # update brightness + async_fire_mqtt_message(hass, "test-topic", '{"brightness": 128}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") is None + + # update effect + async_fire_mqtt_message(hass, "test-topic", '{"effect": "rainbow"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "rainbow" + + # invalid effect + async_fire_mqtt_message(hass, "test-topic", '{"effect": "invalid"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "rainbow" + + # turn off the light + async_fire_mqtt_message(hass, "test-topic", '{"state": "off"}') + state = hass.states.get("light.test") + assert state.state == STATE_OFF + 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 diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 034f9b5ff6e..4aa6ecd03ef 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index d65f1a4d661..fa30283962b 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -1,18 +1,31 @@ """The tests for shared code of the MQTT platform.""" -from unittest.mock import patch +from typing import Any +from unittest.mock import call, patch import pytest from homeassistant.components import mqtt, sensor from homeassistant.components.mqtt.sensor import DEFAULT_NAME as DEFAULT_SENSOR_NAME +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import ( ATTR_FRIENDLY_NAME, EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, ) from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.util import slugify + +from .common import ( + MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA, + MOCK_SUBENTRY_DATA_SET_MIX, +) from tests.common import MockConfigEntry, async_capture_events, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator @@ -453,3 +466,124 @@ async def test_value_template_fails( "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" in caplog.text ) + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_SUBENTRY_DATA_SET_MIX, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_loading_subentries( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_config_subentries_data: tuple[dict[str, Any]], + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test loading subentries.""" + await mqtt_mock_entry() + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id = next(iter(entry.subentries)) + # Each subentry has one device + device = device_registry.async_get_device({("mqtt", subentry_id)}) + assert device is not None + for object_id, component in mqtt_config_subentries_data[0]["data"][ + "components" + ].items(): + platform = component["platform"] + entity_id = f"{platform}.{slugify(device.name)}_{slugify(component['name'])}" + entity_entry_entity_id = entity_registry.async_get_entity_id( + platform, mqtt.DOMAIN, f"{subentry_id}_{object_id}" + ) + assert entity_entry_entity_id == entity_id + state = hass.states.get(entity_id) + assert state is not None + assert ( + state.attributes.get("entity_picture") == f"https://example.com/{object_id}" + ) + # Availability was configured, so entities are unavailable + assert state.state == "unavailable" + + # Make entities available + async_fire_mqtt_message(hass, "test/availability", '{"availability": "online"}') + for component in mqtt_config_subentries_data[0]["data"]["components"].values(): + platform = component["platform"] + entity_id = f"{platform}.{slugify(device.name)}_{slugify(component['name'])}" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "unknown" + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_loading_subentry_with_bad_component_schema( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_config_subentries_data: tuple[dict[str, Any]], + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test loading subentries.""" + await mqtt_mock_entry() + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id = next(iter(entry.subentries)) + # Each subentry has one device + device = device_registry.async_get_device({("mqtt", subentry_id)}) + assert device is None + assert ( + "Schema violation occurred when trying to set up entity from subentry" + in caplog.text + ) + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_qos_on_mqt_device_from_subentry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_config_subentries_data: tuple[dict[str, Any]], + device_registry: dr.DeviceRegistry, +) -> None: + """Test QoS is set correctly on entities from MQTT device.""" + mqtt_mock = await mqtt_mock_entry() + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id = next(iter(entry.subentries)) + # Each subentry has one device + device = device_registry.async_get_device({("mqtt", subentry_id)}) + assert device is not None + assert hass.states.get("notify.milk_notifier_milkman_alert") is not None + await hass.services.async_call( + "notify", + "send_message", + {"entity_id": "notify.milk_notifier_milkman_alert", "message": "Test message"}, + ) + await hass.async_block_till_done() + assert len(mqtt_mock.async_publish.mock_calls) == 1 + mqtt_mock.async_publish.mock_calls[0] = call("test-topic", "Test message", 1, False) diff --git a/tests/components/mqtt/test_notify.py b/tests/components/mqtt/test_notify.py index 4837ee214c4..56da809d1b6 100644 --- a/tests/components/mqtt/test_notify.py +++ b/tests/components/mqtt/test_notify.py @@ -11,7 +11,7 @@ from homeassistant.components.notify import ATTR_MESSAGE from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 7bdd39e81a7..fd54e5f0643 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, @@ -835,32 +835,57 @@ async def test_entity_debug_info_message( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "min_number", "max_number", "step"), [ - { - mqtt.DOMAIN: { - number.DOMAIN: { - "state_topic": "test/state_number", - "command_topic": "test/cmd_number", - "name": "Test Number", - "min": 5, - "max": 110, - "step": 20, + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 5, + "max": 110, + "step": 20, + } } - } - } + }, + 5, + 110, + 20, + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 100, + "max": 100, + } + } + }, + 100, + 100, + 1, + ), ], ) async def test_min_max_step_attributes( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + min_number: float, + max_number: float, + step: float, ) -> None: """Test min/max/step attributes.""" await mqtt_mock_entry() state = hass.states.get("number.test_number") - assert state.attributes.get(ATTR_MIN) == 5 - assert state.attributes.get(ATTR_MAX) == 110 - assert state.attributes.get(ATTR_STEP) == 20 + assert state.attributes.get(ATTR_MIN) == min_number + assert state.attributes.get(ATTR_MAX) == max_number + assert state.attributes.get(ATTR_STEP) == step @pytest.mark.parametrize( @@ -885,7 +910,7 @@ async def test_invalid_min_max_attributes( ) -> None: """Test invalid min/max attributes.""" assert await mqtt_mock_entry() - assert f"'{CONF_MAX}' must be > '{CONF_MIN}'" in caplog.text + assert f"{CONF_MAX} must be >= {CONF_MIN}" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index d78dbe5c003..1650fe74601 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -10,7 +10,7 @@ from homeassistant.components import mqtt, scene from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 8d79a3ce609..a880368fa51 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOW from homeassistant.core import HomeAssistant, State from homeassistant.helpers.typing import ConfigType -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 9226b03a7d2..74dc94de21e 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -21,11 +21,11 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, @@ -71,6 +71,7 @@ from .test_common import ( from tests.common import ( MockConfigEntry, + async_capture_events, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache_with_extra_data, @@ -870,6 +871,71 @@ async def test_invalid_device_class( assert "expected SensorDeviceClass or one of" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "energy", + "unit_of_measurement": "ppm", + } + } + } + ], +) +async def test_invalid_unit_of_measurement( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device_class with invalid unit of measurement.""" + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + assert await mqtt_mock_entry() + assert ( + "The unit of measurement `ppm` is not valid together with device class `energy`" + in caplog.text + ) + # A repair issue was logged + assert len(events) == 1 + assert events[0].data["issue_id"] == "sensor.test" + # Assert the sensor works + async_fire_mqtt_message(hass, "test-topic", "100") + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == "100" + + caplog.clear() + + discovery_payload = { + "name": "bla", + "state_topic": "test-topic2", + "device_class": "temperature", + "unit_of_measurement": "C", + } + # Now discover an other invalid sensor + async_fire_mqtt_message( + hass, "homeassistant/sensor/bla/config", json.dumps(discovery_payload) + ) + await hass.async_block_till_done() + assert ( + "The unit of measurement `C` is not valid together with device class `temperature`" + in caplog.text + ) + # Assert the sensor works + async_fire_mqtt_message(hass, "test-topic2", "21") + await hass.async_block_till_done() + state = hass.states.get("sensor.bla") + assert state is not None + assert state.state == "21" + + # No new issue was registered for the discovered entity + assert len(events) == 1 + + @pytest.mark.parametrize( "hass_config", [ diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 58a5cb735f9..5d82708e242 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index dceeff07377..d834595afe0 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 41c417fe3e9..95326382dcc 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .test_common import help_test_unload_config_entry +from .common import help_test_unload_config_entry from tests.common import ( MockConfigEntry, diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 96924030279..050b2b59590 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -11,7 +11,7 @@ from homeassistant.components import mqtt, text from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 4ca10cbe8b2..335bf9cb4da 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -1,6 +1,7 @@ """The tests for mqtt update component.""" import json +from typing import Any from unittest.mock import patch import pytest @@ -10,7 +11,7 @@ from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INS from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, @@ -210,10 +211,7 @@ async def test_value_template( assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "1.9.0" assert state.attributes.get("latest_version") == "1.9.0" - assert ( - state.attributes.get("entity_picture") - == "https://brands.home-assistant.io/_/mqtt/icon.png" - ) + assert state.attributes.get("entity_picture") is None async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"2.0.0"}') @@ -225,6 +223,71 @@ async def test_value_template( assert state.attributes.get("latest_version") == "2.0.0" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": "test/update", + "value_template": ( + "{\"latest_version\":\"{{ value_json['update']['latest_version'] }}\"," + "\"installed_version\":\"{{ value_json['update']['installed_version'] }}\"," + "\"update_percentage\":{{ value_json['update'].get('progress', 'null') }}}" + ), + "name": "Test Update", + } + } + } + ], +) +async def test_errornous_value_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that it fetches the given payload with a template or handles the exception.""" + state_topic = "test/update" + await mqtt_mock_entry() + + # Simulate a template redendering error with payload + # without "update" mapping + example_payload: dict[str, Any] = { + "child_lock": "UNLOCK", + "current": 0.02, + "energy": 212.92, + "indicator_mode": "off/on", + "linkquality": 65, + "power": 0, + "power_outage_memory": "off", + "state": "ON", + "voltage": 232, + } + + async_fire_mqtt_message(hass, state_topic, json.dumps(example_payload)) + await hass.async_block_till_done() + assert hass.states.get("update.test_update") is not None + assert "Unable to process payload '" in caplog.text + + # Add update info + example_payload["update"] = { + "latest_version": "2.0.0", + "installed_version": "1.9.0", + "progress": 20, + } + + async_fire_mqtt_message(hass, state_topic, json.dumps(example_payload)) + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state is not None + + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + assert state.attributes.get("update_percentage") == 20 + + @pytest.mark.parametrize( "hass_config", [ @@ -258,10 +321,7 @@ async def test_value_template_float( assert state.state == STATE_OFF assert state.attributes.get("installed_version") == "1.9" assert state.attributes.get("latest_version") == "1.9" - assert ( - state.attributes.get("entity_picture") - == "https://brands.home-assistant.io/_/mqtt/icon.png" - ) + assert state.attributes.get("entity_picture") is None async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"2.0"}') @@ -883,9 +943,5 @@ async def test_entity_icon_and_entity_picture( domain = update.DOMAIN config = DEFAULT_CONFIG await help_test_entity_icon_and_entity_picture( - hass, - mqtt_mock_entry, - domain, - config, - default_entity_picture="https://brands.home-assistant.io/_/mqtt/icon.png", + hass, mqtt_mock_entry, domain, config ) diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index c1c662048d7..ba404e2dff0 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -33,7 +33,7 @@ from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_valve.py b/tests/components/mqtt/test_valve.py index 6dd0102b8a3..10387a5b19e 100644 --- a/tests/components/mqtt/test_valve.py +++ b/tests/components/mqtt/test_valve.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index 02ae54c1a85..21969ad7788 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -33,8 +33,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.util.unit_conversion import TemperatureConverter +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, @@ -714,7 +719,7 @@ async def test_temperature_unit( @pytest.mark.parametrize( - ("hass_config", "temperature_unit", "initial", "min_temp", "max_temp", "current"), + ("hass_config", "units", "initial", "min_temp", "max_temp", "current"), [ ( help_custom_config( @@ -727,7 +732,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.CELSIUS, + METRIC_SYSTEM, _DEFAULT_MIN_TEMP_CELSIUS, _DEFAULT_MIN_TEMP_CELSIUS, _DEFAULT_MAX_TEMP_CELSIUS, @@ -744,24 +749,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.KELVIN, - 316, - 316, - 333, - 322, - ), - ( - help_custom_config( - water_heater.DOMAIN, - DEFAULT_CONFIG, - ( - { - "temperature_unit": "F", - "current_temperature_topic": "current_temperature", - }, - ), - ), - UnitOfTemperature.FAHRENHEIT, + US_CUSTOMARY_SYSTEM, DEFAULT_MIN_TEMP, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, @@ -772,25 +760,25 @@ async def test_temperature_unit( async def test_alt_temperature_unit( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - temperature_unit: UnitOfTemperature, + units: UnitSystem, initial: float, min_temp: float, max_temp: float, current: float, ) -> None: """Test deriving the systems temperature unit.""" - with patch.object(hass.config.units, "temperature_unit", temperature_unit): - await mqtt_mock_entry() + hass.config.units = units + await mqtt_mock_entry() - state = hass.states.get(ENTITY_WATER_HEATER) - assert state.attributes.get("temperature") == initial - assert state.attributes.get("min_temp") == min_temp - assert state.attributes.get("max_temp") == max_temp + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == initial + assert state.attributes.get("min_temp") == min_temp + assert state.attributes.get("max_temp") == max_temp - async_fire_mqtt_message(hass, "current_temperature", "120") + async_fire_mqtt_message(hass, "current_temperature", "120") - state = hass.states.get(ENTITY_WATER_HEATER) - assert state.attributes.get("current_temperature") == current + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") == current async def test_setting_attribute_via_mqtt_json_message( diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 7c0f9df751a..a98ae82fbe1 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -2,14 +2,24 @@ from __future__ import annotations +import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock +from music_assistant_models.api import MassEvent from music_assistant_models.enums import EventType -from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track +from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, +) from music_assistant_models.player import Player from music_assistant_models.player_queue import PlayerQueue -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -60,6 +70,10 @@ async def setup_integration_from_fixtures( 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) + library_audiobooks = create_library_audiobooks_from_fixture() + music.get_library_audiobooks = AsyncMock(return_value=library_audiobooks) + library_podcasts = create_library_podcasts_from_fixture() + music.get_library_podcasts = AsyncMock(return_value=library_podcasts) music.get_item_by_uri = AsyncMock() config_entry.add_to_hass(hass) @@ -130,19 +144,58 @@ def create_library_radios_from_fixture() -> list[Radio]: return [Radio.from_dict(radio_data) for radio_data in fixture_data] +def create_library_audiobooks_from_fixture() -> list[Audiobook]: + """Create MA Audiobooks from fixture.""" + fixture_data = load_and_parse_fixture("library_audiobooks") + return [Audiobook.from_dict(radio_data) for radio_data in fixture_data] + + +def create_library_podcasts_from_fixture() -> list[Podcast]: + """Create MA Podcasts from fixture.""" + fixture_data = load_and_parse_fixture("library_podcasts") + return [Podcast.from_dict(radio_data) for radio_data in fixture_data] + + async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, event: EventType = EventType.PLAYER_UPDATED, + object_id: str | None = None, data: Any = None, ) -> None: """Trigger a subscription callback.""" # trigger callback on all subscribers - for sub in client.subscribe_events.call_args_list: - callback = sub.kwargs["callback"] - event_filter = sub.kwargs.get("event_filter") - if event_filter in (None, event): - callback(event, data) + for sub in client.subscribe.call_args_list: + cb_func = sub.kwargs.get("cb_func", sub.args[0]) + event_filter = sub.kwargs.get( + "event_filter", sub.args[1] if len(sub.args) > 1 else None + ) + id_filter = sub.kwargs.get( + "id_filter", sub.args[2] if len(sub.args) > 2 else None + ) + if not ( + event_filter is None + or event == event_filter + or (isinstance(event_filter, list) and event in event_filter) + ): + continue + if not ( + id_filter is None + or object_id == id_filter + or (isinstance(id_filter, list) and object_id in id_filter) + ): + continue + + event = MassEvent( + event=event, + object_id=object_id, + data=data, + ) + if asyncio.iscoroutinefunction(cb_func): + await cb_func(event) + else: + cb_func(event) + await hass.async_block_till_done() diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py index 2df43defe62..2b397891d6f 100644 --- a/tests/components/music_assistant/conftest.py +++ b/tests/components/music_assistant/conftest.py @@ -8,6 +8,7 @@ from music_assistant_client.music import Music from music_assistant_client.player_queues import PlayerQueues from music_assistant_client.players import Players from music_assistant_models.api import ServerInfoMessage +from music_assistant_models.config_entries import PlayerConfig import pytest from homeassistant.components.music_assistant.config_flow import CONF_URL @@ -68,6 +69,21 @@ async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]: client.music = Music(client) client.server_url = client.server_info.base_url client.get_media_item_image_url = MagicMock(return_value=None) + client.config = MagicMock() + + async def get_player_configs() -> list[PlayerConfig]: + """Mock get player configs.""" + # simply return a mock config for each player + return [ + PlayerConfig( + values={}, + provider=player.provider, + player_id=player.player_id, + ) + for player in client.players + ] + + client.config.get_player_configs = get_player_configs yield client diff --git a/tests/components/music_assistant/fixtures/library_audiobooks.json b/tests/components/music_assistant/fixtures/library_audiobooks.json new file mode 100644 index 00000000000..1994ee68e14 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_audiobooks.json @@ -0,0 +1,489 @@ +{ + "library_audiobooks": [ + { + "item_id": "1", + "provider": "library", + "name": "Test Audiobook", + "version": "", + "sort_name": "test audiobook", + "uri": "library://audiobook/1", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "test-audiobook.mp3", + "provider_domain": "filesystem_smb", + "provider_instance": "filesystem_smb--7Kf8QySu", + "available": true, + "audio_format": { + "content_type": "mp3", + "codec_type": "?", + "sample_rate": 48000, + "bit_depth": 16, + "channels": 1, + "output_format_str": "mp3", + "bit_rate": 90304 + }, + "url": null, + "details": "1738502411" + } + ], + "metadata": { + "description": "Cover (front)", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "test-audiobook.mp3", + "provider": "filesystem_smb--7Kf8QySu", + "remotely_accessible": false + } + ], + "genres": [], + "mood": null, + "style": null, + "copyright": null, + "lyrics": "", + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": null, + "authors": ["TestWriter"], + "narrators": [], + "duration": 9, + "fully_played": true, + "resume_position_ms": 9000 + }, + { + "item_id": "11", + "provider": "library", + "name": "Test Audiobook 0", + "version": "", + "sort_name": "test audiobook 0", + "uri": "library://audiobook/11", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "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, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "12", + "provider": "library", + "name": "Test Audiobook 1", + "version": "", + "sort_name": "test audiobook 1", + "uri": "library://audiobook/12", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "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, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "13", + "provider": "library", + "name": "Test Audiobook 2", + "version": "", + "sort_name": "test audiobook 2", + "uri": "library://audiobook/13", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "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, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "14", + "provider": "library", + "name": "Test Audiobook 3", + "version": "", + "sort_name": "test audiobook 3", + "uri": "library://audiobook/14", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "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, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "15", + "provider": "library", + "name": "Test Audiobook 4", + "version": "", + "sort_name": "test audiobook 4", + "uri": "library://audiobook/15", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "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, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_podcasts.json b/tests/components/music_assistant/fixtures/library_podcasts.json new file mode 100644 index 00000000000..2c6a9c62f65 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_podcasts.json @@ -0,0 +1,309 @@ +{ + "library_podcasts": [ + { + "item_id": "6", + "provider": "library", + "name": "Test Podcast 0", + "version": "", + "sort_name": "test podcast 0", + "uri": "library://podcast/6", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_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": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "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, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "7", + "provider": "library", + "name": "Test Podcast 1", + "version": "", + "sort_name": "test podcast 1", + "uri": "library://podcast/7", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_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": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "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, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "8", + "provider": "library", + "name": "Test Podcast 2", + "version": "", + "sort_name": "test podcast 2", + "uri": "library://podcast/8", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_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": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "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, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "9", + "provider": "library", + "name": "Test Podcast 3", + "version": "", + "sort_name": "test podcast 3", + "uri": "library://podcast/9", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_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": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "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, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "10", + "provider": "library", + "name": "Test Podcast 4", + "version": "", + "sort_name": "test podcast 4", + "uri": "library://podcast/10", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_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": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "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, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + } + ] +} diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 8a08a55dc45..e8978f17f86 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -34,12 +34,16 @@ "needs_poll": false, "poll_interval": 30, "enabled": true, - "hidden": false, "icon": "mdi-speaker", "group_volume": 20, "display_name": "Test Player 1", - "extra_data": {}, - "announcement_in_progress": false + "power_control": "native", + "volume_control": "native", + "mute_control": "native", + "hide_player_in_ui": ["when_unavailable"], + "expose_to_ha": true, + "can_group_with": ["00:00:00:00:00:02"], + "source_list": [] }, { "player_id": "00:00:00:00:00:02", @@ -83,15 +87,27 @@ }, "synced_to": null, "enabled_by_default": true, - "needs_poll": false, - "poll_interval": 30, "enabled": true, "hidden": false, "icon": "mdi-speaker", "group_volume": 20, "display_name": "My Super Test Player 2", - "extra_data": {}, - "announcement_in_progress": false + "power_control": "native", + "volume_control": "native", + "mute_control": "native", + "hide_player_in_ui": ["when_unavailable"], + "expose_to_ha": true, + "can_group_with": ["00:00:00:00:00:01"], + "source_list": [ + { + "id": "spotify", + "name": "Spotify Connect", + "passive": true, + "can_play_pause": false, + "can_seek": false, + "can_next_previous": false + } + ] }, { "player_id": "test_group_player_1", @@ -135,15 +151,17 @@ }, "synced_to": null, "enabled_by_default": true, - "needs_poll": true, - "poll_interval": 30, "enabled": true, - "hidden": false, "icon": "mdi-speaker-multiple", "group_volume": 6, "display_name": "Test Group Player 1", - "extra_data": {}, - "announcement_in_progress": false + "power_control": "native", + "volume_control": "native", + "mute_control": "native", + "hide_player_in_ui": ["when_unavailable"], + "expose_to_ha": true, + "can_group_with": [], + "source_list": [] } ] } diff --git a/tests/components/music_assistant/snapshots/test_actions.ambr b/tests/components/music_assistant/snapshots/test_actions.ambr index 6c30ffc512c..32c8776c953 100644 --- a/tests/components/music_assistant/snapshots/test_actions.ambr +++ b/tests/components/music_assistant/snapshots/test_actions.ambr @@ -1,5 +1,195 @@ # serializer version: 1 -# name: test_get_library_action +# name: test_get_library_action[album] + dict({ + 'items': 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', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[artist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'W O L F C L U B', + 'uri': 'library://artist/127', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[audiobook] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook', + 'uri': 'library://audiobook/1', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 0', + 'uri': 'library://audiobook/11', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 1', + 'uri': 'library://audiobook/12', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 2', + 'uri': 'library://audiobook/13', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 3', + 'uri': 'library://audiobook/14', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 4', + 'uri': 'library://audiobook/15', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[playlist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': '1970s Rock Hits', + 'uri': 'library://playlist/40', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[podcast] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 0', + 'uri': 'library://podcast/6', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 1', + 'uri': 'library://podcast/7', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 2', + 'uri': 'library://podcast/8', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 3', + 'uri': 'library://podcast/9', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 4', + 'uri': 'library://podcast/10', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[radio] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'fm4 | ORF | HQ', + 'uri': 'library://radio/1', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[track] dict({ 'items': list([ dict({ @@ -192,8 +382,12 @@ ]), 'artists': list([ ]), + 'audiobooks': list([ + ]), 'playlists': list([ ]), + 'podcasts': 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 a07bde4b29d..f561a5c3afb 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -28,7 +28,7 @@ 'original_name': None, 'platform': 'music_assistant', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:02', 'unit_of_measurement': None, @@ -54,7 +54,7 @@ 'media_duration': 300, 'media_position': 0, 'media_title': 'Test Track', - 'supported_features': , + 'supported_features': , 'volume_level': 0.2, }), 'context': , @@ -94,7 +94,7 @@ 'original_name': None, 'platform': 'music_assistant', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'test_group_player_1', 'unit_of_measurement': None, @@ -109,8 +109,8 @@ 'entity_picture_local': None, 'friendly_name': 'Test Group Player 1', 'group_members': list([ - 'media_player.my_super_test_player_2', 'media_player.test_player_1', + 'media_player.my_super_test_player_2', ]), 'icon': 'mdi:speaker-multiple', 'is_volume_muted': False, @@ -125,7 +125,7 @@ 'media_title': 'November Rain', 'repeat': 'all', 'shuffle': True, - 'supported_features': , + 'supported_features': , 'volume_level': 0.06, }), 'context': , @@ -165,7 +165,7 @@ 'original_name': None, 'platform': 'music_assistant', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:01', 'unit_of_measurement': None, @@ -181,7 +181,7 @@ ]), 'icon': 'mdi:speaker', 'mass_player_type': 'player', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'media_player.test_player_1', diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py index 4d3917091c1..0a469807de3 100644 --- a/tests/components/music_assistant/test_actions.py +++ b/tests/components/music_assistant/test_actions.py @@ -3,7 +3,8 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.media_items import SearchResults -from syrupy import SnapshotAssertion +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.music_assistant.actions import ( SERVICE_GET_LIBRARY, @@ -47,9 +48,22 @@ async def test_search_action( assert response == snapshot +@pytest.mark.parametrize( + "media_type", + [ + "artist", + "album", + "track", + "playlist", + "audiobook", + "podcast", + "radio", + ], +) async def test_get_library_action( hass: HomeAssistant, music_assistant_client: MagicMock, + media_type: str, snapshot: SnapshotAssertion, ) -> None: """Test music assistant get_library action.""" @@ -60,7 +74,7 @@ async def test_get_library_action( { ATTR_CONFIG_ENTRY_ID: entry.entry_id, ATTR_FAVORITE: False, - ATTR_MEDIA_TYPE: "track", + ATTR_MEDIA_TYPE: media_type, }, blocking=True, return_response=True, diff --git a/tests/components/music_assistant/test_init.py b/tests/components/music_assistant/test_init.py new file mode 100644 index 00000000000..4cfefb50bd2 --- /dev/null +++ b/tests/components/music_assistant/test_init.py @@ -0,0 +1,70 @@ +"""Test the Music Assistant integration init.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from music_assistant_models.errors import ActionUnavailable + +from homeassistant.components.music_assistant.const import DOMAIN +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 setup_integration_from_fixtures + +from tests.typing import WebSocketGenerator + + +async def test_remove_config_entry_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test device removal.""" + assert await async_setup_component(hass, "config", {}) + await setup_integration_from_fixtures(hass, music_assistant_client) + await hass.async_block_till_done() + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + client = await hass_ws_client(hass) + + # test if the removal should be denied if the device is still in use + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + )[0] + entity_id = "media_player.test_player_1" + assert device_entry + assert entity_registry.async_get(entity_id) + assert hass.states.get(entity_id) + music_assistant_client.config.remove_player_config = AsyncMock( + side_effect=ActionUnavailable + ) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert music_assistant_client.config.remove_player_config.call_count == 1 + assert response["success"] is False + + # test if the removal should be allowed if the device is not in use + music_assistant_client.config.remove_player_config = AsyncMock() + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] is True + await hass.async_block_till_done() + assert not device_registry.async_get(device_entry.id) + assert not entity_registry.async_get(entity_id) + assert not hass.states.get(entity_id) + + # test if the removal succeeds if its no longer provided by the server + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players.pop(mass_player_id) + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + )[0] + entity_id = "media_player.my_super_test_player_2" + assert device_entry + assert entity_registry.async_get(entity_id) + assert hass.states.get(entity_id) + music_assistant_client.config.remove_player_config = AsyncMock() + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert music_assistant_client.config.remove_player_config.call_count == 0 + assert response["success"] is True diff --git a/tests/components/music_assistant/test_media_browser.py b/tests/components/music_assistant/test_media_browser.py index 96fd54962d8..5a456e9dcb0 100644 --- a/tests/components/music_assistant/test_media_browser.py +++ b/tests/components/music_assistant/test_media_browser.py @@ -1,18 +1,31 @@ """Test Music Assistant media browser implementation.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.media_player import BrowseError, BrowseMedia, MediaType +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + MediaType, + SearchError, + SearchMedia, + SearchMediaQuery, +) from homeassistant.components.music_assistant.const import DOMAIN from homeassistant.components.music_assistant.media_browser import ( LIBRARY_ALBUMS, LIBRARY_ARTISTS, + LIBRARY_AUDIOBOOKS, LIBRARY_PLAYLISTS, + LIBRARY_PODCASTS, LIBRARY_RADIO, LIBRARY_TRACKS, + MEDIA_TYPE_AUDIOBOOK, + MEDIA_TYPE_RADIO, async_browse_media, + async_search_media, ) from homeassistant.core import HomeAssistant @@ -25,8 +38,10 @@ from .common import setup_integration_from_fixtures (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_TRACKS, MediaType.TRACK, "library://track/456"), (LIBRARY_RADIO, DOMAIN, "library://radio/1"), + (LIBRARY_PODCASTS, MediaType.PODCAST, "library://podcast/6"), + (LIBRARY_AUDIOBOOKS, DOMAIN, "library://audiobook/1"), ("artist", MediaType.ARTIST, "library://album/115"), ("album", MediaType.ALBUM, "library://track/247"), ("playlist", DOMAIN, "tidal--Ah76MuMg://track/77616130"), @@ -63,3 +78,249 @@ async def test_browse_media_not_found( with pytest.raises(BrowseError, match="Media not found: unknown / unknown"): await async_browse_media(hass, music_assistant_client, "unknown", "unknown") + + +class MockSearchResults: + """Mock search results.""" + + def __init__(self, media_types: list[str]) -> None: + """Initialize mock search results.""" + self.artists = [] + self.albums = [] + self.tracks = [] + self.playlists = [] + self.radio = [] + self.podcasts = [] + self.audiobooks = [] + + # Create mock items based on requested media types + for media_type in media_types: + items = [] + for i in range(5): # Create 5 mock items for each type + item = MagicMock() + item.name = f"Test {media_type} {i}" + item.uri = f"library://{media_type}/{i}" + item.available = True + item.artists = [] + media_type_mock = MagicMock() + media_type_mock.value = media_type + item.media_type = media_type_mock + items.append(item) + + # Assign to the appropriate attribute + if media_type == "artist": + self.artists = items + elif media_type == "album": + self.albums = items + elif media_type == "track": + self.tracks = items + elif media_type == "playlist": + self.playlists = items + elif media_type == "radio": + self.radio = items + elif media_type == "podcast": + self.podcasts = items + elif media_type == "audiobook": + self.audiobooks = items + + +@pytest.mark.parametrize( + ("search_query", "media_content_type", "expected_items"), + [ + # Search for tracks + ("track", MediaType.TRACK, 5), + # Search for albums + ("album", MediaType.ALBUM, 5), + # Search for artists + ("artist", MediaType.ARTIST, 5), + # Search for playlists + ("playlist", MediaType.PLAYLIST, 5), + # Search for radio stations + ("radio", MEDIA_TYPE_RADIO, 5), + # Search for podcasts + ("podcast", MediaType.PODCAST, 5), + # Search for audiobooks + ("audiobook", MEDIA_TYPE_AUDIOBOOK, 5), + # Search with no media type specified (should return all types) + ("music", None, 35), + ], +) +async def test_search_media( + hass: HomeAssistant, + music_assistant_client: MagicMock, + search_query: str, + media_content_type: str, + expected_items: int, +) -> None: + """Test the async_search_media method with different content types.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + + # Create mock search results + media_types = [] + if media_content_type == MediaType.TRACK: + media_types = ["track"] + elif media_content_type == MediaType.ALBUM: + media_types = ["album"] + elif media_content_type == MediaType.ARTIST: + media_types = ["artist"] + elif media_content_type == MediaType.PLAYLIST: + media_types = ["playlist"] + elif media_content_type == MEDIA_TYPE_RADIO: + media_types = ["radio"] + elif media_content_type == MediaType.PODCAST: + media_types = ["podcast"] + elif media_content_type == MEDIA_TYPE_AUDIOBOOK: + media_types = ["audiobook"] + elif media_content_type is None: + media_types = [ + "artist", + "album", + "track", + "playlist", + "radio", + "podcast", + "audiobook", + ] + + mock_results = MockSearchResults(media_types) + + # Use patch instead of trying to mock return_value + with patch.object( + music_assistant_client.music, "search", return_value=mock_results + ): + # Create search query + query = SearchMediaQuery( + search_query=search_query, + media_content_type=media_content_type, + ) + + # Perform search + search_results = await async_search_media(music_assistant_client, query) + + # Verify search results + assert isinstance(search_results, SearchMedia) + + if media_content_type is not None: + # For specific media types, expect up to 5 results + assert len(search_results.result) <= 5 + else: + # For "all types" search, we'd expect items from each type + # But since we're returning exactly 5 items per type (from mock) + # we'd expect 5 * 7 = 35 items maximum + assert len(search_results.result) <= 35 + + +@pytest.mark.parametrize( + ("search_query", "media_filter_classes", "expected_media_types"), + [ + # Search for tracks + ("track", {MediaClass.TRACK}, ["track"]), + # Search for albums + ("album", {MediaClass.ALBUM}, ["album"]), + # Search for artists + ("artist", {MediaClass.ARTIST}, ["artist"]), + # Search for playlists + ("playlist", {MediaClass.PLAYLIST}, ["playlist"]), + # Search for multiple media classes + ("music", {MediaClass.ALBUM, MediaClass.TRACK}, ["album", "track"]), + ], +) +async def test_search_media_with_filter_classes( + hass: HomeAssistant, + music_assistant_client: MagicMock, + search_query: str, + media_filter_classes: set[MediaClass], + expected_media_types: list[str], +) -> None: + """Test the async_search_media method with different media filter classes.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + + # Create mock search results + mock_results = MockSearchResults(expected_media_types) + + # Use patch instead of trying to mock return_value directly + with patch.object( + music_assistant_client.music, "search", return_value=mock_results + ): + # Create search query + query = SearchMediaQuery( + search_query=search_query, + media_filter_classes=media_filter_classes, + ) + + # Perform search + search_results = await async_search_media(music_assistant_client, query) + + # Verify search results + assert isinstance(search_results, SearchMedia) + expected_items = len(expected_media_types) * 5 # 5 items per media type + assert len(search_results.result) <= expected_items + + +async def test_search_media_within_album( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test searching within an album context.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + + # Mock album and tracks + album = MagicMock() + album.item_id = "396" + album.provider = "library" + + tracks = [] + for i in range(5): + track = MagicMock() + track.name = f"Test Track {i}" + track.uri = f"library://track/{i}" + track.available = True + track.artists = [] + media_type_mock = MagicMock() + media_type_mock.value = "track" + track.media_type = media_type_mock + tracks.append(track) + + # Set up mocks using patch + with ( + patch.object( + music_assistant_client.music, "get_item_by_uri", return_value=album + ), + patch.object( + music_assistant_client.music, "get_album_tracks", return_value=tracks + ), + ): + # Create search query within an album + album_uri = "library://album/396" + query = SearchMediaQuery( + search_query="track", + media_content_id=album_uri, + ) + + # Perform search + search_results = await async_search_media(music_assistant_client, query) + + # Verify search results + assert isinstance(search_results, SearchMedia) + assert len(search_results.result) > 0 # Should have results + + +async def test_search_media_error( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that search errors are properly handled.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + + # Use patch to cause an exception + with patch.object( + music_assistant_client.music, "search", side_effect=Exception("Search failed") + ): + # Create search query + query = SearchMediaQuery( + search_query="error test", + ) + + # Verify that the error is caught and a SearchError is raised + with pytest.raises(SearchError, match="Error searching for error test"): + await async_search_media(music_assistant_client, query) diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 25dfcd22c72..288d49092e5 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -2,10 +2,16 @@ from unittest.mock import MagicMock, call -from music_assistant_models.enums import MediaType, QueueOption +from music_assistant_models.constants import PLAYER_CONTROL_NONE +from music_assistant_models.enums import ( + EventType, + MediaType, + PlayerFeature, + QueueOption, +) from music_assistant_models.media_items import Track import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths from homeassistant.components.media_player import ( @@ -20,6 +26,7 @@ from homeassistant.components.media_player import ( SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, SERVICE_UNJOIN, + MediaPlayerEntityFeature, ) from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN from homeassistant.components.music_assistant.media_player import ( @@ -59,7 +66,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) from tests.common import AsyncMock @@ -607,3 +618,92 @@ async def test_media_player_get_queue_action( # 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")) + + +async def test_media_player_supported_features( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test if media_player entity supported features are cortrectly (re)mapped.""" + 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 + expected_features = ( + MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.MEDIA_ENQUEUE + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SEARCH_MEDIA + ) + assert state.attributes["supported_features"] == expected_features + # remove power control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].power_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.TURN_ON + expected_features &= ~MediaPlayerEntityFeature.TURN_OFF + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove volume control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].volume_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_SET + expected_features &= ~MediaPlayerEntityFeature.VOLUME_STEP + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove mute control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].mute_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_MUTE + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove grouping capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[mass_player_id].supported_features.remove( + PlayerFeature.SET_MEMBERS + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.GROUPING + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 1d407815db0..c2c110466e6 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -53,7 +53,7 @@ def gateway_nodes_fixture() -> dict[int, Sensor]: async def serial_transport_fixture( gateway_nodes: dict[int, Sensor], is_serial_port: MagicMock, -) -> AsyncGenerator[dict[int, Sensor]]: +) -> AsyncGenerator[MagicMock]: """Mock a serial transport.""" with ( patch( @@ -320,6 +320,21 @@ def hvac_node_heat( return nodes[1] +@pytest.fixture(name="hvac_node_only_hvac_state", scope="package") +def hvac_node_only_hvac_state_fixture() -> dict: + """Load the hvac node only hvac state.""" + return load_nodes_state("hvac_node_only_hvac_state.json") + + +@pytest.fixture +def hvac_node_only_hvac( + gateway_nodes: dict[int, Sensor], hvac_node_only_hvac_state: dict +) -> Sensor: + """Load the hvac only hvac child node.""" + nodes = update_gateway_nodes(gateway_nodes, deepcopy(hvac_node_only_hvac_state)) + return nodes[1] + + @pytest.fixture(name="power_sensor_state", scope="package") def power_sensor_state_fixture() -> dict: """Load the power sensor state.""" diff --git a/tests/components/mysensors/fixtures/hvac_node_only_hvac_state.json b/tests/components/mysensors/fixtures/hvac_node_only_hvac_state.json new file mode 100644 index 00000000000..b41470e6076 --- /dev/null +++ b/tests/components/mysensors/fixtures/hvac_node_only_hvac_state.json @@ -0,0 +1,22 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 29, + "description": "", + "values": { + "0": "20.0", + "21": "Off" + } + } + }, + "type": 17, + "sketch_name": "HVAC Node", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/components/mysensors/test_climate.py b/tests/components/mysensors/test_climate.py index 959f92ff512..b919287e046 100644 --- a/tests/components/mysensors/test_climate.py +++ b/tests/components/mysensors/test_climate.py @@ -38,6 +38,8 @@ async def test_hvac_node_auto( assert state assert state.state == HVACMode.OFF assert state.attributes[ATTR_BATTERY_LEVEL] == 0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 394 # Test set hvac mode auto await hass.services.async_call( @@ -153,6 +155,8 @@ async def test_hvac_node_heat( assert state assert state.state == HVACMode.OFF assert state.attributes[ATTR_BATTERY_LEVEL] == 0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 393 # Test set hvac mode heat await hass.services.async_call( @@ -263,8 +267,10 @@ async def test_hvac_node_cool( assert state assert state.state == HVACMode.OFF assert state.attributes[ATTR_BATTERY_LEVEL] == 0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 393 - # Test set hvac mode heat + # Test set hvac mode cool await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -357,3 +363,83 @@ async def test_hvac_node_cool( assert state assert state.state == HVACMode.OFF + + +async def test_hvac_node_only_hvac( + hass: HomeAssistant, + hvac_node_only_hvac: Sensor, + receive_message: Callable[[str], None], + transport_write: MagicMock, +) -> None: + """Test a hvac only hvac node.""" + entity_id = "climate.hvac_node_1_1" + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.OFF + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 384 + + # Test set hvac mode heat + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;21;HeatOn\n") + + receive_message("1;1;1;0;21;HeatOn\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + + transport_write.reset_mock() + + # Test set hvac mode cool + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;21;CoolOn\n") + + receive_message("1;1;1;0;21;CoolOn\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + + transport_write.reset_mock() + + # Test set hvac mode off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;21;Off\n") + + receive_message("1;1;1;0;21;Off\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.OFF diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 7f6ea76d3e1..108f2d7e592 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -2,10 +2,15 @@ from __future__ import annotations +from collections.abc import Callable +from unittest.mock import MagicMock + from mysensors import BaseSyncGateway from mysensors.sensor import Sensor from homeassistant.components.mysensors import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -14,6 +19,50 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +async def test_load_unload( + hass: HomeAssistant, + door_sensor: Sensor, + transport: MagicMock, + integration: MockConfigEntry, + receive_message: Callable[[str], None], +) -> None: + """Test loading and unloading the MySensors config entry.""" + config_entry = integration + + assert config_entry.state == ConfigEntryState.LOADED + + entity_id = "binary_sensor.door_sensor_1_1" + state = hass.states.get(entity_id) + + assert state + assert state.state != STATE_UNAVAILABLE + + receive_message("1;1;1;0;16;1\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state != STATE_UNAVAILABLE + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert transport.return_value.disconnect.call_count == 1 + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNAVAILABLE + + receive_message("1;1;1;0;16;1\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNAVAILABLE + + async def test_remove_config_entry_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/myuplink/test_binary_sensor.py b/tests/components/myuplink/test_binary_sensor.py index 160530bcdab..cf297a0a3f7 100644 --- a/tests/components/myuplink/test_binary_sensor.py +++ b/tests/components/myuplink/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/myuplink/test_diagnostics.py b/tests/components/myuplink/test_diagnostics.py index e0803eb76f0..1da81c5cf1f 100644 --- a/tests/components/myuplink/test_diagnostics.py +++ b/tests/components/myuplink/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the myuplink integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths from homeassistant.core import HomeAssistant diff --git a/tests/components/myuplink/test_init.py b/tests/components/myuplink/test_init.py index 320bf202024..891ba992772 100644 --- a/tests/components/myuplink/test_init.py +++ b/tests/components/myuplink/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock from aiohttp import ClientConnectionError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.myuplink.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/myuplink/test_number.py b/tests/components/myuplink/test_number.py index ef7b1749782..a488ae3972c 100644 --- a/tests/components/myuplink/test_number.py +++ b/tests/components/myuplink/test_number.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import SERVICE_SET_VALUE from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/myuplink/test_select.py b/tests/components/myuplink/test_select.py index f1797ebe5ad..f19aff60d26 100644 --- a/tests/components/myuplink/test_select.py +++ b/tests/components/myuplink/test_select.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import ( ATTR_ENTITY_ID, diff --git a/tests/components/myuplink/test_sensor.py b/tests/components/myuplink/test_sensor.py index 98cdfc322da..9f0beebe995 100644 --- a/tests/components/myuplink/test_sensor.py +++ b/tests/components/myuplink/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/myuplink/test_switch.py b/tests/components/myuplink/test_switch.py index 82d381df7fc..628287b8fd8 100644 --- a/tests/components/myuplink/test_switch.py +++ b/tests/components/myuplink/test_switch.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from aiohttp import ClientError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import ( ATTR_ENTITY_ID, diff --git a/tests/components/nam/fixtures/nam_data.json b/tests/components/nam/fixtures/nam_data.json index 82dacbefb34..47ebe099dc7 100644 --- a/tests/components/nam/fixtures/nam_data.json +++ b/tests/components/nam/fixtures/nam_data.json @@ -26,6 +26,7 @@ { "value_type": "temperature", "value": "6.26" }, { "value_type": "HECA_temperature", "value": "7.95" }, { "value_type": "HECA_humidity", "value": "49.97" }, + { "value_type": "ambient_light", "value": "298.45" }, { "value_type": "signal", "value": "-72" } ] } diff --git a/tests/components/nam/snapshots/test_diagnostics.ambr b/tests/components/nam/snapshots/test_diagnostics.ambr index e92e02fa1d8..c0009899d16 100644 --- a/tests/components/nam/snapshots/test_diagnostics.ambr +++ b/tests/components/nam/snapshots/test_diagnostics.ambr @@ -2,6 +2,7 @@ # name: test_entry_diagnostics dict({ 'data': dict({ + 'bh1750_illuminance': 298.45, 'bme280_humidity': 45.69, 'bme280_pressure': 1011.0117, 'bme280_temperature': 7.56, diff --git a/tests/components/nam/snapshots/test_sensor.ambr b/tests/components/nam/snapshots/test_sensor.ambr index 429d069b741..c6c32737a31 100644 --- a/tests/components/nam/snapshots/test_sensor.ambr +++ b/tests/components/nam/snapshots/test_sensor.ambr @@ -1,4 +1,59 @@ # serializer version: 1 +# name: test_sensor[sensor.nettigo_air_monitor_bh1750_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nettigo_air_monitor_bh1750_illuminance', + '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': 'BH1750 illuminance', + 'platform': 'nam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bh1750_illuminance', + 'unique_id': 'aa:bb:cc:dd:ee:ff-bh1750_illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.nettigo_air_monitor_bh1750_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Nettigo Air Monitor BH1750 illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.nettigo_air_monitor_bh1750_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '298.45', + }) +# --- # name: test_sensor[sensor.nettigo_air_monitor_bme280_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/nam/test_button.py b/tests/components/nam/test_button.py index 39c37d57f89..b410665911a 100644 --- a/tests/components/nam/test_button.py +++ b/tests/components/nam/test_button.py @@ -2,9 +2,20 @@ from unittest.mock import patch -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass +from aiohttp.client_exceptions import ClientError +from nettigo_air_monitor import ApiError, AuthFailedError +import pytest + +from homeassistant.components.button import ( + DOMAIN as BUTTON_DOMAIN, + SERVICE_PRESS, + ButtonDeviceClass, +) +from homeassistant.components.nam import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN 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 @@ -38,7 +49,7 @@ async def test_button_press(hass: HomeAssistant) -> None: ): await hass.services.async_call( BUTTON_DOMAIN, - "press", + SERVICE_PRESS, {ATTR_ENTITY_ID: "button.nettigo_air_monitor_restart"}, blocking=True, ) @@ -49,3 +60,55 @@ async def test_button_press(hass: HomeAssistant) -> None: state = hass.states.get("button.nettigo_air_monitor_restart") assert state assert state.state == now.isoformat() + + +@pytest.mark.parametrize(("exc"), [ApiError("API Error"), ClientError]) +async def test_button_press_exc(hass: HomeAssistant, exc: Exception) -> None: + """Test button press when exception occurs.""" + await init_integration(hass) + + with ( + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_restart", + side_effect=exc, + ), + pytest.raises( + HomeAssistantError, + match="An error occurred while calling action for button.nettigo_air_monitor_restart", + ), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.nettigo_air_monitor_restart"}, + blocking=True, + ) + + +async def test_button_press_auth_error(hass: HomeAssistant) -> None: + """Test button press when auth error occurs.""" + entry = await init_integration(hass) + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_restart", + side_effect=AuthFailedError("auth error"), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.nettigo_air_monitor_restart"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + 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") == entry.entry_id diff --git a/tests/components/nam/test_diagnostics.py b/tests/components/nam/test_diagnostics.py index 7ed49a37e0a..b29e5e834b2 100644 --- a/tests/components/nam/test_diagnostics.py +++ b/tests/components/nam/test_diagnostics.py @@ -1,6 +1,6 @@ """Test NAM diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index 6924af48f01..40cabfb49ae 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch from freezegun.api import FrozenDateTimeFactory from nettigo_air_monitor import ApiError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tenacity import RetryError from homeassistant.components.nam.const import DEFAULT_UPDATE_INTERVAL, DOMAIN diff --git a/tests/components/namecheapdns/test_init.py b/tests/components/namecheapdns/test_init.py index 1d5b4ca5949..b7c1fe732c0 100644 --- a/tests/components/namecheapdns/test_init.py +++ b/tests/components/namecheapdns/test_init.py @@ -18,7 +18,7 @@ PASSWORD = "abcdefgh" @pytest.fixture -def setup_namecheapdns( +async def setup_namecheapdns( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Fixture that sets up NamecheapDNS.""" @@ -28,12 +28,10 @@ def setup_namecheapdns( text="0", ) - hass.loop.run_until_complete( - async_setup_component( - hass, - namecheapdns.DOMAIN, - {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, - ) + await async_setup_component( + hass, + namecheapdns.DOMAIN, + {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, ) diff --git a/tests/components/nanoleaf/__init__.py b/tests/components/nanoleaf/__init__.py index ee614fad173..0e6d571e320 100644 --- a/tests/components/nanoleaf/__init__.py +++ b/tests/components/nanoleaf/__init__.py @@ -1 +1,13 @@ """Tests for the Nanoleaf integration.""" + +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() diff --git a/tests/components/nanoleaf/conftest.py b/tests/components/nanoleaf/conftest.py new file mode 100644 index 00000000000..5dae7727eec --- /dev/null +++ b/tests/components/nanoleaf/conftest.py @@ -0,0 +1,49 @@ +"""Common fixtures for Nanoleaf tests.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.nanoleaf import DOMAIN +from homeassistant.const import CONF_HOST, CONF_TOKEN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a Nanoleaf config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "10.0.0.10", + CONF_TOKEN: "1234567890abcdef", + }, + ) + + +@pytest.fixture +async def mock_nanoleaf() -> AsyncGenerator[AsyncMock]: + """Mock a Nanoleaf device.""" + with patch( + "homeassistant.components.nanoleaf.Nanoleaf", autospec=True + ) as mock_nanoleaf: + client = mock_nanoleaf.return_value + client.model = "NO_TOUCH" + client.host = "10.0.0.10" + client.serial_no = "ABCDEF123456" + client.color_temperature_max = 4500 + client.color_temperature_min = 1200 + client.is_on = False + client.brightness = 50 + client.color_temperature = 2700 + client.hue = 120 + client.saturation = 50 + client.color_mode = "hs" + client.effect = "Rainbow" + client.effects_list = ["Rainbow", "Sunset", "Nemo"] + client.firmware_version = "4.0.0" + client.name = "Nanoleaf" + client.manufacturer = "Nanoleaf" + yield client diff --git a/tests/components/nanoleaf/snapshots/test_light.ambr b/tests/components/nanoleaf/snapshots/test_light.ambr new file mode 100644 index 00000000000..277c24a7365 --- /dev/null +++ b/tests/components/nanoleaf/snapshots/test_light.ambr @@ -0,0 +1,84 @@ +# serializer version: 1 +# name: test_entities[light.nanoleaf-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'effect_list': list([ + 'Rainbow', + 'Sunset', + 'Nemo', + ]), + 'max_color_temp_kelvin': 4500, + 'max_mireds': 833, + 'min_color_temp_kelvin': 1200, + 'min_mireds': 222, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.nanoleaf', + 'has_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': 'nanoleaf', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'light', + 'unique_id': 'ABCDEF123456', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.nanoleaf-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'effect': None, + 'effect_list': list([ + 'Rainbow', + 'Sunset', + 'Nemo', + ]), + 'friendly_name': 'Nanoleaf', + 'hs_color': None, + 'max_color_temp_kelvin': 4500, + 'max_mireds': 833, + 'min_color_temp_kelvin': 1200, + 'min_mireds': 222, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.nanoleaf', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/nanoleaf/test_light.py b/tests/components/nanoleaf/test_light.py new file mode 100644 index 00000000000..3260c2e2609 --- /dev/null +++ b/tests/components/nanoleaf/test_light.py @@ -0,0 +1,68 @@ +"""Tests for the Nanoleaf light platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ATTR_EFFECT_LIST, DOMAIN as LIGHT_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 setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_nanoleaf: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.nanoleaf.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("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) +async def test_turning_on_or_off_writes_state( + hass: HomeAssistant, + mock_nanoleaf: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, +) -> None: + """Test turning on or off the light writes the state.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.nanoleaf").attributes[ATTR_EFFECT_LIST] == [ + "Rainbow", + "Sunset", + "Nemo", + ] + + mock_nanoleaf.effects_list = ["Rainbow", "Sunset", "Nemo", "Something Else"] + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + { + ATTR_ENTITY_ID: "light.nanoleaf", + }, + blocking=True, + ) + assert hass.states.get("light.nanoleaf").attributes[ATTR_EFFECT_LIST] == [ + "Rainbow", + "Sunset", + "Nemo", + "Something Else", + ] diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 92d90a18a7e..b4b94efce5b 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -144,13 +144,14 @@ async def auth( return FakeAuth(aioclient_mock, create_device, device_access_project_id) -@pytest.fixture(autouse=True) -def cleanup_media_storage(hass: HomeAssistant) -> Generator[None]: +@pytest.fixture(autouse=True, name="media_path") +def cleanup_media_storage(hass: HomeAssistant) -> Generator[str]: """Test cleanup, remove any media storage persisted during the test.""" tmp_path = str(uuid.uuid4()) with patch("homeassistant.components.nest.media_source.MEDIA_PATH", new=tmp_path): - yield - shutil.rmtree(hass.config.path(tmp_path), ignore_errors=True) + full_path = hass.config.path(tmp_path) + yield full_path + shutil.rmtree(full_path, ignore_errors=True) @pytest.fixture diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 3e7dbd3f223..c0579c99a62 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -826,7 +826,6 @@ async def test_camera_multiple_streams( assert cam is not None assert cam.state == CameraState.STREAMING # Prefer WebRTC over RTSP/HLS - assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC client = await hass_ws_client(hass) assert await async_frontend_stream_types(client, "camera.my_camera") == [ StreamType.WEB_RTC @@ -905,7 +904,6 @@ async def test_webrtc_refresh_expired_stream( cam = hass.states.get("camera.my_camera") assert cam is not None assert cam.state == CameraState.STREAMING - assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC client = await hass_ws_client(hass) assert await async_frontend_stream_types(client, "camera.my_camera") == [ StreamType.WEB_RTC diff --git a/tests/components/nest/test_diagnostics.py b/tests/components/nest/test_diagnostics.py index a072394a43d..74249a71a8b 100644 --- a/tests/components/nest/test_diagnostics.py +++ b/tests/components/nest/test_diagnostics.py @@ -4,7 +4,7 @@ from unittest.mock import patch from google_nest_sdm.exceptions import SubscriberException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.nest.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index d009e1185da..0b0654fc69c 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -8,11 +8,13 @@ from collections.abc import Generator import datetime from http import HTTPStatus import io +import pathlib from typing import Any from unittest.mock import patch import aiohttp import av +from freezegun import freeze_time import numpy as np import pytest @@ -39,7 +41,7 @@ from .common import ( ) from .conftest import FakeAuth -from tests.common import MockUser, async_capture_events +from tests.common import MockUser, async_capture_events, async_fire_time_changed from tests.typing import ClientSessionGenerator DOMAIN = "nest" @@ -1574,3 +1576,80 @@ async def test_event_clip_media_attachment( response = await client.get(content_path) assert response.status == HTTPStatus.OK, f"Response not matched: {response}" await response.read() + + +@pytest.mark.parametrize(("device_traits", "cache_size"), [(BATTERY_CAMERA_TRAITS, 5)]) +async def test_remove_stale_media( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + auth, + mp4, + hass_client: ClientSessionGenerator, + subscriber, + setup_platform, + media_path: str, +) -> None: + """Test media files getting evicted from the cache.""" + await setup_platform() + + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Publish a media event + auth.responses = [ + aiohttp.web.Response(body=mp4.getvalue()), + ] + event_timestamp = dt_util.now() + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(MOTION_EVENT), + timestamp=event_timestamp, + ) + ) + await hass.async_block_till_done() + + # The first subdirectory is the device id. Media for events are stored in the + # device subdirectory. First verify that the media was persisted. We will + # then add additional media files, then invoke the garbage collector, and + # then verify orphaned files are removed. + storage_path = pathlib.Path(media_path) + device_path = storage_path / device.id + media_files = list(device_path.glob("*")) + assert len(media_files) == 1 + event_media = media_files[0] + assert event_media.name.endswith(".mp4") + + event_time1 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=8) + extra_media1 = ( + device_path / f"{int(event_time1.timestamp())}-camera_motion-test.mp4" + ) + extra_media1.write_bytes(mp4.getvalue()) + event_time2 = event_time1 + datetime.timedelta(hours=20) + extra_media2 = ( + device_path / f"{int(event_time2.timestamp())}-camera_motion-test.jpg" + ) + extra_media2.write_bytes(mp4.getvalue()) + # This event will not be garbage collected because it is too recent + event_time3 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=3) + extra_media3 = ( + device_path / f"{int(event_time3.timestamp())}-camera_motion-test.mp4" + ) + extra_media3.write_bytes(mp4.getvalue()) + + assert len(list(device_path.glob("*"))) == 4 + + # Advance the clock to invoke the garbage collector. This will remove extra + # files that are not valid events that are old enough. + point_in_time = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) + with freeze_time(point_in_time): + async_fire_time_changed(hass, point_in_time) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify that the event media is still present and that the extra files + # are removed. Newer media is not removed. + assert event_media.exists() + assert not extra_media1.exists() + assert not extra_media2.exists() + assert extra_media3.exists() diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 9110f8c724f..06c56aa7e22 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -6,7 +6,7 @@ import json from typing import Any from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.webhook import async_handle_webhook from homeassistant.const import Platform diff --git a/tests/components/netatmo/fixtures/homesdata.json b/tests/components/netatmo/fixtures/homesdata.json index ccc71dc6b41..344d3ecc29c 100644 --- a/tests/components/netatmo/fixtures/homesdata.json +++ b/tests/components/netatmo/fixtures/homesdata.json @@ -630,7 +630,7 @@ "name": "Default", "selected": true, "id": "591b54a2764ff4d50d8b5795", - "type": "therm" + "type": "cooling" }, { "zones": [ @@ -778,6 +778,8 @@ } ], "therm_setpoint_default_duration": 120, + "temperature_control_mode": "cooling", + "cooling_mode": "schedule", "persons": [ { "id": "91827374-7e04-5298-83ad-a0cb8372dff1", diff --git a/tests/components/netatmo/snapshots/test_camera.ambr b/tests/components/netatmo/snapshots/test_camera.ambr index 9bd10ed9b5f..7f38e261768 100644 --- a/tests/components/netatmo/snapshots/test_camera.ambr +++ b/tests/components/netatmo/snapshots/test_camera.ambr @@ -42,7 +42,6 @@ 'brand': 'Netatmo', 'entity_picture': '/api/camera_proxy/camera.front?token=1caab5c3b3', 'friendly_name': 'Front', - 'frontend_stream_type': , 'id': '12:34:56:10:b9:0e', 'is_local': False, 'light_state': None, @@ -104,7 +103,6 @@ 'brand': 'Netatmo', 'entity_picture': '/api/camera_proxy/camera.hall?token=1caab5c3b3', 'friendly_name': 'Hall', - 'frontend_stream_type': , 'id': '12:34:56:00:f1:62', 'is_local': True, 'light_state': None, diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 4ea7e30bcf9..3a66aa84c41 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -8,6 +8,7 @@ 'homes': list([ dict({ 'altitude': 112, + 'cooling_mode': 'schedule', 'coordinates': '**REDACTED**', 'country': 'DE', 'id': '91763b24c43d3e344f424e8b', @@ -539,7 +540,7 @@ 'name': '**REDACTED**', 'selected': True, 'timetable': '**REDACTED**', - 'type': 'therm', + 'type': 'cooling', 'zones': '**REDACTED**', }), dict({ @@ -552,6 +553,7 @@ 'zones': '**REDACTED**', }), ]), + 'temperature_control_mode': 'cooling', 'therm_mode': 'schedule', 'therm_setpoint_default_duration': 120, 'timezone': 'Europe/Berlin', diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index b149e80fa5b..8b974027116 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -499,7 +499,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.baby_bedroom_wi_fi-entry] +# name: test_entity[sensor.baby_bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -512,7 +512,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.baby_bedroom_wi_fi', + 'entity_id': 'sensor.baby_bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -524,7 +524,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -533,16 +533,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.baby_bedroom_wi_fi-state] +# name: test_entity[sensor.baby_bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Baby Bedroom Wi-Fi', + 'friendly_name': 'Baby Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.baby_bedroom_wi_fi', + 'entity_id': 'sensor.baby_bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1033,7 +1033,7 @@ 'state': 'unavailable', }) # --- -# name: test_entity[sensor.bedroom_wi_fi-entry] +# name: test_entity[sensor.bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1046,7 +1046,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.bedroom_wi_fi', + 'entity_id': 'sensor.bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1058,7 +1058,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -1067,16 +1067,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.bedroom_wi_fi-state] +# name: test_entity[sensor.bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Bedroom Wi-Fi', + 'friendly_name': 'Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.bedroom_wi_fi', + 'entity_id': 'sensor.bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1501,7 +1501,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1520,7 +1520,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -1535,10 +1535,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Home avg Gust angle', 'latitude': 32.17901225, 'longitude': -117.17901225, - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -1659,60 +1660,6 @@ 'state': '63.2', }) # --- -# name: test_entity[sensor.home_avg_none-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_avg_none', - 'has_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': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Home-avg-windangle_value', - 'unit_of_measurement': '°', - }) -# --- -# name: test_entity[sensor.home_avg_none-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Home avg None', - 'latitude': 32.17901225, - 'longitude': -117.17901225, - 'state_class': , - 'unit_of_measurement': '°', - }), - 'context': , - 'entity_id': 'sensor.home_avg_none', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17.0', - }) -# --- # name: test_entity[sensor.home_avg_precipitation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1939,6 +1886,61 @@ 'state': '22.7', }) # --- +# name: test_entity[sensor.home_avg_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_avg_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', + 'friendly_name': 'Home avg Wind direction', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.home_avg_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- # name: test_entity[sensor.home_avg_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2061,7 +2063,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2080,7 +2082,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -2095,10 +2097,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Home max Gust angle', 'latitude': 32.17901225, 'longitude': -117.17901225, - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -2219,60 +2222,6 @@ 'state': '76', }) # --- -# name: test_entity[sensor.home_max_none-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_max_none', - 'has_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': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Home-max-windangle_value', - 'unit_of_measurement': '°', - }) -# --- -# name: test_entity[sensor.home_max_none-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Home max None', - 'latitude': 32.17901225, - 'longitude': -117.17901225, - 'state_class': , - 'unit_of_measurement': '°', - }), - 'context': , - 'entity_id': 'sensor.home_max_none', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', - }) -# --- # name: test_entity[sensor.home_max_precipitation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2499,6 +2448,61 @@ 'state': '27.4', }) # --- +# name: test_entity[sensor.home_max_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_max_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', + 'friendly_name': 'Home max Wind direction', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.home_max_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- # name: test_entity[sensor.home_max_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2621,7 +2625,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2640,7 +2644,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -2655,10 +2659,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Home min Gust angle', 'latitude': 32.17901225, 'longitude': -117.17901225, - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -2779,60 +2784,6 @@ 'state': '56', }) # --- -# name: test_entity[sensor.home_min_none-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_min_none', - 'has_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': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Home-min-windangle_value', - 'unit_of_measurement': '°', - }) -# --- -# name: test_entity[sensor.home_min_none-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Home min None', - 'latitude': 32.17901225, - 'longitude': -117.17901225, - 'state_class': , - 'unit_of_measurement': '°', - }), - 'context': , - 'entity_id': 'sensor.home_min_none', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', - }) -# --- # name: test_entity[sensor.home_min_precipitation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3059,6 +3010,61 @@ 'state': '19.8', }) # --- +# name: test_entity[sensor.home_min_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_min_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-min-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_min_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', + 'friendly_name': 'Home min Wind direction', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.home_min_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- # name: test_entity[sensor.home_min_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3662,7 +3668,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.kitchen_wi_fi-entry] +# name: test_entity[sensor.kitchen_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3675,7 +3681,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.kitchen_wi_fi', + 'entity_id': 'sensor.kitchen_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3687,7 +3693,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -3696,16 +3702,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.kitchen_wi_fi-state] +# name: test_entity[sensor.kitchen_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Kitchen Wi-Fi', + 'friendly_name': 'Kitchen Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.kitchen_wi_fi', + 'entity_id': 'sensor.kitchen_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4505,7 +4511,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.livingroom_wi_fi-entry] +# name: test_entity[sensor.livingroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4518,7 +4524,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.livingroom_wi_fi', + 'entity_id': 'sensor.livingroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4530,7 +4536,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -4539,16 +4545,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.livingroom_wi_fi-state] +# name: test_entity[sensor.livingroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Livingroom Wi-Fi', + 'friendly_name': 'Livingroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.livingroom_wi_fi', + 'entity_id': 'sensor.livingroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -5055,7 +5061,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.parents_bedroom_wi_fi-entry] +# name: test_entity[sensor.parents_bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5068,7 +5074,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.parents_bedroom_wi_fi', + 'entity_id': 'sensor.parents_bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5080,7 +5086,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -5089,16 +5095,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.parents_bedroom_wi_fi-state] +# name: test_entity[sensor.parents_bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Parents Bedroom Wi-Fi', + 'friendly_name': 'Parents Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.parents_bedroom_wi_fi', + 'entity_id': 'sensor.parents_bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -5580,54 +5586,6 @@ 'state': '55', }) # --- -# name: test_entity[sensor.villa_bathroom_radio-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.villa_bathroom_radio', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Radio', - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rf_strength', - 'unique_id': '12:34:56:80:7e:18-rf_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity[sensor.villa_bathroom_radio-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Bathroom Radio', - }), - 'context': , - 'entity_id': 'sensor.villa_bathroom_radio', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Full', - }) -# --- # name: test_entity[sensor.villa_bathroom_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5676,6 +5634,54 @@ 'state': 'True', }) # --- +# name: test_entity[sensor.villa_bathroom_rf_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_bathroom_rf_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RF strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rf_strength', + 'unique_id': '12:34:56:80:7e:18-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bathroom_rf_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Bathroom RF strength', + }), + 'context': , + 'entity_id': 'sensor.villa_bathroom_rf_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Full', + }) +# --- # name: test_entity[sensor.villa_bathroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5939,54 +5945,6 @@ 'state': '53', }) # --- -# name: test_entity[sensor.villa_bedroom_radio-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.villa_bedroom_radio', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Radio', - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rf_strength', - 'unique_id': '12:34:56:80:44:92-rf_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity[sensor.villa_bedroom_radio-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Bedroom Radio', - }), - 'context': , - 'entity_id': 'sensor.villa_bedroom_radio', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'High', - }) -# --- # name: test_entity[sensor.villa_bedroom_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6035,6 +5993,54 @@ 'state': 'True', }) # --- +# name: test_entity[sensor.villa_bedroom_rf_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_bedroom_rf_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RF strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rf_strength', + 'unique_id': '12:34:56:80:44:92-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bedroom_rf_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Bedroom RF strength', + }), + 'context': , + 'entity_id': 'sensor.villa_bedroom_rf_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'High', + }) +# --- # name: test_entity[sensor.villa_bedroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6253,7 +6259,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -6272,7 +6278,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -6287,8 +6293,9 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Villa Garden Gust angle', - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -6422,54 +6429,6 @@ 'state': '9', }) # --- -# name: test_entity[sensor.villa_garden_radio-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.villa_garden_radio', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Radio', - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rf_strength', - 'unique_id': '12:34:56:03:1b:e4-rf_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity[sensor.villa_garden_radio-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Garden Radio', - }), - 'context': , - 'entity_id': 'sensor.villa_garden_radio', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Full', - }) -# --- # name: test_entity[sensor.villa_garden_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6518,13 +6477,61 @@ 'state': 'True', }) # --- +# name: test_entity[sensor.villa_garden_rf_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_garden_rf_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RF strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rf_strength', + 'unique_id': '12:34:56:03:1b:e4-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_garden_rf_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Garden RF strength', + }), + 'context': , + 'entity_id': 'sensor.villa_garden_rf_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Full', + }) +# --- # name: test_entity[sensor.villa_garden_wind_angle-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -6543,7 +6550,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Wind angle', 'platform': 'netatmo', @@ -6558,8 +6565,9 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Villa Garden Wind angle', - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -6909,54 +6917,6 @@ 'state': 'unavailable', }) # --- -# name: test_entity[sensor.villa_outdoor_radio-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.villa_outdoor_radio', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Radio', - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rf_strength', - 'unique_id': '12:34:56:80:1c:42-rf_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity[sensor.villa_outdoor_radio-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Outdoor Radio', - }), - 'context': , - 'entity_id': 'sensor.villa_outdoor_radio', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'High', - }) -# --- # name: test_entity[sensor.villa_outdoor_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7005,6 +6965,54 @@ 'state': 'False', }) # --- +# name: test_entity[sensor.villa_outdoor_rf_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_outdoor_rf_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RF strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rf_strength', + 'unique_id': '12:34:56:80:1c:42-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_outdoor_rf_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Outdoor RF strength', + }), + 'context': , + 'entity_id': 'sensor.villa_outdoor_rf_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'High', + }) +# --- # name: test_entity[sensor.villa_outdoor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7374,54 +7382,6 @@ 'state': '6.9', }) # --- -# name: test_entity[sensor.villa_rain_radio-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.villa_rain_radio', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Radio', - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rf_strength', - 'unique_id': '12:34:56:80:c1:ea-rf_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity[sensor.villa_rain_radio-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Rain Radio', - }), - 'context': , - 'entity_id': 'sensor.villa_rain_radio', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Medium', - }) -# --- # name: test_entity[sensor.villa_rain_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7470,6 +7430,54 @@ 'state': 'True', }) # --- +# name: test_entity[sensor.villa_rain_rf_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_rain_rf_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RF strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rf_strength', + 'unique_id': '12:34:56:80:c1:ea-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_rain_rf_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Rain RF strength', + }), + 'context': , + 'entity_id': 'sensor.villa_rain_rf_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Medium', + }) +# --- # name: test_entity[sensor.villa_reachability-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -7628,7 +7636,7 @@ 'state': 'stable', }) # --- -# name: test_entity[sensor.villa_wi_fi-entry] +# name: test_entity[sensor.villa_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7641,7 +7649,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_wi_fi', + 'entity_id': 'sensor.villa_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -7653,7 +7661,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -7662,16 +7670,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_wi_fi-state] +# name: test_entity[sensor.villa_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Wi-Fi', + 'friendly_name': 'Villa Wi-Fi strength', 'latitude': 46.123456, 'longitude': 6.1234567, }), 'context': , - 'entity_id': 'sensor.villa_wi_fi', + 'entity_id': 'sensor.villa_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/netatmo/test_binary_sensor.py b/tests/components/netatmo/test_binary_sensor.py index 7b841ba204e..91d2b3ad63b 100644 --- a/tests/components/netatmo/test_binary_sensor.py +++ b/tests/components/netatmo/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/netatmo/test_button.py b/tests/components/netatmo/test_button.py index bffecf7d83a..d526f508624 100644 --- a/tests/components/netatmo/test_button.py +++ b/tests/components/netatmo/test_button.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 32f20544043..706cf887539 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch import pyatmo import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import camera from homeassistant.components.camera import CameraState diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 18c811fd76b..f3532c999e7 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from voluptuous.error import MultipleInvalid from homeassistant.components.climate import ( @@ -66,6 +66,34 @@ async def test_entity( ) +async def test_schedule_update_webhook_event( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: + """Test schedule update webhook event without schedule_id.""" + + with selected_platforms([Platform.CLIMATE]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + climate_entity_livingroom = "climate.livingroom" + + # Save initial state + initial_state = hass.states.get(climate_entity_livingroom) + + # Create a schedule update event without a schedule_id (the event is sent when temperature sets of a schedule are changed) + response = { + "home_id": "91763b24c43d3e344f424e8b", + "event_type": "schedule", + "push_type": "home_event_changed", + } + await simulate_webhook(hass, webhook_id, response) + + # State should be unchanged + assert hass.states.get(climate_entity_livingroom) == initial_state + + async def test_webhook_event_handling_thermostats( hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: diff --git a/tests/components/netatmo/test_cover.py b/tests/components/netatmo/test_cover.py index 9368a564afb..3aa67395cec 100644 --- a/tests/components/netatmo/test_cover.py +++ b/tests/components/netatmo/test_cover.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( ATTR_POSITION, diff --git a/tests/components/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py index 7a0bf11c652..dadec4a1eb2 100644 --- a/tests/components/netatmo/test_diagnostics.py +++ b/tests/components/netatmo/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths from homeassistant.core import HomeAssistant diff --git a/tests/components/netatmo/test_fan.py b/tests/components/netatmo/test_fan.py index 3dbc8b3a6f5..e80d3ae76fd 100644 --- a/tests/components/netatmo/test_fan.py +++ b/tests/components/netatmo/test_fan.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PRESET_MODE, diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 5fdf4f8ea35..18d255ec6ee 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch import aiohttp from pyatmo.const import ALL_SCOPES import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import cloud from homeassistant.components.netatmo import DOMAIN @@ -25,11 +25,7 @@ from .common import ( simulate_webhook, ) -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_get_persistent_notifications, -) +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.cloud import mock_cloud from tests.typing import WebSocketGenerator @@ -423,9 +419,8 @@ async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) - notifications = async_get_persistent_notifications(hass) - - assert len(notifications) > 0 + # Test a reauth flow is initiated + assert len(list(config_entry.async_get_active_flows(hass, {"reauth"}))) == 1 for config_entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(config_entry.entry_id) @@ -476,8 +471,9 @@ async def test_setup_component_invalid_token( assert config_entry.state is ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) - notifications = async_get_persistent_notifications(hass) - assert len(notifications) > 0 + + # Test a reauth flow is initiated + assert len(list(config_entry.async_get_active_flows(hass, {"reauth"}))) == 1 for entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 0932395b8ec..16a3ac2aaeb 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index f9aff2749d2..3d787a1a813 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -57,8 +57,10 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: # Test invalid base with pytest.raises(BrowseError) as excinfo: await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/") - assert str(excinfo.value) == "Invalid media source URI" - + assert str(excinfo.value) == ( + "Failed to browse media with content id media-source://netatmo/: " + "Invalid media source URI" + ) # Test successful listing media = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/events") diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index 458115f8f5c..6b9eb6f4451 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 2c47cdefa60..95776d21f6a 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.netatmo import sensor from homeassistant.const import Platform @@ -153,7 +153,7 @@ async def test_process_health(health: int, expected: str) -> None: ("uid", "name", "expected"), [ ("12:34:56:03:1b:e4-reachable", "villa_garden_reachable", "True"), - ("12:34:56:03:1b:e4-rf_status", "villa_garden_radio", "Full"), + ("12:34:56:03:1b:e4-rf_status", "villa_garden_rf_strength", "Full"), ( "12:34:56:80:bb:26-wifi_status", "villa_wifi_strength", @@ -205,7 +205,7 @@ async def test_process_health(health: int, expected: str) -> None: ), ( "12:34:56:26:68:92-wifi_status", - "baby_bedroom_wifi", + "baby_bedroom_wifi_strength", "High", ), ("Home-max-windangle_value", "home_max_wind_angle", "17"), diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index 837f6201b1e..fd7b09daa4f 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/network/snapshots/test_init.ambr b/tests/components/network/snapshots/test_init.ambr new file mode 100644 index 00000000000..268c8e0d44f --- /dev/null +++ b/tests/components/network/snapshots/test_init.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_repair_docker_host_network_without_host_networking[mock_socket0] + IssueRegistryItemSnapshot({ + 'active': True, + 'breaks_in_ha_version': None, + 'created': , + 'data': None, + 'dismissed_version': None, + 'domain': 'network', + 'is_fixable': False, + 'is_persistent': False, + 'issue_domain': None, + 'issue_id': 'docker_host_network', + 'learn_more_url': 'https://www.home-assistant.io/installation/linux#install-home-assistant-container', + 'severity': , + 'translation_key': 'docker_host_network', + 'translation_placeholders': dict({ + 'docs_url': 'https://docs.docker.com/network/network-tutorial-host/', + 'install_url': 'https://www.home-assistant.io/installation/linux#install-home-assistant-container', + }), + }) +# --- diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index a2352e6af9e..372dba1772d 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -1,10 +1,13 @@ """Test the Network Configuration.""" +from __future__ import annotations + from ipaddress import IPv4Address from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import network from homeassistant.components.network.const import ( @@ -17,6 +20,7 @@ from homeassistant.components.network.const import ( ) 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 . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR @@ -801,3 +805,48 @@ async def test_websocket_network_url( "external": None, "cloud": None, } + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_not_docker( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test repair is not created when not in Docker.""" + with patch("homeassistant.util.package.is_docker_env", return_value=False): + assert await async_setup_component(hass, "network", {}) + + assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_with_host_networking( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test repair is not created when in Docker with host networking.""" + with ( + patch("homeassistant.util.package.is_docker_env", return_value=True), + patch("homeassistant.components.network.Path.exists", return_value=True), + ): + assert await async_setup_component(hass, "network", {}) + + assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_without_host_networking( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test repair is created when in Docker without host networking.""" + with ( + patch("homeassistant.util.package.is_docker_env", return_value=True), + patch("homeassistant.components.network.Path.exists", return_value=False), + ): + assert await async_setup_component(hass, "network", {}) + + assert (issue := issue_registry.async_get_issue(DOMAIN, "docker_host_network")) + assert issue == snapshot diff --git a/tests/components/nexia/fixtures/sensors_xl1050_house.json b/tests/components/nexia/fixtures/sensors_xl1050_house.json new file mode 100644 index 00000000000..4293b92c6cf --- /dev/null +++ b/tests/components/nexia/fixtures/sensors_xl1050_house.json @@ -0,0 +1,1096 @@ +{ + "success": true, + "error": null, + "result": { + "id": 123456, + "name": "My Home", + "third_party_integrations": [], + "latitude": null, + "longitude": null, + "time_zone": "America/New_York", + "dealer_opt_in": true, + "room_iq_enabled": true, + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/houses/123456" + }, + "edit": [ + { + "href": "https://www.mynexia.com/mobile/houses/123456/edit", + "method": "GET" + } + ], + "child": [ + { + "href": "https://www.mynexia.com/mobile/houses/123456/devices", + "type": "application/vnd.nexia.collection+json", + "data": { + "items": [ + { + "id": 5378307, + "name": "Center", + "name_editable": true, + "features": [ + { + "name": "advanced_info", + "items": [ + { + "type": "label_value", + "label": "Model", + "value": "XL1050" + }, + { + "type": "label_value", + "label": "AUID", + "value": "0295CB84" + }, + { + "type": "label_value", + "label": "Firmware Build Number", + "value": "1726826973" + }, + { + "type": "label_value", + "label": "Firmware Build Date", + "value": "2024-09-20 10:09:33 UTC" + }, + { + "type": "label_value", + "label": "Firmware Version", + "value": "5.11.1" + } + ] + }, + { + "name": "thermostat", + "scale": "f", + "temperature": 69, + "device_identifier": "XxlZone-85034552", + "status": "System Idle", + "status_icon": null, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/setpoints" + } + }, + "setpoint_delta": 3, + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 69, + "system_status": "System Idle", + "operating_state": "idle" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "dealer_contact_info", + "has_dealer_identifier": true, + "actions": { + "request_current_dealer_info": { + "method": "GET", + "href": "https://www.mynexia.com/mobile/dealers/7043919191" + }, + "request_dealers_by_zip": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/dealers/5378307/search" + } + } + }, + { + "name": "thermostat_mode", + "label": "System Mode", + "value": "HEAT", + "display_value": "Heating", + "options": [ + { + "id": "thermostat_mode", + "label": "System Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "run_schedule", + "display_value": "Run Schedule", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/run_mode" + } + } + }, + { + "name": "room_iq_sensors", + "sensors": [ + { + "id": 17687546, + "name": "Center", + "icon": { + "name": "room_iq_onboard", + "modifiers": [] + }, + "type": "thermostat", + "serial_number": "NativeIDTUniqueID", + "weight": 0.5, + "temperature": 68, + "temperature_valid": true, + "humidity": 32, + "humidity_valid": true, + "has_online": false, + "has_battery": false + }, + { + "id": 17687549, + "name": "Upstairs", + "icon": { + "name": "room_iq_wireless", + "modifiers": [] + }, + "type": "930", + "serial_number": "2410R5C53X", + "weight": 0.5, + "temperature": 69, + "temperature_valid": true, + "humidity": 32, + "humidity_valid": true, + "has_online": true, + "connected": true, + "has_battery": true, + "battery_level": 95, + "battery_low": false, + "battery_valid": true + } + ], + "should_show": true, + "actions": { + "request_current_state": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/request_current_sensor_state" + }, + "update_active_sensors": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/update_active_sensors" + } + } + }, + { + "name": "preset_setpoints", + "presets": { + "1": { + "cool": 78, + "heat": 70 + }, + "2": { + "cool": 85, + "heat": 62 + }, + "3": { + "cool": 82, + "heat": 62 + } + } + }, + { + "name": "thermostat_fan_mode", + "label": "Fan Mode", + "options": [ + { + "id": "thermostat_fan_mode", + "label": "Fan Mode", + "value": "thermostat_fan_mode", + "header": true + }, + { + "value": "auto", + "label": "Auto" + }, + { + "value": "on", + "label": "On" + }, + { + "value": "circulate", + "label": "Circulate" + } + ], + "value": "circulate", + "display_value": "Circulate", + "status_icon": { + "name": "thermostat_fan_off", + "modifiers": [] + }, + "actions": { + "update_thermostat_fan_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/fan_mode" + } + } + }, + { + "name": "thermostat_default_fan_mode", + "value": "circulate", + "actions": { + "update_thermostat_default_fan_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/fan_mode" + } + } + }, + { + "name": "gen_2_app", + "is_supported": false, + "validation_failures": [ + "Thermostat has wireless sensors.", + "Unauthorized to use Gen 2 App." + ] + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1.0, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=TraneXl1050-5378307\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=TraneXl1050-5378307", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=TraneXl1050-5378307", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=TraneXl1050-5378307", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + }, + { + "name": "runtime_history", + "actions": { + "get_runtime_history": { + "method": "GET", + "href": "https://www.mynexia.com/mobile/runtime_history/TraneXl1050-5378307?report_type=daily" + }, + "get_monthly_runtime_history": { + "method": "GET", + "href": "https://www.mynexia.com/mobile/runtime_history/TraneXl1050-5378307?report_type=monthly" + } + } + } + ], + "icon": [ + { + "name": "thermostat", + "modifiers": ["temperature-69"] + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307" + }, + "nexia:history": { + "href": "https://www.mynexia.com/mobile/houses/123456/events?device_id=5378307" + }, + "filter_events": { + "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=e16684f6-b1e3-4e25-b006-e4d599dab2e9" + } + }, + "last_updated_at": "2025-01-06T17:45:09.000-05:00", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/preset_selected" + } + } + }, + { + "type": "system_mode", + "title": "System Mode", + "current_value": "HEAT", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "run_schedule", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/scheduling_enabled" + } + } + }, + { + "type": "fan_mode", + "title": "Fan Mode", + "current_value": "circulate", + "options": [ + { + "value": "auto", + "label": "Auto" + }, + { + "value": "on", + "label": "On" + }, + { + "value": "circulate", + "label": "Circulate" + } + ], + "labels": ["Auto", "On", "Circulate"], + "values": ["auto", "on", "circulate"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/fan_mode" + } + } + }, + { + "type": "fan_circulation_time", + "title": "Fan Circulation Time", + "current_value": 10, + "options": [ + { + "value": 10, + "label": "10 minutes" + }, + { + "value": 15, + "label": "15 minutes" + }, + { + "value": 20, + "label": "20 minutes" + }, + { + "value": 25, + "label": "25 minutes" + }, + { + "value": 30, + "label": "30 minutes" + }, + { + "value": 35, + "label": "35 minutes" + }, + { + "value": 40, + "label": "40 minutes" + }, + { + "value": 45, + "label": "45 minutes" + }, + { + "value": 50, + "label": "50 minutes" + }, + { + "value": 55, + "label": "55 minutes" + } + ], + "labels": [ + "10 minutes", + "15 minutes", + "20 minutes", + "25 minutes", + "30 minutes", + "35 minutes", + "40 minutes", + "45 minutes", + "50 minutes", + "55 minutes" + ], + "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/fan_circulation_time" + } + } + }, + { + "type": "air_cleaner_mode", + "title": "Air Cleaner Mode", + "current_value": "auto", + "options": [ + { + "value": "auto", + "label": "Auto" + }, + { + "value": "quick", + "label": "Quick" + }, + { + "value": "allergy", + "label": "Allergy" + } + ], + "labels": ["Auto", "Quick", "Allergy"], + "values": ["auto", "quick", "allergy"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/air_cleaner_mode" + } + } + }, + { + "type": "dehumidify", + "title": "Cooling Dehumidify Set Point", + "current_value": 0.5, + "options": [ + { + "value": 0.35, + "label": "35%" + }, + { + "value": 0.4, + "label": "40%" + }, + { + "value": 0.45, + "label": "45%" + }, + { + "value": 0.5, + "label": "50%" + }, + { + "value": 0.55, + "label": "55%" + }, + { + "value": 0.6, + "label": "60%" + }, + { + "value": 0.65, + "label": "65%" + } + ], + "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], + "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/dehumidify" + } + } + }, + { + "type": "emergency_heat", + "title": "Emergency Heat", + "current_value": false, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/emergency_heat" + } + } + }, + { + "type": "scale", + "title": "Temperature Scale", + "current_value": "f", + "options": [ + { + "value": "f", + "label": "F" + }, + { + "value": "c", + "label": "C" + } + ], + "labels": ["F", "C"], + "values": ["f", "c"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/5378307/scale" + } + } + } + ], + "status_secondary": null, + "status_tertiary": null, + "type": "xxl_thermostat", + "has_outdoor_temperature": true, + "outdoor_temperature": "39", + "has_indoor_humidity": true, + "connected": true, + "indoor_humidity": "33", + "system_status": "System Idle", + "delta": 3, + "manufacturer": "AmericanStandard", + "country_code": "US", + "state_code": "NC", + "zones": [ + { + "type": "xxl_zone", + "id": 85034552, + "name": "NativeZone", + "current_zone_mode": "HEAT", + "temperature": 69, + "setpoints": { + "heat": 69, + "cool": null + }, + "operating_state": "", + "heating_setpoint": 69, + "cooling_setpoint": null, + "zone_status": "", + "settings": [], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-69"] + }, + "features": [ + { + "name": "thermostat", + "scale": "f", + "temperature": 69, + "device_identifier": "XxlZone-85034552", + "status": "", + "status_icon": null, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/setpoints" + } + }, + "setpoint_delta": 3, + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 69, + "system_status": "System Idle" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "dealer_contact_info", + "has_dealer_identifier": true, + "actions": { + "request_current_dealer_info": { + "method": "GET", + "href": "https://www.mynexia.com/mobile/dealers/7043919191" + }, + "request_dealers_by_zip": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/dealers/5378307/search" + } + } + }, + { + "name": "thermostat_mode", + "label": "System Mode", + "value": "HEAT", + "display_value": "Heating", + "options": [ + { + "id": "thermostat_mode", + "label": "System Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "run_schedule", + "display_value": "Run Schedule", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/run_mode" + } + } + }, + { + "name": "room_iq_sensors", + "sensors": [ + { + "id": 17687546, + "name": "Center", + "icon": { + "name": "room_iq_onboard", + "modifiers": [] + }, + "type": "thermostat", + "serial_number": "NativeIDTUniqueID", + "weight": 0.5, + "temperature": 68, + "temperature_valid": true, + "humidity": 32, + "humidity_valid": true, + "has_online": false, + "has_battery": false + }, + { + "id": 17687549, + "name": "Upstairs", + "icon": { + "name": "room_iq_wireless", + "modifiers": [] + }, + "type": "930", + "serial_number": "2410R5C53X", + "weight": 0.5, + "temperature": 69, + "temperature_valid": true, + "humidity": 32, + "humidity_valid": true, + "has_online": true, + "connected": true, + "has_battery": true, + "battery_level": 95, + "battery_low": false, + "battery_valid": true + } + ], + "should_show": true, + "actions": { + "request_current_state": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/request_current_sensor_state" + }, + "update_active_sensors": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/update_active_sensors" + } + } + }, + { + "name": "preset_setpoints", + "presets": { + "1": { + "cool": 78, + "heat": 70 + }, + "2": { + "cool": 85, + "heat": 62 + }, + "3": { + "cool": 82, + "heat": 62 + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1.0, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-85034552\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-85034552", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-85034552", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-85034552", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/85034552" + } + } + } + ], + "generic_input_sensors": [] + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/houses/123456/devices" + }, + "template": { + "data": { + "title": null, + "fields": [], + "_links": { + "child-schema": [ + { + "data": { + "label": "Connect New Device", + "icon": { + "name": "new_device", + "modifiers": [] + }, + "_links": { + "next": { + "href": "https://www.mynexia.com/mobile/houses/123456/enrollables_schema" + } + } + } + }, + { + "data": { + "label": "Create Group", + "icon": { + "name": "create_group", + "modifiers": [] + }, + "_links": { + "next": { + "href": "https://www.mynexia.com/mobile/houses/123456/groups/new" + } + } + } + } + ] + } + } + } + }, + "item_type": "application/vnd.nexia.device+json" + } + }, + { + "href": "https://www.mynexia.com/mobile/houses/123456/automations", + "type": "application/vnd.nexia.collection+json", + "data": { + "items": [ + { + "id": 4995413, + "name": "My First Automation", + "enabled": false, + "settings": [], + "triggers": [], + "description": "Click the Edit button to set up automation for your devices.", + "icon": [], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/automations/4995413" + }, + "edit": { + "href": "https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=4995413", + "method": "POST" + }, + "nexia:history": { + "href": "https://www.mynexia.com/mobile/houses/123456/events?automation_id=4995413" + }, + "filter_events": { + "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=d827e212-3055-4835-8bda-333d26f05c9d" + } + } + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/houses/123456/automations" + }, + "template": { + "href": "https://www.mynexia.com/mobile/houses/123456/automation_edit_buffers", + "method": "POST" + } + }, + "item_type": "application/vnd.nexia.automation+json" + } + }, + { + "href": "https://www.mynexia.com/mobile/houses/123456/modes", + "type": "application/vnd.nexia.collection+json", + "data": { + "items": [ + { + "id": 6631129, + "name": "Day", + "current_mode": false, + "icon": "home.png", + "settings": [], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/modes/6631129" + } + } + }, + { + "id": 6631132, + "name": "Night", + "current_mode": true, + "icon": "home.png", + "settings": [], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/modes/6631132" + } + } + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/houses/123456/modes" + } + }, + "item_type": "application/vnd.nexia.mode+json" + } + }, + { + "href": "https://www.mynexia.com/mobile/houses/123456/events/collection", + "type": "application/vnd.nexia.collection+json", + "data": { + "item_type": "application/vnd.nexia.event+json" + } + }, + { + "href": "https://www.mynexia.com/mobile/houses/123456/videos/collection", + "type": "application/vnd.nexia.collection+json", + "data": { + "item_type": "application/vnd.nexia.video+json" + } + }, + { + "href": "https://www.mynexia.com/mobile/choices", + "type": "application/vnd.nexia.collection+json", + "data": { + "items": [], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/choices" + } + }, + "item_type": "application/vnd.nexia.choice+json" + } + } + ], + "feature_code_url": [ + { + "href": "https://www.mynexia.com/mobile/houses/123456/feature_code", + "method": "POST" + } + ], + "remove_zwave_device": [ + { + "href": "https://www.mynexia.com/mobile/houses/123456/remove_zwave_device", + "cancel_href": "https://www.mynexia.com/mobile/houses/123456/cancel_remove_zwave_device", + "method": "POST", + "timeout": 240, + "display": true + } + ] + } + } +} diff --git a/tests/components/nexia/test_diagnostics.py b/tests/components/nexia/test_diagnostics.py index ff9696d1567..fc3a8d5ee98 100644 --- a/tests/components/nexia/test_diagnostics.py +++ b/tests/components/nexia/test_diagnostics.py @@ -1,6 +1,6 @@ """Test august diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/nexia/test_switch.py b/tests/components/nexia/test_switch.py index 821d939bac5..e532201f01e 100644 --- a/tests/components/nexia/test_switch.py +++ b/tests/components/nexia/test_switch.py @@ -1,12 +1,74 @@ """The switch tests for the nexia platform.""" -from homeassistant.const import STATE_ON +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_STOP, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant from .util import async_init_integration +from tests.common import async_fire_time_changed + async def test_hold_switch(hass: HomeAssistant) -> None: """Test creation of the hold switch.""" await async_init_integration(hass) assert hass.states.get("switch.nick_office_hold").state == STATE_ON + + +async def test_nexia_sensor_switch( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test NexiaRoomIQSensorSwitch.""" + await async_init_integration(hass, house_fixture="nexia/sensors_xl1050_house.json") + sw1_id = f"{Platform.SWITCH}.center_nativezone_include_center" + sw1 = {ATTR_ENTITY_ID: sw1_id} + sw2_id = f"{Platform.SWITCH}.center_nativezone_include_upstairs" + sw2 = {ATTR_ENTITY_ID: sw2_id} + + # Switch starts out on. + assert (entity_state := hass.states.get(sw1_id)) is not None + assert entity_state.state == STATE_ON + + # Turn switch off. + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_OFF, sw1, blocking=True) + assert hass.states.get(sw1_id).state == STATE_OFF + + # Turn switch back on. + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_ON, sw1, blocking=True) + assert hass.states.get(sw1_id).state == STATE_ON + + # The other switch also starts out on. + assert (entity_state := hass.states.get(sw2_id)) is not None + assert entity_state.state == STATE_ON + + # Turn both switches off, an invalid combination. + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_OFF, sw1, blocking=True) + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_OFF, sw2, blocking=True) + assert hass.states.get(sw1_id).state == STATE_OFF + assert hass.states.get(sw2_id).state == STATE_OFF + + # Wait for switches to revert to device status. + freezer.tick(6) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(sw1_id).state == STATE_ON + assert hass.states.get(sw2_id).state == STATE_ON + + # Turn switch off. + await hass.services.async_call(SWITCH_DOMAIN, SERVICE_TURN_OFF, sw2, blocking=True) + assert hass.states.get(sw2_id).state == STATE_OFF + + # Exercise shutdown path. + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert hass.states.get(sw2_id).state == STATE_ON diff --git a/tests/components/nexia/util.py b/tests/components/nexia/util.py index 1104ffad63d..d9f0f59b719 100644 --- a/tests/components/nexia/util.py +++ b/tests/components/nexia/util.py @@ -17,10 +17,11 @@ async def async_init_integration( hass: HomeAssistant, skip_setup: bool = False, exception: Exception | None = None, + *, + house_fixture="nexia/mobile_houses_123456.json", ) -> MockConfigEntry: """Set up the nexia integration in Home Assistant.""" - house_fixture = "nexia/mobile_houses_123456.json" session_fixture = "nexia/session_123456.json" sign_in_fixture = "nexia/sign_in.json" set_fan_speed_fixture = "nexia/set_fan_speed_2293892.json" diff --git a/tests/components/nextcloud/snapshots/test_sensor.ambr b/tests/components/nextcloud/snapshots/test_sensor.ambr index 84c1d33f886..e6154841a28 100644 --- a/tests/components/nextcloud/snapshots/test_sensor.ambr +++ b/tests/components/nextcloud/snapshots/test_sensor.ambr @@ -1424,7 +1424,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Cache ttl', + 'original_name': 'Cache TTL', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1436,7 +1436,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cache_ttl-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local Cache ttl', + 'friendly_name': 'my.nc_url.local Cache TTL', }), 'context': , 'entity_id': 'sensor.my_nc_url_local_cache_ttl', @@ -1474,7 +1474,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CPU Load last 15 minutes', + 'original_name': 'CPU load last 15 minutes', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1486,7 +1486,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cpu_load_last_15_minutes-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local CPU Load last 15 minutes', + 'friendly_name': 'my.nc_url.local CPU load last 15 minutes', 'unit_of_measurement': 'load', }), 'context': , @@ -1525,7 +1525,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CPU Load last 1 minute', + 'original_name': 'CPU load last 1 minute', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1537,7 +1537,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cpu_load_last_1_minute-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local CPU Load last 1 minute', + 'friendly_name': 'my.nc_url.local CPU load last 1 minute', 'unit_of_measurement': 'load', }), 'context': , @@ -1576,7 +1576,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CPU Load last 5 minutes', + 'original_name': 'CPU load last 5 minutes', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1588,7 +1588,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cpu_load_last_5_minutes-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local CPU Load last 5 minutes', + 'friendly_name': 'my.nc_url.local CPU load last 5 minutes', 'unit_of_measurement': 'load', }), 'context': , diff --git a/tests/components/nextdns/test_binary_sensor.py b/tests/components/nextdns/test_binary_sensor.py index 19cad755fb4..99e40af0dce 100644 --- a/tests/components/nextdns/test_binary_sensor.py +++ b/tests/components/nextdns/test_binary_sensor.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import patch from nextdns import ApiError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nextdns/test_button.py b/tests/components/nextdns/test_button.py index 51970b9bb48..0cb4a7cd0df 100644 --- a/tests/components/nextdns/test_button.py +++ b/tests/components/nextdns/test_button.py @@ -1,12 +1,19 @@ """Test button of NextDNS integration.""" -from unittest.mock import patch +from unittest.mock import Mock, patch -from syrupy import SnapshotAssertion +from aiohttp import ClientError +from aiohttp.client_exceptions import ClientConnectorError +from nextdns import ApiError, InvalidApiKeyError +import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.nextdns.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 entity_registry as er from homeassistant.util import dt as dt_util @@ -36,7 +43,7 @@ async def test_button_press(hass: HomeAssistant) -> None: ): await hass.services.async_call( BUTTON_DOMAIN, - "press", + SERVICE_PRESS, {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, blocking=True, ) @@ -47,3 +54,60 @@ async def test_button_press(hass: HomeAssistant) -> None: state = hass.states.get("button.fake_profile_clear_logs") assert state assert state.state == now.isoformat() + + +@pytest.mark.parametrize( + "exc", + [ + ApiError(Mock()), + TimeoutError, + ClientConnectorError(Mock(), Mock()), + ClientError, + ], +) +async def test_button_failure(hass: HomeAssistant, exc: Exception) -> None: + """Tests that the press action throws HomeAssistantError.""" + await init_integration(hass) + + with ( + patch("homeassistant.components.nextdns.NextDns.clear_logs", side_effect=exc), + pytest.raises( + HomeAssistantError, + match="An error occurred while calling the NextDNS API method for button.fake_profile_clear_logs", + ), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, + blocking=True, + ) + + +async def test_button_auth_error(hass: HomeAssistant) -> None: + """Tests that the press action starts re-auth flow.""" + entry = await init_integration(hass) + + with patch( + "homeassistant.components.nextdns.NextDns.clear_logs", + side_effect=InvalidApiKeyError, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + 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") == entry.entry_id diff --git a/tests/components/nextdns/test_diagnostics.py b/tests/components/nextdns/test_diagnostics.py index 3bb1fc3ee67..4a5e09908ec 100644 --- a/tests/components/nextdns/test_diagnostics.py +++ b/tests/components/nextdns/test_diagnostics.py @@ -1,6 +1,6 @@ """Test NextDNS diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/nextdns/test_sensor.py b/tests/components/nextdns/test_sensor.py index eddf5a1cc5a..43e823fbf38 100644 --- a/tests/components/nextdns/test_sensor.py +++ b/tests/components/nextdns/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import patch from nextdns import ApiError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index 6e344e34336..1b0edb2c83c 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -5,12 +5,14 @@ from unittest.mock import Mock, patch from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError -from nextdns import ApiError +from nextdns import ApiError, InvalidApiKeyError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tenacity import RetryError +from homeassistant.components.nextdns.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -158,3 +160,32 @@ async def test_switch_failure(hass: HomeAssistant, exc: Exception) -> None: {ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, blocking=True, ) + + +async def test_switch_auth_error(hass: HomeAssistant) -> None: + """Tests that the turn on/off action starts re-auth flow.""" + entry = await init_integration(hass) + + with patch( + "homeassistant.components.nextdns.NextDns.set_setting", + side_effect=InvalidApiKeyError, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + 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") == entry.entry_id diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index 073e142f7ff..91245503eb3 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -12,7 +12,7 @@ from nibe.coil_groups import ( ) from nibe.heatpump import Model import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_HVAC_MODE, diff --git a/tests/components/nibe_heatpump/test_coordinator.py b/tests/components/nibe_heatpump/test_coordinator.py index 2fade8e34d7..05c771ee420 100644 --- a/tests/components/nibe_heatpump/test_coordinator.py +++ b/tests/components/nibe_heatpump/test_coordinator.py @@ -7,7 +7,7 @@ from unittest.mock import patch from nibe.coil import Coil, CoilData from nibe.heatpump import Model import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py index 73fed9ee08a..dc7faf0a80e 100644 --- a/tests/components/nibe_heatpump/test_number.py +++ b/tests/components/nibe_heatpump/test_number.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from nibe.coil import CoilData from nibe.heatpump import Model import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/nice_go/test_cover.py b/tests/components/nice_go/test_cover.py index 542b1717d88..df708f64b8f 100644 --- a/tests/components/nice_go/test_cover.py +++ b/tests/components/nice_go/test_cover.py @@ -6,7 +6,7 @@ from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory from nice_go import ApiError, AuthFailedError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, diff --git a/tests/components/nice_go/test_diagnostics.py b/tests/components/nice_go/test_diagnostics.py index 5c8647f3d6e..283709aa167 100644 --- a/tests/components/nice_go/test_diagnostics.py +++ b/tests/components/nice_go/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/nice_go/test_light.py b/tests/components/nice_go/test_light.py index 2bc9de59b2b..5c43367f169 100644 --- a/tests/components/nice_go/test_light.py +++ b/tests/components/nice_go/test_light.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError from nice_go import ApiError, AuthFailedError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, diff --git a/tests/components/niko_home_control/conftest.py b/tests/components/niko_home_control/conftest.py index 130baf72228..35260b387de 100644 --- a/tests/components/niko_home_control/conftest.py +++ b/tests/components/niko_home_control/conftest.py @@ -45,7 +45,7 @@ def dimmable_light() -> NHCLight: mock.is_dimmable = True mock.name = "dimmable light" mock.suggested_area = "room" - mock.state = 100 + mock.state = 255 return mock diff --git a/tests/components/niko_home_control/test_cover.py b/tests/components/niko_home_control/test_cover.py index 5e9a17c3324..3941c60b5c8 100644 --- a/tests/components/niko_home_control/test_cover.py +++ b/tests/components/niko_home_control/test_cover.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.const import ( diff --git a/tests/components/niko_home_control/test_light.py b/tests/components/niko_home_control/test_light.py index 865e1303cb0..476ea95cda8 100644 --- a/tests/components/niko_home_control/test_light.py +++ b/tests/components/niko_home_control/test_light.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN from homeassistant.const import ( @@ -42,11 +42,11 @@ async def test_entities( @pytest.mark.parametrize( ("light_id", "data", "set_brightness"), [ - (0, {ATTR_ENTITY_ID: "light.light"}, 100), + (0, {ATTR_ENTITY_ID: "light.light"}, 255), ( 1, {ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50}, - 20, + 50, ), ], ) @@ -121,8 +121,8 @@ async def test_updating( 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) + dimmable_light.state = 204 + await find_update_callback(mock_niko_home_control_connection, 2)(204) await hass.async_block_till_done() assert hass.states.get("light.dimmable_light").state == STATE_ON diff --git a/tests/components/nmbs/__init__.py b/tests/components/nmbs/__init__.py index 91226950aba..3d284e5bb77 100644 --- a/tests/components/nmbs/__init__.py +++ b/tests/components/nmbs/__init__.py @@ -1,20 +1 @@ """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 index 69200fc4c98..a39334ba62c 100644 --- a/tests/components/nmbs/conftest.py +++ b/tests/components/nmbs/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from pyrail.models import StationsApiResponse import pytest from homeassistant.components.nmbs.const import ( @@ -38,8 +39,8 @@ def mock_nmbs_client() -> Generator[AsyncMock]: ), ): client = mock_client.return_value - client.get_stations.return_value = load_json_object_fixture( - "stations.json", DOMAIN + client.get_stations.return_value = StationsApiResponse.from_dict( + load_json_object_fixture("stations.json", DOMAIN) ) yield client diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py index ff4c5bdf72a..7e0f087607b 100644 --- a/tests/components/nmbs/test_config_flow.py +++ b/tests/components/nmbs/test_config_flow.py @@ -142,7 +142,7 @@ 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 + mock_nmbs_client.get_stations.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, @@ -203,7 +203,7 @@ 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 + mock_nmbs_client.get_stations.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, diff --git a/tests/components/no_ip/test_init.py b/tests/components/no_ip/test_init.py index e344b984e7d..4e9c5d67c74 100644 --- a/tests/components/no_ip/test_init.py +++ b/tests/components/no_ip/test_init.py @@ -22,22 +22,20 @@ USERNAME = "abc@123.com" @pytest.fixture -def setup_no_ip(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_no_ip(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Fixture that sets up NO-IP.""" aioclient_mock.get(UPDATE_URL, params={"hostname": DOMAIN}, text="good 0.0.0.0") - hass.loop.run_until_complete( - async_setup_component( - hass, - no_ip.DOMAIN, - { - no_ip.DOMAIN: { - "domain": DOMAIN, - "username": USERNAME, - "password": PASSWORD, - } - }, - ) + await async_setup_component( + hass, + no_ip.DOMAIN, + { + no_ip.DOMAIN: { + "domain": DOMAIN, + "username": USERNAME, + "password": PASSWORD, + } + }, ) diff --git a/tests/components/nordpool/fixtures/delivery_period_today.json b/tests/components/nordpool/fixtures/delivery_period_today.json index 77d51dc9433..df48c32a9a9 100644 --- a/tests/components/nordpool/fixtures/delivery_period_today.json +++ b/tests/components/nordpool/fixtures/delivery_period_today.json @@ -162,7 +162,7 @@ "deliveryEnd": "2024-11-05T19:00:00Z", "entryPerArea": { "SE3": 1011.77, - "SE4": 1804.46 + "SE4": 0.0 } }, { diff --git a/tests/components/nordpool/snapshots/test_diagnostics.ambr b/tests/components/nordpool/snapshots/test_diagnostics.ambr index 76a3dd96405..d7f7c4041cd 100644 --- a/tests/components/nordpool/snapshots/test_diagnostics.ambr +++ b/tests/components/nordpool/snapshots/test_diagnostics.ambr @@ -519,7 +519,7 @@ 'deliveryStart': '2024-11-05T18:00:00Z', 'entryPerArea': dict({ 'SE3': 1011.77, - 'SE4': 1804.46, + 'SE4': 0.0, }), }), dict({ diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr index 86aa49357c5..be2b04cc520 100644 --- a/tests/components/nordpool/snapshots/test_sensor.ambr +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -1332,7 +1332,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.80446', + 'state': '0.0', }) # --- # name: test_sensor[sensor.nord_pool_se4_daily_average-entry] @@ -1580,9 +1580,9 @@ # name: test_sensor[sensor.nord_pool_se4_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'end': '2024-11-05T03:00:00+00:00', + 'end': '2024-11-05T19:00:00+00:00', 'friendly_name': 'Nord Pool SE4 Lowest price', - 'start': '2024-11-05T02:00:00+00:00', + 'start': '2024-11-05T18:00:00+00:00', 'unit_of_measurement': 'SEK/kWh', }), 'context': , @@ -1590,7 +1590,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.06519', + 'state': '0.0', }) # --- # name: test_sensor[sensor.nord_pool_se4_next_price-entry] diff --git a/tests/components/nordpool/test_sensor.py b/tests/components/nordpool/test_sensor.py index 60be1ee3258..082684a2a02 100644 --- a/tests/components/nordpool/test_sensor.py +++ b/tests/components/nordpool/test_sensor.py @@ -33,6 +33,19 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_current_price_is_0( + hass: HomeAssistant, load_int: ConfigEntry +) -> None: + """Test the Nord Pool sensor working if price is 0.""" + + current_price = hass.states.get("sensor.nord_pool_se4_current_price") + + assert current_price is not None + assert current_price.state == "0.0" # SE4 2024-11-05T18:00:00Z + + @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: diff --git a/tests/components/ntfy/__init__.py b/tests/components/ntfy/__init__.py new file mode 100644 index 00000000000..e059dc61ae9 --- /dev/null +++ b/tests/components/ntfy/__init__.py @@ -0,0 +1 @@ +"""Tests for ntfy integration.""" diff --git a/tests/components/ntfy/conftest.py b/tests/components/ntfy/conftest.py new file mode 100644 index 00000000000..d9bc620b464 --- /dev/null +++ b/tests/components/ntfy/conftest.py @@ -0,0 +1,79 @@ +"""Common fixtures for the ntfy tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from aiontfy import Account, AccountTokenResponse +import pytest + +from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ntfy.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_aiontfy() -> Generator[AsyncMock]: + """Mock aiontfy.""" + + with ( + patch("homeassistant.components.ntfy.Ntfy", autospec=True) as mock_client, + patch("homeassistant.components.ntfy.config_flow.Ntfy", new=mock_client), + ): + client = mock_client.return_value + + client.publish.return_value = {} + client.account.return_value = Account.from_json( + load_fixture("account.json", DOMAIN) + ) + client.generate_token.return_value = AccountTokenResponse( + token="token", last_access=datetime.now() + ) + yield client + + +@pytest.fixture(autouse=True) +def mock_random() -> Generator[MagicMock]: + """Mock random.""" + + with patch( + "homeassistant.components.ntfy.config_flow.random.choices", + return_value=["randomtopic"], + ) as mock_client: + yield mock_client + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock ntfy configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: None, + CONF_TOKEN: "token", + CONF_VERIFY_SSL: True, + }, + entry_id="123456789", + subentries_data=[ + ConfigSubentryData( + data={CONF_TOPIC: "mytopic"}, + subentry_id="ABCDEF", + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + ], + ) diff --git a/tests/components/ntfy/fixtures/account.json b/tests/components/ntfy/fixtures/account.json new file mode 100644 index 00000000000..29a96beb23b --- /dev/null +++ b/tests/components/ntfy/fixtures/account.json @@ -0,0 +1,66 @@ +{ + "username": "username", + "role": "user", + "sync_topic": "st_xxxxxxxxxxxxx", + "language": "en", + "notification": { + "min_priority": 2, + "delete_after": 604800 + }, + "subscriptions": [ + { + "base_url": "http://localhost", + "topic": "test", + "display_name": null + } + ], + "reservations": [ + { + "topic": "test", + "everyone": "read-only" + } + ], + "tokens": [ + { + "token": "tk_xxxxxxxxxxxxxxxxxxxxxxxxxx", + "last_access": 1743362634, + "last_origin": "172.17.0.1", + "expires": 1743621234 + } + ], + "tier": { + "code": "starter", + "name": "starter" + }, + "limits": { + "basis": "tier", + "messages": 5000, + "messages_expiry_duration": 43200, + "emails": 20, + "calls": 0, + "reservations": 3, + "attachment_total_size": 104857600, + "attachment_file_size": 15728640, + "attachment_expiry_duration": 21600, + "attachment_bandwidth": 1073741824 + }, + "stats": { + "messages": 10, + "messages_remaining": 4990, + "emails": 0, + "emails_remaining": 20, + "calls": 0, + "calls_remaining": 0, + "reservations": 1, + "reservations_remaining": 2, + "attachment_total_size": 0, + "attachment_total_size_remaining": 104857600 + }, + "billing": { + "customer": true, + "subscription": true, + "status": "active", + "interval": "year", + "paid_until": 1754080667 + } +} diff --git a/tests/components/ntfy/snapshots/test_diagnostics.ambr b/tests/components/ntfy/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..3dd464f8670 --- /dev/null +++ b/tests/components/ntfy/snapshots/test_diagnostics.ambr @@ -0,0 +1,24 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'topics': dict({ + 'ABCDEF': dict({ + 'data': dict({ + 'topic': 'mytopic', + }), + 'subentry_id': 'ABCDEF', + 'subentry_type': 'topic', + 'title': 'mytopic', + 'unique_id': 'mytopic', + }), + }), + 'url': 'https://ntfy.sh/', + }) +# --- +# name: test_diagnostics_redacted_url + dict({ + 'topics': dict({ + }), + 'url': 'http://**redacted**/', + }) +# --- diff --git a/tests/components/ntfy/snapshots/test_notify.ambr b/tests/components/ntfy/snapshots/test_notify.ambr new file mode 100644 index 00000000000..619ae59cc2f --- /dev/null +++ b/tests/components/ntfy/snapshots/test_notify.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_notify_platform[notify.mytopic-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.mytopic', + 'has_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': 'ntfy', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'publish', + 'unique_id': '123456789_ABCDEF_publish', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.mytopic-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mytopic', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.mytopic', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py new file mode 100644 index 00000000000..2d3656536a9 --- /dev/null +++ b/tests/components/ntfy/test_config_flow.py @@ -0,0 +1,500 @@ +"""Test the ntfy config flow.""" + +from datetime import datetime +from typing import Any +from unittest.mock import AsyncMock + +from aiontfy import AccountTokenResponse +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +import pytest + +from homeassistant.components.ntfy.const import CONF_TOPIC, DOMAIN, SECTION_AUTH +from homeassistant.config_entries import SOURCE_USER, ConfigSubentry +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("user_input", "entry_data"), + [ + ( + { + CONF_URL: "https://ntfy.sh", + CONF_VERIFY_SSL: True, + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + { + CONF_URL: "https://ntfy.sh/", + CONF_VERIFY_SSL: True, + CONF_USERNAME: "username", + CONF_TOKEN: "token", + }, + ), + ( + {CONF_URL: "https://ntfy.sh", CONF_VERIFY_SSL: True, SECTION_AUTH: {}}, + { + CONF_URL: "https://ntfy.sh/", + CONF_VERIFY_SSL: True, + CONF_USERNAME: None, + CONF_TOKEN: "token", + }, + ), + ], +) +@pytest.mark.usefixtures("mock_aiontfy") +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + user_input: dict[str, Any], + entry_data: dict[str, Any], +) -> 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"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ntfy.sh" + assert result["data"] == entry_data + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + NtfyHTTPError(418001, 418, "I'm a teapot", ""), + "cannot_connect", + ), + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + "invalid_auth", + ), + (NtfyException, "cannot_connect"), + (TypeError, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_aiontfy: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_aiontfy.account.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://ntfy.sh", + CONF_VERIFY_SSL: True, + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_aiontfy.account.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://ntfy.sh", + CONF_VERIFY_SSL: True, + SECTION_AUTH: {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ntfy.sh" + assert result["data"] == { + CONF_URL: "https://ntfy.sh/", + CONF_VERIFY_SSL: True, + CONF_USERNAME: "username", + CONF_TOKEN: "token", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_form_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_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_configure( + result["flow_id"], + user_input={ + CONF_URL: "https://ntfy.sh", + CONF_VERIFY_SSL: True, + SECTION_AUTH: {}, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_add_topic_flow(hass: HomeAssistant) -> None: + """Test add topic subentry flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/", CONF_VERIFY_SSL: True, CONF_USERNAME: None}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + } + + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_generated_topic(hass: HomeAssistant, mock_random: AsyncMock) -> None: + """Test add topic subentry flow with generated topic name.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/", CONF_VERIFY_SSL: True}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "generate_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "generate_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: ""}, + ) + + mock_random.assert_called_once() + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "randomtopic", CONF_NAME: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="randomtopic", + ) + } + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_invalid_topic(hass: HomeAssistant, mock_random: AsyncMock) -> None: + """Test add topic subentry flow with invalid topic name.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "https://ntfy.sh/", CONF_VERIFY_SSL: True}, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "invalid,topic"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_topic"} + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={CONF_TOPIC: "mytopic"}, + subentry_id=subentry_id, + subentry_type="topic", + title="mytopic", + unique_id="mytopic", + ) + } + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_topic_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "topic"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + assert "add_topic" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "add_topic"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_topic" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_TOPIC: "mytopic"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "user_input", [{CONF_PASSWORD: "password"}, {CONF_TOKEN: "newtoken"}] +) +@pytest.mark.usefixtures("mock_aiontfy") +async def test_flow_reauth( + hass: HomeAssistant, + mock_aiontfy: AsyncMock, + user_input: dict[str, Any], +) -> None: + """Test reauth flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + CONF_TOKEN: "token", + }, + ) + mock_aiontfy.generate_token.return_value = AccountTokenResponse( + token="newtoken", last_access=datetime.now() + ) + 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_TOKEN] == "newtoken" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + NtfyHTTPError(418001, 418, "I'm a teapot", ""), + "cannot_connect", + ), + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + "invalid_auth", + ), + (NtfyException, "cannot_connect"), + (TypeError, "unknown"), + ], +) +async def test_form_reauth_errors( + hass: HomeAssistant, + mock_aiontfy: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test reauth flow errors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + CONF_TOKEN: "token", + }, + ) + mock_aiontfy.account.side_effect = exception + mock_aiontfy.generate_token.return_value = AccountTokenResponse( + token="newtoken", last_access=datetime.now() + ) + 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"], {CONF_PASSWORD: "password"} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_aiontfy.account.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "password"} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data == { + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + CONF_TOKEN: "newtoken", + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_flow_reauth_account_mismatch( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> 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"], + {CONF_TOKEN: "newtoken"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "account_mismatch" diff --git a/tests/components/ntfy/test_diagnostics.py b/tests/components/ntfy/test_diagnostics.py new file mode 100644 index 00000000000..a4aa3ee6aa7 --- /dev/null +++ b/tests/components/ntfy/test_diagnostics.py @@ -0,0 +1,55 @@ +"""Tests for ntfy diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ntfy.const import DOMAIN +from homeassistant.const import CONF_URL +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("mock_aiontfy") +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 + ) + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_diagnostics_redacted_url( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics redacted URL.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="mydomain", + data={ + CONF_URL: "http://mydomain/", + }, + entry_id="123456789", + subentries_data=[], + ) + 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/ntfy/test_init.py b/tests/components/ntfy/test_init.py new file mode 100644 index 00000000000..b80badd8581 --- /dev/null +++ b/tests/components/ntfy/test_init.py @@ -0,0 +1,67 @@ +"""Tests for the ntfy integration.""" + +from unittest.mock import AsyncMock + +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + 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 + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + ConfigEntryState.SETUP_ERROR, + ), + (NtfyHTTPError(418001, 418, "I'm a teapot", ""), ConfigEntryState.SETUP_RETRY), + (NtfyConnectionError, ConfigEntryState.SETUP_RETRY), + (NtfyTimeoutError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test config entry not ready.""" + + mock_aiontfy.account.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() + + assert config_entry.state is state diff --git a/tests/components/ntfy/test_notify.py b/tests/components/ntfy/test_notify.py new file mode 100644 index 00000000000..ec947ba5a1f --- /dev/null +++ b/tests/components/ntfy/test_notify.py @@ -0,0 +1,187 @@ +"""Tests for the ntfy notify platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import patch + +from aiontfy import Message +from aiontfy.exceptions import ( + NtfyException, + NtfyHTTPError, + NtfyUnauthorizedAuthenticationError, +) +from freezegun.api import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TITLE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.components.ntfy.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import AsyncMock, MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def notify_only() -> AsyncGenerator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.ntfy.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_notify_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the ntfy notify 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) + + +@freeze_time("2025-01-09T12:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, +) -> None: + """Test publishing ntfy message.""" + + 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 + + state = hass.states.get("notify.mytopic") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + state = hass.states.get("notify.mytopic") + assert state + assert state.state == "2025-01-09T12:00:00+00:00" + + mock_aiontfy.publish.assert_called_once_with( + Message(topic="mytopic", message="triggered", title="test") + ) + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + ( + NtfyHTTPError(41801, 418, "I'm a teapot", ""), + "Failed to publish notification: I'm a teapot", + ), + ( + NtfyException, + "Failed to publish notification due to a connection error", + ), + ( + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + "Failed to authenticate with ntfy service. Please verify your credentials", + ), + ], +) +async def test_send_message_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, + error_msg: str, +) -> None: + """Test publish message 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_aiontfy.publish.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error_msg): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + mock_aiontfy.publish.assert_called_once_with( + Message(topic="mytopic", message="triggered", title="test") + ) + + +async def test_send_message_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, +) -> None: + """Test unauthorized exception initiates reauth flow.""" + + 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_aiontfy.publish.side_effect = ( + NtfyUnauthorizedAuthenticationError(40101, 401, "unauthorized"), + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.mytopic", + ATTR_MESSAGE: "triggered", + ATTR_TITLE: "test", + }, + blocking=True, + ) + + 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 diff --git a/tests/components/nuki/test_binary_sensor.py b/tests/components/nuki/test_binary_sensor.py index 54fbc93c144..11507100aae 100644 --- a/tests/components/nuki/test_binary_sensor.py +++ b/tests/components/nuki/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nuki/test_lock.py b/tests/components/nuki/test_lock.py index 824d508f3dc..fc2d9d1cba8 100644 --- a/tests/components/nuki/test_lock.py +++ b/tests/components/nuki/test_lock.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nuki/test_sensor.py b/tests/components/nuki/test_sensor.py index dde803d573f..69a0aec56f7 100644 --- a/tests/components/nuki/test_sensor.py +++ b/tests/components/nuki/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/nut/fixtures/EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json b/tests/components/nut/fixtures/EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json new file mode 100644 index 00000000000..96394e618c9 --- /dev/null +++ b/tests/components/nut/fixtures/EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json @@ -0,0 +1,539 @@ +{ + "ambient.contacts.1.status": "opened", + "ambient.contacts.2.status": "opened", + "ambient.count": "0", + "ambient.humidity": "29.90", + "ambient.humidity.high": "90", + "ambient.humidity.high.critical": "90", + "ambient.humidity.high.warning": "65", + "ambient.humidity.low": "10", + "ambient.humidity.low.critical": "10", + "ambient.humidity.low.warning": "20", + "ambient.humidity.status": "good", + "ambient.present": "no", + "ambient.temperature": "28.9", + "ambient.temperature.high": "43.30", + "ambient.temperature.high.critical": "43.30", + "ambient.temperature.high.warning": "37.70", + "ambient.temperature.low": "5", + "ambient.temperature.low.critical": "5", + "ambient.temperature.low.warning": "10", + "ambient.temperature.status": "good", + "device.contact": "Contact Name", + "device.count": "1", + "device.description": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.location": "Device Location", + "device.macaddr": "00 00 00 FF FF FF ", + "device.mfr": "EATON", + "device.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.part": "EMA000-00", + "device.serial": "A000A00000", + "device.type": "pdu", + "driver.debug": "0", + "driver.flag.allow_killpower": "0", + "driver.name": "snmp-ups", + "driver.parameter.pollinterval": "2", + "driver.parameter.port": "eaton-pdu", + "driver.parameter.synchronous": "auto", + "driver.state": "dumping", + "driver.version": "2.8.2.882-882-g63d90ebcb", + "driver.version.data": "eaton_epdu MIB 0.69", + "driver.version.internal": "1.31", + "input.current": "4.30", + "input.current.high.critical": "16", + "input.current.high.warning": "12.80", + "input.current.low.warning": "0", + "input.current.nominal": "16", + "input.current.status": "good", + "input.feed.color": "0", + "input.feed.desc": "Feed A", + "input.frequency": "60", + "input.frequency.status": "good", + "input.L1.current": "4.30", + "input.L1.current.high.critical": "16", + "input.L1.current.high.warning": "12.80", + "input.L1.current.low.warning": "0", + "input.L1.current.nominal": "16", + "input.L1.current.status": "good", + "input.L1.load": "26", + "input.L1.power": "529", + "input.L1.realpower": "482", + "input.L1.voltage": "122.91", + "input.L1.voltage.high.critical": "140", + "input.L1.voltage.high.warning": "130", + "input.L1.voltage.low.critical": "90", + "input.L1.voltage.low.warning": "95", + "input.L1.voltage.status": "good", + "input.load": "26", + "input.phases": "1", + "input.power": "532", + "input.realpower": "482", + "input.realpower.nominal": "1920", + "input.voltage": "122.91", + "input.voltage.high.critical": "140", + "input.voltage.high.warning": "130", + "input.voltage.low.critical": "90", + "input.voltage.low.warning": "95", + "input.voltage.status": "good", + "outlet.1.current": "0", + "outlet.1.current.high.critical": "16", + "outlet.1.current.high.warning": "12.80", + "outlet.1.current.low.warning": "0", + "outlet.1.current.status": "good", + "outlet.1.delay.shutdown": "120", + "outlet.1.delay.start": "1", + "outlet.1.desc": "Outlet A1", + "outlet.1.groupid": "1", + "outlet.1.id": "1", + "outlet.1.name": "A1", + "outlet.1.power": "0", + "outlet.1.realpower": "0", + "outlet.1.status": "on", + "outlet.1.switchable": "yes", + "outlet.1.timer.shutdown": "-1", + "outlet.1.timer.start": "-1", + "outlet.1.type": "nema520", + "outlet.10.current": "0.26", + "outlet.10.current.high.critical": "16", + "outlet.10.current.high.warning": "12.80", + "outlet.10.current.low.warning": "0", + "outlet.10.current.status": "good", + "outlet.10.delay.shutdown": "120", + "outlet.10.delay.start": "10", + "outlet.10.desc": "Outlet A10", + "outlet.10.groupid": "1", + "outlet.10.id": "10", + "outlet.10.name": "A10", + "outlet.10.power": "32", + "outlet.10.realpower": "15", + "outlet.10.status": "on", + "outlet.10.switchable": "yes", + "outlet.10.timer.shutdown": "-1", + "outlet.10.timer.start": "-1", + "outlet.10.type": "nema520", + "outlet.11.current": "0.24", + "outlet.11.current.high.critical": "16", + "outlet.11.current.high.warning": "12.80", + "outlet.11.current.low.warning": "0", + "outlet.11.current.status": "good", + "outlet.11.delay.shutdown": "120", + "outlet.11.delay.start": "11", + "outlet.11.desc": "Outlet A11", + "outlet.11.groupid": "1", + "outlet.11.id": "11", + "outlet.11.name": "A11", + "outlet.11.power": "29", + "outlet.11.realpower": "22", + "outlet.11.status": "on", + "outlet.11.switchable": "yes", + "outlet.11.timer.shutdown": "-1", + "outlet.11.timer.start": "-1", + "outlet.11.type": "nema520", + "outlet.12.current": "0", + "outlet.12.current.high.critical": "16", + "outlet.12.current.high.warning": "12.80", + "outlet.12.current.low.warning": "0", + "outlet.12.current.status": "good", + "outlet.12.delay.shutdown": "120", + "outlet.12.delay.start": "12", + "outlet.12.desc": "Outlet A12", + "outlet.12.groupid": "1", + "outlet.12.id": "12", + "outlet.12.name": "A12", + "outlet.12.power": "0", + "outlet.12.realpower": "0", + "outlet.12.status": "on", + "outlet.12.switchable": "yes", + "outlet.12.timer.shutdown": "-1", + "outlet.12.timer.start": "-1", + "outlet.12.type": "nema520", + "outlet.13.current": "0.23", + "outlet.13.current.high.critical": "16", + "outlet.13.current.high.warning": "12.80", + "outlet.13.current.low.warning": "0", + "outlet.13.current.status": "good", + "outlet.13.delay.shutdown": "0", + "outlet.13.delay.start": "0", + "outlet.13.desc": "Outlet A13", + "outlet.13.groupid": "1", + "outlet.13.id": "0", + "outlet.13.name": "A13", + "outlet.13.power": "27", + "outlet.13.realpower": "9", + "outlet.13.status": "on", + "outlet.13.switchable": "yes", + "outlet.13.timer.shutdown": "-1", + "outlet.13.timer.start": "-1", + "outlet.13.type": "nema520", + "outlet.14.current": "0.10", + "outlet.14.current.high.critical": "16", + "outlet.14.current.high.warning": "12.80", + "outlet.14.current.low.warning": "0", + "outlet.14.current.status": "good", + "outlet.14.delay.shutdown": "120", + "outlet.14.delay.start": "14", + "outlet.14.desc": "Outlet A14", + "outlet.14.groupid": "1", + "outlet.14.id": "14", + "outlet.14.name": "A14", + "outlet.14.power": "12", + "outlet.14.realpower": "7", + "outlet.14.status": "on", + "outlet.14.switchable": "yes", + "outlet.14.timer.shutdown": "-1", + "outlet.14.timer.start": "-1", + "outlet.14.type": "nema520", + "outlet.15.current": "0.03", + "outlet.15.current.high.critical": "16", + "outlet.15.current.high.warning": "12.80", + "outlet.15.current.low.warning": "0", + "outlet.15.current.status": "good", + "outlet.15.delay.shutdown": "120", + "outlet.15.delay.start": "15", + "outlet.15.desc": "Outlet A15", + "outlet.15.groupid": "1", + "outlet.15.id": "15", + "outlet.15.name": "A15", + "outlet.15.power": "3", + "outlet.15.realpower": "1", + "outlet.15.status": "on", + "outlet.15.switchable": "yes", + "outlet.15.timer.shutdown": "-1", + "outlet.15.timer.start": "-1", + "outlet.15.type": "nema520", + "outlet.16.current": "0.04", + "outlet.16.current.high.critical": "16", + "outlet.16.current.high.warning": "12.80", + "outlet.16.current.low.warning": "0", + "outlet.16.current.status": "good", + "outlet.16.delay.shutdown": "120", + "outlet.16.delay.start": "16", + "outlet.16.desc": "Outlet A16", + "outlet.16.groupid": "1", + "outlet.16.id": "16", + "outlet.16.name": "A16", + "outlet.16.power": "4", + "outlet.16.realpower": "1", + "outlet.16.status": "on", + "outlet.16.switchable": "yes", + "outlet.16.timer.shutdown": "-1", + "outlet.16.timer.start": "-1", + "outlet.16.type": "nema520", + "outlet.17.current": "0.19", + "outlet.17.current.high.critical": "16", + "outlet.17.current.high.warning": "12.80", + "outlet.17.current.low.warning": "0", + "outlet.17.current.status": "good", + "outlet.17.delay.shutdown": "0", + "outlet.17.delay.start": "0", + "outlet.17.desc": "Outlet A17", + "outlet.17.groupid": "1", + "outlet.17.id": "0", + "outlet.17.name": "A17", + "outlet.17.power": "23", + "outlet.17.realpower": "5", + "outlet.17.status": "on", + "outlet.17.switchable": "yes", + "outlet.17.timer.shutdown": "-1", + "outlet.17.timer.start": "-1", + "outlet.17.type": "nema520", + "outlet.18.current": "0.35", + "outlet.18.current.high.critical": "16", + "outlet.18.current.high.warning": "12.80", + "outlet.18.current.low.warning": "0", + "outlet.18.current.status": "good", + "outlet.18.delay.shutdown": "0", + "outlet.18.delay.start": "0", + "outlet.18.desc": "Outlet A18", + "outlet.18.groupid": "1", + "outlet.18.id": "0", + "outlet.18.name": "A18", + "outlet.18.power": "42", + "outlet.18.realpower": "34", + "outlet.18.status": "on", + "outlet.18.switchable": "yes", + "outlet.18.timer.shutdown": "-1", + "outlet.18.timer.start": "-1", + "outlet.18.type": "nema520", + "outlet.19.current": "0.12", + "outlet.19.current.high.critical": "16", + "outlet.19.current.high.warning": "12.80", + "outlet.19.current.low.warning": "0", + "outlet.19.current.status": "good", + "outlet.19.delay.shutdown": "0", + "outlet.19.delay.start": "0", + "outlet.19.desc": "Outlet A19", + "outlet.19.groupid": "1", + "outlet.19.id": "0", + "outlet.19.name": "A19", + "outlet.19.power": "15", + "outlet.19.realpower": "6", + "outlet.19.status": "on", + "outlet.19.switchable": "yes", + "outlet.19.timer.shutdown": "-1", + "outlet.19.timer.start": "-1", + "outlet.19.type": "nema520", + "outlet.2.current": "0.39", + "outlet.2.current.high.critical": "16", + "outlet.2.current.high.warning": "12.80", + "outlet.2.current.low.warning": "0", + "outlet.2.current.status": "good", + "outlet.2.delay.shutdown": "120", + "outlet.2.delay.start": "2", + "outlet.2.desc": "Outlet A2", + "outlet.2.groupid": "1", + "outlet.2.id": "2", + "outlet.2.name": "A2", + "outlet.2.power": "47", + "outlet.2.realpower": "43", + "outlet.2.status": "on", + "outlet.2.switchable": "yes", + "outlet.2.timer.shutdown": "-1", + "outlet.2.timer.start": "-1", + "outlet.2.type": "nema520", + "outlet.20.current": "0", + "outlet.20.current.high.critical": "16", + "outlet.20.current.high.warning": "12.80", + "outlet.20.current.low.warning": "0", + "outlet.20.current.status": "good", + "outlet.20.delay.shutdown": "120", + "outlet.20.delay.start": "20", + "outlet.20.desc": "Outlet A20", + "outlet.20.groupid": "1", + "outlet.20.id": "20", + "outlet.20.name": "A20", + "outlet.20.power": "0", + "outlet.20.realpower": "0", + "outlet.20.status": "on", + "outlet.20.switchable": "yes", + "outlet.20.timer.shutdown": "-1", + "outlet.20.timer.start": "-1", + "outlet.20.type": "nema520", + "outlet.21.current": "0", + "outlet.21.current.high.critical": "16", + "outlet.21.current.high.warning": "12.80", + "outlet.21.current.low.warning": "0", + "outlet.21.current.status": "good", + "outlet.21.delay.shutdown": "120", + "outlet.21.delay.start": "21", + "outlet.21.desc": "Outlet A21", + "outlet.21.groupid": "1", + "outlet.21.id": "21", + "outlet.21.name": "A21", + "outlet.21.power": "0", + "outlet.21.realpower": "0", + "outlet.21.status": "on", + "outlet.21.switchable": "yes", + "outlet.21.timer.shutdown": "-1", + "outlet.21.timer.start": "-1", + "outlet.21.type": "nema520", + "outlet.22.current": "0", + "outlet.22.current.high.critical": "16", + "outlet.22.current.high.warning": "12.80", + "outlet.22.current.low.warning": "0", + "outlet.22.current.status": "good", + "outlet.22.delay.shutdown": "0", + "outlet.22.delay.start": "0", + "outlet.22.desc": "Outlet A22", + "outlet.22.groupid": "1", + "outlet.22.id": "0", + "outlet.22.name": "A22", + "outlet.22.power": "0", + "outlet.22.realpower": "0", + "outlet.22.status": "on", + "outlet.22.switchable": "yes", + "outlet.22.timer.shutdown": "-1", + "outlet.22.timer.start": "-1", + "outlet.22.type": "nema520", + "outlet.23.current": "0.34", + "outlet.23.current.high.critical": "16", + "outlet.23.current.high.warning": "12.80", + "outlet.23.current.low.warning": "0", + "outlet.23.current.status": "good", + "outlet.23.delay.shutdown": "120", + "outlet.23.delay.start": "23", + "outlet.23.desc": "Outlet A23", + "outlet.23.groupid": "1", + "outlet.23.id": "23", + "outlet.23.name": "A23", + "outlet.23.power": "41", + "outlet.23.realpower": "39", + "outlet.23.status": "on", + "outlet.23.switchable": "yes", + "outlet.23.timer.shutdown": "-1", + "outlet.23.timer.start": "-1", + "outlet.23.type": "nema520", + "outlet.24.current": "0.19", + "outlet.24.current.high.critical": "16", + "outlet.24.current.high.warning": "12.80", + "outlet.24.current.low.warning": "0", + "outlet.24.current.status": "good", + "outlet.24.delay.shutdown": "0", + "outlet.24.delay.start": "0", + "outlet.24.desc": "Outlet A24", + "outlet.24.groupid": "1", + "outlet.24.id": "0", + "outlet.24.name": "A24", + "outlet.24.power": "23", + "outlet.24.realpower": "11", + "outlet.24.status": "on", + "outlet.24.switchable": "yes", + "outlet.24.timer.shutdown": "-1", + "outlet.24.timer.start": "-1", + "outlet.24.type": "nema520", + "outlet.3.current": "0.46", + "outlet.3.current.high.critical": "16", + "outlet.3.current.high.warning": "12.80", + "outlet.3.current.low.warning": "0", + "outlet.3.current.status": "good", + "outlet.3.delay.shutdown": "120", + "outlet.3.delay.start": "3", + "outlet.3.desc": "Outlet A3", + "outlet.3.groupid": "1", + "outlet.3.id": "3", + "outlet.3.name": "A3", + "outlet.3.power": "56", + "outlet.3.realpower": "53", + "outlet.3.status": "on", + "outlet.3.switchable": "yes", + "outlet.3.timer.shutdown": "-1", + "outlet.3.timer.start": "-1", + "outlet.3.type": "nema520", + "outlet.4.current": "0.44", + "outlet.4.current.high.critical": "16", + "outlet.4.current.high.warning": "12.80", + "outlet.4.current.low.warning": "0", + "outlet.4.current.status": "good", + "outlet.4.delay.shutdown": "120", + "outlet.4.delay.start": "4", + "outlet.4.desc": "Outlet A4", + "outlet.4.groupid": "1", + "outlet.4.id": "4", + "outlet.4.name": "A4", + "outlet.4.power": "53", + "outlet.4.realpower": "48", + "outlet.4.status": "on", + "outlet.4.switchable": "yes", + "outlet.4.timer.shutdown": "-1", + "outlet.4.timer.start": "-1", + "outlet.4.type": "nema520", + "outlet.5.current": "0.43", + "outlet.5.current.high.critical": "16", + "outlet.5.current.high.warning": "12.80", + "outlet.5.current.low.warning": "0", + "outlet.5.current.status": "good", + "outlet.5.delay.shutdown": "120", + "outlet.5.delay.start": "5", + "outlet.5.desc": "Outlet A5", + "outlet.5.groupid": "1", + "outlet.5.id": "5", + "outlet.5.name": "A5", + "outlet.5.power": "52", + "outlet.5.realpower": "48", + "outlet.5.status": "on", + "outlet.5.switchable": "yes", + "outlet.5.timer.shutdown": "-1", + "outlet.5.timer.start": "-1", + "outlet.5.type": "nema520", + "outlet.6.current": "1.07", + "outlet.6.current.high.critical": "16", + "outlet.6.current.high.warning": "12.80", + "outlet.6.current.low.warning": "0", + "outlet.6.current.status": "good", + "outlet.6.delay.shutdown": "120", + "outlet.6.delay.start": "6", + "outlet.6.desc": "Outlet A6", + "outlet.6.groupid": "1", + "outlet.6.id": "6", + "outlet.6.name": "A6", + "outlet.6.power": "131", + "outlet.6.realpower": "118", + "outlet.6.status": "on", + "outlet.6.switchable": "yes", + "outlet.6.timer.shutdown": "-1", + "outlet.6.timer.start": "-1", + "outlet.6.type": "nema520", + "outlet.7.current": "0", + "outlet.7.current.high.critical": "16", + "outlet.7.current.high.warning": "12.80", + "outlet.7.current.low.warning": "0", + "outlet.7.current.status": "good", + "outlet.7.delay.shutdown": "120", + "outlet.7.delay.start": "7", + "outlet.7.desc": "Outlet A7", + "outlet.7.groupid": "1", + "outlet.7.id": "7", + "outlet.7.name": "A7", + "outlet.7.power": "0", + "outlet.7.realpower": "0", + "outlet.7.status": "on", + "outlet.7.switchable": "yes", + "outlet.7.timer.shutdown": "-1", + "outlet.7.timer.start": "-1", + "outlet.7.type": "nema520", + "outlet.8.current": "0", + "outlet.8.current.high.critical": "16", + "outlet.8.current.high.warning": "12.80", + "outlet.8.current.low.warning": "0", + "outlet.8.current.status": "good", + "outlet.8.delay.shutdown": "120", + "outlet.8.delay.start": "8", + "outlet.8.desc": "Outlet A8", + "outlet.8.groupid": "1", + "outlet.8.id": "8", + "outlet.8.name": "A8", + "outlet.8.power": "0", + "outlet.8.realpower": "0", + "outlet.8.status": "on", + "outlet.8.switchable": "yes", + "outlet.8.timer.shutdown": "-1", + "outlet.8.timer.start": "-1", + "outlet.8.type": "nema520", + "outlet.9.current": "0", + "outlet.9.current.high.critical": "16", + "outlet.9.current.high.warning": "12.80", + "outlet.9.current.low.warning": "0", + "outlet.9.current.status": "good", + "outlet.9.delay.shutdown": "120", + "outlet.9.delay.start": "9", + "outlet.9.desc": "Outlet A9", + "outlet.9.groupid": "1", + "outlet.9.id": "9", + "outlet.9.name": "A9", + "outlet.9.power": "0", + "outlet.9.realpower": "0", + "outlet.9.status": "on", + "outlet.9.switchable": "yes", + "outlet.9.timer.shutdown": "-1", + "outlet.9.timer.start": "-1", + "outlet.9.type": "nema520", + "outlet.count": "24", + "outlet.current": "43.05", + "outlet.desc": "All outlets", + "outlet.frequency": "60", + "outlet.group.1.color": "16051527", + "outlet.group.1.count": "24", + "outlet.group.1.desc": "Section A", + "outlet.group.1.id": "1", + "outlet.group.1.input": "1", + "outlet.group.1.name": "A", + "outlet.group.1.phase": "1", + "outlet.group.1.status": "on", + "outlet.group.1.type": "outlet-section", + "outlet.group.1.voltage": "122.83", + "outlet.group.1.voltage.high.critical": "140", + "outlet.group.1.voltage.high.warning": "130", + "outlet.group.1.voltage.low.critical": "90", + "outlet.group.1.voltage.low.warning": "95", + "outlet.group.1.voltage.status": "good", + "outlet.group.count": "1", + "outlet.id": "0", + "outlet.switchable": "yes", + "outlet.voltage": "122.91", + "ups.firmware": "05.01.0002", + "ups.mfr": "EATON", + "ups.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "ups.serial": "A000A00000", + "ups.status": "", + "ups.type": "pdu" +} diff --git a/tests/components/nut/test_button.py b/tests/components/nut/test_button.py new file mode 100644 index 00000000000..bbcc521b7f3 --- /dev/null +++ b/tests/components/nut/test_button.py @@ -0,0 +1,102 @@ +"""Test the NUT button platform.""" + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .util import async_init_integration + + +@pytest.mark.parametrize( + "model", + [ + "CP1350C", + "5E650I", + "5E850I", + "CP1500PFCLCD", + "DL650ELCD", + "EATON5P1550", + "blazer_usb", + ], +) +async def test_buttons_ups( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str +) -> None: + """Tests that there are no standard buttons.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + button = hass.states.get("button.ups1_power_cycle_outlet_1") + assert not button + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + ), + ], +) +async def test_buttons_pdu_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Tests that the button entities are correct.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + for num in range(1, 25): + command = f"outlet.{num!s}.load.cycle" + list_commands_return_value[command] = command + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + entity_id = "button.ups1_power_cycle_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{unique_id_base}outlet.1.load.cycle" + + button = hass.states.get(entity_id) + assert button + assert button.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + button = hass.states.get(entity_id) + assert button.state != STATE_UNKNOWN + + button = hass.states.get("button.ups1_power_cycle_outlet_25") + assert not button + + button = hass.states.get("button.ups1_power_cycle_outlet_a25") + assert not button diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index ed9c87f2f90..6e308e22faa 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -5,7 +5,8 @@ from unittest.mock import patch from aionut import NUTError, NUTLoginError -from homeassistant import config_entries, setup +from homeassistant import config_entries +from homeassistant.components.nut.config_flow import PASSWORD_NOT_CHANGED from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_ALIAS, @@ -14,14 +15,13 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_RESOURCES, - CONF_SCAN_INTERVAL, CONF_USERNAME, ) 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 +from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry @@ -84,9 +84,8 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_one_ups(hass: HomeAssistant) -> None: - """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) +async def test_form_user_one_alias(hass: HomeAssistant) -> None: + """Test we can configure a device with one alias.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -129,10 +128,8 @@ async def test_form_user_one_ups(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_multiple_ups(hass: HomeAssistant) -> None: - """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - +async def test_form_user_multiple_aliases(hass: HomeAssistant) -> None: + """Test we can configure device with multiple aliases.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "2.2.2.2", CONF_PORT: 123, CONF_RESOURCES: ["battery.charge"]}, @@ -195,14 +192,13 @@ async def test_form_user_multiple_ups(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 2 -async def test_form_user_one_ups_with_ignored_entry(hass: HomeAssistant) -> None: +async def test_form_user_one_alias_with_ignored_entry(hass: HomeAssistant) -> None: """Test we can setup a new one when there is an ignored one.""" ignored_entry = MockConfigEntry( domain=DOMAIN, data={}, source=config_entries.SOURCE_IGNORE ) ignored_entry.add_to_hass(hass) - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -245,8 +241,8 @@ async def test_form_user_one_ups_with_ignored_entry(hass: HomeAssistant) -> None assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_no_upses_found(hass: HomeAssistant) -> None: - """Test we abort when the NUT server has not UPSes.""" +async def test_form_no_aliases_found(hass: HomeAssistant) -> None: + """Test we abort when the NUT server has no aliases.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -525,6 +521,104 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" +async def test_abort_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test we abort if unique_id is already setup.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + mock_pynut = _get_mock_nutclient(list_ups={"ups2": "UPS 2"}, list_vars=list_vars) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 2222, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_abort_multiple_aliases_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test we abort on multiple aliases if unique_id is already setup.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + mock_pynut = _get_mock_nutclient( + list_ups={"ups2": "UPS 2", "ups3": "UPS 3"}, list_vars=list_vars + ) + + 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.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 2222, + }, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "ups" + assert result2["type"] is FlowResultType.FORM + + await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + async def test_abort_if_already_setup_alias(hass: HomeAssistant) -> None: """Test we abort if component is already setup with same alias.""" config_entry = MockConfigEntry( @@ -575,43 +669,760 @@ async def test_abort_if_already_setup_alias(hass: HomeAssistant) -> None: assert result3["reason"] == "already_configured" -async def test_options_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="abcde12345", - data=VALID_CONFIG, +async def test_reconfigure_one_alias_successful(hass: HomeAssistant) -> None: + """Test reconfigure one alias successful.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, ) - config_entry.add_to_hass(hass) - with patch("homeassistant.components.nut.async_setup_entry", return_value=True): - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await entry.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_SCAN_INTERVAL: 60, - } + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" - with patch("homeassistant.components.nut.async_setup_entry", return_value=True): - result2 = await hass.config_entries.options.async_init(config_entry.entry_id) + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-new-password" + + +async def test_reconfigure_one_alias_nochange(hass: HomeAssistant) -> None: + """Test reconfigure one alias when there is no change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: int(entry.data[CONF_PORT]), + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_password_nochange(hass: HomeAssistant) -> None: + """Test reconfigure one alias when there is no password change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: PASSWORD_NOT_CHANGED, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_already_configured(hass: HomeAssistant) -> None: + """Test reconfigure when config changed to an existing host/port/alias.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: int(entry.data[CONF_PORT]), + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_unique_id_change(hass: HomeAssistant) -> None: + """Test reconfigure when the unique ID is changed.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + }, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_one_alias_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test reconfigure that results in a duplicate unique ID.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + username="test-username", + password="test-password", + list_ups={"ups2": "UPS 2"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups2": "UPS 2"}, + list_vars=list_vars, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "3.3.3.3", + CONF_PORT: 789, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_multiple_aliases_successful(hass: HomeAssistant) -> None: + """Test reconfigure with multiple aliases is successful.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "init" + assert result2["step_id"] == "reconfigure_ups" - result2 = await hass.config_entries.options.async_configure( + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - user_input={CONF_SCAN_INTERVAL: 12}, + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-new-password" + assert entry.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_nochange(hass: HomeAssistant) -> None: + """Test reconfigure with multiple aliases and no change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_SCAN_INTERVAL: 12, - } + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups1"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups1" + + +async def test_reconfigure_multiple_aliases_password_nochange( + hass: HomeAssistant, +) -> None: + """Test reconfigure with multiple aliases when no password change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: PASSWORD_NOT_CHANGED, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_already_configured( + hass: HomeAssistant, +) -> None: + """Test reconfigure multi aliases changed to existing host/port/alias.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars={"battery.voltage": "voltage"}, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + alias="ups2", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups1" + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + assert entry2.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_unique_id_change( + hass: HomeAssistant, +) -> None: + """Test reconfigure with multiple aliases and the unique ID is changed.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_multiple_aliases_duplicate_unique_ids( + hass: HomeAssistant, +) -> None: + """Test reconfigure multi aliases that results in duplicate unique ID.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars=list_vars, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + alias="ups2", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars=list_vars, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "3.3.3.3", + CONF_PORT: 789, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "unique_id_mismatch" diff --git a/tests/components/nut/test_device_action.py b/tests/components/nut/test_device_action.py index 01675f928e3..3f48d073f9f 100644 --- a/tests/components/nut/test_device_action.py +++ b/tests/components/nut/test_device_action.py @@ -15,12 +15,13 @@ from homeassistant.components.nut import DOMAIN from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS from homeassistant.const import CONF_DEVICE_ID, CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .util import async_init_integration -from tests.common import async_get_device_automations +from tests.common import MockConfigEntry, async_get_device_automations async def test_get_all_actions_for_specified_user( @@ -78,10 +79,10 @@ async def test_no_actions_for_anonymous_user( assert len(actions) == 0 -async def test_no_actions_invalid_device( +async def test_no_actions_device_not_found( hass: HomeAssistant, ) -> None: - """Test we get no actions for an invalid device.""" + """Test we get no actions for a device that cannot be found.""" list_commands_return_value = {"beeper.enable": None} await async_init_integration( hass, @@ -98,6 +99,30 @@ async def test_no_actions_invalid_device( assert len(actions) == 0 +async def test_no_actions_device_invalid( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we get no actions for a device that is invalid.""" + list_commands_return_value = {"beeper.enable": None} + entry = await async_init_integration( + hass, + list_vars={"ups.status": "OL"}, + list_commands_return_value=list_commands_return_value, + ) + device_entry = next(device for device in device_registry.devices.values()) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + platform = await device_automation.async_get_device_automation_platform( + hass, DOMAIN, DeviceAutomationType.ACTION + ) + actions = await platform.async_get_actions(hass, device_entry.id) + + assert len(actions) == 0 + + async def test_list_commands_exception( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: @@ -191,52 +216,43 @@ async def test_action(hass: HomeAssistant, device_registry: dr.DeviceRegistry) - run_command.assert_called_with("someUps", "beeper.disable") -async def test_rund_command_exception( +async def test_run_command_exception( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - caplog: pytest.LogCaptureFixture, ) -> None: - """Test logged error if run command raises exception.""" + """Test if run command raises exception with translation.""" - list_commands_return_value = {"beeper.enable": None} - error_message = "Something wrong happened" - run_command = AsyncMock(side_effect=NUTError(error_message)) + command_name = "beeper.enable" + nut_error_message = "Something wrong happened" + run_command = AsyncMock(side_effect=NUTError(nut_error_message)) await async_init_integration( hass, list_vars={"ups.status": "OL"}, - list_commands_return_value=list_commands_return_value, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value={command_name: None}, run_command=run_command, ) device_entry = next(device for device in device_registry.devices.values()) - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: [ - { - "trigger": { - "platform": "event", - "event_type": "test_some_event", - }, - "action": { - "domain": DOMAIN, - "device_id": device_entry.id, - "type": "beeper_enable", - }, - }, - ] - }, + platform = await device_automation.async_get_device_automation_platform( + hass, DOMAIN, DeviceAutomationType.ACTION ) - hass.bus.async_fire("test_some_event") - await hass.async_block_till_done() - - assert error_message in caplog.text + error_message = f"Error running command {command_name}, {nut_error_message}" + with pytest.raises(HomeAssistantError, match=error_message): + await platform.async_call_action_from_config( + hass, + { + CONF_TYPE: command_name, + CONF_DEVICE_ID: device_entry.id, + }, + {}, + None, + ) -async def test_action_exception_invalid_device(hass: HomeAssistant) -> None: - """Test raises exception if invalid device.""" +async def test_action_exception_device_not_found(hass: HomeAssistant) -> None: + """Test raises exception if device not found.""" list_commands_return_value = {"beeper.enable": None} await async_init_integration( hass, @@ -248,10 +264,73 @@ async def test_action_exception_invalid_device(hass: HomeAssistant) -> None: hass, DOMAIN, DeviceAutomationType.ACTION ) - with pytest.raises(InvalidDeviceAutomationConfig): + device_id = "invalid_device_id" + error_message = f"Unable to find a NUT device with ID {device_id}" + with pytest.raises(InvalidDeviceAutomationConfig, match=error_message): await platform.async_call_action_from_config( hass, - {CONF_TYPE: "beeper.enable", CONF_DEVICE_ID: "invalid_device_id"}, + {CONF_TYPE: "beeper.enable", CONF_DEVICE_ID: device_id}, + {}, + None, + ) + + +async def test_action_exception_invalid_config( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test raises exception if no NUT config entry found.""" + + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "mock-identifier")}, + ) + + platform = await device_automation.async_get_device_automation_platform( + hass, DOMAIN, DeviceAutomationType.ACTION + ) + + with pytest.raises(InvalidDeviceAutomationConfig): + await platform.async_call_action_from_config( + hass, + {CONF_TYPE: "beeper.enable", CONF_DEVICE_ID: device_entry.id}, + {}, + None, + ) + + +async def test_action_exception_device_invalid( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test raises exception if config entry for device is invalid.""" + list_commands_return_value = {"beeper.enable": None} + entry = await async_init_integration( + hass, + list_vars={"ups.status": "OL"}, + list_commands_return_value=list_commands_return_value, + ) + device_entry = next(device for device in device_registry.devices.values()) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + platform = await device_automation.async_get_device_automation_platform( + hass, DOMAIN, DeviceAutomationType.ACTION + ) + + error_message = ( + f"Invalid configuration entries for NUT device with ID {device_entry.id}" + ) + with pytest.raises(InvalidDeviceAutomationConfig, match=error_message): + await platform.async_call_action_from_config( + hass, + {CONF_TYPE: "beeper.enable", CONF_DEVICE_ID: device_entry.id}, {}, None, ) diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index 0585696cef2..6f1fb94478d 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -4,6 +4,7 @@ from copy import deepcopy from unittest.mock import patch from aionut import NUTError, NUTLoginError +import pytest from homeassistant.components.nut.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -11,15 +12,44 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_USERNAME, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +async def test_config_entry_migrations(hass: HomeAssistant) -> None: + """Test that config entries were migrated.""" + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 123, + }, + options={CONF_SCAN_INTERVAL: 30}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + assert CONF_SCAN_INTERVAL not in entry.options async def test_async_setup_entry(hass: HomeAssistant) -> None: @@ -56,60 +86,16 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: assert not hass.data.get(DOMAIN) -async def test_config_not_ready(hass: HomeAssistant) -> None: - """Test for setup failure if connection to broker is missing.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "mock", CONF_PORT: "mock"}, - ) - entry.add_to_hass(hass) +async def test_remove_device_valid( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that we cannot remove a device that still exists.""" + assert await async_setup_component(hass, "config", {}) - with ( - patch( - "homeassistant.components.nut.AIONUTClient.list_ups", - return_value={"ups1"}, - ), - patch( - "homeassistant.components.nut.AIONUTClient.list_vars", - side_effect=NUTError, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_auth_fails(hass: HomeAssistant) -> None: - """Test for setup failure if auth has changed.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "mock", CONF_PORT: "mock"}, - ) - entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.nut.AIONUTClient.list_ups", - return_value={"ups1"}, - ), - patch( - "homeassistant.components.nut.AIONUTClient.list_vars", - side_effect=NUTLoginError, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_ERROR - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["context"]["source"] == "reauth" - - -async def test_serial_number(hass: HomeAssistant) -> None: - """Test for serial number set on device.""" mock_serial_number = "A00000000000" - await async_init_integration( + config_entry = await async_init_integration( hass, username="someuser", password="somepassword", @@ -128,8 +114,141 @@ async def test_serial_number(hass: HomeAssistant) -> None: assert device_entry is not None assert device_entry.serial_number == mock_serial_number + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] -async def test_device_location(hass: HomeAssistant) -> None: + +async def test_remove_device_stale( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that we can remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + mock_serial_number = "A00000000000" + config_entry = await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.serial": mock_serial_number}, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_registry = dr.async_get(hass) + assert device_registry is not None + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "remove-device-id")}, + ) + + assert device_entry is not None + + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] + + # Verify that device entry is removed + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "remove-device-id")} + ) + assert device_entry is None + + +async def test_config_not_ready( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test for setup failure if connection to broker is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, + ) + entry.add_to_hass(hass) + + nut_error_message = "Something wrong happened" + error_message = f"Error fetching UPS state: {nut_error_message}" + with ( + patch( + "homeassistant.components.nut.AIONUTClient.list_ups", + return_value={"ups1"}, + ), + patch( + "homeassistant.components.nut.AIONUTClient.list_vars", + side_effect=NUTError(nut_error_message), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_RETRY + + assert error_message in caplog.text + + +async def test_auth_fails( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test for setup failure if auth has changed.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, + ) + entry.add_to_hass(hass) + + nut_error_message = "Something wrong happened" + error_message = f"Device authentication error: {nut_error_message}" + with ( + patch( + "homeassistant.components.nut.AIONUTClient.list_ups", + return_value={"ups1"}, + ), + patch( + "homeassistant.components.nut.AIONUTClient.list_vars", + side_effect=NUTLoginError(nut_error_message), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_ERROR + + assert error_message in caplog.text + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" + + +async def test_serial_number( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test for serial number set on device.""" + mock_serial_number = "A00000000000" + await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.serial": mock_serial_number}, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_serial_number)} + ) + + assert device_entry is not None + assert device_entry.serial_number == mock_serial_number + + +async def test_device_location( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: """Test for suggested location on device.""" mock_serial_number = "A00000000000" mock_device_location = "XYZ Location" @@ -145,9 +264,6 @@ async def test_device_location(hass: HomeAssistant) -> None: list_commands_return_value=[], ) - device_registry = dr.async_get(hass) - assert device_registry is not None - device_entry = device_registry.async_get_device( identifiers={(DOMAIN, mock_serial_number)} ) diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index eb171c39011..db9028222b1 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -7,15 +7,20 @@ import pytest from homeassistant.components.nut.const import DOMAIN from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_PORT, CONF_RESOURCES, PERCENTAGE, STATE_UNKNOWN, + Platform, + UnitOfElectricCurrent, UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, translation from .util import ( _get_mock_nutclient, @@ -52,9 +57,9 @@ async def test_ups_devices( assert state.state == "100" expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, + ATTR_DEVICE_CLASS: "battery", + ATTR_FRIENDLY_NAME: "Ups1 Battery charge", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -87,9 +92,9 @@ async def test_ups_devices_with_unique_ids( assert state.state == "100" expected_attributes = { - "device_class": "battery", - "friendly_name": "Ups1 Battery charge", - "unit_of_measurement": PERCENTAGE, + ATTR_DEVICE_CLASS: "battery", + ATTR_FRIENDLY_NAME: "Ups1 Battery charge", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -103,7 +108,7 @@ async def test_ups_devices_with_unique_ids( [ ( "EATON-EPDU-G3", - "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", ), ], ) @@ -115,44 +120,43 @@ async def test_pdu_devices_with_unique_ids( ) -> None: """Test creation of device sensors with unique ids.""" - await _test_sensor_and_attributes( + await async_init_integration(hass, model) + + _test_sensor_and_attributes( hass, entity_registry, - model, - unique_id=f"{unique_id_base}input.voltage", + unique_id=f"{unique_id_base}_input.voltage", device_id="sensor.ups1_input_voltage", state_value="122.91", expected_attributes={ - "device_class": SensorDeviceClass.VOLTAGE, + ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, "state_class": SensorStateClass.MEASUREMENT, - "friendly_name": "Ups1 Input voltage", - "unit_of_measurement": UnitOfElectricPotential.VOLT, + ATTR_FRIENDLY_NAME: "Ups1 Input voltage", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, }, ) - await _test_sensor_and_attributes( + _test_sensor_and_attributes( hass, entity_registry, - model, - unique_id=f"{unique_id_base}ambient.humidity.status", + unique_id=f"{unique_id_base}_ambient.humidity.status", device_id="sensor.ups1_ambient_humidity_status", state_value="good", expected_attributes={ - "device_class": SensorDeviceClass.ENUM, - "friendly_name": "Ups1 Ambient humidity status", + ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, + ATTR_FRIENDLY_NAME: "Ups1 Ambient humidity status", }, ) - await _test_sensor_and_attributes( + _test_sensor_and_attributes( hass, entity_registry, - model, - unique_id=f"{unique_id_base}ambient.temperature.status", + unique_id=f"{unique_id_base}_ambient.temperature.status", device_id="sensor.ups1_ambient_temperature_status", state_value="good", expected_attributes={ - "device_class": SensorDeviceClass.ENUM, - "friendly_name": "Ups1 Ambient temperature status", + ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, + ATTR_FRIENDLY_NAME: "Ups1 Ambient temperature status", }, ) @@ -241,3 +245,117 @@ async def test_stale_options( state = hass.states.get("sensor.ups1_battery_charge") assert state.state == "10" + + +async def test_state_ambient_translation(hass: HomeAssistant) -> None: + """Test translation of ambient state sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "mock", CONF_PORT: "mock"}, + ) + entry.add_to_hass(hass) + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, list_vars={"ambient.humidity.status": "good"} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + key = "ambient_humidity_status" + state = hass.states.get(f"sensor.ups1_{key}") + assert state.state == "good" + + result = translation.async_translate_state( + hass, state.state, Platform.SENSOR, DOMAIN, key, None + ) + + assert result == "Good" + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3-AMBIENT-NOT-PRESENT", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", + ), + ], +) +async def test_pdu_devices_ambient_not_present( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Test that ambient sensors not created.""" + + await async_init_integration(hass, model) + + entry = entity_registry.async_get("sensor.ups1_ambient_humidity") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_ambient_humidity_status") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_ambient_temperature") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_ambient_temperature_status") + assert not entry + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", + ), + ], +) +async def test_pdu_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Test for dynamically created outlet sensors.""" + + await async_init_integration(hass, model) + + _test_sensor_and_attributes( + hass, + entity_registry, + unique_id=f"{unique_id_base}_outlet.1.current", + device_id="sensor.ups1_outlet_a1_current", + state_value="0", + expected_attributes={ + ATTR_DEVICE_CLASS: SensorDeviceClass.CURRENT, + ATTR_FRIENDLY_NAME: "Ups1 Outlet A1 current", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricCurrent.AMPERE, + }, + ) + + _test_sensor_and_attributes( + hass, + entity_registry, + unique_id=f"{unique_id_base}_outlet.24.current", + device_id="sensor.ups1_outlet_a24_current", + state_value="0.19", + expected_attributes={ + ATTR_DEVICE_CLASS: SensorDeviceClass.CURRENT, + ATTR_FRIENDLY_NAME: "Ups1 Outlet A24 current", + ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricCurrent.AMPERE, + }, + ) + + entry = entity_registry.async_get("sensor.ups1_outlet_25_current") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_outlet_a25_current") + assert not entry diff --git a/tests/components/nut/test_switch.py b/tests/components/nut/test_switch.py new file mode 100644 index 00000000000..f2de5eeb5e6 --- /dev/null +++ b/tests/components/nut/test_switch.py @@ -0,0 +1,159 @@ +"""Test the NUT switch platform.""" + +import json +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .util import async_init_integration + +from tests.common import load_fixture + + +@pytest.mark.parametrize( + "model", + [ + "CP1350C", + "5E650I", + "5E850I", + "CP1500PFCLCD", + "DL650ELCD", + "EATON5P1550", + "blazer_usb", + ], +) +async def test_switch_ups( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str +) -> None: + """Tests that there are no standard switches.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + switch = hass.states.get("switch.ups1_power_outlet_1") + assert not switch + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", + ), + ], +) +async def test_switch_pdu_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Tests that the switch entities are correct.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + for num in range(1, 25): + command = f"outlet.{num!s}.load.on" + list_commands_return_value[command] = command + command = f"outlet.{num!s}.load.off" + list_commands_return_value[command] = command + + ups_fixture = f"nut/{model}.json" + list_vars = json.loads(load_fixture(ups_fixture)) + + run_command = AsyncMock() + + await async_init_integration( + hass, + model, + list_vars=list_vars, + list_commands_return_value=list_commands_return_value, + run_command=run_command, + ) + + entity_id = "switch.ups1_power_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{unique_id_base}_outlet.1.load.poweronoff" + + switch = hass.states.get(entity_id) + assert switch + assert switch.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + run_command.assert_called_with("ups1", "outlet.1.load.off") + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + run_command.assert_called_with("ups1", "outlet.1.load.on") + + switch = hass.states.get("switch.ups1_power_outlet_25") + assert not switch + + switch = hass.states.get("switch.ups1_power_outlet_a25") + assert not switch + + +async def test_switch_pdu_dynamic_outlets_state_unknown( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test switch entity with missing status is reported as unknown.""" + + config_entry = await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars={ + "outlet.count": "1", + "outlet.1.switchable": "yes", + "outlet.1.name": "A1", + }, + list_commands_return_value={ + "outlet.1.load.on": None, + "outlet.1.load.off": None, + }, + ) + + entity_id = "switch.ups1_power_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{config_entry.entry_id}_outlet.1.load.poweronoff" + + switch = hass.states.get(entity_id) + assert switch + assert switch.state == STATE_UNKNOWN diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index bd82ffdd6b4..49510fc9d72 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -1,10 +1,17 @@ """Tests for the nut integration.""" import json +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.nut.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_ALIAS, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -35,8 +42,11 @@ def _get_mock_nutclient( async def async_init_integration( hass: HomeAssistant, ups_fixture: str | None = None, + host: str = "mock", + port: int = 1234, username: str = "mock", password: str = "mock", + alias: str | None = None, list_ups: dict[str, str] | None = None, list_vars: dict[str, str] | None = None, list_commands_return_value: dict[str, str] | None = None, @@ -65,15 +75,24 @@ async def async_init_integration( "homeassistant.components.nut.AIONUTClient", return_value=mock_pynut, ): + extra_config_entry_data: dict[str, Any] = {} + + if alias is not None: + extra_config_entry_data = { + CONF_ALIAS: alias, + } + entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "mock", + CONF_HOST: host, CONF_PASSWORD: password, - CONF_PORT: "mock", + CONF_PORT: port, CONF_USERNAME: username, - }, + } + | extra_config_entry_data, ) + entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -82,18 +101,16 @@ async def async_init_integration( return entry -async def _test_sensor_and_attributes( +def _test_sensor_and_attributes( hass: HomeAssistant, entity_registry: er.EntityRegistry, - model: str, unique_id: str, device_id: str, state_value: str, expected_attributes: dict, ) -> None: - """Test creation of device sensors with unique ids.""" + """Test all of the sensor entry attributes.""" - await async_init_integration(hass, model) entry = entity_registry.async_get(device_id) assert entry assert entry.unique_id == unique_id diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index 39e954af15a..1de8f67fbdb 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -176,7 +176,7 @@ WEATHER_EXPECTED_OBSERVATION_METRIC = { ATTR_WEATHER_HUMIDITY: 10, } -NONE_OBSERVATION = {key: None for key in DEFAULT_OBSERVATION} +NONE_OBSERVATION = dict.fromkeys(DEFAULT_OBSERVATION) DEFAULT_FORECAST = [ { @@ -235,4 +235,4 @@ EXPECTED_FORECAST_METRIC = { ATTR_FORECAST_HUMIDITY: 75, } -NONE_FORECAST = [{key: None for key in DEFAULT_FORECAST[0]}] +NONE_FORECAST = [dict.fromkeys(DEFAULT_FORECAST[0])] diff --git a/tests/components/nws/test_diagnostics.py b/tests/components/nws/test_diagnostics.py index 55f7f3100a0..fecd74eb0f4 100644 --- a/tests/components/nws/test_diagnostics.py +++ b/tests/components/nws/test_diagnostics.py @@ -1,6 +1,6 @@ """Test NWS diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components import nws from homeassistant.core import HomeAssistant diff --git a/tests/components/nyt_games/test_init.py b/tests/components/nyt_games/test_init.py index 2e1a8c92f90..ced155ac5a2 100644 --- a/tests/components/nyt_games/test_init.py +++ b/tests/components/nyt_games/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.nyt_games.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/nyt_games/test_sensor.py b/tests/components/nyt_games/test_sensor.py index f35caf20b57..5802b38dd83 100644 --- a/tests/components/nyt_games/test_sensor.py +++ b/tests/components/nyt_games/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from nyt_games import NYTGamesError, WordleStats import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.nyt_games.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index 01cc668ae32..e8a7d27b2c3 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -60,10 +60,14 @@ def mock_client(): client.preconditioning = 15 client.serial = "chargerid" client.ct_connected = True + client.cap_available = True + client.cap_enabled = True client.energy = 1000 client.device_info = { "name": "Ohme Home Pro", "model": "Home Pro", "sw_version": "v2.65", } + client.vehicles = ["Nissan Leaf", "Tesla Model 3"] + client.current_vehicle = "Nissan Leaf" yield client diff --git a/tests/components/ohme/snapshots/test_diagnostics.ambr b/tests/components/ohme/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f51c701b71b --- /dev/null +++ b/tests/components/ohme/snapshots/test_diagnostics.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'cap_available': True, + 'ct_connected': True, + 'device_info': dict({ + 'model': 'Home Pro', + 'name': 'Ohme Home Pro', + 'sw_version': 'v2.65', + }), + 'vehicles': list([ + 'Nissan Leaf', + 'Tesla Model 3', + ]), + }) +# --- diff --git a/tests/components/ohme/snapshots/test_select.ambr b/tests/components/ohme/snapshots/test_select.ambr index 8eec0556889..063a9616588 100644 --- a/tests/components/ohme/snapshots/test_select.ambr +++ b/tests/components/ohme/snapshots/test_select.ambr @@ -57,3 +57,59 @@ 'state': 'unknown', }) # --- +# name: test_selects[select.ohme_home_pro_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Nissan Leaf', + 'Tesla Model 3', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.ohme_home_pro_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': 'Vehicle', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle', + 'unique_id': 'chargerid_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.ohme_home_pro_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Vehicle', + 'options': list([ + 'Nissan Leaf', + 'Tesla Model 3', + ]), + }), + 'context': , + 'entity_id': 'select.ohme_home_pro_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Nissan Leaf', + }) +# --- diff --git a/tests/components/ohme/snapshots/test_switch.ambr b/tests/components/ohme/snapshots/test_switch.ambr index 49bf5d5709a..4790d96c551 100644 --- a/tests/components/ohme/snapshots/test_switch.ambr +++ b/tests/components/ohme/snapshots/test_switch.ambr @@ -46,6 +46,53 @@ 'state': 'on', }) # --- +# name: test_switches[switch.ohme_home_pro_price_cap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.ohme_home_pro_price_cap', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Price cap', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'price_cap', + 'unique_id': 'chargerid_price_cap', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ohme_home_pro_price_cap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Price cap', + }), + 'context': , + 'entity_id': 'switch.ohme_home_pro_price_cap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[switch.ohme_home_pro_require_approval-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ohme/test_button.py b/tests/components/ohme/test_button.py index 1728563b2e9..70dab600b6d 100644 --- a/tests/components/ohme/test_button.py +++ b/tests/components/ohme/test_button.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory from ohme import ChargerStatus -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ( diff --git a/tests/components/ohme/test_config_flow.py b/tests/components/ohme/test_config_flow.py index bb7ecc00bdc..b8754711d76 100644 --- a/tests/components/ohme/test_config_flow.py +++ b/tests/components/ohme/test_config_flow.py @@ -182,3 +182,84 @@ async def test_reauth_fail( await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_form(hass: HomeAssistant, mock_client: MagicMock) -> None: + """Test reconfigure form.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reconfigure", "entry_id": entry.entry_id} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + 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"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("test_exception", "expected_error"), + [(AuthException, "invalid_auth"), (ApiException, "unknown")], +) +async def test_reconfigure_fail( + hass: HomeAssistant, + mock_client: MagicMock, + test_exception: Exception, + expected_error: str, +) -> None: + """Test reconfigure errors.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reconfigure", "entry_id": entry.entry_id} + ) + + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + # Simulate failed login attempt + 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} + + # Retry with a successful login + 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"] == "reconfigure_successful" diff --git a/tests/components/ohme/test_diagnostics.py b/tests/components/ohme/test_diagnostics.py new file mode 100644 index 00000000000..25ee5ae10db --- /dev/null +++ b/tests/components/ohme/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for the diagnostics data provided by the Ohme integration.""" + +from unittest.mock import MagicMock + +from syrupy.assertion 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( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + 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 + ) diff --git a/tests/components/ohme/test_init.py b/tests/components/ohme/test_init.py index 0f4c7cd64ee..7d9d388867f 100644 --- a/tests/components/ohme/test_init.py +++ b/tests/components/ohme/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ohme.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/ohme/test_number.py b/tests/components/ohme/test_number.py index 9cfce2a850f..e162cd337ae 100644 --- a/tests/components/ohme/test_number.py +++ b/tests/components/ohme/test_number.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/ohme/test_select.py b/tests/components/ohme/test_select.py index 5aeebc1f477..1f0225fd70f 100644 --- a/tests/components/ohme/test_select.py +++ b/tests/components/ohme/test_select.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from ohme import ChargerMode -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/ohme/test_sensor.py b/tests/components/ohme/test_sensor.py index 21f9f06f963..b7c8f82aafc 100644 --- a/tests/components/ohme/test_sensor.py +++ b/tests/components/ohme/test_sensor.py @@ -5,7 +5,8 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory from ohme import ApiException -from syrupy import SnapshotAssertion +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant @@ -16,6 +17,7 @@ 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_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/ohme/test_services.py b/tests/components/ohme/test_services.py index 76c7ce94b57..c228ddcd9a7 100644 --- a/tests/components/ohme/test_services.py +++ b/tests/components/ohme/test_services.py @@ -1,13 +1,16 @@ """Tests for services.""" -from unittest.mock import MagicMock +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock +from ohme import ChargeSlot import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.ohme.const import DOMAIN from homeassistant.components.ohme.services import ( ATTR_CONFIG_ENTRY, + ATTR_PRICE_CAP, SERVICE_LIST_CHARGE_SLOTS, ) from homeassistant.core import HomeAssistant @@ -29,11 +32,11 @@ async def test_list_charge_slots( 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, - } + ChargeSlot( + datetime.fromisoformat("2024-12-30T04:00:00+00:00"), + datetime.fromisoformat("2024-12-30T04:30:39+00:00"), + 2.042, + ) ] assert snapshot == await hass.services.async_call( @@ -47,6 +50,30 @@ async def test_list_charge_slots( ) +async def test_set_price_cap( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test set price cap service.""" + + await setup_integration(hass, mock_config_entry) + mock_client.async_change_price_cap = AsyncMock() + + await hass.services.async_call( + DOMAIN, + "set_price_cap", + { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + ATTR_PRICE_CAP: 10.0, + }, + blocking=True, + ) + + mock_client.async_change_price_cap.assert_called_once_with(cap=10.0) + + async def test_list_charge_slots_exception( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/ohme/test_switch.py b/tests/components/ohme/test_switch.py index b16b70d67f8..976b5cfcccd 100644 --- a/tests/components/ohme/test_switch.py +++ b/tests/components/ohme/test_switch.py @@ -1,8 +1,8 @@ """Tests for switches.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, @@ -32,7 +32,49 @@ async def test_switches( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_switch_on( +async def test_cap_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) + mock_client.async_change_price_cap = AsyncMock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.ohme_home_pro_price_cap", + }, + blocking=True, + ) + + mock_client.async_change_price_cap.assert_called_once_with(True) + + +async def test_cap_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) + mock_client.async_change_price_cap = AsyncMock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "switch.ohme_home_pro_price_cap", + }, + blocking=True, + ) + + mock_client.async_change_price_cap.assert_called_once_with(False) + + +async def test_config_switch_on( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock, @@ -52,7 +94,7 @@ async def test_switch_on( assert len(mock_client.async_set_configuration_value.mock_calls) == 1 -async def test_switch_off( +async def test_config_switch_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock, diff --git a/tests/components/ohme/test_time.py b/tests/components/ohme/test_time.py index 0562dfa124c..8c604e19086 100644 --- a/tests/components/ohme/test_time.py +++ b/tests/components/ohme/test_time.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.time import ( ATTR_TIME, diff --git a/tests/components/ollama/snapshots/test_conversation.ambr b/tests/components/ollama/snapshots/test_conversation.ambr index 93f3b03d9af..de414019317 100644 --- a/tests/components/ollama/snapshots/test_conversation.ambr +++ b/tests/components/ollama/snapshots/test_conversation.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ + 'continue_conversation': False, 'conversation_id': '1234', 'response': IntentResponse( card=dict({ diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index db641ba703b..c718aab1e81 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator from typing import Any from unittest.mock import AsyncMock, Mock, patch +from freezegun.api import FrozenDateTimeFactory from ollama import Message, ResponseError import pytest from syrupy.assertion import SnapshotAssertion @@ -404,7 +405,10 @@ async def test_unknown_hass_api( async def test_message_history_trimming( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + freezer: FrozenDateTimeFactory, ) -> None: """Test that a single message history is trimmed according to the config.""" response_idx = 0 diff --git a/tests/components/omnilogic/test_sensor.py b/tests/components/omnilogic/test_sensor.py index 166eb7f87f2..ed7d781ab2d 100644 --- a/tests/components/omnilogic/test_sensor.py +++ b/tests/components/omnilogic/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/omnilogic/test_switch.py b/tests/components/omnilogic/test_switch.py index 1f9506380a2..adc8fe04763 100644 --- a/tests/components/omnilogic/test_switch.py +++ b/tests/components/omnilogic/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index b7189bda6cc..08acdc94afc 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -3,28 +3,27 @@ import asyncio from collections.abc import AsyncGenerator from http import HTTPStatus -from io import StringIO import os from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest -from syrupy import SnapshotAssertion -from homeassistant.components import backup, onboarding +from homeassistant.components import onboarding from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar -from homeassistant.helpers.backup import async_initialize_backup -from homeassistant.setup import async_setup_component +from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from . import mock_storage from tests.common import ( CLIENT_ID, CLIENT_REDIRECT_URI, + MockModule, MockUser, + mock_integration, + mock_platform, register_auth_provider, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -32,11 +31,9 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -def auth_active(hass: HomeAssistant) -> None: +async def auth_active(hass: HomeAssistant) -> None: """Ensure auth is always active.""" - hass.loop.run_until_complete( - register_auth_provider(hass, {"type": "homeassistant"}) - ) + await register_auth_provider(hass, {"type": "homeassistant"}) @pytest.fixture(name="rpi") @@ -631,13 +628,6 @@ async def test_onboarding_installation_type( ("method", "view", "kwargs"), [ ("get", "installation_type", {}), - ("get", "backup/info", {}), - ( - "post", - "backup/restore", - {"json": {"backup_id": "abc123", "agent_id": "test"}}, - ), - ("post", "backup/upload", {}), ], ) async def test_onboarding_view_after_done( @@ -723,347 +713,135 @@ async def test_complete_onboarding( @pytest.mark.parametrize( - ("method", "view", "kwargs"), + ("domain", "expected_result"), [ - ("get", "backup/info", {}), - ( - "post", - "backup/restore", - {"json": {"backup_id": "abc123", "agent_id": "test"}}, - ), - ("post", "backup/upload", {}), + ("onboarding", {"integration_loaded": True}), + ("non_existing_domain", {"integration_loaded": False}), ], ) -async def test_onboarding_backup_view_without_backup( +async def test_wait_integration( hass: HomeAssistant, hass_storage: dict[str, Any], hass_client: ClientSessionGenerator, - method: str, - view: str, - kwargs: dict[str, Any], + domain: str, + expected_result: dict[str, Any], ) -> None: - """Test interacting with backup wievs when backup integration is missing.""" + """Test we can get wait for an integration to load.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() client = await hass_client() + req = await client.post("/api/onboarding/integration/wait", json={"domain": domain}) - resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) - - assert resp.status == 500 - assert await resp.json() == {"code": "backup_disabled"} + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == expected_result -async def test_onboarding_backup_info( +async def test_wait_integration_startup( hass: HomeAssistant, hass_storage: dict[str, Any], hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, ) -> None: - """Test returning installation type during onboarding.""" + """Test we can get wait for an integration to load during startup.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() - client = await hass_client() - backups = { - "abc123": backup.ManagerBackup( - addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], - agents={ - "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) - }, - backup_id="abc123", - date="1970-01-01T00:00:00.000Z", - database_included=True, - extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, - folders=[backup.Folder.MEDIA, backup.Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - failed_agent_ids=[], - with_automatic_settings=True, - ), - "def456": backup.ManagerBackup( - addons=[], - agents={ - "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) - }, - backup_id="def456", - date="1980-01-01T00:00:00.000Z", - database_included=False, - extra_metadata={ - "instance_id": "unknown_uuid", - "with_automatic_settings": True, - }, - folders=[backup.Folder.MEDIA, backup.Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test 2", - failed_agent_ids=[], - with_automatic_settings=None, - ), - } + setup_stall = asyncio.Event() + setup_started = asyncio.Event() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backups", - return_value=(backups, {}), - ): - resp = await client.get("/api/onboarding/backup/info") + async def mock_setup(hass: HomeAssistant, _) -> bool: + setup_started.set() + await setup_stall.wait() + return True - assert resp.status == 200 - assert await resp.json() == snapshot + mock_integration(hass, MockModule("test", async_setup=mock_setup)) + + # The integration is not loaded, and is also not scheduled to load + req = await client.post("/api/onboarding/integration/wait", json={"domain": "test"}) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"integration_loaded": False} + + # Mark the component as scheduled to be loaded + async_set_domains_to_be_loaded(hass, {"test"}) + + # Start loading the component, including its config entries + hass.async_create_task(async_setup_component(hass, "test", {})) + await setup_started.wait() + + # The component is not yet loaded + assert "test" not in hass.config.components + + # Allow setup to proceed + setup_stall.set() + + # The component is scheduled to load, this will block until the config entry is loaded + req = await client.post("/api/onboarding/integration/wait", json={"domain": "test"}) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"integration_loaded": True} + + # The component has been loaded + assert "test" in hass.config.components + + +async def test_not_setup_platform_if_onboarded( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test if onboarding is done, we don't setup platforms.""" + mock_storage(hass_storage, {"done": onboarding.STEPS}) + + platform_mock = Mock(async_setup_views=AsyncMock(), spec=["async_setup_views"]) + mock_platform(hass, "test.onboarding", platform_mock) + assert await async_setup_component(hass, "test", {}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + assert len(platform_mock.async_setup_views.mock_calls) == 0 + + +async def test_setup_platform_if_not_onboarded( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test if onboarding is not done, we setup platforms.""" + platform_mock = Mock(async_setup_views=AsyncMock(), spec=["async_setup_views"]) + mock_platform(hass, "test.onboarding", platform_mock) + assert await async_setup_component(hass, "test", {}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + platform_mock.async_setup_views.assert_awaited_once_with(hass, {"done": []}) @pytest.mark.parametrize( - ("params", "expected_kwargs"), + "platform_mock", [ - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - { - "agent_id": "backup.local", - "password": None, - "restore_addons": None, - "restore_database": True, - "restore_folders": None, - "restore_homeassistant": True, - }, - ), - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1"], - "restore_database": True, - "restore_folders": ["media"], - }, - { - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1"], - "restore_database": True, - "restore_folders": [backup.Folder.MEDIA], - "restore_homeassistant": True, - }, - ), - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1", "addon_2"], - "restore_database": False, - "restore_folders": ["media", "share"], - }, - { - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1", "addon_2"], - "restore_database": False, - "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], - "restore_homeassistant": True, - }, - ), + Mock(some_method=AsyncMock(), spec=["some_method"]), + Mock(spec=[]), ], ) -async def test_onboarding_backup_restore( +async def test_bad_platform( hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - expected_kwargs: dict[str, Any], + platform_mock: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test returning installation type during onboarding.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) + """Test loading onboarding platform which doesn't have the expected methods.""" + mock_platform(hass, "test.onboarding", platform_mock) + assert await async_setup_component(hass, "test", {}) await hass.async_block_till_done() - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - assert resp.status == 200 - mock_restore.assert_called_once_with("abc123", **expected_kwargs) - - -@pytest.mark.parametrize( - ("params", "restore_error", "expected_status", "expected_json", "restore_calls"), - [ - # Missing agent_id - ( - {"backup_id": "abc123"}, - None, - 400, - { - "message": "Message format incorrect: required key not provided @ data['agent_id']" - }, - 0, - ), - # Missing backup_id - ( - {"agent_id": "backup.local"}, - None, - 400, - { - "message": "Message format incorrect: required key not provided @ data['backup_id']" - }, - 0, - ), - # Invalid restore_database - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "restore_database": "yes_please", - }, - None, - 400, - { - "message": "Message format incorrect: expected bool for dictionary value @ data['restore_database']" - }, - 0, - ), - # Invalid folder - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "restore_folders": ["invalid"], - }, - None, - 400, - { - "message": "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]" - }, - 0, - ), - # Wrong password - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - backup.IncorrectPasswordError, - 400, - {"code": "incorrect_password"}, - 1, - ), - # Home Assistant error - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - HomeAssistantError("Boom!"), - 400, - {"code": "restore_failed", "message": "Boom!"}, - 1, - ), - ], -) -async def test_onboarding_backup_restore_error( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - restore_error: Exception | None, - expected_status: int, - expected_json: str, - restore_calls: int, -) -> None: - """Test returning installation type during onboarding.""" - mock_storage(hass_storage, {"done": []}) - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - side_effect=restore_error, - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - - assert resp.status == expected_status - assert await resp.json() == expected_json - assert len(mock_restore.mock_calls) == restore_calls - - -@pytest.mark.parametrize( - ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), - [ - # Unexpected error - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - Exception("Boom!"), - 500, - "500 Internal Server Error", - 1, - ), - ], -) -async def test_onboarding_backup_restore_unexpected_error( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - restore_error: Exception | None, - expected_status: int, - expected_message: str, - restore_calls: int, -) -> None: - """Test returning installation type during onboarding.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - side_effect=restore_error, - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - - assert resp.status == expected_status - assert (await resp.content.read()).decode().startswith(expected_message) - assert len(mock_restore.mock_calls) == restore_calls - - -async def test_onboarding_backup_upload( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, -) -> None: - """Test returning installation type during onboarding.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_receive_backup", - return_value="abc123", - ) as mock_receive: - resp = await client.post( - "/api/onboarding/backup/upload?agent_id=backup.local", - data={"file": StringIO("test")}, - ) - assert resp.status == 201 - assert await resp.json() == {"backup_id": "abc123"} - mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) + assert platform_mock.mock_calls == [] + assert "'test.onboarding' is not a valid onboarding platform" in caplog.text diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py index d88774307c0..d7821861e88 100644 --- a/tests/components/oncue/__init__.py +++ b/tests/components/oncue/__init__.py @@ -1,881 +1 @@ """Tests for the Oncue integration.""" - -from contextlib import contextmanager -from unittest.mock import patch - -from aiooncue import LoginFailedException, OncueDevice, OncueSensor - -MOCK_ASYNC_FETCH_ALL = { - "123456": OncueDevice( - name="My Generator", - state="Off", - product_name="RDC 2.4", - hardware_version="319", - serial_number="SERIAL", - sensors={ - "Product": OncueSensor( - name="Product", - display_name="Controller Type", - value="RDC 2.4", - display_value="RDC 2.4", - unit=None, - ), - "FirmwareVersion": OncueSensor( - name="FirmwareVersion", - display_name="Current Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "LatestFirmware": OncueSensor( - name="LatestFirmware", - display_name="Latest Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "EngineSpeed": OncueSensor( - name="EngineSpeed", - display_name="Engine Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineTargetSpeed": OncueSensor( - name="EngineTargetSpeed", - display_name="Engine Target Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineOilPressure": OncueSensor( - name="EngineOilPressure", - display_name="Engine Oil Pressure", - value=0, - display_value="0 Psi", - unit="Psi", - ), - "EngineCoolantTemperature": OncueSensor( - name="EngineCoolantTemperature", - display_name="Engine Coolant Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "BatteryVoltage": OncueSensor( - name="BatteryVoltage", - display_name="Battery Voltage", - value="13.4", - display_value="13.4 V", - unit="V", - ), - "LubeOilTemperature": OncueSensor( - name="LubeOilTemperature", - display_name="Lube Oil Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "GensetControllerTemperature": OncueSensor( - name="GensetControllerTemperature", - display_name="Generator Controller Temperature", - value=84.2, - display_value="84.2 F", - unit="F", - ), - "EngineCompartmentTemperature": OncueSensor( - name="EngineCompartmentTemperature", - display_name="Engine Compartment Temperature", - value=62.6, - display_value="62.6 F", - unit="F", - ), - "GeneratorTrueTotalPower": OncueSensor( - name="GeneratorTrueTotalPower", - display_name="Generator True Total Power", - value="0.0", - display_value="0.0 W", - unit="W", - ), - "GeneratorTruePercentOfRatedPower": OncueSensor( - name="GeneratorTruePercentOfRatedPower", - display_name="Generator True Percent Of Rated Power", - value="0", - display_value="0 %", - unit="%", - ), - "GeneratorVoltageAB": OncueSensor( - name="GeneratorVoltageAB", - display_name="Generator Voltage AB", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorVoltageAverageLineToLine": OncueSensor( - name="GeneratorVoltageAverageLineToLine", - display_name="Generator Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorCurrentAverage": OncueSensor( - name="GeneratorCurrentAverage", - display_name="Generator Current Average", - value="0.0", - display_value="0.0 A", - unit="A", - ), - "GeneratorFrequency": OncueSensor( - name="GeneratorFrequency", - display_name="Generator Frequency", - value="0.0", - display_value="0.0 Hz", - unit="Hz", - ), - "GensetSerialNumber": OncueSensor( - name="GensetSerialNumber", - display_name="Generator Serial Number", - value="33FDGMFR0026", - display_value="33FDGMFR0026", - unit=None, - ), - "GensetState": OncueSensor( - name="GensetState", - display_name="Generator State", - value="Off", - display_value="Off", - unit=None, - ), - "GensetControllerSerialNumber": OncueSensor( - name="GensetControllerSerialNumber", - display_name="Generator Controller Serial Number", - value="-1", - display_value="-1", - unit=None, - ), - "GensetModelNumberSelect": OncueSensor( - name="GensetModelNumberSelect", - display_name="Genset Model Number Select", - value="38 RCLB", - display_value="38 RCLB", - unit=None, - ), - "GensetControllerClockTime": OncueSensor( - name="GensetControllerClockTime", - display_name="Generator Controller Clock Time", - value="2022-01-13 18:08:13", - display_value="2022-01-13 18:08:13", - unit=None, - ), - "GensetControllerTotalOperationTime": OncueSensor( - name="GensetControllerTotalOperationTime", - display_name="Generator Controller Total Operation Time", - value="16770.8", - display_value="16770.8 h", - unit="h", - ), - "EngineTotalRunTime": OncueSensor( - name="EngineTotalRunTime", - display_name="Engine Total Run Time", - value="28.1", - display_value="28.1 h", - unit="h", - ), - "EngineTotalRunTimeLoaded": OncueSensor( - name="EngineTotalRunTimeLoaded", - display_name="Engine Total Run Time Loaded", - value="5.5", - display_value="5.5 h", - unit="h", - ), - "EngineTotalNumberOfStarts": OncueSensor( - name="EngineTotalNumberOfStarts", - display_name="Engine Total Number Of Starts", - value="101", - display_value="101", - unit=None, - ), - "GensetTotalEnergy": OncueSensor( - name="GensetTotalEnergy", - display_name="Genset Total Energy", - value="1.2022309E7", - display_value="1.2022309E7 kWh", - unit="kWh", - ), - "AtsContactorPosition": OncueSensor( - name="AtsContactorPosition", - display_name="Ats Contactor Position", - value="Source1", - display_value="Source1", - unit=None, - ), - "AtsSourcesAvailable": OncueSensor( - name="AtsSourcesAvailable", - display_name="Ats Sources Available", - value="Source1", - display_value="Source1", - unit=None, - ), - "Source1VoltageAverageLineToLine": OncueSensor( - name="Source1VoltageAverageLineToLine", - display_name="Source1 Voltage Average Line To Line", - value="253.5", - display_value="253.5 V", - unit="V", - ), - "Source2VoltageAverageLineToLine": OncueSensor( - name="Source2VoltageAverageLineToLine", - display_name="Source2 Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "IPAddress": OncueSensor( - name="IPAddress", - display_name="IP Address", - value="1.2.3.4:1026", - display_value="1.2.3.4:1026", - unit=None, - ), - "MacAddress": OncueSensor( - name="MacAddress", - display_name="Mac Address", - value="221157033710592", - display_value="221157033710592", - unit=None, - ), - "ConnectedServerIPAddress": OncueSensor( - name="ConnectedServerIPAddress", - display_name="Connected Server IP Address", - value="40.117.195.28", - display_value="40.117.195.28", - unit=None, - ), - "NetworkConnectionEstablished": OncueSensor( - name="NetworkConnectionEstablished", - display_name="Network Connection Established", - value="true", - display_value="True", - unit=None, - ), - "SerialNumber": OncueSensor( - name="SerialNumber", - display_name="Serial Number", - value="1073879692", - display_value="1073879692", - unit=None, - ), - }, - ) -} - - -MOCK_ASYNC_FETCH_ALL_OFFLINE_DEVICE = { - "456789": OncueDevice( - name="My Generator", - state="Off", - product_name="RDC 2.4", - hardware_version="319", - serial_number="SERIAL", - sensors={ - "Product": OncueSensor( - name="Product", - display_name="Controller Type", - value="RDC 2.4", - display_value="RDC 2.4", - unit=None, - ), - "FirmwareVersion": OncueSensor( - name="FirmwareVersion", - display_name="Current Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "LatestFirmware": OncueSensor( - name="LatestFirmware", - display_name="Latest Firmware", - value="2.0.6", - display_value="2.0.6", - unit=None, - ), - "EngineSpeed": OncueSensor( - name="EngineSpeed", - display_name="Engine Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineTargetSpeed": OncueSensor( - name="EngineTargetSpeed", - display_name="Engine Target Speed", - value="0", - display_value="0 R/min", - unit="R/min", - ), - "EngineOilPressure": OncueSensor( - name="EngineOilPressure", - display_name="Engine Oil Pressure", - value=0, - display_value="0 Psi", - unit="Psi", - ), - "EngineCoolantTemperature": OncueSensor( - name="EngineCoolantTemperature", - display_name="Engine Coolant Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "BatteryVoltage": OncueSensor( - name="BatteryVoltage", - display_name="Battery Voltage", - value="13.4", - display_value="13.4 V", - unit="V", - ), - "LubeOilTemperature": OncueSensor( - name="LubeOilTemperature", - display_name="Lube Oil Temperature", - value=32, - display_value="32 F", - unit="F", - ), - "GensetControllerTemperature": OncueSensor( - name="GensetControllerTemperature", - display_name="Generator Controller Temperature", - value=84.2, - display_value="84.2 F", - unit="F", - ), - "EngineCompartmentTemperature": OncueSensor( - name="EngineCompartmentTemperature", - display_name="Engine Compartment Temperature", - value=62.6, - display_value="62.6 F", - unit="F", - ), - "GeneratorTrueTotalPower": OncueSensor( - name="GeneratorTrueTotalPower", - display_name="Generator True Total Power", - value="0.0", - display_value="0.0 W", - unit="W", - ), - "GeneratorTruePercentOfRatedPower": OncueSensor( - name="GeneratorTruePercentOfRatedPower", - display_name="Generator True Percent Of Rated Power", - value="0", - display_value="0 %", - unit="%", - ), - "GeneratorVoltageAB": OncueSensor( - name="GeneratorVoltageAB", - display_name="Generator Voltage AB", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorVoltageAverageLineToLine": OncueSensor( - name="GeneratorVoltageAverageLineToLine", - display_name="Generator Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "GeneratorCurrentAverage": OncueSensor( - name="GeneratorCurrentAverage", - display_name="Generator Current Average", - value="0.0", - display_value="0.0 A", - unit="A", - ), - "GeneratorFrequency": OncueSensor( - name="GeneratorFrequency", - display_name="Generator Frequency", - value="0.0", - display_value="0.0 Hz", - unit="Hz", - ), - "GensetSerialNumber": OncueSensor( - name="GensetSerialNumber", - display_name="Generator Serial Number", - value="33FDGMFR0026", - display_value="33FDGMFR0026", - unit=None, - ), - "GensetState": OncueSensor( - name="GensetState", - display_name="Generator State", - value="Off", - display_value="Off", - unit=None, - ), - "GensetControllerSerialNumber": OncueSensor( - name="GensetControllerSerialNumber", - display_name="Generator Controller Serial Number", - value="-1", - display_value="-1", - unit=None, - ), - "GensetModelNumberSelect": OncueSensor( - name="GensetModelNumberSelect", - display_name="Genset Model Number Select", - value="38 RCLB", - display_value="38 RCLB", - unit=None, - ), - "GensetControllerClockTime": OncueSensor( - name="GensetControllerClockTime", - display_name="Generator Controller Clock Time", - value="2022-01-13 18:08:13", - display_value="2022-01-13 18:08:13", - unit=None, - ), - "GensetControllerTotalOperationTime": OncueSensor( - name="GensetControllerTotalOperationTime", - display_name="Generator Controller Total Operation Time", - value="16770.8", - display_value="16770.8 h", - unit="h", - ), - "EngineTotalRunTime": OncueSensor( - name="EngineTotalRunTime", - display_name="Engine Total Run Time", - value="28.1", - display_value="28.1 h", - unit="h", - ), - "EngineTotalRunTimeLoaded": OncueSensor( - name="EngineTotalRunTimeLoaded", - display_name="Engine Total Run Time Loaded", - value="5.5", - display_value="5.5 h", - unit="h", - ), - "EngineTotalNumberOfStarts": OncueSensor( - name="EngineTotalNumberOfStarts", - display_name="Engine Total Number Of Starts", - value="101", - display_value="101", - unit=None, - ), - "GensetTotalEnergy": OncueSensor( - name="GensetTotalEnergy", - display_name="Genset Total Energy", - value="1.2022309E7", - display_value="1.2022309E7 kWh", - unit="kWh", - ), - "AtsContactorPosition": OncueSensor( - name="AtsContactorPosition", - display_name="Ats Contactor Position", - value="Source1", - display_value="Source1", - unit=None, - ), - "AtsSourcesAvailable": OncueSensor( - name="AtsSourcesAvailable", - display_name="Ats Sources Available", - value="Source1", - display_value="Source1", - unit=None, - ), - "Source1VoltageAverageLineToLine": OncueSensor( - name="Source1VoltageAverageLineToLine", - display_name="Source1 Voltage Average Line To Line", - value="253.5", - display_value="253.5 V", - unit="V", - ), - "Source2VoltageAverageLineToLine": OncueSensor( - name="Source2VoltageAverageLineToLine", - display_name="Source2 Voltage Average Line To Line", - value="0.0", - display_value="0.0 V", - unit="V", - ), - "IPAddress": OncueSensor( - name="IPAddress", - display_name="IP Address", - value="1.2.3.4:1026", - display_value="1.2.3.4:1026", - unit=None, - ), - "MacAddress": OncueSensor( - name="MacAddress", - display_name="Mac Address", - value="--", - display_value="--", - unit=None, - ), - "ConnectedServerIPAddress": OncueSensor( - name="ConnectedServerIPAddress", - display_name="Connected Server IP Address", - value="40.117.195.28", - display_value="40.117.195.28", - unit=None, - ), - "NetworkConnectionEstablished": OncueSensor( - name="NetworkConnectionEstablished", - display_name="Network Connection Established", - value="true", - display_value="True", - unit=None, - ), - "SerialNumber": OncueSensor( - name="SerialNumber", - display_name="Serial Number", - value="1073879692", - display_value="1073879692", - unit=None, - ), - }, - ) -} - -MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE = { - "456789": OncueDevice( - name="My Generator", - state="Off", - product_name="RDC 2.4", - hardware_version="319", - serial_number="SERIAL", - sensors={ - "Product": OncueSensor( - name="Product", - display_name="Controller Type", - value="--", - display_value="RDC 2.4", - unit=None, - ), - "FirmwareVersion": OncueSensor( - name="FirmwareVersion", - display_name="Current Firmware", - value="--", - display_value="2.0.6", - unit=None, - ), - "LatestFirmware": OncueSensor( - name="LatestFirmware", - display_name="Latest Firmware", - value="--", - display_value="2.0.6", - unit=None, - ), - "EngineSpeed": OncueSensor( - name="EngineSpeed", - display_name="Engine Speed", - value="--", - display_value="0 R/min", - unit="R/min", - ), - "EngineTargetSpeed": OncueSensor( - name="EngineTargetSpeed", - display_name="Engine Target Speed", - value="--", - display_value="0 R/min", - unit="R/min", - ), - "EngineOilPressure": OncueSensor( - name="EngineOilPressure", - display_name="Engine Oil Pressure", - value="--", - display_value="0 Psi", - unit="Psi", - ), - "EngineCoolantTemperature": OncueSensor( - name="EngineCoolantTemperature", - display_name="Engine Coolant Temperature", - value="--", - display_value="32 F", - unit="F", - ), - "BatteryVoltage": OncueSensor( - name="BatteryVoltage", - display_name="Battery Voltage", - value="0.0", - display_value="13.4 V", - unit="V", - ), - "LubeOilTemperature": OncueSensor( - name="LubeOilTemperature", - display_name="Lube Oil Temperature", - value="--", - display_value="32 F", - unit="F", - ), - "GensetControllerTemperature": OncueSensor( - name="GensetControllerTemperature", - display_name="Generator Controller Temperature", - value="--", - display_value="84.2 F", - unit="F", - ), - "EngineCompartmentTemperature": OncueSensor( - name="EngineCompartmentTemperature", - display_name="Engine Compartment Temperature", - value="--", - display_value="62.6 F", - unit="F", - ), - "GeneratorTrueTotalPower": OncueSensor( - name="GeneratorTrueTotalPower", - display_name="Generator True Total Power", - value="--", - display_value="0.0 W", - unit="W", - ), - "GeneratorTruePercentOfRatedPower": OncueSensor( - name="GeneratorTruePercentOfRatedPower", - display_name="Generator True Percent Of Rated Power", - value="--", - display_value="0 %", - unit="%", - ), - "GeneratorVoltageAB": OncueSensor( - name="GeneratorVoltageAB", - display_name="Generator Voltage AB", - value="--", - display_value="0.0 V", - unit="V", - ), - "GeneratorVoltageAverageLineToLine": OncueSensor( - name="GeneratorVoltageAverageLineToLine", - display_name="Generator Voltage Average Line To Line", - value="--", - display_value="0.0 V", - unit="V", - ), - "GeneratorCurrentAverage": OncueSensor( - name="GeneratorCurrentAverage", - display_name="Generator Current Average", - value="--", - display_value="0.0 A", - unit="A", - ), - "GeneratorFrequency": OncueSensor( - name="GeneratorFrequency", - display_name="Generator Frequency", - value="--", - display_value="0.0 Hz", - unit="Hz", - ), - "GensetSerialNumber": OncueSensor( - name="GensetSerialNumber", - display_name="Generator Serial Number", - value="--", - display_value="33FDGMFR0026", - unit=None, - ), - "GensetState": OncueSensor( - name="GensetState", - display_name="Generator State", - value="--", - display_value="Off", - unit=None, - ), - "GensetControllerSerialNumber": OncueSensor( - name="GensetControllerSerialNumber", - display_name="Generator Controller Serial Number", - value="--", - display_value="-1", - unit=None, - ), - "GensetModelNumberSelect": OncueSensor( - name="GensetModelNumberSelect", - display_name="Genset Model Number Select", - value="--", - display_value="38 RCLB", - unit=None, - ), - "GensetControllerClockTime": OncueSensor( - name="GensetControllerClockTime", - display_name="Generator Controller Clock Time", - value="--", - display_value="2022-01-13 18:08:13", - unit=None, - ), - "GensetControllerTotalOperationTime": OncueSensor( - name="GensetControllerTotalOperationTime", - display_name="Generator Controller Total Operation Time", - value="--", - display_value="16770.8 h", - unit="h", - ), - "EngineTotalRunTime": OncueSensor( - name="EngineTotalRunTime", - display_name="Engine Total Run Time", - value="--", - display_value="28.1 h", - unit="h", - ), - "EngineTotalRunTimeLoaded": OncueSensor( - name="EngineTotalRunTimeLoaded", - display_name="Engine Total Run Time Loaded", - value="--", - display_value="5.5 h", - unit="h", - ), - "EngineTotalNumberOfStarts": OncueSensor( - name="EngineTotalNumberOfStarts", - display_name="Engine Total Number Of Starts", - value="--", - display_value="101", - unit=None, - ), - "GensetTotalEnergy": OncueSensor( - name="GensetTotalEnergy", - display_name="Genset Total Energy", - value="--", - display_value="1.2022309E7 kWh", - unit="kWh", - ), - "AtsContactorPosition": OncueSensor( - name="AtsContactorPosition", - display_name="Ats Contactor Position", - value="--", - display_value="Source1", - unit=None, - ), - "AtsSourcesAvailable": OncueSensor( - name="AtsSourcesAvailable", - display_name="Ats Sources Available", - value="--", - display_value="Source1", - unit=None, - ), - "Source1VoltageAverageLineToLine": OncueSensor( - name="Source1VoltageAverageLineToLine", - display_name="Source1 Voltage Average Line To Line", - value="--", - display_value="253.5 V", - unit="V", - ), - "Source2VoltageAverageLineToLine": OncueSensor( - name="Source2VoltageAverageLineToLine", - display_name="Source2 Voltage Average Line To Line", - value="--", - display_value="0.0 V", - unit="V", - ), - "IPAddress": OncueSensor( - name="IPAddress", - display_name="IP Address", - value="--", - display_value="1.2.3.4:1026", - unit=None, - ), - "MacAddress": OncueSensor( - name="MacAddress", - display_name="Mac Address", - value="--", - display_value="--", - unit=None, - ), - "ConnectedServerIPAddress": OncueSensor( - name="ConnectedServerIPAddress", - display_name="Connected Server IP Address", - value="--", - display_value="40.117.195.28", - unit=None, - ), - "NetworkConnectionEstablished": OncueSensor( - name="NetworkConnectionEstablished", - display_name="Network Connection Established", - value="--", - display_value="True", - unit=None, - ), - "SerialNumber": OncueSensor( - name="SerialNumber", - display_name="Serial Number", - value="--", - display_value="1073879692", - unit=None, - ), - }, - ) -} - - -def _patch_login_and_data(): - @contextmanager - def _patcher(): - with ( - patch( - "homeassistant.components.oncue.Oncue.async_login", - ), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_offline_device(): - @contextmanager - def _patcher(): - with ( - patch( - "homeassistant.components.oncue.Oncue.async_login", - ), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL_OFFLINE_DEVICE, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_unavailable(): - @contextmanager - def _patcher(): - with ( - patch("homeassistant.components.oncue.Oncue.async_login"), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_unavailable_device(): - @contextmanager - def _patcher(): - with ( - patch("homeassistant.components.oncue.Oncue.async_login"), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - return_value=MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE, - ), - ): - yield - - return _patcher() - - -def _patch_login_and_data_auth_failure(): - @contextmanager - def _patcher(): - with ( - patch( - "homeassistant.components.oncue.Oncue.async_login", - side_effect=LoginFailedException, - ), - patch( - "homeassistant.components.oncue.Oncue.async_fetch_all", - side_effect=LoginFailedException, - ), - ): - yield - - return _patcher() diff --git a/tests/components/oncue/test_binary_sensor.py b/tests/components/oncue/test_binary_sensor.py deleted file mode 100644 index d9fce699d39..00000000000 --- a/tests/components/oncue/test_binary_sensor.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tests for the oncue binary_sensor.""" - -from __future__ import annotations - -from homeassistant.components import oncue -from homeassistant.components.oncue.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from . import _patch_login_and_data, _patch_login_and_data_unavailable - -from tests.common import MockConfigEntry - - -async def test_binary_sensors(hass: HomeAssistant) -> None: - """Test that the binary sensors are setup with the expected values.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with _patch_login_and_data(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - assert len(hass.states.async_all("binary_sensor")) == 1 - assert ( - hass.states.get( - "binary_sensor.my_generator_network_connection_established" - ).state - == STATE_ON - ) - - -async def test_binary_sensors_not_unavailable(hass: HomeAssistant) -> None: - """Test the network connection established binary sensor is available when connection status is false.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with _patch_login_and_data_unavailable(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - assert len(hass.states.async_all("binary_sensor")) == 1 - assert ( - hass.states.get( - "binary_sensor.my_generator_network_connection_established" - ).state - == STATE_OFF - ) diff --git a/tests/components/oncue/test_config_flow.py b/tests/components/oncue/test_config_flow.py deleted file mode 100644 index 3907242e26c..00000000000 --- a/tests/components/oncue/test_config_flow.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Test the Oncue config flow.""" - -from unittest.mock import patch - -from aiooncue import LoginFailedException - -from homeassistant import config_entries -from homeassistant.components.oncue.const import DOMAIN -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 - - -async def test_form(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 result["errors"] == {} - - with ( - patch("homeassistant.components.oncue.config_flow.Oncue.async_login"), - patch( - "homeassistant.components.oncue.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "TEST-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "TEST-username", - "password": "test-password", - } - assert mock_setup_entry.call_count == 1 - - -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": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.oncue.config_flow.Oncue.async_login", - side_effect=LoginFailedException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> 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.oncue.config_flow.Oncue.async_login", - side_effect=TimeoutError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_exception(hass: HomeAssistant) -> None: - """Test we handle unknown exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.oncue.config_flow.Oncue.async_login", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_already_configured(hass: HomeAssistant) -> None: - """Test already configured.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "username": "TEST-username", - "password": "test-password", - }, - unique_id="test-username", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch("homeassistant.components.oncue.config_flow.Oncue.async_login"): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" - - -async def test_reauth(hass: HomeAssistant) -> None: - """Test reauth flow.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: "any", - CONF_PASSWORD: "old", - }, - ) - config_entry.add_to_hass(hass) - config_entry.async_start_reauth(hass) - await hass.async_block_till_done() - flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) - assert len(flows) == 1 - flow = flows[0] - - with patch( - "homeassistant.components.oncue.config_flow.Oncue.async_login", - side_effect=LoginFailedException, - ): - result2 = await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"password": "invalid_auth"} - - with ( - patch("homeassistant.components.oncue.config_flow.Oncue.async_login"), - patch( - "homeassistant.components.oncue.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert config_entry.data[CONF_PASSWORD] == "test-password" - assert mock_setup_entry.call_count == 1 diff --git a/tests/components/oncue/test_init.py b/tests/components/oncue/test_init.py index cf93b51dee1..204f9eb9ecf 100644 --- a/tests/components/oncue/test_init.py +++ b/tests/components/oncue/test_init.py @@ -1,94 +1,79 @@ -"""Tests for the oncue component.""" +"""Tests for the Oncue integration.""" -from __future__ import annotations - -from datetime import timedelta -from unittest.mock import patch - -from aiooncue import LoginFailedException - -from homeassistant.components import oncue -from homeassistant.components.oncue.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.oncue import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util +from homeassistant.helpers import issue_registry as ir -from . import _patch_login_and_data, _patch_login_and_data_auth_failure - -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry -async def test_config_entry_reload(hass: HomeAssistant) -> None: - """Test that a config entry can be reloaded.""" - config_entry = MockConfigEntry( +async def test_oncue_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Oncue configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with _patch_login_and_data(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - await hass.config_entries.async_unload(config_entry.entry_id) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED + assert config_entry_1.state is ConfigEntryState.LOADED - -async def test_config_entry_login_error(hass: HomeAssistant) -> None: - """Test that a config entry is failed on login error.""" - config_entry = MockConfigEntry( + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.oncue.Oncue.async_login", - side_effect=LoginFailedException, - ): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_ERROR + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) -async def test_config_entry_retry_later(hass: HomeAssistant) -> None: - """Test that a config entry retry on connection error.""" - config_entry = MockConfigEntry( + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.oncue.Oncue.async_login", - side_effect=TimeoutError, - ): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + assert config_entry_3.state is ConfigEntryState.NOT_LOADED -async def test_late_auth_failure(hass: HomeAssistant) -> None: - """Test auth fails after already setup.""" - config_entry = MockConfigEntry( + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", ) - config_entry.add_to_hass(hass) - with _patch_login_and_data(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() - with _patch_login_and_data_auth_failure(): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + assert config_entry_4.state is ConfigEntryState.NOT_LOADED - flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) - assert len(flows) == 1 - flow = flows[0] - assert flow["context"]["source"] == "reauth" + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/oncue/test_sensor.py b/tests/components/oncue/test_sensor.py deleted file mode 100644 index e5f55d54062..00000000000 --- a/tests/components/oncue/test_sensor.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Tests for the oncue sensor.""" - -from __future__ import annotations - -import pytest - -from homeassistant.components import oncue -from homeassistant.components.oncue.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE -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 ( - _patch_login_and_data, - _patch_login_and_data_offline_device, - _patch_login_and_data_unavailable, - _patch_login_and_data_unavailable_device, -) - -from tests.common import MockConfigEntry - - -@pytest.mark.parametrize( - ("patcher", "connections"), - [ - (_patch_login_and_data, {("mac", "c9:24:22:6f:14:00")}), - (_patch_login_and_data_offline_device, set()), - ], -) -async def test_sensors( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - patcher, - connections, -) -> None: - """Test that the sensors are setup with the expected values.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with patcher(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - ent = entity_registry.async_get("sensor.my_generator_latest_firmware") - dev = device_registry.async_get(ent.device_id) - assert dev.connections == connections - - assert len(hass.states.async_all("sensor")) == 25 - assert hass.states.get("sensor.my_generator_latest_firmware").state == "2.0.6" - - assert hass.states.get("sensor.my_generator_engine_speed").state == "0" - - assert hass.states.get("sensor.my_generator_engine_oil_pressure").state == "0" - - assert ( - hass.states.get("sensor.my_generator_engine_coolant_temperature").state == "0" - ) - - assert hass.states.get("sensor.my_generator_battery_voltage").state == "13.4" - - assert hass.states.get("sensor.my_generator_lube_oil_temperature").state == "0" - - assert ( - hass.states.get("sensor.my_generator_generator_controller_temperature").state - == "29.0" - ) - - assert ( - hass.states.get("sensor.my_generator_engine_compartment_temperature").state - == "17.0" - ) - - assert ( - hass.states.get("sensor.my_generator_generator_true_total_power").state == "0.0" - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_true_percent_of_rated_power" - ).state - == "0" - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_voltage_average_line_to_line" - ).state - == "0.0" - ) - - assert hass.states.get("sensor.my_generator_generator_frequency").state == "0.0" - - assert hass.states.get("sensor.my_generator_generator_state").state == "Off" - - assert ( - hass.states.get( - "sensor.my_generator_generator_controller_total_operation_time" - ).state - == "16770.8" - ) - - assert hass.states.get("sensor.my_generator_engine_total_run_time").state == "28.1" - - assert ( - hass.states.get("sensor.my_generator_ats_contactor_position").state == "Source1" - ) - - assert hass.states.get("sensor.my_generator_ip_address").state == "1.2.3.4:1026" - - assert ( - hass.states.get("sensor.my_generator_connected_server_ip_address").state - == "40.117.195.28" - ) - - assert hass.states.get("sensor.my_generator_engine_target_speed").state == "0" - - assert ( - hass.states.get("sensor.my_generator_engine_total_run_time_loaded").state - == "5.5" - ) - - assert ( - hass.states.get( - "sensor.my_generator_source1_voltage_average_line_to_line" - ).state - == "253.5" - ) - - assert ( - hass.states.get( - "sensor.my_generator_source2_voltage_average_line_to_line" - ).state - == "0.0" - ) - - assert ( - hass.states.get("sensor.my_generator_genset_total_energy").state - == "1.2022309E7" - ) - assert ( - hass.states.get("sensor.my_generator_engine_total_number_of_starts").state - == "101" - ) - assert ( - hass.states.get("sensor.my_generator_generator_current_average").state == "0.0" - ) - - -@pytest.mark.parametrize( - ("patcher", "connections"), - [ - (_patch_login_and_data_unavailable_device, set()), - (_patch_login_and_data_unavailable, {("mac", "c9:24:22:6f:14:00")}), - ], -) -async def test_sensors_unavailable(hass: HomeAssistant, patcher, connections) -> None: - """Test that the sensors are unavailable.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, - unique_id="any", - ) - config_entry.add_to_hass(hass) - with patcher(): - await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - assert len(hass.states.async_all("sensor")) == 25 - assert ( - hass.states.get("sensor.my_generator_latest_firmware").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_speed").state == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_oil_pressure").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_coolant_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_battery_voltage").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_lube_oil_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_controller_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_compartment_temperature").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_true_total_power").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_true_percent_of_rated_power" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_voltage_average_line_to_line" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_frequency").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_generator_state").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_generator_controller_total_operation_time" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_total_run_time").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_ats_contactor_position").state - == STATE_UNAVAILABLE - ) - - assert hass.states.get("sensor.my_generator_ip_address").state == STATE_UNAVAILABLE - - assert ( - hass.states.get("sensor.my_generator_connected_server_ip_address").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_target_speed").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_engine_total_run_time_loaded").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_source1_voltage_average_line_to_line" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get( - "sensor.my_generator_source2_voltage_average_line_to_line" - ).state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_genset_total_energy").state - == STATE_UNAVAILABLE - ) - assert ( - hass.states.get("sensor.my_generator_engine_total_number_of_starts").state - == STATE_UNAVAILABLE - ) - assert ( - hass.states.get("sensor.my_generator_generator_current_average").state - == STATE_UNAVAILABLE - ) - - assert ( - hass.states.get("sensor.my_generator_battery_voltage").state - == STATE_UNAVAILABLE - ) diff --git a/tests/components/ondilo_ico/conftest.py b/tests/components/ondilo_ico/conftest.py index d35e5ac0003..891f60eb549 100644 --- a/tests/components/ondilo_ico/conftest.py +++ b/tests/components/ondilo_ico/conftest.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant.components.ondilo_ico.const import DOMAIN +from homeassistant.util.json import JsonArrayType from tests.common import ( MockConfigEntry, @@ -71,7 +72,7 @@ def ico_details2() -> dict[str, Any]: @pytest.fixture(scope="package") -def last_measures() -> list[dict[str, Any]]: +def last_measures() -> JsonArrayType: """Pool measurements.""" return load_json_array_fixture("last_measures.json", DOMAIN) diff --git a/tests/components/ondilo_ico/snapshots/test_sensor.ambr b/tests/components/ondilo_ico/snapshots/test_sensor.ambr index 84a2d3da4cb..7df2bfc22ce 100644 --- a/tests/components/ondilo_ico/snapshots/test_sensor.ambr +++ b/tests/components/ondilo_ico/snapshots/test_sensor.ambr @@ -13,7 +13,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_1_battery', 'has_entity_name': True, 'hidden_by': None, @@ -167,7 +167,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_1_rssi', 'has_entity_name': True, 'hidden_by': None, @@ -372,7 +372,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_2_battery', 'has_entity_name': True, 'hidden_by': None, @@ -526,7 +526,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_2_rssi', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/ondilo_ico/test_config_flow.py b/tests/components/ondilo_ico/test_config_flow.py index deab2a8e0b9..19407cecb9d 100644 --- a/tests/components/ondilo_ico/test_config_flow.py +++ b/tests/components/ondilo_ico/test_config_flow.py @@ -4,15 +4,13 @@ from unittest.mock import patch import pytest -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.ondilo_ico.const import ( DOMAIN, OAUTH2_AUTHORIZE, - OAUTH2_CLIENTID, - OAUTH2_CLIENTSECRET, + OAUTH2_CLIENT_ID as CLIENT_ID, OAUTH2_TOKEN, ) -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 @@ -21,13 +19,12 @@ from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -CLIENT_ID = OAUTH2_CLIENTID -CLIENT_SECRET = OAUTH2_CLIENTSECRET - -async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: +async def test_abort_if_existing_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -43,15 +40,6 @@ async def test_full_flow( aioclient_mock: AiohttpClientMocker, ) -> None: """Check full flow.""" - assert await setup.async_setup_component( - hass, - DOMAIN, - { - DOMAIN: {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET}, - "http": {"base_url": "https://example.com"}, - }, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/ondilo_ico/test_init.py b/tests/components/ondilo_ico/test_init.py index 58b1e27987d..d93c5ce4df6 100644 --- a/tests/components/ondilo_ico/test_init.py +++ b/tests/components/ondilo_ico/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from ondilo import OndiloError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant diff --git a/tests/components/ondilo_ico/test_sensor.py b/tests/components/ondilo_ico/test_sensor.py index 0043d22f6c0..8785ca39880 100644 --- a/tests/components/ondilo_ico/test_sensor.py +++ b/tests/components/ondilo_ico/test_sensor.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import MagicMock, patch from ondilo import OndiloError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -45,7 +45,9 @@ async def test_no_ico_for_one_pool( # Only the second pool is created assert len(hass.states.async_all()) == 7 assert hass.states.get("sensor.pool_1_temperature") is None - assert hass.states.get("sensor.pool_2_rssi").state == next( + state = hass.states.get("sensor.pool_2_rssi") + assert state is not None + assert state.state == next( str(item["value"]) for item in last_measures if item["data_type"] == "rssi" ) diff --git a/tests/components/onedrive/snapshots/test_diagnostics.ambr b/tests/components/onedrive/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..827b9397313 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_diagnostics.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'auth_implementation': 'onedrive', + 'folder_id': 'my_folder_id', + 'folder_name': 'name', + 'token': '**REDACTED**', + }), + 'drive': dict({ + 'drive_type': 'personal', + 'id': 'mock_drive_id', + 'name': 'My Drive', + 'owner': dict({ + 'application': None, + 'user': dict({ + 'display_name': '**REDACTED**', + 'email': '**REDACTED**', + 'id': 'id', + }), + }), + 'quota': dict({ + 'deleted': 5, + 'remaining': 805306368, + 'state': 'nearing', + 'total': 5368709120, + 'used': 4250000000, + }), + }), + }) +# --- diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index a81eb03a51c..f3f2fbdad40 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -75,7 +75,6 @@ async def test_agents_info( async def test_agents_list_backups( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_config_entry: MockConfigEntry, ) -> None: """Test agent list backups.""" @@ -105,6 +104,22 @@ async def test_agents_list_backups( ] +async def test_agents_list_backups_with_download_failure( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_onedrive_client: MagicMock, +) -> None: + """Test agent list backups still works if one of the items fails to download.""" + mock_onedrive_client.download_drive_item.side_effect = OneDriveException("test") + 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"]["agent_errors"] == {} + assert response["result"]["backups"] == [] + + async def test_agents_get_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -231,6 +246,78 @@ async def test_agents_upload_corrupt_upload( assert "Hash validation failed, backup file might be corrupt" in caplog.text +async def test_agents_upload_metadata_upload_failed( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test metadata upload fails.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + mock_onedrive_client.upload_file.side_effect = OneDriveException("test") + + 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( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_large_file_upload_client.assert_called_once() + mock_onedrive_client.delete_drive_item.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 0 + + +async def test_agents_upload_metadata_metadata_failed( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test metadata upload on file description update.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + mock_onedrive_client.update_drive_item.side_effect = OneDriveException("test") + + 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( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_large_file_upload_client.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 1 + assert mock_onedrive_client.delete_drive_item.call_count == 2 + + async def test_agents_download( hass_client: ClientSessionGenerator, mock_onedrive_client: MagicMock, diff --git a/tests/components/onedrive/test_diagnostics.py b/tests/components/onedrive/test_diagnostics.py new file mode 100644 index 00000000000..9be8455f287 --- /dev/null +++ b/tests/components/onedrive/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for the diagnostics data provided by the OneDrive integration.""" + +from syrupy.assertion 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( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + 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 + ) diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index c7765e0a7f8..af12f66b60e 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -13,7 +13,7 @@ from onedrive_personal_sdk.exceptions import ( ) from onedrive_personal_sdk.models.items import AppRoot, Drive, File, Folder, ItemUpdate import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.onedrive.const import ( CONF_FOLDER_ID, @@ -236,7 +236,6 @@ async def test_data_cap_issues( async def test_1_1_to_1_2_migration( hass: HomeAssistant, - mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, mock_folder: Folder, ) -> None: @@ -251,12 +250,34 @@ async def test_1_1_to_1_2_migration( }, ) + await setup_integration(hass, old_config_entry) + assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + assert old_config_entry.minor_version == 2 + + +async def test_1_1_to_1_2_migration_failure( + hass: HomeAssistant, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from 1.1 to 1.2 failure.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + ) + # will always 404 after migration, because of dummy id mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") await setup_integration(hass, old_config_entry) - assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id - assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR + assert old_config_entry.minor_version == 1 async def test_migration_guard_against_major_downgrade( diff --git a/tests/components/onedrive/test_sensor.py b/tests/components/onedrive/test_sensor.py index ea9d93a9a7b..18e8ad85ac2 100644 --- a/tests/components/onedrive/test_sensor.py +++ b/tests/components/onedrive/test_sensor.py @@ -9,7 +9,7 @@ from onedrive_personal_sdk.const import DriveType from onedrive_personal_sdk.exceptions import HttpRequestException from onedrive_personal_sdk.models.items import Drive import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant diff --git a/tests/components/onedrive/test_services.py b/tests/components/onedrive/test_services.py new file mode 100644 index 00000000000..31d2d932cd0 --- /dev/null +++ b/tests/components/onedrive/test_services.py @@ -0,0 +1,280 @@ +"""Tests for OneDrive services.""" + +from collections.abc import Generator +from dataclasses import dataclass +import re +from typing import Any, cast +from unittest.mock import MagicMock, Mock, patch + +from onedrive_personal_sdk.exceptions import OneDriveException +import pytest + +from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.components.onedrive.services import ( + CONF_CONFIG_ENTRY_ID, + CONF_DESTINATION_FOLDER, + UPLOAD_SERVICE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_FILENAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_FILENAME = "doorbell_snapshot.jpg" +DESINATION_FOLDER = "TestFolder" + + +@dataclass +class MockUploadFile: + """Dataclass used to configure the test with a fake file behavior.""" + + content: bytes = b"image bytes" + exists: bool = True + is_allowed_path: bool = True + size: int | None = None + + +@pytest.fixture(name="upload_file") +def upload_file_fixture() -> MockUploadFile: + """Fixture to set up test configuration with a fake file.""" + return MockUploadFile() + + +@pytest.fixture(autouse=True) +def mock_upload_file( + hass: HomeAssistant, upload_file: MockUploadFile +) -> Generator[None]: + """Fixture that mocks out the file calls using the FakeFile fixture.""" + with ( + patch( + "homeassistant.components.onedrive.services.Path.read_bytes", + return_value=upload_file.content, + ), + patch( + "homeassistant.components.onedrive.services.Path.exists", + return_value=upload_file.exists, + ), + patch.object( + hass.config, "is_allowed_path", return_value=upload_file.is_allowed_path + ), + patch("pathlib.Path.stat") as mock_stat, + ): + mock_stat.return_value = Mock() + mock_stat.return_value.st_size = ( + upload_file.size if upload_file.size else len(upload_file.content) + ) + yield + + +async def test_upload_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service call to upload content.""" + await setup_integration(hass, mock_config_entry) + + assert hass.services.has_service(DOMAIN, "upload") + + response = await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + assert response + assert response["files"] + assert cast(list[dict[str, Any]], response["files"])[0]["id"] == "id" + + +async def test_upload_service_no_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service call to upload content without response.""" + await setup_integration(hass, mock_config_entry) + + assert hass.services.has_service(DOMAIN, "upload") + + response = await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + ) + + assert response is None + + +async def test_upload_service_config_entry_not_found( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a config entry that does not exist.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id", + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +async def test_config_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a config entry that is not loaded.""" + await setup_integration(hass, mock_config_entry) + 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 + + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.unique_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(is_allowed_path=False)]) +async def test_path_is_not_allowed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that is not allowed.""" + await setup_integration(hass, mock_config_entry) + with ( + pytest.raises(HomeAssistantError, match="no access to path"), + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(exists=False)]) +async def test_filename_does_not_exist( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that does not exist.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises(HomeAssistantError, match="does not exist"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +async def test_upload_service_fails_upload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test service call to upload content.""" + await setup_integration(hass, mock_config_entry) + mock_onedrive_client.upload_file.side_effect = OneDriveException("error") + + with pytest.raises(HomeAssistantError, match="Failed to upload"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(size=260 * 1024 * 1024)]) +async def test_upload_size_limit( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that does not exist.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises( + HomeAssistantError, + match=re.escape(f"`{TEST_FILENAME}` is too large (272629760 > 262144000)"), + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +async def test_create_album_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test service call when folder creation fails.""" + await setup_integration(hass, mock_config_entry) + assert hass.services.has_service(DOMAIN, "upload") + + mock_onedrive_client.create_folder.side_effect = OneDriveException() + + with pytest.raises(HomeAssistantError, match="Failed to create folder"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 28186503ead..92a4a34e8fb 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -1,12 +1,10 @@ """Test Onkyo config flow.""" -from typing import Any from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components.onkyo import InputSource from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, @@ -536,89 +534,6 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None: assert config_entry.unique_id == old_unique_id -@pytest.mark.parametrize( - ("user_input", "exception", "error"), - [ - ( - # No host, and thus no host reachable - { - CONF_HOST: None, - "receiver_max_volume": 100, - "max_volume": 100, - "sources": {}, - }, - None, - "cannot_connect", - ), - ( - # No host, and connection exception - { - CONF_HOST: None, - "receiver_max_volume": 100, - "max_volume": 100, - "sources": {}, - }, - Exception(), - "cannot_connect", - ), - ], -) -async def test_import_fail( - hass: HomeAssistant, - user_input: dict[str, Any], - exception: Exception, - error: str, -) -> None: - """Test import flow failed.""" - - 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 - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == error - - -async def test_import_success( - hass: HomeAssistant, -) -> None: - """Test import flow succeeded.""" - info = create_receiver_info(1) - - user_input = { - CONF_HOST: info.host, - "receiver_max_volume": 80, - "max_volume": 110, - "sources": { - InputSource("00"): "Auxiliary", - InputSource("01"): "Video", - }, - "info": info, - } - - import_result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=user_input - ) - await hass.async_block_till_done() - - assert import_result["type"] is FlowResultType.CREATE_ENTRY - assert import_result["data"] == {"host": "host 1"} - assert import_result["options"] == { - "volume_resolution": 80, - "max_volume": 100, - "input_sources": { - "00": "Auxiliary", - "01": "Video", - }, - "listening_modes": {}, - } - - @pytest.mark.parametrize( "ignore_missing_translations", [ diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index 8a86538b977..868624fb2e4 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -123,7 +123,7 @@ def setup_mock_onvif_camera( mock_onvif_camera.side_effect = mock_constructor -def setup_mock_device(mock_device, capabilities=None): +def setup_mock_device(mock_device, capabilities=None, profiles=None): """Prepare mock ONVIFDevice.""" mock_device.async_setup = AsyncMock(return_value=True) mock_device.port = 80 @@ -145,7 +145,7 @@ def setup_mock_device(mock_device, capabilities=None): ptz=None, video_source_token=None, ) - mock_device.profiles = [profile1] + mock_device.profiles = profiles or [profile1] mock_device.events = MagicMock( webhook_manager=MagicMock(state=WebHookManagerState.STARTED), pullpoint_manager=MagicMock(state=PullPointManagerState.PAUSED), diff --git a/tests/components/onvif/test_diagnostics.py b/tests/components/onvif/test_diagnostics.py index ce8febe2341..ca2ba8e8c74 100644 --- a/tests/components/onvif/test_diagnostics.py +++ b/tests/components/onvif/test_diagnostics.py @@ -1,6 +1,6 @@ """Test ONVIF diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/onvif/test_init.py b/tests/components/onvif/test_init.py new file mode 100644 index 00000000000..c176bdcc112 --- /dev/null +++ b/tests/components/onvif/test_init.py @@ -0,0 +1,102 @@ +"""Tests for the ONVIF integration __init__ module.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MAC, setup_mock_device + +from tests.common import MockConfigEntry + + +@pytest.mark.asyncio +async def test_migrate_camera_entities_unique_ids(hass: HomeAssistant) -> None: + """Test that camera entities unique ids get migrated properly.""" + config_entry = MockConfigEntry(domain="onvif", unique_id=MAC) + config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + + entity_with_only_mac = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=MAC, + config_entry=config_entry, + ) + entity_with_index = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}_1", + config_entry=config_entry, + ) + # This one should not be migrated (different domain) + entity_sensor = entity_registry.async_get_or_create( + domain="sensor", + platform="onvif", + unique_id=MAC, + config_entry=config_entry, + ) + # This one should not be migrated (already migrated) + entity_migrated = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}#profile_token_2", + config_entry=config_entry, + ) + # Unparsable index + entity_unparsable_index = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}_a", + config_entry=config_entry, + ) + # Unexisting index + entity_unexisting_index = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}_9", + config_entry=config_entry, + ) + + with patch("homeassistant.components.onvif.ONVIFDevice") as mock_device: + setup_mock_device( + mock_device, + capabilities=None, + profiles=[ + MagicMock(token="profile_token_0"), + MagicMock(token="profile_token_1"), + MagicMock(token="profile_token_2"), + ], + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_with_only_mac = entity_registry.async_get(entity_with_only_mac.entity_id) + entity_with_index = entity_registry.async_get(entity_with_index.entity_id) + entity_sensor = entity_registry.async_get(entity_sensor.entity_id) + entity_migrated = entity_registry.async_get(entity_migrated.entity_id) + + assert entity_with_only_mac is not None + assert entity_with_only_mac.unique_id == f"{MAC}#profile_token_0" + + assert entity_with_index is not None + assert entity_with_index.unique_id == f"{MAC}#profile_token_1" + + # Make sure the sensor entity is unchanged + assert entity_sensor is not None + assert entity_sensor.unique_id == MAC + + # Make sure the already migrated entity is unchanged + assert entity_migrated is not None + assert entity_migrated.unique_id == f"{MAC}#profile_token_2" + + # Make sure the unparsable index entity is unchanged + assert entity_unparsable_index is not None + assert entity_unparsable_index.unique_id == f"{MAC}_a" + + # Make sure the unexisting index entity is unchanged + assert entity_unexisting_index is not None + assert entity_unexisting_index.unique_id == f"{MAC}_9" diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py index 4f7e10abae6..8448a6e8195 100644 --- a/tests/components/onvif/test_parsers.py +++ b/tests/components/onvif/test_parsers.py @@ -5,6 +5,7 @@ import os import onvif import onvif.settings +import pytest from zeep import Client from zeep.transports import Transport @@ -732,25 +733,24 @@ async def test_tapo_intrusion(hass: HomeAssistant) -> None: 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 + with pytest.raises(AttributeError, match="SimpleItem"): + 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", + }, + } + ) async def test_tapo_unknown_type(hass: HomeAssistant) -> None: @@ -789,3 +789,93 @@ async def test_tapo_unknown_type(hass: HomeAssistant) -> None: ) assert event is None + + +async def test_reolink_package(hass: HomeAssistant) -> None: + """Tests reolink package event.""" + event = await get_event( + { + "SubscriptionReference": None, + "Topic": { + "_value_1": "tns1:RuleEngine/MyRuleDetector/Package", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": None, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [{"Name": "Source", "Value": "000"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "State", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 3, 12, 9, 54, 27, tzinfo=datetime.UTC + ), + "PropertyOperation": "Initialized", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Package Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "occupancy" + assert event.value + assert event.uid == (f"{TEST_UID}_tns1:RuleEngine/MyRuleDetector/Package_000") + + +async def test_hikvision_alarm(hass: HomeAssistant) -> None: + """Tests hikvision camera alarm event.""" + event = await get_event( + { + "SubscriptionReference": None, + "Topic": { + "_value_1": "tns1:Device/Trigger/tnshik:AlarmIn", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": None, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [{"Name": "AlarmInToken", "Value": "AlarmIn_1"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "State", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 3, 13, 22, 57, 26, tzinfo=datetime.UTC + ), + "PropertyOperation": "Initialized", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Motion Alarm" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == (f"{TEST_UID}_tns1:Device/Trigger/tnshik:AlarmIn_AlarmIn_1") diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 77c28de2773..0f874969aff 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -17,13 +17,6 @@ }), 'tool_name': 'test_tool', }), - dict({ - 'id': 'call_call_2', - 'tool_args': dict({ - 'param1': 'call2', - }), - 'tool_name': 'test_tool', - }), ]), }), dict({ @@ -33,6 +26,20 @@ 'tool_name': 'test_tool', 'tool_result': 'value1', }), + dict({ + 'agent_id': 'conversation.openai', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'call_call_2', + 'tool_args': dict({ + 'param1': 'call2', + }), + 'tool_name': 'test_tool', + }), + ]), + }), dict({ 'agent_id': 'conversation.openai', 'role': 'tool_result', @@ -48,3 +55,38 @@ }), ]) # --- +# name: test_function_call_without_reasoning + list([ + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.openai', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.openai', + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.openai', + 'content': 'Cool', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 90a08471f39..9cf27b4f147 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -1,9 +1,10 @@ """Test the OpenAI Conversation config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from httpx import Response +import httpx from openai import APIConnectionError, AuthenticationError, BadRequestError +from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText import pytest from homeassistant import config_entries @@ -16,6 +17,13 @@ from homeassistant.components.openai_conversation.const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, @@ -103,7 +111,7 @@ async def test_options_unsupported_model( CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_CHAT_MODEL: "o1-mini", - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], }, ) await hass.async_block_till_done() @@ -117,13 +125,17 @@ async def test_options_unsupported_model( (APIConnectionError(request=None), "cannot_connect"), ( AuthenticationError( - response=Response(status_code=None, request=""), body=None, message=None + response=httpx.Response(status_code=None, request=""), + body=None, + message=None, ), "invalid_auth", ), ( BadRequestError( - response=Response(status_code=None, request=""), body=None, message=None + response=httpx.Response(status_code=None, request=""), + body=None, + message=None, ), "unknown", ), @@ -156,7 +168,6 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ( { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "none", CONF_PROMPT: "bla", }, { @@ -172,6 +183,9 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: False, }, ), ( @@ -183,7 +197,22 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: False, }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ), + ( { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: "assist", @@ -191,7 +220,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, ), @@ -225,3 +259,105 @@ async def test_options_switching( await hass.async_block_till_done() assert options["type"] is FlowResultType.CREATE_ENTRY assert options["data"] == expected_options + + +async def test_options_web_search_user_location( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test fetching user location.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + hass.config.country = "US" + hass.config.time_zone = "America/Los_Angeles" + hass.states.async_set( + "zone.home", "0", {"latitude": 37.7749, "longitude": -122.4194} + ) + with patch( + "openai.resources.responses.AsyncResponses.create", + new_callable=AsyncMock, + ) as mock_create: + mock_create.return_value = Response( + object="response", + id="resp_A", + created_at=1700000000, + model="gpt-4o-mini", + parallel_tool_calls=True, + tool_choice="auto", + tools=[], + output=[ + ResponseOutputMessage( + type="message", + id="msg_A", + content=[ + ResponseOutputText( + type="output_text", + text='{"city": "San Francisco", "region": "California"}', + annotations=[], + ) + ], + role="assistant", + status="completed", + ) + ], + ) + + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: True, + }, + ) + await hass.async_block_till_done() + assert ( + mock_create.call_args.kwargs["input"][0]["content"] == "Where are the following" + " coordinates located: (37.7749, -122.4194)?" + ) + assert options["type"] is FlowResultType.CREATE_ENTRY + assert options["data"] == { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + } + + +async def test_options_web_search_unsupported_model( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the options form giving error about web search not being available.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + result = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_CHAT_MODEL: "o1-pro", + CONF_LLM_HASS_API: ["assist"], + CONF_WEB_SEARCH: True, + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"web_search": "web_search_not_supported"} diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 238fd5f2d7b..99559cb3b61 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -3,20 +3,52 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from httpx import Response +import httpx from openai import AuthenticationError, RateLimitError -from openai.types.chat.chat_completion_chunk import ( - ChatCompletionChunk, - Choice, - ChoiceDelta, - ChoiceDeltaToolCall, - ChoiceDeltaToolCallFunction, +from openai.types import ResponseFormatText +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseCreatedEvent, + ResponseError, + ResponseErrorEvent, + ResponseFailedEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseIncompleteEvent, + ResponseInProgressEvent, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputText, + ResponseReasoningItem, + ResponseStreamEvent, + ResponseTextConfig, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, + ResponseWebSearchCallCompletedEvent, + ResponseWebSearchCallInProgressEvent, + ResponseWebSearchCallSearchingEvent, ) +from openai.types.responses.response import IncompleteDetails import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.openai_conversation.const import ( + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, +) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import intent @@ -28,40 +60,88 @@ from tests.components.conversation import ( mock_chat_log, # noqa: F401 ) -ASSIST_RESPONSE_FINISH = ( - # Assistant message - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))], - ), - # Finish stream - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[Choice(index=0, finish_reason="stop", delta=ChoiceDelta())], - ), -) - @pytest.fixture def mock_create_stream() -> Generator[AsyncMock]: """Mock stream response.""" - async def mock_generator(stream): - for value in stream: + async def mock_generator(events, **kwargs): + response = Response( + id="resp_A", + created_at=1700000000, + error=None, + incomplete_details=None, + instructions=kwargs.get("instructions"), + metadata=kwargs.get("metadata", {}), + model=kwargs.get("model", "gpt-4o-mini"), + object="response", + output=[], + parallel_tool_calls=kwargs.get("parallel_tool_calls", True), + temperature=kwargs.get("temperature", 1.0), + tool_choice=kwargs.get("tool_choice", "auto"), + tools=kwargs.get("tools"), + top_p=kwargs.get("top_p", 1.0), + max_output_tokens=kwargs.get("max_output_tokens", 100000), + previous_response_id=kwargs.get("previous_response_id"), + reasoning=kwargs.get("reasoning"), + status="in_progress", + text=kwargs.get( + "text", ResponseTextConfig(format=ResponseFormatText(type="text")) + ), + truncation=kwargs.get("truncation", "disabled"), + usage=None, + user=kwargs.get("user"), + store=kwargs.get("store", True), + ) + yield ResponseCreatedEvent( + response=response, + type="response.created", + ) + yield ResponseInProgressEvent( + response=response, + type="response.in_progress", + ) + response.status = "completed" + + for value in events: + if isinstance(value, ResponseOutputItemDoneEvent): + response.output.append(value.item) + elif isinstance(value, IncompleteDetails): + response.status = "incomplete" + response.incomplete_details = value + break + if isinstance(value, ResponseError): + response.status = "failed" + response.error = value + break + yield value + if isinstance(value, ResponseErrorEvent): + return + + if response.status == "incomplete": + yield ResponseIncompleteEvent( + response=response, + type="response.incomplete", + ) + elif response.status == "failed": + yield ResponseFailedEvent( + response=response, + type="response.failed", + ) + else: + yield ResponseCompletedEvent( + response=response, + type="response.completed", + ) + with patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", AsyncMock(), ) as mock_create: mock_create.side_effect = lambda **kwargs: mock_generator( - mock_create.return_value.pop(0) + mock_create.return_value.pop(0), **kwargs ) yield mock_create @@ -99,13 +179,17 @@ async def test_entity( [ ( RateLimitError( - response=Response(status_code=429, request=""), body=None, message=None + response=httpx.Response(status_code=429, request=""), + body=None, + message=None, ), "Rate limited or insufficient funds", ), ( AuthenticationError( - response=Response(status_code=401, request=""), body=None, message=None + response=httpx.Response(status_code=401, request=""), + body=None, + message=None, ), "Error talking to OpenAI", ), @@ -120,7 +204,7 @@ async def test_error_handling( ) -> None: """Test that we handle errors when calling completion API.""" with patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", new_callable=AsyncMock, side_effect=exception, ): @@ -132,6 +216,121 @@ async def test_error_handling( assert result.response.speech["plain"]["speech"] == message, result.response.speech +@pytest.mark.parametrize( + ("reason", "message"), + [ + ( + "max_output_tokens", + "max output tokens reached", + ), + ( + "content_filter", + "content filter triggered", + ), + ( + None, + "unknown reason", + ), + ], +) +async def test_incomplete_response( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + reason: str, + message: str, +) -> None: + """Test handling early model stop.""" + # Incomplete details received after some content is generated + mock_create_stream.return_value = [ + ( + # Start message + *create_message_item( + id="msg_A", + text=["Once upon", " a time, ", "there was "], + output_index=0, + ), + # Length limit or content filter + IncompleteDetails(reason=reason), + ) + ] + + result = await conversation.async_converse( + hass, + "Please tell me a big story", + "mock-conversation-id", + Context(), + agent_id="conversation.openai", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert ( + result.response.speech["plain"]["speech"] + == f"OpenAI response incomplete: {message}" + ), result.response.speech + + # Incomplete details received before any content is generated + mock_create_stream.return_value = [ + ( + # Start generating response + *create_reasoning_item(id="rs_A", output_index=0), + # Length limit or content filter + IncompleteDetails(reason=reason), + ) + ] + + result = await conversation.async_converse( + hass, + "please tell me a big story", + "mock-conversation-id", + Context(), + agent_id="conversation.openai", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert ( + result.response.speech["plain"]["speech"] + == f"OpenAI response incomplete: {message}" + ), result.response.speech + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + ResponseError(code="rate_limit_exceeded", message="Rate limit exceeded"), + "OpenAI response failed: Rate limit exceeded", + ), + ( + ResponseErrorEvent(type="error", message="Some error"), + "OpenAI response error: Some error", + ), + ], +) +async def test_failed_response( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + error: ResponseError | ResponseErrorEvent, + message: str, +) -> None: + """Test handling failed and error responses.""" + mock_create_stream.return_value = [(error,)] + + result = await conversation.async_converse( + hass, + "next natural number please", + "mock-conversation-id", + Context(), + agent_id="conversation.openai", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.speech["plain"]["speech"] == message, result.response.speech + + async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -144,6 +343,200 @@ async def test_conversation_agent( assert agent.supported_languages == "*" +def create_message_item( + id: str, text: str | list[str], output_index: int +) -> list[ResponseStreamEvent]: + """Create a message item.""" + if isinstance(text, str): + text = [text] + + content = ResponseOutputText(annotations=[], text="", type="output_text") + events = [ + ResponseOutputItemAddedEvent( + item=ResponseOutputMessage( + id=id, + content=[], + type="message", + role="assistant", + status="in_progress", + ), + output_index=output_index, + type="response.output_item.added", + ), + ResponseContentPartAddedEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + type="response.content_part.added", + ), + ] + + content.text = "".join(text) + events.extend( + ResponseTextDeltaEvent( + content_index=0, + delta=delta, + item_id=id, + output_index=output_index, + type="response.output_text.delta", + ) + for delta in text + ) + + events.extend( + [ + ResponseTextDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + text="".join(text), + type="response.output_text.done", + ), + ResponseContentPartDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + type="response.content_part.done", + ), + ResponseOutputItemDoneEvent( + item=ResponseOutputMessage( + id=id, + content=[content], + role="assistant", + status="completed", + type="message", + ), + output_index=output_index, + type="response.output_item.done", + ), + ] + ) + + return events + + +def create_function_tool_call_item( + id: str, arguments: str | list[str], call_id: str, name: str, output_index: int +) -> list[ResponseStreamEvent]: + """Create a function tool call item.""" + if isinstance(arguments, str): + arguments = [arguments] + + events = [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="", + call_id=call_id, + name=name, + type="function_call", + status="in_progress", + ), + output_index=output_index, + type="response.output_item.added", + ) + ] + + events.extend( + ResponseFunctionCallArgumentsDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + type="response.function_call_arguments.delta", + ) + for delta in arguments + ) + + events.append( + ResponseFunctionCallArgumentsDoneEvent( + arguments="".join(arguments), + item_id=id, + output_index=output_index, + type="response.function_call_arguments.done", + ) + ) + + events.append( + ResponseOutputItemDoneEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="".join(arguments), + call_id=call_id, + name=name, + type="function_call", + status="completed", + ), + output_index=output_index, + type="response.output_item.done", + ) + ) + + return events + + +def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a reasoning item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + ), + output_index=output_index, + type="response.output_item.added", + ), + ResponseOutputItemDoneEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + ), + output_index=output_index, + type="response.output_item.done", + ), + ] + + +def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a web search call item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionWebSearch( + id=id, status="in_progress", type="web_search_call" + ), + output_index=output_index, + type="response.output_item.added", + ), + ResponseWebSearchCallInProgressEvent( + item_id=id, + output_index=output_index, + type="response.web_search_call.in_progress", + ), + ResponseWebSearchCallSearchingEvent( + item_id=id, + output_index=output_index, + type="response.web_search_call.searching", + ), + ResponseWebSearchCallCompletedEvent( + item_id=id, + output_index=output_index, + type="response.web_search_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ResponseFunctionWebSearch( + id=id, status="completed", type="web_search_call" + ), + output_index=output_index, + type="response.output_item.done", + ), + ] + + async def test_function_call( hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, @@ -156,111 +549,27 @@ async def test_function_call( mock_create_stream.return_value = [ # Initial conversation ( + # Wait for the model to think + *create_reasoning_item(id="rs_A", output_index=0), # First tool call - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_1", - index=0, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments=None, - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction( - name=None, - arguments='{"para', - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction( - name=None, - arguments='m1":"call1"}', - ), - ) - ] - ), - ) - ], + *create_function_tool_call_item( + id="fc_1", + arguments=['{"para', 'm1":"call1"}'], + call_id="call_call_1", + name="test_tool", + output_index=1, ), # Second tool call - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_2", - index=1, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments='{"param1":"call2"}', - ), - ) - ] - ), - ) - ], - ), - # Finish stream - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice(index=0, finish_reason="tool_calls", delta=ChoiceDelta()) - ], + *create_function_tool_call_item( + id="fc_2", + arguments='{"param1":"call2"}', + call_id="call_call_2", + name="test_tool", + output_index=2, ), ), # Response after tool responses - ASSIST_RESPONSE_FINISH, + create_message_item(id="msg_A", text="Cool", output_index=0), ] mock_chat_log.mock_tool_results( { @@ -277,6 +586,53 @@ async def test_function_call( agent_id="conversation.openai", ) + assert mock_create_stream.call_args.kwargs["input"][2] == { + "id": "rs_A", + "summary": [], + "type": "reasoning", + } + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + # Don't test the prompt, as it's not deterministic + assert mock_chat_log.content[1:] == snapshot + + +async def test_function_call_without_reasoning( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 + snapshot: SnapshotAssertion, +) -> None: + """Test function call from the assistant.""" + mock_create_stream.return_value = [ + # Initial conversation + ( + *create_function_tool_call_item( + id="fc_1", + arguments=['{"para', 'm1":"call1"}'], + call_id="call_call_1", + name="test_tool", + output_index=1, + ), + ), + # Response after tool responses + create_message_item(id="msg_A", text="Cool", output_index=0), + ] + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + } + ) + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai", + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE # Don't test the prompt, as it's not deterministic assert mock_chat_log.content[1:] == snapshot @@ -288,99 +644,27 @@ async def test_function_call( ( "Test function call started with missing arguments", ( - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_1", - index=0, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments=None, - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))], + *create_function_tool_call_item( + id="fc_1", + arguments=[], + call_id="call_call_1", + name="test_tool", + output_index=0, ), + *create_message_item(id="msg_A", text="Cool", output_index=1), ), ), ( "Test invalid JSON", ( - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_1", - index=0, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments=None, - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction( - name=None, - arguments='{"para', - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta(content="Cool"), - finish_reason="tool_calls", - ) - ], + *create_function_tool_call_item( + id="fc_1", + arguments=['{"para'], + call_id="call_call_1", + name="test_tool", + output_index=0, ), + *create_message_item(id="msg_A", text="Cool", output_index=1), ), ), ], @@ -390,9 +674,8 @@ async def test_function_call_invalid( mock_config_entry_with_assist: MockConfigEntry, mock_init_component, mock_create_stream: AsyncMock, - mock_chat_log: MockChatLog, # noqa: F811 description: str, - messages: tuple[ChatCompletionChunk], + messages: tuple[ResponseStreamEvent], ) -> None: """Test function call containing invalid data.""" mock_create_stream.return_value = [messages] @@ -432,7 +715,9 @@ async def test_assist_api_tools_conversion( hass.states.async_set(f"{component}.test", "on") async_expose_entity(hass, "conversation", f"{component}.test", True) - mock_create_stream.return_value = [ASSIST_RESPONSE_FINISH] + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Cool", output_index=0) + ] await conversation.async_converse( hass, "hello", None, Context(), agent_id="conversation.openai" @@ -440,3 +725,60 @@ async def test_assist_api_tools_conversion( tools = mock_create_stream.mock_calls[0][2]["tools"] assert tools + + +async def test_web_search( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test web_search_tool.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + **mock_config_entry.options, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + + message = "Home Assistant now supports ChatGPT Search in Assist" + mock_create_stream.return_value = [ + # Initial conversation + ( + *create_web_search_item(id="ws_A", output_index=0), + *create_message_item(id="msg_A", text=message, output_index=1), + ) + ] + + result = await conversation.async_converse( + hass, + "What's on the latest news?", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai", + ) + + assert mock_create_stream.mock_calls[0][2]["tools"] == [ + { + "type": "web_search_preview", + "search_context_size": "low", + "user_location": { + "type": "approximate", + "city": "San Francisco", + "region": "California", + "country": "US", + "timezone": "America/Los_Angeles", + }, + } + ] + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == message, result.response.speech diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index d78ce398c92..b4f816707e9 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,8 +1,8 @@ """Tests for the OpenAI integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, mock_open, patch -from httpx import Response +import httpx from openai import ( APIConnectionError, AuthenticationError, @@ -11,8 +11,10 @@ from openai import ( ) from openai.types.image import Image from openai.types.images_response import ImagesResponse +from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText import pytest +from homeassistant.components.openai_conversation import CONF_FILENAMES from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component @@ -114,7 +116,9 @@ async def test_generate_image_service_error( patch( "openai.resources.images.AsyncImages.generate", side_effect=RateLimitError( - response=Response(status_code=None, request=""), + response=httpx.Response( + status_code=500, request=httpx.Request(method="GET", url="") + ), body=None, message="Reason", ), @@ -132,23 +136,88 @@ async def test_generate_image_service_error( return_response=True, ) + with ( + patch( + "openai.resources.images.AsyncImages.generate", + return_value=ImagesResponse( + created=1700000000, + data=[ + Image( + b64_json=None, + revised_prompt=None, + url=None, + ) + ], + ), + ), + pytest.raises(HomeAssistantError, match="No image returned"), + ): + await hass.services.async_call( + "openai_conversation", + "generate_image", + { + "config_entry": mock_config_entry.entry_id, + "prompt": "Image of an epic fail", + }, + blocking=True, + return_response=True, + ) + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_service_with_image_not_allowed_path( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test generate content service with an image in a not allowed path.""" + with ( + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=False), + pytest.raises( + HomeAssistantError, + match=( + "Cannot read `doorbell_snapshot.jpg`, no access to path; " + "`allowlist_external_dirs` may need to be adjusted in " + "`configuration.yaml`" + ), + ), + ): + await hass.services.async_call( + "openai_conversation", + "generate_content", + { + "config_entry": mock_config_entry.entry_id, + "prompt": "Describe this image from my doorbell camera", + "filenames": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize( + ("service_name", "error"), + [ + ("generate_image", "Invalid config entry provided. Got invalid_entry"), + ("generate_content", "Invalid config entry provided. Got invalid_entry"), + ], +) async def test_invalid_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, + service_name: str, + error: str, ) -> None: """Assert exception when invalid config entry is provided.""" service_data = { "prompt": "Picture of a dog", "config_entry": "invalid_entry", } - with pytest.raises( - ServiceValidationError, match="Invalid config entry provided. Got invalid_entry" - ): + with pytest.raises(ServiceValidationError, match=error): await hass.services.async_call( "openai_conversation", - "generate_image", + service_name, service_data, blocking=True, return_response=True, @@ -158,18 +227,29 @@ async def test_invalid_config_entry( @pytest.mark.parametrize( ("side_effect", "error"), [ - (APIConnectionError(request=None), "Connection error"), + ( + APIConnectionError(request=httpx.Request(method="GET", url="test")), + "Connection error", + ), ( AuthenticationError( - response=Response(status_code=None, request=""), body=None, message=None + response=httpx.Response( + status_code=500, request=httpx.Request(method="GET", url="test") + ), + body=None, + message="", ), "Invalid API key", ), ( BadRequestError( - response=Response(status_code=None, request=""), body=None, message=None + response=httpx.Response( + status_code=500, request=httpx.Request(method="GET", url="test") + ), + body=None, + message="", ), - "openai_conversation integration not ready yet: None", + "openai_conversation integration not ready yet", ), ], ) @@ -188,3 +268,271 @@ async def test_init_error( assert await async_setup_component(hass, "openai_conversation", {}) await hass.async_block_till_done() assert error in caplog.text + + +@pytest.mark.parametrize( + ("service_data", "expected_args", "number_of_files"), + [ + ( + {"prompt": "Picture of a dog", "filenames": []}, + { + "input": [ + { + "content": [ + { + "type": "input_text", + "text": "Picture of a dog", + }, + ], + }, + ], + }, + 0, + ), + ( + {"prompt": "Picture of a dog", "filenames": ["/a/b/c.pdf"]}, + { + "input": [ + { + "content": [ + { + "type": "input_text", + "text": "Picture of a dog", + }, + { + "type": "input_file", + "file_data": "data:application/pdf;base64,BASE64IMAGE1", + "filename": "/a/b/c.pdf", + }, + ], + }, + ], + }, + 1, + ), + ( + {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, + { + "input": [ + { + "content": [ + { + "type": "input_text", + "text": "Picture of a dog", + }, + { + "type": "input_image", + "image_url": "data:image/jpeg;base64,BASE64IMAGE1", + "detail": "auto", + }, + ], + }, + ], + }, + 1, + ), + ( + { + "prompt": "Picture of a dog", + "filenames": ["/a/b/c.jpg", "d/e/f.jpg"], + }, + { + "input": [ + { + "content": [ + { + "type": "input_text", + "text": "Picture of a dog", + }, + { + "type": "input_image", + "image_url": "data:image/jpeg;base64,BASE64IMAGE1", + "detail": "auto", + }, + { + "type": "input_image", + "image_url": "data:image/jpeg;base64,BASE64IMAGE2", + "detail": "auto", + }, + ], + }, + ], + }, + 2, + ), + ], +) +async def test_generate_content_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + service_data, + expected_args, + number_of_files, +) -> None: + """Test generate content service.""" + service_data["config_entry"] = mock_config_entry.entry_id + expected_args["model"] = "gpt-4o-mini" + expected_args["max_output_tokens"] = 150 + expected_args["top_p"] = 1.0 + expected_args["temperature"] = 1.0 + expected_args["user"] = None + expected_args["store"] = False + expected_args["input"][0]["type"] = "message" + expected_args["input"][0]["role"] = "user" + + with ( + patch( + "openai.resources.responses.AsyncResponses.create", + new_callable=AsyncMock, + ) as mock_create, + patch( + "base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"] + ) as mock_b64encode, + patch("builtins.open", mock_open(read_data="ABC")) as mock_file, + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=True), + ): + mock_create.return_value = Response( + object="response", + id="resp_A", + created_at=1700000000, + model="gpt-4o-mini", + parallel_tool_calls=True, + tool_choice="auto", + tools=[], + output=[ + ResponseOutputMessage( + type="message", + id="msg_A", + content=[ + ResponseOutputText( + type="output_text", + text="This is the response", + annotations=[], + ) + ], + role="assistant", + status="completed", + ) + ], + ) + + response = await hass.services.async_call( + "openai_conversation", + "generate_content", + service_data, + blocking=True, + return_response=True, + ) + assert response == {"text": "This is the response"} + assert len(mock_create.mock_calls) == 1 + assert mock_create.mock_calls[0][2] == expected_args + assert mock_b64encode.call_count == number_of_files + for idx, file in enumerate(service_data[CONF_FILENAMES]): + assert mock_file.call_args_list[idx][0][0] == file + + +@pytest.mark.parametrize( + ( + "service_data", + "error", + "number_of_files", + "exists_side_effect", + "is_allowed_side_effect", + ), + [ + ( + {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, + "`/a/b/c.jpg` does not exist", + 0, + [False], + [True], + ), + ( + { + "prompt": "Picture of a dog", + "filenames": ["/a/b/c.jpg", "d/e/f.png"], + }, + "Cannot read `d/e/f.png`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`", + 1, + [True, True], + [True, False], + ), + ( + {"prompt": "Not a picture of a dog", "filenames": ["/a/b/c.mov"]}, + "Only images and PDF are supported by the OpenAI API,`/a/b/c.mov` is not an image file or PDF", + 1, + [True], + [True], + ), + ], +) +async def test_generate_content_service_invalid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + service_data, + error, + number_of_files, + exists_side_effect, + is_allowed_side_effect, +) -> None: + """Test generate content service.""" + service_data["config_entry"] = mock_config_entry.entry_id + + with ( + patch( + "openai.resources.responses.AsyncResponses.create", + new_callable=AsyncMock, + ) as mock_create, + patch( + "base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"] + ) as mock_b64encode, + patch("builtins.open", mock_open(read_data="ABC")), + patch("pathlib.Path.exists", side_effect=exists_side_effect), + patch.object( + hass.config, "is_allowed_path", side_effect=is_allowed_side_effect + ), + ): + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + "openai_conversation", + "generate_content", + service_data, + blocking=True, + return_response=True, + ) + assert len(mock_create.mock_calls) == 0 + assert mock_b64encode.call_count == number_of_files + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_service_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test generate content service handles errors.""" + with ( + patch( + "openai.resources.responses.AsyncResponses.create", + side_effect=RateLimitError( + response=httpx.Response( + status_code=417, request=httpx.Request(method="GET", url="") + ), + body=None, + message="Reason", + ), + ), + pytest.raises(HomeAssistantError, match="Error generating content: Reason"), + ): + await hass.services.async_call( + "openai_conversation", + "generate_content", + { + "config_entry": mock_config_entry.entry_id, + "prompt": "Image of an epic fail", + }, + blocking=True, + return_response=True, + ) diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py index 937540a42c1..54bab7e7ee6 100644 --- a/tests/components/opensky/test_sensor.py +++ b/tests/components/opensky/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from python_opensky import StatesResponse -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.opensky.const import ( DOMAIN, diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 57bea4e55dc..99a2dde4acc 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -54,30 +54,6 @@ async def test_form_user( assert mock_pyotgw.return_value.disconnect.await_count == 1 -# Deprecated import from configuration.yaml, can be removed in 2025.4.0 -async def test_form_import( - hass: HomeAssistant, - mock_pyotgw: MagicMock, - mock_setup_entry: AsyncMock, -) -> None: - """Test import from existing config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_ID: "legacy_gateway", CONF_DEVICE: "/dev/ttyUSB1"}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "legacy_gateway" - assert result["data"] == { - CONF_NAME: "legacy_gateway", - CONF_DEVICE: "/dev/ttyUSB1", - CONF_ID: "legacy_gateway", - } - assert mock_pyotgw.return_value.connect.await_count == 1 - assert mock_pyotgw.return_value.disconnect.await_count == 1 - - async def test_form_duplicate_entries( hass: HomeAssistant, mock_pyotgw: MagicMock, diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 3e85afbf782..84629137ce1 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -4,18 +4,12 @@ from unittest.mock import MagicMock from pyotgw.vars import OTGW, OTGW_ABOUT -from homeassistant import setup from homeassistant.components.opentherm_gw.const import ( DOMAIN, OpenThermDeviceIdentifier, ) -from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr from .conftest import MOCK_GATEWAY_ID, VERSION_TEST @@ -74,104 +68,3 @@ async def test_device_registry_update( ) assert gw_dev is not None assert gw_dev.sw_version == VERSION_NEW - - -# Device migration test can be removed in 2025.4.0 -async def test_device_migration( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that the device registry is updated correctly.""" - mock_config_entry.add_to_hass(hass) - - device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - identifiers={ - (DOMAIN, MOCK_GATEWAY_ID), - }, - name="Mock Gateway", - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - sw_version=VERSION_TEST, - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert ( - device_registry.async_get_device(identifiers={(DOMAIN, MOCK_GATEWAY_ID)}) - is None - ) - - gw_dev = device_registry.async_get_device( - identifiers={(DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}")} - ) - assert gw_dev is not None - - assert ( - device_registry.async_get_device( - identifiers={ - (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.BOILER}") - } - ) - is not None - ) - - assert ( - device_registry.async_get_device( - identifiers={ - (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.THERMOSTAT}") - } - ) - is not None - ) - - -# Entity migration test can be removed in 2025.4.0 -async def test_climate_entity_migration( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that the climate entity unique_id gets migrated correctly.""" - mock_config_entry.add_to_hass(hass) - entry = entity_registry.async_get_or_create( - domain="climate", - platform="opentherm_gw", - unique_id=mock_config_entry.data[CONF_ID], - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - updated_entry = entity_registry.async_get(entry.entity_id) - assert updated_entry is not None - assert ( - updated_entry.unique_id - == f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" - ) - - -# Deprecation test, can be removed in 2025.4.0 -async def test_configuration_yaml_deprecation( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that existing configuration in configuration.yaml creates an issue.""" - - await setup.async_setup_component( - hass, DOMAIN, {DOMAIN: {"legacy_gateway": {"device": "/dev/null"}}} - ) - - await hass.async_block_till_done() - assert ( - issue_registry.async_get_issue( - DOMAIN, "deprecated_import_from_configuration_yaml" - ) - is not None - ) diff --git a/tests/components/openweathermap/__init__.py b/tests/components/openweathermap/__init__.py index e718962766f..9552cdb4f70 100644 --- a/tests/components/openweathermap/__init__.py +++ b/tests/components/openweathermap/__init__.py @@ -1 +1,24 @@ -"""Tests for the OpenWeatherMap integration.""" +"""Shared utilities for OpenWeatherMap tests.""" + +from unittest.mock import patch + +from homeassistant.config_entries import ConfigEntryState +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], +): + """Set up the OpenWeatherMap platform.""" + config_entry.add_to_hass(hass) + with ( + patch("homeassistant.components.openweathermap.PLATFORMS", platforms), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/openweathermap/conftest.py b/tests/components/openweathermap/conftest.py new file mode 100644 index 00000000000..44f4b971bd8 --- /dev/null +++ b/tests/components/openweathermap/conftest.py @@ -0,0 +1,146 @@ +"""Configure tests for the OpenWeatherMap integration.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +from pyopenweathermap import ( + CurrentWeather, + DailyTemperature, + DailyWeatherForecast, + MinutelyWeatherForecast, + WeatherCondition, + WeatherReport, +) +from pyopenweathermap.client.owm_abstract_client import OWMClient +import pytest + +from homeassistant.components.openweathermap.const import DEFAULT_LANGUAGE, DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_LANGUAGE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + CONF_NAME, +) + +from tests.common import MockConfigEntry, patch + +API_KEY = "test_api_key" +LATITUDE = 12.34 +LONGITUDE = 56.78 +NAME = "openweathermap" + + +@pytest.fixture +def mode(request: pytest.FixtureRequest) -> str: + """Return mode passed in parameter.""" + return request.param + + +@pytest.fixture +def mock_config_entry(mode: str) -> MockConfigEntry: + """Fixture for creating a mock OpenWeatherMap config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: API_KEY, + CONF_LATITUDE: LATITUDE, + CONF_LONGITUDE: LONGITUDE, + CONF_NAME: NAME, + }, + options={ + CONF_MODE: mode, + CONF_LANGUAGE: DEFAULT_LANGUAGE, + }, + entry_id="test", + version=5, + unique_id=f"{LATITUDE}-{LONGITUDE}", + ) + + +@pytest.fixture +def owm_client_mock() -> Generator[AsyncMock]: + """Mock OWMClient.""" + client = AsyncMock(spec=OWMClient, autospec=True) + current_weather = CurrentWeather( + date_time=datetime.fromtimestamp(1714063536, tz=UTC), + temperature=6.84, + feels_like=2.07, + pressure=1000, + humidity=82, + dew_point=3.99, + uv_index=0.13, + cloud_coverage=75, + visibility=10000, + wind_speed=9.83, + wind_bearing=199, + wind_gust=None, + rain={"1h": 1.21}, + snow=None, + condition=WeatherCondition( + id=803, + main="Clouds", + description="broken clouds", + icon="04d", + ), + ) + daily_weather_forecast = DailyWeatherForecast( + date_time=datetime.fromtimestamp(1714063536, tz=UTC), + summary="There will be clear sky until morning, then partly cloudy", + temperature=DailyTemperature( + day=18.76, + min=8.11, + max=21.26, + night=13.06, + evening=20.51, + morning=8.47, + ), + feels_like=DailyTemperature( + day=18.76, + min=8.11, + max=21.26, + night=13.06, + evening=20.51, + morning=8.47, + ), + pressure=1015, + humidity=62, + dew_point=11.34, + wind_speed=8.14, + wind_bearing=168, + wind_gust=11.81, + condition=WeatherCondition( + id=803, + main="Clouds", + description="broken clouds", + icon="04d", + ), + cloud_coverage=84, + precipitation_probability=0, + uv_index=4.06, + rain=0, + snow=0, + ) + minutely_weather_forecast = [ + MinutelyWeatherForecast(date_time=1728672360, precipitation=0), + MinutelyWeatherForecast(date_time=1728672420, precipitation=1.23), + MinutelyWeatherForecast(date_time=1728672480, precipitation=4.5), + MinutelyWeatherForecast(date_time=1728672540, precipitation=0), + ] + client.get_weather.return_value = WeatherReport( + current_weather, minutely_weather_forecast, [], [daily_weather_forecast] + ) + client.validate_key.return_value = True + with ( + patch( + "homeassistant.components.openweathermap.create_owm_client", + return_value=client, + ), + patch( + "homeassistant.components.openweathermap.utils.create_owm_client", + return_value=client, + ), + ): + yield client diff --git a/tests/components/openweathermap/snapshots/test_sensor.ambr b/tests/components/openweathermap/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..7b0cf4fbf99 --- /dev/null +++ b/tests/components/openweathermap/snapshots/test_sensor.ambr @@ -0,0 +1,1659 @@ +# serializer version: 1 +# name: test_sensor_states[current][sensor.openweathermap_cloud_coverage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_cloud_coverage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cloud coverage', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-clouds', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Cloud coverage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_condition', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-condition', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Condition', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew Point', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-dew_point', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Dew Point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.99', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_feels_like_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_feels_like_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': 'Feels like temperature', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-feels_like_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_feels_like_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Feels like temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_feels_like_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.07', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_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': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'humidity', + 'friendly_name': 'openweathermap Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '82', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_precipitation_kind-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_precipitation_kind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation kind', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-precipitation_kind', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_precipitation_kind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Precipitation kind', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_precipitation_kind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Rain', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_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': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'pressure', + 'friendly_name': 'openweathermap Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-rain', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'openweathermap Rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.21', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_snow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_snow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Snow', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-snow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_snow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'openweathermap Snow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_snow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_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': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.84', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_uv_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV Index', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-uv_index', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap UV Index', + 'state_class': , + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.13', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_visibility-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_visibility', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Visibility', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-visibility_distance', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_visibility-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'distance', + 'friendly_name': 'openweathermap Visibility', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_visibility', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10000', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_weather-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_weather', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Weather', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-weather', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_weather-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Weather', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_weather', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'broken clouds', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_weather_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_weather_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': 'Weather Code', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-weather_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_weather_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Weather Code', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_weather_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '803', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_wind_bearing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_wind_bearing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind bearing', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_bearing', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_wind_bearing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_direction', + 'friendly_name': 'openweathermap Wind bearing', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_bearing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '199', + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_wind_speed', + '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': 'Wind speed', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[current][sensor.openweathermap_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_speed', + 'friendly_name': 'openweathermap Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.39', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_cloud_coverage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_cloud_coverage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cloud coverage', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-clouds', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Cloud coverage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_condition', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-condition', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Condition', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew Point', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-dew_point', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Dew Point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.99', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_feels_like_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_feels_like_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': 'Feels like temperature', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-feels_like_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_feels_like_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Feels like temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_feels_like_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.07', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_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': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'humidity', + 'friendly_name': 'openweathermap Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '82', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_precipitation_kind-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_precipitation_kind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation kind', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-precipitation_kind', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_precipitation_kind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Precipitation kind', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_precipitation_kind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Rain', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_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': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'pressure', + 'friendly_name': 'openweathermap Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-rain', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'openweathermap Rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.21', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_snow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_snow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Snow', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-snow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_snow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'openweathermap Snow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_snow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_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': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'temperature', + 'friendly_name': 'openweathermap Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.84', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_uv_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV Index', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-uv_index', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap UV Index', + 'state_class': , + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.13', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_visibility-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_visibility', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Visibility', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-visibility_distance', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_visibility-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'distance', + 'friendly_name': 'openweathermap Visibility', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_visibility', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10000', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_weather-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_weather', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Weather', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-weather', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_weather-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Weather', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_weather', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'broken clouds', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_weather_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_weather_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': 'Weather Code', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-weather_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_weather_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'friendly_name': 'openweathermap Weather Code', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_weather_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '803', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_bearing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_wind_bearing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind bearing', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_bearing', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_bearing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_direction', + 'friendly_name': 'openweathermap Wind bearing', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_bearing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '199', + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.openweathermap_wind_speed', + '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': 'Wind speed', + 'platform': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78-wind_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[v3.0][sensor.openweathermap_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by OpenWeatherMap', + 'device_class': 'wind_speed', + 'friendly_name': 'openweathermap Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.openweathermap_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.39', + }) +# --- diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr index c89dcb96a9c..1d77d9179a5 100644 --- a/tests/components/openweathermap/snapshots/test_weather.ambr +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_get_minute_forecast[mock_service_response] +# name: test_get_minute_forecast[v3.0][mock_service_response] dict({ 'weather.openweathermap': dict({ 'forecast': list([ @@ -23,3 +23,188 @@ }), }) # --- +# name: test_weather_states[current][weather.openweathermap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.openweathermap', + 'has_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': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12.34-56.78', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather_states[current][weather.openweathermap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'apparent_temperature': 2.1, + 'attribution': 'Data provided by OpenWeatherMap', + 'cloud_coverage': 75, + 'dew_point': 4.0, + 'friendly_name': 'openweathermap', + 'humidity': 82, + 'precipitation_unit': , + 'pressure': 1000.0, + 'pressure_unit': , + 'temperature': 6.8, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 199, + 'wind_speed': 35.39, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.openweathermap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- +# name: test_weather_states[forecast][weather.openweathermap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.openweathermap', + 'has_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': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12.34-56.78', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather_states[forecast][weather.openweathermap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'apparent_temperature': 2.1, + 'attribution': 'Data provided by OpenWeatherMap', + 'cloud_coverage': 75, + 'dew_point': 4.0, + 'friendly_name': 'openweathermap', + 'humidity': 82, + 'precipitation_unit': , + 'pressure': 1000.0, + 'pressure_unit': , + 'supported_features': , + 'temperature': 6.8, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 199, + 'wind_speed': 35.39, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.openweathermap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- +# name: test_weather_states[v3.0][weather.openweathermap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.openweathermap', + 'has_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': 'openweathermap', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12.34-56.78', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather_states[v3.0][weather.openweathermap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'apparent_temperature': 2.1, + 'attribution': 'Data provided by OpenWeatherMap', + 'cloud_coverage': 75, + 'dew_point': 4.0, + 'friendly_name': 'openweathermap', + 'humidity': 82, + 'precipitation_unit': , + 'pressure': 1000.0, + 'pressure_unit': , + 'supported_features': , + 'temperature': 6.8, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 199, + 'wind_speed': 35.39, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.openweathermap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cloudy', + }) +# --- diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index d5e01677dd8..0315ca91010 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -1,17 +1,8 @@ """Define tests for the OpenWeatherMap config flow.""" -from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock -from pyopenweathermap import ( - CurrentWeather, - DailyTemperature, - DailyWeatherForecast, - MinutelyWeatherForecast, - RequestError, - WeatherCondition, - WeatherReport, -) +from pyopenweathermap import RequestError import pytest from homeassistant.components.openweathermap.const import ( @@ -32,13 +23,15 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import LATITUDE, LONGITUDE + from tests.common import MockConfigEntry CONFIG = { CONF_NAME: "openweathermap", CONF_API_KEY: "foo", - CONF_LATITUDE: 50, - CONF_LONGITUDE: 40, + CONF_LATITUDE: LATITUDE, + CONF_LONGITUDE: LONGITUDE, CONF_LANGUAGE: DEFAULT_LANGUAGE, CONF_MODE: OWM_MODE_V30, } @@ -46,118 +39,11 @@ CONFIG = { VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} -def _create_static_weather_report() -> WeatherReport: - """Create a static WeatherReport.""" - - current_weather = CurrentWeather( - date_time=datetime.fromtimestamp(1714063536, tz=UTC), - temperature=6.84, - feels_like=2.07, - pressure=1000, - humidity=82, - dew_point=3.99, - uv_index=0.13, - cloud_coverage=75, - visibility=10000, - wind_speed=9.83, - wind_bearing=199, - wind_gust=None, - rain={"1h": 1.21}, - snow=None, - condition=WeatherCondition( - id=803, - main="Clouds", - description="broken clouds", - icon="04d", - ), - ) - daily_weather_forecast = DailyWeatherForecast( - date_time=datetime.fromtimestamp(1714063536, tz=UTC), - summary="There will be clear sky until morning, then partly cloudy", - temperature=DailyTemperature( - day=18.76, - min=8.11, - max=21.26, - night=13.06, - evening=20.51, - morning=8.47, - ), - feels_like=DailyTemperature( - day=18.76, - min=8.11, - max=21.26, - night=13.06, - evening=20.51, - morning=8.47, - ), - pressure=1015, - humidity=62, - dew_point=11.34, - wind_speed=8.14, - wind_bearing=168, - wind_gust=11.81, - condition=WeatherCondition( - id=803, - main="Clouds", - description="broken clouds", - icon="04d", - ), - cloud_coverage=84, - precipitation_probability=0, - uv_index=4.06, - rain=0, - snow=0, - ) - minutely_weather_forecast = [ - MinutelyWeatherForecast(date_time=1728672360, precipitation=0), - MinutelyWeatherForecast(date_time=1728672420, precipitation=1.23), - MinutelyWeatherForecast(date_time=1728672480, precipitation=4.5), - MinutelyWeatherForecast(date_time=1728672540, precipitation=0), - ] - return WeatherReport( - current_weather, minutely_weather_forecast, [], [daily_weather_forecast] - ) - - -def _create_mocked_owm_factory(is_valid: bool): - """Create a mocked OWM client.""" - - weather_report = _create_static_weather_report() - mocked_owm_client = MagicMock() - mocked_owm_client.validate_key = AsyncMock(return_value=is_valid) - mocked_owm_client.get_weather = AsyncMock(return_value=weather_report) - - return mocked_owm_client - - -@pytest.fixture(name="owm_client_mock") -def mock_owm_client(): - """Mock config_flow OWMClient.""" - with patch( - "homeassistant.components.openweathermap.create_owm_client", - ) as mock: - yield mock - - -@pytest.fixture(name="config_flow_owm_client_mock") -def mock_config_flow_owm_client(): - """Mock config_flow OWMClient.""" - with patch( - "homeassistant.components.openweathermap.utils.create_owm_client", - ) as mock: - yield mock - - async def test_successful_config_flow( hass: HomeAssistant, - owm_client_mock, - config_flow_owm_client_mock, + owm_client_mock: AsyncMock, ) -> None: """Test that the form is served with valid input.""" - mock = _create_mocked_owm_factory(True) - owm_client_mock.return_value = mock - config_flow_owm_client_mock.return_value = mock - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -187,39 +73,32 @@ async def test_successful_config_flow( assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] +@pytest.mark.parametrize("mode", [OWM_MODE_V30], indirect=True) async def test_abort_config_flow( hass: HomeAssistant, - owm_client_mock, - config_flow_owm_client_mock, + owm_client_mock: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test that the form is served with same data.""" - mock = _create_mocked_owm_factory(True) - owm_client_mock.return_value = mock - config_flow_owm_client_mock.return_value = mock - + 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} ) - await hass.async_block_till_done() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) - await hass.async_block_till_done() + 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"], CONFIG) assert result["type"] is FlowResultType.ABORT async def test_config_flow_options_change( hass: HomeAssistant, - owm_client_mock, - config_flow_owm_client_mock, + owm_client_mock: AsyncMock, ) -> None: """Test that the options form.""" - mock = _create_mocked_owm_factory(True) - owm_client_mock.return_value = mock - config_flow_owm_client_mock.return_value = mock - config_entry = MockConfigEntry( domain=DOMAIN, unique_id="openweathermap_unique_id", data=CONFIG ) @@ -274,10 +153,10 @@ async def test_config_flow_options_change( async def test_form_invalid_api_key( hass: HomeAssistant, - config_flow_owm_client_mock, + owm_client_mock: AsyncMock, ) -> None: """Test that the form is served with no input.""" - config_flow_owm_client_mock.return_value = _create_mocked_owm_factory(False) + owm_client_mock.validate_key.return_value = False result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG ) @@ -285,7 +164,7 @@ async def test_form_invalid_api_key( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_api_key"} - config_flow_owm_client_mock.return_value = _create_mocked_owm_factory(True) + owm_client_mock.validate_key.return_value = True result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG ) @@ -295,11 +174,10 @@ async def test_form_invalid_api_key( async def test_form_api_call_error( hass: HomeAssistant, - config_flow_owm_client_mock, + owm_client_mock: AsyncMock, ) -> None: """Test setting up with api call error.""" - config_flow_owm_client_mock.return_value = _create_mocked_owm_factory(True) - config_flow_owm_client_mock.side_effect = RequestError("oops") + owm_client_mock.validate_key.side_effect = RequestError("oops") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG ) @@ -307,7 +185,7 @@ async def test_form_api_call_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} - config_flow_owm_client_mock.side_effect = None + owm_client_mock.validate_key.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG ) diff --git a/tests/components/openweathermap/test_sensor.py b/tests/components/openweathermap/test_sensor.py new file mode 100644 index 00000000000..fdf21ec71fe --- /dev/null +++ b/tests/components/openweathermap/test_sensor.py @@ -0,0 +1,49 @@ +"""Tests for OpenWeatherMap sensors.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.openweathermap.const import ( + OWM_MODE_FREE_CURRENT, + OWM_MODE_FREE_FORECAST, + OWM_MODE_V30, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("mode", [OWM_MODE_V30, OWM_MODE_FREE_CURRENT], indirect=True) +async def test_sensor_states( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + owm_client_mock: MagicMock, + mode: str, +) -> None: + """Test sensor states are correctly collected from library with different modes and mocked function responses.""" + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("mode", [OWM_MODE_FREE_FORECAST], indirect=True) +async def test_mode_no_sensor( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + owm_client_mock: MagicMock, + mode: str, +) -> None: + """Test modes that do not provide any sensor.""" + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + assert len(entity_registry.entities) == 0 diff --git a/tests/components/openweathermap/test_weather.py b/tests/components/openweathermap/test_weather.py index 5d3565d6ca9..0d7dfcad71f 100644 --- a/tests/components/openweathermap/test_weather.py +++ b/tests/components/openweathermap/test_weather.py @@ -1,91 +1,40 @@ """Test the OpenWeatherMap weather entity.""" +from unittest.mock import MagicMock + import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.openweathermap.const import ( - DEFAULT_LANGUAGE, DOMAIN, - OWM_MODE_V25, + OWM_MODE_FREE_CURRENT, + OWM_MODE_FREE_FORECAST, OWM_MODE_V30, ) from homeassistant.components.openweathermap.weather import SERVICE_GET_MINUTE_FORECAST -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - CONF_API_KEY, - CONF_LANGUAGE, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MODE, - CONF_NAME, -) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er -from .test_config_flow import _create_static_weather_report +from . import setup_platform -from tests.common import AsyncMock, MockConfigEntry, patch +from tests.common import MockConfigEntry, snapshot_platform ENTITY_ID = "weather.openweathermap" -API_KEY = "test_api_key" -LATITUDE = 12.34 -LONGITUDE = 56.78 -NAME = "openweathermap" - -# Define test data for mocked weather report -static_weather_report = _create_static_weather_report() -def mock_config_entry(mode: str) -> MockConfigEntry: - """Create a mock OpenWeatherMap config entry.""" - return MockConfigEntry( - domain=DOMAIN, - data={ - CONF_API_KEY: API_KEY, - CONF_LATITUDE: LATITUDE, - CONF_LONGITUDE: LONGITUDE, - CONF_NAME: NAME, - }, - options={CONF_MODE: mode, CONF_LANGUAGE: DEFAULT_LANGUAGE}, - version=5, - ) - - -@pytest.fixture -def mock_config_entry_v25() -> MockConfigEntry: - """Create a mock OpenWeatherMap v2.5 config entry.""" - return mock_config_entry(OWM_MODE_V25) - - -@pytest.fixture -def mock_config_entry_v30() -> MockConfigEntry: - """Create a mock OpenWeatherMap v3.0 config entry.""" - return mock_config_entry(OWM_MODE_V30) - - -async def setup_mock_config_entry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -): - """Set up the MockConfigEntry and assert it is loaded correctly.""" - 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() - assert hass.states.get(ENTITY_ID) - assert mock_config_entry.state is ConfigEntryState.LOADED - - -@patch( - "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", - AsyncMock(return_value=static_weather_report), -) +@pytest.mark.parametrize("mode", [OWM_MODE_V30], indirect=True) async def test_get_minute_forecast( hass: HomeAssistant, - mock_config_entry_v30: MockConfigEntry, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + owm_client_mock: MagicMock, + mode: str, ) -> None: """Test the get_minute_forecast Service call.""" - await setup_mock_config_entry(hass, mock_config_entry_v30) + await setup_platform(hass, mock_config_entry, [Platform.WEATHER]) result = await hass.services.async_call( DOMAIN, SERVICE_GET_MINUTE_FORECAST, @@ -96,18 +45,19 @@ async def test_get_minute_forecast( assert result == snapshot(name="mock_service_response") -@patch( - "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", - AsyncMock(return_value=static_weather_report), +@pytest.mark.parametrize( + "mode", [OWM_MODE_FREE_CURRENT, OWM_MODE_FREE_FORECAST], indirect=True ) -async def test_mode_fail( +async def test_get_minute_forecast_unavailable( hass: HomeAssistant, - mock_config_entry_v25: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + owm_client_mock: MagicMock, + mode: str, ) -> None: """Test that Minute forecasting fails when mode is not v3.0.""" - await setup_mock_config_entry(hass, mock_config_entry_v25) - # Expect a ServiceValidationError when mode is not OWM_MODE_V30 + await setup_platform(hass, mock_config_entry, [Platform.WEATHER]) with pytest.raises( ServiceValidationError, match="Minute forecast is available only when OpenWeatherMap mode is set to v3.0", @@ -119,3 +69,19 @@ async def test_mode_fail( blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + "mode", [OWM_MODE_V30, OWM_MODE_FREE_CURRENT, OWM_MODE_FREE_FORECAST], indirect=True +) +async def test_weather_states( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + owm_client_mock: MagicMock, +) -> None: + """Test weather states are correctly collected from library with different modes and mocked function responses.""" + + await setup_platform(hass, mock_config_entry, [Platform.WEATHER]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/osoenergy/test_water_heater.py b/tests/components/osoenergy/test_water_heater.py index 851e710fa1c..fd27975c938 100644 --- a/tests/components/osoenergy/test_water_heater.py +++ b/tests/components/osoenergy/test_water_heater.py @@ -3,7 +3,7 @@ from unittest.mock import ANY, MagicMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.osoenergy.const import DOMAIN from homeassistant.components.osoenergy.water_heater import ( diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 711cc6c1d86..410c2ebb5f1 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -40,6 +40,7 @@ TEST_GATEWAY_ID3 = "SOMFY_PROTECT-v0NT53occUBPyuJRzx59kalW1hFfzimN" TEST_HOST = "gateway-1234-5678-9123.local:8443" TEST_HOST2 = "192.168.11.104:8443" +TEST_TOKEN = "1234123412341234" MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID3), Mock(id=TEST_GATEWAY_ID2)] @@ -81,21 +82,21 @@ async def test_form_cloud(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with ( patch("pyoverkiz.client.OverkizClient.login", return_value=True), @@ -104,7 +105,7 @@ async def test_form_cloud(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N return_value=MOCK_GATEWAY_RESPONSE, ), ): - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) @@ -124,13 +125,13 @@ async def test_form_only_cloud_supported( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER2}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with ( patch("pyoverkiz.client.OverkizClient.login", return_value=True), @@ -139,7 +140,7 @@ async def test_form_only_cloud_supported( return_value=MOCK_GATEWAY_RESPONSE, ), ): - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) @@ -152,48 +153,54 @@ async def test_form_only_cloud_supported( async def test_form_local_happy_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test local API configuration flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "local"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local" with patch.multiple( "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, "host": "gateway-1234-5678-1234.local:8443", + "token": TEST_TOKEN, + "verify_ssl": True, }, ) await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "gateway-1234-5678-1234.local:8443" + assert result["data"] == { + "host": "gateway-1234-5678-1234.local:8443", + "token": TEST_TOKEN, + "verify_ssl": True, + "hub": TEST_SERVER, + "api_type": "local", + } assert len(mock_setup_entry.mock_calls) == 1 @@ -220,32 +227,32 @@ async def test_form_invalid_auth_cloud( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) await hass.async_block_till_done() - assert result4["type"] is FlowResultType.FORM - assert result4["errors"] == {"base": error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} @pytest.mark.parametrize( @@ -262,7 +269,7 @@ async def test_form_invalid_auth_cloud( (MaintenanceException, "server_in_maintenance"), (TooManyAttemptsBannedException, "too_many_attempts"), (UnknownUserException, "unsupported_hardware"), - (NotSuchTokenException, "no_such_token"), + (NotSuchTokenException, "invalid_auth"), (Exception, "unknown"), ], ) @@ -276,83 +283,36 @@ async def test_form_invalid_auth_local( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "local"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local" with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, "verify_ssl": True, }, ) await hass.async_block_till_done() - assert result4["type"] is FlowResultType.FORM - assert result4["errors"] == {"base": error} - - -async def test_form_local_developer_mode_disabled( - 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} - ) - assert result["type"] is FlowResultType.FORM - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"hub": TEST_SERVER}, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" - - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"api_type": "local"}, - ) - - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" - - with patch.multiple( - "pyoverkiz.client.OverkizClient", - login=AsyncMock(return_value=True), - get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=None), - ): - result4 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "host": "gateway-1234-5678-1234.local:8443", - "verify_ssl": True, - }, - ) - - assert result4["type"] is FlowResultType.FORM - assert result4["errors"] == {"base": "developer_mode_disabled"} + assert result["errors"] == {"base": error} @pytest.mark.parametrize( @@ -371,25 +331,25 @@ async def test_form_invalid_cozytouch_auth( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER_COZYTOUCH}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": error} - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + assert result["step_id"] == "cloud" async def test_cloud_abort_on_duplicate_entry( @@ -409,21 +369,21 @@ async def test_cloud_abort_on_duplicate_entry( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with ( patch("pyoverkiz.client.OverkizClient.login", return_value=True), @@ -432,28 +392,30 @@ async def test_cloud_abort_on_duplicate_entry( return_value=MOCK_GATEWAY_RESPONSE, ), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result4["type"] is FlowResultType.ABORT - assert result4["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_local_abort_on_duplicate_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test local API configuration is aborted if gateway already exists.""" MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, + version=2, data={ "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, + "verify_ssl": True, "hub": TEST_SERVER, + "api_type": "local", }, ).add_to_hass(hass) @@ -463,42 +425,39 @@ async def test_local_abort_on_duplicate_entry( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "local"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local" with patch.multiple( "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "host": TEST_HOST, - "username": TEST_EMAIL, - "password": TEST_PASSWORD, + "token": TEST_TOKEN, "verify_ssl": True, }, ) - assert result4["type"] is FlowResultType.ABORT - assert result4["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_cloud_allow_multiple_unique_entries( @@ -519,21 +478,21 @@ async def test_cloud_allow_multiple_unique_entries( assert result["type"] is FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with ( patch("pyoverkiz.client.OverkizClient.login", return_value=True), @@ -542,14 +501,14 @@ async def test_cloud_allow_multiple_unique_entries( return_value=MOCK_GATEWAY_RESPONSE, ), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == TEST_EMAIL - assert result4["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL + assert result["data"] == { "api_type": "cloud", "username": TEST_EMAIL, "password": TEST_PASSWORD, @@ -585,7 +544,7 @@ async def test_cloud_reauth_success(hass: HomeAssistant) -> None: return_value=MOCK_GATEWAY_RESPONSE, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ "username": TEST_EMAIL, @@ -593,8 +552,8 @@ async def test_cloud_reauth_success(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert mock_entry.data["username"] == TEST_EMAIL assert mock_entry.data["password"] == TEST_PASSWORD2 @@ -627,7 +586,7 @@ async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None: return_value=MOCK_GATEWAY2_RESPONSE, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ "username": TEST_EMAIL, @@ -635,22 +594,22 @@ async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_wrong_account" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_wrong_account" -async def test_local_reauth_success(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" - +async def test_local_reauth_legacy(hass: HomeAssistant) -> None: + """Test legacy reauthentication flow with username/password.""" mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, version=2, data={ + "host": TEST_HOST, "username": TEST_EMAIL, "password": TEST_PASSWORD, + "verify_ssl": True, "hub": TEST_SERVER, - "host": TEST_HOST, "api_type": "local", }, ) @@ -672,36 +631,85 @@ async def test_local_reauth_success(hass: HomeAssistant) -> None: "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, }, ) assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" - assert mock_entry.data["username"] == TEST_EMAIL - assert mock_entry.data["password"] == TEST_PASSWORD2 + assert mock_entry.data["host"] == TEST_HOST + assert mock_entry.data["token"] == "new_token" + assert mock_entry.data["verify_ssl"] is True + + +async def test_local_reauth_success(hass: HomeAssistant) -> None: + """Test modern local reauth flow.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "host": TEST_HOST, + "token": "old_token", + "verify_ssl": True, + "hub": TEST_SERVER, + "api_type": "local", + }, + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result2["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, + }, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data["host"] == TEST_HOST + assert mock_entry.data["token"] == "new_token" + assert mock_entry.data["verify_ssl"] is True + assert "username" not in mock_entry.data + assert "password" not in mock_entry.data async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" + """Test local reauth flow with wrong gateway account.""" mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID2, version=2, data={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_SERVER, "host": TEST_HOST, + "token": "old_token", + "verify_ssl": True, + "hub": TEST_SERVER, "api_type": "local", }, ) @@ -722,15 +730,13 @@ async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None: "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, + { + "host": TEST_HOST, + "token": "new_token", + "verify_ssl": True, }, ) @@ -753,15 +759,15 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) @@ -770,7 +776,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch("pyoverkiz.client.OverkizClient.get_gateways", return_value=None), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": TEST_EMAIL, @@ -778,9 +784,9 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No }, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == TEST_EMAIL - assert result4["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL + assert result["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER, @@ -824,21 +830,21 @@ async def test_zeroconf_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "cloud"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud" with ( patch("pyoverkiz.client.OverkizClient.login", return_value=True), @@ -847,14 +853,14 @@ async def test_zeroconf_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) - return_value=MOCK_GATEWAY_RESPONSE, ), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == TEST_EMAIL - assert result4["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL + assert result["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER, @@ -877,47 +883,47 @@ async def test_local_zeroconf_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"hub": TEST_SERVER}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "local_or_cloud" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"api_type": "local"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "local" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local" with patch.multiple( "pyoverkiz.client.OverkizClient", login=AsyncMock(return_value=True), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), - get_setup_option=AsyncMock(return_value=True), - generate_local_token=AsyncMock(return_value="1234123412341234"), - activate_local_token=AsyncMock(return_value=True), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "verify_ssl": False}, + { + "host": "gateway-1234-5678-9123.local:8443", + "token": TEST_TOKEN, + "verify_ssl": False, + }, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == "gateway-1234-5678-9123.local:8443" - assert result4["data"] == { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_SERVER, - "host": "gateway-1234-5678-9123.local:8443", - "api_type": "local", - "token": "1234123412341234", - "verify_ssl": False, - } + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "gateway-1234-5678-9123.local:8443" + # Verify no username/password in data + assert result["data"] == { + "host": "gateway-1234-5678-9123.local:8443", + "token": TEST_TOKEN, + "verify_ssl": False, + "hub": TEST_SERVER, + "api_type": "local", + } assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/overkiz/test_diagnostics.py b/tests/components/overkiz/test_diagnostics.py index 672370c2667..e052818daee 100644 --- a/tests/components/overkiz/test_diagnostics.py +++ b/tests/components/overkiz/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.overkiz.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/overkiz/test_init.py b/tests/components/overkiz/test_init.py index ba4de56ad86..d1961d79735 100644 --- a/tests/components/overkiz/test_init.py +++ b/tests/components/overkiz/test_init.py @@ -7,7 +7,7 @@ from homeassistant.setup import async_setup_component from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_PASSWORD, TEST_SERVER -from tests.common import MockConfigEntry, mock_registry +from tests.common import MockConfigEntry, RegistryEntryWithDefaults, mock_registry ENTITY_SENSOR_DISCRETE_RSSI_LEVEL = "sensor.zipscreen_woonkamer_discrete_rssi_level" ENTITY_ALARM_CONTROL_PANEL = "alarm_control_panel.alarm" @@ -33,35 +33,35 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: hass, { # This entity will be migrated to "io://1234-5678-1234/3541212-core:DiscreteRSSILevelState" - ENTITY_SENSOR_DISCRETE_RSSI_LEVEL: er.RegistryEntry( + ENTITY_SENSOR_DISCRETE_RSSI_LEVEL: RegistryEntryWithDefaults( entity_id=ENTITY_SENSOR_DISCRETE_RSSI_LEVEL, unique_id="io://1234-5678-1234/3541212-OverkizState.CORE_DISCRETE_RSSI_LEVEL", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), # This entity will be migrated to "internal://1234-5678-1234/alarm/0-TSKAlarmController" - ENTITY_ALARM_CONTROL_PANEL: er.RegistryEntry( + ENTITY_ALARM_CONTROL_PANEL: RegistryEntryWithDefaults( entity_id=ENTITY_ALARM_CONTROL_PANEL, unique_id="internal://1234-5678-1234/alarm/0-UIWidget.TSKALARM_CONTROLLER", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), # This entity will be migrated to "io://1234-5678-1234/0-OnOff" - ENTITY_SWITCH_GARAGE: er.RegistryEntry( + ENTITY_SWITCH_GARAGE: RegistryEntryWithDefaults( entity_id=ENTITY_SWITCH_GARAGE, unique_id="io://1234-5678-1234/0-UIClass.ON_OFF", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), # This entity will be removed since "io://1234-5678-1234/3541212-core:TargetClosureState" already exists - ENTITY_SENSOR_TARGET_CLOSURE_STATE: er.RegistryEntry( + ENTITY_SENSOR_TARGET_CLOSURE_STATE: RegistryEntryWithDefaults( entity_id=ENTITY_SENSOR_TARGET_CLOSURE_STATE, unique_id="io://1234-5678-1234/3541212-OverkizState.CORE_TARGET_CLOSURE", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), # This entity will not be migrated" - ENTITY_SENSOR_TARGET_CLOSURE_STATE_2: er.RegistryEntry( + ENTITY_SENSOR_TARGET_CLOSURE_STATE_2: RegistryEntryWithDefaults( entity_id=ENTITY_SENSOR_TARGET_CLOSURE_STATE_2, unique_id="io://1234-5678-1234/3541212-core:TargetClosureState", platform=DOMAIN, diff --git a/tests/components/overseerr/test_diagnostics.py b/tests/components/overseerr/test_diagnostics.py index 28b97e9514f..394799a277c 100644 --- a/tests/components/overseerr/test_diagnostics.py +++ b/tests/components/overseerr/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/overseerr/test_event.py b/tests/components/overseerr/test_event.py index 3866ccc09ca..448cac7c5c1 100644 --- a/tests/components/overseerr/test_event.py +++ b/tests/components/overseerr/test_event.py @@ -7,7 +7,7 @@ from freezegun.api import FrozenDateTimeFactory from future.backports.datetime import timedelta import pytest from python_overseerr import OverseerrConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py index 6418e2103db..66e6a5c134c 100644 --- a/tests/components/overseerr/test_init.py +++ b/tests/components/overseerr/test_init.py @@ -6,7 +6,7 @@ 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 syrupy.assertion import SnapshotAssertion from homeassistant.components import cloud from homeassistant.components.cloud import CloudNotAvailable diff --git a/tests/components/overseerr/test_sensor.py b/tests/components/overseerr/test_sensor.py index 6689b1ebcc3..2350f1b0883 100644 --- a/tests/components/overseerr/test_sensor.py +++ b/tests/components/overseerr/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/overseerr/test_services.py b/tests/components/overseerr/test_services.py index a0b87b5deef..3d7bcc3577f 100644 --- a/tests/components/overseerr/test_services.py +++ b/tests/components/overseerr/test_services.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock import pytest from python_overseerr import OverseerrConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr.const import ( ATTR_CONFIG_ENTRY_ID, diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 93f40d0ae3d..41565c6b1fd 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -291,13 +291,13 @@ BAD_JSON_SUFFIX = "** and it ends here ^^" @pytest.fixture -def setup_comp( +async def setup_comp( hass: HomeAssistant, mock_device_tracker_conf: list[Device], mqtt_mock: MqttMockHAClient, ) -> None: """Initialize components.""" - hass.loop.run_until_complete(async_setup_component(hass, "device_tracker", {})) + await async_setup_component(hass, "device_tracker", {}) hass.states.async_set("zone.inner", "zoning", INNER_ZONE) @@ -320,7 +320,7 @@ async def setup_owntracks( @pytest.fixture -def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: +async def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: """Set up the mocked context.""" orig_context = owntracks.OwnTracksContext context = None @@ -331,16 +331,14 @@ def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: context = orig_context(*args) return context - hass.loop.run_until_complete( - setup_owntracks( - hass, - { - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_WAYPOINT_WHITELIST: ["jon", "greg"], - }, - store_context, - ) + await setup_owntracks( + hass, + { + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_WAYPOINT_WHITELIST: ["jon", "greg"], + }, + store_context, ) def get_context(): @@ -382,7 +380,7 @@ def assert_location_longitude(hass: HomeAssistant, longitude: float) -> None: assert state.attributes.get("longitude") == longitude -def assert_location_accuracy(hass: HomeAssistant, accuracy: int) -> None: +def assert_location_accuracy(hass: HomeAssistant, accuracy: float) -> None: """Test the assertion of a location accuracy.""" state = hass.states.get(DEVICE_TRACKER_STATE) assert state.attributes.get("gps_accuracy") == accuracy diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index 5ef0efb0ab9..266a66b2760 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -43,7 +43,7 @@ def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: @pytest.fixture -def mock_client( +async def mock_client( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> TestClient: """Start the Home Assistant HTTP component.""" @@ -54,9 +54,9 @@ def mock_client( MockConfigEntry( domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"} ).add_to_hass(hass) - hass.loop.run_until_complete(async_setup_component(hass, "owntracks", {})) + await async_setup_component(hass, "owntracks", {}) - return hass.loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() async def test_handle_valid_message(mock_client) -> None: diff --git a/tests/components/p1_monitor/test_init.py b/tests/components/p1_monitor/test_init.py index 3b7426051d4..a8ce2646034 100644 --- a/tests/components/p1_monitor/test_init.py +++ b/tests/components/p1_monitor/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from p1monitor import P1MonitorConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.p1_monitor.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/palazzetti/conftest.py b/tests/components/palazzetti/conftest.py index d3694653cd4..ab1e6323247 100644 --- a/tests/components/palazzetti/conftest.py +++ b/tests/components/palazzetti/conftest.py @@ -65,6 +65,7 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.has_fan_auto = True mock_client.has_on_off_switch = True mock_client.has_pellet_level = False + mock_client.host = "XXXXXXXXXX" mock_client.connected = True mock_client.status = 6 mock_client.is_heating = True diff --git a/tests/components/palazzetti/test_button.py b/tests/components/palazzetti/test_button.py index de0f26fe8aa..85fd63d45d5 100644 --- a/tests/components/palazzetti/test_button.py +++ b/tests/components/palazzetti/test_button.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from pypalazzetti.exceptions import CommunicationError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/palazzetti/test_climate.py b/tests/components/palazzetti/test_climate.py index 22bd04f234e..d2aa17e71b3 100644 --- a/tests/components/palazzetti/test_climate.py +++ b/tests/components/palazzetti/test_climate.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from pypalazzetti.exceptions import CommunicationError, ValidationError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_FAN_MODE, diff --git a/tests/components/palazzetti/test_diagnostics.py b/tests/components/palazzetti/test_diagnostics.py index 80d021be511..e25ad7b9c6e 100644 --- a/tests/components/palazzetti/test_diagnostics.py +++ b/tests/components/palazzetti/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Palazzetti diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/palazzetti/test_init.py b/tests/components/palazzetti/test_init.py index 710144b2b7b..3002de1a0d2 100644 --- a/tests/components/palazzetti/test_init.py +++ b/tests/components/palazzetti/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant diff --git a/tests/components/palazzetti/test_number.py b/tests/components/palazzetti/test_number.py index 8f09384c1b7..6483834e190 100644 --- a/tests/components/palazzetti/test_number.py +++ b/tests/components/palazzetti/test_number.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from pypalazzetti.exceptions import CommunicationError, ValidationError from pypalazzetti.fan import FanType import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/palazzetti/test_sensor.py b/tests/components/palazzetti/test_sensor.py index c7d7317bb0b..55889692203 100644 --- a/tests/components/palazzetti/test_sensor.py +++ b/tests/components/palazzetti/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/pegel_online/test_diagnostics.py b/tests/components/pegel_online/test_diagnostics.py index 220f244b751..a5b08d4bae2 100644 --- a/tests/components/pegel_online/test_diagnostics.py +++ b/tests/components/pegel_online/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.pegel_online.const import CONF_STATION, DOMAIN diff --git a/tests/components/person/conftest.py b/tests/components/person/conftest.py index a6dc95ccc9e..2b1724f0c48 100644 --- a/tests/components/person/conftest.py +++ b/tests/components/person/conftest.py @@ -31,7 +31,7 @@ def storage_collection(hass: HomeAssistant) -> person.PersonStorageCollection: @pytest.fixture -def storage_setup( +async def storage_setup( hass: HomeAssistant, hass_storage: dict[str, Any], hass_admin_user: MockUser ) -> None: """Storage setup.""" @@ -49,4 +49,4 @@ def storage_setup( ] }, } - assert hass.loop.run_until_complete(async_setup_component(hass, DOMAIN, {})) + assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/pglab/snapshots/test_sensor.ambr b/tests/components/pglab/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..71889b65183 --- /dev/null +++ b/tests/components/pglab/snapshots/test_sensor.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_sensors[mpu_voltage][initial_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[mpu_voltage][updated_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.31', + }) +# --- +# name: test_sensors[run_time][initial_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[run_time][updated_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-26T01:04:54+00:00', + }) +# --- +# name: test_sensors[temperature][initial_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[temperature][updated_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33.4', + }) +# --- diff --git a/tests/components/pglab/test_common.py b/tests/components/pglab/test_common.py new file mode 100644 index 00000000000..0ff3271d5d6 --- /dev/null +++ b/tests/components/pglab/test_common.py @@ -0,0 +1,50 @@ +"""Common code for PG LAB Electronics tests.""" + +import json + +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message + + +def get_device_discovery_payload( + number_of_shutters: int, + number_of_boards: int, + device_name: str = "test", +) -> dict[str, any]: + """Return the device discovery payload.""" + + # be sure the number of shutters and boards are in the correct range + assert 0 <= number_of_boards <= 8 + assert 0 <= number_of_shutters <= (number_of_boards * 4) + + # define the number of E-RELAY boards connected to E-BOARD + boards = "1" * number_of_boards + "0" * (8 - number_of_boards) + + return { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": device_name, + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-BOARD", + "id": "E-BOARD-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": number_of_shutters, "boards": boards}, + } + + +async def send_discovery_message( + hass: HomeAssistant, + payload: dict[str, any] | None, +) -> None: + """Send the discovery message to make E-BOARD device discoverable.""" + + topic = "pglab/discovery/E-BOARD-DD53AC85/config" + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload if payload is not None else ""), + ) + await hass.async_block_till_done() diff --git a/tests/components/pglab/test_cover.py b/tests/components/pglab/test_cover.py new file mode 100644 index 00000000000..aa92e2da433 --- /dev/null +++ b/tests/components/pglab/test_cover.py @@ -0,0 +1,161 @@ +"""The tests for the PG LAB Electronics cover.""" + +from homeassistant.components import cover +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from .test_common import get_device_discovery_payload, send_discovery_message + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + +COVER_FEATURES = ( + cover.CoverEntityFeature.OPEN + | cover.CoverEntityFeature.CLOSE + | cover.CoverEntityFeature.STOP +) + + +async def call_service(hass: HomeAssistant, entity_id, service, **kwargs): + """Call a service.""" + await hass.services.async_call( + COVER_DOMAIN, + service, + {"entity_id": entity_id, **kwargs}, + blocking=True, + ) + + +async def test_cover_features( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Test cover features.""" + + payload = get_device_discovery_payload( + number_of_shutters=4, + number_of_boards=1, + ) + + await send_discovery_message(hass, payload) + + assert len(hass.states.async_all("cover")) == 4 + + for i in range(4): + cover = hass.states.get(f"cover.test_shutter_{i}") + assert cover + assert cover.attributes["supported_features"] == COVER_FEATURES + + +async def test_cover_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Check if covers are properly created.""" + + payload = get_device_discovery_payload( + number_of_shutters=6, + number_of_boards=2, + ) + + await send_discovery_message(hass, payload) + + # We are creating 6 covers using two E-RELAY devices connected to E-BOARD. + # Now we are going to check if all covers are created and their state is unknown. + for i in range(5): + cover = hass.states.get(f"cover.test_shutter_{i}") + assert cover.state == STATE_UNKNOWN + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + + # The cover with id 7 should not be created. + cover = hass.states.get("cover.test_shutter_7") + assert not cover + + +async def test_cover_change_state_via_mqtt( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Test state update via MQTT.""" + payload = get_device_discovery_payload( + number_of_shutters=2, + number_of_boards=1, + ) + + await send_discovery_message(hass, payload) + + # Check initial state is unknown + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_UNKNOWN + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + + # Simulate the device responds sending mqtt messages and check if the cover state + # change appropriately. + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "OPEN") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + assert cover.state == STATE_OPEN + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "OPENING") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_OPENING + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "CLOSING") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_CLOSING + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "CLOSED") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_CLOSED + + +async def test_cover_mqtt_state_by_calling_service( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Calling service to OPEN/CLOSE cover and check mqtt state.""" + + payload = get_device_discovery_payload( + number_of_shutters=2, + number_of_boards=1, + ) + + await send_discovery_message(hass, payload) + + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_UNKNOWN + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + + # Call HA covers services and verify that the MQTT messages are sent correctly + + await call_service(hass, "cover.test_shutter_0", SERVICE_OPEN_COVER) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/shutter/0/set", "OPEN", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + await call_service(hass, "cover.test_shutter_0", SERVICE_STOP_COVER) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/shutter/0/set", "STOP", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + await call_service(hass, "cover.test_shutter_0", SERVICE_CLOSE_COVER) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/shutter/0/set", "CLOSE", 0, False + ) + mqtt_mock.async_publish.reset_mock() diff --git a/tests/components/pglab/test_discovery.py b/tests/components/pglab/test_discovery.py index 65716236277..df897264163 100644 --- a/tests/components/pglab/test_discovery.py +++ b/tests/components/pglab/test_discovery.py @@ -1,13 +1,12 @@ """The tests for the PG LAB Electronics discovery device.""" -import json - from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from tests.common import async_fire_mqtt_message +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.typing import MqttMockHAClient @@ -19,25 +18,13 @@ async def test_device_discover( setup_pglab, ) -> None: """Test setting up a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device and registry entries are created device_entry = device_reg.async_get_device( @@ -60,25 +47,12 @@ async def test_device_update( snapshot: SnapshotAssertion, ) -> None: """Test update a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -90,12 +64,7 @@ async def test_device_update( payload["fw"] = "1.0.1" payload["hw"] = "1.0.8" - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), - ) - await hass.async_block_till_done() + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -114,25 +83,12 @@ async def test_device_remove( setup_pglab, ) -> None: """Test remove a device.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Verify device is created device_entry = device_reg.async_get_device( @@ -140,12 +96,7 @@ async def test_device_remove( ) assert device_entry is not None - async_fire_mqtt_message( - hass, - topic, - "", - ) - await hass.async_block_till_done() + await send_discovery_message(hass, None) # Verify device entry is removed device_entry = device_reg.async_get_device( diff --git a/tests/components/pglab/test_sensor.py b/tests/components/pglab/test_sensor.py new file mode 100644 index 00000000000..0991d6bd814 --- /dev/null +++ b/tests/components/pglab/test_sensor.py @@ -0,0 +1,54 @@ +"""The tests for the PG LAB Electronics sensor.""" + +import json + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from .test_common import get_device_discovery_payload, send_discovery_message + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +@freeze_time("2024-02-26 01:21:34") +@pytest.mark.parametrize( + "sensor_suffix", + [ + "temperature", + "mpu_voltage", + "run_time", + ], +) +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mqtt_mock: MqttMockHAClient, + setup_pglab, + sensor_suffix: str, +) -> None: + """Check if sensors are properly created and updated.""" + + # send the discovery message to make E-BOARD device discoverable + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=0, + ) + + await send_discovery_message(hass, payload) + + # check initial sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"initial_sensor_{sensor_suffix}") + + # update sensors value via mqtt + update_payload = {"temp": 33.4, "volt": 3.31, "rtime": 1000} + async_fire_mqtt_message(hass, "pglab/test/sensor/value", json.dumps(update_payload)) + await hass.async_block_till_done() + + # check updated sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"updated_sensor_{sensor_suffix}") diff --git a/tests/components/pglab/test_switch.py b/tests/components/pglab/test_switch.py index fef445f80f3..0f1a2e4bb04 100644 --- a/tests/components/pglab/test_switch.py +++ b/tests/components/pglab/test_switch.py @@ -1,7 +1,6 @@ """The tests for the PG LAB Electronics switch.""" from datetime import timedelta -import json from homeassistant import config_entries from homeassistant.components.switch import ( @@ -20,6 +19,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util +from .test_common import get_device_discovery_payload, send_discovery_message + from tests.common import async_fire_mqtt_message, async_fire_time_changed from tests.typing import MqttMockHAClient @@ -38,25 +39,13 @@ async def test_available_relay( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Check if relay are properly created when two E-Relay boards are connected.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) for i in range(16): state = hass.states.get(f"switch.test_relay_{i}") @@ -68,25 +57,13 @@ async def test_change_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Test state update via MQTT.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Simulate response from the device state = hass.states.get("switch.test_relay_0") @@ -123,25 +100,13 @@ async def test_mqtt_state_by_calling_service( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab ) -> None: """Calling service to turn ON/OFF relay and check mqtt state.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Turn relay ON await call_service(hass, "switch.test_relay_0", SERVICE_TURN_ON) @@ -177,26 +142,13 @@ async def test_discovery_update( ) -> None: """Update discovery message and check if relay are property updated.""" - # publish the first discovery message - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "first_test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + device_name="first_test", + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # test the available relay in the first configuration for i in range(8): @@ -206,25 +158,13 @@ async def test_discovery_update( # prepare a new message ... the same device but renamed # and with different relay configuration - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "second_test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "11000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + device_name="second_test", + number_of_shutters=0, + number_of_boards=2, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # be sure that old relay are been removed for i in range(8): @@ -245,25 +185,12 @@ async def test_disable_entity_state_change_via_mqtt( ) -> None: """Test state update via MQTT of disable entity.""" - topic = "pglab/discovery/E-Board-DD53AC85/config" - payload = { - "ip": "192.168.1.16", - "mac": "80:34:28:1B:18:5A", - "name": "test", - "hw": "1.0.7", - "fw": "1.0.0", - "type": "E-Board", - "id": "E-Board-DD53AC85", - "manufacturer": "PG LAB Electronics", - "params": {"shutters": 0, "boards": "10000000"}, - } - - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), + payload = get_device_discovery_payload( + number_of_shutters=0, + number_of_boards=1, ) - await hass.async_block_till_done() + + await send_discovery_message(hass, payload) # Be sure that the entity relay_0 is available state = hass.states.get("switch.test_relay_0") @@ -298,12 +225,7 @@ async def test_disable_entity_state_change_via_mqtt( await hass.async_block_till_done() # Re-send the discovery message - async_fire_mqtt_message( - hass, - topic, - json.dumps(payload), - ) - await hass.async_block_till_done() + await send_discovery_message(hass, payload) # Be sure that the state is not changed state = hass.states.get("switch.test_relay_0") diff --git a/tests/components/philips_js/test_diagnostics.py b/tests/components/philips_js/test_diagnostics.py index d61546e52c3..0d8909c86be 100644 --- a/tests/components/philips_js/test_diagnostics.py +++ b/tests/components/philips_js/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from haphilipsjs.typing import ChannelListType, ContextType, FavoriteListType -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/ping/snapshots/test_sensor.ambr b/tests/components/ping/snapshots/test_sensor.ambr index bb811af6a34..6b86c327863 100644 --- a/tests/components/ping/snapshots/test_sensor.ambr +++ b/tests/components/ping/snapshots/test_sensor.ambr @@ -26,7 +26,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Round Trip Time Average', + 'original_name': 'Round-trip time average', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -38,7 +38,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': '10.10.10.10 Round Trip Time Average', + 'friendly_name': '10.10.10.10 Round-trip time average', 'state_class': , 'unit_of_measurement': , }), @@ -77,7 +77,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Round Trip Time Maximum', + 'original_name': 'Round-trip time maximum', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -89,7 +89,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': '10.10.10.10 Round Trip Time Maximum', + 'friendly_name': '10.10.10.10 Round-trip time maximum', 'state_class': , 'unit_of_measurement': , }), @@ -134,7 +134,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Round Trip Time Minimum', + 'original_name': 'Round-trip time minimum', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -146,7 +146,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': '10.10.10.10 Round Trip Time Minimum', + 'friendly_name': '10.10.10.10 Round-trip time minimum', 'state_class': , 'unit_of_measurement': , }), diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index 660b5ca31f1..93742ca9005 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory from icmplib import Host import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.ping.const import CONF_IMPORTED_BY, DOMAIN diff --git a/tests/components/ping/test_sensor.py b/tests/components/ping/test_sensor.py index 5c4833aaf06..bdc8b7d28e4 100644 --- a/tests/components/ping/test_sensor.py +++ b/tests/components/ping/test_sensor.py @@ -1,7 +1,7 @@ """Test sensor platform of Ping.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/plaato/test_binary_sensor.py b/tests/components/plaato/test_binary_sensor.py index 73d378dd531..5542c79e8ea 100644 --- a/tests/components/plaato/test_binary_sensor.py +++ b/tests/components/plaato/test_binary_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pyplaato.models.device import PlaatoDeviceType import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/plaato/test_sensor.py b/tests/components/plaato/test_sensor.py index e4574634c4b..63e9255faa0 100644 --- a/tests/components/plaato/test_sensor.py +++ b/tests/components/plaato/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pyplaato.models.device import PlaatoDeviceType import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 42dcf449168..2644f0f21c6 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -856,7 +856,7 @@ async def test_client_header_issues(hass: HomeAssistant) -> None: patch("plexauth.PlexAuth.initiate_auth"), patch("plexauth.PlexAuth.token", return_value=None), patch( - "homeassistant.components.http.current_request.get", + "homeassistant.helpers.http.current_request.get", return_value=MockRequest(), ), pytest.raises( diff --git a/tests/components/plex/test_update.py b/tests/components/plex/test_update.py index 7ad2481a726..dbdee5f9390 100644 --- a/tests/components/plex/test_update.py +++ b/tests/components/plex/test_update.py @@ -16,7 +16,7 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator -UPDATE_ENTITY = "update.plex_media_server_plex_server_1" +UPDATE_ENTITY = "update.plex_server_1_update" async def test_plex_update( diff --git a/tests/components/plugwise/test_diagnostics.py b/tests/components/plugwise/test_diagnostics.py index a2b0521d6e1..dbfd810d4dc 100644 --- a/tests/components/plugwise/test_diagnostics.py +++ b/tests/components/plugwise/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index ea003af86c7..4de1c9a4583 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -10,7 +10,6 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.point.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -157,16 +156,3 @@ async def test_reauthentication_flow( assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected assert old_entry.unique_id == expected_unique_id - - -async def test_import_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test import flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "pick_implementation" diff --git a/tests/components/poolsense/test_binary_sensor.py b/tests/components/poolsense/test_binary_sensor.py index 4d10413c124..debf0faa52a 100644 --- a/tests/components/poolsense/test_binary_sensor.py +++ b/tests/components/poolsense/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/poolsense/test_sensor.py b/tests/components/poolsense/test_sensor.py index 7f088eee6a3..bac5dd8c701 100644 --- a/tests/components/poolsense/test_sensor.py +++ b/tests/components/poolsense/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/powerfox/test_diagnostics.py b/tests/components/powerfox/test_diagnostics.py index 7dc2c3c7263..220c809a5f9 100644 --- a/tests/components/powerfox/test_diagnostics.py +++ b/tests/components/powerfox/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/powerfox/test_sensor.py b/tests/components/powerfox/test_sensor.py index 547d8de202c..2dfc1227d77 100644 --- a/tests/components/powerfox/test_sensor.py +++ b/tests/components/powerfox/test_sensor.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from powerfox import PowerfoxConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, Platform diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index 9b533304fbc..7f23550f522 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -118,7 +118,6 @@ async def test_sensors(hass: HomeAssistant, device_registry: dr.DeviceRegistry) expected_attributes = { "unit_of_measurement": PERCENTAGE, "friendly_name": "MySite Backup reserve", - "device_class": "battery", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears diff --git a/tests/components/pterodactyl/__init__.py b/tests/components/pterodactyl/__init__.py new file mode 100644 index 00000000000..0142399ec42 --- /dev/null +++ b/tests/components/pterodactyl/__init__.py @@ -0,0 +1,16 @@ +"""Tests for the Pterodactyl integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up Pterodactyl mock config entry.""" + mock_config_entry.add_to_hass(hass) + 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/pterodactyl/conftest.py b/tests/components/pterodactyl/conftest.py new file mode 100644 index 00000000000..c395410b6ae --- /dev/null +++ b/tests/components/pterodactyl/conftest.py @@ -0,0 +1,64 @@ +"""Common fixtures for the Pterodactyl tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pydactyl.responses import PaginatedResponse +import pytest + +from homeassistant.components.pterodactyl.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_URL + +from .const import TEST_API_KEY, TEST_URL + +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.pterodactyl.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create Pterodactyl mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=None, + entry_id="01234567890123456789012345678901", + title=TEST_URL, + data={ + CONF_URL: TEST_URL, + CONF_API_KEY: TEST_API_KEY, + }, + version=1, + ) + + +@pytest.fixture +def mock_pterodactyl() -> Generator[AsyncMock]: + """Mock the Pterodactyl API.""" + with patch( + "homeassistant.components.pterodactyl.api.PterodactylClient", autospec=True + ) as mock: + server_list_data = load_json_object_fixture("server_list_data.json", DOMAIN) + server_1_data = load_json_object_fixture("server_1_data.json", DOMAIN) + server_2_data = load_json_object_fixture("server_2_data.json", DOMAIN) + utilization_data = load_json_object_fixture("utilization_data.json", DOMAIN) + + mock.return_value.client.servers.list_servers.return_value = PaginatedResponse( + mock.return_value, "client", server_list_data + ) + mock.return_value.client.servers.get_server.side_effect = [ + server_1_data, + server_2_data, + ] + mock.return_value.client.servers.get_server_utilization.return_value = ( + utilization_data + ) + + yield mock.return_value diff --git a/tests/components/pterodactyl/const.py b/tests/components/pterodactyl/const.py new file mode 100644 index 00000000000..f6684a82fc5 --- /dev/null +++ b/tests/components/pterodactyl/const.py @@ -0,0 +1,12 @@ +"""Constants for Pterodactyl tests.""" + +from homeassistant.const import CONF_API_KEY, CONF_URL + +TEST_URL = "https://192.168.0.1:8080/" + +TEST_API_KEY = "TestClientApiKey" + +TEST_USER_INPUT = { + CONF_URL: TEST_URL, + CONF_API_KEY: TEST_API_KEY, +} diff --git a/tests/components/pterodactyl/fixtures/server_1_data.json b/tests/components/pterodactyl/fixtures/server_1_data.json new file mode 100644 index 00000000000..c780d55b318 --- /dev/null +++ b/tests/components/pterodactyl/fixtures/server_1_data.json @@ -0,0 +1,39 @@ +{ + "server_owner": true, + "identifier": "1", + "internal_id": 1, + "uuid": "1-1-1-1-1", + "name": "Test Server 1", + "node": "default_node", + "is_node_under_maintenance": false, + "sftp_details": { + "ip": "192.168.0.1", + "port": 2022 + }, + "description": "", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": null, + "oom_disabled": true + }, + "invocation": "java -jar test1.jar", + "docker_image": "test_docker_image1", + "egg_features": ["eula", "java_version", "pid_limit"], + "feature_limits": { + "databases": 0, + "allocations": 0, + "backups": 3 + }, + "status": null, + "is_suspended": false, + "is_installing": false, + "is_transferring": false, + "relationships": { + "allocations": {}, + "variables": {} + } +} diff --git a/tests/components/pterodactyl/fixtures/server_2_data.json b/tests/components/pterodactyl/fixtures/server_2_data.json new file mode 100644 index 00000000000..b240ff62ced --- /dev/null +++ b/tests/components/pterodactyl/fixtures/server_2_data.json @@ -0,0 +1,39 @@ +{ + "server_owner": true, + "identifier": "2", + "internal_id": 2, + "uuid": "2-2-2-2-2", + "name": "Test Server 2", + "node": "default_node", + "is_node_under_maintenance": false, + "sftp_details": { + "ip": "192.168.0.2", + "port": 2022 + }, + "description": "", + "limits": { + "memory": 4096, + "swap": 2048, + "disk": 20480, + "io": 1000, + "cpu": 200, + "threads": null, + "oom_disabled": true + }, + "invocation": "java -jar test2.jar", + "docker_image": "test_docker_image2", + "egg_features": ["eula", "java_version", "pid_limit"], + "feature_limits": { + "databases": 1, + "allocations": 1, + "backups": 5 + }, + "status": null, + "is_suspended": false, + "is_installing": false, + "is_transferring": false, + "relationships": { + "allocations": {}, + "variables": {} + } +} diff --git a/tests/components/pterodactyl/fixtures/server_list_data.json b/tests/components/pterodactyl/fixtures/server_list_data.json new file mode 100644 index 00000000000..d8796ad533e --- /dev/null +++ b/tests/components/pterodactyl/fixtures/server_list_data.json @@ -0,0 +1,60 @@ +{ + "meta": { + "pagination": { + "total": 2, + "count": 2, + "per_page": 50, + "current_page": 1 + } + }, + "data": [ + { + "object": "server", + "attributes": { + "server_owner": true, + "identifier": "1", + "internal_id": 1, + "uuid": "1-1-1-1-1", + "name": "Test Server 1", + "node": "default_node", + "description": "Description of Test Server 1", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": null, + "oom_disabled": true + }, + "invocation": "java -jar test1.jar", + "docker_image": "test_docker_image_1", + "egg_features": ["java_version"] + } + }, + { + "object": "server", + "attributes": { + "server_owner": true, + "identifier": "2", + "internal_id": 2, + "uuid": "2-2-2-2-2", + "name": "Test Server 2", + "node": "default_node", + "description": "Description of Test Server 2", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": null, + "oom_disabled": true + }, + "invocation": "java -jar test2.jar", + "docker_image": "test_docker_image2", + "egg_features": ["java_version"] + } + } + ] +} diff --git a/tests/components/pterodactyl/fixtures/utilization_data.json b/tests/components/pterodactyl/fixtures/utilization_data.json new file mode 100644 index 00000000000..6b71cb44635 --- /dev/null +++ b/tests/components/pterodactyl/fixtures/utilization_data.json @@ -0,0 +1,12 @@ +{ + "current_state": "running", + "is_suspended": false, + "resources": { + "memory_bytes": 1111, + "cpu_absolute": 22, + "disk_bytes": 3333, + "network_rx_bytes": 44, + "network_tx_bytes": 55, + "uptime": 6666 + } +} diff --git a/tests/components/pterodactyl/snapshots/test_binary_sensor.ambr b/tests/components/pterodactyl/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..9bd7abc830b --- /dev/null +++ b/tests/components/pterodactyl/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.test_server_1_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_server_1_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': 'pterodactyl', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': '1-1-1-1-1_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_server_1_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Server 1 Status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_server_1_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_server_2_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_server_2_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': 'pterodactyl', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': '2-2-2-2-2_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_server_2_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Server 2 Status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_server_2_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/pterodactyl/test_binary_sensor.py b/tests/components/pterodactyl/test_binary_sensor.py new file mode 100644 index 00000000000..4bacd30e011 --- /dev/null +++ b/tests/components/pterodactyl/test_binary_sensor.py @@ -0,0 +1,89 @@ +"""Tests for the binary sensor platform of the Pterodactyl integration.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from requests.exceptions import ConnectionError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_ON, 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_pterodactyl") +async def test_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensor.""" + with patch( + "homeassistant.components.pterodactyl._PLATFORMS", [Platform.BINARY_SENSOR] + ): + mock_config_entry = await setup_integration(hass, mock_config_entry) + + assert len(hass.states.async_all(Platform.BINARY_SENSOR)) == 2 + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("mock_pterodactyl") +async def test_binary_sensor_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary sensor update.""" + await setup_integration(hass, mock_config_entry) + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(hass.states.async_all(Platform.BINARY_SENSOR)) == 2 + assert ( + hass.states.get(f"{Platform.BINARY_SENSOR}.test_server_1_status").state + == STATE_ON + ) + assert ( + hass.states.get(f"{Platform.BINARY_SENSOR}.test_server_2_status").state + == STATE_ON + ) + + +async def test_binary_sensor_update_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pterodactyl: Generator[AsyncMock], + freezer: FrozenDateTimeFactory, +) -> None: + """Test failed binary sensor update.""" + await setup_integration(hass, mock_config_entry) + + mock_pterodactyl.client.servers.get_server.side_effect = ConnectionError( + "Simulated connection error" + ) + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(hass.states.async_all(Platform.BINARY_SENSOR)) == 2 + assert ( + hass.states.get(f"{Platform.BINARY_SENSOR}.test_server_1_status").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get(f"{Platform.BINARY_SENSOR}.test_server_2_status").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py new file mode 100644 index 00000000000..8837fbe753b --- /dev/null +++ b/tests/components/pterodactyl/test_config_flow.py @@ -0,0 +1,174 @@ +"""Test the Pterodactyl config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock + +from pydactyl.exceptions import BadRequestError, PterodactylApiError +import pytest +from requests.exceptions import HTTPError +from requests.models import Response + +from homeassistant.components.pterodactyl.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import TEST_API_KEY, TEST_URL, TEST_USER_INPUT + +from tests.common import MockConfigEntry + + +def mock_response(): + """Mock HTTP response.""" + mock = Response() + mock.status_code = 401 + + return mock + + +@pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") +async def test_full_flow(hass: HomeAssistant) -> None: + """Test full flow without errors.""" + 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( + flow_id=result["flow_id"], + user_input=TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_URL + assert result["data"] == TEST_USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception_type", "expected_error"), + [ + (PterodactylApiError, "cannot_connect"), + (BadRequestError, "cannot_connect"), + (Exception, "unknown"), + (HTTPError(response=mock_response()), "invalid_auth"), + ], +) +async def test_recovery_after_error( + hass: HomeAssistant, + exception_type: Exception, + expected_error: str, + mock_pterodactyl: Generator[AsyncMock], +) -> None: + """Test recovery after an error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_pterodactyl.client.servers.list_servers.side_effect = exception_type + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input=TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_pterodactyl.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_URL + assert result["data"] == TEST_USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_pterodactyl") +async def test_service_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow abort if the Pterodactyl server is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") +async def test_reauth_full_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth config flow 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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_URL] == TEST_URL + assert mock_config_entry.data[CONF_API_KEY] == TEST_API_KEY + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception_type", "expected_error"), + [ + (PterodactylApiError, "cannot_connect"), + (BadRequestError, "cannot_connect"), + (Exception, "unknown"), + (HTTPError(response=mock_response()), "invalid_auth"), + ], +) +async def test_reauth_recovery_after_error( + hass: HomeAssistant, + exception_type: Exception, + expected_error: str, + mock_config_entry: MockConfigEntry, + mock_pterodactyl: Generator[AsyncMock], +) -> None: + """Test recovery after an error during re-authentication.""" + 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_pterodactyl.client.servers.list_servers.side_effect = exception_type + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_pterodactyl.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_URL] == TEST_URL + assert mock_config_entry.data[CONF_API_KEY] == TEST_API_KEY diff --git a/tests/components/purpleair/conftest.py b/tests/components/purpleair/conftest.py index 1809b16bd75..a9a51c22b7c 100644 --- a/tests/components/purpleair/conftest.py +++ b/tests/components/purpleair/conftest.py @@ -8,7 +8,7 @@ from aiopurpleair.endpoints.sensors import NearbySensorResult from aiopurpleair.models.sensors import GetSensorsResponse import pytest -from homeassistant.components.purpleair import DOMAIN +from homeassistant.components.purpleair.const import DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -20,7 +20,7 @@ TEST_SENSOR_INDEX2 = 567890 @pytest.fixture(name="api") def api_fixture(get_sensors_response: GetSensorsResponse) -> Mock: - """Define a fixture to return a mocked aiopurple API object.""" + """Define a fixture to return a mocked aiopurpleair API object.""" return Mock( async_check_api_key=AsyncMock(), get_map_url=Mock(return_value="http://example.com"), diff --git a/tests/components/purpleair/test_config_flow.py b/tests/components/purpleair/test_config_flow.py index 998cb2b7878..5ee15de4e6b 100644 --- a/tests/components/purpleair/test_config_flow.py +++ b/tests/components/purpleair/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from aiopurpleair.errors import InvalidApiKeyError, PurpleAirError import pytest -from homeassistant.components.purpleair import DOMAIN +from homeassistant.components.purpleair.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -288,6 +288,7 @@ async def test_options_remove_sensor( device_entry = device_registry.async_get_device( identifiers={(DOMAIN, str(TEST_SENSOR_INDEX1))} ) + assert device_entry is not None result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"sensor_device_id": device_entry.id}, diff --git a/tests/components/pushover/test_config_flow.py b/tests/components/pushover/test_config_flow.py index 58485bfb427..a3c9ac3ccbb 100644 --- a/tests/components/pushover/test_config_flow.py +++ b/tests/components/pushover/test_config_flow.py @@ -217,7 +217,7 @@ async def test_reauth_with_existing_config(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_API_KEY: "MYAPIKEY2", + CONF_API_KEY: MOCK_CONFIG[CONF_API_KEY], }, ) diff --git a/tests/components/pvoutput/test_sensor.py b/tests/components/pvoutput/test_sensor.py index fbcff94be60..36a37653efe 100644 --- a/tests/components/pvoutput/test_sensor.py +++ b/tests/components/pvoutput/test_sensor.py @@ -30,8 +30,8 @@ async def test_sensors( ) -> None: """Test the PVOutput sensors.""" - state = hass.states.get("sensor.frenck_s_solar_farm_energy_consumed") - entry = entity_registry.async_get("sensor.frenck_s_solar_farm_energy_consumed") + state = hass.states.get("sensor.frenck_s_solar_farm_energy_consumption") + entry = entity_registry.async_get("sensor.frenck_s_solar_farm_energy_consumption") assert entry assert state assert entry.unique_id == "12345_energy_consumption" @@ -40,14 +40,14 @@ async def test_sensors( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ( state.attributes.get(ATTR_FRIENDLY_NAME) - == "Frenck's Solar Farm Energy consumed" + == "Frenck's Solar Farm Energy consumption" ) assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.WATT_HOUR assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.frenck_s_solar_farm_energy_generated") - entry = entity_registry.async_get("sensor.frenck_s_solar_farm_energy_generated") + state = hass.states.get("sensor.frenck_s_solar_farm_energy_generation") + entry = entity_registry.async_get("sensor.frenck_s_solar_farm_energy_generation") assert entry assert state assert entry.unique_id == "12345_energy_generation" @@ -56,7 +56,7 @@ async def test_sensors( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ( state.attributes.get(ATTR_FRIENDLY_NAME) - == "Frenck's Solar Farm Energy generated" + == "Frenck's Solar Farm Energy generation" ) assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.WATT_HOUR @@ -78,8 +78,8 @@ async def test_sensors( assert ATTR_DEVICE_CLASS not in state.attributes assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.frenck_s_solar_farm_power_consumed") - entry = entity_registry.async_get("sensor.frenck_s_solar_farm_power_consumed") + state = hass.states.get("sensor.frenck_s_solar_farm_power_consumption") + entry = entity_registry.async_get("sensor.frenck_s_solar_farm_power_consumption") assert entry assert state assert entry.unique_id == "12345_power_consumption" @@ -87,14 +87,15 @@ async def test_sensors( assert state.state == "2500.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's Solar Farm Power consumed" + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Frenck's Solar Farm Power consumption" ) assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.frenck_s_solar_farm_power_generated") - entry = entity_registry.async_get("sensor.frenck_s_solar_farm_power_generated") + state = hass.states.get("sensor.frenck_s_solar_farm_power_generation") + entry = entity_registry.async_get("sensor.frenck_s_solar_farm_power_generation") assert entry assert state assert entry.unique_id == "12345_power_generation" @@ -103,7 +104,7 @@ async def test_sensors( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ( state.attributes.get(ATTR_FRIENDLY_NAME) - == "Frenck's Solar Farm Power generated" + == "Frenck's Solar Farm Power generation" ) assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index 46144771cc1..9b410a5fdd6 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -19,10 +20,8 @@ from homeassistant.const import ( from tests.common import MockConfigEntry USER_INPUT = { - CONF_HOST: "pyload.local", + CONF_URL: "https://pyload.local:8000/prefix", CONF_PASSWORD: "test-password", - CONF_PORT: 8000, - CONF_SSL: True, CONF_USERNAME: "test-username", CONF_VERIFY_SSL: False, } @@ -33,10 +32,8 @@ REAUTH_INPUT = { } NEW_INPUT = { - CONF_HOST: "pyload.local", + CONF_URL: "https://pyload.local:8000/prefix", CONF_PASSWORD: "new-password", - CONF_PORT: 8000, - CONF_SSL: True, CONF_USERNAME: "new-username", CONF_VERIFY_SSL: False, } @@ -97,5 +94,28 @@ def mock_pyloadapi() -> Generator[MagicMock]: def mock_config_entry() -> MockConfigEntry: """Mock pyLoad configuration entry.""" return MockConfigEntry( - domain=DOMAIN, title=DEFAULT_NAME, data=USER_INPUT, entry_id="XXXXXXXXXXXXXX" + domain=DOMAIN, + title=DEFAULT_NAME, + data=USER_INPUT, + entry_id="XXXXXXXXXXXXXX", + ) + + +@pytest.fixture(name="config_entry_migrate") +def mock_config_entry_migrate() -> MockConfigEntry: + """Mock pyLoad configuration entry for migration.""" + return MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={ + CONF_HOST: "pyload.local", + CONF_PASSWORD: "test-password", + CONF_PORT: 8000, + CONF_SSL: True, + CONF_USERNAME: "test-username", + CONF_VERIFY_SSL: False, + }, + version=1, + minor_version=0, + entry_id="XXXXXXXXXXXXXX", ) diff --git a/tests/components/pyload/snapshots/test_diagnostics.ambr b/tests/components/pyload/snapshots/test_diagnostics.ambr index e2b51ad184a..d773804bf73 100644 --- a/tests/components/pyload/snapshots/test_diagnostics.ambr +++ b/tests/components/pyload/snapshots/test_diagnostics.ambr @@ -2,10 +2,8 @@ # name: test_diagnostics dict({ 'config_entry_data': dict({ - 'host': '**REDACTED**', 'password': '**REDACTED**', - 'port': 8000, - 'ssl': True, + 'url': 'https://**redacted**:8000/prefix', 'username': '**REDACTED**', 'verify_ssl': False, }), @@ -15,6 +13,7 @@ 'download': True, 'free_space': 99999999999, 'pause': False, + 'proxy': None, 'queue': 6, 'reconnect': False, 'speed': 5405963.0, diff --git a/tests/components/pyload/test_init.py b/tests/components/pyload/test_init.py index 00b1f0aa3a8..5c85979b9df 100644 --- a/tests/components/pyload/test_init.py +++ b/tests/components/pyload/test_init.py @@ -8,6 +8,7 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_PATH, CONF_URL from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed @@ -88,3 +89,22 @@ async def test_coordinator_update_invalid_auth( await hass.async_block_till_done() assert any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_migration( + hass: HomeAssistant, + config_entry_migrate: MockConfigEntry, +) -> None: + """Test config entry migration.""" + + config_entry_migrate.add_to_hass(hass) + assert config_entry_migrate.data.get(CONF_PATH) is None + + await hass.config_entries.async_setup(config_entry_migrate.entry_id) + await hass.async_block_till_done() + + assert config_entry_migrate.state is ConfigEntryState.LOADED + assert config_entry_migrate.version == 1 + assert config_entry_migrate.minor_version == 1 + assert config_entry_migrate.data[CONF_URL] == "https://pyload.local:8000/" diff --git a/tests/components/qbus/conftest.py b/tests/components/qbus/conftest.py index 8268d091bda..f1fd96c321b 100644 --- a/tests/components/qbus/conftest.py +++ b/tests/components/qbus/conftest.py @@ -1,5 +1,7 @@ """Test fixtures for qbus.""" +import json + import pytest from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN @@ -7,9 +9,13 @@ from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonObjectType -from .const import FIXTURE_PAYLOAD_CONFIG +from .const import FIXTURE_PAYLOAD_CONFIG, TOPIC_CONFIG -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import ( + MockConfigEntry, + async_fire_mqtt_message, + load_json_object_fixture, +) @pytest.fixture @@ -31,3 +37,18 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: def payload_config() -> JsonObjectType: """Return the config topic payload.""" return load_json_object_fixture(FIXTURE_PAYLOAD_CONFIG, DOMAIN) + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> None: + """Set up the integration.""" + + 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() diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json index e2c7f463e4e..fc204c975ad 100644 --- a/tests/components/qbus/fixtures/payload_config.json +++ b/tests/components/qbus/fixtures/payload_config.json @@ -46,7 +46,7 @@ { "id": "UL15", "location": "Media room", - "locationId": 0, + "locationId": 1, "name": "MEDIA ROOM", "originalName": "MEDIA ROOM", "refId": "000001/28", @@ -65,6 +65,40 @@ "write": true } } + }, + { + "id": "UL20", + "location": "Living", + "locationId": 0, + "name": "LIVING TH", + "originalName": "LIVING TH", + "refId": "000001/120", + "type": "thermo", + "actions": {}, + "properties": { + "currRegime": { + "enumValues": ["MANUEEL", "VORST", "ECONOMY", "COMFORT", "NACHT"], + "read": true, + "type": "enumString", + "write": true + }, + "currTemp": { + "max": 35, + "min": 0, + "read": true, + "step": 0.5, + "type": "number", + "write": false + }, + "setTemp": { + "max": 35, + "min": 0, + "read": true, + "step": 0.5, + "type": "number", + "write": true + } + } } ] } diff --git a/tests/components/qbus/test_climate.py b/tests/components/qbus/test_climate.py new file mode 100644 index 00000000000..d521e310984 --- /dev/null +++ b/tests/components/qbus/test_climate.py @@ -0,0 +1,228 @@ +"""Test Qbus light entities.""" + +from datetime import timedelta +from unittest.mock import MagicMock, call + +import pytest + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntity, + HVACAction, + HVACMode, +) +from homeassistant.components.qbus.climate import STATE_REQUEST_DELAY +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_mqtt_message, async_fire_time_changed +from tests.typing import MqttMockHAClient + +_CURRENT_TEMPERATURE = 21.5 +_SET_TEMPERATURE = 20.5 +_REGIME = "COMFORT" + +_PAYLOAD_CLIMATE_STATE_TEMP = ( + f'{{"id":"UL20","properties":{{"setTemp":{_SET_TEMPERATURE}}},"type":"event"}}' +) +_PAYLOAD_CLIMATE_STATE_TEMP_FULL = f'{{"id":"UL20","properties":{{"currRegime":"MANUEEL","currTemp":{_CURRENT_TEMPERATURE},"setTemp":{_SET_TEMPERATURE}}},"type":"state"}}' + +_PAYLOAD_CLIMATE_STATE_PRESET = ( + f'{{"id":"UL20","properties":{{"currRegime":"{_REGIME}"}},"type":"event"}}' +) +_PAYLOAD_CLIMATE_STATE_PRESET_FULL = f'{{"id":"UL20","properties":{{"currRegime":"{_REGIME}","currTemp":{_CURRENT_TEMPERATURE},"setTemp":22.0}},"type":"state"}}' + +_PAYLOAD_CLIMATE_SET_TEMP = f'{{"id": "UL20", "type": "state", "properties": {{"setTemp": {_SET_TEMPERATURE}}}}}' +_PAYLOAD_CLIMATE_SET_PRESET = ( + '{"id": "UL20", "type": "state", "properties": {"currRegime": "COMFORT"}}' +) + +_TOPIC_CLIMATE_STATE = "cloudapp/QBUSMQTTGW/UL1/UL20/state" +_TOPIC_CLIMATE_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL20/setState" +_TOPIC_GET_STATE = "cloudapp/QBUSMQTTGW/getState" + +_CLIMATE_ENTITY_ID = "climate.living_th" + + +async def test_climate( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate temperature & preset.""" + + # Set temperature + mqtt_mock.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_TEMPERATURE: _SET_TEMPERATURE, + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_CLIMATE_SET_STATE, _PAYLOAD_CLIMATE_SET_TEMP, 0, False + ) + + # Simulate a partial state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + + # Check state + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] is None + assert entity.attributes[ATTR_PRESET_MODE] == "MANUEEL" + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # After a delay, a full state request should've been sent + _wait_and_assert_state_request(hass, mqtt_mock) + + # Simulate a full state response + async_fire_mqtt_message( + hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP_FULL + ) + await hass.async_block_till_done() + + # Check state after full state response + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == "MANUEEL" + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # Set preset + mqtt_mock.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_PRESET_MODE: _REGIME, + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_CLIMATE_SET_STATE, _PAYLOAD_CLIMATE_SET_PRESET, 0, False + ) + + # Simulate a partial state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_PRESET) + await hass.async_block_till_done() + + # Check state + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == _REGIME + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # After a delay, a full state request should've been sent + _wait_and_assert_state_request(hass, mqtt_mock) + + # Simulate a full state response + async_fire_mqtt_message( + hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_PRESET_FULL + ) + await hass.async_block_till_done() + + # Check state after full state response + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == 22.0 + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == _REGIME + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert entity.state == HVACMode.HEAT + + +async def test_climate_when_invalid_state_received( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate when no valid state is received.""" + + platform: EntityPlatform = hass.data["entity_components"][CLIMATE_DOMAIN] + entity: ClimateEntity = next( + ( + entity + for entity in platform.entities + if entity.entity_id == _CLIMATE_ENTITY_ID + ), + None, + ) + + assert entity + entity.async_schedule_update_ha_state = MagicMock() + + # Simulate state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, "") + await hass.async_block_till_done() + + entity.async_schedule_update_ha_state.assert_not_called() + + +async def test_climate_with_fast_subsequent_changes( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate with fast subsequent changes.""" + + # Simulate two subsequent partial state responses + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + + # State request should be requested only once + _wait_and_assert_state_request(hass, mqtt_mock) + + +async def test_climate_with_unknown_preset( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate with passing an unknown preset value.""" + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_PRESET_MODE: "What is cooler than being cool?", + }, + blocking=True, + ) + + +def _wait_and_assert_state_request( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + mqtt_mock.reset_mock() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(STATE_REQUEST_DELAY)) + mqtt_mock.async_publish.assert_has_calls( + [call(_TOPIC_GET_STATE, '["UL20"]', 0, False)], + any_order=True, + ) diff --git a/tests/components/qbus/test_light.py b/tests/components/qbus/test_light.py index c64219f1269..2db2c622289 100644 --- a/tests/components/qbus/test_light.py +++ b/tests/components/qbus/test_light.py @@ -1,7 +1,5 @@ """Test Qbus light entities.""" -import json - from homeassistant.components.light import ( ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN, @@ -10,11 +8,8 @@ from homeassistant.components.light import ( ) 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.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient # 186 = 73% (rounded) @@ -44,17 +39,10 @@ _LIGHT_ENTITY_ID = "light.media_room" async def test_light( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, - mock_config_entry: MockConfigEntry, - payload_config: JsonObjectType, + setup_integration: None, ) -> 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( diff --git a/tests/components/qbus/test_switch.py b/tests/components/qbus/test_switch.py index 83bb667e4eb..ddb63e933da 100644 --- a/tests/components/qbus/test_switch.py +++ b/tests/components/qbus/test_switch.py @@ -1,7 +1,5 @@ """Test Qbus switch entities.""" -import json - from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -9,11 +7,8 @@ from homeassistant.components.switch import ( ) 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.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient _PAYLOAD_SWITCH_STATE_ON = '{"id":"UL10","properties":{"value":true},"type":"state"}' @@ -34,17 +29,10 @@ _SWITCH_ENTITY_ID = "switch.living" async def test_switch_turn_on_off( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, - mock_config_entry: MockConfigEntry, - payload_config: JsonObjectType, + setup_integration: None, ) -> 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( diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py index 31630913a70..b7e811b69ef 100644 --- a/tests/components/rainforest_eagle/test_sensor.py +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -10,17 +10,17 @@ async def test_sensors_200(hass: HomeAssistant, setup_rainforest_200) -> None: """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.eagle_200_meter_power_demand") + demand = hass.states.get("sensor.eagle_200_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_200_total_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.eagle_200_total_meter_energy_received") + received = hass.states.get("sensor.eagle_200_total_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" @@ -33,7 +33,7 @@ async def test_sensors_200(hass: HomeAssistant, setup_rainforest_200) -> None: assert len(hass.states.async_all()) == 4 - price = hass.states.get("sensor.eagle_200_meter_price") + price = hass.states.get("sensor.eagle_200_energy_price") assert price is not None assert price.state == "0.053990" assert price.attributes["unit_of_measurement"] == "USD/kWh" @@ -43,17 +43,17 @@ async def test_sensors_100(hass: HomeAssistant, setup_rainforest_100) -> None: """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.eagle_100_meter_power_demand") + demand = hass.states.get("sensor.eagle_100_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.eagle_100_total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_100_total_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.eagle_100_total_meter_energy_received") + received = hass.states.get("sensor.eagle_100_total_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" diff --git a/tests/components/rainforest_raven/snapshots/test_sensor.ambr b/tests/components/rainforest_raven/snapshots/test_sensor.ambr index 618766c1613..fc0d5862352 100644 --- a/tests/components/rainforest_raven/snapshots/test_sensor.ambr +++ b/tests/components/rainforest_raven/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[sensor.raven_device_meter_power_demand-entry] +# name: test_sensors[sensor.raven_device_energy_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,59 +14,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.raven_device_meter_power_demand', - '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 power demand', - 'platform': 'rainforest_raven', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'power_demand', - 'unique_id': '1234567890abcdef.InstantaneousDemand.demand', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.raven_device_meter_power_demand-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'RAVEn Device Meter power demand', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.raven_device_meter_power_demand', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1.2345', - }) -# --- -# name: test_sensors[sensor.raven_device_meter_price-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.raven_device_meter_price', + 'entity_id': 'sensor.raven_device_energy_price', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -78,33 +26,85 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Meter price', + 'original_name': 'Energy price', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'meter_price', + 'translation_key': 'energy_price', 'unique_id': '1234567890abcdef.PriceCluster.price', 'unit_of_measurement': 'USD/kWh', }) # --- -# name: test_sensors[sensor.raven_device_meter_price-state] +# name: test_sensors[sensor.raven_device_energy_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'RAVEn Device Meter price', + 'friendly_name': 'RAVEn Device Energy price', 'rate_label': 'Set by user', 'state_class': , 'tier': 3, 'unit_of_measurement': 'USD/kWh', }), 'context': , - 'entity_id': 'sensor.raven_device_meter_price', + 'entity_id': 'sensor.raven_device_energy_price', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.10', }) # --- -# name: test_sensors[sensor.raven_device_meter_signal_strength-entry] +# name: test_sensors[sensor.raven_device_power_demand-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.raven_device_power_demand', + '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 demand', + 'platform': 'rainforest_raven', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_demand', + 'unique_id': '1234567890abcdef.InstantaneousDemand.demand', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.raven_device_power_demand-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'RAVEn Device Power demand', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.raven_device_power_demand', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2345', + }) +# --- +# name: test_sensors[sensor.raven_device_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,7 +119,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.raven_device_meter_signal_strength', + 'entity_id': 'sensor.raven_device_signal_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -131,7 +131,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Meter signal strength', + 'original_name': 'Signal strength', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, @@ -140,23 +140,23 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[sensor.raven_device_meter_signal_strength-state] +# name: test_sensors[sensor.raven_device_signal_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'channel': 13, - 'friendly_name': 'RAVEn Device Meter signal strength', + 'friendly_name': 'RAVEn Device Signal strength', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.raven_device_meter_signal_strength', + 'entity_id': 'sensor.raven_device_signal_strength', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '100', }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_delivered-entry] +# name: test_sensors[sensor.raven_device_total_energy_delivered-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -171,7 +171,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.raven_device_total_meter_energy_delivered', + 'entity_id': 'sensor.raven_device_total_energy_delivered', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -183,7 +183,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total meter energy delivered', + 'original_name': 'Total energy delivered', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, @@ -192,23 +192,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_delivered-state] +# name: test_sensors[sensor.raven_device_total_energy_delivered-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'RAVEn Device Total meter energy delivered', + 'friendly_name': 'RAVEn Device Total energy delivered', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.raven_device_total_meter_energy_delivered', + 'entity_id': 'sensor.raven_device_total_energy_delivered', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '23456.7890', }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_received-entry] +# name: test_sensors[sensor.raven_device_total_energy_received-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -223,7 +223,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.raven_device_total_meter_energy_received', + 'entity_id': 'sensor.raven_device_total_energy_received', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -235,7 +235,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total meter energy received', + 'original_name': 'Total energy received', 'platform': 'rainforest_raven', 'previous_unique_id': None, 'supported_features': 0, @@ -244,16 +244,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.raven_device_total_meter_energy_received-state] +# name: test_sensors[sensor.raven_device_total_energy_received-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'RAVEn Device Total meter energy received', + 'friendly_name': 'RAVEn Device Total energy received', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.raven_device_total_meter_energy_received', + 'entity_id': 'sensor.raven_device_total_energy_received', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/rainmachine/test_binary_sensor.py b/tests/components/rainmachine/test_binary_sensor.py index d428993da51..55736f118b3 100644 --- a/tests/components/rainmachine/test_binary_sensor.py +++ b/tests/components/rainmachine/test_binary_sensor.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rainmachine import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/rainmachine/test_button.py b/tests/components/rainmachine/test_button.py index 629c325c79e..a9d4042bf8f 100644 --- a/tests/components/rainmachine/test_button.py +++ b/tests/components/rainmachine/test_button.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rainmachine import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/rainmachine/test_diagnostics.py b/tests/components/rainmachine/test_diagnostics.py index ad5743957dd..65cf45810a3 100644 --- a/tests/components/rainmachine/test_diagnostics.py +++ b/tests/components/rainmachine/test_diagnostics.py @@ -1,7 +1,7 @@ """Test RainMachine diagnostics.""" from regenmaschine.errors import RainMachineError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/rainmachine/test_select.py b/tests/components/rainmachine/test_select.py index ca9ce2e644d..31768313c0b 100644 --- a/tests/components/rainmachine/test_select.py +++ b/tests/components/rainmachine/test_select.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rainmachine import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/rainmachine/test_sensor.py b/tests/components/rainmachine/test_sensor.py index 3ff533b6da0..15bb87a8151 100644 --- a/tests/components/rainmachine/test_sensor.py +++ b/tests/components/rainmachine/test_sensor.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rainmachine import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/rainmachine/test_switch.py b/tests/components/rainmachine/test_switch.py index 50e73a78efe..cc0552a15f1 100644 --- a/tests/components/rainmachine/test_switch.py +++ b/tests/components/rainmachine/test_switch.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rainmachine import DOMAIN from homeassistant.const import Platform diff --git a/tests/components/rdw/test_diagnostics.py b/tests/components/rdw/test_diagnostics.py index a5e8c72dba1..0f4a2279993 100644 --- a/tests/components/rdw/test_diagnostics.py +++ b/tests/components/rdw/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the RDW integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 352a2345052..99d6705e4a4 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -87,6 +87,7 @@ async def test_validate_db_schema_fix_float_issue( "created_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION", "mean DOUBLE PRECISION", + "mean_weight DOUBLE PRECISION", "min DOUBLE PRECISION", "max DOUBLE PRECISION", "last_reset_ts DOUBLE PRECISION", diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 5e1f02baeed..d381c225275 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -15,7 +15,7 @@ from typing import Any, Literal, cast from unittest.mock import MagicMock, patch, sentinel from freezegun import freeze_time -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event as sqlalchemy_event from sqlalchemy.orm.session import Session from homeassistant import core as ha @@ -35,7 +35,8 @@ from homeassistant.components.recorder.db_schema import ( StatesMeta, ) from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask -from homeassistant.const import UnitOfTemperature +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import DEGREE, UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import recorder as recorder_helper from homeassistant.util import dt as dt_util @@ -290,6 +291,7 @@ def record_states( sns2 = "sensor.test2" sns3 = "sensor.test3" sns4 = "sensor.test4" + sns5 = "sensor.wind_direction" sns1_attr = { "device_class": "temperature", "state_class": "measurement", @@ -302,6 +304,11 @@ def record_states( } sns3_attr = {"device_class": "temperature"} sns4_attr = {} + sns5_attr = { + "device_class": SensorDeviceClass.WIND_DIRECTION, + "state_class": SensorStateClass.MEASUREMENT_ANGLE, + "unit_of_measurement": DEGREE, + } def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -315,7 +322,7 @@ def record_states( three = two + timedelta(seconds=30 * 5) four = three + timedelta(seconds=14 * 5) - states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} + states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: [], sns5: []} with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) @@ -324,6 +331,7 @@ def record_states( states[sns2].append(set_state(sns2, "10", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "10", attributes=sns4_attr)) + states[sns5].append(set_state(sns5, "10", attributes=sns5_attr)) freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( @@ -335,12 +343,14 @@ def record_states( states[sns2].append(set_state(sns2, "15", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "15", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "15", attributes=sns4_attr)) + states[sns5].append(set_state(sns5, "350", attributes=sns5_attr)) freezer.move_to(three) states[sns1].append(set_state(sns1, "20", attributes=sns1_attr)) states[sns2].append(set_state(sns2, "20", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "20", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "20", attributes=sns4_attr)) + states[sns5].append(set_state(sns5, "5", attributes=sns5_attr)) return zero, four, states @@ -414,7 +424,15 @@ def create_engine_test_for_schema_version_postfix( schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) old_db_schema = sys.modules[schema_module] + instance: Recorder | None = None + if "hass" in kwargs: + hass: HomeAssistant = kwargs.pop("hass") + instance = recorder.get_instance(hass) engine = create_engine(*args, **kwargs) + if instance is not None: + instance = recorder.get_instance(hass) + instance.engine = engine + sqlalchemy_event.listen(engine, "connect", instance._setup_recorder_connection) old_db_schema.Base.metadata.create_all(engine) with Session(engine) as session: session.add( @@ -435,7 +453,7 @@ def get_schema_module_path(schema_version_postfix: str) -> str: @contextmanager -def old_db_schema(schema_version_postfix: str) -> Iterator[None]: +def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[None]: """Fixture to initialize the db with the old schema.""" schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) @@ -455,6 +473,7 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: CREATE_ENGINE_TARGET, new=partial( create_engine_test_for_schema_version_postfix, + hass=hass, schema_version_postfix=schema_version_postfix, ), ), diff --git a/tests/components/recorder/conftest.py b/tests/components/recorder/conftest.py index 9cdf9dbb372..681205126af 100644 --- a/tests/components/recorder/conftest.py +++ b/tests/components/recorder/conftest.py @@ -13,6 +13,7 @@ from sqlalchemy.orm.session import Session from homeassistant.components import recorder from homeassistant.components.recorder import db_schema +from homeassistant.components.recorder.const import MAX_IDS_FOR_INDEXED_GROUP_BY from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant @@ -190,3 +191,9 @@ def instrument_migration( instrumented_migration.live_migration_done_stall.set() instrumented_migration.non_live_migration_done_stall.set() yield instrumented_migration + + +@pytest.fixture(params=[1, 2, MAX_IDS_FOR_INDEXED_GROUP_BY]) +def ids_for_start_time_chunk_sizes(request: pytest.FixtureRequest) -> int: + """Fixture to test different chunk sizes for start time query.""" + return request.param diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index daa7fb6977c..9c19a1c7405 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -583,6 +583,8 @@ class StatisticsBase: last_reset_ts = Column(TIMESTAMP_TYPE) state = Column(DOUBLE_TYPE) sum = Column(DOUBLE_TYPE) + # *** Not originally in v32, only added for tests. Added in v49 + mean_weight = Column(DOUBLE_TYPE) @classmethod def from_stats(cls, metadata_id: int, stats: StatisticData) -> Self: diff --git a/tests/components/recorder/table_managers/test_statistics_meta.py b/tests/components/recorder/table_managers/test_statistics_meta.py index 66edb84c3ef..1af60b71ed5 100644 --- a/tests/components/recorder/table_managers/test_statistics_meta.py +++ b/tests/components/recorder/table_managers/test_statistics_meta.py @@ -2,10 +2,19 @@ from __future__ import annotations +import logging +import threading + import pytest from homeassistant.components import recorder +from homeassistant.components.recorder.db_schema import StatisticsMeta +from homeassistant.components.recorder.models import ( + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.util import session_scope +from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant from tests.typing import RecorderInstanceGenerator @@ -55,3 +64,78 @@ async def test_unsafe_calls_to_statistics_meta_manager( session, statistic_ids=["light.kitchen"], ) + + +async def test_invalid_mean_types( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test passing invalid mean types will be skipped and logged.""" + instance = await async_setup_recorder_instance( + hass, {recorder.CONF_COMMIT_INTERVAL: 0} + ) + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + valid_metadata: dict[str, tuple[int, StatisticMetaData]] = { + "sensor.energy": ( + 1, + { + "mean_type": StatisticMeanType.NONE, + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.energy", + "unit_of_measurement": "kWh", + }, + ), + "sensor.wind_direction": ( + 2, + { + "mean_type": StatisticMeanType.CIRCULAR, + "has_mean": False, + "has_sum": False, + "name": "Wind direction", + "source": "recorder", + "statistic_id": "sensor.wind_direction", + "unit_of_measurement": DEGREE, + }, + ), + "sensor.wind_speed": ( + 3, + { + "mean_type": StatisticMeanType.ARITHMETIC, + "has_mean": True, + "has_sum": False, + "name": "Wind speed", + "source": "recorder", + "statistic_id": "sensor.wind_speed", + "unit_of_measurement": "km/h", + }, + ), + } + manager = instance.statistics_meta_manager + with instance.get_session() as session: + for _, metadata in valid_metadata.values(): + session.add(StatisticsMeta.from_meta(metadata)) + + # Add invalid mean type + session.add( + StatisticsMeta( + statistic_id="sensor.invalid", + source="recorder", + has_sum=False, + name="Invalid", + mean_type=12345, + ) + ) + session.commit() + + # Check that the invalid mean type was skipped + assert manager.get_many(session) == valid_metadata + assert ( + "homeassistant.components.recorder.table_managers.statistics_meta", + logging.WARNING, + "Invalid mean type found for statistic_id: sensor.invalid, mean_type: 12345. Skipping", + ) in caplog.record_tuples diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index 08fbef01bdd..a4362b1fa4c 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -1,12 +1,13 @@ """Test backup platform for the Recorder integration.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from unittest.mock import patch import pytest from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.backup import async_post_backup, async_pre_backup -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -16,7 +17,42 @@ async def test_async_pre_backup(recorder_mock: Recorder, hass: HomeAssistant) -> "homeassistant.components.recorder.core.Recorder.lock_database" ) as lock_mock: await async_pre_backup(hass) - assert lock_mock.called + assert lock_mock.called + + +RAISES_HASS_NOT_RUNNING = pytest.raises( + HomeAssistantError, match="Home Assistant is not running" +) + + +@pytest.mark.parametrize( + ("core_state", "expected_result", "lock_calls"), + [ + (CoreState.final_write, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.not_running, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.running, does_not_raise(), 1), + (CoreState.starting, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopped, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopping, RAISES_HASS_NOT_RUNNING, 0), + ], +) +async def test_async_pre_backup_core_state( + recorder_mock: Recorder, + hass: HomeAssistant, + core_state: CoreState, + expected_result: AbstractContextManager, + lock_calls: int, +) -> None: + """Test pre backup in different core states.""" + hass.set_state(core_state) + with ( # pylint: disable=confusing-with-statement + patch( + "homeassistant.components.recorder.core.Recorder.lock_database" + ) as lock_mock, + expected_result, + ): + await async_pre_backup(hass) + assert len(lock_mock.mock_calls) == lock_calls async def test_async_pre_backup_with_timeout( @@ -39,13 +75,17 @@ async def test_async_pre_backup_with_migration( ) -> None: """Test pre backup with migration.""" with ( + patch( + "homeassistant.components.recorder.core.Recorder.lock_database" + ) as lock_mock, patch( "homeassistant.components.recorder.backup.async_migration_in_progress", return_value=True, ), - pytest.raises(HomeAssistantError), + pytest.raises(HomeAssistantError, match="Database migration in progress"), ): await async_pre_backup(hass) + assert not lock_mock.called async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) -> None: @@ -54,7 +94,7 @@ async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) - "homeassistant.components.recorder.core.Recorder.unlock_database" ) as unlock_mock: await async_post_backup(hass) - assert unlock_mock.called + assert unlock_mock.called async def test_async_post_backup_failure( @@ -66,7 +106,9 @@ async def test_async_post_backup_failure( "homeassistant.components.recorder.core.Recorder.unlock_database", return_value=False, ) as unlock_mock, - pytest.raises(HomeAssistantError), + pytest.raises( + HomeAssistantError, match="Could not release database write lock" + ), ): await async_post_backup(hass) assert unlock_mock.called diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py index d3024df4ed6..2e9883aaf53 100644 --- a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -1,6 +1,6 @@ """The tests for the recorder filter matching the EntityFilter component.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator import json from unittest.mock import patch @@ -32,12 +32,21 @@ from homeassistant.helpers.entityfilter import ( from .common import async_wait_recording_done, old_db_schema +from tests.typing import RecorderInstanceContextManager + + +@pytest.fixture +async def mock_recorder_before_hass( + async_test_recorder: RecorderInstanceContextManager, +) -> None: + """Set up recorder.""" + # This test is for schema 37 and below (32 is new enough to test) @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 166451cc971..d6223eb55b3 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -2,10 +2,11 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json -from unittest.mock import sentinel +from unittest.mock import patch, sentinel from freezegun import freeze_time import pytest @@ -36,6 +37,24 @@ from .common import ( from tests.typing import RecorderInstanceContextManager +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the recorder to use different chunk sizes for start time query. + + In effect this forces get_significant_states_with_session + to call _generate_significant_states_with_session_stmt multiple times. + """ + with patch( + "homeassistant.components.recorder.history.modern.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield + + @pytest.fixture async def mock_recorder_before_hass( async_test_recorder: RecorderInstanceContextManager, @@ -429,6 +448,7 @@ async def test_ensure_state_can_be_copied( assert_states_equal_without_context(copy(hist[entity_id][1]), hist[entity_id][1]) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states(hass: HomeAssistant) -> None: """Test that only significant states are returned. @@ -443,6 +463,7 @@ async def test_get_significant_states(hass: HomeAssistant) -> None: assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_minimal_response( hass: HomeAssistant, ) -> None: @@ -512,6 +533,7 @@ async def test_get_significant_states_minimal_response( ) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) async def test_get_significant_states_with_initial( time_zone, hass: HomeAssistant @@ -544,6 +566,7 @@ async def test_get_significant_states_with_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_without_initial( hass: HomeAssistant, ) -> None: @@ -578,6 +601,7 @@ async def test_get_significant_states_without_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_entity_id( hass: HomeAssistant, ) -> None: @@ -596,6 +620,7 @@ async def test_get_significant_states_entity_id( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_multiple_entity_ids( hass: HomeAssistant, ) -> None: diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 142d2fc87f6..908a67cd635 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -50,9 +51,9 @@ def disable_states_meta_manager(): @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 1523f373ea8..20d0c162d35 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -42,9 +43,9 @@ async def mock_recorder_before_hass( @pytest.fixture(autouse=True) -def db_schema_42(): +def db_schema_42(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 42.""" - with old_db_schema("42"): + with old_db_schema(hass, "42"): yield diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 95cd959db3b..2023e15176f 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -77,6 +77,7 @@ from homeassistant.helpers import ( issue_registry as ir, recorder as recorder_helper, ) +from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -2798,3 +2799,22 @@ async def test_empty_entity_id( hass.bus.async_fire("hello", {"entity_id": ""}) await async_wait_recording_done(hass) assert "Invalid entity ID" not in caplog.text + + +async def test_setting_up_recorder_fails_entity_registry_listener( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test recorder setup fails if an entity registry listener is in place.""" + async_track_entity_registry_updated_event(hass, "test.test", lambda x: x) + recorder_helper.async_initialize_recorder(hass) + with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True): + assert not await async_setup_component( + hass, + recorder.DOMAIN, + {recorder.DOMAIN: {recorder.CONF_DB_URL: "sqlite://"}}, + ) + await hass.async_block_till_done() + assert ( + "The recorder entity registry listener must be installed before " + "async_track_entity_registry_updated_event is called" 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 012e227c11a..7fd73aaf735 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -1538,6 +1538,7 @@ async def test_stats_timestamp_conversion_is_reentrant( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": process_timestamp(one_year_ago).replace(tzinfo=None), @@ -1553,6 +1554,7 @@ async def test_stats_timestamp_conversion_is_reentrant( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1568,6 +1570,7 @@ async def test_stats_timestamp_conversion_is_reentrant( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": process_timestamp(one_month_ago).replace(tzinfo=None), @@ -1705,6 +1708,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1720,6 +1724,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1735,6 +1740,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1758,6 +1764,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1773,6 +1780,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1788,6 +1796,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1932,6 +1941,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1947,6 +1957,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1962,6 +1973,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1985,6 +1997,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, diff --git a/tests/components/recorder/test_pool.py b/tests/components/recorder/test_pool.py index 3cca095399b..e391161c1ec 100644 --- a/tests/components/recorder/test_pool.py +++ b/tests/components/recorder/test_pool.py @@ -1,5 +1,6 @@ """Test pool.""" +import asyncio import threading import pytest @@ -8,6 +9,7 @@ from sqlalchemy.orm import sessionmaker from homeassistant.components.recorder.const import DB_WORKER_PREFIX from homeassistant.components.recorder.pool import RecorderPool +from homeassistant.core import HomeAssistant async def test_recorder_pool_called_from_event_loop() -> None: @@ -22,7 +24,9 @@ async def test_recorder_pool_called_from_event_loop() -> None: sessionmaker(bind=engine)().connection() -def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: +async def test_recorder_pool( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test RecorderPool gives the same connection in the creating thread.""" recorder_and_worker_thread_ids: set[int] = set() engine = create_engine( @@ -35,6 +39,8 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: connections = [] add_thread = False + event = asyncio.Event() + def _get_connection_twice(): if add_thread: recorder_and_worker_thread_ids.add(threading.get_ident()) @@ -48,33 +54,42 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: session = get_session() connections.append(session.connection().connection.driver_connection) session.close() + hass.loop.call_soon_threadsafe(event.set) caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice) new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" in caplog.text assert connections[0] != connections[1] add_thread = True caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX) new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" not in caplog.text assert connections[2] == connections[3] caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice, name="Recorder") new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" not in caplog.text assert connections[4] == connections[5] shutdown = True caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX) new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" not in caplog.text assert connections[6] != connections[7] diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 45bef68dabd..0212e4b012e 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -58,9 +58,9 @@ async def mock_recorder_before_hass( @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 6e192295c58..a8d8ed61020 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1,16 +1,21 @@ """The tests for sensor recorder platform.""" +from collections.abc import Generator from datetime import timedelta +import re from typing import Any from unittest.mock import ANY, Mock, patch import pytest from sqlalchemy import select +import voluptuous as vol +from homeassistant import exceptions from homeassistant.components import recorder from homeassistant.components.recorder import Recorder, history, statistics from homeassistant.components.recorder.db_schema import StatisticsShortTerm from homeassistant.components.recorder.models import ( + StatisticMeanType, datetime_to_timestamp_or_none, process_timestamp, ) @@ -18,7 +23,8 @@ from homeassistant.components.recorder.statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, PlatformCompiledStatistics, _generate_max_mean_min_statistic_in_sub_period_stmt, - _generate_statistics_at_time_stmt, + _generate_statistics_at_time_stmt_dependent_sub_query, + _generate_statistics_at_time_stmt_group_by, _generate_statistics_during_period_stmt, async_add_external_statistics, async_import_statistics, @@ -37,7 +43,7 @@ from homeassistant.components.recorder.table_managers.statistics_meta import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import UNIT_CONVERTERS -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -53,10 +59,28 @@ from .common import ( statistics_during_period, ) -from tests.common import MockPlatform, mock_platform +from tests.common import MockPlatform, MockUser, mock_platform from tests.typing import RecorderInstanceContextManager, WebSocketGenerator +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the statistics query to use different chunk sizes for start time query. + + In effect this forces _statistics_at_time + to call _generate_statistics_at_time_stmt_group_by multiple times. + """ + with patch( + "homeassistant.components.recorder.statistics.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield + + @pytest.fixture async def mock_recorder_before_hass( async_test_recorder: RecorderInstanceContextManager, @@ -103,32 +127,38 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) assert stats == {} - for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): + for kwargs in ({}, {"statistic_ids": ["sensor.test1", "sensor.wind_direction"]}): stats = statistics_during_period(hass, zero, period="5minute", **kwargs) assert stats == {} - stats = get_last_short_term_statistics( - hass, - 0, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {} + for sensor in ("sensor.test1", "sensor.wind_direction"): + stats = get_last_short_term_statistics( + hass, + 0, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {} do_adhoc_statistics(hass, start=zero) do_adhoc_statistics(hass, start=four) await async_wait_recording_done(hass) - metadata = get_metadata(hass, statistic_ids={"sensor.test1", "sensor.test2"}) - assert metadata["sensor.test1"][1]["has_mean"] is True - assert metadata["sensor.test1"][1]["has_sum"] is False - assert metadata["sensor.test2"][1]["has_mean"] is True - assert metadata["sensor.test2"][1]["has_sum"] is False + metadata = get_metadata( + hass, statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"} + ) + for sensor, mean_type in ( + ("sensor.test1", StatisticMeanType.ARITHMETIC), + ("sensor.test2", StatisticMeanType.ARITHMETIC), + ("sensor.wind_direction", StatisticMeanType.CIRCULAR), + ): + assert metadata[sensor][1]["mean_type"] is mean_type + assert metadata[sensor][1]["has_sum"] is False expected_1 = { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), @@ -148,11 +178,39 @@ async def test_compile_hourly_statistics( expected_stats1 = [expected_1, expected_2] expected_stats2 = [expected_1, expected_2] + expected_stats_wind_direction1 = { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + } + expected_stats_wind_direction2 = { + "start": process_timestamp(four).timestamp(), + "end": process_timestamp(four + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(5), + "min": None, + "max": None, + "last_reset": None, + } + expected_stats_wind_direction = [ + expected_stats_wind_direction1, + expected_stats_wind_direction2, + ] + # Test statistics_during_period stats = statistics_during_period( - hass, zero, period="5minute", statistic_ids={"sensor.test1", "sensor.test2"} + hass, + zero, + period="5minute", + statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"}, ) - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Test statistics_during_period with a far future start and end date future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) @@ -161,7 +219,7 @@ async def test_compile_hourly_statistics( future, end_time=future, period="5minute", - statistic_ids={"sensor.test1", "sensor.test2"}, + statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"}, ) assert stats == {} @@ -171,9 +229,13 @@ async def test_compile_hourly_statistics( zero, end_time=future, period="5minute", - statistic_ids={"sensor.test1", "sensor.test2"}, + statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"}, ) - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } stats = statistics_during_period( hass, zero, statistic_ids={"sensor.test2"}, period="5minute" @@ -186,32 +248,39 @@ async def test_compile_hourly_statistics( assert stats == {} # Test get_last_short_term_statistics and get_latest_short_term_statistics - stats = get_last_short_term_statistics( - hass, - 0, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {} + for sensor, expected in ( + ("sensor.test1", expected_2), + ("sensor.wind_direction", expected_stats_wind_direction2), + ): + stats = get_last_short_term_statistics( + hass, + 0, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {} - stats = get_last_short_term_statistics( - hass, - 1, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {"sensor.test1": [expected_2]} + stats = get_last_short_term_statistics( + hass, + 1, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {sensor: [expected]} with session_scope(hass=hass, read_only=True) as session: stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) - assert stats == {"sensor.test1": [expected_2]} + assert stats == { + "sensor.test1": [expected_2], + "sensor.wind_direction": [expected_stats_wind_direction2], + } # Now wipe the latest_short_term_statistics_ids table and test again # to make sure we can rebuild the missing data @@ -221,13 +290,15 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) - assert stats == {"sensor.test1": [expected_2]} + assert stats == { + "sensor.test1": [expected_2], + "sensor.wind_direction": [expected_stats_wind_direction2], + } metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) - with session_scope(hass=hass, read_only=True) as session: stats = get_latest_short_term_statistics_with_session( hass, @@ -238,23 +309,44 @@ async def test_compile_hourly_statistics( ) assert stats == {"sensor.test1": [expected_2]} - stats = get_last_short_term_statistics( - hass, - 2, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, + # Test with multiple metadata ids + metadata = get_metadata( + hass, statistic_ids={"sensor.test1", "sensor.wind_direction"} ) - assert stats == {"sensor.test1": expected_stats1[::-1]} + with session_scope(hass=hass, read_only=True) as session: + stats = get_latest_short_term_statistics_with_session( + hass, + session, + {"sensor.test1", "sensor.wind_direction"}, + {"last_reset", "max", "mean", "min", "state", "sum"}, + metadata=metadata, + ) + assert stats == { + "sensor.test1": [expected_2], + "sensor.wind_direction": [expected_stats_wind_direction2], + } - stats = get_last_short_term_statistics( - hass, - 3, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {"sensor.test1": expected_stats1[::-1]} + for sensor, expected in ( + ("sensor.test1", expected_stats1[::-1]), + ("sensor.wind_direction", expected_stats_wind_direction[::-1]), + ): + stats = get_last_short_term_statistics( + hass, + 2, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {sensor: expected} + + stats = get_last_short_term_statistics( + hass, + 3, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {sensor: expected} stats = get_last_short_term_statistics( hass, @@ -271,7 +363,7 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) assert stats == {} @@ -286,7 +378,7 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) assert stats == {} @@ -440,15 +532,35 @@ async def test_rename_entity( expected_stats1 = [expected_1] expected_stats2 = [expected_1] expected_stats99 = [expected_1] + expected_stats_wind_direction = [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } entity_registry.async_update_entity("sensor.test1", new_entity_id="sensor.test99") await async_wait_recording_done(hass) stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test99": expected_stats99, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } async def test_statistics_during_period_set_back_compat( @@ -524,9 +636,25 @@ async def test_rename_entity_collision( } expected_stats1 = [expected_1] expected_stats2 = [expected_1] + expected_stats_wind_direction = [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Insert metadata for sensor.test99 metadata_1 = { @@ -547,7 +675,11 @@ async def test_rename_entity_collision( # Statistics failed to migrate due to the collision stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Verify the safeguard in the states meta manager was hit assert ( @@ -611,9 +743,25 @@ async def test_rename_entity_collision_states_meta_check_disabled( } expected_stats1 = [expected_1] expected_stats2 = [expected_1] + expected_stats_wind_direction = [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Insert metadata for sensor.test99 metadata_1 = { @@ -640,7 +788,11 @@ async def test_rename_entity_collision_states_meta_check_disabled( # Statistics failed to migrate due to the collision stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Verify the filter_unique_constraint_integrity_error safeguard was hit assert "Blocked attempt to insert duplicated statistic rows" in caplog.text @@ -766,6 +918,7 @@ async def test_import_statistics( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -780,6 +933,7 @@ async def test_import_statistics( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -856,6 +1010,7 @@ async def test_import_statistics( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy renamed", @@ -870,6 +1025,7 @@ async def test_import_statistics( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy renamed", "source": source, @@ -1113,6 +1269,7 @@ async def test_import_statistics_errors( assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_daily_statistics_sum( @@ -1293,6 +1450,215 @@ async def test_daily_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_multiple_daily_statistics_sum( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test daily statistics.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 1", + "source": "test", + "statistic_id": "test:total_energy_import2", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 2", + "source": "test", + "statistic_id": "test:total_energy_import1", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata1, external_statistics) + async_add_external_statistics(hass, external_metadata2, external_statistics) + + await async_wait_recording_done(hass) + stats = statistics_during_period( + hass, + zero, + period="day", + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + ) + day1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + day1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-06 00:00:00")) + expected_stats_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, + ] + expected_stats = { + "test:total_energy_import1": expected_stats_inner, + "test:total_energy_import2": expected_stats_inner, + } + assert stats == expected_stats + + # Get change + stats = statistics_during_period( + hass, + start_time=period1, + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + types={"change"}, + ) + expected_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "change": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "change": 2.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "change": 2.0, + }, + ] + assert stats == { + "test:total_energy_import1": expected_inner, + "test:total_energy_import2": expected_inner, + } + + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Get data with end during the third period + stats = statistics_during_period( + hass, + start_time=zero, + end_time=period6 - timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={ + "not", + "the", + "same", + "test:total_energy_import1", + "test:total_energy_import2", + }, + period="day", + ) + assert stats == expected_stats + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=[ + "test:total_energy_import1", + "with_other", + "test:total_energy_import2", + ], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="day" + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_mean( @@ -1428,6 +1794,7 @@ async def test_weekly_statistics_mean( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_sum( @@ -1608,6 +1975,7 @@ async def test_weekly_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") async def test_monthly_statistics_sum( @@ -1914,20 +2282,43 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> N assert cache_key_1 != cache_key_3 -def test_cache_key_for_generate_statistics_at_time_stmt() -> None: - """Test cache key for _generate_statistics_at_time_stmt.""" - stmt = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) +def test_cache_key_for_generate_statistics_at_time_stmt_group_by() -> None: + """Test cache key for _generate_statistics_at_time_stmt_group_by.""" + stmt = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_1 = stmt._generate_cache_key() - stmt2 = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) + stmt2 = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - stmt3 = _generate_statistics_at_time_stmt( + stmt3 = _generate_statistics_at_time_stmt_group_by( StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 +def test_cache_key_for_generate_statistics_at_time_stmt_dependent_sub_query() -> None: + """Test cache key for _generate_statistics_at_time_stmt_dependent_sub_query.""" + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + stmt3 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} + ) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change( @@ -2263,6 +2654,392 @@ async def test_change( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_change_multiple( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test deriving change from sum statistic.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import1", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import2", + "unit_of_measurement": "kWh", + } + async_import_statistics(hass, external_metadata1, external_statistics) + async_import_statistics(hass, external_metadata2, external_statistics) + await async_wait_recording_done(hass) + # Get change from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + }, + ] + expected_stats = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats + + # Get change + sum from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change", "sum"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + "sum": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + "sum": 3.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + "sum": 5.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + "sum": 8.0, + }, + ] + expected_stats_change_sum = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_change_sum + + # Get change from far in the past with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 * 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 * 1000, + }, + ] + expected_stats_wh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_wh + + # Get change from far in the past with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 / 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 / 1000, + }, + ] + expected_stats_mwh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the first recorded hour + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats + + # Get change from the first recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == expected_stats_wh + + # Get change from the first recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_wh["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats_wh["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_mwh[ + "sensor.total_energy_import1" + ][1:4], + "sensor.total_energy_import2": expected_stats_mwh[ + "sensor.total_energy_import2" + ][1:4], + } + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second until the third recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + end_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:3 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:3 + ], + } + + # Get change from the fourth recorded hour + stats = statistics_during_period( + hass, + start_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 3:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 3:4 + ], + } + + # Test change with a far future start date + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, + start_time=future, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change_with_none( @@ -2647,3 +3424,319 @@ async def test_recorder_platform_with_partial_statistics_support( for meth in supported_methods: getattr(recorder_platform, meth).assert_called_once() + + +@pytest.mark.parametrize( + ("service_args", "expected_result"), + [ + ( + { + "start_time": "2023-05-08 07:00:00Z", + "period": "hour", + "statistic_ids": ["sensor.i_dont_exist"], + "types": ["change", "last_reset", "max", "mean", "min", "state", "sum"], + }, + {"statistics": {}}, + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "period": "hour", + "statistic_ids": [ + "sensor.total_energy_import1", + "sensor.total_energy_import2", + ], + "types": ["change", "last_reset", "max", "mean", "min", "state", "sum"], + }, + { + "statistics": { + "sensor.total_energy_import1": [ + { + "last_reset": "2021-12-31T22:00:00+00:00", + "change": 2.0, + "end": "2023-05-08T08:00:00+00:00", + "start": "2023-05-08T07:00:00+00:00", + "state": 0.0, + "sum": 2.0, + "min": 0.0, + "max": 10.0, + "mean": 1.0, + }, + { + "change": 1.0, + "end": "2023-05-08T09:00:00+00:00", + "start": "2023-05-08T08:00:00+00:00", + "state": 1.0, + "sum": 3.0, + "min": 1.0, + "max": 11.0, + "mean": 1.0, + }, + { + "change": 2.0, + "end": "2023-05-08T10:00:00+00:00", + "start": "2023-05-08T09:00:00+00:00", + "state": 2.0, + "sum": 5.0, + "min": 2.0, + "max": 12.0, + "mean": 1.0, + }, + { + "change": 3.0, + "end": "2023-05-08T11:00:00+00:00", + "start": "2023-05-08T10:00:00+00:00", + "state": 3.0, + "sum": 8.0, + "min": 3.0, + "max": 13.0, + "mean": 1.0, + }, + ], + "sensor.total_energy_import2": [ + { + "last_reset": "2021-12-31T22:00:00+00:00", + "change": 2.0, + "end": "2023-05-08T08:00:00+00:00", + "start": "2023-05-08T07:00:00+00:00", + "state": 0.0, + "sum": 2.0, + "min": 0.0, + "max": 10.0, + "mean": 1.0, + }, + { + "change": 1.0, + "end": "2023-05-08T09:00:00+00:00", + "start": "2023-05-08T08:00:00+00:00", + "state": 1.0, + "sum": 3.0, + "min": 1.0, + "max": 11.0, + "mean": 1.0, + }, + { + "change": 2.0, + "end": "2023-05-08T10:00:00+00:00", + "start": "2023-05-08T09:00:00+00:00", + "state": 2.0, + "sum": 5.0, + "min": 2.0, + "max": 12.0, + "mean": 1.0, + }, + { + "change": 3.0, + "end": "2023-05-08T11:00:00+00:00", + "start": "2023-05-08T10:00:00+00:00", + "state": 3.0, + "sum": 8.0, + "min": 3.0, + "max": 13.0, + "mean": 1.0, + }, + ], + } + }, + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "period": "day", + "statistic_ids": [ + "sensor.total_energy_import1", + "sensor.total_energy_import2", + ], + "types": ["sum"], + }, + { + "statistics": { + "sensor.total_energy_import1": [ + { + "start": "2023-05-08T07:00:00+00:00", + "end": "2023-05-09T07:00:00+00:00", + "sum": 8.0, + } + ], + "sensor.total_energy_import2": [ + { + "start": "2023-05-08T07:00:00+00:00", + "end": "2023-05-09T07:00:00+00:00", + "sum": 8.0, + } + ], + } + }, + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "end_time": "2023-05-08 08:00:00Z", + "period": "hour", + "types": ["change", "sum"], + "statistic_ids": ["sensor.total_energy_import1"], + "units": {"energy": "Wh"}, + }, + { + "statistics": { + "sensor.total_energy_import1": [ + { + "start": "2023-05-08T07:00:00+00:00", + "end": "2023-05-08T08:00:00+00:00", + "change": 2000.0, + "sum": 2000.0, + }, + ], + } + }, + ), + ], +) +@pytest.mark.usefixtures("recorder_mock") +async def test_get_statistics_service( + hass: HomeAssistant, + hass_read_only_user: MockUser, + service_args: dict[str, Any], + expected_result: dict[str, Any], +) -> None: + """Test the get_statistics service.""" + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + last_reset = dt_util.parse_datetime("2022-01-01T00:00:00+02:00") + external_statistics = ( + { + "start": period1, + "state": 0, + "sum": 2, + "min": 0, + "max": 10, + "mean": 1, + "last_reset": last_reset, + }, + { + "start": period2, + "state": 1, + "sum": 3, + "min": 1, + "max": 11, + "mean": 1, + "last_reset": None, + }, + { + "start": period3, + "state": 2, + "sum": 5, + "min": 2, + "max": 12, + "mean": 1, + "last_reset": None, + }, + { + "start": period4, + "state": 3, + "sum": 8, + "min": 3, + "max": 13, + "mean": 1, + "last_reset": None, + }, + ) + external_metadata1 = { + "has_mean": True, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import1", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": True, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import2", + "unit_of_measurement": "kWh", + } + async_import_statistics(hass, external_metadata1, external_statistics) + async_import_statistics(hass, external_metadata2, external_statistics) + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + + result = await hass.services.async_call( + "recorder", "get_statistics", service_args, return_response=True, blocking=True + ) + assert result == expected_result + + with pytest.raises(exceptions.Unauthorized): + result = await hass.services.async_call( + "recorder", + "get_statistics", + service_args, + return_response=True, + blocking=True, + context=Context(user_id=hass_read_only_user.id), + ) + + +@pytest.mark.parametrize( + ("service_args", "missing_key"), + [ + ( + { + "period": "hour", + "statistic_ids": ["sensor.sensor"], + "types": ["change", "last_reset", "max", "mean", "min", "state", "sum"], + }, + "start_time", + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "period": "hour", + "types": ["change", "last_reset", "max", "mean", "min", "state", "sum"], + }, + "statistic_ids", + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "statistic_ids": ["sensor.sensor"], + "types": ["change", "last_reset", "max", "mean", "min", "state", "sum"], + }, + "period", + ), + ( + { + "start_time": "2023-05-08 07:00:00Z", + "period": "hour", + "statistic_ids": ["sensor.sensor"], + }, + "types", + ), + ], +) +@pytest.mark.usefixtures("recorder_mock") +async def test_get_statistics_service_missing_mandatory_keys( + hass: HomeAssistant, + service_args: dict[str, Any], + missing_key: str, +) -> None: + """Test the get_statistics service with missing mandatory keys.""" + + await async_recorder_block_till_done(hass) + + with pytest.raises( + vol.error.MultipleInvalid, + match=re.escape(f"required key not provided @ data['{missing_key}']"), + ): + await hass.services.async_call( + "recorder", + "get_statistics", + service_args, + return_response=True, + blocking=True, + ) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index c9020762d4b..6c324f4b01a 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -417,7 +417,12 @@ def test_supported_mysql(caplog: pytest.LogCaptureFixture, mysql_version) -> Non dbapi_connection = MagicMock(cursor=_make_cursor_mock) - util.setup_connection_for_dialect(instance_mock, "mysql", dbapi_connection, True) + database_engine = util.setup_connection_for_dialect( + instance_mock, "mysql", dbapi_connection, True + ) + assert database_engine is not None + assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is True assert "minimum supported version" not in caplog.text @@ -502,6 +507,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 True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -583,6 +589,7 @@ def test_supported_sqlite(caplog: pytest.LogCaptureFixture, sqlite_version) -> N 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_dependent_subquery is False @pytest.mark.parametrize( @@ -675,6 +682,7 @@ async def test_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -731,6 +739,7 @@ async def test_no_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index a4e35bc8753..2460de994ec 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -1,11 +1,14 @@ """The tests for sensor recorder platform.""" +from collections.abc import Iterable import datetime from datetime import timedelta +import math from statistics import fmean import sys from unittest.mock import ANY, patch +from _pytest.python_api import ApproxBase from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -13,7 +16,14 @@ import pytest from homeassistant.components import recorder from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.db_schema import Statistics, StatisticsShortTerm +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( + DEG_TO_RAD, + RAD_TO_DEG, async_add_external_statistics, get_last_statistics, get_latest_short_term_statistics_with_session, @@ -24,6 +34,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.components.recorder.util import session_scope from homeassistant.components.recorder.websocket_api import UNIT_SCHEMA from homeassistant.components.sensor import UNIT_CONVERTERS +from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component @@ -247,12 +258,12 @@ async def test_statistics_during_period( @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("recorder_mock") @pytest.mark.parametrize("offset", [0, 1, 2]) async def test_statistic_during_period( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - offset, + offset: int, ) -> None: """Test statistic_during_period.""" now = dt_util.utcnow() @@ -307,7 +318,7 @@ async def test_statistic_during_period( ) imported_metadata = { - "has_mean": False, + "has_mean": True, "has_sum": True, "name": "Total imported energy", "source": "recorder", @@ -655,7 +666,7 @@ async def test_statistic_during_period( hass, session, {"sensor.test"}, - {"last_reset", "max", "mean", "min", "state", "sum"}, + {"last_reset", "state", "sum"}, ) start = imported_stats_5min[-1]["start"].timestamp() end = start + (5 * 60) @@ -672,18 +683,394 @@ async def test_statistic_during_period( } +def _circular_mean(values: Iterable[StatisticData]) -> dict[str, float]: + sin_sum = 0 + cos_sum = 0 + for x in values: + mean = x.get("mean") + assert mean is not None + sin_sum += math.sin(mean * DEG_TO_RAD) + cos_sum += math.cos(mean * DEG_TO_RAD) + + return { + "mean": (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360, + "mean_weight": math.sqrt(sin_sum**2 + cos_sum**2), + } + + +def _circular_mean_approx( + values: Iterable[StatisticData], tolerance: float | None = None +) -> ApproxBase: + return pytest.approx(_circular_mean(values)["mean"], abs=tolerance) + + +@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("recorder_mock") +@pytest.mark.parametrize("offset", [0, 1, 2]) +@pytest.mark.parametrize( + ("step_size", "tolerance"), + [ + (123.456, 1e-4), + # In this case the angles are uniformly distributed and the mean is undefined. + # This edge case is not handled by the current implementation, but the test + # checks the behavior is consistent. + # We could consider returning None in this case, or returning also an estimate + # of the variance. + (120, 10), + ], +) +async def test_statistic_during_period_circular_mean( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + offset: int, + step_size: float, + tolerance: float, +) -> None: + """Test statistic_during_period.""" + now = dt_util.utcnow() + + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + zero = now + start = zero.replace(minute=offset * 5, second=0, microsecond=0) + timedelta( + hours=-3 + ) + + imported_stats_5min: list[StatisticData] = [ + { + "start": (start + timedelta(minutes=5 * i)), + "mean": (step_size * i) % 360, + "mean_weight": 1, + } + for i in range(39) + ] + + imported_stats = [] + slice_end = 12 - offset + imported_stats.append( + { + "start": imported_stats_5min[0]["start"].replace(minute=0), + **_circular_mean(imported_stats_5min[0:slice_end]), + } + ) + for i in range(2): + slice_start = i * 12 + (12 - offset) + slice_end = (i + 1) * 12 + (12 - offset) + assert imported_stats_5min[slice_start]["start"].minute == 0 + imported_stats.append( + { + "start": imported_stats_5min[slice_start]["start"], + **_circular_mean(imported_stats_5min[slice_start:slice_end]), + } + ) + + imported_metadata: StatisticMetaData = { + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": "Wind direction", + "source": "recorder", + "statistic_id": "sensor.test", + "unit_of_measurement": DEGREE, + } + + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats, + Statistics, + ) + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats_5min, + StatisticsShortTerm, + ) + await async_wait_recording_done(hass) + + metadata = get_metadata(hass, statistic_ids={"sensor.test"}) + metadata_id = metadata["sensor.test"][0] + run_cache = get_short_term_statistics_run_cache(hass) + # Verify the import of the short term statistics + # also updates the run cache + assert run_cache.get_latest_ids({metadata_id}) is not None + + # No data for this period yet + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": now.isoformat(), + "end_time": now.isoformat(), + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": None, + "mean": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[:] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min, tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics_5min[:] + start_time = ( + dt_util.parse_datetime("2022-10-21T04:00:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + end_time = ( + dt_util.parse_datetime("2022-10-21T07:15:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min, tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics_5min[:] + start_time = ( + dt_util.parse_datetime("2022-10-21T04:00:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + end_time = ( + dt_util.parse_datetime("2022-10-21T08:20:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min, tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[26:] + start_time = ( + dt_util.parse_datetime("2022-10-21T06:10:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[26]["start"].isoformat() == start_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[26:], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics_5min[26:] + start_time = ( + dt_util.parse_datetime("2022-10-21T06:09:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[26:], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[:26] + end_time = ( + dt_util.parse_datetime("2022-10-21T06:10:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[26]["start"].isoformat() == end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "end_time": end_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[:26], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[26:32] (less than a full hour) + start_time = ( + dt_util.parse_datetime("2022-10-21T06:10:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[26]["start"].isoformat() == start_time + end_time = ( + dt_util.parse_datetime("2022-10-21T06:40:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[32]["start"].isoformat() == end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[26:32], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics[2:] + imported_statistics_5min[36:] + start_time = "2022-10-21T06:00:00+00:00" + assert imported_stats_5min[24 - offset]["start"].isoformat() == start_time + assert imported_stats[2]["start"].isoformat() == start_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[24 - offset :], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics[2:] + imported_statistics_5min[36:] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "rolling_window": { + "duration": {"hours": 1, "minutes": 25}, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[24 - offset :], tolerance), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics[2:3] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "rolling_window": { + "duration": {"hours": 1}, + "offset": {"minutes": -25}, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + slice_start = 24 - offset + slice_end = 36 - offset + assert response["result"] == { + "mean": _circular_mean_approx( + imported_stats_5min[slice_start:slice_end], tolerance + ), + "max": None, + "min": None, + "change": None, + } + + # Test we can get only selected types + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "types": ["mean"], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min, tolerance), + } + + @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) async def test_statistic_during_period_hole( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test statistic_during_period when there are holes in the data.""" - stat_id = 1 - - def next_id(): - nonlocal stat_id - stat_id += 1 - return stat_id - now = dt_util.utcnow() await async_recorder_block_till_done(hass) @@ -704,7 +1091,7 @@ async def test_statistic_during_period_hole( ] imported_metadata = { - "has_mean": False, + "has_mean": True, "has_sum": True, "name": "Total imported energy", "source": "recorder", @@ -830,6 +1217,156 @@ async def test_statistic_during_period_hole( } +@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("recorder_mock") +async def test_statistic_during_period_hole_circular_mean( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test statistic_during_period when there are holes in the data.""" + now = dt_util.utcnow() + + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + zero = now + start = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=-18) + + imported_stats: list[StatisticData] = [ + { + "start": (start + timedelta(hours=3 * i)), + "mean": (123.456 * i) % 360, + "mean_weight": 1, + } + for i in range(6) + ] + + imported_metadata: StatisticMetaData = { + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": "Wind direction", + "source": "recorder", + "statistic_id": "sensor.test", + "unit_of_measurement": DEGREE, + } + + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats, + Statistics, + ) + await async_wait_recording_done(hass) + + # This should include imported_stats[:] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[:]), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_stats[:] + start_time = "2022-10-20T13:00:00+00:00" + end_time = "2022-10-21T05:00:00+00:00" + assert imported_stats[0]["start"].isoformat() == start_time + assert imported_stats[-1]["start"].isoformat() < end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[:]), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_stats[:] + start_time = "2022-10-20T13:00:00+00:00" + end_time = "2022-10-21T08:20:00+00:00" + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[:]), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_stats[1:4] + start_time = "2022-10-20T16:00:00+00:00" + end_time = "2022-10-20T23:00:00+00:00" + assert imported_stats[1]["start"].isoformat() == start_time + assert imported_stats[3]["start"].isoformat() < end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[1:4]), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_stats[1:4] + start_time = "2022-10-20T15:00:00+00:00" + end_time = "2022-10-21T00:00:00+00:00" + assert imported_stats[1]["start"].isoformat() > start_time + assert imported_stats[3]["start"].isoformat() < end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[1:4]), + "max": None, + "min": None, + "change": None, + } + + @pytest.mark.parametrize( "frozen_time", [ @@ -897,7 +1434,7 @@ async def test_statistic_during_period_partial_overlap( statId = "sensor.test_overlapping" imported_metadata = { - "has_mean": False, + "has_mean": True, "has_sum": True, "name": "Total imported energy overlapping", "source": "recorder", @@ -1766,6 +2303,7 @@ async def test_list_statistic_ids( """Test list_statistic_ids.""" now = get_start_time(dt_util.utcnow()) has_mean = attributes["state_class"] == "measurement" + mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE has_sum = not has_mean hass.config.units = units @@ -1791,6 +2329,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1813,6 +2352,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1838,6 +2378,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1859,6 +2400,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1939,6 +2481,7 @@ async def test_list_statistic_ids_unit_change( """Test list_statistic_ids.""" now = get_start_time(dt_util.utcnow()) has_mean = attributes["state_class"] == "measurement" + mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE has_sum = not has_mean await async_setup_component(hass, "sensor", {}) @@ -1966,6 +2509,7 @@ async def test_list_statistic_ids_unit_change( "statistic_id": "sensor.test", "display_unit_of_measurement": statistics_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1987,6 +2531,7 @@ async def test_list_statistic_ids_unit_change( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -2208,6 +2753,7 @@ async def test_update_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2235,6 +2781,7 @@ async def test_update_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": new_display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2324,6 +2871,7 @@ async def test_change_statistics_unit( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2375,6 +2923,7 @@ async def test_change_statistics_unit( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2428,6 +2977,7 @@ async def test_change_statistics_unit( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2455,6 +3005,7 @@ async def test_change_statistics_unit_errors( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2774,6 +3325,7 @@ async def test_get_statistics_metadata( """Test get_statistics_metadata.""" now = get_start_time(dt_util.utcnow()) has_mean = attributes["state_class"] == "measurement" + mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE has_sum = not has_mean hass.config.units = units @@ -2843,6 +3395,7 @@ async def test_get_statistics_metadata( "statistic_id": "test:total_gas", "display_unit_of_measurement": unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": "Total imported energy", "source": "test", @@ -2874,6 +3427,7 @@ async def test_get_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": attributes["unit_of_measurement"], "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -2901,6 +3455,7 @@ async def test_get_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": attributes["unit_of_measurement"], "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -2995,6 +3550,7 @@ async def test_import_statistics( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3009,6 +3565,7 @@ async def test_import_statistics( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -3213,6 +3770,7 @@ async def test_adjust_sum_statistics_energy( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3227,6 +3785,7 @@ async def test_adjust_sum_statistics_energy( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -3406,6 +3965,7 @@ async def test_adjust_sum_statistics_gas( { "display_unit_of_measurement": "m³", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3420,6 +3980,7 @@ async def test_adjust_sum_statistics_gas( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -3617,6 +4178,7 @@ async def test_adjust_sum_statistics_errors( { "display_unit_of_measurement": state_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3631,6 +4193,7 @@ async def test_adjust_sum_statistics_errors( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, diff --git a/tests/components/rehlko/__init__.py b/tests/components/rehlko/__init__.py new file mode 100644 index 00000000000..437138a713d --- /dev/null +++ b/tests/components/rehlko/__init__.py @@ -0,0 +1 @@ +"""Rehlko Tests Package.""" diff --git a/tests/components/rehlko/conftest.py b/tests/components/rehlko/conftest.py new file mode 100644 index 00000000000..f5e5a00142b --- /dev/null +++ b/tests/components/rehlko/conftest.py @@ -0,0 +1,100 @@ +"""Module for testing the Rehlko integration in Home Assistant.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.rehlko import CONF_REFRESH_TOKEN, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_value_fixture + +TEST_EMAIL = "MyEmail@email.com" +TEST_PASSWORD = "password" +TEST_SUBJECT = TEST_EMAIL.lower() +TEST_REFRESH_TOKEN = "my_refresh_token" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.rehlko.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="homes") +def rehlko_homes_fixture() -> list[dict[str, Any]]: + """Create sonos favorites fixture.""" + return load_json_value_fixture("homes.json", DOMAIN) + + +@pytest.fixture(name="generator") +def rehlko_generator_fixture() -> dict[str, Any]: + """Create sonos favorites fixture.""" + return load_json_value_fixture("generator.json", DOMAIN) + + +@pytest.fixture(name="rehlko_config_entry") +def rehlko_config_entry_fixture() -> MockConfigEntry: + """Create a config entry fixture.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id=TEST_SUBJECT, + ) + + +@pytest.fixture(name="rehlko_config_entry_with_refresh_token") +def rehlko_config_entry_with_refresh_token_fixture() -> MockConfigEntry: + """Create a config entry fixture with refresh token.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + CONF_REFRESH_TOKEN: TEST_REFRESH_TOKEN, + }, + unique_id=TEST_SUBJECT, + ) + + +@pytest.fixture +async def mock_rehlko( + homes: list[dict[str, Any]], + generator: dict[str, Any], +): + """Mock Rehlko instance.""" + with ( + patch("homeassistant.components.rehlko.AioKem", autospec=True) as mock_kem, + patch("homeassistant.components.rehlko.config_flow.AioKem", new=mock_kem), + ): + client = mock_kem.return_value + client.get_homes = AsyncMock(return_value=homes) + client.get_generator_data = AsyncMock(return_value=generator) + client.authenticate = AsyncMock(return_value=None) + client.get_token_subject = Mock(return_value=TEST_SUBJECT) + client.get_refresh_token = AsyncMock(return_value=TEST_REFRESH_TOKEN) + client.set_refresh_token_callback = Mock() + client.set_retry_policy = Mock() + yield client + + +@pytest.fixture +async def load_rehlko_config_entry( + hass: HomeAssistant, + mock_rehlko: Mock, + rehlko_config_entry: MockConfigEntry, +) -> None: + """Load the config entry.""" + rehlko_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(rehlko_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/rehlko/fixtures/generator.json b/tests/components/rehlko/fixtures/generator.json new file mode 100644 index 00000000000..fa1d4d0b45b --- /dev/null +++ b/tests/components/rehlko/fixtures/generator.json @@ -0,0 +1,191 @@ +{ + "device": { + "id": 12345, + "serialNumber": "123MGVHR4567", + "displayName": "Generator 1", + "deviceHost": "Oncue", + "hasAcceptedPrivacyPolicy": true, + "address": { + "lat": 41.3341111, + "long": -72.3333111, + "address1": "Highway 66", + "address2": null, + "city": "Somewhere", + "state": "CA", + "postalCode": "00000", + "country": "US" + }, + "product": "Rdc2v4", + "productDisplayName": "RDC 2.4", + "controllerType": "RDC2 (Blue Board)", + "firmwareVersion": "3.4.5", + "currentFirmware": "RDC2.4 3.4.5", + "isConnected": true, + "lastConnectedTimestamp": "2025-04-14T09:30:17+00:00", + "deviceIpAddress": "1.1.1.1:2402", + "macAddress": "91:E1:20:63:10:00", + "status": "ReadyToRun", + "statusUpdateTimestamp": "2025-04-14T09:29:01+00:00", + "dealerOrgs": [ + { + "id": 123, + "businessPartnerNo": "123456", + "name": "Generators R Us", + "e164PhoneNumber": "+199999999999", + "displayPhoneNumber": "(999) 999-9999", + "wizardStep": "OnboardingComplete", + "wizardComplete": true, + "address": { + "lat": null, + "long": null, + "address1": "Highway 66", + "address2": null, + "city": "Revisited", + "state": "CA", + "postalCode": "000000", + "country": null + }, + "userCount": 4, + "technicianCount": 3, + "deviceCount": 71, + "adminEmails": ["admin@gmail.com"] + } + ], + "alertCount": 0, + "model": "Model20KW", + "modelDisplayName": "20 KW", + "lastMaintenanceTimestamp": "2025-04-10T09:12:59", + "nextMaintenanceTimestamp": "2026-04-10T09:12:59", + "maintenancePeriodDays": 365, + "hasServiceAgreement": null, + "totalRuntimeHours": 120.2 + }, + "powerSource": "Utility", + "switchState": "Auto", + "coolingType": "Air", + "connectionType": "Unknown", + "serverIpAddress": "2.2.2.2", + "serviceAgreement": { + "hasServiceAgreement": null, + "beginTimestamp": null, + "term": null, + "termMonths": null, + "termDays": null + }, + "exercise": { + "frequency": "Weekly", + "nextStartTimestamp": "2025-04-19T10:00:00", + "mode": "Unloaded", + "runningMode": null, + "durationMinutes": 20, + "lastStartTimestamp": "2025-04-12T14:00:00+00:00", + "lastEndTimestamp": "2025-04-12T14:19:59+00:00" + }, + "lastRanTimestamp": "2025-04-12T14:00:00+00:00", + "totalRuntimeHours": 120.2, + "totalOperationHours": 33932.3, + "runtimeSinceLastMaintenanceHours": 0.3, + "remoteResetCounterSeconds": 0, + "addedBy": null, + "associatedUsers": ["pete.rage@rage.com"], + "controllerClockTimestamp": "2025-04-15T07:08:50", + "fuelType": "LiquidPropane", + "batteryVoltageV": 13.9, + "engineCoolantTempF": null, + "engineFrequencyHz": 0, + "engineSpeedRpm": 0, + "lubeOilTempF": 42.8, + "controllerTempF": 71.6, + "engineCompartmentTempF": null, + "engineOilPressurePsi": null, + "engineOilPressureOk": true, + "generatorLoadW": 0, + "generatorLoadPercent": 0, + "generatorVoltageAvgV": 0, + "setOutputVoltageV": 240, + "utilityVoltageV": 259.7, + "engineState": "Standby", + "engineStateDisplayNameEn": "Standby", + "loadShed": { + "isConnected": true, + "parameters": [ + { + "definitionId": 1, + "displayName": "HVAC A", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 2, + "displayName": "HVAC B", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 3, + "displayName": "Load A", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 4, + "displayName": "Load B", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 5, + "displayName": "Load C", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 6, + "displayName": "Load D", + "value": false, + "isReadOnly": false + } + ] + }, + "pim": { + "isConnected": false, + "parameters": [ + { + "definitionId": 7, + "displayName": "Digital Output B1 Value", + "value": false, + "isReadOnly": true + }, + { + "definitionId": 8, + "displayName": "Digital Output B2 Value", + "value": false, + "isReadOnly": true + }, + { + "definitionId": 9, + "displayName": "Digital Output B3 Value", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 10, + "displayName": "Digital Output B4 Value", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 11, + "displayName": "Digital Output B5 Value", + "value": false, + "isReadOnly": false + }, + { + "definitionId": 12, + "displayName": "Digital Output B6 Value", + "value": false, + "isReadOnly": false + } + ] + } +} diff --git a/tests/components/rehlko/fixtures/homes.json b/tests/components/rehlko/fixtures/homes.json new file mode 100644 index 00000000000..5cd29e9111c --- /dev/null +++ b/tests/components/rehlko/fixtures/homes.json @@ -0,0 +1,82 @@ +[ + { + "id": 12345, + "name": "Generator 1", + "weatherCondition": "Mist", + "weatherTempF": 46.11200000000006, + "weatherTimePeriod": "Day", + "address": { + "lat": 41.334111, + "long": -72.3333111, + "address1": "Highway 66", + "address2": null, + "city": "Somewhere", + "state": "CA", + "postalCode": "000000", + "country": "US" + }, + "devices": [ + { + "id": 12345, + "serialNumber": "123MGVHR4567", + "displayName": "Generator 1", + "deviceHost": "Oncue", + "hasAcceptedPrivacyPolicy": true, + "address": { + "lat": 41.334111, + "long": -72.3333111, + "address1": "Highway 66", + "address2": null, + "city": "Somewhere", + "state": "CA", + "postalCode": "000000", + "country": "US" + }, + "product": "Rdc2v4", + "productDisplayName": "RDC 2.4", + "controllerType": "RDC2 (Blue Board)", + "firmwareVersion": "3.4.5", + "currentFirmware": "RDC2.4 3.4.5", + "isConnected": true, + "lastConnectedTimestamp": "2025-04-14T09:30:17+00:00", + "deviceIpAddress": "1.1.1.1:2402", + "macAddress": "91:E1:20:63:10:00", + "status": "ReadyToRun", + "statusUpdateTimestamp": "2025-04-14T09:29:01+00:00", + "dealerOrgs": [ + { + "id": 123, + "businessPartnerNo": "123456", + "name": "Generators R Us", + "e164PhoneNumber": "+199999999999", + "displayPhoneNumber": "(999) 999-9999", + "wizardStep": "OnboardingComplete", + "wizardComplete": true, + "address": { + "lat": null, + "long": null, + "address1": "Highway 66", + "address2": null, + "city": "Revisited", + "state": "CA", + "postalCode": "000000", + "country": null + }, + "userCount": 4, + "technicianCount": 3, + "deviceCount": 71, + "adminEmails": ["admin@gmail.com"] + } + ], + "alertCount": 0, + "model": "Model20KW", + "modelDisplayName": "20 KW", + "lastMaintenanceTimestamp": "2025-04-10T09:12:59", + "nextMaintenanceTimestamp": "2026-04-10T09:12:59", + "maintenancePeriodDays": 365, + "hasServiceAgreement": null, + "totalRuntimeHours": 120.2 + } + ] + } +] diff --git a/tests/components/rehlko/snapshots/test_sensor.ambr b/tests/components/rehlko/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3973996ba80 --- /dev/null +++ b/tests/components/rehlko/snapshots/test_sensor.ambr @@ -0,0 +1,1017 @@ +# serializer version: 1 +# name: test_sensors[sensor.generator_1_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_battery_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': 'Battery voltage', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'myemail@email.com_12345_batteryVoltageV', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Generator 1 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.9', + }) +# --- +# name: test_sensors[sensor.generator_1_controller_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_controller_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': 'Controller temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'controller_temperature', + 'unique_id': 'myemail@email.com_12345_controllerTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_controller_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Controller temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_controller_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensors[sensor.generator_1_device_ip_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_device_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': 'Device IP address', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_ip_address', + 'unique_id': 'myemail@email.com_12345_deviceIpAddress', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_device_ip_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Device IP address', + }), + 'context': , + 'entity_id': 'sensor.generator_1_device_ip_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1.1.1:2402', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_compartment_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_engine_compartment_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': 'Engine compartment temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_compartment_temperature', + 'unique_id': 'myemail@email.com_12345_engineCompartmentTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_compartment_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Engine compartment temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_compartment_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_coolant_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_engine_coolant_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': 'Engine coolant temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_coolant_temperature', + 'unique_id': 'myemail@email.com_12345_engineCoolantTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_coolant_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Engine coolant temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_coolant_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_engine_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': 'Engine frequency', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_frequency', + 'unique_id': 'myemail@email.com_12345_engineFrequencyHz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Generator 1 Engine frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_oil_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_engine_oil_pressure', + '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': 'Engine oil pressure', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_oil_pressure', + 'unique_id': 'myemail@email.com_12345_engineOilPressurePsi', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_engine_oil_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Generator 1 Engine oil pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_oil_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_engine_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': 'Engine speed', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_speed', + 'unique_id': 'myemail@email.com_12345_engineSpeedRpm', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Engine speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_engine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_engine_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': 'Engine state', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'engine_state', + 'unique_id': 'myemail@email.com_12345_engineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_engine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Engine state', + }), + 'context': , + 'entity_id': 'sensor.generator_1_engine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Standby', + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_generator_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': 'Generator load', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_load', + 'unique_id': 'myemail@email.com_12345_generatorLoadW', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Generator 1 Generator load', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_generator_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load_percentage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_generator_load_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': 'Generator load percentage', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_load_percent', + 'unique_id': 'myemail@email.com_12345_generatorLoadPercent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.generator_1_generator_load_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Generator load percentage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.generator_1_generator_load_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.generator_1_generator_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_generator_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Generator status', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_status', + 'unique_id': 'myemail@email.com_12345_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_generator_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Generator status', + }), + 'context': , + 'entity_id': 'sensor.generator_1_generator_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ReadyToRun', + }) +# --- +# name: test_sensors[sensor.generator_1_lube_oil_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_lube_oil_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': 'Lube oil temperature', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lube_oil_temperature', + 'unique_id': 'myemail@email.com_12345_lubeOilTempF', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_lube_oil_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Generator 1 Lube oil temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_lube_oil_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.0', + }) +# --- +# name: test_sensors[sensor.generator_1_power_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_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': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_source', + 'unique_id': 'myemail@email.com_12345_powerSource', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_power_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Power source', + }), + 'context': , + 'entity_id': 'sensor.generator_1_power_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Utility', + }) +# --- +# name: test_sensors[sensor.generator_1_runtime_since_last_maintenance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_runtime_since_last_maintenance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Runtime since last maintenance', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'runtime_since_last_maintenance', + 'unique_id': 'myemail@email.com_12345_runtimeSinceLastMaintenanceHours', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_runtime_since_last_maintenance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Generator 1 Runtime since last maintenance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_runtime_since_last_maintenance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensors[sensor.generator_1_server_ip_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_server_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': 'Server IP address', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'server_ip_address', + 'unique_id': 'myemail@email.com_12345_serverIpAddress', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.generator_1_server_ip_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Generator 1 Server IP address', + }), + 'context': , + 'entity_id': 'sensor.generator_1_server_ip_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2.2.2', + }) +# --- +# name: test_sensors[sensor.generator_1_total_operation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_total_operation', + '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 operation', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_operation', + 'unique_id': 'myemail@email.com_12345_totalOperationHours', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_total_operation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Generator 1 Total operation', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_total_operation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33932.3', + }) +# --- +# name: test_sensors[sensor.generator_1_total_runtime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.generator_1_total_runtime', + '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 runtime', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_runtime', + 'unique_id': 'myemail@email.com_12345_totalRuntimeHours', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_total_runtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Generator 1 Total runtime', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_total_runtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.2', + }) +# --- +# name: test_sensors[sensor.generator_1_utility_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_utility_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': 'Utility voltage', + 'platform': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'utility_voltage', + 'unique_id': 'myemail@email.com_12345_utilityVoltageV', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_utility_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Generator 1 Utility voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_utility_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '259.7', + }) +# --- +# name: test_sensors[sensor.generator_1_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.generator_1_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': 'rehlko', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_voltage_avg', + 'unique_id': 'myemail@email.com_12345_generatorVoltageAvgV', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.generator_1_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Generator 1 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.generator_1_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/rehlko/test_config_flow.py b/tests/components/rehlko/test_config_flow.py new file mode 100644 index 00000000000..6e3400941ab --- /dev/null +++ b/tests/components/rehlko/test_config_flow.py @@ -0,0 +1,218 @@ +"""Test the Rehlko config flow.""" + +from unittest.mock import AsyncMock + +from aiokem import AuthenticationCredentialsError +import pytest + +from homeassistant.components.rehlko import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +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 .conftest import TEST_EMAIL, TEST_PASSWORD, TEST_SUBJECT + +from tests.common import MockConfigEntry + +DHCP_DISCOVERY = DhcpServiceInfo( + ip="1.1.1.1", + hostname="KohlerGen", + macaddress="00146FAABBCC", +) + + +async def test_configure_entry( + hass: HomeAssistant, mock_rehlko: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we can configure the entry.""" + 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_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL.lower() + assert result["data"] == { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + } + assert result["result"].unique_id == TEST_SUBJECT + assert mock_setup_entry.call_count == 1 + + +@pytest.mark.parametrize( + ("error", "conf_error"), + [ + (AuthenticationCredentialsError, {CONF_PASSWORD: "invalid_auth"}), + (TimeoutError, {"base": "cannot_connect"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_configure_entry_exceptions( + hass: HomeAssistant, + mock_rehlko: AsyncMock, + error: Exception, + conf_error: dict[str, str], + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle a variety of exceptions and recover by adding new entry.""" + # First try to authenticate and get an error + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_rehlko.authenticate.side_effect = error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == conf_error + assert mock_setup_entry.call_count == 0 + + # Now try to authenticate again and succeed + # This should create a new entry + mock_rehlko.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL.lower() + assert result["data"] == { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + } + assert result["result"].unique_id == TEST_SUBJECT + assert mock_setup_entry.call_count == 1 + + +async def test_already_configured( + hass: HomeAssistant, rehlko_config_entry: MockConfigEntry, mock_rehlko: AsyncMock +) -> None: + """Test if entry is already configured.""" + rehlko_config_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_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth( + hass: HomeAssistant, + rehlko_config_entry: MockConfigEntry, + mock_rehlko: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth flow.""" + rehlko_config_entry.add_to_hass(hass) + result = await rehlko_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: TEST_PASSWORD + "new", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert rehlko_config_entry.data[CONF_PASSWORD] == TEST_PASSWORD + "new" + assert mock_setup_entry.call_count == 1 + + +async def test_reauth_exception( + hass: HomeAssistant, + rehlko_config_entry: MockConfigEntry, + mock_rehlko: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth flow.""" + rehlko_config_entry.add_to_hass(hass) + result = await rehlko_config_entry.start_reauth_flow(hass) + + mock_rehlko.authenticate.side_effect = AuthenticationCredentialsError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"password": "invalid_auth"} + + mock_rehlko.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: TEST_PASSWORD + "new", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_dhcp_discovery( + hass: HomeAssistant, mock_rehlko: 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["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_dhcp_discovery_already_set_up( + hass: HomeAssistant, rehlko_config_entry: MockConfigEntry, mock_rehlko: AsyncMock +) -> None: + """Test DHCP discovery aborts if already set up.""" + rehlko_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" diff --git a/tests/components/rehlko/test_sensor.py b/tests/components/rehlko/test_sensor.py new file mode 100644 index 00000000000..ce361678a59 --- /dev/null +++ b/tests/components/rehlko/test_sensor.py @@ -0,0 +1,85 @@ +"""Tests for the Rehlko sensors.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.rehlko.coordinator import SCAN_INTERVAL_MINUTES +from homeassistant.const import STATE_UNAVAILABLE, 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(name="platform_sensor", autouse=True) +async def platform_sensor_fixture(): + """Patch Rehlko to only load Sensor platform.""" + with patch("homeassistant.components.rehlko.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + rehlko_config_entry: MockConfigEntry, + load_rehlko_config_entry: None, +) -> None: + """Test the Rehlko sensors.""" + await snapshot_platform( + hass, entity_registry, snapshot, rehlko_config_entry.entry_id + ) + + +async def test_sensor_availability_device_disconnect( + hass: HomeAssistant, + generator: dict[str, Any], + mock_rehlko: AsyncMock, + load_rehlko_config_entry: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Rehlko sensor availability when device is disconnected.""" + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == "13.9" + + generator["device"]["isConnected"] = False + + # Move time to next update + freezer.tick(SCAN_INTERVAL_MINUTES) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_availability_poll_failure( + hass: HomeAssistant, + mock_rehlko: AsyncMock, + load_rehlko_config_entry: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Rehlko sensor availability when cloud poll fails.""" + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == "13.9" + + mock_rehlko.get_generator_data.side_effect = Exception("Test exception") + + # Move time to next update + freezer.tick(SCAN_INTERVAL_MINUTES) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.generator_1_battery_voltage") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/remote_calendar/__init__.py b/tests/components/remote_calendar/__init__.py new file mode 100644 index 00000000000..2ffb157f072 --- /dev/null +++ b/tests/components/remote_calendar/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the Remote Calendar 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) diff --git a/tests/components/remote_calendar/conftest.py b/tests/components/remote_calendar/conftest.py new file mode 100644 index 00000000000..bf5184bbf54 --- /dev/null +++ b/tests/components/remote_calendar/conftest.py @@ -0,0 +1,89 @@ +"""Fixtures for Remote Calendar.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +import textwrap +from typing import Any +import urllib + +import pytest + +from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + +CALENDAR_NAME = "Home Assistant Events" +TEST_ENTITY = "calendar.home_assistant_events" +CALENDER_URL = "https://some.calendar.com/calendar.ics" +FRIENDLY_NAME = "Home Assistant Events" + + +@pytest.fixture(name="time_zone") +def mock_time_zone() -> str: + """Fixture for time zone to use in tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + return "America/Regina" + + +@pytest.fixture(autouse=True) +async def set_time_zone(hass: HomeAssistant, time_zone: str): + """Set the time zone for the tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + await hass.config.async_set_time_zone(time_zone) + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Fixture for mock configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_CALENDAR_NAME: CALENDAR_NAME, CONF_URL: CALENDER_URL} + ) + + +type GetEventsFn = Callable[[str, str], Awaitable[list[dict[str, Any]]]] + + +@pytest.fixture(name="get_events") +def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: + """Fetch calendar events from the HTTP API.""" + + async def _fetch(start: str, end: str) -> list[dict[str, Any]]: + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + ) + assert response.status == HTTPStatus.OK + return await response.json() + + return _fetch + + +def event_fields(data: dict[str, str]) -> dict[str, str]: + """Filter event API response to minimum fields.""" + return { + k: data[k] + for k in ("summary", "start", "end", "recurrence_id", "location") + if data.get(k) + } + + +@pytest.fixture(name="ics_content") +def mock_ics_content(request: pytest.FixtureRequest) -> str: + """Fixture to allow tests to set initial ics content for the calendar store.""" + default_content = textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART:19970714T170000Z + DTEND:19970715T040000Z + END:VEVENT + END:VCALENDAR + """ + ) + return request.param if hasattr(request, "param") else default_content diff --git a/tests/components/remote_calendar/snapshots/test_calendar.ambr b/tests/components/remote_calendar/snapshots/test_calendar.ambr new file mode 100644 index 00000000000..e372be5255c --- /dev/null +++ b/tests/components/remote_calendar/snapshots/test_calendar.ambr @@ -0,0 +1,19 @@ +# serializer version: 1 +# name: test_calendar_examples[office365_invalid_tzid] + list([ + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2024-04-26T15:00:00-06:00', + }), + 'location': '', + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-04-26T14:00:00-06:00', + }), + 'summary': 'Uffe', + 'uid': '040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000010000000309AE93C8C3A94489F90ADBEA30C2F2B', + }), + ]) +# --- diff --git a/tests/components/remote_calendar/snapshots/test_diagnostics.ambr b/tests/components/remote_calendar/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..de955f8a2aa --- /dev/null +++ b/tests/components/remote_calendar/snapshots/test_diagnostics.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'ics': ''' + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:*** + DTSTART:19970714T170000Z + DTEND:19970715T040000Z + END:VEVENT + END:VCALENDAR + ''', + 'now': '2023-06-04T18:00:00-06:00', + 'system_timezone': 'tzlocal()', + 'timezone': 'America/Regina', + }) +# --- diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py new file mode 100644 index 00000000000..a0c18383369 --- /dev/null +++ b/tests/components/remote_calendar/test_calendar.py @@ -0,0 +1,424 @@ +"""Tests for calendar platform of Remote Calendar.""" + +from datetime import datetime +import pathlib +import textwrap + +from httpx import Response +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import ( + CALENDER_URL, + FRIENDLY_NAME, + TEST_ENTITY, + GetEventsFn, + event_fields, +) + +from tests.common import MockConfigEntry + +# Test data files with known calendars from various sources. You can add a new file +# in the testdata directory and add it will be parsed and tested. +TESTDATA_FILES = sorted( + pathlib.Path("tests/components/remote_calendar/testdata/").glob("*.ics") +) +TESTDATA_IDS = [f.stem for f in TESTDATA_FILES] + + +@respx.mock +async def test_empty_calendar( + hass: HomeAssistant, + config_entry: MockConfigEntry, + get_events: GetEventsFn, +) -> None: + """Test querying the API and fetching events.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//hacksw/handcal//NONSGML v1.0//EN + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + events = await get_events("1997-07-14T00:00:00", "1997-07-16T00:00:00") + assert len(events) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == FRIENDLY_NAME + assert state.state == STATE_OFF + assert dict(state.attributes) == { + "friendly_name": FRIENDLY_NAME, + } + + +@pytest.mark.parametrize( + "ics_content", + [ + textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART;TZID=Europe/Berlin:19970714T190000 + DTEND;TZID=Europe/Berlin:19970715T060000 + END:VEVENT + END:VCALENDAR + """ + ), + textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART:19970714T170000Z + DTEND:19970715T040000Z + END:VEVENT + END:VCALENDAR + """ + ), + textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART;TZID=America/Regina:19970714T110000 + DTEND;TZID=America/Regina:19970714T220000 + END:VEVENT + END:VCALENDAR + """ + ), + textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART;TZID=America/Los_Angeles:19970714T100000 + DTEND;TZID=America/Los_Angeles:19970714T210000 + END:VEVENT + END:VCALENDAR + """ + ), + ], +) +@respx.mock +async def test_api_date_time_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, + ics_content: str, +) -> None: + """Test an event with a start/end date time.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + await setup_integration(hass, config_entry) + events = await get_events("1997-07-14T00:00:00Z", "1997-07-16T00:00:00Z") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party", + "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, + "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + } + ] + + # Query events in UTC + + # Time range before event + events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T16:00:00Z") + assert len(events) == 0 + # Time range after event + events = await get_events("1997-07-15T05:00:00Z", "1997-07-15T06:00:00Z") + assert len(events) == 0 + + # Overlap with event start + events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T18:00:00Z") + assert len(events) == 1 + # Overlap with event end + events = await get_events("1997-07-15T03:00:00Z", "1997-07-15T06:00:00Z") + assert len(events) == 1 + + # Query events overlapping with start and end but in another timezone + events = await get_events("1997-07-12T23:00:00-01:00", "1997-07-14T17:00:00-01:00") + assert len(events) == 1 + events = await get_events("1997-07-15T02:00:00-01:00", "1997-07-15T05:00:00-01:00") + assert len(events) == 1 + + +@respx.mock +async def test_api_date_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a start/end date all day event.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Festival International de Jazz de Montreal + DTSTART:20070628 + DTEND:20070709 + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + events = await get_events("2007-06-20T00:00:00", "2007-07-20T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Festival International de Jazz de Montreal", + "start": {"date": "2007-06-28"}, + "end": {"date": "2007-07-09"}, + } + ] + + # Time range before event (timezone is -6) + events = await get_events("2007-06-26T00:00:00Z", "2007-06-28T01:00:00Z") + assert len(events) == 0 + # Time range after event + events = await get_events("2007-07-10T00:00:00Z", "2007-07-11T00:00:00Z") + assert len(events) == 0 + + # Overlap with event start (timezone is -6) + events = await get_events("2007-06-26T00:00:00Z", "2007-06-28T08:00:00Z") + assert len(events) == 1 + # Overlap with event end + events = await get_events("2007-07-09T00:00:00Z", "2007-07-11T00:00:00Z") + assert len(events) == 1 + + +@pytest.mark.freeze_time(datetime(2007, 6, 28, 12)) +@respx.mock +async def test_active_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a start/end date time.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Festival International de Jazz de Montreal + LOCATION:Montreal + DTSTART:20070628 + DTEND:20070709 + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == FRIENDLY_NAME + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": FRIENDLY_NAME, + "message": "Festival International de Jazz de Montreal", + "all_day": True, + "description": "", + "location": "Montreal", + "start_time": "2007-06-28 00:00:00", + "end_time": "2007-07-09 00:00:00", + } + + +@pytest.mark.freeze_time(datetime(2007, 6, 27, 12)) +@respx.mock +async def test_upcoming_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a start/end date time.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Festival International de Jazz de Montreal + LOCATION:Montreal + DTSTART:20070628 + DTEND:20070709 + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == FRIENDLY_NAME + assert state.state == STATE_OFF + assert dict(state.attributes) == { + "friendly_name": FRIENDLY_NAME, + "message": "Festival International de Jazz de Montreal", + "all_day": True, + "description": "", + "location": "Montreal", + "start_time": "2007-06-28 00:00:00", + "end_time": "2007-07-09 00:00:00", + } + + +@respx.mock +async def test_recurring_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a recurrence rule.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTSTART:20220829T090000 + DTEND:20220829T100000 + SUMMARY:Monday meeting + RRULE:FREQ=WEEKLY;BYDAY=MO + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + + events = await get_events("2022-08-20T00:00:00", "2022-09-20T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-08-29T09:00:00-06:00"}, + "end": {"dateTime": "2022-08-29T10:00:00-06:00"}, + "recurrence_id": "20220829T090000", + }, + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-09-05T09:00:00-06:00"}, + "end": {"dateTime": "2022-09-05T10:00:00-06:00"}, + "recurrence_id": "20220905T090000", + }, + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-09-12T09:00:00-06:00"}, + "end": {"dateTime": "2022-09-12T10:00:00-06:00"}, + "recurrence_id": "20220912T090000", + }, + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-09-19T09:00:00-06:00"}, + "end": {"dateTime": "2022-09-19T10:00:00-06:00"}, + "recurrence_id": "20220919T090000", + }, + ] + + +@respx.mock +@pytest.mark.parametrize( + ("time_zone", "event_order"), + [ + ("America/Los_Angeles", ["One", "Two", "All Day Event"]), + ("America/Regina", ["One", "Two", "All Day Event"]), + ("UTC", ["One", "All Day Event", "Two"]), + ("Asia/Tokyo", ["All Day Event", "One", "Two"]), + ], +) +async def test_all_day_iter_order( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, + event_order: list[str], +) -> None: + """Test the sort order of an all day events depending on the time zone.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + + BEGIN:VEVENT + DTSTART:20221008 + DTEND:20221009 + SUMMARY:All Day Event + END:VEVENT + + BEGIN:VEVENT + DTSTART:20221007T230000Z + DTEND:20221008T233000Z + SUMMARY:One + END:VEVENT + + BEGIN:VEVENT + DTSTART:20221008T010000Z + DTEND:20221008T020000Z + SUMMARY:Two + END:VEVENT + + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + + events = await get_events("2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z") + assert [event["summary"] for event in events] == event_order + + +@respx.mock +@pytest.mark.parametrize("ics_filename", TESTDATA_FILES, ids=TESTDATA_IDS) +async def test_calendar_examples( + hass: HomeAssistant, + config_entry: MockConfigEntry, + get_events: GetEventsFn, + ics_filename: pathlib.Path, + snapshot: SnapshotAssertion, +) -> None: + """Test parsing known calendars form test data files.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_filename.read_text(), + ) + ) + await setup_integration(hass, config_entry) + events = await get_events("1997-07-14T00:00:00", "2025-07-01T00:00:00") + assert events == snapshot diff --git a/tests/components/remote_calendar/test_config_flow.py b/tests/components/remote_calendar/test_config_flow.py new file mode 100644 index 00000000000..9aff1594db3 --- /dev/null +++ b/tests/components/remote_calendar/test_config_flow.py @@ -0,0 +1,314 @@ +"""Test the Remote Calendar config flow.""" + +from httpx import ConnectError, Response, UnsupportedProtocol +import pytest +import respx + +from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration +from .conftest import CALENDAR_NAME, CALENDER_URL + +from tests.common import MockConfigEntry + + +@respx.mock +async def test_form_import_ics(hass: HomeAssistant, ics_content: str) -> None: + """Test we get the import form.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == CALENDAR_NAME + assert result2["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +@respx.mock +async def test_form_import_webcal(hass: HomeAssistant, ics_content: str) -> None: + """Test we get the import form.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: "webcal://some.calendar.com/calendar.ics", + }, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == CALENDAR_NAME + assert result2["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +@pytest.mark.parametrize( + ("side_effect"), + [ + ConnectError("Connection failed"), + UnsupportedProtocol("Unsupported protocol"), + ], +) +@respx.mock +async def test_form_inavild_url( + hass: HomeAssistant, + side_effect: Exception, + ics_content: str, +) -> None: + """Test we get the import form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + respx.get("invalid-url.com").mock(side_effect=side_effect) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: "invalid-url.com", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == CALENDAR_NAME + assert result3["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +@pytest.mark.parametrize( + ("url", "log_message"), + [ + ( + "unsupported://protocol.com", # Test for httpx.UnsupportedProtocol + "Request URL has an unsupported protocol 'unsupported://'", + ), + ( + "invalid-url", # Test for httpx.ProtocolError + "Request URL is missing an 'http://' or 'https://' protocol", + ), + ( + "https://example.com:abc/", # Test for httpx.InvalidURL + "Invalid port: 'abc'", + ), + ], +) +async def test_unsupported_inputs( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, url: str, log_message: str +) -> None: + """Test that an unsupported inputs results in a form error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: url, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + assert log_message in caplog.text + ## It's not possible to test a successful config flow because, we need to mock httpx.get here + ## and then the exception isn't raised anymore. + + +@pytest.mark.parametrize( + ("http_status", "error"), + [ + (401, "cannot_connect"), + (403, "forbidden"), + ], +) +@respx.mock +async def test_form_http_status_error( + hass: HomeAssistant, ics_content: str, http_status: int, error: str +) -> None: + """Test we http status.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=http_status, + ) + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": error} + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == CALENDAR_NAME + assert result3["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +@respx.mock +async def test_no_valid_calendar(hass: HomeAssistant, ics_content: str) -> None: + """Test invalid ics content.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text="blabla", + ) + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_ics_file"} + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == CALENDAR_NAME + assert result3["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +async def test_duplicate_name( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test two calendars cannot be added with the same name.""" + + 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 + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: "http://other-calendar.com", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_duplicate_url( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test two calendars cannot be added with the same url.""" + + 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 + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: "new name", + CONF_URL: CALENDER_URL, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/remote_calendar/test_diagnostics.py b/tests/components/remote_calendar/test_diagnostics.py new file mode 100644 index 00000000000..428369b1180 --- /dev/null +++ b/tests/components/remote_calendar/test_diagnostics.py @@ -0,0 +1,39 @@ +"""Test the remote calendar diagnostics.""" + +import datetime + +from httpx import Response +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import CALENDER_URL + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@respx.mock +@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5)) +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + ics_content: str, +) -> None: + """Test config entry diagnostics.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + await setup_integration(hass, config_entry) + await hass.async_block_till_done() + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == snapshot diff --git a/tests/components/remote_calendar/test_init.py b/tests/components/remote_calendar/test_init.py new file mode 100644 index 00000000000..f4ca500b2e1 --- /dev/null +++ b/tests/components/remote_calendar/test_init.py @@ -0,0 +1,86 @@ +"""Tests for init platform of Remote Calendar.""" + +from httpx import ConnectError, Response, UnsupportedProtocol +import pytest +import respx + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import CALENDER_URL, TEST_ENTITY + +from tests.common import MockConfigEntry + + +@respx.mock +async def test_load_unload( + hass: HomeAssistant, config_entry: MockConfigEntry, ics_content: str +) -> None: + """Test loading and unloading a config entry.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == STATE_OFF + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@respx.mock +async def test_raise_for_status( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test update failed using respx to simulate HTTP exceptions.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=403, + ) + ) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + "side_effect", + [ + ConnectError("Connection failed"), + UnsupportedProtocol("Unsupported protocol"), + ValueError("Invalid response"), + ], +) +@respx.mock +async def test_update_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test update failed using respx to simulate different exceptions.""" + respx.get(CALENDER_URL).mock(side_effect=side_effect) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@respx.mock +async def test_calendar_parse_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test CalendarParseError using respx.""" + respx.get(CALENDER_URL).mock( + return_value=Response(status_code=200, text="not a calendar") + ) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/remote_calendar/testdata/office365_invalid_tzid.ics b/tests/components/remote_calendar/testdata/office365_invalid_tzid.ics new file mode 100644 index 00000000000..bfadba446d2 --- /dev/null +++ b/tests/components/remote_calendar/testdata/office365_invalid_tzid.ics @@ -0,0 +1,58 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +PRODID:Microsoft Exchange Server 2010 +VERSION:2.0 +X-WR-CALNAME:Kalender +BEGIN:VTIMEZONE +TZID:W. Europe Standard Time +BEGIN:STANDARD +DTSTART:16010101T030000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T020000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VTIMEZONE +TZID:UTC +BEGIN:STANDARD +DTSTART:16010101T000000 +TZOFFSETFROM:+0000 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T000000 +TZOFFSETFROM:+0000 +TZOFFSETTO:+0000 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000 + 010000000309AE93C8C3A94489F90ADBEA30C2F2B +SUMMARY:Uffe +DTSTART;TZID=Customized Time Zone:20240426T140000 +DTEND;TZID=Customized Time Zone:20240426T150000 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20250417T155647Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:0 +LOCATION: +X-MICROSOFT-CDO-APPT-SEQUENCE:0 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:0 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:FALSE +END:VEVENT +END:VCALENDAR diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index a7c6b314ccb..b621d7d940c 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -1,101 +1 @@ """Tests for the Renault integration.""" - -from __future__ import annotations - -from types import MappingProxyType - -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_ICON, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_MODEL_ID, - ATTR_NAME, - ATTR_STATE, - STATE_UNAVAILABLE, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry -from homeassistant.helpers.entity_registry import EntityRegistry - -from .const import ( - ATTR_UNIQUE_ID, - DYNAMIC_ATTRIBUTES, - FIXED_ATTRIBUTES, - ICON_FOR_EMPTY_VALUES, -) - - -def get_no_data_icon(expected_entity: MappingProxyType): - """Check icon attribute for inactive sensors.""" - entity_id = expected_entity[ATTR_ENTITY_ID] - return ICON_FOR_EMPTY_VALUES.get(entity_id, expected_entity.get(ATTR_ICON)) - - -def check_device_registry( - device_registry: DeviceRegistry, expected_device: MappingProxyType -) -> None: - """Ensure that the expected_device is correctly registered.""" - assert len(device_registry.devices) == 1 - registry_entry = device_registry.async_get_device( - identifiers=expected_device[ATTR_IDENTIFIERS] - ) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device[ATTR_IDENTIFIERS] - assert registry_entry.manufacturer == expected_device[ATTR_MANUFACTURER] - assert registry_entry.name == expected_device[ATTR_NAME] - assert registry_entry.model == expected_device[ATTR_MODEL] - assert registry_entry.model_id == expected_device[ATTR_MODEL_ID] - - -def check_entities( - hass: HomeAssistant, - entity_registry: EntityRegistry, - expected_entities: MappingProxyType, -) -> None: - """Ensure that the expected_entities are correct.""" - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is not None - assert registry_entry.unique_id == expected_entity[ATTR_UNIQUE_ID] - state = hass.states.get(entity_id) - assert state.state == expected_entity[ATTR_STATE] - for attr in FIXED_ATTRIBUTES + DYNAMIC_ATTRIBUTES: - assert state.attributes.get(attr) == expected_entity.get(attr) - - -def check_entities_no_data( - hass: HomeAssistant, - entity_registry: EntityRegistry, - expected_entities: MappingProxyType, - expected_state: str, -) -> None: - """Ensure that the expected_entities are correct.""" - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is not None - assert registry_entry.unique_id == expected_entity[ATTR_UNIQUE_ID] - state = hass.states.get(entity_id) - assert state.state == expected_state - for attr in FIXED_ATTRIBUTES: - assert state.attributes.get(attr) == expected_entity.get(attr) - - -def check_entities_unavailable( - hass: HomeAssistant, - entity_registry: EntityRegistry, - expected_entities: MappingProxyType, -) -> None: - """Ensure that the expected_entities are correct.""" - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is not None, f"{entity_id} not found in registry" - assert registry_entry.unique_id == expected_entity[ATTR_UNIQUE_ID] - state = hass.states.get(entity_id) - assert state.state == STATE_UNAVAILABLE - for attr in FIXED_ATTRIBUTES: - assert state.attributes.get(attr) == expected_entity.get(attr) diff --git a/tests/components/renault/conftest.py b/tests/components/renault/conftest.py index 9be41eb7ba0..ad968358c78 100644 --- a/tests/components/renault/conftest.py +++ b/tests/components/renault/conftest.py @@ -1,13 +1,12 @@ """Provide common Renault fixtures.""" -from collections.abc import Generator, Iterator +from collections.abc import AsyncGenerator, Generator import contextlib from types import MappingProxyType -from typing import Any from unittest.mock import AsyncMock, patch import pytest -from renault_api.kamereon import exceptions, schemas +from renault_api.kamereon import exceptions, models, schemas from renault_api.renault_account import RenaultAccount from homeassistant.components.renault.const import DOMAIN @@ -51,7 +50,7 @@ def get_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture(name="patch_renault_account") -async def patch_renault_account(hass: HomeAssistant) -> RenaultAccount: +async def patch_renault_account(hass: HomeAssistant) -> AsyncGenerator[RenaultAccount]: """Create a Renault account.""" renault_account = RenaultAccount( MOCK_ACCOUNT_ID, @@ -68,15 +67,27 @@ async def patch_renault_account(hass: HomeAssistant) -> RenaultAccount: @pytest.fixture(name="patch_get_vehicles") -def patch_get_vehicles(vehicle_type: str): +def patch_get_vehicles(vehicle_type: str) -> Generator[None]: """Mock fixtures.""" + fixture_code = vehicle_type if vehicle_type in MOCK_VEHICLES else "zoe_40" + return_value: models.KamereonVehiclesResponse = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{fixture_code}.json") + ) + ) + + if vehicle_type == "missing_details": + return_value.vehicleLinks[0].vehicleDetails = None + elif vehicle_type == "multi": + return_value.vehicleLinks.extend( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_captur_fuel.json") + ).vehicleLinks + ) + with patch( "renault_api.renault_account.RenaultAccount.get_vehicles", - return_value=( - schemas.KamereonVehiclesResponseSchema.loads( - load_fixture(f"renault/vehicle_{vehicle_type}.json") - ) - ), + return_value=return_value, ): yield @@ -123,149 +134,100 @@ def _get_fixtures(vehicle_type: str) -> MappingProxyType: } +@contextlib.contextmanager +def patch_get_vehicle_data() -> Generator[dict[str, AsyncMock]]: + """Mock get_vehicle_data methods.""" + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status" + ) as get_battery_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode" + ) as get_charge_mode, + patch("renault_api.renault_vehicle.RenaultVehicle.get_cockpit") as get_cockpit, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status" + ) as get_hvac_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_location" + ) as get_location, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_lock_status" + ) as get_lock_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_res_state" + ) as get_res_state, + ): + yield { + "battery_status": get_battery_status, + "charge_mode": get_charge_mode, + "cockpit": get_cockpit, + "hvac_status": get_hvac_status, + "location": get_location, + "lock_status": get_lock_status, + "res_state": get_res_state, + } + + @pytest.fixture(name="fixtures_with_data") -def patch_fixtures_with_data(vehicle_type: str): +def patch_fixtures_with_data(vehicle_type: str) -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" mock_fixtures = _get_fixtures(vehicle_type) - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - return_value=mock_fixtures["battery_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - return_value=mock_fixtures["charge_mode"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - return_value=mock_fixtures["cockpit"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - return_value=mock_fixtures["hvac_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - return_value=mock_fixtures["location"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - return_value=mock_fixtures["lock_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - return_value=mock_fixtures["res_state"], - ), - ): - yield + with patch_get_vehicle_data() as patches: + for key, value in patches.items(): + value.return_value = mock_fixtures[key] + yield patches @pytest.fixture(name="fixtures_with_no_data") -def patch_fixtures_with_no_data(): +def patch_fixtures_with_no_data() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" mock_fixtures = _get_fixtures("") - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - return_value=mock_fixtures["battery_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - return_value=mock_fixtures["charge_mode"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - return_value=mock_fixtures["cockpit"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - return_value=mock_fixtures["hvac_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - return_value=mock_fixtures["location"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - return_value=mock_fixtures["lock_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - return_value=mock_fixtures["res_state"], - ), - ): - yield - - -@contextlib.contextmanager -def _patch_fixtures_with_side_effect(side_effect: Any) -> Iterator[None]: - """Mock fixtures.""" - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - side_effect=side_effect, - ), - ): - yield + with patch_get_vehicle_data() as patches: + for key, value in patches.items(): + value.return_value = mock_fixtures[key] + yield patches @pytest.fixture(name="fixtures_with_access_denied_exception") -def patch_fixtures_with_access_denied_exception(): +def patch_fixtures_with_access_denied_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" access_denied_exception = exceptions.AccessDeniedException( "err.func.403", "Access is denied for this resource", ) - with _patch_fixtures_with_side_effect(access_denied_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = access_denied_exception + yield patches @pytest.fixture(name="fixtures_with_invalid_upstream_exception") -def patch_fixtures_with_invalid_upstream_exception(): +def patch_fixtures_with_invalid_upstream_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" invalid_upstream_exception = exceptions.InvalidUpstreamException( "err.tech.500", "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", ) - with _patch_fixtures_with_side_effect(invalid_upstream_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = invalid_upstream_exception + yield patches @pytest.fixture(name="fixtures_with_not_supported_exception") -def patch_fixtures_with_not_supported_exception(): +def patch_fixtures_with_not_supported_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" not_supported_exception = exceptions.NotSupportedException( "err.tech.501", "This feature is not technically supported by this gateway", ) - with _patch_fixtures_with_side_effect(not_supported_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = not_supported_exception + yield patches diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index c552321ef97..259d1b52f63 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -1,61 +1,7 @@ """Constants for the Renault integration tests.""" -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.renault.const import ( - CONF_KAMEREON_ACCOUNT_ID, - CONF_LOCALE, - DOMAIN, -) -from homeassistant.components.select import ATTR_OPTIONS -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_ICON, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_MODEL_ID, - ATTR_NAME, - ATTR_STATE, - ATTR_UNIT_OF_MEASUREMENT, - CONF_PASSWORD, - CONF_USERNAME, - PERCENTAGE, - STATE_NOT_HOME, - STATE_OFF, - STATE_ON, - STATE_UNKNOWN, - Platform, - UnitOfEnergy, - UnitOfLength, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, - UnitOfVolume, -) - -ATTR_DEFAULT_DISABLED = "default_disabled" -ATTR_UNIQUE_ID = "unique_id" - -FIXED_ATTRIBUTES = ( - ATTR_DEVICE_CLASS, - ATTR_OPTIONS, - ATTR_STATE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, -) -DYNAMIC_ATTRIBUTES = (ATTR_ICON,) - -ICON_FOR_EMPTY_VALUES = { - "binary_sensor.reg_number_hvac": "mdi:fan-off", - "select.reg_number_charge_mode": "mdi:calendar-remove", - "sensor.reg_number_charge_state": "mdi:flash-off", - "sensor.reg_number_plug_state": "mdi:power-plug-off", -} +from homeassistant.components.renault.const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME MOCK_ACCOUNT_ID = "account_id_1" @@ -63,220 +9,20 @@ MOCK_ACCOUNT_ID = "account_id_1" MOCK_CONFIG = { CONF_USERNAME: "email@test.com", CONF_PASSWORD: "test", - CONF_KAMEREON_ACCOUNT_ID: "account_id_1", + CONF_KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID, CONF_LOCALE: "fr_FR", } MOCK_VEHICLES = { "zoe_40": { - "expected_device": { - ATTR_IDENTIFIERS: {(DOMAIN, "VF1AAAAA555777999")}, - ATTR_MANUFACTURER: "Renault", - ATTR_MODEL: "Zoe", - ATTR_NAME: "REG-NUMBER", - ATTR_MODEL_ID: "X101VE", - }, "endpoints": { "battery_status": "battery_status_charging.json", "charge_mode": "charge_mode_always.json", "cockpit": "cockpit_ev.json", "hvac_status": "hvac_status.1.json", }, - Platform.BINARY_SENSOR: [ - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PLUG, - ATTR_ENTITY_ID: "binary_sensor.reg_number_plug", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_plugged_in", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.BATTERY_CHARGING, - ATTR_ENTITY_ID: "binary_sensor.reg_number_charging", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging", - }, - { - ATTR_ENTITY_ID: "binary_sensor.reg_number_hvac", - ATTR_ICON: "mdi:fan-off", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_status", - }, - ], - Platform.BUTTON: [ - { - ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", - ATTR_ICON: "mdi:air-conditioner", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_air_conditioner", - }, - { - ATTR_ENTITY_ID: "button.reg_number_start_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", - }, - { - ATTR_ENTITY_ID: "button.reg_number_stop_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_stop_charge", - }, - ], - Platform.DEVICE_TRACKER: [], - Platform.SELECT: [ - { - ATTR_ENTITY_ID: "select.reg_number_charge_mode", - ATTR_ICON: "mdi:calendar-remove", - ATTR_OPTIONS: [ - "always", - "always_charging", - "schedule_mode", - "scheduled", - ], - ATTR_STATE: "always", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_mode", - }, - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_autonomy", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: "141", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_autonomy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, - ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", - ATTR_STATE: "31", - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_available_energy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, - ATTR_ENTITY_ID: "sensor.reg_number_battery", - ATTR_STATE: "60", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_level", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_battery_activity", - ATTR_STATE: "2020-01-12T21:40:16+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_last_activity", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_temperature", - ATTR_STATE: "20", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_charge_state", - ATTR_ICON: "mdi:flash", - ATTR_OPTIONS: [ - "not_in_charge", - "waiting_for_a_planned_charge", - "charge_ended", - "waiting_for_current_charge", - "energy_flap_opened", - "charge_in_progress", - "charge_error", - "unavailable", - ], - ATTR_STATE: "charge_in_progress", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_state", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, - ATTR_ENTITY_ID: "sensor.reg_number_charging_power", - ATTR_STATE: "0.027", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_power", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, - ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", - ATTR_ICON: "mdi:timer", - ATTR_STATE: "145", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_remaining_time", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_mileage", - ATTR_ICON: "mdi:sign-direction", - ATTR_STATE: "49114", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_mileage", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.reg_number_outside_temperature", - ATTR_STATE: "8.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_outside_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_hvac_soc_threshold", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_soc_threshold", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_hvac_activity", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_last_activity", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_plug_state", - ATTR_ICON: "mdi:power-plug", - ATTR_OPTIONS: [ - "unplugged", - "plugged", - "plugged_waiting_for_charge", - "plug_error", - "plug_unknown", - ], - ATTR_STATE: "plugged", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_plug_state", - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_res_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start_code", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_res_state_code", - }, - ], }, "zoe_50": { - "expected_device": { - ATTR_IDENTIFIERS: {(DOMAIN, "VF1AAAAA555777999")}, - ATTR_MANUFACTURER: "Renault", - ATTR_MODEL: "Zoe", - ATTR_NAME: "REG-NUMBER", - ATTR_MODEL_ID: "X102VE", - }, "endpoints": { "battery_status": "battery_status_not_charging.json", "charge_mode": "charge_mode_schedule.json", @@ -286,251 +32,8 @@ MOCK_VEHICLES = { "lock_status": "lock_status.1.json", "res_state": "res_state.1.json", }, - Platform.BINARY_SENSOR: [ - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PLUG, - ATTR_ENTITY_ID: "binary_sensor.reg_number_plug", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_plugged_in", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.BATTERY_CHARGING, - ATTR_ENTITY_ID: "binary_sensor.reg_number_charging", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging", - }, - { - ATTR_ENTITY_ID: "binary_sensor.reg_number_hvac", - ATTR_ICON: "mdi:fan-off", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.LOCK, - ATTR_ENTITY_ID: "binary_sensor.reg_number_lock", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_lock_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_left_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_rear_left_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_right_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_rear_right_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_driver_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_driver_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_passenger_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_passenger_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_hatch", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hatch_status", - }, - ], - Platform.BUTTON: [ - { - ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", - ATTR_ICON: "mdi:air-conditioner", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_air_conditioner", - }, - { - ATTR_ENTITY_ID: "button.reg_number_start_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", - }, - { - ATTR_ENTITY_ID: "button.reg_number_stop_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_stop_charge", - }, - ], - Platform.DEVICE_TRACKER: [ - { - ATTR_ENTITY_ID: "device_tracker.reg_number_location", - ATTR_ICON: "mdi:car", - ATTR_STATE: STATE_NOT_HOME, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_location", - } - ], - Platform.SELECT: [ - { - ATTR_ENTITY_ID: "select.reg_number_charge_mode", - ATTR_ICON: "mdi:calendar-clock", - ATTR_OPTIONS: [ - "always", - "always_charging", - "schedule_mode", - "scheduled", - ], - ATTR_STATE: "schedule_mode", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_mode", - }, - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_autonomy", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: "128", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_autonomy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, - ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", - ATTR_STATE: "0", - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_available_energy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, - ATTR_ENTITY_ID: "sensor.reg_number_battery", - ATTR_STATE: "50", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_level", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_battery_activity", - ATTR_STATE: "2020-11-17T08:06:48+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_last_activity", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_temperature", - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_charge_state", - ATTR_ICON: "mdi:flash-off", - ATTR_OPTIONS: [ - "not_in_charge", - "waiting_for_a_planned_charge", - "charge_ended", - "waiting_for_current_charge", - "energy_flap_opened", - "charge_in_progress", - "charge_error", - "unavailable", - ], - ATTR_STATE: "charge_error", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charge_state", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, - ATTR_ENTITY_ID: "sensor.reg_number_admissible_charging_power", - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_power", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, - ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", - ATTR_ICON: "mdi:timer", - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_charging_remaining_time", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_mileage", - ATTR_ICON: "mdi:sign-direction", - ATTR_STATE: "49114", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_mileage", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.reg_number_outside_temperature", - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777999_outside_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_hvac_soc_threshold", - ATTR_STATE: "30.0", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_soc_threshold", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_hvac_activity", - ATTR_STATE: "2020-12-03T00:00:00+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_hvac_last_activity", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_plug_state", - ATTR_ICON: "mdi:power-plug-off", - ATTR_OPTIONS: [ - "unplugged", - "plugged", - "plugged_waiting_for_charge", - "plug_error", - "plug_unknown", - ], - ATTR_STATE: "unplugged", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_plug_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_location_activity", - ATTR_STATE: "2020-02-18T16:58:38+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_location_last_activity", - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start", - ATTR_STATE: "Stopped, ready for RES", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_res_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start_code", - ATTR_STATE: "10", - ATTR_UNIQUE_ID: "vf1aaaaa555777999_res_state_code", - }, - ], }, "captur_phev": { - "expected_device": { - ATTR_IDENTIFIERS: {(DOMAIN, "VF1AAAAA555777123")}, - ATTR_MANUFACTURER: "Renault", - ATTR_MODEL: "Captur ii", - ATTR_NAME: "REG-NUMBER", - ATTR_MODEL_ID: "XJB1SU", - }, "endpoints": { "battery_status": "battery_status_charging.json", "charge_mode": "charge_mode_always.json", @@ -539,349 +42,22 @@ MOCK_VEHICLES = { "lock_status": "lock_status.1.json", "res_state": "res_state.1.json", }, - Platform.BINARY_SENSOR: [ - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PLUG, - ATTR_ENTITY_ID: "binary_sensor.reg_number_plug", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_plugged_in", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.BATTERY_CHARGING, - ATTR_ENTITY_ID: "binary_sensor.reg_number_charging", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_charging", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.LOCK, - ATTR_ENTITY_ID: "binary_sensor.reg_number_lock", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_lock_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_left_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_rear_left_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_right_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_rear_right_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_driver_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_driver_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_passenger_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_passenger_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_hatch", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_hatch_status", - }, - ], - Platform.BUTTON: [ - { - ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", - ATTR_ICON: "mdi:air-conditioner", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_air_conditioner", - }, - { - ATTR_ENTITY_ID: "button.reg_number_start_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_charge", - }, - { - ATTR_ENTITY_ID: "button.reg_number_stop_charge", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_stop_charge", - }, - ], - Platform.DEVICE_TRACKER: [ - { - ATTR_ENTITY_ID: "device_tracker.reg_number_location", - ATTR_ICON: "mdi:car", - ATTR_STATE: STATE_NOT_HOME, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_location", - } - ], - Platform.SELECT: [ - { - ATTR_ENTITY_ID: "select.reg_number_charge_mode", - ATTR_ICON: "mdi:calendar-remove", - ATTR_OPTIONS: [ - "always", - "always_charging", - "schedule_mode", - "scheduled", - ], - ATTR_STATE: "always", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_charge_mode", - }, - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_autonomy", - ATTR_ICON: "mdi:ev-station", - ATTR_STATE: "141", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_autonomy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, - ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", - ATTR_STATE: "31", - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_available_energy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, - ATTR_ENTITY_ID: "sensor.reg_number_battery", - ATTR_STATE: "60", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_level", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_battery_activity", - ATTR_STATE: "2020-01-12T21:40:16+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_last_activity", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.reg_number_battery_temperature", - ATTR_STATE: "20", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_charge_state", - ATTR_ICON: "mdi:flash", - ATTR_OPTIONS: [ - "not_in_charge", - "waiting_for_a_planned_charge", - "charge_ended", - "waiting_for_current_charge", - "energy_flap_opened", - "charge_in_progress", - "charge_error", - "unavailable", - ], - ATTR_STATE: "charge_in_progress", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_charge_state", - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, - ATTR_ENTITY_ID: "sensor.reg_number_admissible_charging_power", - ATTR_STATE: "27.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_charging_power", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, - ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", - ATTR_ICON: "mdi:timer", - ATTR_STATE: "145", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_charging_remaining_time", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_fuel_autonomy", - ATTR_ICON: "mdi:gas-station", - ATTR_STATE: "35", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_autonomy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME, - ATTR_ENTITY_ID: "sensor.reg_number_fuel_quantity", - ATTR_ICON: "mdi:fuel", - ATTR_STATE: "3", - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_quantity", - ATTR_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_mileage", - ATTR_ICON: "mdi:sign-direction", - ATTR_STATE: "5567", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_mileage", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.reg_number_plug_state", - ATTR_ICON: "mdi:power-plug", - ATTR_OPTIONS: [ - "unplugged", - "plugged", - "plugged_waiting_for_charge", - "plug_error", - "plug_unknown", - ], - ATTR_STATE: "plugged", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_plug_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_location_activity", - ATTR_STATE: "2020-02-18T16:58:38+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_location_last_activity", - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start", - ATTR_STATE: "Stopped, ready for RES", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_res_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start_code", - ATTR_STATE: "10", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_res_state_code", - }, - ], }, "captur_fuel": { - "expected_device": { - ATTR_IDENTIFIERS: {(DOMAIN, "VF1AAAAA555777123")}, - ATTR_MANUFACTURER: "Renault", - ATTR_MODEL: "Captur ii", - ATTR_NAME: "REG-NUMBER", - ATTR_MODEL_ID: "XJB1SU", - }, "endpoints": { "cockpit": "cockpit_fuel.json", "location": "location.json", "lock_status": "lock_status.1.json", "res_state": "res_state.1.json", }, - Platform.BINARY_SENSOR: [ - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.LOCK, - ATTR_ENTITY_ID: "binary_sensor.reg_number_lock", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_lock_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_left_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_rear_left_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_rear_right_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_rear_right_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_driver_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_driver_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_passenger_door", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_passenger_door_status", - }, - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.DOOR, - ATTR_ENTITY_ID: "binary_sensor.reg_number_hatch", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_hatch_status", - }, - ], - Platform.BUTTON: [ - { - ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", - ATTR_ICON: "mdi:air-conditioner", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_air_conditioner", - }, - ], - Platform.DEVICE_TRACKER: [ - { - ATTR_ENTITY_ID: "device_tracker.reg_number_location", - ATTR_ICON: "mdi:car", - ATTR_STATE: STATE_NOT_HOME, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_location", - } - ], - Platform.SELECT: [], - Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_fuel_autonomy", - ATTR_ICON: "mdi:gas-station", - ATTR_STATE: "35", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_autonomy", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME, - ATTR_ENTITY_ID: "sensor.reg_number_fuel_quantity", - ATTR_ICON: "mdi:fuel", - ATTR_STATE: "3", - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_quantity", - ATTR_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DISTANCE, - ATTR_ENTITY_ID: "sensor.reg_number_mileage", - ATTR_ICON: "mdi:sign-direction", - ATTR_STATE: "5567", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "vf1aaaaa555777123_mileage", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - ATTR_ENTITY_ID: "sensor.reg_number_last_location_activity", - ATTR_STATE: "2020-02-18T16:58:38+00:00", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_location_last_activity", - }, - { - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start", - ATTR_STATE: "Stopped, ready for RES", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_res_state", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.reg_number_remote_engine_start_code", - ATTR_STATE: "10", - ATTR_UNIQUE_ID: "vf1aaaaa555777123_res_state_code", - }, - ], + }, + "twingo_3_electric": { + "endpoints": { + "battery_status": "battery_status_waiting_for_charger.json", + "charge_mode": "charge_mode_always.2.json", + "cockpit": "cockpit_ev.json", + "hvac_status": "hvac_status.3.json", + "location": "location.json", + }, }, } diff --git a/tests/components/renault/fixtures/battery_status_waiting_for_charger.json b/tests/components/renault/fixtures/battery_status_waiting_for_charger.json new file mode 100644 index 00000000000..a904de8627c --- /dev/null +++ b/tests/components/renault/fixtures/battery_status_waiting_for_charger.json @@ -0,0 +1,13 @@ +{ + "data": { + "id": "VF1AAAAA555777999", + "attributes": { + "timestamp": "2025-04-28T05:27:07Z", + "batteryLevel": 96, + "batteryAutonomy": 182, + "plugStatus": 3, + "chargingStatus": 0.3, + "chargingRemainingTime": 15 + } + } +} diff --git a/tests/components/renault/fixtures/charge_mode_always.2.json b/tests/components/renault/fixtures/charge_mode_always.2.json new file mode 100644 index 00000000000..c8c33942541 --- /dev/null +++ b/tests/components/renault/fixtures/charge_mode_always.2.json @@ -0,0 +1,9 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "chargeMode": "always_charging" + } + } +} diff --git a/tests/components/renault/fixtures/hvac_status.3.json b/tests/components/renault/fixtures/hvac_status.3.json new file mode 100644 index 00000000000..b0e5c2759e6 --- /dev/null +++ b/tests/components/renault/fixtures/hvac_status.3.json @@ -0,0 +1,11 @@ +{ + "data": { + "id": "VF1AAAAA555777999", + "attributes": { + "internalTemperature": 26.0, + "hvacStatus": "off", + "socThreshold": 30.0, + "lastUpdateTime": "2025-04-28T04:29:26Z" + } + } +} diff --git a/tests/components/renault/fixtures/vehicle_captur_fuel.json b/tests/components/renault/fixtures/vehicle_captur_fuel.json index 3aa854c61ea..b9c3c04b79c 100644 --- a/tests/components/renault/fixtures/vehicle_captur_fuel.json +++ b/tests/components/renault/fixtures/vehicle_captur_fuel.json @@ -4,7 +4,7 @@ "vehicleLinks": [ { "brand": "RENAULT", - "vin": "VF1AAAAA555777123", + "vin": "VF1CAPTURFUELVIN", "status": "ACTIVE", "linkType": "USER", "garageBrand": "RENAULT", @@ -19,7 +19,7 @@ "lastModifiedDate": "2020-06-15T06:20:39.107794Z" }, "vehicleDetails": { - "vin": "VF1AAAAA555777123", + "vin": "VF1CAPTURFUELVIN", "engineType": "H5H", "engineRatio": "470", "modelSCR": "CP1", @@ -76,7 +76,7 @@ "label": "ESSENCE", "group": "019" }, - "registrationNumber": "REG-NUMBER", + "registrationNumber": "REG-CAPTUR-FUEL", "vcd": "ADR00/DLIGM2/PGPRT2/FEUAR3/CDVIT1/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET04/DANGMO/ECOMOD/SSRCAR/AIVCT/AVGSI/TPRPE/TSGNE/2TON/ITPK7/MLEXP1/SPERTA/SSPERG/SPERTP/VOLCHA/SREACT/AVOSP1/SWALBO/DWGE01/AVC1A/1234Y/AEBS07/PRAHL/AVCAM/STANDA/XJB/HJB/EA3/MF/ESS/DG/TEMP/TR4X2/AFURGE/RVDIST/ABS/SBARTO/CA02/TOPAN/PBNCH/LAC/VSTLAR/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV02/SGAR02/BIXPE/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/SCACBA/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETIN2/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGACHA/BEL01/APL03/FSTPO/ALOUC5/CMAR3P/FIPOU2/NA406/BVA7/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRGAC/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06T/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/SSTYAD/HYB01/SSCABA/SANBAT/VEC012/XJB1SU/SSNBT/H5H", "assets": [ { diff --git a/tests/components/renault/fixtures/vehicle_captur_phev.json b/tests/components/renault/fixtures/vehicle_captur_phev.json index 03066c8238f..72d57af2b34 100644 --- a/tests/components/renault/fixtures/vehicle_captur_phev.json +++ b/tests/components/renault/fixtures/vehicle_captur_phev.json @@ -4,7 +4,7 @@ "vehicleLinks": [ { "brand": "RENAULT", - "vin": "VF1AAAAA555777123", + "vin": "VF1CAPTURPHEVVIN", "status": "ACTIVE", "linkType": "OWNER", "garageBrand": "RENAULT", @@ -19,7 +19,7 @@ "lastModifiedDate": "2020-10-08T17:36:39.445523Z" }, "vehicleDetails": { - "vin": "VF1AAAAA555777123", + "vin": "VF1CAPTURPHEVVIN", "registrationDate": "2020-09-30", "firstRegistrationDate": "2020-09-30", "engineType": "H4M", @@ -78,7 +78,7 @@ "label": "PETROL", "group": "019" }, - "registrationNumber": "REG-NUMBER", + "registrationNumber": "REG-CAPTUR_PHEV", "vcd": "STANDA/XJB/HJB/EA3/MM/ESS/DG/TEMP/TR4X2/AFURGE/RV/ABS/SBARTO/CA02/TN/PBNCH/LAC/VT/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV01/SGAR02/BIYPC/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/CACBL3/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETRCR/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGSCHA/ITA01/APL03/FSTPO/ALOUC5/PART01/CMAR3P/FIPOU2/NA418/BVH4/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRFLY/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06U/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/5DHS/HYB06/010KWH/BT9AE1/VEC237/XJB1SU/NBT018/H4M/NOADR/DLIGM2/PGPRT2/FEUAR3/SCDVIT/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET05/SDANGM/ECOMOD/SSRCAR/AIVCT/AVGSI/TPQNW/TSGNE/2TON/ITPK4/MLEXP1/SPERTA/SSPERG/SPERTP/VOLNCH/SREACT/AVTSR1/SWALBO/DWGE01/AVC1A/VSPTA/1234Y/AEBS07/PRAHL/RRCAM", "assets": [ { diff --git a/tests/components/renault/fixtures/vehicle_missing_details.json b/tests/components/renault/fixtures/vehicle_missing_details.json deleted file mode 100644 index f6467e0c8f8..00000000000 --- a/tests/components/renault/fixtures/vehicle_missing_details.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "accountId": "account-id-1", - "country": "FR", - "vehicleLinks": [ - { - "brand": "RENAULT", - "vin": "VF1AAAAA555777999", - "status": "ACTIVE", - "linkType": "OWNER", - "garageBrand": "RENAULT", - "annualMileage": 16000, - "mileage": 26464, - "startDate": "2017-08-07", - "createdDate": "2019-05-23T21:38:16.409008Z", - "lastModifiedDate": "2020-11-17T08:41:40.497400Z", - "ownershipStartDate": "2017-08-01", - "cancellationReason": {}, - "connectedDriver": { - "role": "MAIN_DRIVER", - "createdDate": "2019-06-17T09:49:06.880627Z", - "lastModifiedDate": "2019-06-17T09:49:06.880627Z" - } - } - ] -} diff --git a/tests/components/renault/fixtures/vehicle_twingo_3_electric.json b/tests/components/renault/fixtures/vehicle_twingo_3_electric.json new file mode 100644 index 00000000000..a19d6f196a0 --- /dev/null +++ b/tests/components/renault/fixtures/vehicle_twingo_3_electric.json @@ -0,0 +1,254 @@ +{ + "accountId": "account-id-1", + "country": "FR", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1TWINGOIIIVIN", + "status": "ACTIVE", + "linkType": "OWNER", + "garageBrand": "renault", + "mileage": 23362, + "mileageUnit": "km", + "mileageDate": "2024-07-24", + "startDate": "2023-03-12", + "createdDate": "2023-03-11T23:53:55.253006Z", + "lastModifiedDate": "2024-07-24T15:13:28.062494Z", + "ownershipStartDate": "2023-03-07", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2023-03-18T09:24:35.745983023Z", + "lastModifiedDate": "2023-03-18T09:24:35.745983023Z" + }, + "vehicleDetails": { + "vin": "VF1TWINGOIIIVIN", + "registrationDate": "2023-03-07", + "firstRegistrationDate": "2023-03-07", + "engineType": "5AL", + "engineRatio": "605", + "modelSCR": "2WE", + "passToSalesDate": "2023-02-10", + "deliveryCountry": { + "code": "FR", + "label": "FRANCE" + }, + "family": { + "code": "X07", + "label": "FAMILLE X07", + "group": "007" + }, + "tcu": { + "code": "AIVCT", + "label": "WITH AIVC CONNECTION UNIT", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "SSNAV", + "label": "WITHOUT NAVIGATION ASSISTANCE", + "group": "408" + }, + "battery": { + "code": "BT6AE", + "label": "BT6AE BATTERY", + "group": "968" + }, + "radioType": { + "code": "NA435", + "label": "CORE NAV DAB - CLASS", + "group": "425" + }, + "registrationCountry": { + "code": "FR" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "X071VE", + "label": "TWINGO III", + "group": "971" + }, + "gearbox": { + "code": "BVEL", + "label": "ELEC.VAR.GEARBOX", + "group": "427" + }, + "version": { + "code": "E3W A1E C1 X" + }, + "energy": { + "code": "ELEC", + "label": "ELECTRICITY", + "group": "019" + }, + "bodyType": { + "code": "B07", + "label": "5-DOOR X07 SALOON", + "group": "008" + }, + "steeringSide": { + "code": "DG", + "label": "LEFT-HAND DRIVE", + "group": "027" + }, + "registrationNumber": "REG-TWINGO-III", + "vcd": "STANDA/X07/B07/EA3/A1/ELEC/DG/TEMP/TR4X2/DA/RV/CAREG1/TOTOIL/LAC/VSTLAR/CPE/RET01/SPROJA/RALU15/CEAVFX/ADAC/CCHBAM/SERIE/DRA/TICUI6/HARM01/ATAR/SGAV02/FBANAR/OVRPP/BANAL/KM/TPRM3/VERCAP/SSDECA/ABLAV1/RDAR02/ALEVA/PRENFA/SOP02C/CTHAB2/VLCUIR/REPNTC/LVCIPE/KTGREP/SGSCHA/FRA01/APL03/BECQA1/PLAT02/VOLRH/SBRDA/PROJ1/SSNAV/NA435/BVEL/SSCAPO/STALT/SPREST/RANPAR/RDIF24/PRLOO1/PNSTRD/ISOFIA/ENPH02/HRGM01/SANACF/PREALA/CHARAP/TLFRAN/RGAR1/SPRODI/SAN613/SSFAP/SSABGE/SAN713/CHC03/ELC1/SANCML/PRUPT2/SSRESE/SSFLEX/M2021/PHAS1/SAN913/024KWH/BT6AE/VEC029/X071VE/NB005/5AL/SDLIGM/AVSVEL/RAGAC2/CDVOL1/COIN02/SKTPOU/SKTPGR/SSCCPC/SRGTLU/ELCTRI/SSTOST/SECAMH/FDIU1/SSESM/SRGPDB/SSCALL/FACBA1/SPRCIN/TABANA/CABDO1/AIVCT/PREVSE/TPRPP/TSRPP/1TON/SPERTA/PERB09/SPERTN/SPERTP/VOLNCH/SAFDEP/1234YF/SAACC1/COFMOF/SPMIR/SANVF/TCHQ0", + "manufacturingDate": "2023-02-10", + "assets": [ + { + "assetType": "PICTURE", + "viewpoint": "mybrand_2", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "mybrand_2" + }, + { + "assetType": "PICTURE", + "viewpoint": "mybrand_5", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=RSITE&bookmark=EXT_34_AV&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=RSITE&bookmark=EXT_34_AV&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "mybrand_5" + }, + { + "assetType": "PICTURE", + "viewpoint": "myb_car_selector", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_RIGHT_SIDE&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_RIGHT_SIDE&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "myb_car_selector" + }, + { + "assetType": "PICTURE", + "viewpoint": "myb_car_page_dashboard", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "myb_car_page_dashboard" + }, + { + "assetType": "PICTURE", + "viewpoint": "myb_program_settings_page", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "myb_program_settings_page" + }, + { + "assetType": "PICTURE", + "viewpoint": "myb_plug_and_charge_activation", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_RIGHT_SIDE&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_RIGHT_SIDE&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "myb_plug_and_charge_activation" + }, + { + "assetType": "PICTURE", + "viewpoint": "myb_plug_and_charge_my_car", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_SMALL_V2" + }, + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv.renault.com/ImageFromBookmark?configuration=X07%2FB07%2FEA3%2FELEC%2FDG%2FRV%2FCAREG1%2FTOTOIL%2FVSTLAR%2FRET01%2FSPROJA%2FRALU15%2FTICUI6%2FATAR%2FSGAV02%2FOVRPP%2FKM%2FVERCAP%2FSSDECA%2FRDAR02%2FALEVA%2FVLCUIR%2FREPNTC%2FLVCIPE%2FSGSCHA%2FAPL03%2FBECQA1%2FPLAT02%2FVOLRH%2FSSNAV%2FNA435%2FBVEL%2FSPREST%2FRANPAR%2FRDIF24%2FENPH02%2FSANACF%2FRGAR1%2FCHC03%2FPRUPT2%2FSAN913%2FSDLIGM%2FAVSVEL%2FRAGAC2%2FCOIN02%2FSKTPOU%2FSRGTLU%2FELCTRI%2FSSTOST%2FSECAMH%2FSRGPDB%2FPERB09%2FSPERTN%2FSPERTP&databaseId=86ccf096-af52-4768-a165-edd53ab8259c&bookmarkSet=CARPICKER&bookmark=EXT_LEFT_SIDE&profile=HELIOS_OWNERSERVICES_LARGE" + } + ], + "viewPointInLowerCase": "myb_plug_and_charge_my_car" + }, + { + "assetType": "URL", + "assetRole": "GUIDE", + "title": "e-guide", + "description": "", + "renditions": [ + { + "url": "https://tutos-videos.renault.fr/?id=twingo-electric", + "size": "51" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Video 1", + "description": "", + "renditions": [ + { + "url": "1ChWFBuLqfU&t", + "size": "13" + } + ] + }, + { + "assetType": "URL", + "assetRole": "CAR", + "title": "More videos", + "description": "", + "renditions": [ + { + "url": "https://tutos-videos.renault.fr/?id=twingo-electric", + "size": "51" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "RLINK1", + "easyConnectStore": true, + "electrical": true, + "deliveryDate": "2023-03-21", + "retrievedFromDhs": false, + "engineEnergyType": "ELEC", + "radioCode": "", + "premiumSubscribed": false, + "batteryType": "NMC" + } + } + ] +} diff --git a/tests/components/renault/fixtures/vehicle_zoe_40.json b/tests/components/renault/fixtures/vehicle_zoe_40.json index ab80d586652..ea7faf4e109 100644 --- a/tests/components/renault/fixtures/vehicle_zoe_40.json +++ b/tests/components/renault/fixtures/vehicle_zoe_40.json @@ -4,7 +4,7 @@ "vehicleLinks": [ { "brand": "RENAULT", - "vin": "VF1AAAAA555777999", + "vin": "VF1ZOE40VIN", "status": "ACTIVE", "linkType": "OWNER", "garageBrand": "RENAULT", @@ -21,7 +21,7 @@ "lastModifiedDate": "2019-06-17T09:49:06.880627Z" }, "vehicleDetails": { - "vin": "VF1AAAAA555777999", + "vin": "VF1ZOE40VIN", "registrationDate": "2017-08-01", "firstRegistrationDate": "2017-08-01", "engineType": "5AQ", @@ -80,7 +80,7 @@ "label": "ELECTRIQUE", "group": "019" }, - "registrationNumber": "REG-NUMBER", + "registrationNumber": "REG-ZOE-40", "vcd": "SYTINC/SKTPOU/SAND41/FDIU1/SSESM/MAPSUP/SSCALL/SAND88/SAND90/SQKDRO/SDIFPA/FACBA2/PRLEX1/SSRCAR/CABDO2/TCU0G2/SWALBO/EVTEC1/STANDA/X10/B10/EA2/MB/ELEC/DG/TEMP/TR4X2/RV/ABS/CAREG/LAC/VT003/CPE/RET03/SPROJA/RALU16/CEAVRH/AIRBA1/SERIE/DRA/DRAP08/HARM02/ATAR/TERQG/SFBANA/KM/DPRPN/AVREPL/SSDECA/ASRESP/RDAR02/ALEVA/CACBL2/SOP02C/CTHAB2/TRNOR/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/APL03/ALOUCC/CMAR3P/NAV3G5/RAD37A/BVEL/AUTAUG/RNORM/ISOFIX/EQPEUR/HRGM01/SDPCLV/TLFRAN/SPRODI/SAN613/SSAPEX/GENEV1/ELC1/SANCML/PE2012/PHAS1/SAN913/045KWH/BT4AR1/VEC153/X101VE/NBT017/5AQ", "assets": [ { diff --git a/tests/components/renault/fixtures/vehicle_zoe_50.json b/tests/components/renault/fixtures/vehicle_zoe_50.json index 560b2a2246a..50bdd4181af 100644 --- a/tests/components/renault/fixtures/vehicle_zoe_50.json +++ b/tests/components/renault/fixtures/vehicle_zoe_50.json @@ -113,7 +113,7 @@ "yearsOfMaintenance": 12, "rlinkStore": false, "radioCode": "1234", - "registrationNumber": "REG-NUMBER", + "registrationNumber": "REG-ZOE-50", "modelSCR": "ZOE", "easyConnectStore": false, "engineRatio": "605", @@ -122,7 +122,7 @@ "code": "BT4AR1", "label": "BATTERIE BT4AR1" }, - "vin": "VF1AAAAA555777999", + "vin": "VF1ZOE50VIN", "retrievedFromDhs": false, "vcd": "ASCOD0/DLIGM2/SSTINC/KITPOU/SKTPGR/SSCCPC/SDPSEC/FDIU2/SSMAP/SSCALL/FACBA1/DANGMO/SSRCAR/SSCABD/AIVCT/AVGSI/ITPK4/VOLNCH/REACTI/AVOSP1/SWALBO/SSDWGE/1234Y/SSAEBS/PRAHL/RRCAM/STANDA/X10/B10/EA3/MD/ELEC/DG/TEMP/TR4X2/AFURGE/RV/ABS/CAREG/LAC/VSTLAR/CPETIR/RET03/PROJAB/RALU16/CEAVRH/ADAC/AIRBA2/SERIE/DRA/DRAP13/HARM02/3ATRPH/SGAV01/BARRAB/TELNJ/SFBANA/KM/DPRPN/AVREPL/SSDECA/ABLAV/ASRESP/ALEVA/SCACBA/SOP02C/STHPLG/SKTGRV/VLCUIR/RETRCR/TRSEV1/RETC/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/FRA01/APL03/FSTPO/ALOUC5/CMAR3P/SAN408/NA418/BVEL/AUTAUG/SPREST/RDIF01/ISOFIX/EQPEUR/HRGM01/SDPCLV/CHASTD/TL01A/SPRODI/SAN613/AIRBDE/PSMREC/ELC1/SSPTLP/SANCML/SEXTIN/PE2019/PHAS2/SAN913/THABT2/SSTYAD/SSHYB/052KWH/BT4AR1/VEC018/X102VE/NBT022/5AQ", "firstRegistrationDate": "2020-01-13", @@ -149,7 +149,7 @@ "lastModifiedDate": "2020-08-22T09:41:53.477398Z", "createdDate": "2020-08-22T09:41:53.477398Z" }, - "vin": "VF1AAAAA555777999", + "vin": "VF1ZOE50VIN", "lastModifiedDate": "2020-11-29T22:01:21.162572Z", "brand": "RENAULT", "startDate": "2020-08-21", diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index b62cfb4d1b1..e89873593e9 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -1,2629 +1,1388 @@ # serializer version: 1 -# name: test_binary_sensor_empty[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_charging', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - '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', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_lock_status', - 'unit_of_measurement': None, +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-ZOE-40 Charging', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777123_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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': 'Rear left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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': 'Rear right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_driver_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777123_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_passenger_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777123_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_binary_sensor_empty[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1zoe40vin_hvac_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC', }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_binary_sensor_empty[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_charging', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - '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', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_lock_status', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777123_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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': 'Rear left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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': 'Rear right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_driver_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777123_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_passenger_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777123_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_plugged_in', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_binary_sensor_empty[zoe_40][binary_sensor.reg_zoe_40_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-ZOE-40 Plug', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_binary_sensor_empty[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_charging', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-ZOE-40 Charging', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_charging', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'HVAC', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_status', - 'unique_id': 'vf1aaaaa555777999_hvac_status', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) # --- -# name: test_binary_sensor_empty[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1zoe40vin_hvac_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC', }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) # --- -# name: test_binary_sensor_empty[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_charging', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'HVAC', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_status', - 'unique_id': 'vf1aaaaa555777999_hvac_status', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - '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', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_lock_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777999_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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': 'Rear left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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': 'Rear right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_driver_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777999_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_passenger_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777999_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_plugged_in', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensor_empty[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_binary_sensor_errors[zoe_40][binary_sensor.reg_zoe_40_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-ZOE-40 Plug', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) # --- -# name: test_binary_sensors[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_driver_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_driver_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': 'Driver door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1capturfuelvin_driver_door_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - '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', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_lock_status', - 'unit_of_measurement': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR-FUEL Driver door', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777123_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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': 'Rear left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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': 'Rear right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_driver_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777123_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_passenger_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777123_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_driver_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_hatch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1capturfuelvin_hatch_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_hatch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR-FUEL Hatch', }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_hatch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_charging', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - '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', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_lock_status', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777123_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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': 'Rear left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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': 'Rear right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_driver_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777123_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_passenger_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777123_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1capturfuelvin_lock_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-CAPTUR-FUEL Lock', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_passenger_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_passenger_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': 'Passenger door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1capturfuelvin_passenger_door_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR-FUEL Passenger door', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_charging', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'HVAC', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_status', - 'unique_id': 'vf1aaaaa555777999_hvac_status', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_passenger_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_left_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1capturfuelvin_rear_left_door_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_left_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR-FUEL Rear left door', }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_rear_left_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_plug', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Plug', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_plugged_in', - 'unit_of_measurement': None, +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_right_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_charging', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_fuel_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_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': 'HVAC', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_status', - 'unique_id': 'vf1aaaaa555777999_hvac_status', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_lock', - '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', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_lock_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1aaaaa555777999_hatch_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_left_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': 'Rear left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_rear_right_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': 'Rear right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_driver_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': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1aaaaa555777999_driver_door_status', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_number_passenger_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': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1aaaaa555777999_passenger_door_status', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1capturfuelvin_rear_right_door_status', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'plug', - 'friendly_name': 'REG-NUMBER Plug', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_plug', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_right_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR-FUEL Rear right door', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'REG-NUMBER Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-NUMBER Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-NUMBER Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_number_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.reg_captur_fuel_rear_right_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1capturphevvin_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-CAPTUR_PHEV Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_driver_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_driver_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': 'Driver door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1capturphevvin_driver_door_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR_PHEV Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_driver_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_hatch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1capturphevvin_hatch_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_hatch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR_PHEV Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_hatch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_lock', + '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', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1capturphevvin_lock_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-CAPTUR_PHEV Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_passenger_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_passenger_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': 'Passenger door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1capturphevvin_passenger_door_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR_PHEV Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_passenger_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1capturphevvin_plugged_in', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-CAPTUR_PHEV Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_left_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_rear_left_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': 'Rear left door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1capturphevvin_rear_left_door_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_left_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR_PHEV Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_rear_left_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_right_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_captur_phev_rear_right_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': 'Rear right door', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1capturphevvin_rear_right_door_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_right_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-CAPTUR_PHEV Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_captur_phev_rear_right_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_twingo_iii_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1twingoiiivin_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-TWINGO-III Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_twingo_iii_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_twingo_iii_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': 'HVAC', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1twingoiiivin_hvac_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_twingo_iii_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_twingo_iii_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1twingoiiivin_plugged_in', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-TWINGO-III Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_twingo_iii_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-ZOE-40 Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_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': 'HVAC', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1zoe40vin_hvac_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_plugged_in', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_40][binary_sensor.reg_zoe_40_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-ZOE-40 Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_40_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_50_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe50vin_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-ZOE-50 Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_50_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_50_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': 'HVAC', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1zoe50vin_hvac_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_50_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_zoe_50_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe50vin_plugged_in', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[zoe_50][binary_sensor.reg_zoe_50_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-ZOE-50 Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_zoe_50_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr index 58789c7aa47..1c7d5f80af2 100644 --- a/tests/components/renault/snapshots/test_button.ambr +++ b/tests/components/renault/snapshots/test_button.ambr @@ -1,1205 +1,1176 @@ # serializer version: 1 -# name: test_button_empty[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'has_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 air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe40vin_start_air_conditioner', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - 'has_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 air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start air conditioner', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_button_empty[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe40vin_start_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start charge', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_button_empty[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - 'has_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 air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777123_start_charge', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777123_stop_charge', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe40vin_stop_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_access_denied[zoe_40][button.reg_zoe_40_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Stop charge', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_button_empty[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_empty[zoe_40][button.reg_zoe_40_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'has_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 air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe40vin_start_air_conditioner', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - 'has_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 air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_empty[zoe_40][button.reg_zoe_40_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start air conditioner', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777999_start_charge', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777999_stop_charge', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_button_empty[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_empty[zoe_40][button.reg_zoe_40_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe40vin_start_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_empty[zoe_40][button.reg_zoe_40_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start charge', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_button_empty[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - 'has_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 air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_empty[zoe_40][button.reg_zoe_40_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777999_start_charge', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777999_stop_charge', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe40vin_stop_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_button_empty[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_empty[zoe_40][button.reg_zoe_40_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Stop charge', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_errors[zoe_40][button.reg_zoe_40_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'has_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 air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe40vin_start_air_conditioner', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - 'has_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 air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_errors[zoe_40][button.reg_zoe_40_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start air conditioner', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_errors[zoe_40][button.reg_zoe_40_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe40vin_start_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_errors[zoe_40][button.reg_zoe_40_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start charge', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - 'has_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 air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_errors[zoe_40][button.reg_zoe_40_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777123_start_charge', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777123_stop_charge', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe40vin_stop_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_errors[zoe_40][button.reg_zoe_40_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Stop charge', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'has_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 air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe40vin_start_air_conditioner', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - 'has_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 air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start air conditioner', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777999_start_charge', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777999_stop_charge', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe40vin_start_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start charge', }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_buttons[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_air_conditioner', - 'has_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 air conditioner', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_air_conditioner', - 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', - 'unit_of_measurement': None, +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_start_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': 'Start charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'start_charge', - 'unique_id': 'vf1aaaaa555777999_start_charge', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.reg_number_stop_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': 'Stop charge', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'stop_charge', - 'unique_id': 'vf1aaaaa555777999_stop_charge', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe40vin_stop_charge', + 'unit_of_measurement': None, + }) # --- -# name: test_buttons[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start air conditioner', - }), - 'context': , - 'entity_id': 'button.reg_number_start_air_conditioner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_button_not_supported[zoe_40][button.reg_zoe_40_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Stop charge', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Start charge', - }), - 'context': , - 'entity_id': 'button.reg_number_start_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Stop charge', - }), - 'context': , - 'entity_id': 'button.reg_number_stop_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'context': , + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[captur_fuel][button.reg_captur_fuel_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_captur_fuel_start_air_conditioner', + 'has_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 air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1capturfuelvin_start_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[captur_fuel][button.reg_captur_fuel_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR-FUEL Start air conditioner', + }), + 'context': , + 'entity_id': 'button.reg_captur_fuel_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_captur_phev_start_air_conditioner', + 'has_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 air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1capturphevvin_start_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Start air conditioner', + }), + 'context': , + 'entity_id': 'button.reg_captur_phev_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_captur_phev_start_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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1capturphevvin_start_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Start charge', + }), + 'context': , + 'entity_id': 'button.reg_captur_phev_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_captur_phev_stop_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': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1capturphevvin_stop_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[captur_phev][button.reg_captur_phev_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Stop charge', + }), + 'context': , + 'entity_id': 'button.reg_captur_phev_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_twingo_iii_start_air_conditioner', + 'has_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 air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1twingoiiivin_start_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III Start air conditioner', + }), + 'context': , + 'entity_id': 'button.reg_twingo_iii_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_twingo_iii_start_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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1twingoiiivin_start_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III Start charge', + }), + 'context': , + 'entity_id': 'button.reg_twingo_iii_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_twingo_iii_stop_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': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1twingoiiivin_stop_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[twingo_3_electric][button.reg_twingo_iii_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III Stop charge', + }), + 'context': , + 'entity_id': 'button.reg_twingo_iii_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'has_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 air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe40vin_start_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start air conditioner', + }), + 'context': , + 'entity_id': 'button.reg_zoe_40_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_start_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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe40vin_start_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Start charge', + }), + 'context': , + 'entity_id': 'button.reg_zoe_40_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_40_stop_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': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe40vin_stop_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_40][button.reg_zoe_40_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Stop charge', + }), + 'context': , + 'entity_id': 'button.reg_zoe_40_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_start_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_50_start_air_conditioner', + 'has_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 air conditioner', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1zoe50vin_start_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_start_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Start air conditioner', + }), + 'context': , + 'entity_id': 'button.reg_zoe_50_start_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_start_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_50_start_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': 'Start charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1zoe50vin_start_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_start_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Start charge', + }), + 'context': , + 'entity_id': 'button.reg_zoe_50_start_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_stop_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_zoe_50_stop_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': 'Stop charge', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1zoe50vin_stop_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[zoe_50][button.reg_zoe_50_stop_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Stop charge', + }), + 'context': , + 'entity_id': 'button.reg_zoe_50_stop_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index 119defca4ac..7a35f70b51c 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -1,618 +1,300 @@ # serializer version: 1 -# name: test_device_tracker_empty[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_device_tracker_empty[zoe_50][device_tracker.reg_zoe_50_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_device_tracker_empty[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777123_location', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_zoe_50_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_device_tracker_empty[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1zoe50vin_location', + 'unit_of_measurement': None, + }) # --- -# name: test_device_tracker_empty[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_device_tracker_empty[zoe_50][device_tracker.reg_zoe_50_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Location', + 'source_type': , }), - ]) + 'context': , + 'entity_id': 'device_tracker.reg_zoe_50_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_device_tracker_empty[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777123_location', - 'unit_of_measurement': None, +# name: test_device_tracker_errors[zoe_50][device_tracker.reg_zoe_50_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_device_tracker_empty[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_zoe_50_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_device_tracker_empty[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - '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': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1zoe50vin_location', + 'unit_of_measurement': None, + }) # --- -# name: test_device_tracker_empty[zoe_40].1 - list([ - ]) -# --- -# name: test_device_tracker_empty[zoe_40].2 - list([ - ]) -# --- -# name: test_device_tracker_empty[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_device_tracker_errors[zoe_50][device_tracker.reg_zoe_50_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Location', }), - ]) + 'context': , + 'entity_id': 'device_tracker.reg_zoe_50_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) # --- -# name: test_device_tracker_empty[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777999_location', - 'unit_of_measurement': None, +# name: test_device_trackers[captur_fuel][device_tracker.reg_captur_fuel_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_device_tracker_empty[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_captur_fuel_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_device_trackers[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - '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': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1capturfuelvin_location', + 'unit_of_measurement': None, + }) # --- -# name: test_device_trackers[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777123_location', - 'unit_of_measurement': None, +# name: test_device_trackers[captur_fuel][device_tracker.reg_captur_fuel_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR-FUEL Location', + 'gps_accuracy': 0, + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , }), - ]) + 'context': , + 'entity_id': 'device_tracker.reg_captur_fuel_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) # --- -# name: test_device_trackers[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'gps_accuracy': 0, - 'latitude': 48.1234567, - 'longitude': 11.1234567, - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'not_home', +# name: test_device_trackers[captur_phev][device_tracker.reg_captur_phev_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_device_trackers[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_captur_phev_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_device_trackers[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777123_location', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1capturphevvin_location', + 'unit_of_measurement': None, + }) # --- -# name: test_device_trackers[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'gps_accuracy': 0, - 'latitude': 48.1234567, - 'longitude': 11.1234567, - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'not_home', +# name: test_device_trackers[captur_phev][device_tracker.reg_captur_phev_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Location', + 'gps_accuracy': 0, + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , }), - ]) + 'context': , + 'entity_id': 'device_tracker.reg_captur_phev_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) # --- -# name: test_device_trackers[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_device_trackers[twingo_3_electric][device_tracker.reg_twingo_iii_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_device_trackers[zoe_40].1 - list([ - ]) -# --- -# name: test_device_trackers[zoe_40].2 - list([ - ]) -# --- -# name: test_device_trackers[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_twingo_iii_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_device_trackers[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'device_tracker', - 'entity_category': , - 'entity_id': 'device_tracker.reg_number_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': 'Location', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location', - 'unique_id': 'vf1aaaaa555777999_location', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1twingoiiivin_location', + 'unit_of_measurement': None, + }) # --- -# name: test_device_trackers[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Location', - 'gps_accuracy': 0, - 'latitude': 48.1234567, - 'longitude': 11.1234567, - 'source_type': , - }), - 'context': , - 'entity_id': 'device_tracker.reg_number_location', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'not_home', +# name: test_device_trackers[twingo_3_electric][device_tracker.reg_twingo_iii_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III Location', + 'gps_accuracy': 0, + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , }), - ]) + 'context': , + 'entity_id': 'device_tracker.reg_twingo_iii_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_trackers[zoe_50][device_tracker.reg_zoe_50_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_zoe_50_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': 'Location', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1zoe50vin_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_trackers[zoe_50][device_tracker.reg_zoe_50_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Location', + 'gps_accuracy': 0, + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_zoe_50_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) # --- diff --git a/tests/components/renault/snapshots/test_diagnostics.ambr b/tests/components/renault/snapshots/test_diagnostics.ambr index a2921dff35e..80ef412427d 100644 --- a/tests/components/renault/snapshots/test_diagnostics.ambr +++ b/tests/components/renault/snapshots/test_diagnostics.ambr @@ -24,8 +24,6 @@ 'externalTemperature': 8.0, 'hvacStatus': 'off', }), - 'res_state': dict({ - }), }), 'details': dict({ 'assets': list([ @@ -229,8 +227,6 @@ 'externalTemperature': 8.0, 'hvacStatus': 'off', }), - 'res_state': dict({ - }), }), 'details': dict({ 'assets': list([ diff --git a/tests/components/renault/snapshots/test_init.ambr b/tests/components/renault/snapshots/test_init.ambr new file mode 100644 index 00000000000..9a10083b227 --- /dev/null +++ b/tests/components/renault/snapshots/test_init.ambr @@ -0,0 +1,176 @@ +# serializer version: 1 +# name: test_device_registry[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1CAPTURFUELVIN', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'model_id': 'XJB1SU', + 'name': 'REG-CAPTUR-FUEL', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_registry[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1CAPTURPHEVVIN', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'model_id': 'XJB1SU', + 'name': 'REG-CAPTUR_PHEV', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_registry[twingo_3_electric] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1TWINGOIIIVIN', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Renault', + 'model': 'Twingo iii', + 'model_id': 'X071VE', + 'name': 'REG-TWINGO-III', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_registry[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1ZOE40VIN', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'model_id': 'X101VE', + 'name': 'REG-ZOE-40', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_registry[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1ZOE50VIN', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'model_id': 'X102VE', + 'name': 'REG-ZOE-50', + '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/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr index 526c8af5bc4..9df17d0a3ec 100644 --- a/tests/components/renault/snapshots/test_select.ambr +++ b/tests/components/renault/snapshots/test_select.ambr @@ -1,681 +1,361 @@ # serializer version: 1 -# name: test_select_empty[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_select_empty[zoe_40][select.reg_zoe_40_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_select_empty[captur_fuel].1 - list([ - ]) -# --- -# name: test_select_empty[captur_fuel].2 - list([ - ]) -# --- -# name: test_select_empty[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - '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({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) -# --- -# name: test_select_empty[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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 mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777123_charge_mode', - 'unit_of_measurement': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_zoe_40_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_select_empty[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1zoe40vin_charge_mode', + 'unit_of_measurement': None, + }) # --- -# name: test_select_empty[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_select_empty[zoe_40][select.reg_zoe_40_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) + 'context': , + 'entity_id': 'select.reg_zoe_40_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_select_empty[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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 mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777999_charge_mode', - 'unit_of_measurement': None, +# name: test_select_errors[zoe_40][select.reg_zoe_40_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_select_empty[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) -# --- -# name: test_select_empty[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_zoe_40_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_select_empty[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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 mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777999_charge_mode', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1zoe40vin_charge_mode', + 'unit_of_measurement': None, + }) # --- -# name: test_select_empty[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_select_errors[zoe_40][select.reg_zoe_40_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) + 'context': , + 'entity_id': 'select.reg_zoe_40_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) # --- -# name: test_selects[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_selects[captur_phev][select.reg_captur_phev_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_selects[captur_fuel].1 - list([ - ]) -# --- -# name: test_selects[captur_fuel].2 - list([ - ]) -# --- -# name: test_selects[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - '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({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) -# --- -# name: test_selects[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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 mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777123_charge_mode', - 'unit_of_measurement': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_captur_phev_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_selects[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'always', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1capturphevvin_charge_mode', + 'unit_of_measurement': None, + }) # --- -# name: test_selects[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_selects[captur_phev][select.reg_captur_phev_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) + 'context': , + 'entity_id': 'select.reg_captur_phev_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'always', + }) # --- -# name: test_selects[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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 mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777999_charge_mode', - 'unit_of_measurement': None, +# name: test_selects[twingo_3_electric][select.reg_twingo_iii_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_selects[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'always', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) -# --- -# name: test_selects[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_twingo_iii_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_selects[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_number_charge_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 mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1aaaaa555777999_charge_mode', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1twingoiiivin_charge_mode', + 'unit_of_measurement': None, + }) # --- -# name: test_selects[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_number_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'schedule_mode', +# name: test_selects[twingo_3_electric][select.reg_twingo_iii_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), }), - ]) + 'context': , + 'entity_id': 'select.reg_twingo_iii_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'always_charging', + }) +# --- +# name: test_selects[zoe_40][select.reg_zoe_40_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_zoe_40_charge_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 mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1zoe40vin_charge_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[zoe_40][select.reg_zoe_40_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'select.reg_zoe_40_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'always', + }) +# --- +# name: test_selects[zoe_50][select.reg_zoe_50_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_zoe_50_charge_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 mode', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1zoe50vin_charge_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[zoe_50][select.reg_zoe_50_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 Charge mode', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'select.reg_zoe_50_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'schedule_mode', + }) # --- diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index 175ad2422ed..b6c9569e0d3 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -1,5345 +1,4645 @@ # serializer version: 1 -# name: test_sensor_empty[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_battery_level', + 'unit_of_measurement': '%', + }) # --- -# name: test_sensor_empty[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777123_mileage', +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1zoe40vin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Battery autonomy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_autonomy', - 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1zoe40vin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-ZOE-40 Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1zoe40vin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charge_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': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1zoe40vin_charge_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_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': 'Charging power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'vf1zoe40vin_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-ZOE-40 Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_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': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1zoe40vin_charging_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-ZOE-40 Charging remaining time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1zoe40vin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + '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 battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1zoe40vin_battery_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_last_hvac_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + '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 HVAC activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1zoe40vin_hvac_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_last_hvac_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1zoe40vin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Mileage', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel quantity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_quantity', - 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'context': , + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1zoe40vin_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_plug_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': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1zoe40vin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_empty[zoe_40][sensor.reg_zoe_40_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1zoe40vin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Battery autonomy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1zoe40vin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-ZOE-40 Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1zoe40vin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charge_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': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1zoe40vin_charge_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_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': 'Charging power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'vf1zoe40vin_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-ZOE-40 Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_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': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1zoe40vin_charging_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-ZOE-40 Charging remaining time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1zoe40vin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + '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 battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1zoe40vin_battery_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_last_hvac_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + '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 HVAC activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1zoe40vin_hvac_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_last_hvac_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1zoe40vin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1zoe40vin_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_plug_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': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1zoe40vin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_errors[zoe_40][sensor.reg_zoe_40_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_fuel_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1capturfuelvin_fuel_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_fuel_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR-FUEL Fuel autonomy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_fuel_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_fuel_quantity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1capturfuelvin_fuel_quantity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_fuel_quantity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-CAPTUR-FUEL Fuel quantity', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777123_location_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777123_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777123_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_fuel_quantity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) # --- -# name: test_sensor_empty[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_last_location_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Fuel autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'volume', - 'friendly_name': 'REG-NUMBER Fuel quantity', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1capturfuelvin_location_last_activity', + 'unit_of_measurement': None, + }) # --- -# name: test_sensor_empty[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_last_location_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-CAPTUR-FUEL Last location activity', }), - ]) + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_last_location_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }) # --- -# name: test_sensor_empty[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_battery_level', - 'unit_of_measurement': '%', +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777123_charge_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_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': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', - 'unit_of_measurement': , + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_mileage', + '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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_admissible_charging_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': 'Admissible charging power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'admissible_charging_power', - 'unique_id': 'vf1aaaaa555777123_charging_power', + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1capturfuelvin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR-FUEL Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5567', + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_remote_engine_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': 'Remote engine start', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1capturfuelvin_res_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR-FUEL Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_remote_engine_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Stopped, ready for RES', + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_remote_engine_start_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': 'Remote engine start code', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1capturfuelvin_res_state_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR-FUEL Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_remote_engine_start_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_admissible_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_admissible_charging_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': 'Admissible charging power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1capturphevvin_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_admissible_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-CAPTUR_PHEV Admissible charging power', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777123_plug_state', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'sensor.reg_captur_phev_admissible_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.0', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777123_battery_autonomy', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1capturphevvin_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-CAPTUR_PHEV Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1capturphevvin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR_PHEV Battery autonomy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777123_battery_available_energy', + 'context': , + 'entity_id': 'sensor.reg_captur_phev_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '141', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1capturphevvin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-CAPTUR_PHEV Battery available energy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777123_battery_temperature', + 'context': , + 'entity_id': 'sensor.reg_captur_phev_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1capturphevvin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-CAPTUR_PHEV Battery temperature', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777123_battery_last_activity', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'sensor.reg_captur_phev_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777123_mileage', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_charge_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': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1capturphevvin_charge_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-CAPTUR_PHEV Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charge_in_progress', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_charging_remaining_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': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1capturphevvin_charging_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-CAPTUR_PHEV Charging remaining time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '145', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_fuel_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1capturphevvin_fuel_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_fuel_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR_PHEV Fuel autonomy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_autonomy', - 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.reg_captur_phev_fuel_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_fuel_quantity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel quantity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_quantity', - 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1capturphevvin_fuel_quantity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_fuel_quantity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-CAPTUR_PHEV Fuel quantity', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777123_location_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777123_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777123_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_captur_phev_fuel_quantity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) # --- -# name: test_sensor_empty[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Admissible charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Fuel autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'volume', - 'friendly_name': 'REG-NUMBER Fuel quantity', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1capturphevvin_battery_last_activity', + 'unit_of_measurement': None, + }) # --- -# name: test_sensor_empty[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-CAPTUR_PHEV Last battery activity', }), - ]) + 'context': , + 'entity_id': 'sensor.reg_captur_phev_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-01-12T21:40:16+00:00', + }) # --- -# name: test_sensor_empty[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_battery_level', - 'unit_of_measurement': '%', +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_location_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777999_charge_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_last_location_activity', + '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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_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': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', - 'unit_of_measurement': , + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_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': 'Charging power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_power', - 'unique_id': 'vf1aaaaa555777999_charging_power', + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1capturphevvin_location_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_location_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-CAPTUR_PHEV Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_last_location_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1capturphevvin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR_PHEV Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5567', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_plug_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': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1capturphevvin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-CAPTUR_PHEV Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plugged', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_remote_engine_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': 'Remote engine start', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1capturphevvin_res_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_remote_engine_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Stopped, ready for RES', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_remote_engine_start_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': 'Remote engine start code', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1capturphevvin_res_state_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_remote_engine_start_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_admissible_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_admissible_charging_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': 'Admissible charging power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1twingoiiivin_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_admissible_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-TWINGO-III Admissible charging power', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777999_plug_state', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_admissible_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1twingoiiivin_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-TWINGO-III Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '96', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1twingoiiivin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-TWINGO-III Battery autonomy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '182', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1twingoiiivin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-TWINGO-III Battery available energy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1twingoiiivin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-TWINGO-III Battery temperature', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777999_battery_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777999_mileage', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'outside_temperature', - 'unique_id': 'vf1aaaaa555777999_outside_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'HVAC SoC threshold', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_soc_threshold', - 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_hvac_activity', - '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 HVAC activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_last_activity', - 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777999_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777999_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_sensor_empty[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Outside temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_outside_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC SoC threshold', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last HVAC activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_hvac_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1twingoiiivin_charge_state', + 'unit_of_measurement': None, + }) # --- -# name: test_sensor_empty[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-TWINGO-III Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), }), - ]) + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'waiting_for_current_charge', + }) # --- -# name: test_sensor_empty[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_battery_level', - 'unit_of_measurement': '%', +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777999_charge_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_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': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_charging_remaining_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': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1twingoiiivin_charging_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-TWINGO-III Charging remaining time', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_admissible_charging_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': 'Admissible charging power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'admissible_charging_power', - 'unique_id': 'vf1aaaaa555777999_charging_power', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_hvac_soc_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777999_plug_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_hvac_soc_threshold', + '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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1twingoiiivin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-TWINGO-III HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_last_battery_activity', + '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 battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1twingoiiivin_battery_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-TWINGO-III Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-28T05:27:07+00:00', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_hvac_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_last_hvac_activity', + '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 HVAC activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1twingoiiivin_hvac_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_hvac_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-TWINGO-III Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_last_hvac_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-28T04:29:26+00:00', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_location_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_last_location_activity', + '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 location activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1twingoiiivin_location_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_last_location_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-TWINGO-III Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_last_location_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1twingoiiivin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-TWINGO-III Mileage', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49114', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1twingoiiivin_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-TWINGO-III Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_twingo_iii_plug_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': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1twingoiiivin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-TWINGO-III Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_twingo_iii_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe40vin_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-40 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1zoe40vin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Battery autonomy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '141', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1zoe40vin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-ZOE-40 Battery available energy', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1zoe40vin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Battery temperature', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777999_battery_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777999_mileage', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'outside_temperature', - 'unique_id': 'vf1aaaaa555777999_outside_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'HVAC SoC threshold', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_soc_threshold', - 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_hvac_activity', - '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 HVAC activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_last_activity', - 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777999_location_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777999_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777999_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_40_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) # --- -# name: test_sensor_empty[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Admissible charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Outside temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_outside_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC SoC threshold', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last HVAC activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_hvac_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1zoe40vin_charge_state', + 'unit_of_measurement': None, + }) # --- -# name: test_sensors[captur_fuel] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charge_in_progress', + }) # --- -# name: test_sensors[captur_fuel].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777123_mileage', - 'unit_of_measurement': , +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_autonomy', - 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', - 'unit_of_measurement': , + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel quantity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_quantity', - 'unique_id': 'vf1aaaaa555777123_fuel_quantity', - 'unit_of_measurement': , + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777123_location_last_activity', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777123_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777123_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'vf1zoe40vin_charging_power', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[captur_fuel].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '5567', +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-ZOE-40 Charging power', + 'state_class': , + 'unit_of_measurement': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Fuel autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '35', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'volume', - 'friendly_name': 'REG-NUMBER Fuel quantity', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-02-18T16:58:38+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Stopped, ready for RES', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.027', + }) # --- -# name: test_sensors[captur_phev] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777123', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Captur ii', - 'model_id': 'XJB1SU', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_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': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1zoe40vin_charging_remaining_time', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[captur_phev].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777123_battery_level', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777123_charge_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_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': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', +# name: test_sensors[zoe_40][sensor.reg_zoe_40_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-ZOE-40 Charging remaining time', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_admissible_charging_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': 'Admissible charging power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'admissible_charging_power', - 'unique_id': 'vf1aaaaa555777123_charging_power', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777123_plug_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777123_battery_autonomy', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777123_battery_available_energy', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777123_battery_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777123_battery_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777123_mileage', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_autonomy', - 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fuel quantity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fuel_quantity', - 'unique_id': 'vf1aaaaa555777123_fuel_quantity', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777123_location_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777123_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777123_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_40_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '145', + }) # --- -# name: test_sensors[captur_phev].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '60', +# name: test_sensors[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'charge_in_progress', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '145', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Admissible charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'plugged', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '141', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '31', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '20', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-01-12T21:40:16+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '5567', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Fuel autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '35', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'volume', - 'friendly_name': 'REG-NUMBER Fuel quantity', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_fuel_quantity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-02-18T16:58:38+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Stopped, ready for RES', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1zoe40vin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) # --- -# name: test_sensors[zoe_40] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X101VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[zoe_40].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_battery_level', +# name: test_sensors[zoe_40][sensor.reg_zoe_40_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-40 HVAC SoC threshold', 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777999_charge_state', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'sensor.reg_zoe_40_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_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': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + '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 battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1zoe40vin_battery_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-01-12T21:40:16+00:00', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_last_hvac_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + '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 HVAC activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1zoe40vin_hvac_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_last_hvac_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-40 Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_last_hvac_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1zoe40vin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-40 Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49114', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1zoe40vin_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-40 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_40_plug_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': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1zoe40vin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_40][sensor.reg_zoe_40_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-40 Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_40_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plugged', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_admissible_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_admissible_charging_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': 'Admissible charging power', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1zoe50vin_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_admissible_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-ZOE-50 Admissible charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_admissible_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'vf1zoe50vin_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-ZOE-50 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_autonomy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_battery_autonomy', + '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 autonomy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1zoe50vin_battery_autonomy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_autonomy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-50 Battery autonomy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_battery_autonomy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_battery_available_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': 'Battery available energy', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1zoe50vin_battery_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-ZOE-50 Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_battery_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_battery_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': 'Battery temperature', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1zoe50vin_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-50 Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_charge_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_charge_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': 'Charge state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1zoe50vin_charge_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_charge_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-50 Charge state', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_charge_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charge_error', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_charging_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_charging_remaining_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': 'Charging remaining time', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1zoe50vin_charging_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_charging_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'REG-ZOE-50 Charging remaining time', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_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': 'Charging power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_power', - 'unique_id': 'vf1aaaaa555777999_charging_power', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.reg_zoe_50_charging_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_hvac_soc_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777999_plug_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_hvac_soc_threshold', + '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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777999_battery_autonomy', - 'unit_of_measurement': , + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777999_battery_available_energy', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777999_battery_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777999_battery_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777999_mileage', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'outside_temperature', - 'unique_id': 'vf1aaaaa555777999_outside_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'HVAC SoC threshold', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_soc_threshold', - 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1zoe50vin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-ZOE-50 HVAC SoC threshold', 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_hvac_activity', - '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 HVAC activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_last_activity', - 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777999_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777999_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_50_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) # --- -# name: test_sensors[zoe_40].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '60', +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_battery_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'charge_in_progress', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '145', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.027', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'plugged', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '141', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '31', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '20', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-01-12T21:40:16+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '49114', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Outside temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_outside_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC SoC threshold', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last HVAC activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_hvac_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1zoe50vin_battery_last_activity', + 'unit_of_measurement': None, + }) # --- -# name: test_sensors[zoe_50] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'renault', - 'VF1AAAAA555777999', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Renault', - 'model': 'Zoe', - 'model_id': 'X102VE', - 'name': 'REG-NUMBER', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_battery_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-50 Last battery activity', }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_50_last_battery_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-11-17T08:06:48+00:00', + }) # --- -# name: test_sensors[zoe_50].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1aaaaa555777999_battery_level', - 'unit_of_measurement': '%', +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_hvac_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charge_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': 'Charge state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state', - 'unique_id': 'vf1aaaaa555777999_charge_state', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_last_hvac_activity', + '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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_charging_remaining_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': 'Charging remaining time', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charging_remaining_time', - 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', - 'unit_of_measurement': , + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_admissible_charging_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': 'Admissible charging power', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'admissible_charging_power', - 'unique_id': 'vf1aaaaa555777999_charging_power', - 'unit_of_measurement': , + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last HVAC activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1zoe50vin_hvac_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_hvac_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-50 Last HVAC activity', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_plug_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': 'Plug state', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1aaaaa555777999_plug_state', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'sensor.reg_zoe_50_last_hvac_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-12-03T00:00:00+00:00', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_location_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_autonomy', - '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 autonomy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_last_location_activity', + '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 location activity', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1zoe50vin_location_last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_last_location_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-ZOE-50 Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_last_location_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1zoe50vin_mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-ZOE-50 Mileage', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_available_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': 'Battery available energy', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_available_energy', - 'unique_id': 'vf1aaaaa555777999_battery_available_energy', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.reg_zoe_50_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49114', + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_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': 'Battery temperature', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_temperature', - 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_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': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1zoe50vin_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-ZOE-50 Outside temperature', + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_battery_activity', - '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 battery activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_last_activity', - 'unique_id': 'vf1aaaaa555777999_battery_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_mileage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Mileage', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1aaaaa555777999_mileage', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_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': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'outside_temperature', - 'unique_id': 'vf1aaaaa555777999_outside_temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'HVAC SoC threshold', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_soc_threshold', - 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_hvac_activity', - '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 HVAC activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_last_activity', - 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_last_location_activity', - '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 location activity', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1aaaaa555777999_location_last_activity', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_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': 'Remote engine start', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1aaaaa555777999_res_state', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.reg_number_remote_engine_start_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': 'Remote engine start code', - 'platform': 'renault', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1aaaaa555777999_res_state_code', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'sensor.reg_zoe_50_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_sensors[zoe_50].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '50', +# name: test_sensors[zoe_50][sensor.reg_zoe_50_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Charge state', - 'options': list([ - 'not_in_charge', - 'waiting_for_a_planned_charge', - 'charge_ended', - 'waiting_for_current_charge', - 'energy_flap_opened', - 'charge_in_progress', - 'charge_error', - 'unavailable', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_charge_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'charge_error', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'REG-NUMBER Charging remaining time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_charging_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_zoe_50_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'REG-NUMBER Admissible charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_admissible_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-NUMBER Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), - }), - 'context': , - 'entity_id': 'sensor.reg_number_plug_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unplugged', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '128', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'REG-NUMBER Battery available energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_available_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Battery temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_battery_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last battery activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_battery_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-11-17T08:06:48+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-NUMBER Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '49114', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'REG-NUMBER Outside temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.reg_number_outside_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER HVAC SoC threshold', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.reg_number_hvac_soc_threshold', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last HVAC activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_hvac_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-12-03T00:00:00+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'REG-NUMBER Last location activity', - }), - 'context': , - 'entity_id': 'sensor.reg_number_last_location_activity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2020-02-18T16:58:38+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Stopped, ready for RES', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-NUMBER Remote engine start code', - }), - 'context': , - 'entity_id': 'sensor.reg_number_remote_engine_start_code', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug state', + 'platform': 'renault', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1zoe50vin_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[zoe_50][sensor.reg_zoe_50_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-ZOE-50 Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_zoe_50_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unplugged', + }) # --- diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py index 52b6de33f14..1a7863780b1 100644 --- a/tests/components/renault/test_binary_sensor.py +++ b/tests/components/renault/test_binary_sensor.py @@ -9,10 +9,9 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry 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 check_device_registry, check_entities_unavailable -from .const import MOCK_VEHICLES +from tests.common import snapshot_platform pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -28,7 +27,6 @@ def override_platforms() -> Generator[None]: async def test_binary_sensors( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -36,28 +34,14 @@ async def test_binary_sensors( 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 - ) - 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 - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_binary_sensor_empty( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -65,42 +49,22 @@ async def test_binary_sensor_empty( 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 - ) - 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 - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_binary_sensor_errors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault binary sensors with temporary failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.BINARY_SENSOR] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_unavailable(hass, entity_registry, expected_entities) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_access_denied_exception") @@ -108,17 +72,12 @@ async def test_binary_sensor_errors( async def test_binary_sensor_access_denied( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault binary sensors with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 @@ -127,15 +86,10 @@ async def test_binary_sensor_access_denied( async def test_binary_sensor_not_supported( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault binary sensors with not supported failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 diff --git a/tests/components/renault/test_button.py b/tests/components/renault/test_button.py index 32c5ce651ae..61754578948 100644 --- a/tests/components/renault/test_button.py +++ b/tests/components/renault/test_button.py @@ -9,14 +9,11 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN, Platform +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 +from homeassistant.helpers import entity_registry as er -from . import check_device_registry, check_entities_no_data -from .const import ATTR_ENTITY_ID, MOCK_VEHICLES - -from tests.common import load_fixture +from tests.common import load_fixture, snapshot_platform pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -32,7 +29,6 @@ def override_platforms() -> Generator[None]: async def test_buttons( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -40,28 +36,14 @@ async def test_buttons( 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 - ) - 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 - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_button_empty( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -69,42 +51,22 @@ async def test_button_empty( 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 - ) - 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 - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_button_errors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with temporary failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_access_denied_exception") @@ -112,21 +74,14 @@ async def test_button_errors( async def test_button_access_denied( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_not_supported_exception") @@ -134,21 +89,14 @@ async def test_button_access_denied( async def test_button_not_supported( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with not supported failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_data") @@ -161,7 +109,7 @@ async def test_button_start_charge( await hass.async_block_till_done() data = { - ATTR_ENTITY_ID: "button.reg_number_start_charge", + ATTR_ENTITY_ID: "button.reg_zoe_40_start_charge", } with patch( @@ -189,7 +137,7 @@ async def test_button_stop_charge( await hass.async_block_till_done() data = { - ATTR_ENTITY_ID: "button.reg_number_stop_charge", + ATTR_ENTITY_ID: "button.reg_zoe_40_stop_charge", } with patch( @@ -217,7 +165,7 @@ async def test_button_start_air_conditioner( await hass.async_block_till_done() data = { - ATTR_ENTITY_ID: "button.reg_number_start_air_conditioner", + ATTR_ENTITY_ID: "button.reg_zoe_40_start_air_conditioner", } with patch( diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index 781b7efe226..9a7146c96cd 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import aiohttp_client -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, get_schema_suggested_value, load_fixture pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -67,6 +67,11 @@ async def test_config_flow_single_account( assert result["step_id"] == "user" assert result["errors"] == {"base": error} + data_schema = result["data_schema"].schema + assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR" + assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com" + assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test" + renault_account = AsyncMock() type(renault_account).account_id = PropertyMock(return_value="account_id_1") renault_account.get_vehicles.return_value = ( @@ -278,3 +283,114 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non assert config_entry.data[CONF_USERNAME] == "email@test.com" assert config_entry.data[CONF_PASSWORD] == "any" + + +async def test_reconfigure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure works.""" + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + data_schema = result["data_schema"].schema + assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR" + assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com" + assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test" + + renault_account = AsyncMock() + type(renault_account).account_id = PropertyMock(return_value="account_id_1") + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) + ) + + # Account list single + with ( + patch("renault_api.renault_session.RenaultSession.login"), + patch( + "renault_api.renault_account.RenaultAccount.account_id", return_value="123" + ), + patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email2@test.com", + CONF_PASSWORD: "test2", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.data[CONF_USERNAME] == "email2@test.com" + assert config_entry.data[CONF_PASSWORD] == "test2" + assert config_entry.data[CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" + assert config_entry.data[CONF_LOCALE] == "fr_FR" + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_mismatch( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure fails on account ID mismatch.""" + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + data_schema = result["data_schema"].schema + assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR" + assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com" + assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test" + + renault_account = AsyncMock() + type(renault_account).account_id = PropertyMock(return_value="account_id_other") + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) + ) + + # Account list single + with ( + patch("renault_api.renault_session.RenaultSession.login"), + patch( + "renault_api.renault_account.RenaultAccount.account_id", return_value="1234" + ), + patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email2@test.com", + CONF_PASSWORD: "test2", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + # Unchanged values + assert config_entry.data[CONF_USERNAME] == "email@test.com" + assert config_entry.data[CONF_PASSWORD] == "test" + assert config_entry.data[CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" + assert config_entry.data[CONF_LOCALE] == "fr_FR" + + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/renault/test_device_tracker.py b/tests/components/renault/test_device_tracker.py index 39f37d12a4d..090a73ae904 100644 --- a/tests/components/renault/test_device_tracker.py +++ b/tests/components/renault/test_device_tracker.py @@ -9,13 +9,17 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry 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 check_device_registry, check_entities_unavailable from .const import MOCK_VEHICLES +from tests.common import snapshot_platform + pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") +# Zoe 40 does not expose GPS information +_TEST_VEHICLES = [v for v in MOCK_VEHICLES if v != "zoe_40"] + @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: @@ -25,10 +29,10 @@ def override_platforms() -> Generator[None]: @pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", _TEST_VEHICLES, indirect=True) async def test_device_trackers( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -36,28 +40,14 @@ async def test_device_trackers( 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 - ) - 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 - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_50"], indirect=True) async def test_device_tracker_empty( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -65,77 +55,47 @@ async def test_device_tracker_empty( 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 - ) - 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 - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_50"], indirect=True) async def test_device_tracker_errors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with temporary failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.DEVICE_TRACKER] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_unavailable(hass, entity_registry, expected_entities) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_access_denied_exception") -@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +@pytest.mark.parametrize("vehicle_type", ["zoe_50"], indirect=True) async def test_device_tracker_access_denied( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault device trackers with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 @pytest.mark.usefixtures("fixtures_with_not_supported_exception") -@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +@pytest.mark.parametrize("vehicle_type", ["zoe_50"], indirect=True) async def test_device_tracker_not_supported( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault device trackers with not supported failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 diff --git a/tests/components/renault/test_diagnostics.py b/tests/components/renault/test_diagnostics.py index 7159de26b11..1e238b15225 100644 --- a/tests/components/renault/test_diagnostics.py +++ b/tests/components/renault/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Renault diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.renault import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -48,9 +48,7 @@ async def test_device_diagnostics( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - device = device_registry.async_get_device( - identifiers={(DOMAIN, "VF1AAAAA555777999")} - ) + device = device_registry.async_get_device(identifiers={(DOMAIN, "VF1ZOE40VIN")}) assert device is not None assert ( diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index a71192dda47..48cac8e1add 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import Mock, patch import aiohttp import pytest from renault_api.gigya.exceptions import GigyaException, InvalidCredentialsException +from syrupy.assertion import SnapshotAssertion from homeassistant.components.renault.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry, ConfigEntryState @@ -24,13 +25,8 @@ def override_platforms() -> Generator[None]: yield -@pytest.fixture(autouse=True, name="vehicle_type", params=["zoe_40"]) -def override_vehicle_type(request: pytest.FixtureRequest) -> str: - """Parametrize vehicle type.""" - return request.param - - @pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_setup_unload_entry( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: @@ -119,6 +115,24 @@ async def test_setup_entry_missing_vehicle_details( assert config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") +async def test_device_registry( + hass: HomeAssistant, + config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device is correctly registered.""" + 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 + ) + assert device_entries == snapshot + + @pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_registry_cleanup( @@ -130,7 +144,7 @@ async def test_registry_cleanup( """Test being able to remove a disconnected device.""" assert await async_setup_component(hass, "config", {}) entry_id = config_entry.entry_id - live_id = "VF1AAAAA555777999" + live_id = "VF1ZOE40VIN" dead_id = "VF1AAAAA555777888" assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 0 @@ -148,7 +162,7 @@ async def test_registry_cleanup( await hass.async_block_till_done() assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 - # Try to remove "VF1AAAAA555777999" - fails as it is live + # Try to remove "VF1ZOE40VIN" - fails as it is live device = device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) client = await hass_ws_client(hass) response = await client.remove_device(device.id, entry_id) diff --git a/tests/components/renault/test_select.py b/tests/components/renault/test_select.py index 7b589d86863..b8ba3ef4b58 100644 --- a/tests/components/renault/test_select.py +++ b/tests/components/renault/test_select.py @@ -15,16 +15,19 @@ from homeassistant.components.select import ( from homeassistant.config_entries import ConfigEntry 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 +from homeassistant.helpers import entity_registry as er -from . import check_device_registry, check_entities_unavailable from .const import MOCK_VEHICLES -from tests.common import load_fixture +from tests.common import load_fixture, snapshot_platform pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") +# Captur (fuel version) does not have a charge mode select +_TEST_VEHICLES = [v for v in MOCK_VEHICLES if v != "captur_fuel"] + + @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" @@ -33,10 +36,10 @@ def override_platforms() -> Generator[None]: @pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", _TEST_VEHICLES, indirect=True) async def test_selects( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -44,28 +47,14 @@ async def test_selects( 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 - ) - 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 - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_select_empty( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -73,42 +62,22 @@ async def test_select_empty( 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 - ) - 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 - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_select_errors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault selects with temporary failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.SELECT] - assert len(entity_registry.entities) == len(expected_entities) - - check_entities_unavailable(hass, entity_registry, expected_entities) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_access_denied_exception") @@ -116,17 +85,12 @@ async def test_select_errors( async def test_select_access_denied( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault selects with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 @@ -135,17 +99,12 @@ async def test_select_access_denied( async def test_select_not_supported( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault selects with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 @@ -159,7 +118,7 @@ async def test_select_charge_mode( await hass.async_block_till_done() data = { - ATTR_ENTITY_ID: "select.reg_number_charge_mode", + ATTR_ENTITY_ID: "select.reg_zoe_40_charge_mode", ATTR_OPTION: "always", } diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index d69ab5c0b7f..e75d0558f19 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -1,18 +1,26 @@ """Tests for Renault sensors.""" from collections.abc import Generator -from unittest.mock import patch +import datetime +from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest +from renault_api.kamereon.exceptions import ( + AccessDeniedException, + NotSupportedException, + QuotaLimitException, +) from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE, 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 check_device_registry, check_entities_unavailable -from .const import MOCK_VEHICLES +from .conftest import _get_fixtures, patch_get_vehicle_data + +from tests.common import async_fire_time_changed, snapshot_platform pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -24,11 +32,10 @@ def override_platforms() -> Generator[None]: yield -@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.usefixtures("fixtures_with_data", "entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -36,34 +43,14 @@ async def test_sensors( 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 - ) - 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 - - # 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 + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_no_data", "entity_registry_enabled_by_default") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_sensor_empty( hass: HomeAssistant, config_entry: ConfigEntry, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -71,47 +58,24 @@ async def test_sensor_empty( 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 - ) - 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 - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures( "fixtures_with_invalid_upstream_exception", "entity_registry_enabled_by_default" ) +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_sensor_errors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault sensors with temporary failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - - expected_entities = mock_vehicle[Platform.SENSOR] - assert len(entity_registry.entities) == len(expected_entities) - - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - check_entities_unavailable(hass, entity_registry, expected_entities) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("fixtures_with_access_denied_exception") @@ -119,17 +83,12 @@ async def test_sensor_errors( async def test_sensor_access_denied( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault sensors with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 @@ -138,15 +97,181 @@ async def test_sensor_access_denied( async def test_sensor_not_supported( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for Renault sensors with access denied failure.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) - assert len(entity_registry.entities) == 0 + + +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_sensor_throttling_during_setup( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_type: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for Renault sensors with a throttling error during setup.""" + mock_fixtures = _get_fixtures(vehicle_type) + with patch_get_vehicle_data() as patches: + for key, get_data_mock in patches.items(): + get_data_mock.return_value = mock_fixtures[key] + get_data_mock.side_effect = QuotaLimitException( + "err.func.wired.overloaded", "You have reached your quota limit" + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Initial state + entity_id = "sensor.reg_zoe_40_battery" + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # Test QuotaLimitException recovery, with new battery level + for get_data_mock in patches.values(): + get_data_mock.side_effect = None + patches["battery_status"].return_value.batteryLevel = 55 + freezer.tick(datetime.timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "55" + + +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_sensor_throttling_after_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_type: str, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for Renault sensors with a throttling error during setup.""" + mock_fixtures = _get_fixtures(vehicle_type) + with patch_get_vehicle_data() as patches: + for key, get_data_mock in patches.items(): + get_data_mock.return_value = mock_fixtures[key] + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Initial state + entity_id = "sensor.reg_zoe_40_battery" + assert hass.states.get(entity_id).state == "60" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled: scan skipped" not in caplog.text + + # Test QuotaLimitException state + caplog.clear() + for get_data_mock in patches.values(): + get_data_mock.side_effect = QuotaLimitException( + "err.func.wired.overloaded", "You have reached your quota limit" + ) + freezer.tick(datetime.timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "60" + assert hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" in caplog.text + assert "Renault hub currently throttled: scan skipped" in caplog.text + + # Test QuotaLimitException recovery, with new battery level + caplog.clear() + for get_data_mock in patches.values(): + get_data_mock.side_effect = None + patches["battery_status"].return_value.batteryLevel = 55 + freezer.tick(datetime.timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "55" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" not in caplog.text + assert "Renault hub currently throttled: scan skipped" not in caplog.text + + +# scan interval in seconds = (3600 * num_calls) / MAX_CALLS_PER_HOURS +# MAX_CALLS_PER_HOURS being a constant, for now 60 calls per hour +# num_calls = num_coordinator_car_0 + num_coordinator_car_1 + ... + num_coordinator_car_n +@pytest.mark.parametrize( + ("vehicle_type", "vehicle_count", "scan_interval"), + [ + ("zoe_50", 1, 300), # 5 coordinators => 5 minutes interval + ("captur_fuel", 1, 240), # 4 coordinators => 4 minutes interval + ("multi", 2, 480), # 8 coordinators => 8 minutes interval + ], + indirect=["vehicle_type"], +) +async def test_dynamic_scan_interval( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_count: int, + scan_interval: int, + freezer: FrozenDateTimeFactory, + fixtures_with_data: dict[str, AsyncMock], +) -> None: + """Test scan interval.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds before the expected scan interval > not called + freezer.tick(datetime.timedelta(seconds=scan_interval - 2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds after the expected scan interval > called + freezer.tick(datetime.timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count * 2 + + +# scan interval in seconds = (3600 * num_calls) / MAX_CALLS_PER_HOURS +# MAX_CALLS_PER_HOURS being a constant, for now 60 calls per hour +# num_calls = num_coordinator_car_0 + num_coordinator_car_1 + ... + num_coordinator_car_n +@pytest.mark.parametrize( + ("vehicle_type", "vehicle_count", "scan_interval"), + [ + ("zoe_50", 1, 240), # (6-2) coordinators => 4 minutes interval + ("captur_fuel", 1, 180), # (4-1) coordinators => 3 minutes interval + ("multi", 2, 360), # (8-2) coordinators => 6 minutes interval + ], + indirect=["vehicle_type"], +) +async def test_dynamic_scan_interval_failed_coordinator( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_count: int, + scan_interval: int, + freezer: FrozenDateTimeFactory, + fixtures_with_data: dict[str, AsyncMock], +) -> None: + """Test scan interval.""" + fixtures_with_data["battery_status"].side_effect = NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + fixtures_with_data["lock_status"].side_effect = AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds before the expected scan interval > not called + freezer.tick(datetime.timedelta(seconds=scan_interval - 2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds after the expected scan interval > called + freezer.tick(datetime.timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count * 2 diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 970d7cf4ad8..eef38c00f36 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -8,7 +8,7 @@ import pytest from renault_api.exceptions import RenaultException from renault_api.kamereon import schemas from renault_api.kamereon.models import ChargeSchedule, HvacSchedule -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.renault.const import DOMAIN from homeassistant.components.renault.services import ( @@ -22,19 +22,10 @@ from homeassistant.components.renault.services import ( SERVICE_CHARGE_SET_SCHEDULES, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_MODEL_ID, - ATTR_NAME, -) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr -from .const import MOCK_VEHICLES - from tests.common import load_fixture pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -56,7 +47,7 @@ def override_vehicle_type(request: pytest.FixtureRequest) -> str: def get_device_id(hass: HomeAssistant) -> str: """Get device_id.""" device_registry = dr.async_get(hass) - identifiers = {(DOMAIN, "VF1AAAAA555777999")} + identifiers = {(DOMAIN, "VF1ZOE40VIN")} device = device_registry.async_get_device(identifiers=identifiers) return device.id @@ -72,13 +63,14 @@ async def test_service_set_ac_cancel( ATTR_VEHICLE: get_device_id(hass), } - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", - side_effect=RenaultException("Didn't work"), - ) as mock_action, - pytest.raises(HomeAssistantError, match="Didn't work"), - ): + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", + return_value=( + schemas.KamereonVehicleHvacStartActionDataSchema.loads( + load_fixture("renault/action.set_ac_stop.json") + ) + ), + ) as mock_action: await hass.services.async_call( DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True ) @@ -158,11 +150,12 @@ async def test_service_set_charge_schedule( } with ( + patch("renault_api.renault_vehicle.RenaultVehicle.get_full_endpoint"), patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charging_settings", - return_value=schemas.KamereonVehicleDataResponseSchema.loads( + "renault_api.renault_vehicle.RenaultVehicle.http_get", + return_value=schemas.KamereonResponseSchema.loads( load_fixture("renault/charging_settings.json") - ).get_attributes(schemas.KamereonVehicleChargingSettingsDataSchema), + ), ), patch( "renault_api.renault_vehicle.RenaultVehicle.set_charge_schedules", @@ -207,11 +200,12 @@ async def test_service_set_charge_schedule_multi( } with ( + patch("renault_api.renault_vehicle.RenaultVehicle.get_full_endpoint"), patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charging_settings", - return_value=schemas.KamereonVehicleDataResponseSchema.loads( + "renault_api.renault_vehicle.RenaultVehicle.http_get", + return_value=schemas.KamereonResponseSchema.loads( load_fixture("renault/charging_settings.json") - ).get_attributes(schemas.KamereonVehicleChargingSettingsDataSchema), + ), ), patch( "renault_api.renault_vehicle.RenaultVehicle.set_charge_schedules", @@ -337,7 +331,7 @@ async def test_service_set_ac_schedule_multi( async def test_service_invalid_device_id( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: - """Test that service fails with ValueError if device_id not found in registry.""" + """Test that service fails if device_id not found in registry.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -354,22 +348,19 @@ async def test_service_invalid_device_id( async def test_service_invalid_device_id2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: ConfigEntry ) -> None: - """Test that service fails with ValueError if device_id not found in vehicles.""" + """Test that service fails if device_id not available in the hub.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - extra_vehicle = MOCK_VEHICLES["captur_phev"]["expected_device"] - + # Create a fake second vehicle in the device registry, but + # not initialised by the hub. device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers=extra_vehicle[ATTR_IDENTIFIERS], - manufacturer=extra_vehicle[ATTR_MANUFACTURER], - name=extra_vehicle[ATTR_NAME], - model=extra_vehicle[ATTR_MODEL], - model_id=extra_vehicle[ATTR_MODEL_ID], + identifiers={(DOMAIN, "VF1AAAAA111222333")}, + name="REG-NUMBER", ) device_id = device_registry.async_get_device( - identifiers=extra_vehicle[ATTR_IDENTIFIERS] + identifiers={(DOMAIN, "VF1AAAAA111222333")}, ).id data = {ATTR_VEHICLE: device_id} @@ -380,3 +371,28 @@ async def test_service_invalid_device_id2( ) assert err.value.translation_key == "no_config_entry_for_device" assert err.value.translation_placeholders == {"device_id": "REG-NUMBER"} + + +async def test_service_exception( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Test that service invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data = { + ATTR_VEHICLE: get_device_id(hass), + } + + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.set_ac_stop", + side_effect=RenaultException("Didn't work"), + ) as mock_action, + pytest.raises(HomeAssistantError, match="Didn't work"), + ): + await hass.services.async_call( + DOMAIN, SERVICE_AC_CANCEL, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 2862aa55b4d..a2155ba00eb 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -10,6 +10,7 @@ from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import ( + CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN, @@ -34,6 +35,7 @@ TEST_PASSWORD = "password" TEST_PASSWORD2 = "new_password" TEST_MAC = "aa:bb:cc:dd:ee:ff" TEST_MAC2 = "ff:ee:dd:cc:bb:aa" +TEST_MAC_CAM = "11:22:33:44:55:66" DHCP_FORMATTED_MAC = "aabbccddeeff" TEST_UID = "ABC1234567D89EFG" TEST_UID_CAM = "DEF7654321D89GHT" @@ -48,6 +50,7 @@ TEST_ITEM_NUMBER = "P000" TEST_CAM_MODEL = "RLC-123" TEST_DUO_MODEL = "Reolink Duo PoE" TEST_PRIVACY = True +TEST_BC_PORT = 5678 @pytest.fixture @@ -74,6 +77,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.check_new_firmware.return_value = False host_mock.unsubscribe.return_value = True host_mock.logout.return_value = True + host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC host_mock.uid = TEST_UID @@ -123,6 +127,8 @@ def reolink_connect_class() -> Generator[MagicMock]: "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" ) + reolink_connect.chime_list = [] + # enums host_mock.whiteled_mode.return_value = 1 host_mock.whiteled_mode_list.return_value = ["off", "auto"] @@ -130,13 +136,30 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.doorbell_led_list.return_value = ["stayoff", "auto"] host_mock.auto_track_method.return_value = 3 host_mock.daynight_state.return_value = "Black&White" + host_mock.hub_alarm_tone_id.return_value = 1 + host_mock.hub_visitor_tone_id.return_value = 1 + host_mock.recording_packing_time_list = ["30 Minutes", "60 Minutes"] + host_mock.recording_packing_time = "60 Minutes" # Baichuan host_mock.baichuan = create_autospec(Baichuan) # Disable tcp push by default for tests + host_mock.baichuan.port = TEST_BC_PORT host_mock.baichuan.events_active = False + host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM host_mock.baichuan.privacy_mode.return_value = False + host_mock.baichuan.day_night_state.return_value = "day" host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + host_mock.baichuan.active_scene = "off" + host_mock.baichuan.scene_names = ["off", "home"] + host_mock.baichuan.abilities = { + 0: {"chnID": 0, "aitype": 34615}, + "Host": {"pushAlarm": 7}, + } + host_mock.baichuan.smart_location_list.return_value = [0] + host_mock.baichuan.smart_ai_type_list.return_value = ["people"] + host_mock.baichuan.smart_ai_index.return_value = 1 + host_mock.baichuan.smart_ai_name.return_value = "zone1" yield host_mock_class @@ -169,6 +192,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 71c5397fbd1..5eb80d16356 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -1,6 +1,27 @@ # serializer version: 1 # name: test_entry_diagnostics dict({ + 'BC_abilities': dict({ + '0': dict({ + 'aitype': 34615, + 'chnID': 0, + }), + 'Host': dict({ + 'pushAlarm': 7, + }), + }), + 'Chimes': dict({ + '12345678': dict({ + 'channel': 0, + 'event_types': list([ + 'md', + 'people', + 'visitor', + ]), + 'name': 'Test chime', + 'online': True, + }), + }), 'HTTP(S) port': 1234, 'HTTPS': True, 'IPC cams': dict({ @@ -41,6 +62,14 @@ 0, ]), 'cmd list': dict({ + '296': dict({ + '0': 1, + 'null': 1, + }), + 'DingDongOpt': dict({ + '0': 2, + 'null': 2, + }), 'GetAiAlarm': dict({ '0': 5, 'null': 5, @@ -81,6 +110,10 @@ '0': 2, 'null': 4, }), + 'GetDingDongCfg': dict({ + '0': 3, + 'null': 3, + }), 'GetEmail': dict({ '0': 1, 'null': 2, @@ -137,6 +170,9 @@ '0': 1, 'null': 2, }), + 'GetScene': dict({ + 'null': 1, + }), 'GetStateLight': dict({ 'null': 1, }), diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index 71318c27b25..99c9efba002 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -51,6 +51,32 @@ async def test_motion_sensor( assert hass.states.get(entity_id).state == STATE_ON +async def test_smart_ai_sensor( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test smart ai binary sensor entity.""" + reolink_connect.model = TEST_HOST_MODEL + reolink_connect.baichuan.smart_ai_state.return_value = True + 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}_crossline_zone1_person" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.baichuan.smart_ai_state.return_value = False + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF + + async def test_tcp_callback( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4fe671f8cca..e706af0d067 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -19,6 +19,7 @@ from homeassistant import config_entries from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import ( + CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN, @@ -40,6 +41,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import ( DHCP_FORMATTED_MAC, + TEST_BC_PORT, TEST_HOST, TEST_HOST2, TEST_MAC, @@ -88,6 +90,7 @@ async def test_config_flow_manual_success( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -140,6 +143,7 @@ async def test_config_flow_privacy_success( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -290,6 +294,7 @@ async def test_config_flow_errors( CONF_HOST: TEST_HOST, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, ) @@ -302,6 +307,7 @@ async def test_config_flow_errors( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -322,6 +328,7 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: "rtsp", @@ -360,6 +367,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -405,6 +413,7 @@ async def test_reauth_abort_unique_id_mismatch( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -474,6 +483,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -496,6 +506,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -536,6 +547,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( protocol=DEFAULT_PROTOCOL, timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, + bc_port=TEST_BC_PORT, ) assert expected_call in reolink_connect_class.call_args_list @@ -593,6 +605,7 @@ async def test_dhcp_ip_update( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -635,6 +648,7 @@ async def test_dhcp_ip_update( protocol=DEFAULT_PROTOCOL, timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, + bc_port=TEST_BC_PORT, ) assert expected_call in reolink_connect_class.call_args_list @@ -671,6 +685,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -702,6 +717,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( protocol=DEFAULT_PROTOCOL, timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, + bc_port=TEST_BC_PORT, ) assert expected_call in reolink_connect_class.call_args_list @@ -731,6 +747,7 @@ async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> Non CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -777,6 +794,7 @@ async def test_reconfig_abort_unique_id_mismatch( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py index 57b474c13ad..d45163d3cf0 100644 --- a/tests/components/reolink/test_diagnostics.py +++ b/tests/components/reolink/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +from reolink_aio.api import Chime from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -15,6 +16,7 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, reolink_connect: MagicMock, + test_chime: Chime, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 28d8c542f4f..f2ae22913ad 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -19,10 +19,14 @@ from homeassistant.components.reolink import ( FIRMWARE_UPDATE_INTERVAL, NUM_CRED_ERRORS, ) -from homeassistant.components.reolink.const import DOMAIN +from homeassistant.components.reolink.const import CONF_BC_PORT, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -35,16 +39,26 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.setup import async_setup_component from .conftest import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DEFAULT_PROTOCOL, + TEST_BC_PORT, TEST_CAM_MODEL, + TEST_HOST, TEST_HOST_MODEL, TEST_MAC, + TEST_MAC_CAM, TEST_NVR_NAME, TEST_PORT, + TEST_PRIVACY, TEST_UID, TEST_UID_CAM, + TEST_USE_HTTPS, + TEST_USERNAME, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -411,6 +425,15 @@ async def test_removing_chime( True, True, ), + ( + f"{TEST_UID}_unexpected", + f"{TEST_UID}_unexpected", + f"{TEST_UID}_{TEST_UID_CAM}", + f"{TEST_UID}_{TEST_UID_CAM}", + Platform.SWITCH, + True, + True, + ), ], ) async def test_migrate_entity_ids( @@ -456,7 +479,8 @@ async def test_migrate_entity_ids( ) assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) is None + if original_id != new_id: + assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) is None assert device_registry.async_get_device(identifiers={(DOMAIN, original_dev_id)}) if new_dev_id != original_dev_id: @@ -469,7 +493,8 @@ async def test_migrate_entity_ids( 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 + if original_id != new_id: + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) if new_dev_id != original_dev_id: @@ -590,6 +615,115 @@ async def test_migrate_with_already_existing_entity( assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) +async def test_cleanup_mac_connection( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test cleanup of the MAC of a IPC which was set to the MAC of the host.""" + reolink_connect.channels = [0] + reolink_connect.baichuan.mac_address.return_value = None + entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio" + dev_id = f"{TEST_UID}_{TEST_UID_CAM}" + domain = Platform.SWITCH + + dev_entry = device_registry.async_get_or_create( + identifiers={(DOMAIN, dev_id), ("OTHER_INTEGRATION", "SOME_ID")}, + connections={(CONNECTION_NETWORK_MAC, TEST_MAC)}, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) + + entity_registry.async_get_or_create( + domain=domain, + platform=DOMAIN, + unique_id=entity_id, + config_entry=config_entry, + suggested_object_id=entity_id, + disabled_by=None, + device_id=dev_entry.id, + ) + + assert entity_registry.async_get_entity_id(domain, DOMAIN, entity_id) + device = device_registry.async_get_device(identifiers={(DOMAIN, dev_id)}) + assert device + assert device.connections == {(CONNECTION_NETWORK_MAC, TEST_MAC)} + + # 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, entity_id) + device = device_registry.async_get_device(identifiers={(DOMAIN, dev_id)}) + assert device + assert device.connections == set() + + reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM + + +async def test_cleanup_combined_with_NVR( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test cleanup of the device registry if IPC camera device was combined with the NVR device.""" + reolink_connect.channels = [0] + reolink_connect.baichuan.mac_address.return_value = None + entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio" + dev_id = f"{TEST_UID}_{TEST_UID_CAM}" + domain = Platform.SWITCH + start_identifiers = { + (DOMAIN, dev_id), + (DOMAIN, TEST_UID), + ("OTHER_INTEGRATION", "SOME_ID"), + } + + dev_entry = device_registry.async_get_or_create( + identifiers=start_identifiers, + connections={(CONNECTION_NETWORK_MAC, TEST_MAC)}, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) + + entity_registry.async_get_or_create( + domain=domain, + platform=DOMAIN, + unique_id=entity_id, + config_entry=config_entry, + suggested_object_id=entity_id, + disabled_by=None, + device_id=dev_entry.id, + ) + + assert entity_registry.async_get_entity_id(domain, DOMAIN, entity_id) + device = device_registry.async_get_device(identifiers={(DOMAIN, dev_id)}) + assert device + assert device.identifiers == start_identifiers + + # 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, entity_id) + device = device_registry.async_get_device(identifiers={(DOMAIN, dev_id)}) + assert device + assert device.identifiers == {(DOMAIN, dev_id)} + host_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_UID)}) + assert host_device + assert host_device.identifiers == { + (DOMAIN, TEST_UID), + ("OTHER_INTEGRATION", "SOME_ID"), + } + + reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM + + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry ) -> None: @@ -722,6 +856,41 @@ async def test_firmware_repair_issue( await hass.async_block_till_done() assert (DOMAIN, "firmware_update_host") in issue_registry.issues + reolink_connect.camera_sw_version_update_required.return_value = False + + +async def test_password_too_long_repair_issue( + hass: HomeAssistant, + reolink_connect: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test password too long issue is raised.""" + reolink_connect.valid_password.return_value = False + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: "too_longgggggggggggggggggggggggggggggggggggggggggggggggggg", + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + DOMAIN, + f"password_too_long_{config_entry.entry_id}", + ) in issue_registry.issues + reolink_connect.valid_password.return_value = True async def test_new_device_discovered( @@ -762,6 +931,21 @@ async def test_port_changed( assert config_entry.data[CONF_PORT] == 4567 +async def test_baichuan_port_changed( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test config_entry baichuan port update when it has changed during initial login.""" + assert config_entry.data[CONF_BC_PORT] == TEST_BC_PORT + reolink_connect.baichuan.port = 8901 + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data[CONF_BC_PORT] == 8901 + + async def test_privacy_mode_on( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index a5a34514598..7044ea53671 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -15,7 +15,7 @@ from homeassistant.components.media_source import ( async_resolve_media, ) from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import CONF_BC_PORT, CONF_USE_HTTPS, DOMAIN from homeassistant.components.stream import DOMAIN as MEDIA_STREAM_DOMAIN from homeassistant.const import ( CONF_HOST, @@ -31,6 +31,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.setup import async_setup_component from .conftest import ( + TEST_BC_PORT, TEST_HOST2, TEST_HOST_MODEL, TEST_MAC2, @@ -348,6 +349,7 @@ async def test_browsing_not_loaded( CONF_PASSWORD: TEST_PASSWORD2, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index c6507fa36c1..dd70376d658 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -67,6 +67,48 @@ async def test_number( reolink_connect.set_volume.reset_mock(side_effect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_smart_ai_number( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test number entity with smart ai sensitivity.""" + reolink_connect.baichuan.smart_ai_sensitivity.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}_AI_crossline_zone1_sensitivity" + + assert hass.states.get(entity_id).state == "80" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + reolink_connect.baichuan.set_smart_ai.assert_called_with( + 0, "crossline", 0, sensitivity=50 + ) + + reolink_connect.baichuan.set_smart_ai.side_effect = InvalidParameterError( + "Test error" + ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + + reolink_connect.baichuan.set_smart_ai.reset_mock(side_effect=True) + + async def test_host_number( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 7910174380a..32bc5e4435e 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -104,6 +104,58 @@ async def test_play_quick_reply_message( reolink_connect.quick_reply_dict = MagicMock() +async def test_host_scene_select( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test host select entity with scene mode.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): + 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.SELECT}.{TEST_NVR_NAME}_scene_mode" + assert hass.states.get(entity_id).state == "off" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "home"}, + blocking=True, + ) + reolink_connect.baichuan.set_scene.assert_called_once() + + reolink_connect.baichuan.set_scene.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "home"}, + blocking=True, + ) + + reolink_connect.baichuan.set_scene.side_effect = InvalidParameterError("Test error") + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "home"}, + blocking=True, + ) + + reolink_connect.baichuan.active_scene = "Invalid value" + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + reolink_connect.baichuan.set_scene.reset_mock(side_effect=True) + reolink_connect.baichuan.active_scene = "off" + + async def test_chime_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index a6cfe862963..d48362516b8 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from reolink_aio.exceptions import ReolinkError +from reolink_aio.exceptions import ApiError, ReolinkError from reolink_aio.software_version import NewSoftwareVersion from homeassistant.components.reolink.update import POLL_AFTER_INSTALL, POLL_PROGRESS @@ -144,6 +144,17 @@ async def test_update_firm( blocking=True, ) + reolink_connect.update_firmware.side_effect = ApiError( + "Test error", translation_key="firmware_rate_limit" + ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + # test _async_update_future reolink_connect.camera_sw_version.return_value = "v3.3.0.226_23031644" reolink_connect.firmware_update_available.return_value = False diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py index f66f4682b98..181249b8bff 100644 --- a/tests/components/reolink/test_util.py +++ b/tests/components/reolink/test_util.py @@ -23,66 +23,80 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) +from homeassistant.components.reolink.const import DOMAIN +from homeassistant.components.reolink.util import get_device_uid_and_ch 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.helpers import device_registry as dr -from .conftest import TEST_NVR_NAME +from .conftest import TEST_NVR_NAME, TEST_UID, TEST_UID_CAM from tests.common import MockConfigEntry +DEV_ID_NVR = f"{TEST_UID}_{TEST_UID_CAM}" +DEV_ID_STANDALONE_CAM = f"{TEST_UID_CAM}" + @pytest.mark.parametrize( ("side_effect", "expected"), [ ( ApiError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="api_error"), + ), + ( + ApiError("Test error", translation_key="firmware_rate_limit"), + HomeAssistantError(translation_key="firmware_rate_limit"), + ), + ( + ApiError("Test error", translation_key="not_in_strings.json"), + HomeAssistantError(translation_key="api_error"), ), ( CredentialsInvalidError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="invalid_credentials"), ), ( InvalidContentTypeError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="invalid_content_type"), ), ( InvalidParameterError("Test error"), - ServiceValidationError, + ServiceValidationError(translation_key="invalid_parameter"), ), ( LoginError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="login_error"), ), ( NoDataError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="no_data"), ), ( NotSupportedError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="not_supported"), ), ( ReolinkConnectionError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="connection_error"), ), ( ReolinkError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="unexpected"), ), ( ReolinkTimeoutError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="timeout"), ), ( SubscriptionError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="subscription_error"), ), ( UnexpectedDataError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="unexpected_data"), ), ], ) @@ -91,7 +105,7 @@ async def test_try_function( config_entry: MockConfigEntry, reolink_connect: MagicMock, side_effect: ReolinkError, - expected: Exception, + expected: HomeAssistantError, ) -> None: """Test try_function error translations using number entity.""" reolink_connect.volume.return_value = 80 @@ -104,7 +118,7 @@ async def test_try_function( entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" reolink_connect.set_volume.side_effect = side_effect - with pytest.raises(expected): + with pytest.raises(expected.__class__) as err: await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -112,4 +126,39 @@ async def test_try_function( blocking=True, ) + assert err.value.translation_key == expected.translation_key + reolink_connect.set_volume.reset_mock(side_effect=True) + + +@pytest.mark.parametrize( + ("identifiers"), + [ + ({(DOMAIN, DEV_ID_NVR), (DOMAIN, DEV_ID_STANDALONE_CAM)}), + ({(DOMAIN, DEV_ID_STANDALONE_CAM), (DOMAIN, DEV_ID_NVR)}), + ], +) +async def test_get_device_uid_and_ch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + device_registry: dr.DeviceRegistry, + identifiers: set[tuple[str, str]], +) -> None: + """Test get_device_uid_and_ch with multiple identifiers.""" + reolink_connect.channels = [0] + + dev_entry = device_registry.async_get_or_create( + identifiers=identifiers, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) + + # setup CH 0 and host entities/device + 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() + + result = get_device_uid_and_ch(dev_entry, config_entry.runtime_data.host) + # always get the uid and channel form the DEV_ID_NVR since is_nvr = True + assert result == ([TEST_UID, TEST_UID_CAM], 0, False) diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py index c994cc59c5d..3521de072b6 100644 --- a/tests/components/reolink/test_views.py +++ b/tests/components/reolink/test_views.py @@ -46,8 +46,12 @@ def get_mock_session( mock_response = Mock() mock_response.content_length = content_length + mock_response.headers = {} + mock_response.status = 200 + mock_response.reason = "OK" mock_response.content_type = content_type mock_response.content.iter_chunked = Mock(return_value=content) + mock_response.text = AsyncMock(return_value="test") mock_session = Mock() mock_session.get = AsyncMock(return_value=mock_response) @@ -178,16 +182,18 @@ async def test_playback_proxy_timeout( assert response.status == 200 +@pytest.mark.parametrize(("content_type"), [("video/x-flv"), ("text/html")]) async def test_playback_wrong_content( hass: HomeAssistant, reolink_connect: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, + content_type: str, ) -> 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") + mock_session = get_mock_session(content_type=content_type) with patch( "homeassistant.components.reolink.views.async_get_clientsession", diff --git a/tests/components/repairs/__init__.py b/tests/components/repairs/__init__.py index e787d657e5c..7d5e4a43cd8 100644 --- a/tests/components/repairs/__init__.py +++ b/tests/components/repairs/__init__.py @@ -42,20 +42,20 @@ async def get_repairs( async def start_repair_fix_flow( - client: TestClient, handler: str, issue_id: int + client: TestClient, handler: str, issue_id: str ) -> dict[str, Any]: """Start a flow from an issue.""" url = RepairsFlowIndexView.url resp = await client.post(url, json={"handler": handler, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK + assert resp.status == HTTPStatus.OK, f"Error: {resp.status}, {await resp.text()}" return await resp.json() async def process_repair_fix_flow( - client: TestClient, flow_id: int, json: dict[str, Any] | None = None + client: TestClient, flow_id: str, json: dict[str, Any] | None = None ) -> dict[str, Any]: """Return the repairs list of issues.""" url = RepairsFlowResourceView.url.format(flow_id=flow_id) resp = await client.post(url, json=json) - assert resp.status == HTTPStatus.OK + assert resp.status == HTTPStatus.OK, f"Error: {resp.status}, {await resp.text()}" return await resp.json() diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 65ec6bf5c05..6992794d596 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -595,3 +595,53 @@ async def test_availability_in_config(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.rest_binary_sensor") assert state.state == STATE_UNAVAILABLE + + +@respx.mock +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for binary_sensor.block_template: 'x' is undefined" + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="51") + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "binary_sensor": [ + { + "unique_id": "block_template", + "name": "block_template", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("binary_sensor.block_template") + assert state + assert state.state == STATE_UNAVAILABLE + + respx.clear() + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="50") + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["binary_sensor.block_template"]}, + blocking=True, + ) + await hass.async_block_till_done() + + assert error in caplog.text diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index d5fc5eca55c..81440125b12 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -1035,22 +1035,211 @@ async def test_entity_config( @respx.mock async def test_availability_in_config(hass: HomeAssistant) -> None: """Test entity configuration.""" - - config = { - SENSOR_DOMAIN: { - # REST configuration - "platform": DOMAIN, - "method": "GET", - "resource": "http://localhost", - # Entity configuration - "availability": "{{value==1}}", - "name": "{{'REST' + ' ' + 'Sensor'}}", + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={ + "state": "okay", + "available": True, + "name": "rest_sensor", + "icon": "mdi:foo", + "picture": "foo.jpg", }, - } + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "somethingunique", + "availability": "{{ value_json.available }}", + "value_template": "{{ value_json.state }}", + "name": "{{ value_json.name if value_json is defined else 'rest_sensor' }}", + "icon": "{{ value_json.icon }}", + "picture": "{{ value_json.picture }}", + } + ], + } + ] + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() - respx.get("http://localhost").respond(status_code=HTTPStatus.OK, text="123") - assert await async_setup_component(hass, SENSOR_DOMAIN, config) + state = hass.states.get("sensor.rest_sensor") + assert state.state == "okay" + assert state.attributes["friendly_name"] == "rest_sensor" + assert state.attributes["icon"] == "mdi:foo" + assert state.attributes["entity_picture"] == "foo.jpg" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={ + "state": "okay", + "available": False, + "name": "unavailable", + "icon": "mdi:unavailable", + "picture": "unavailable.jpg", + }, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.rest_sensor"]}, + blocking=True, + ) await hass.async_block_till_done() state = hass.states.get("sensor.rest_sensor") assert state.state == STATE_UNAVAILABLE + assert "friendly_name" not in state.attributes + assert "icon" not in state.attributes + assert "entity_picture" not in state.attributes + + +@respx.mock +async def test_json_response_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test availability with syntax error.""" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"heartbeatList": {"1": [{"status": 1, "ping": 21.4}]}}, + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "complex_json", + "name": "complex_json", + "value_template": '{% set v = value_json.heartbeatList["1"][-1] %}{{ v.ping }}', + "availability": "{{ what_the_heck == 2 }}", + } + ], + } + ] + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + + state = hass.states.get("sensor.complex_json") + assert state.state == "21.4" + + assert ( + "Error rendering availability template for sensor.complex_json: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +@respx.mock +async def test_json_response_with_availability(hass: HomeAssistant) -> None: + """Test availability with complex json.""" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"heartbeatList": {"1": [{"status": 1, "ping": 21.4}]}}, + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "complex_json", + "name": "complex_json", + "value_template": '{% set v = value_json.heartbeatList["1"][-1] %}{{ v.ping }}', + "availability": '{% set v = value_json.heartbeatList["1"][-1] %}{{ v.status == 1 and is_number(v.ping) }}', + "unit_of_measurement": "ms", + "state_class": "measurement", + } + ], + } + ] + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + + state = hass.states.get("sensor.complex_json") + assert state.state == "21.4" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"heartbeatList": {"1": [{"status": 0, "ping": None}]}}, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.complex_json"]}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.complex_json") + assert state.state == STATE_UNAVAILABLE + + +@respx.mock +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.block_template: 'x' is undefined" + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="51") + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "block_template", + "name": "block_template", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + "unit_of_measurement": "ms", + "state_class": "measurement", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("sensor.block_template") + assert state + assert state.state == STATE_UNAVAILABLE + + respx.clear() + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="50") + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.block_template"]}, + blocking=True, + ) + await hass.async_block_till_done() + + assert error in caplog.text diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index e0fc36d053e..2a69f5a477a 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -37,6 +37,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -482,3 +483,122 @@ async def test_entity_config( ATTR_FRIENDLY_NAME: "REST Switch", ATTR_ICON: "mdi:one_two_three", } + + +@respx.mock +async def test_availability( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test entity configuration.""" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"beer": 1}, + ) + assert await async_setup_component( + hass, + SWITCH_DOMAIN, + { + SWITCH_DOMAIN: { + # REST configuration + CONF_PLATFORM: DOMAIN, + CONF_METHOD: "POST", + CONF_RESOURCE: "http://localhost", + # Entity configuration + CONF_NAME: "{{'REST' + ' ' + 'Switch'}}", + "is_on_template": "{{ value_json.beer == 1 }}", + "availability": "{{ value_json.beer is defined }}", + CONF_ICON: "mdi:{{ value_json.beer }}", + CONF_PICTURE: "{{ value_json.beer }}.png", + }, + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + state = hass.states.get("switch.rest_switch") + assert state + assert state.state == STATE_ON + assert state.attributes["icon"] == "mdi:1" + assert state.attributes["entity_picture"] == "1.png" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"x": 1}, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.rest_switch"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.rest_switch") + assert state + assert state.state == STATE_UNAVAILABLE + assert "icon" not in state.attributes + assert "entity_picture" not in state.attributes + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"beer": 0}, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.rest_switch"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.rest_switch") + assert state + assert state.state == STATE_OFF + assert state.attributes["icon"] == "mdi:0" + assert state.attributes["entity_picture"] == "0.png" + + +@respx.mock +async def test_availability_blocks_is_on_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks is_on_template from rendering.""" + error = "Error parsing value for switch.block_template: 'x' is undefined" + respx.get(RESOURCE).respond(status_code=HTTPStatus.OK, content="51") + config = { + SWITCH_DOMAIN: { + # REST configuration + CONF_PLATFORM: DOMAIN, + CONF_METHOD: "POST", + CONF_RESOURCE: "http://localhost", + # Entity configuration + CONF_NAME: "block_template", + "is_on_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + + assert await async_setup_component(hass, SWITCH_DOMAIN, config) + await hass.async_block_till_done() + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("switch.block_template") + assert state + assert state.state == STATE_UNAVAILABLE + + respx.clear() + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="50") + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.block_template"]}, + blocking=True, + ) + await hass.async_block_till_done() + + assert error in caplog.text diff --git a/tests/components/rflink/test_init.py b/tests/components/rflink/test_init.py index 1caae302748..d702cd44718 100644 --- a/tests/components/rflink/test_init.py +++ b/tests/components/rflink/test_init.py @@ -1,5 +1,6 @@ """Common functions for RFLink component tests and generic platform tests.""" +import logging from unittest.mock import Mock import pytest @@ -21,6 +22,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, + EVENT_LOGGING_CHANGED, SERVICE_STOP_COVER, SERVICE_TURN_OFF, ) @@ -556,3 +558,30 @@ async def test_unique_id( temperature_entry = entity_registry.async_get("sensor.temperature_device") assert temperature_entry assert temperature_entry.unique_id == "my_temperature_device_unique_id" + + +async def test_enable_debug_logs( + hass: HomeAssistant, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that changing debug level enables RFDEBUG.""" + + domain = RFLINK_DOMAIN + config = {RFLINK_DOMAIN: {CONF_HOST: "10.10.0.1", CONF_PORT: 1234}} + + # setup mocking rflink module + _, mock_create, _, _ = await mock_rflink(hass, config, domain, monkeypatch) + + logging.getLogger("rflink").setLevel(logging.DEBUG) + hass.bus.async_fire(EVENT_LOGGING_CHANGED) + await hass.async_block_till_done() + + assert "RFDEBUG enabled" in caplog.text + assert "RFDEBUG disabled" not in caplog.text + + logging.getLogger("rflink").setLevel(logging.INFO) + hass.bus.async_fire(EVENT_LOGGING_CHANGED) + await hass.async_block_till_done() + + assert "RFDEBUG disabled" in caplog.text diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index 45683bba903..bfdf7d8a9da 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -1,6 +1,6 @@ """Test Ridwell diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/ring/snapshots/test_camera.ambr b/tests/components/ring/snapshots/test_camera.ambr index 8c3b8a083b0..0e5efd68753 100644 --- a/tests/components/ring/snapshots/test_camera.ambr +++ b/tests/components/ring/snapshots/test_camera.ambr @@ -94,7 +94,6 @@ 'attribution': 'Data provided by Ring.com', 'entity_picture': '/api/camera_proxy/camera.front_door_live_view?token=1caab5c3b3', 'friendly_name': 'Front Door Live view', - 'frontend_stream_type': , 'last_video_id': None, 'supported_features': , 'video_url': None, @@ -201,7 +200,6 @@ 'attribution': 'Data provided by Ring.com', 'entity_picture': '/api/camera_proxy/camera.front_live_view?token=1caab5c3b3', 'friendly_name': 'Front Live view', - 'frontend_stream_type': , 'last_video_id': None, 'supported_features': , 'video_url': None, @@ -309,7 +307,6 @@ 'attribution': 'Data provided by Ring.com', 'entity_picture': '/api/camera_proxy/camera.internal_live_view?token=1caab5c3b3', 'friendly_name': 'Internal Live view', - 'frontend_stream_type': , 'last_video_id': None, 'supported_features': , 'video_url': None, diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 9b3a6633c62..f95e4795d1d 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -3,10 +3,9 @@ from collections.abc import Generator from copy import deepcopy import pathlib -import shutil +import tempfile from typing import Any from unittest.mock import Mock, patch -import uuid import pytest from roborock import RoborockCategory, RoomMapping @@ -19,7 +18,6 @@ from homeassistant.components.roborock.const import ( CONF_USER_DATA, DOMAIN, ) -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant @@ -30,6 +28,7 @@ from .mock_data import ( MULTI_MAP_LIST, NETWORK_INFO, PROP, + ROBOROCK_RRUID, SCENES, USER_DATA, USER_EMAIL, @@ -73,13 +72,16 @@ def bypass_api_client_fixture() -> None: """Skip calls to the API client.""" with ( patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", return_value=HOME_DATA, ), patch( "homeassistant.components.roborock.RoborockApiClient.get_scenes", return_value=SCENES, ), + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.load_multi_map" + ), ): yield @@ -110,7 +112,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: return_value=MULTI_MAP_LIST, ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=MAP_DATA, ), patch( @@ -127,7 +129,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: "roborock.version_1_apis.AttributeCache.value", ), patch( - "homeassistant.components.roborock.image.MAP_SLEEP", + "homeassistant.components.roborock.coordinator.MAP_SLEEP", 0, ), patch( @@ -181,24 +183,34 @@ def bypass_api_fixture_v1_only(bypass_api_fixture) -> None: home_data_copy = deepcopy(HOME_DATA) home_data_copy.received_devices = [] with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", return_value=home_data_copy, ): yield +@pytest.fixture(name="config_entry_data") +def config_entry_data_fixture() -> dict[str, Any]: + """Fixture that returns the unique id for the config entry.""" + return { + CONF_USERNAME: USER_EMAIL, + CONF_USER_DATA: USER_DATA.as_dict(), + CONF_BASE_URL: BASE_URL, + } + + @pytest.fixture -def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: +def mock_roborock_entry( + hass: HomeAssistant, config_entry_data: dict[str, Any] +) -> MockConfigEntry: """Create a Roborock Entry that has not been setup.""" mock_entry = MockConfigEntry( domain=DOMAIN, title=USER_EMAIL, - data={ - CONF_USERNAME: USER_EMAIL, - CONF_USER_DATA: USER_DATA.as_dict(), - CONF_BASE_URL: BASE_URL, - }, - unique_id=USER_EMAIL, + data=config_entry_data, + unique_id=ROBOROCK_RRUID, + version=1, + minor_version=2, ) mock_entry.add_to_hass(hass) return mock_entry @@ -210,36 +222,40 @@ def mock_platforms() -> list[Platform]: return [] +@pytest.fixture(autouse=True) +async def mock_patforms_fixture( + hass: HomeAssistant, + platforms: list[Platform], +) -> Generator[None]: + """Set up the Roborock platform.""" + with patch("homeassistant.components.roborock.PLATFORMS", platforms): + yield + + @pytest.fixture async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, - cleanup_map_storage: pathlib.Path, - platforms: list[Platform], ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" - with patch("homeassistant.components.roborock.PLATFORMS", platforms): - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) - await hass.async_block_till_done() - yield mock_roborock_entry + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + return mock_roborock_entry -@pytest.fixture -async def cleanup_map_storage( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +@pytest.fixture(autouse=True, name="storage_path") +async def storage_path_fixture( + hass: HomeAssistant, ) -> Generator[pathlib.Path]: """Test cleanup, remove any map storage persisted during the test.""" - tmp_path = str(uuid.uuid4()) - with patch( - "homeassistant.components.roborock.roborock_storage.STORAGE_PATH", new=tmp_path - ): - storage_path = ( - pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id - ) - yield storage_path - # We need to first unload the config entry because unloading it will - # persist any unsaved maps to storage. - if mock_roborock_entry.state is ConfigEntryState.LOADED: - await hass.config_entries.async_unload(mock_roborock_entry.entry_id) - shutil.rmtree(str(storage_path), ignore_errors=True) + with tempfile.TemporaryDirectory() as tmp_path: + + def get_storage_path(_: HomeAssistant, entry_id: str) -> pathlib.Path: + return pathlib.Path(tmp_path) / entry_id + + with patch( + "homeassistant.components.roborock.roborock_storage._storage_path_prefix", + new=get_storage_path, + ): + yield pathlib.Path(tmp_path) diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 59c54892687..cf4f167ef7f 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -28,6 +28,7 @@ USER_EMAIL = "user@domain.com" BASE_URL = "https://usiot.roborock.com" +ROBOROCK_RRUID = "roboborock-userid-abc-123" USER_DATA = UserData.from_dict( { "tuyaname": "abc123", @@ -35,7 +36,7 @@ USER_DATA = UserData.from_dict( "uid": 123456, "tokentype": "", "token": "abc123", - "rruid": "abc123", + "rruid": ROBOROCK_RRUID, "region": "us", "countrycode": "1", "country": "US", @@ -1120,7 +1121,10 @@ PROP = DeviceProp( ) NETWORK_INFO = NetworkInfo( - ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc", bssid="bssid", rssi=90 + ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc:cc", bssid="bssid", rssi=90 +) +NETWORK_INFO_2 = NetworkInfo( + ip="123.232.12.2", ssid="wifi", mac="ac:cc:cc:cc:cd:cc", bssid="bssid", rssi=90 ) MULTI_MAP_LIST = MultiMapsList.from_dict( @@ -1151,6 +1155,7 @@ MAP_DATA = MapData(0, 0) MAP_DATA.image = ImageData( 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (1, 1)), lambda p: p ) +MAP_DATA.vacuum_room = 17 SCENES = [ diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index 0e4b338f469..6a234d735e5 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -18,7 +18,7 @@ async def test_binary_sensors( hass: HomeAssistant, setup_entry: MockConfigEntry ) -> None: """Test binary sensors and check test values are correctly set.""" - assert len(hass.states.async_all("binary_sensor")) == 8 + assert len(hass.states.async_all("binary_sensor")) == 10 assert hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached").state == "on" assert ( hass.states.get("binary_sensor.roborock_s7_maxv_water_box_attached").state @@ -28,3 +28,4 @@ async def test_binary_sensors( hass.states.get("binary_sensor.roborock_s7_maxv_water_shortage").state == "off" ) assert hass.states.get("binary_sensor.roborock_s7_maxv_cleaning").state == "off" + assert hass.states.get("binary_sensor.roborock_s7_maxv_charging").state == "on" diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 0a7efe83513..77c5d4d7cb0 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -1,9 +1,10 @@ """Test Roborock Button platform.""" -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest import roborock +from roborock import RoborockException from homeassistant.components.button import SERVICE_PRESS from homeassistant.const import Platform @@ -13,6 +14,18 @@ from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def bypass_api_client_get_scenes_fixture(bypass_api_fixture) -> None: + """Fixture to raise when getting scenes.""" + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.get_scenes", + side_effect=RoborockException(), + ), + ): + yield + + @pytest.fixture def platforms() -> list[Platform]: """Fixture to set platforms used in the test.""" @@ -84,3 +97,85 @@ async def test_update_failure( ) assert mock_send_message.assert_called_once assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("button.roborock_s7_maxv_sc1"), + ("button.roborock_s7_maxv_sc2"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_get_button_routines_failure( + hass: HomeAssistant, + bypass_api_client_get_scenes_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test that if routine retrieval fails, no entity is being created.""" + # Ensure that the entity does not exist + assert hass.states.get(entity_id) is None + + +@pytest.mark.parametrize( + ("entity_id", "routine_id"), + [ + ("button.roborock_s7_maxv_sc1", 12), + ("button.roborock_s7_maxv_sc2", 24), + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_press_routine_button_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + routine_id: int, +) -> None: + """Test pressing the button entities.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.execute_scene" + ) as mock_execute_scene: + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + mock_execute_scene.assert_called_once_with(ANY, routine_id) + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.parametrize( + ("entity_id", "routine_id"), + [ + ("button.roborock_s7_maxv_sc1", 12), + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_press_routine_button_failure( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + routine_id: int, +) -> None: + """Test failure while pressing the button entity.""" + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.execute_scene", + side_effect=RoborockException, + ) as mock_execute_scene, + pytest.raises(HomeAssistantError, match="Error while calling execute_scene"), + ): + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + mock_execute_scene.assert_called_once_with(ANY, routine_id) + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 13bc23e6e2b..7958f17a696 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -16,15 +16,22 @@ from vacuum_map_parser_base.config.drawable import Drawable from homeassistant import config_entries from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN, DRAWABLES -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL +from .mock_data import MOCK_CONFIG, NETWORK_INFO, ROBOROCK_RRUID, USER_DATA, USER_EMAIL from tests.common import MockConfigEntry +@pytest.fixture +def cleanup_map_storage(): + """Override the map storage fixture as it is not relevant here.""" + return + + async def test_config_flow_success( hass: HomeAssistant, bypass_api_fixture, @@ -57,6 +64,7 @@ async def test_config_flow_success( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -121,6 +129,7 @@ async def test_config_flow_failures_request_code( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -182,6 +191,7 @@ async def test_config_flow_failures_code_login( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -189,25 +199,31 @@ async def test_config_flow_failures_code_login( async def test_options_flow_drawables( - hass: HomeAssistant, setup_entry: MockConfigEntry + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> None: """Test that the options flow works.""" - result = await hass.config_entries.options.async_init(setup_entry.entry_id) - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == DRAWABLES - with patch( - "homeassistant.components.roborock.async_setup_entry", return_value=True - ) as mock_setup: - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={Drawable.PREDICTED_PATH: True}, - ) + with patch("homeassistant.components.roborock.roborock_storage"): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert setup_entry.options[DRAWABLES][Drawable.PREDICTED_PATH] is True - assert len(mock_setup.mock_calls) == 1 + result = await hass.config_entries.options.async_init( + mock_roborock_entry.entry_id + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == DRAWABLES + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={Drawable.PREDICTED_PATH: True}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert mock_roborock_entry.options[DRAWABLES][Drawable.PREDICTED_PATH] is True + assert len(mock_setup.mock_calls) == 1 async def test_reauth_flow( @@ -243,6 +259,7 @@ async def test_reauth_flow( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + assert mock_roborock_entry.unique_id == ROBOROCK_RRUID assert mock_roborock_entry.data["user_data"]["rriot"]["s"] == "new_password_hash" @@ -251,7 +268,8 @@ async def test_account_already_configured( bypass_api_fixture, mock_roborock_entry: MockConfigEntry, ) -> None: - """Handle the config flow and make sure it succeeds.""" + """Ensure the same account cannot be setup twice.""" + assert mock_roborock_entry.unique_id == ROBOROCK_RRUID with patch( "homeassistant.components.roborock.async_setup_entry", return_value=True ): @@ -267,5 +285,121 @@ async def test_account_already_configured( result["flow_id"], {CONF_USERNAME: USER_EMAIL} ) + assert result["step_id"] == "code" + assert result["type"] is FlowResultType.FORM + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=USER_DATA, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured_account" + + +async def test_reauth_wrong_account( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Ensure that reauthentication must use the same account.""" + + # Start reauth + result = mock_roborock_entry.async_start_reauth(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ): + 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["step_id"] == "code" + assert result["type"] is FlowResultType.FORM + new_user_data = deepcopy(USER_DATA) + new_user_data.rruid = "new_rruid" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=new_user_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +async def test_discovery_not_setup( + hass: HomeAssistant, + bypass_api_fixture, +) -> 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_DHCP}, + data=DhcpServiceInfo( + ip=NETWORK_INFO.ip, + macaddress=NETWORK_INFO.mac.replace(":", ""), + hostname="roborock-vacuum-a72", + ), + ) + 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.FORM + assert result["step_id"] == "code" + assert result["errors"] == {} + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=USER_DATA, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["context"]["unique_id"] == ROBOROCK_RRUID + assert result["title"] == USER_EMAIL + assert result["data"] == MOCK_CONFIG + assert result["result"] + + +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_discovery_already_setup( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Handle aborting if the device is already setup.""" + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip=NETWORK_INFO.ip, + macaddress=NETWORK_INFO.mac.replace(":", ""), + hostname="roborock-vacuum-a72", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py new file mode 100644 index 00000000000..dec4e0a62d4 --- /dev/null +++ b/tests/components/roborock/test_coordinator.py @@ -0,0 +1,114 @@ +"""Test Roborock Coordinator specific logic.""" + +import copy +from datetime import timedelta +from unittest.mock import patch + +import pytest +from roborock.exceptions import RoborockException + +from homeassistant.components.roborock.const import ( + V1_CLOUD_IN_CLEANING_INTERVAL, + V1_CLOUD_NOT_CLEANING_INTERVAL, + V1_LOCAL_IN_CLEANING_INTERVAL, + V1_LOCAL_NOT_CLEANING_INTERVAL, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .mock_data import PROP + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SENSOR] + + +@pytest.mark.parametrize( + ("interval", "in_cleaning"), + [ + (V1_CLOUD_IN_CLEANING_INTERVAL, 1), + (V1_CLOUD_NOT_CLEANING_INTERVAL, 0), + ], +) +async def test_dynamic_cloud_scan_interval( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, + interval: timedelta, + in_cleaning: int, +) -> None: + """Test dynamic scan interval.""" + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = in_cleaning + with ( + # Force the system to use the cloud api. + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.ping", + side_effect=RoborockException(), + ), + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_prop", + return_value=prop, + ), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + prop = copy.deepcopy(prop) + prop.status.battery = 20 + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_prop", + return_value=prop, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + interval - timedelta(seconds=5) + ) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + async_fire_time_changed(hass, dt_util.utcnow() + interval) + + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" + + +@pytest.mark.parametrize( + ("interval", "in_cleaning"), + [ + (V1_LOCAL_IN_CLEANING_INTERVAL, 1), + (V1_LOCAL_NOT_CLEANING_INTERVAL, 0), + ], +) +async def test_dynamic_local_scan_interval( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, + interval: timedelta, + in_cleaning: int, +) -> None: + """Test dynamic scan interval.""" + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = in_cleaning + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + prop = copy.deepcopy(prop) + prop.status.battery = 20 + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + interval - timedelta(seconds=5) + ) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + + async_fire_time_changed(hass, dt_util.utcnow() + interval) + + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index fd6c8b2796a..b7c811e0ce2 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -3,7 +3,6 @@ import copy from datetime import timedelta from http import HTTPStatus -import io from unittest.mock import patch from PIL import Image @@ -12,6 +11,7 @@ from roborock import RoborockException from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN +from homeassistant.components.roborock.const import V1_LOCAL_NOT_CLEANING_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -63,20 +63,26 @@ async def test_floorplan_image( return_value=prop, ), patch( - "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", - return_value=new_map_data, + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", + return_value=MAP_DATA, ) as parse_map, ): + # This should call parse_map twice as the both devices are in cleaning. async_fire_time_changed(hass, now) - await hass.async_block_till_done() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + resp = await client.get("/api/image_proxy/image.roborock_s7_2_upstairs") + assert resp.status == HTTPStatus.OK + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_downstairs") assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None - assert parse_map.call_count == 1 + + assert parse_map.call_count == 2 async def test_floorplan_image_failed_parse( @@ -92,10 +98,11 @@ async def test_floorplan_image_failed_parse( # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 + previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state # Update image, but get none for parse image. with ( patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), patch( @@ -103,45 +110,16 @@ async def test_floorplan_image_failed_parse( return_value=prop, ), patch( - "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ), ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert not resp.ok - - -async def test_load_stored_image( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - setup_entry: MockConfigEntry, -) -> None: - """Test that we correctly load an image from storage when it already exists.""" - img_byte_arr = io.BytesIO() - MAP_DATA.image.data.save(img_byte_arr, format="PNG") - img_bytes = img_byte_arr.getvalue() - - # Load the image on demand, which should queue it to be cached on disk - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert resp.status == HTTPStatus.OK - - with patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", - ) as parse_map: - # Reload the config entry so that the map is saved in storage and entities exist. - await hass.config_entries.async_reload(setup_entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - # Test that we can get the image and it correctly serialized and unserialized. - assert resp.status == HTTPStatus.OK - body = await resp.read() - assert body == img_bytes - - # Ensure that we never tried to update the map, and only used the cached image. - assert parse_map.call_count == 0 + # The map should load fine from the coordinator, but it should not update the + # last_updated timestamp. + assert resp.ok + assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state async def test_fail_to_save_image( @@ -181,9 +159,6 @@ async def test_fail_to_load_image( ) -> None: """Test that we gracefully handle failing to load an image.""" with ( - patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", - ) as parse_map, patch( "homeassistant.components.roborock.roborock_storage.Path.exists", return_value=True, @@ -192,13 +167,14 @@ async def test_fail_to_load_image( "homeassistant.components.roborock.roborock_storage.Path.read_bytes", side_effect=OSError, ) as read_bytes, + patch( + "homeassistant.components.roborock.coordinator.RoborockDataUpdateCoordinator.refresh_coordinator_map" + ), ): # Reload the config entry so that the map is saved in storage and entities exist. await hass.config_entries.async_reload(setup_entry.entry_id) await hass.async_block_till_done() assert read_bytes.call_count == 4 - # Ensure that we never updated the map manually since we couldn't load it. - assert parse_map.call_count == 0 assert "Unable to read map file" in caplog.text @@ -212,7 +188,7 @@ async def test_fail_parse_on_startup( map_data = copy.deepcopy(MAP_DATA) map_data.image = None with patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ): await async_setup_component(hass, DOMAIN, {}) @@ -258,9 +234,10 @@ async def test_fail_updating_image( prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 # Update image, but get none for parse image. + previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state with ( patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), patch( @@ -268,7 +245,8 @@ async def test_fail_updating_image( return_value=prop, ), patch( - "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ), patch( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", @@ -277,4 +255,91 @@ async def test_fail_updating_image( ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert not resp.ok + # The map should load fine from the coordinator, but it should not update the + # last_updated timestamp. + assert resp.ok + assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state + + +async def test_index_error_map( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that we handle failing getting the image after it has already been setup with a indexerror.""" + client = await hass_client() + now = dt_util.utcnow() + timedelta(seconds=91) + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = 1 + previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state + # Update image, but get IndexError for image. + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", + side_effect=IndexError, + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, + ), + ): + async_fire_time_changed(hass, now) + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # The map should load fine from the coordinator, but it should not update the + # last_updated timestamp. + assert resp.ok + assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state + + +async def test_map_status_change( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test floor plan map image is correctly updated on status change.""" + assert len(hass.states.async_all("image")) == 4 + + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + old_body = await resp.read() + assert old_body[0:4] == b"\x89PNG" + + # Call a second time. This interval does not directly trigger a map update, but does + # trigger a status update which detects the state has changed and uddates the map + now = dt_util.utcnow() + V1_LOCAL_NOT_CLEANING_INTERVAL + + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.state_name = "testing" + new_map_data = copy.deepcopy(MAP_DATA) + new_map_data.image = ImageData( + 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (2, 2)), lambda p: p + ) + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", + return_value=new_map_data, + ), + ): + async_fire_time_changed(hass, now) + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body is not None + assert body != old_body diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 904a3af89d6..01a8aa26de7 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -3,6 +3,7 @@ from copy import deepcopy from http import HTTPStatus import pathlib +from typing import Any from unittest.mock import patch import pytest @@ -17,9 +18,16 @@ from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component -from .mock_data import HOME_DATA +from .mock_data import ( + HOME_DATA, + NETWORK_INFO, + NETWORK_INFO_2, + ROBOROCK_RRUID, + USER_EMAIL, +) from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -46,7 +54,7 @@ async def test_config_entry_not_ready( """Test that when coordinator update fails, entry retries.""" with ( patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", @@ -63,7 +71,7 @@ async def test_config_entry_not_ready_home_data( """Test that when we fail to get home data, entry retries.""" with ( patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", side_effect=RoborockException(), ), patch( @@ -156,7 +164,7 @@ async def test_reauth_started( ) -> None: """Test reauth flow started.""" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", side_effect=RoborockInvalidCredentials(), ): await async_setup_component(hass, DOMAIN, {}) @@ -173,7 +181,7 @@ async def test_remove_from_hass( bypass_api_fixture, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, - cleanup_map_storage: pathlib.Path, + storage_path: pathlib.Path, ) -> None: """Test that removing from hass removes any existing images.""" @@ -183,17 +191,18 @@ async def test_remove_from_hass( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK - assert not cleanup_map_storage.exists() + config_entry_storage = storage_path / setup_entry.entry_id + assert not config_entry_storage.exists() # Flush to disk await hass.config_entries.async_unload(setup_entry.entry_id) - assert cleanup_map_storage.exists() - paths = list(cleanup_map_storage.walk()) - assert len(paths) == 3 # One map image and two directories + assert config_entry_storage.exists() + paths = list(config_entry_storage.walk()) + assert len(paths) == 4 # Two map image and two directories await hass.config_entries.async_remove(setup_entry.entry_id) # After removal, directories should be empty. - assert not cleanup_map_storage.exists() + assert not config_entry_storage.exists() @pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) @@ -201,7 +210,7 @@ async def test_oserror_remove_image( hass: HomeAssistant, bypass_api_fixture, setup_entry: MockConfigEntry, - cleanup_map_storage: pathlib.Path, + storage_path: pathlib.Path, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, ) -> None: @@ -214,12 +223,13 @@ async def test_oserror_remove_image( assert resp.status == HTTPStatus.OK # Image content is saved when unloading - assert not cleanup_map_storage.exists() + config_entry_storage = storage_path / setup_entry.entry_id + assert not config_entry_storage.exists() await hass.config_entries.async_unload(setup_entry.entry_id) - assert cleanup_map_storage.exists() - paths = list(cleanup_map_storage.walk()) - assert len(paths) == 3 # One map image and two directories + assert config_entry_storage.exists() + paths = list(config_entry_storage.walk()) + assert len(paths) == 4 # Two map image and two directories with patch( "homeassistant.components.roborock.roborock_storage.shutil.rmtree", @@ -239,10 +249,10 @@ async def test_not_supported_protocol( home_data_copy = deepcopy(HOME_DATA) home_data_copy.received_devices[0].pv = "random" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", return_value=home_data_copy, ): - await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() assert "because its protocol version random" in caplog.text @@ -257,7 +267,7 @@ async def test_not_supported_a01_device( home_data_copy = deepcopy(HOME_DATA) home_data_copy.products[2].category = "random" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", return_value=home_data_copy, ): await async_setup_component(hass, DOMAIN, {}) @@ -272,7 +282,7 @@ async def test_invalid_user_agreement( ) -> 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", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", side_effect=RoborockInvalidUserAgreement(), ): await hass.config_entries.async_setup(mock_roborock_entry.entry_id) @@ -289,9 +299,104 @@ async def test_no_user_agreement( ) -> None: """Test that we fail setting up if the user has no agreement.""" with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", 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" + + +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_stale_device( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, + device_registry: DeviceRegistry, +) -> None: + """Test that we remove a device if it no longer is given by home_data.""" + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.LOADED + existing_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert len(existing_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo + hd = deepcopy(HOME_DATA) + hd.devices = [hd.devices[0]] + + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v3", + return_value=hd, + ), + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ), + ): + await hass.config_entries.async_reload(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + new_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert ( + len(new_devices) == 4 + ) # 2 for the one remaining robot. 1 for both the A01s which are shared and + # therefore not deleted. + + +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_no_stale_device( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, + device_registry: DeviceRegistry, +) -> None: + """Test that we don't remove a device if fails to setup.""" + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.LOADED + existing_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert len(existing_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo + + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, RoborockException], + ): + await hass.config_entries.async_reload(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + new_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert len(new_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo + + +async def test_migrate_config_entry_unique_id( + hass: HomeAssistant, + bypass_api_fixture, + config_entry_data: dict[str, Any], +) -> None: + """Test migrating the config entry unique id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=USER_EMAIL, + data=config_entry_data, + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.unique_id == ROBOROCK_RRUID diff --git a/tests/components/roborock/test_scene.py b/tests/components/roborock/test_scene.py deleted file mode 100644 index 15707784feb..00000000000 --- a/tests/components/roborock/test_scene.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Test Roborock Scene platform.""" - -from unittest.mock import ANY, patch - -import pytest -from roborock import RoborockException - -from homeassistant.const import SERVICE_TURN_ON, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError - -from tests.common import MockConfigEntry - - -@pytest.fixture -def bypass_api_client_get_scenes_fixture(bypass_api_fixture) -> None: - """Fixture to raise when getting scenes.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.get_scenes", - side_effect=RoborockException(), - ), - ): - yield - - -@pytest.mark.parametrize( - ("entity_id"), - [ - ("scene.roborock_s7_maxv_sc1"), - ("scene.roborock_s7_maxv_sc2"), - ], -) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_get_scenes_failure( - hass: HomeAssistant, - bypass_api_client_get_scenes_fixture, - setup_entry: MockConfigEntry, - entity_id: str, -) -> None: - """Test that if scene retrieval fails, no entity is being created.""" - # Ensure that the entity does not exist - assert hass.states.get(entity_id) is None - - -@pytest.fixture -def platforms() -> list[Platform]: - """Fixture to set platforms used in the test.""" - return [Platform.SCENE] - - -@pytest.mark.parametrize( - ("entity_id", "scene_id"), - [ - ("scene.roborock_s7_maxv_sc1", 12), - ("scene.roborock_s7_maxv_sc2", 24), - ], -) -@pytest.mark.freeze_time("2023-10-30 08:50:00") -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_execute_success( - hass: HomeAssistant, - bypass_api_fixture, - setup_entry: MockConfigEntry, - entity_id: str, - scene_id: int, -) -> None: - """Test activating the scene entities.""" - with patch( - "homeassistant.components.roborock.RoborockApiClient.execute_scene" - ) as mock_execute_scene: - await hass.services.async_call( - "scene", - SERVICE_TURN_ON, - blocking=True, - target={"entity_id": entity_id}, - ) - mock_execute_scene.assert_called_once_with(ANY, scene_id) - assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" - - -@pytest.mark.parametrize( - ("entity_id", "scene_id"), - [ - ("scene.roborock_s7_maxv_sc1", 12), - ], -) -@pytest.mark.freeze_time("2023-10-30 08:50:00") -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_execute_failure( - hass: HomeAssistant, - bypass_api_fixture, - setup_entry: MockConfigEntry, - entity_id: str, - scene_id: int, -) -> None: - """Test failure while activating the scene entity.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.execute_scene", - side_effect=RoborockException, - ) as mock_execute_scene, - pytest.raises(HomeAssistantError, match="Error while calling execute_scene"), - ): - await hass.services.async_call( - "scene", - SERVICE_TURN_ON, - blocking=True, - target={"entity_id": entity_id}, - ) - mock_execute_scene.assert_called_once_with(ANY, scene_id) - assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index e33d3aa78d5..719b398de94 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -29,7 +29,7 @@ def platforms() -> list[Platform]: 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")) == 40 + assert len(hass.states.async_all("sensor")) == 42 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -53,7 +53,7 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_cleaning_area").state == "21.0" 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_dock_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 @@ -63,6 +63,10 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state == "2023-01-01T03:43:58+00:00" ) + assert ( + hass.states.get("sensor.roborock_s7_maxv_current_room").state + == "Example room 2" + ) assert hass.states.get("sensor.dyad_pro_status").state == "drying" assert hass.states.get("sensor.dyad_pro_battery").state == "100" assert hass.states.get("sensor.dyad_pro_filter_time_left").state == "111" diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index e2df9a3498f..120c4fc4860 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -22,8 +22,8 @@ def platforms() -> list[Platform]: @pytest.mark.parametrize( ("entity_id"), [ - ("switch.roborock_s7_maxv_child_lock"), - ("switch.roborock_s7_maxv_status_indicator_light"), + ("switch.roborock_s7_maxv_dock_child_lock"), + ("switch.roborock_s7_maxv_dock_status_indicator_light"), ("switch.roborock_s7_maxv_do_not_disturb"), ], ) @@ -59,8 +59,8 @@ async def test_update_success( @pytest.mark.parametrize( ("entity_id", "service"), [ - ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_ON), - ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_OFF), + ("switch.roborock_s7_maxv_dock_status_indicator_light", SERVICE_TURN_ON), + ("switch.roborock_s7_maxv_dock_status_indicator_light", SERVICE_TURN_OFF), ], ) @pytest.mark.parametrize( diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index d9d4340ec83..5d6e7a599bd 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -117,6 +117,30 @@ async def test_commands( assert mock_send_command.call_args[0][1] == called_params +async def test_cloud_command( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test sending commands to the vacuum.""" + + vacuum = hass.states.get(ENTITY_ID) + assert vacuum + + data = {ATTR_ENTITY_ID: ENTITY_ID, "command": "get_map_v1"} + with patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.send_command" + ) as mock_send_command: + await hass.services.async_call( + Platform.VACUUM, + SERVICE_SEND_COMMAND, + data, + blocking=True, + ) + assert mock_send_command.call_count == 1 + assert mock_send_command.call_args[0][0] == RoborockCommand.GET_MAP_V1 + + @pytest.mark.parametrize( ("in_cleaning_int", "expected_command"), [ @@ -237,7 +261,7 @@ async def test_get_current_position( return_value=b"", ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), ): @@ -267,7 +291,9 @@ async def test_get_current_position_no_map_data( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", return_value=None, ), - pytest.raises(HomeAssistantError, match="Failed to retrieve map data."), + pytest.raises( + HomeAssistantError, match="Something went wrong creating the map" + ), ): await hass.services.async_call( DOMAIN, @@ -292,7 +318,7 @@ async def test_get_current_position_no_robot_position( return_value=b"", ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), pytest.raises(HomeAssistantError, match="Robot position not found"), diff --git a/tests/components/roku/test_binary_sensor.py b/tests/components/roku/test_binary_sensor.py index ad27a857101..c3aec4f0968 100644 --- a/tests/components/roku/test_binary_sensor.py +++ b/tests/components/roku/test_binary_sensor.py @@ -50,7 +50,7 @@ async def test_roku_binary_sensors( assert entry.unique_id == f"{UPNP_SERIAL}_supports_ethernet" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_ON - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports ethernet" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports Ethernet" assert ATTR_DEVICE_CLASS not in state.attributes state = hass.states.get("binary_sensor.my_roku_3_supports_find_remote") @@ -125,7 +125,7 @@ async def test_rokutv_binary_sensors( assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_ON assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports ethernet' + state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports Ethernet' ) assert ATTR_DEVICE_CLASS not in state.attributes diff --git a/tests/components/roku/test_diagnostics.py b/tests/components/roku/test_diagnostics.py index 37e0d43a582..c352fa60b56 100644 --- a/tests/components/roku/test_diagnostics.py +++ b/tests/components/roku/test_diagnostics.py @@ -1,7 +1,7 @@ """Tests for the diagnostics data provided by the Roku integration.""" from rokuecp import Device as RokuDevice -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util diff --git a/tests/components/rova/test_init.py b/tests/components/rova/test_init.py index 2190e2f8ce3..5441a730bf6 100644 --- a/tests/components/rova/test_init.py +++ b/tests/components/rova/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock import pytest from requests import ConnectTimeout -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.rova import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/rova/test_sensor.py b/tests/components/rova/test_sensor.py index ae8b64363da..27a3c109ce3 100644 --- a/tests/components/rova/test_sensor.py +++ b/tests/components/rova/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/rss_feed_template/test_init.py b/tests/components/rss_feed_template/test_init.py index 802fbb2244b..3b708b577af 100644 --- a/tests/components/rss_feed_template/test_init.py +++ b/tests/components/rss_feed_template/test_init.py @@ -1,6 +1,5 @@ """The tests for the rss_feed_api component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus from aiohttp.test_utils import TestClient @@ -14,13 +13,11 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def mock_http_client( - event_loop: AbstractEventLoop, +async def mock_http_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Set up test fixture.""" - loop = event_loop config = { "rss_feed_template": { "testfeed": { @@ -35,8 +32,8 @@ def mock_http_client( } } - loop.run_until_complete(async_setup_component(hass, "rss_feed_template", config)) - return loop.run_until_complete(hass_client()) + await async_setup_component(hass, "rss_feed_template", config) + return await hass_client() async def test_get_nonexistant_feed(mock_http_client) -> None: diff --git a/tests/components/rtsp_to_webrtc/__init__.py b/tests/components/rtsp_to_webrtc/__init__.py deleted file mode 100644 index ee4206e357d..00000000000 --- a/tests/components/rtsp_to_webrtc/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the RTSPtoWebRTC integration.""" diff --git a/tests/components/rtsp_to_webrtc/conftest.py b/tests/components/rtsp_to_webrtc/conftest.py deleted file mode 100644 index 956825f6372..00000000000 --- a/tests/components/rtsp_to_webrtc/conftest.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Tests for RTSPtoWebRTC initialization.""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator, Awaitable, Callable -from typing import Any -from unittest.mock import patch - -import pytest -import rtsp_to_webrtc - -from homeassistant.components import camera -from homeassistant.components.rtsp_to_webrtc import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry - -STREAM_SOURCE = "rtsp://example.com" -SERVER_URL = "http://127.0.0.1:8083" - -CONFIG_ENTRY_DATA = {"server_url": SERVER_URL} - -# Typing helpers -type ComponentSetup = Callable[[], Awaitable[None]] -type AsyncYieldFixture[_T] = AsyncGenerator[_T] - - -@pytest.fixture(autouse=True) -async def webrtc_server() -> None: - """Patch client library to force usage of RTSPtoWebRTC server.""" - with patch( - "rtsp_to_webrtc.client.WebClient.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ResponseError(), - ): - yield - - -@pytest.fixture -async def mock_camera(hass: HomeAssistant) -> AsyncGenerator[None]: - """Initialize a demo camera platform.""" - assert await async_setup_component( - hass, "camera", {camera.DOMAIN: {"platform": "demo"}} - ) - await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.demo.camera.Path.read_bytes", - return_value=b"Test", - ), - patch( - "homeassistant.components.camera.Camera.stream_source", - return_value=STREAM_SOURCE, - ), - ): - yield - - -@pytest.fixture -async def config_entry_data() -> dict[str, Any]: - """Fixture for MockConfigEntry data.""" - return CONFIG_ENTRY_DATA - - -@pytest.fixture -def config_entry_options() -> dict[str, Any] | None: - """Fixture to set initial config entry options.""" - return None - - -@pytest.fixture -async def config_entry( - config_entry_data: dict[str, Any], - config_entry_options: dict[str, Any] | None, -) -> MockConfigEntry: - """Fixture for MockConfigEntry.""" - return MockConfigEntry( - domain=DOMAIN, data=config_entry_data, options=config_entry_options - ) - - -@pytest.fixture -async def rtsp_to_webrtc_client() -> None: - """Fixture for mock rtsp_to_webrtc client.""" - with patch("rtsp_to_webrtc.client.Client.heartbeat"): - yield - - -@pytest.fixture -async def setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> AsyncYieldFixture[ComponentSetup]: - """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) - - async def func() -> None: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - yield func - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - await hass.config_entries.async_unload(entries[0].entry_id) - await hass.async_block_till_done() - - assert not hass.data.get(DOMAIN) - assert entries[0].state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/rtsp_to_webrtc/test_config_flow.py b/tests/components/rtsp_to_webrtc/test_config_flow.py deleted file mode 100644 index d3afa80b0b4..00000000000 --- a/tests/components/rtsp_to_webrtc/test_config_flow.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Test the RTSPtoWebRTC config flow.""" - -from __future__ import annotations - -from unittest.mock import patch - -import rtsp_to_webrtc - -from homeassistant import config_entries -from homeassistant.components.rtsp_to_webrtc import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.service_info.hassio import HassioServiceInfo - -from .conftest import ComponentSetup - -from tests.common import MockConfigEntry - - -async def test_web_full_flow(hass: HomeAssistant) -> None: - """Check full flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("data_schema").schema.get("server_url") is str - assert not result.get("errors") - with ( - patch("rtsp_to_webrtc.client.Client.heartbeat"), - patch( - "homeassistant.components.rtsp_to_webrtc.async_setup_entry", - return_value=True, - ) as mock_setup, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"server_url": "https://example.com"} - ) - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "https://example.com" - assert "result" in result - assert result["result"].data == {"server_url": "https://example.com"} - - assert len(mock_setup.mock_calls) == 1 - - -async def test_single_config_entry(hass: HomeAssistant) -> None: - """Test that only a single config entry is allowed.""" - old_entry = MockConfigEntry(domain=DOMAIN, data={"example": True}) - old_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "single_instance_allowed" - - -async def test_invalid_url(hass: HomeAssistant) -> None: - """Check full flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("data_schema").schema.get("server_url") is str - assert not result.get("errors") - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"server_url": "not-a-url"} - ) - - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == {"server_url": "invalid_url"} - - -async def test_server_unreachable(hass: HomeAssistant) -> None: - """Exercise case where the server is unreachable.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert not result.get("errors") - with patch( - "rtsp_to_webrtc.client.Client.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ClientError(), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"server_url": "https://example.com"} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == {"base": "server_unreachable"} - - -async def test_server_failure(hass: HomeAssistant) -> None: - """Exercise case where server returns a failure.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert not result.get("errors") - with patch( - "rtsp_to_webrtc.client.Client.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ResponseError(), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"server_url": "https://example.com"} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert result.get("errors") == {"base": "server_failure"} - - -async def test_hassio_discovery(hass: HomeAssistant) -> None: - """Test supervisor add-on discovery.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - data=HassioServiceInfo( - config={ - "addon": "RTSPtoWebRTC", - "host": "fake-server", - "port": 8083, - }, - name="RTSPtoWebRTC", - slug="rtsp-to-webrtc", - uuid="1234", - ), - context={"source": config_entries.SOURCE_HASSIO}, - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "hassio_confirm" - assert result.get("description_placeholders") == {"addon": "RTSPtoWebRTC"} - - with ( - patch("rtsp_to_webrtc.client.Client.heartbeat"), - patch( - "homeassistant.components.rtsp_to_webrtc.async_setup_entry", - return_value=True, - ) as mock_setup, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - await hass.async_block_till_done() - - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "RTSPtoWebRTC" - assert "result" in result - assert result["result"].data == {"server_url": "http://fake-server:8083"} - - assert len(mock_setup.mock_calls) == 1 - - -async def test_hassio_single_config_entry(hass: HomeAssistant) -> None: - """Test supervisor add-on discovery only allows a single entry.""" - old_entry = MockConfigEntry(domain=DOMAIN, data={"example": True}) - old_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - data=HassioServiceInfo( - config={ - "addon": "RTSPtoWebRTC", - "host": "fake-server", - "port": 8083, - }, - name="RTSPtoWebRTC", - slug="rtsp-to-webrtc", - uuid="1234", - ), - context={"source": config_entries.SOURCE_HASSIO}, - ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "single_instance_allowed" - - -async def test_hassio_ignored(hass: HomeAssistant) -> None: - """Test ignoring superversor add-on discovery.""" - old_entry = MockConfigEntry(domain=DOMAIN, source=config_entries.SOURCE_IGNORE) - old_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - data=HassioServiceInfo( - config={ - "addon": "RTSPtoWebRTC", - "host": "fake-server", - "port": 8083, - }, - name="RTSPtoWebRTC", - slug="rtsp-to-webrtc", - uuid="1234", - ), - context={"source": config_entries.SOURCE_HASSIO}, - ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "single_instance_allowed" - - -async def test_hassio_discovery_server_failure(hass: HomeAssistant) -> None: - """Test server failure during supvervisor add-on discovery shows an error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - data=HassioServiceInfo( - config={ - "addon": "RTSPtoWebRTC", - "host": "fake-server", - "port": 8083, - }, - name="RTSPtoWebRTC", - slug="rtsp-to-webrtc", - uuid="1234", - ), - context={"source": config_entries.SOURCE_HASSIO}, - ) - - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "hassio_confirm" - assert not result.get("errors") - - with patch( - "rtsp_to_webrtc.client.Client.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ResponseError(), - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "server_failure" - - -async def test_options_flow( - hass: HomeAssistant, - config_entry: MockConfigEntry, - setup_integration: ComponentSetup, -) -> None: - """Test setting stun server in options flow.""" - with patch( - "homeassistant.components.rtsp_to_webrtc.async_setup_entry", - return_value=True, - ): - await setup_integration() - - assert config_entry.state is ConfigEntryState.LOADED - assert not config_entry.options - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - data_schema = result["data_schema"].schema - assert set(data_schema) == {"stun_server"} - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "stun_server": "example.com:1234", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - assert config_entry.options == {"stun_server": "example.com:1234"} - - # Clear the value - result = await hass.config_entries.options.async_init(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={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - assert config_entry.options == {} diff --git a/tests/components/rtsp_to_webrtc/test_diagnostics.py b/tests/components/rtsp_to_webrtc/test_diagnostics.py deleted file mode 100644 index ad3522686b6..00000000000 --- a/tests/components/rtsp_to_webrtc/test_diagnostics.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Test nest diagnostics.""" - -from typing import Any - -from homeassistant.core import HomeAssistant - -from .conftest import ComponentSetup - -from tests.common import MockConfigEntry -from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.typing import ClientSessionGenerator - -THERMOSTAT_TYPE = "sdm.devices.types.THERMOSTAT" - - -async def test_entry_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - config_entry: MockConfigEntry, - rtsp_to_webrtc_client: Any, - setup_integration: ComponentSetup, -) -> None: - """Test config entry diagnostics.""" - await setup_integration() - - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert "webrtc" in result diff --git a/tests/components/rtsp_to_webrtc/test_init.py b/tests/components/rtsp_to_webrtc/test_init.py deleted file mode 100644 index 985e76fa1d1..00000000000 --- a/tests/components/rtsp_to_webrtc/test_init.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Tests for RTSPtoWebRTC initialization.""" - -from __future__ import annotations - -import base64 -from typing import Any -from unittest.mock import patch - -import aiohttp -import pytest -import rtsp_to_webrtc - -from homeassistant.components.rtsp_to_webrtc import DOMAIN -from homeassistant.components.websocket_api import TYPE_RESULT -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component - -from .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup - -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import WebSocketGenerator - -# The webrtc component does not inspect the details of the offer and answer, -# and is only a pass through. -OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..." -ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 host.example.com\r\n..." - - -@pytest.fixture(autouse=True) -async def setup_homeassistant(hass: HomeAssistant): - """Set up the homeassistant integration.""" - await async_setup_component(hass, "homeassistant", {}) - - -@pytest.mark.usefixtures("rtsp_to_webrtc_client") -async def test_setup_success( - hass: HomeAssistant, - config_entry: MockConfigEntry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test successful setup and unload.""" - config_entry.add_to_hass(hass) - - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert issue_registry.async_get_issue(DOMAIN, "deprecated") - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - await hass.config_entries.async_unload(entries[0].entry_id) - await hass.async_block_till_done() - - assert not hass.data.get(DOMAIN) - assert entries[0].state is ConfigEntryState.NOT_LOADED - assert not issue_registry.async_get_issue(DOMAIN, "deprecated") - - -@pytest.mark.parametrize("config_entry_data", [{}]) -async def test_invalid_config_entry( - hass: HomeAssistant, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup -) -> None: - """Test a config entry with missing required fields.""" - await setup_integration() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.SETUP_ERROR - - -async def test_setup_server_failure( - hass: HomeAssistant, setup_integration: ComponentSetup -) -> None: - """Test server responds with a failure on startup.""" - with patch( - "rtsp_to_webrtc.client.Client.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ResponseError(), - ): - await setup_integration() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.SETUP_RETRY - - -async def test_setup_communication_failure( - hass: HomeAssistant, setup_integration: ComponentSetup -) -> None: - """Test unable to talk to server on startup.""" - with patch( - "rtsp_to_webrtc.client.Client.heartbeat", - side_effect=rtsp_to_webrtc.exceptions.ClientError(), - ): - await setup_integration() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.SETUP_RETRY - - -@pytest.mark.usefixtures("mock_camera", "rtsp_to_webrtc_client") -async def test_offer_for_stream_source( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - hass_ws_client: WebSocketGenerator, - setup_integration: ComponentSetup, -) -> None: - """Test successful response from RTSPtoWebRTC server.""" - await setup_integration() - - aioclient_mock.post( - f"{SERVER_URL}/stream", - json={"sdp64": base64.b64encode(ANSWER_SDP.encode("utf-8")).decode("utf-8")}, - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": OFFER_SDP, - } - ) - - response = await client.receive_json() - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "answer", - "answer": ANSWER_SDP, - } - - # Validate request parameters were sent correctly - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][2] == { - "sdp64": base64.b64encode(OFFER_SDP.encode("utf-8")).decode("utf-8"), - "url": STREAM_SOURCE, - } - - -@pytest.mark.usefixtures("mock_camera", "rtsp_to_webrtc_client") -async def test_offer_failure( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - hass_ws_client: WebSocketGenerator, - setup_integration: ComponentSetup, -) -> None: - """Test a transient failure talking to RTSPtoWebRTC server.""" - await setup_integration() - - aioclient_mock.post( - f"{SERVER_URL}/stream", - exc=aiohttp.ClientError, - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", - "offer": OFFER_SDP, - } - ) - - response = await client.receive_json() - assert response["type"] == TYPE_RESULT - assert response["success"] - subscription_id = response["id"] - - # Session id - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"]["type"] == "session" - - # Answer - response = await client.receive_json() - assert response["id"] == subscription_id - assert response["type"] == "event" - assert response["event"] == { - "type": "error", - "code": "webrtc_offer_failed", - "message": "RTSPtoWebRTC server communication failure: ", - } diff --git a/tests/components/russound_rio/test_diagnostics.py b/tests/components/russound_rio/test_diagnostics.py index c6c5441128d..3d83ef12df1 100644 --- a/tests/components/russound_rio/test_diagnostics.py +++ b/tests/components/russound_rio/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/russound_rio/test_init.py b/tests/components/russound_rio/test_init.py index d654eea32bd..935b921b069 100644 --- a/tests/components/russound_rio/test_init.py +++ b/tests/components/russound_rio/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, Mock from aiorussound.models import CallbackType import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.russound_rio.const import DOMAIN from homeassistant.config_entries import ConfigEntryState diff --git a/tests/components/sabnzbd/conftest.py b/tests/components/sabnzbd/conftest.py index 6fa3d14e880..4d85055bb58 100644 --- a/tests/components/sabnzbd/conftest.py +++ b/tests/components/sabnzbd/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.sabnzbd import DOMAIN +from homeassistant.components.sabnzbd.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/sabnzbd/test_binary_sensor.py b/tests/components/sabnzbd/test_binary_sensor.py index 48a3c006488..e823ae6ba96 100644 --- a/tests/components/sabnzbd/test_binary_sensor.py +++ b/tests/components/sabnzbd/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/sabnzbd/test_button.py b/tests/components/sabnzbd/test_button.py index 199d8eb03a0..813d532a38b 100644 --- a/tests/components/sabnzbd/test_button.py +++ b/tests/components/sabnzbd/test_button.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from pysabnzbd import SabnzbdApiException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ( diff --git a/tests/components/sabnzbd/test_config_flow.py b/tests/components/sabnzbd/test_config_flow.py index 797af63c096..ec9044f4223 100644 --- a/tests/components/sabnzbd/test_config_flow.py +++ b/tests/components/sabnzbd/test_config_flow.py @@ -6,7 +6,7 @@ from pysabnzbd import SabnzbdApiException import pytest from homeassistant import config_entries -from homeassistant.components.sabnzbd import DOMAIN +from homeassistant.components.sabnzbd.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant @@ -153,10 +153,10 @@ async def test_abort_already_configured( assert result["reason"] == "already_configured" -async def test_abort_reconfigure_already_configured( +async def test_abort_reconfigure_successful( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: - """Test that the reconfigure flow aborts if SABnzbd instance is already configured.""" + """Test that the reconfigure flow aborts successfully if SABnzbd instance is already configured.""" result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -166,4 +166,4 @@ async def test_abort_reconfigure_already_configured( VALID_CONFIG, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/sabnzbd/test_init.py b/tests/components/sabnzbd/test_init.py deleted file mode 100644 index 9b833875bbc..00000000000 --- a/tests/components/sabnzbd/test_init.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Tests for the SABnzbd Integration.""" - -import pytest - -from homeassistant.components.sabnzbd.const import ( - ATTR_API_KEY, - DOMAIN, - SERVICE_PAUSE, - SERVICE_RESUME, - SERVICE_SET_SPEED, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - - -@pytest.mark.parametrize( - ("service", "issue_id"), - [ - (SERVICE_RESUME, "resume_action_deprecated"), - (SERVICE_PAUSE, "pause_action_deprecated"), - (SERVICE_SET_SPEED, "set_speed_action_deprecated"), - ], -) -@pytest.mark.usefixtures("setup_integration") -async def test_deprecated_service_creates_issue( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - service: str, - issue_id: str, -) -> None: - """Test that deprecated actions creates an issue.""" - await hass.services.async_call( - DOMAIN, - service, - {ATTR_API_KEY: "edc3eee7330e4fdda04489e3fbc283d0"}, - blocking=True, - ) - - issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) - assert issue - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.breaks_in_ha_version == "2025.6" diff --git a/tests/components/sabnzbd/test_number.py b/tests/components/sabnzbd/test_number.py index 61f7ea45ab1..974c5435f15 100644 --- a/tests/components/sabnzbd/test_number.py +++ b/tests/components/sabnzbd/test_number.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from pysabnzbd import SabnzbdApiException import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, diff --git a/tests/components/sabnzbd/test_sensor.py b/tests/components/sabnzbd/test_sensor.py index 31c0868a5a7..1e5e41efce0 100644 --- a/tests/components/sabnzbd/test_sensor.py +++ b/tests/components/sabnzbd/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/samsungtv/__init__.py b/tests/components/samsungtv/__init__.py index f77cd7a9b3e..54b23f45efe 100644 --- a/tests/components/samsungtv/__init__.py +++ b/tests/components/samsungtv/__init__.py @@ -2,30 +2,30 @@ from __future__ import annotations -from datetime import timedelta +from collections.abc import Mapping +from typing import Any -from homeassistant.components.samsungtv.const import DOMAIN, ENTRY_RELOAD_COOLDOWN +from homeassistant.components.samsungtv.const import DOMAIN, METHOD_LEGACY from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_METHOD from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry -async def async_wait_config_entry_reload(hass: HomeAssistant) -> None: - """Wait for the config entry to reload.""" - await hass.async_block_till_done() - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) - ) - await hass.async_block_till_done() - - -async def setup_samsungtv_entry(hass: HomeAssistant, data: ConfigType) -> ConfigEntry: +async def setup_samsungtv_entry( + hass: HomeAssistant, data: Mapping[str, Any] +) -> ConfigEntry: """Set up mock Samsung TV from config entry data.""" entry = MockConfigEntry( - domain=DOMAIN, data=data, entry_id="123456", unique_id="any" + domain=DOMAIN, + data=data, + entry_id="123456", + unique_id=( + None + if data[CONF_METHOD] == METHOD_LEGACY + else "be9554b9-c9fb-41f4-8920-22da015376a4" + ), ) entry.add_to_hass(hass) diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 105ef0f25ad..6fe784addd7 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Generator -from datetime import datetime from socket import AddressFamily # pylint: disable=no-name-in-module from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -20,10 +19,11 @@ from samsungtvws.event import ED_INSTALLED_APP_EVENT from samsungtvws.exceptions import ResponseError from samsungtvws.remote import ChannelEmitCommand -from homeassistant.components.samsungtv.const import WEBSOCKET_SSL_PORT -from homeassistant.util import dt as dt_util +from homeassistant.components.samsungtv.const import DOMAIN, WEBSOCKET_SSL_PORT -from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI +from .const import SAMPLE_DEVICE_INFO_WIFI + +from tests.common import load_json_object_fixture @pytest.fixture @@ -53,7 +53,7 @@ def silent_ssdp_scanner() -> Generator[None]: @pytest.fixture(autouse=True) -def samsungtv_mock_async_get_local_ip(): +def samsungtv_mock_async_get_local_ip() -> Generator[None]: """Mock upnp util's async_get_local_ip.""" with patch( "homeassistant.components.samsungtv.media_player.async_get_local_ip", @@ -63,24 +63,24 @@ def samsungtv_mock_async_get_local_ip(): @pytest.fixture(autouse=True) -def fake_host_fixture() -> None: +def fake_host_fixture() -> Generator[None]: """Patch gethostbyname.""" with patch( "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", - return_value="fake_host", + return_value="10.20.43.21", ): yield @pytest.fixture(autouse=True) -def app_list_delay_fixture() -> None: +def app_list_delay_fixture() -> Generator[None]: """Patch APP_LIST_DELAY.""" with patch("homeassistant.components.samsungtv.media_player.APP_LIST_DELAY", 0): yield @pytest.fixture(name="upnp_factory", autouse=True) -def upnp_factory_fixture() -> Mock: +def upnp_factory_fixture() -> Generator[Mock]: """Patch UpnpFactory.""" with patch( "homeassistant.components.samsungtv.media_player.UpnpFactory", @@ -92,17 +92,17 @@ def upnp_factory_fixture() -> Mock: @pytest.fixture(name="upnp_device") -async def upnp_device_fixture(upnp_factory: Mock) -> Mock: +def upnp_device_fixture(upnp_factory: Mock) -> Mock: """Patch async_upnp_client.""" upnp_device = Mock(UpnpDevice) upnp_device.services = {} - with patch.object(upnp_factory, "async_create_device", side_effect=[upnp_device]): - yield upnp_device + upnp_factory.async_create_device.side_effect = [upnp_device] + return upnp_device @pytest.fixture(name="dmr_device") -async def dmr_device_fixture(upnp_device: Mock) -> Mock: +def dmr_device_fixture(upnp_device: Mock) -> Generator[Mock]: """Patch async_upnp_client.""" with patch( "homeassistant.components.samsungtv.media_player.DmrDevice", @@ -137,7 +137,7 @@ async def dmr_device_fixture(upnp_device: Mock) -> Mock: @pytest.fixture(name="upnp_notify_server") -async def upnp_notify_server_fixture(upnp_factory: Mock) -> Mock: +def upnp_notify_server_fixture(upnp_factory: Mock) -> Generator[Mock]: """Patch async_upnp_client.""" with patch( "homeassistant.components.samsungtv.media_player.AiohttpNotifyServer", @@ -148,19 +148,20 @@ async def upnp_notify_server_fixture(upnp_factory: Mock) -> Mock: yield notify_server -@pytest.fixture(name="remote") -def remote_fixture() -> Mock: +@pytest.fixture(name="remote_legacy") +def remote_legacy_fixture() -> Generator[Mock]: """Patch the samsungctl Remote.""" - with patch("homeassistant.components.samsungtv.bridge.Remote") as remote_class: - remote = Mock(Remote) - remote.__enter__ = Mock() - remote.__exit__ = Mock() - remote_class.return_value = remote - yield remote + remote_legacy = Mock(Remote) + remote_legacy.__enter__ = Mock() + remote_legacy.__exit__ = Mock() + with patch( + "homeassistant.components.samsungtv.bridge.Remote", return_value=remote_legacy + ): + yield remote_legacy @pytest.fixture(name="rest_api") -def rest_api_fixture() -> Mock: +def rest_api_fixture() -> Generator[Mock]: """Patch the samsungtvws SamsungTVAsyncRest.""" with patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", @@ -173,7 +174,7 @@ def rest_api_fixture() -> Mock: @pytest.fixture(name="rest_api_non_ssl_only") -def rest_api_fixture_non_ssl_only() -> Mock: +def rest_api_fixture_non_ssl_only() -> Generator[None]: """Patch the samsungtvws SamsungTVAsyncRest non-ssl only.""" class MockSamsungTVAsyncRest: @@ -188,7 +189,7 @@ def rest_api_fixture_non_ssl_only() -> Mock: """Mock rest_device_info to fail for ssl and work for non-ssl.""" if self.port == WEBSOCKET_SSL_PORT: raise ResponseError - return SAMPLE_DEVICE_INFO_UE48JU6400 + return load_json_object_fixture("device_info_UE48JU6400.json", DOMAIN) with patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", @@ -198,7 +199,7 @@ def rest_api_fixture_non_ssl_only() -> Mock: @pytest.fixture(name="rest_api_failing") -def rest_api_failure_fixture() -> Mock: +def rest_api_failure_fixture() -> Generator[None]: """Patch the samsungtvws SamsungTVAsyncRest.""" with patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", @@ -208,8 +209,8 @@ def rest_api_failure_fixture() -> Mock: yield -@pytest.fixture(name="remoteencws_failing") -def remoteencws_failing_fixture(): +@pytest.fixture(name="remote_encrypted_websocket_failing") +def remote_encrypted_websocket_failing_fixture() -> Generator[None]: """Patch the samsungtvws SamsungTVEncryptedWSAsyncRemote.""" with patch( "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", @@ -218,81 +219,81 @@ def remoteencws_failing_fixture(): yield -@pytest.fixture(name="remotews") -def remotews_fixture() -> Mock: +@pytest.fixture(name="remote_websocket") +def remote_websocket_fixture() -> Generator[Mock]: """Patch the samsungtvws SamsungTVWS.""" - remotews = Mock(SamsungTVWSAsyncRemote) - remotews.__aenter__ = AsyncMock(return_value=remotews) - remotews.__aexit__ = AsyncMock() - remotews.token = "FAKE_TOKEN" - remotews.app_list_data = None + remote_websocket = Mock(SamsungTVWSAsyncRemote) + remote_websocket.__aenter__ = AsyncMock(return_value=remote_websocket) + remote_websocket.__aexit__ = AsyncMock() + remote_websocket.token = "FAKE_TOKEN" + remote_websocket.app_list_data = None async def _start_listening( ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None, ): - remotews.ws_event_callback = ws_event_callback + remote_websocket.ws_event_callback = ws_event_callback async def _send_commands(commands: list[SamsungTVCommand]): if ( len(commands) == 1 and isinstance(commands[0], ChannelEmitCommand) and commands[0].params["event"] == "ed.installedApp.get" - and remotews.app_list_data is not None + and remote_websocket.app_list_data is not None ): - remotews.raise_mock_ws_event_callback( + remote_websocket.raise_mock_ws_event_callback( ED_INSTALLED_APP_EVENT, - remotews.app_list_data, + remote_websocket.app_list_data, ) def _mock_ws_event_callback(event: str, response: Any): - if remotews.ws_event_callback: - remotews.ws_event_callback(event, response) + if remote_websocket.ws_event_callback: + remote_websocket.ws_event_callback(event, response) - remotews.start_listening.side_effect = _start_listening - remotews.send_commands.side_effect = _send_commands - remotews.raise_mock_ws_event_callback = Mock(side_effect=_mock_ws_event_callback) + remote_websocket.start_listening.side_effect = _start_listening + remote_websocket.send_commands.side_effect = _send_commands + remote_websocket.raise_mock_ws_event_callback = Mock( + side_effect=_mock_ws_event_callback + ) with patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote", - ) as remotews_class: - remotews_class.return_value = remotews - yield remotews + return_value=remote_websocket, + ): + yield remote_websocket -@pytest.fixture(name="remoteencws") -def remoteencws_fixture() -> Mock: +@pytest.fixture(name="remote_encrypted_websocket") +def remote_encrypted_websocket_fixture() -> Generator[Mock]: """Patch the samsungtvws SamsungTVEncryptedWSAsyncRemote.""" - remoteencws = Mock(SamsungTVEncryptedWSAsyncRemote) - remoteencws.__aenter__ = AsyncMock(return_value=remoteencws) - remoteencws.__aexit__ = AsyncMock() + remote_encrypted_websocket = Mock(SamsungTVEncryptedWSAsyncRemote) + remote_encrypted_websocket.__aenter__ = AsyncMock( + return_value=remote_encrypted_websocket + ) + remote_encrypted_websocket.__aexit__ = AsyncMock() def _start_listening( ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None, ): - remoteencws.ws_event_callback = ws_event_callback + remote_encrypted_websocket.ws_event_callback = ws_event_callback def _mock_ws_event_callback(event: str, response: Any): - if remoteencws.ws_event_callback: - remoteencws.ws_event_callback(event, response) + if remote_encrypted_websocket.ws_event_callback: + remote_encrypted_websocket.ws_event_callback(event, response) - remoteencws.start_listening.side_effect = _start_listening - remoteencws.raise_mock_ws_event_callback = Mock(side_effect=_mock_ws_event_callback) + remote_encrypted_websocket.start_listening.side_effect = _start_listening + remote_encrypted_websocket.raise_mock_ws_event_callback = Mock( + side_effect=_mock_ws_event_callback + ) with patch( "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote", ) as remotews_class: - remotews_class.return_value = remoteencws - yield remoteencws - - -@pytest.fixture -def mock_now() -> datetime: - """Fixture for dtutil.now.""" - return dt_util.utcnow() + remotews_class.return_value = remote_encrypted_websocket + yield remote_encrypted_websocket @pytest.fixture(name="mac_address", autouse=True) -def mac_address_fixture() -> Mock: +def mac_address_fixture() -> Generator[Mock]: """Patch getmac.get_mac_address.""" with patch("getmac.get_mac_address", return_value=None) as mac: yield mac diff --git a/tests/components/samsungtv/const.py b/tests/components/samsungtv/const.py index c1a9da4e284..16ffb6b9c05 100644 --- a/tests/components/samsungtv/const.py +++ b/tests/components/samsungtv/const.py @@ -1,88 +1,49 @@ """Constants for the samsungtv tests.""" -from samsungtvws.event import ED_INSTALLED_APP_EVENT - from homeassistant.components.samsungtv.const import ( CONF_SESSION_ID, + DOMAIN, + ENCRYPTED_WEBSOCKET_PORT, + LEGACY_PORT, + METHOD_ENCRYPTED_WEBSOCKET, METHOD_LEGACY, METHOD_WEBSOCKET, + WEBSOCKET_SSL_PORT, ) from homeassistant.const import ( CONF_HOST, - CONF_IP_ADDRESS, CONF_MAC, CONF_METHOD, CONF_MODEL, - CONF_NAME, 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, -) +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo -MOCK_CONFIG = { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 55000, +from tests.common import load_json_object_fixture + +ENTRYDATA_LEGACY = { + CONF_HOST: "10.10.12.34", + CONF_PORT: LEGACY_PORT, CONF_METHOD: METHOD_LEGACY, } -MOCK_CONFIG_ENCRYPTED_WS = { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 8000, -} -MOCK_ENTRYDATA_ENCRYPTED_WS = { - **MOCK_CONFIG_ENCRYPTED_WS, - CONF_IP_ADDRESS: "test", - CONF_METHOD: "encrypted", +ENTRYDATA_ENCRYPTED_WEBSOCKET = { + CONF_HOST: "10.10.12.34", + CONF_PORT: ENCRYPTED_WEBSOCKET_PORT, + CONF_METHOD: METHOD_ENCRYPTED_WEBSOCKET, CONF_MAC: "aa:bb:cc:dd:ee:ff", CONF_TOKEN: "037739871315caef138547b03e348b72", CONF_SESSION_ID: "2", } -MOCK_ENTRYDATA_WS = { - CONF_HOST: "fake_host", +ENTRYDATA_WEBSOCKET = { + CONF_HOST: "10.10.12.34", CONF_METHOD: METHOD_WEBSOCKET, - CONF_PORT: 8002, - CONF_MODEL: "any", - CONF_NAME: "any", -} -MOCK_ENTRY_WS_WITH_MAC = { - CONF_IP_ADDRESS: "test", - CONF_HOST: "fake_host", - CONF_METHOD: "websocket", + CONF_PORT: WEBSOCKET_SSL_PORT, CONF_MAC: "aa:bb:cc:dd:ee:ff", - CONF_NAME: "fake", - CONF_PORT: 8002, + CONF_MODEL: "UE43LS003", CONF_TOKEN: "123456789", } -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", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "[TV] fake_name", - ATTR_UPNP_MANUFACTURER: "Samsung fake_manufacturer", - ATTR_UPNP_MODEL_NAME: "fake_model", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", - }, -) -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", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "[TV] fake_name", - ATTR_UPNP_MANUFACTURER: "Samsung fake_manufacturer", - ATTR_UPNP_MODEL_NAME: "fake_model", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", - }, -) SAMPLE_DEVICE_INFO_WIFI = { "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", @@ -95,101 +56,14 @@ SAMPLE_DEVICE_INFO_WIFI = { }, } -SAMPLE_DEVICE_INFO_FRAME = { - "device": { - "FrameTVSupport": "true", - "GamePadSupport": "true", - "ImeSyncedSupport": "true", - "OS": "Tizen", - "TokenAuthSupport": "true", - "VoiceSupport": "true", - "countryCode": "FR", - "description": "Samsung DTV RCR", - "developerIP": "0.0.0.0", - "developerMode": "0", - "duid": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", - "firmwareVersion": "Unknown", - "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", - "ip": "1.2.3.4", - "model": "17_KANTM_UHD", - "modelName": "UE43LS003", - "name": "[TV] Samsung Frame (43)", - "networkType": "wired", - "resolution": "3840x2160", - "smartHubAgreement": "true", - "type": "Samsung SmartTV", - "udn": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", - "wifiMac": "aa:ee:tt:hh:ee:rr", - }, - "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", - "isSupport": ( - '{"DMP_DRM_PLAYREADY":"false","DMP_DRM_WIDEVINE":"false","DMP_available":"true",' - '"EDEN_available":"true","FrameTVSupport":"true","ImeSyncedSupport":"true",' - '"TokenAuthSupport":"true","remote_available":"true","remote_fourDirections":"true",' - '"remote_touchPad":"true","remote_voiceControl":"true"}\n' - ), - "name": "[TV] Samsung Frame (43)", - "remote": "1.0", - "type": "Samsung SmartTV", - "uri": "https://1.2.3.4:8002/api/v2/", - "version": "2.0.25", -} +MOCK_SSDP_DATA = SsdpServiceInfo( + **load_json_object_fixture("ssdp_service_remote_control_receiver.json", DOMAIN) +) -SAMPLE_DEVICE_INFO_UE48JU6400 = { - "id": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", - "name": "[TV] TV-UE48JU6470", - "version": "2.0.25", - "device": { - "type": "Samsung SmartTV", - "duid": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", - "model": "15_HAWKM_UHD_2D", - "modelName": "UE48JU6400", - "description": "Samsung DTV RCR", - "networkType": "wired", - "ssid": "", - "ip": "1.2.3.4", - "firmwareVersion": "Unknown", - "name": "[TV] TV-UE48JU6470", - "id": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", - "udn": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", - "resolution": "1920x1080", - "countryCode": "AT", - "msfVersion": "2.0.25", - "smartHubAgreement": "true", - "wifiMac": "aa:bb:aa:aa:aa:aa", - "developerMode": "0", - "developerIP": "", - }, - "type": "Samsung SmartTV", - "uri": "https://1.2.3.4:8002/api/v2/", -} +MOCK_SSDP_DATA_RENDERING_CONTROL_ST = SsdpServiceInfo( + **load_json_object_fixture("ssdp_service_rendering_control.json", DOMAIN) +) -SAMPLE_EVENT_ED_INSTALLED_APP = { - "event": ED_INSTALLED_APP_EVENT, - "from": "host", - "data": { - "data": [ - { - "appId": "111299001912", - "app_type": 2, - "icon": "/opt/share/webappservice/apps_icon/FirstScreen/111299001912/250x250.png", - "is_lock": 0, - "name": "YouTube", - }, - { - "appId": "3201608010191", - "app_type": 2, - "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201608010191/250x250.png", - "is_lock": 0, - "name": "Deezer", - }, - { - "appId": "3201606009684", - "app_type": 2, - "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201606009684/250x250.png", - "is_lock": 0, - "name": "Spotify - Music and Podcasts", - }, - ] - }, -} +MOCK_SSDP_DATA_MAIN_TV_AGENT_ST = SsdpServiceInfo( + **load_json_object_fixture("ssdp_device_main_tv_agent.json", DOMAIN) +) diff --git a/tests/components/samsungtv/fixtures/device_info_UE43LS003.json b/tests/components/samsungtv/fixtures/device_info_UE43LS003.json new file mode 100644 index 00000000000..ac961fafd6b --- /dev/null +++ b/tests/components/samsungtv/fixtures/device_info_UE43LS003.json @@ -0,0 +1,34 @@ +{ + "device": { + "FrameTVSupport": "true", + "GamePadSupport": "true", + "ImeSyncedSupport": "true", + "OS": "Tizen", + "TokenAuthSupport": "true", + "VoiceSupport": "true", + "countryCode": "FR", + "description": "Samsung DTV RCR", + "developerIP": "0.0.0.0", + "developerMode": "0", + "duid": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", + "firmwareVersion": "Unknown", + "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", + "ip": "1.2.3.4", + "model": "17_KANTM_UHD", + "modelName": "UE43LS003", + "name": "[TV] Samsung Frame (43)", + "networkType": "wired", + "resolution": "3840x2160", + "smartHubAgreement": "true", + "type": "Samsung SmartTV", + "udn": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", + "wifiMac": "aa:ee:tt:hh:ee:rr" + }, + "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", + "isSupport": "{\"DMP_DRM_PLAYREADY\":\"false\",\"DMP_DRM_WIDEVINE\":\"false\",\"DMP_available\":\"true\",\"EDEN_available\":\"true\",\"FrameTVSupport\":\"true\",\"ImeSyncedSupport\":\"true\",\"TokenAuthSupport\":\"true\",\"remote_available\":\"true\",\"remote_fourDirections\":\"true\",\"remote_touchPad\":\"true\",\"remote_voiceControl\":\"true\"}\n", + "name": "[TV] Samsung Frame (43)", + "remote": "1.0", + "type": "Samsung SmartTV", + "uri": "https://1.2.3.4:8002/api/v2/", + "version": "2.0.25" +} diff --git a/tests/components/samsungtv/fixtures/device_info_UE48JU6400.json b/tests/components/samsungtv/fixtures/device_info_UE48JU6400.json new file mode 100644 index 00000000000..65cecf095a2 --- /dev/null +++ b/tests/components/samsungtv/fixtures/device_info_UE48JU6400.json @@ -0,0 +1,28 @@ +{ + "id": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", + "name": "[TV] TV-UE48JU6470", + "version": "2.0.25", + "device": { + "type": "Samsung SmartTV", + "duid": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", + "model": "15_HAWKM_UHD_2D", + "modelName": "UE48JU6400", + "description": "Samsung DTV RCR", + "networkType": "wired", + "ssid": "", + "ip": "1.2.3.4", + "firmwareVersion": "Unknown", + "name": "[TV] TV-UE48JU6470", + "id": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", + "udn": "uuid:223da676-497a-4e06-9507-5e27ec4f0fb3", + "resolution": "1920x1080", + "countryCode": "AT", + "msfVersion": "2.0.25", + "smartHubAgreement": "true", + "wifiMac": "aa:bb:aa:aa:aa:aa", + "developerMode": "0", + "developerIP": "" + }, + "type": "Samsung SmartTV", + "uri": "https://1.2.3.4:8002/api/v2/" +} diff --git a/tests/components/samsungtv/fixtures/ssdp_device_main_tv_agent.json b/tests/components/samsungtv/fixtures/ssdp_device_main_tv_agent.json new file mode 100644 index 00000000000..252d352f514 --- /dev/null +++ b/tests/components/samsungtv/fixtures/ssdp_device_main_tv_agent.json @@ -0,0 +1,54 @@ +{ + "ssdp_usn": "uuid:055d4a80-005a-1000-b872-84a4668d8423::urn:samsung.com:service:MainTVAgent2:1", + "ssdp_st": "urn:samsung.com:service:MainTVAgent2:1", + "upnp": { + "deviceType": "urn:samsung.com:device:MainTVServer2:1", + "friendlyName": "[TV]Samsung LED55", + "manufacturer": "Samsung Electronics", + "manufacturerURL": "http://www.samsung.com", + "modelDescription": "Samsung DTV MainTVServer2", + "modelName": "UE55H6400", + "modelNumber": "1.0", + "modelURL": "http://www.samsung.com", + "serialNumber": "20100621", + "UDN": "uuid:055d4a80-005a-1000-b872-84a4668d8423", + "UPC": "123456789012", + "deviceID": "ZPCNHA5IWYRV6", + "ProductCap": "Y2013", + "serviceList": { + "service": { + "serviceType": "urn:samsung.com:service:MainTVAgent2:1", + "serviceId": "urn:samsung.com:serviceId:MainTVAgent2", + "controlURL": "/smp_4_", + "eventSubURL": "/smp_5_", + "SCPDURL": "/smp_3_" + } + } + }, + "ssdp_location": "http://10.10.12.34:7676/smp_2_", + "ssdp_nt": null, + "ssdp_udn": "uuid:055d4a80-005a-1000-b872-84a4668d8423", + "ssdp_ext": "", + "ssdp_server": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ssdp_headers": { + "CACHE-CONTROL": "max-age:1800", + "Date": "Thu, 01 Jan 1970 00:06:48 GMT", + "EXT": "", + "LOCATION": "http://10.10.12.34:7676/smp_2_", + "SERVER": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ST": "urn:samsung.com:service:MainTVAgent2:1", + "USN": "uuid:055d4a80-005a-1000-b872-84a4668d8423::urn:samsung.com:service:MainTVAgent2:1", + "Content-Length": "0", + "_host": "10.10.12.34", + "_udn": "uuid:055d4a80-005a-1000-b872-84a4668d8423", + "_location_original": "http://10.10.12.34:7676/smp_2_", + "location": "http://10.10.12.34:7676/smp_2_", + "_timestamp": "2025-04-30T07:30:24.160549", + "_remote_addr": ["10.10.12.34", 58482], + "_port": 58482, + "_local_addr": ["0.0.0.0", 0], + "_source": "search" + }, + "ssdp_all_locations": ["http://10.10.12.34:7676/smp_2_"], + "x_homeassistant_matching_domains": ["samsungtv"] +} diff --git a/tests/components/samsungtv/fixtures/ssdp_service_remote_control_receiver.json b/tests/components/samsungtv/fixtures/ssdp_service_remote_control_receiver.json new file mode 100644 index 00000000000..21cd39a65a9 --- /dev/null +++ b/tests/components/samsungtv/fixtures/ssdp_service_remote_control_receiver.json @@ -0,0 +1,62 @@ +{ + "ssdp_usn": "uuid:068e7781-006e-1000-bbbf-84a4668d8423::urn:samsung.com:device:RemoteControlReceiver:1", + "ssdp_st": "urn:samsung.com:device:RemoteControlReceiver:1", + "upnp": { + "deviceType": "urn:samsung.com:device:RemoteControlReceiver:1", + "friendlyName": "[TV]Samsung LED55", + "manufacturer": "Samsung Electronics", + "manufacturerURL": "http://www.samsung.com/sec", + "modelDescription": "Samsung TV RCR", + "modelName": "UE55H6400", + "modelNumber": "1.0", + "modelURL": "http://www.samsung.com/sec", + "serialNumber": "20090804RCR", + "UDN": "uuid:068e7781-006e-1000-bbbf-84a4668d8423", + "deviceID": "ZPCNHA5IWYRV6", + "ProductCap": "Resolution:1920X1080,ImageZoom,ImageRotate,Y2014,ENC", + "serviceList": { + "service": { + "serviceType": "urn:samsung.com:service:MultiScreenService:1", + "serviceId": "urn:samsung.com:serviceId:MultiScreenService", + "controlURL": "/smp_9_", + "eventSubURL": "/smp_10_", + "SCPDURL": "/smp_8_" + } + }, + "Capabilities": { + "Capability": { + "@name": "samsung:multiscreen:1", + "@port": "8001", + "@location": "/ms/1.0/" + } + } + }, + "ssdp_location": "http://10.10.12.34:7676/smp_7_", + "ssdp_nt": "urn:samsung.com:device:RemoteControlReceiver:1", + "ssdp_udn": "uuid:068e7781-006e-1000-bbbf-84a4668d8423", + "ssdp_ext": "", + "ssdp_server": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ssdp_headers": { + "CACHE-CONTROL": "max-age:1800", + "Date": "Thu, 01 Jan 1970 00:06:48 GMT", + "EXT": "", + "LOCATION": "http://10.10.12.34:7676/smp_7_", + "SERVER": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ST": "urn:samsung.com:device:RemoteControlReceiver:1", + "USN": "uuid:068e7781-006e-1000-bbbf-84a4668d8423::urn:samsung.com:device:RemoteControlReceiver:1", + "Content-Length": "0", + "_host": "10.10.12.34", + "_udn": "uuid:068e7781-006e-1000-bbbf-84a4668d8423", + "_location_original": "http://10.10.12.34:7676/smp_7_", + "location": "http://10.10.12.34:7676/smp_7_", + "_timestamp": "2025-04-30T07:30:24.384758", + "_remote_addr": ["10.10.12.34", 24234], + "_port": 24234, + "_local_addr": ["0.0.0.0", 1900], + "HOST": "239.255.255.250:1900", + "NT": "urn:samsung.com:device:RemoteControlReceiver:1", + "NTS": "ssdp:alive" + }, + "ssdp_all_locations": ["http://10.10.12.34:7676/smp_7_"], + "x_homeassistant_matching_domains": ["samsungtv"] +} diff --git a/tests/components/samsungtv/fixtures/ssdp_service_rendering_control.json b/tests/components/samsungtv/fixtures/ssdp_service_rendering_control.json new file mode 100644 index 00000000000..31c0944e0ac --- /dev/null +++ b/tests/components/samsungtv/fixtures/ssdp_service_rendering_control.json @@ -0,0 +1,105 @@ +{ + "ssdp_usn": "uuid:09896802-00a0-1000-adfd-84a4668d8423::urn:schemas-upnp-org:service:RenderingControl:1", + "ssdp_st": "urn:schemas-upnp-org:service:RenderingControl:1", + "upnp": { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "X_compatibleId": "MS_DigitalMediaDeviceClass_DMR_V001", + "X_deviceCategory": "Display.TV.LCD Multimedia.DMR", + "X_DLNADOC": "DMR-1.50", + "friendlyName": "[TV]Samsung LED55", + "manufacturer": "Samsung Electronics", + "manufacturerURL": "http://www.samsung.com/sec", + "modelDescription": "Samsung TV DMR", + "modelName": "UE55H6400", + "modelNumber": "AllShare1.0", + "modelURL": "http://www.samsung.com/sec", + "serialNumber": "20110517DMR", + "UDN": "uuid:09896802-00a0-1000-adfd-84a4668d8423", + "deviceID": "ZPCNHA5IWYRV6", + "iconList": { + "icon": [ + { + "mimetype": "image/jpeg", + "width": "48", + "height": "48", + "depth": "24", + "url": "/dmr/icon_SML.jpg" + }, + { + "mimetype": "image/jpeg", + "width": "120", + "height": "120", + "depth": "24", + "url": "/dmr/icon_LRG.jpg" + }, + { + "mimetype": "image/png", + "width": "48", + "height": "48", + "depth": "24", + "url": "/dmr/icon_SML.png" + }, + { + "mimetype": "image/png", + "width": "120", + "height": "120", + "depth": "24", + "url": "/dmr/icon_LRG.png" + } + ] + }, + "serviceList": { + "service": [ + { + "serviceType": "urn:schemas-upnp-org:service:RenderingControl:1", + "serviceId": "urn:upnp-org:serviceId:RenderingControl", + "controlURL": "/smp_17_", + "eventSubURL": "/smp_18_", + "SCPDURL": "/smp_16_" + }, + { + "serviceType": "urn:schemas-upnp-org:service:ConnectionManager:1", + "serviceId": "urn:upnp-org:serviceId:ConnectionManager", + "controlURL": "/smp_20_", + "eventSubURL": "/smp_21_", + "SCPDURL": "/smp_19_" + }, + { + "serviceType": "urn:schemas-upnp-org:service:AVTransport:1", + "serviceId": "urn:upnp-org:serviceId:AVTransport", + "controlURL": "/smp_23_", + "eventSubURL": "/smp_24_", + "SCPDURL": "/smp_22_" + } + ] + }, + "ProductCap": "Y2014,WebURIPlayable,SeekTRACK_NR,NavigateInPause", + "X_hardwareId": "VEN_0105&DEV_VD0001" + }, + "ssdp_location": "http://10.10.12.34:7676/smp_15_", + "ssdp_nt": null, + "ssdp_udn": "uuid:09896802-00a0-1000-adfd-84a4668d8423", + "ssdp_ext": "", + "ssdp_server": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ssdp_headers": { + "CACHE-CONTROL": "max-age:1800", + "Date": "Thu, 01 Jan 1970 00:06:48 GMT", + "EXT": "", + "LOCATION": "http://10.10.12.34:7676/smp_15_", + "SERVER": "SHP, UPnP/1.0, Samsung UPnP SDK/1.0", + "ST": "urn:schemas-upnp-org:service:RenderingControl:1", + "USN": "uuid:09896802-00a0-1000-adfd-84a4668d8423::urn:schemas-upnp-org:service:RenderingControl:1", + "Content-Length": "0", + "_host": "10.10.12.34", + "_udn": "uuid:09896802-00a0-1000-adfd-84a4668d8423", + "_location_original": "http://10.10.12.34:7676/smp_15_", + "location": "http://10.10.12.34:7676/smp_15_", + "_timestamp": "2025-04-30T07:30:24.146243", + "_remote_addr": ["10.10.12.34", 52226], + "_port": 52226, + "_local_addr": ["0.0.0.0", 0], + "_source": "search" + }, + "ssdp_all_locations": ["http://10.10.12.34:7676/smp_15_"], + "x_homeassistant_matching_domains": ["samsungtv"] +} diff --git a/tests/components/samsungtv/fixtures/ws_installed_app_event.json b/tests/components/samsungtv/fixtures/ws_installed_app_event.json new file mode 100644 index 00000000000..81c64f60958 --- /dev/null +++ b/tests/components/samsungtv/fixtures/ws_installed_app_event.json @@ -0,0 +1,29 @@ +{ + "event": "ed.installedApp.get", + "from": "host", + "data": { + "data": [ + { + "appId": "111299001912", + "app_type": 2, + "icon": "/opt/share/webappservice/apps_icon/FirstScreen/111299001912/250x250.png", + "is_lock": 0, + "name": "YouTube" + }, + { + "appId": "3201608010191", + "app_type": 2, + "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201608010191/250x250.png", + "is_lock": 0, + "name": "Deezer" + }, + { + "appId": "3201606009684", + "app_type": 2, + "icon": "/opt/share/webappservice/apps_icon/FirstScreen/3201606009684/250x250.png", + "is_lock": 0, + "name": "Spotify - Music and Podcasts" + } + ] + } +} diff --git a/tests/components/samsungtv/snapshots/test_diagnostics.ambr b/tests/components/samsungtv/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..fb7bcd83ae7 --- /dev/null +++ b/tests/components/samsungtv/snapshots/test_diagnostics.ambr @@ -0,0 +1,131 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'device_info': dict({ + 'device': dict({ + 'modelName': '82GXARRS', + 'name': '[TV] Living Room', + 'networkType': 'wireless', + 'type': 'Samsung SmartTV', + 'wifiMac': 'aa:bb:aa:aa:aa:aa', + }), + 'id': 'uuid:be9554b9-c9fb-41f4-8920-22da015376a4', + }), + 'entry': dict({ + 'data': dict({ + 'host': '10.10.12.34', + 'mac': 'aa:bb:cc:dd:ee:ff', + 'method': 'websocket', + 'model': 'UE43LS003', + 'port': 8002, + 'token': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'samsungtv', + 'entry_id': '123456', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'be9554b9-c9fb-41f4-8920-22da015376a4', + 'version': 2, + }), + }) +# --- +# name: test_entry_diagnostics_encrypte_offline + dict({ + 'device_info': None, + 'entry': dict({ + 'data': dict({ + 'host': '10.10.12.34', + 'mac': 'aa:bb:cc:dd:ee:ff', + 'method': 'encrypted', + 'port': 8000, + 'session_id': '**REDACTED**', + 'token': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'samsungtv', + 'entry_id': '123456', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'be9554b9-c9fb-41f4-8920-22da015376a4', + 'version': 2, + }), + }) +# --- +# name: test_entry_diagnostics_encrypted + dict({ + 'device_info': dict({ + 'device': dict({ + 'countryCode': 'AT', + 'description': 'Samsung DTV RCR', + 'developerIP': '', + 'developerMode': '0', + 'duid': 'uuid:223da676-497a-4e06-9507-5e27ec4f0fb3', + 'firmwareVersion': 'Unknown', + 'id': 'uuid:223da676-497a-4e06-9507-5e27ec4f0fb3', + 'ip': '1.2.3.4', + 'model': '15_HAWKM_UHD_2D', + 'modelName': 'UE48JU6400', + 'msfVersion': '2.0.25', + 'name': '[TV] TV-UE48JU6470', + 'networkType': 'wired', + 'resolution': '1920x1080', + 'smartHubAgreement': 'true', + 'ssid': '', + 'type': 'Samsung SmartTV', + 'udn': 'uuid:223da676-497a-4e06-9507-5e27ec4f0fb3', + 'wifiMac': 'aa:bb:aa:aa:aa:aa', + }), + 'id': 'uuid:223da676-497a-4e06-9507-5e27ec4f0fb3', + 'name': '[TV] TV-UE48JU6470', + 'type': 'Samsung SmartTV', + 'uri': 'https://1.2.3.4:8002/api/v2/', + 'version': '2.0.25', + }), + 'entry': dict({ + 'data': dict({ + 'host': '10.10.12.34', + 'mac': 'aa:bb:cc:dd:ee:ff', + 'method': 'encrypted', + 'model': 'UE48JU6400', + 'port': 8000, + 'session_id': '**REDACTED**', + 'token': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'samsungtv', + 'entry_id': '123456', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'be9554b9-c9fb-41f4-8920-22da015376a4', + 'version': 2, + }), + }) +# --- diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index ad01b5454ff..b29b824a7dd 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -1,20 +1,16 @@ # serializer version: 1 -# name: test_cleanup_mac +# name: test_setup[encrypted] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'config_subentries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( 'mac', 'aa:bb:cc:dd:ee:ff', ), - tuple( - 'mac', - 'none', - ), }), 'disabled_by': None, 'entry_type': None, @@ -23,16 +19,16 @@ 'identifiers': set({ tuple( 'samsungtv', - 'any', + 'be9554b9-c9fb-41f4-8920-22da015376a4', ), }), 'is_new': False, 'labels': set({ }), 'manufacturer': None, - 'model': '82GXARRS', + 'model': None, 'model_id': None, - 'name': 'fake', + 'name': 'Mock Title', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, @@ -42,22 +38,53 @@ }), ]) # --- -# name: test_cleanup_mac.1 +# name: test_setup[legacy] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'config_subentries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'samsungtv', + '123456', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Mock Title', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_setup[websocket] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( 'mac', 'aa:bb:cc:dd:ee:ff', ), - tuple( - 'mac', - 'none', - ), }), 'disabled_by': None, 'entry_type': None, @@ -66,16 +93,16 @@ 'identifiers': set({ tuple( 'samsungtv', - 'any', + 'be9554b9-c9fb-41f4-8920-22da015376a4', ), }), 'is_new': False, 'labels': set({ }), 'manufacturer': None, - 'model': '82GXARRS', - 'model_id': '82GXARRS', - 'name': 'fake', + 'model': None, + 'model_id': 'UE43LS003', + 'name': 'Mock Title', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, @@ -85,62 +112,3 @@ }), ]) # --- -# name: test_setup_updates_from_ssdp - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'tv', - 'friendly_name': 'any', - 'is_volume_muted': False, - 'source_list': list([ - 'TV', - 'HDMI', - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'media_player.any', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_setup_updates_from_ssdp.1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'source_list': list([ - 'TV', - 'HDMI', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'media_player', - 'entity_category': None, - 'entity_id': 'media_player.any', - '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': 'samsungtv', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'sample-entry-id', - 'unit_of_measurement': None, - }) -# --- diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 576a5f6d534..25c8bf9bab9 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -2,6 +2,7 @@ from copy import deepcopy from ipaddress import ip_address +import socket from unittest.mock import ANY, AsyncMock, Mock, call, patch import pytest @@ -17,7 +18,10 @@ from websockets import frames from websockets.exceptions import ConnectionClosedError, WebSocketException from homeassistant import config_entries -from homeassistant.components.samsungtv.config_flow import SamsungTVConfigFlow +from homeassistant.components.samsungtv.config_flow import ( + SamsungTVConfigFlow, + _strip_uuid, +) from homeassistant.components.samsungtv.const import ( CONF_MANUFACTURER, CONF_SESSION_ID, @@ -26,6 +30,7 @@ from homeassistant.components.samsungtv.const import ( DEFAULT_MANUFACTURER, DOMAIN, LEGACY_PORT, + METHOD_LEGACY, RESULT_AUTH_MISSING, RESULT_CANNOT_CONNECT, RESULT_NOT_SUPPORTED, @@ -35,102 +40,44 @@ from homeassistant.components.samsungtv.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, - CONF_ID, - CONF_IP_ADDRESS, CONF_MAC, CONF_METHOD, CONF_MODEL, - CONF_NAME, + CONF_PIN, CONF_PORT, CONF_TOKEN, ) 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 ( 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 ( - MOCK_ENTRYDATA_ENCRYPTED_WS, - MOCK_ENTRYDATA_WS, + ENTRYDATA_ENCRYPTED_WEBSOCKET, + ENTRYDATA_LEGACY, + ENTRYDATA_WEBSOCKET, + MOCK_SSDP_DATA, MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - SAMPLE_DEVICE_INFO_FRAME, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture RESULT_ALREADY_CONFIGURED = "already_configured" RESULT_ALREADY_IN_PROGRESS = "already_in_progress" -MOCK_IMPORT_DATA = { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 55000, -} -MOCK_IMPORT_DATA_WITHOUT_NAME = { - CONF_HOST: "fake_host", -} -MOCK_IMPORT_WSDATA = { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 8002, -} -MOCK_USER_DATA = {CONF_HOST: "fake_host", CONF_NAME: "fake_name"} -MOCK_SSDP_DATA = SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="https://fake_host:12345/test", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "[TV] fake_name", - ATTR_UPNP_MANUFACTURER: "Samsung fake_manufacturer", - ATTR_UPNP_MODEL_NAME: "fake_model", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", - }, -) -MOCK_SSDP_DATA_NO_MANUFACTURER = SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="https://fake_host:12345/test", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "[TV] fake_name", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", - }, -) +MOCK_USER_DATA = {CONF_HOST: "fake_host"} -MOCK_SSDP_DATA_NOPREFIX = SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="http://fake2_host:12345/test", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "fake2_name", - ATTR_UPNP_MANUFACTURER: "Samsung fake2_manufacturer", - ATTR_UPNP_MODEL_NAME: "fake2_model", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", - }, -) -MOCK_SSDP_DATA_WRONGMODEL = SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="http://fake2_host:12345/test", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "fake2_name", - ATTR_UPNP_MANUFACTURER: "fake2_manufacturer", - ATTR_UPNP_MODEL_NAME: "HW-Qfake", - ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", - }, -) MOCK_DHCP_DATA = DhcpServiceInfo( - ip="fake_host", macaddress="aabbccddeeff", hostname="fake_hostname" + ip="10.10.12.34", macaddress="aabbccddeeff", hostname="fake_hostname" ) -EXISTING_IP = "192.168.40.221" MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], @@ -145,19 +92,6 @@ MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( }, type="mock_type", ) -MOCK_OLD_ENTRY = { - CONF_HOST: "fake_host", - CONF_ID: "0d1cef00-00dc-1000-9c80-4844f7b172de_old", - CONF_IP_ADDRESS: EXISTING_IP, - CONF_METHOD: "legacy", - CONF_PORT: None, -} -MOCK_LEGACY_ENTRY = { - CONF_HOST: EXISTING_IP, - CONF_ID: "0d1cef00-00dc-1000-9c80-4844f7b172de_old", - CONF_METHOD: "legacy", - CONF_PORT: None, -} MOCK_DEVICE_INFO = { "device": { "type": "Samsung SmartTV", @@ -171,42 +105,29 @@ AUTODETECT_LEGACY = { "name": "HomeAssistant", "description": "HomeAssistant", "id": "ha.component.samsung", - "method": "legacy", - "port": None, - "host": "fake_host", + "method": METHOD_LEGACY, + "port": LEGACY_PORT, + "host": "10.20.43.21", "timeout": TIMEOUT_REQUEST, } -AUTODETECT_WEBSOCKET_PLAIN = { - "host": "fake_host", - "name": "HomeAssistant", - "port": 8001, - "timeout": TIMEOUT_REQUEST, - "token": None, -} AUTODETECT_WEBSOCKET_SSL = { - "host": "fake_host", + "host": "10.20.43.21", "name": "HomeAssistant", "port": 8002, "timeout": TIMEOUT_REQUEST, "token": None, } DEVICEINFO_WEBSOCKET_SSL = { - "host": "fake_host", + "host": "10.20.43.21", "session": ANY, "port": 8002, "timeout": TIMEOUT_WEBSOCKET, } -DEVICEINFO_WEBSOCKET_NO_SSL = { - "host": "fake_host", - "session": ANY, - "port": 8001, - "timeout": TIMEOUT_WEBSOCKET, -} pytestmark = pytest.mark.usefixtures("mock_setup_entry") -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_user_legacy(hass: HomeAssistant) -> None: """Test starting a flow by user.""" # show form @@ -216,16 +137,28 @@ async def test_user_legacy(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - # entry was added + # Wrong host allow to retry + with patch( + "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", + side_effect=socket.gaierror("[Error -2] Name or Service not known"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_host"} + + # Good host creates entry result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA ) # legacy tv entry created assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "fake_name" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake_name" - assert result["data"][CONF_METHOD] == "legacy" + assert result["title"] == "10.20.43.21" + assert result["data"][CONF_HOST] == "10.20.43.21" + assert result["data"][CONF_METHOD] == METHOD_LEGACY assert result["data"][CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert result["data"][CONF_MODEL] is None assert result["result"].unique_id is None @@ -257,16 +190,17 @@ async def test_user_legacy_does_not_ok_first_time(hass: HomeAssistant) -> None: # legacy tv entry created assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "fake_name" - assert result3["data"][CONF_HOST] == "fake_host" - assert result3["data"][CONF_NAME] == "fake_name" - assert result3["data"][CONF_METHOD] == "legacy" + assert result3["title"] == "10.20.43.21" + assert result3["data"][CONF_HOST] == "10.20.43.21" + assert result3["data"][CONF_METHOD] == METHOD_LEGACY assert result3["data"][CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert result3["data"][CONF_MODEL] is None assert result3["result"].unique_id is None -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_user_websocket(hass: HomeAssistant) -> None: """Test starting a flow by user.""" with patch( @@ -286,15 +220,14 @@ async def test_user_websocket(hass: HomeAssistant) -> None: # websocket tv entry created assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Living Room (82GXARRS)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "Living Room" + assert result["data"][CONF_HOST] == "10.20.43.21" assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remoteencws", "rest_api_non_ssl_only") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api_non_ssl_only") async def test_user_encrypted_websocket( hass: HomeAssistant, ) -> None: @@ -324,19 +257,18 @@ async def test_user_encrypted_websocket( assert result2["step_id"] == "encrypted_pairing" result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], user_input={"pin": "invalid"} + result2["flow_id"], user_input={CONF_PIN: "invalid"} ) assert result3["step_id"] == "encrypted_pairing" assert result3["errors"] == {"base": "invalid_pin"} result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], user_input={"pin": "1234"} + result3["flow_id"], user_input={CONF_PIN: "1234"} ) assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["title"] == "TV-UE48JU6470 (UE48JU6400)" - assert result4["data"][CONF_HOST] == "fake_host" - assert result4["data"][CONF_NAME] == "TV-UE48JU6470" + assert result4["data"][CONF_HOST] == "10.20.43.21" assert result4["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result4["data"][CONF_MANUFACTURER] == "Samsung" assert result4["data"][CONF_MODEL] == "UE48JU6400" @@ -388,7 +320,7 @@ async def test_user_legacy_not_supported(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("rest_api", "remote_encrypted_websocket_failing") async def test_user_websocket_not_supported(hass: HomeAssistant) -> None: """Test starting a flow by user for not supported device.""" with ( @@ -409,7 +341,7 @@ async def test_user_websocket_not_supported(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("rest_api", "remote_encrypted_websocket_failing") async def test_user_websocket_access_denied( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -433,7 +365,7 @@ async def test_user_websocket_access_denied( assert "Please check the Device Connection Manager on your TV" in caplog.text -@pytest.mark.usefixtures("rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("rest_api", "remote_encrypted_websocket_failing") async def test_user_websocket_auth_retry(hass: HomeAssistant) -> None: """Test starting a flow by user for not supported device.""" with ( @@ -467,8 +399,7 @@ async def test_user_websocket_auth_retry(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Living Room (82GXARRS)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "Living Room" + assert result["data"][CONF_HOST] == "10.20.43.21" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -514,7 +445,7 @@ async def test_user_not_successful_2(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_CANNOT_CONNECT -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_ssdp(hass: HomeAssistant) -> None: """Test starting a flow from discovery.""" # confirm to add the entry @@ -529,30 +460,32 @@ async def test_ssdp(hass: HomeAssistant) -> None: result["flow_id"], user_input="whatever" ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "fake_model" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake_model" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" - assert result["data"][CONF_MODEL] == "fake_model" - assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" + assert result["title"] == "UE55H6400" + assert result["data"][CONF_HOST] == "10.10.12.34" + assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" + assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_ssdp_no_manufacturer(hass: HomeAssistant) -> None: """Test starting a flow from discovery when the manufacturer data is missing.""" + ssdp_data = deepcopy(MOCK_SSDP_DATA) + ssdp_data.upnp.pop(ATTR_UPNP_MANUFACTURER) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_NO_MANUFACTURER, + data=ssdp_data, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_supported" + assert result["reason"] == RESULT_NOT_SUPPORTED @pytest.mark.parametrize( "data", [MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, MOCK_SSDP_DATA_RENDERING_CONTROL_ST] ) -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_ssdp_legacy_not_remote_control_receiver_udn( hass: HomeAssistant, data: SsdpServiceInfo ) -> None: @@ -564,14 +497,19 @@ async def test_ssdp_legacy_not_remote_control_receiver_udn( assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_ssdp_noprefix(hass: HomeAssistant) -> None: - """Test starting a flow from discovery without prefixes.""" + """Test starting a flow from discovery when friendly name doesn't start with [TV].""" + ssdp_data = deepcopy(MOCK_SSDP_DATA) + ssdp_data.upnp[ATTR_UPNP_FRIENDLY_NAME] = ssdp_data.upnp[ATTR_UPNP_FRIENDLY_NAME][ + 4: + ] + # confirm to add the entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_NOPREFIX, + data=ssdp_data, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -580,15 +518,14 @@ async def test_ssdp_noprefix(hass: HomeAssistant) -> None: result["flow_id"], user_input="whatever" ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "fake2_model" - assert result["data"][CONF_HOST] == "fake2_host" - assert result["data"][CONF_NAME] == "fake2_model" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake2_manufacturer" - assert result["data"][CONF_MODEL] == "fake2_model" - assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172df" + assert result["title"] == "UE55H6400" + assert result["data"][CONF_HOST] == "10.10.12.34" + assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" + assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" -@pytest.mark.usefixtures("remotews", "rest_api_failing") +@pytest.mark.usefixtures("remote_websocket", "rest_api_failing") async def test_ssdp_legacy_missing_auth(hass: HomeAssistant) -> None: """Test starting a flow from discovery with authentication.""" with patch( @@ -616,15 +553,14 @@ async def test_ssdp_legacy_missing_auth(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "fake_model" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake_model" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" - assert result["data"][CONF_MODEL] == "fake_model" - assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" + assert result["title"] == "UE55H6400" + assert result["data"][CONF_HOST] == "10.10.12.34" + assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" + assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" -@pytest.mark.usefixtures("remotews", "rest_api_failing") +@pytest.mark.usefixtures("remote_websocket", "rest_api_failing") async def test_ssdp_legacy_not_supported(hass: HomeAssistant) -> None: """Test starting a flow from discovery for not supported device.""" with patch( @@ -639,7 +575,9 @@ async def test_ssdp_legacy_not_supported(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_ssdp_websocket_success_populates_mac_address_and_ssdp_location( hass: HomeAssistant, ) -> None: @@ -657,19 +595,20 @@ async def test_ssdp_websocket_success_populates_mac_address_and_ssdp_location( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Living Room (82GXARRS)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "Living Room" + assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" + assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "82GXARRS" assert ( result["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_ssdp_websocket_success_populates_mac_address_and_main_tv_ssdp_location( hass: HomeAssistant, ) -> None: @@ -687,19 +626,18 @@ async def test_ssdp_websocket_success_populates_mac_address_and_main_tv_ssdp_loc ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Living Room (82GXARRS)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "Living Room" + assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" + assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "82GXARRS" assert ( result["data"][CONF_SSDP_MAIN_TV_AGENT_LOCATION] - == "https://fake_host:12345/tv_agent" + == "http://10.10.12.34:7676/smp_2_" ) assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remoteencws", "rest_api_non_ssl_only") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api_non_ssl_only") async def test_ssdp_encrypted_websocket_success_populates_mac_address_and_ssdp_location( hass: HomeAssistant, ) -> None: @@ -728,25 +666,24 @@ async def test_ssdp_encrypted_websocket_success_populates_mac_address_and_ssdp_l assert result2["step_id"] == "encrypted_pairing" result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], user_input={"pin": "invalid"} + result2["flow_id"], user_input={CONF_PIN: "invalid"} ) assert result3["step_id"] == "encrypted_pairing" assert result3["errors"] == {"base": "invalid_pin"} result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], user_input={"pin": "1234"} + result3["flow_id"], user_input={CONF_PIN: "1234"} ) assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["title"] == "TV-UE48JU6470 (UE48JU6400)" - assert result4["data"][CONF_HOST] == "fake_host" - assert result4["data"][CONF_NAME] == "TV-UE48JU6470" + assert result4["data"][CONF_HOST] == "10.10.12.34" assert result4["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" - assert result4["data"][CONF_MANUFACTURER] == "Samsung fake_manufacturer" + assert result4["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result4["data"][CONF_MODEL] == "UE48JU6400" assert ( result4["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) assert result4["data"][CONF_TOKEN] == "037739871315caef138547b03e348b72" assert result4["data"][CONF_SESSION_ID] == "1" @@ -785,8 +722,8 @@ async def test_ssdp_websocket_cannot_connect(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote", - ) as remotews, - patch.object(remotews, "open", side_effect=WebSocketException("Boom")), + ) as remote_websocket, + patch.object(remote_websocket, "open", side_effect=WebSocketException("Boom")), ): # device not supported result = await hass.config_entries.flow.async_init( @@ -796,21 +733,22 @@ async def test_ssdp_websocket_cannot_connect(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_CANNOT_CONNECT -@pytest.mark.usefixtures("remote") -async def test_ssdp_model_not_supported(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("remote_legacy") +async def test_ssdp_wrong_manufacturer(hass: HomeAssistant) -> None: """Test starting a flow from discovery.""" - + ssdp_data = deepcopy(MOCK_SSDP_DATA) + ssdp_data.upnp[ATTR_UPNP_MANUFACTURER] = ssdp_data.upnp[ATTR_UPNP_MANUFACTURER][7:] # confirm to add the entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_WRONGMODEL, + data=ssdp_data, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remoteencws_failing") +@pytest.mark.usefixtures("remote_encrypted_websocket_failing") async def test_ssdp_not_successful(hass: HomeAssistant) -> None: """Test starting a flow from discovery but no device found.""" with ( @@ -842,7 +780,7 @@ async def test_ssdp_not_successful(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_CANNOT_CONNECT -@pytest.mark.usefixtures("remoteencws_failing") +@pytest.mark.usefixtures("remote_encrypted_websocket_failing") async def test_ssdp_not_successful_2(hass: HomeAssistant) -> None: """Test starting a flow from discovery but no device found.""" with ( @@ -874,7 +812,7 @@ async def test_ssdp_not_successful_2(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_CANNOT_CONNECT -@pytest.mark.usefixtures("remote", "remoteencws_failing") +@pytest.mark.usefixtures("remote_legacy", "remote_encrypted_websocket_failing") async def test_ssdp_already_in_progress(hass: HomeAssistant) -> None: """Test starting a flow from discovery twice.""" with patch( @@ -896,7 +834,7 @@ async def test_ssdp_already_in_progress(hass: HomeAssistant) -> None: assert result["reason"] == RESULT_ALREADY_IN_PROGRESS -@pytest.mark.usefixtures("remotews", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket", "remote_encrypted_websocket_failing") async def test_ssdp_already_configured(hass: HomeAssistant) -> None: """Test starting a flow from discovery when already configured.""" with patch( @@ -924,7 +862,9 @@ async def test_ssdp_already_configured(hass: HomeAssistant) -> None: assert entry.unique_id == "123" -@pytest.mark.usefixtures("remotews", "rest_api_non_ssl_only", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api_non_ssl_only", "remote_encrypted_websocket_failing" +) async def test_dhcp_wireless(hass: HomeAssistant) -> None: """Test starting a flow from dhcp.""" # confirm to add the entry @@ -943,19 +883,22 @@ async def test_dhcp_wireless(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "TV-UE48JU6470 (UE48JU6400)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "TV-UE48JU6470" + assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "UE48JU6400" assert result["result"].unique_id == "223da676-497a-4e06-9507-5e27ec4f0fb3" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_dhcp_wired(hass: HomeAssistant, rest_api: Mock) -> None: """Test starting a flow from dhcp.""" # Even though it is named "wifiMac", it matches the mac of the wired connection - rest_api.rest_device_info.return_value = SAMPLE_DEVICE_INFO_FRAME + rest_api.rest_device_info.return_value = load_json_object_fixture( + "device_info_UE43LS003.json", DOMAIN + ) # confirm to add the entry result = await hass.config_entries.flow.async_init( DOMAIN, @@ -972,15 +915,16 @@ async def test_dhcp_wired(hass: HomeAssistant, rest_api: Mock) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Samsung Frame (43) (UE43LS003)" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "Samsung Frame (43)" + assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MAC] == "aa:ee:tt:hh:ee:rr" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "UE43LS003" assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api_non_ssl_only", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api_non_ssl_only", "remote_encrypted_websocket_failing" +) @pytest.mark.parametrize( ("source1", "data1", "source2", "data2", "is_matching_result"), [ @@ -1052,7 +996,9 @@ async def test_dhcp_zeroconf_already_in_progress( assert return_values == [is_matching_result] -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_zeroconf(hass: HomeAssistant) -> None: """Test starting a flow from zeroconf.""" result = await hass.config_entries.flow.async_init( @@ -1071,14 +1017,13 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Living Room (82GXARRS)" assert result["data"][CONF_HOST] == "127.0.0.1" - assert result["data"][CONF_NAME] == "Living Room" assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket", "remote_encrypted_websocket_failing") async def test_zeroconf_ignores_soundbar(hass: HomeAssistant, rest_api: Mock) -> None: """Test starting a flow from zeroconf where the device is actually a soundbar.""" rest_api.rest_device_info.return_value = { @@ -1098,10 +1043,15 @@ async def test_zeroconf_ignores_soundbar(hass: HomeAssistant, rest_api: Mock) -> ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_supported" + assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remote", "remotews", "remoteencws", "rest_api_failing") +@pytest.mark.usefixtures( + "remote_legacy", + "remote_websocket", + "remote_encrypted_websocket", + "rest_api_failing", +) async def test_zeroconf_no_device_info(hass: HomeAssistant) -> None: """Test starting a flow from zeroconf where device_info returns None.""" result = await hass.config_entries.flow.async_init( @@ -1111,10 +1061,12 @@ async def test_zeroconf_no_device_info(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_supported" + assert result["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_zeroconf_and_dhcp_same_time(hass: HomeAssistant) -> None: """Test starting a flow from zeroconf and dhcp.""" result = await hass.config_entries.flow.async_init( @@ -1136,7 +1088,7 @@ async def test_zeroconf_and_dhcp_same_time(hass: HomeAssistant) -> None: assert result2["reason"] == "already_in_progress" -@pytest.mark.usefixtures("remoteencws_failing") +@pytest.mark.usefixtures("remote_encrypted_websocket_failing") async def test_autodetect_websocket(hass: HomeAssistant) -> None: """Test for send key with autodetection of protocol.""" with ( @@ -1146,7 +1098,7 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote" - ) as remotews, + ) as remote_websocket, patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", ) as rest_api_class, @@ -1169,7 +1121,7 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: } ) remote.token = "123456789" - remotews.return_value = remote + remote_websocket.return_value = remote result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA @@ -1177,7 +1129,7 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_TOKEN] == "123456789" - remotews.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) + remote_websocket.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) rest_api_class.assert_called_once_with(**DEVICEINFO_WEBSOCKET_SSL) await hass.async_block_till_done() @@ -1186,7 +1138,7 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: assert entries[0].data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" -@pytest.mark.usefixtures("remoteencws_failing") +@pytest.mark.usefixtures("remote_encrypted_websocket_failing") async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: """Test for send key with autodetection of protocol.""" mac_address.return_value = "gg:ee:tt:mm:aa:cc" @@ -1197,7 +1149,7 @@ async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote" - ) as remotews, + ) as remote_websocket, patch( "homeassistant.components.samsungtv.bridge.SamsungTVAsyncRest", ) as rest_api_class, @@ -1219,7 +1171,7 @@ async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: ) remote.token = "123456789" - remotews.return_value = remote + remote_websocket.return_value = remote result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA @@ -1228,7 +1180,7 @@ async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_TOKEN] == "123456789" assert result["data"][CONF_MAC] == "gg:ee:tt:mm:aa:cc" - remotews.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) + remote_websocket.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) rest_api_class.assert_called_once_with(**DEVICEINFO_WEBSOCKET_SSL) await hass.async_block_till_done() @@ -1282,15 +1234,14 @@ async def test_autodetect_not_supported(hass: HomeAssistant) -> None: assert remote.call_args_list == [call(AUTODETECT_LEGACY)] -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_autodetect_legacy(hass: HomeAssistant) -> None: """Test for send key with autodetection of protocol.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_METHOD] == "legacy" - assert result["data"][CONF_NAME] == "fake_name" + assert result["data"][CONF_METHOD] == METHOD_LEGACY assert result["data"][CONF_MAC] is None assert result["data"][CONF_PORT] == LEGACY_PORT @@ -1319,17 +1270,17 @@ async def test_autodetect_none(hass: HomeAssistant) -> None: assert rest_device_info.call_count == 2 -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_old_entry(hass: HomeAssistant) -> None: - """Test update of old entry.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY) + """Test update of old entry sets unique id.""" + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_LEGACY) entry.add_to_hass(hass) config_entries_domain = hass.config_entries.async_entries(DOMAIN) assert len(config_entries_domain) == 1 assert entry is config_entries_domain[0] - assert entry.data[CONF_ID] == "0d1cef00-00dc-1000-9c80-4844f7b172de_old" - assert entry.data[CONF_IP_ADDRESS] == EXISTING_IP assert not entry.unique_id assert await async_setup_component(hass, DOMAIN, {}) is True @@ -1347,17 +1298,18 @@ async def test_update_old_entry(hass: HomeAssistant) -> None: entry2 = config_entries_domain[0] # check updated device info - assert entry2.data.get(CONF_ID) is not None - assert entry2.data.get(CONF_IP_ADDRESS) is not None assert entry2.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_update_missing_mac_unique_id_added_from_dhcp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac and unique id added.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) + # Incorrect MAC cleanup introduced in #110599, can be removed in 2026.3 + entry_data = deepcopy(ENTRYDATA_WEBSOCKET) + del entry_data[CONF_MAC] + entry = MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id=None) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -1374,12 +1326,14 @@ async def test_update_missing_mac_unique_id_added_from_dhcp( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_incorrectly_formatted_mac_unique_id_added_from_dhcp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test incorrectly formatted mac is updated and unique id added.""" - entry_data = MOCK_OLD_ENTRY.copy() + entry_data = ENTRYDATA_LEGACY.copy() entry_data[CONF_MAC] = "aabbccddeeff" entry = MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id=None) entry.add_to_hass(hass) @@ -1398,13 +1352,17 @@ async def test_update_incorrectly_formatted_mac_unique_id_added_from_dhcp( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_missing_mac_unique_id_added_from_zeroconf( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac and unique id added.""" entry = MockConfigEntry( - domain=DOMAIN, data={**MOCK_OLD_ENTRY, "host": "127.0.0.1"}, unique_id=None + domain=DOMAIN, + data={**ENTRYDATA_LEGACY, "host": "127.0.0.1"}, + unique_id=None, ) entry.add_to_hass(hass) @@ -1422,14 +1380,14 @@ async def test_update_missing_mac_unique_id_added_from_zeroconf( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remote", "rest_api_failing") +@pytest.mark.usefixtures("remote_legacy", "rest_api_failing") async def test_update_missing_model_added_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing model added via ssdp on legacy models.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_OLD_ENTRY, + data=ENTRYDATA_LEGACY, unique_id=None, ) entry.add_to_hass(hass) @@ -1444,15 +1402,17 @@ async def test_update_missing_model_added_from_ssdp( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert entry.data[CONF_MODEL] == "fake_model" + assert entry.data[CONF_MODEL] == "UE55H6400" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_missing_mac_unique_id_ssdp_location_added_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac, ssdp_location, and unique id added via ssdp.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_LEGACY, unique_id=None) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -1472,7 +1432,10 @@ async def test_update_missing_mac_unique_id_ssdp_location_added_from_ssdp( @pytest.mark.usefixtures( - "remote", "remotews", "remoteencws_failing", "rest_api_failing" + "remote_legacy", + "remote_websocket", + "remote_encrypted_websocket_failing", + "rest_api_failing", ) async def test_update_zeroconf_discovery_preserved_unique_id( hass: HomeAssistant, @@ -1480,7 +1443,7 @@ async def test_update_zeroconf_discovery_preserved_unique_id( """Test zeroconf discovery preserves unique id.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:zz:ee:rr:oo"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:zz:ee:rr:oo"}, unique_id="original", ) entry.add_to_hass(hass) @@ -1491,12 +1454,14 @@ async def test_update_zeroconf_discovery_preserved_unique_id( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_supported" + assert result["reason"] == RESULT_NOT_SUPPORTED assert entry.data[CONF_MAC] == "aa:bb:zz:ee:rr:oo" assert entry.unique_id == "original" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_missing_mac_unique_id_added_ssdp_location_updated_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: @@ -1504,7 +1469,7 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_updated_from_ssd entry = MockConfigEntry( domain=DOMAIN, data={ - **MOCK_OLD_ENTRY, + **ENTRYDATA_LEGACY, CONF_SSDP_RENDERING_CONTROL_LOCATION: "https://1.2.3.4:555/test", }, unique_id=None, @@ -1529,7 +1494,9 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_updated_from_ssd assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_missing_mac_unique_id_added_ssdp_location_rendering_st_updated_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: @@ -1537,7 +1504,7 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_rendering_st_upd entry = MockConfigEntry( domain=DOMAIN, data={ - **MOCK_OLD_ENTRY, + **ENTRYDATA_LEGACY, CONF_SSDP_RENDERING_CONTROL_LOCATION: "https://1.2.3.4:555/test", }, unique_id=None, @@ -1558,12 +1525,14 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_rendering_st_upd # Correct ST, ssdp location should change assert ( entry.data[CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_missing_mac_unique_id_added_ssdp_location_main_tv_agent_st_updated_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: @@ -1571,7 +1540,7 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_main_tv_agent_st entry = MockConfigEntry( domain=DOMAIN, data={ - **MOCK_OLD_ENTRY, + **ENTRYDATA_LEGACY, CONF_SSDP_RENDERING_CONTROL_LOCATION: "https://1.2.3.4:555/test", CONF_SSDP_MAIN_TV_AGENT_LOCATION: "https://1.2.3.4:555/test", }, @@ -1592,8 +1561,7 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_main_tv_agent_st assert entry.data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" # Main TV Agent ST, ssdp location should change assert ( - entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] - == "https://fake_host:12345/tv_agent" + entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] == "http://10.10.12.34:7676/smp_2_" ) # Rendering control should not be affected assert ( @@ -1602,14 +1570,16 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_main_tv_agent_st assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_ssdp_location_rendering_st_updated_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test with outdated ssdp_location with the correct st added via ssdp.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) @@ -1628,19 +1598,21 @@ async def test_update_ssdp_location_rendering_st_updated_from_ssdp( # Correct ST, ssdp location should be added assert ( entry.data[CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_main_tv_ssdp_location_rendering_st_updated_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test with outdated ssdp_location with the correct st added via ssdp.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) @@ -1658,20 +1630,19 @@ async def test_update_main_tv_ssdp_location_rendering_st_updated_from_ssdp( assert entry.data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" # Correct ST for MainTV, ssdp location should be added assert ( - entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] - == "https://fake_host:12345/tv_agent" + entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] == "http://10.10.12.34:7676/smp_2_" ) assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_update_missing_mac_added_unique_id_preserved_from_zeroconf( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac and unique id added.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, "host": "127.0.0.1"}, + data={**ENTRYDATA_LEGACY, "host": "127.0.0.1"}, unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) @@ -1690,14 +1661,14 @@ async def test_update_missing_mac_added_unique_id_preserved_from_zeroconf( assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_update_legacy_missing_mac_from_dhcp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac added.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_LEGACY_ENTRY, + data=ENTRYDATA_LEGACY, unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) @@ -1706,7 +1677,7 @@ async def test_update_legacy_missing_mac_from_dhcp( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" + ip="10.10.12.34", macaddress="aabbccddeeff", hostname="fake_hostname" ), ) await hass.async_block_till_done() @@ -1718,7 +1689,7 @@ async def test_update_legacy_missing_mac_from_dhcp( assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( hass: HomeAssistant, rest_api: Mock, mock_setup_entry: AsyncMock ) -> None: @@ -1726,7 +1697,7 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( rest_api.rest_device_info.side_effect = HttpApiError entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_LEGACY_ENTRY, + data=ENTRYDATA_LEGACY, ) entry.add_to_hass(hass) with ( @@ -1743,26 +1714,28 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" + ip="10.10.12.34", macaddress="aabbccddeeff", hostname="fake_hostname" ), ) await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_supported" + assert result["reason"] == RESULT_NOT_SUPPORTED assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" assert entry.unique_id is None -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_ssdp_location_unique_id_added_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing ssdp_location, and unique id added via ssdp.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, unique_id=None, ) entry.add_to_hass(hass) @@ -1783,14 +1756,16 @@ async def test_update_ssdp_location_unique_id_added_from_ssdp( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_ssdp_location_unique_id_added_from_ssdp_with_rendering_control_st( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing ssdp_location, and unique id added via ssdp with rendering control st.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, unique_id=None, ) entry.add_to_hass(hass) @@ -1809,15 +1784,15 @@ async def test_update_ssdp_location_unique_id_added_from_ssdp_with_rendering_con # Correct st assert ( entry.data[CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_form_reauth_legacy(hass: HomeAssistant) -> None: """Test reauthenticate legacy.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_LEGACY) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM @@ -1832,10 +1807,10 @@ async def test_form_reauth_legacy(hass: HomeAssistant) -> None: assert result2["reason"] == "reauth_successful" -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_form_reauth_websocket(hass: HomeAssistant) -> None: """Test reauthenticate websocket.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRYDATA_WS) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_WEBSOCKET) entry.add_to_hass(hass) assert entry.state is ConfigEntryState.NOT_LOADED @@ -1855,16 +1830,16 @@ async def test_form_reauth_websocket(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") async def test_form_reauth_websocket_cannot_connect( - hass: HomeAssistant, remotews: Mock + hass: HomeAssistant, remote_websocket: Mock ) -> None: """Test reauthenticate websocket when we cannot connect on the first attempt.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRYDATA_WS) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_WEBSOCKET) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch.object(remotews, "open", side_effect=ConnectionFailure): + with patch.object(remote_websocket, "open", side_effect=ConnectionFailure): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {}, @@ -1886,7 +1861,7 @@ async def test_form_reauth_websocket_cannot_connect( async def test_form_reauth_websocket_not_supported(hass: HomeAssistant) -> None: """Test reauthenticate websocket when the device is not supported.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRYDATA_WS) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRYDATA_WEBSOCKET) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM @@ -1903,13 +1878,13 @@ async def test_form_reauth_websocket_not_supported(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "not_supported" + assert result2["reason"] == RESULT_NOT_SUPPORTED -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_form_reauth_encrypted(hass: HomeAssistant) -> None: """Test reauth flow for encrypted TVs.""" - encrypted_entry_data = {**MOCK_ENTRYDATA_ENCRYPTED_WS} + encrypted_entry_data = deepcopy(ENTRYDATA_ENCRYPTED_WEBSOCKET) del encrypted_entry_data[CONF_TOKEN] del encrypted_entry_data[CONF_SESSION_ID] @@ -1947,14 +1922,14 @@ async def test_form_reauth_encrypted(hass: HomeAssistant) -> None: # Invalid PIN result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"pin": "invalid"} + result["flow_id"], user_input={CONF_PIN: "invalid"} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm_encrypted" # Valid PIN result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"pin": "1234"} + result["flow_id"], user_input={CONF_PIN: "1234"} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT @@ -1962,7 +1937,7 @@ async def test_form_reauth_encrypted(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.LOADED authenticator_mock.assert_called_once() - assert authenticator_mock.call_args[0] == ("fake_host",) + assert authenticator_mock.call_args[0] == ("10.10.12.34",) authenticator_mock.return_value.start_pairing.assert_called_once() assert authenticator_mock.return_value.try_pin.call_count == 2 @@ -1976,15 +1951,17 @@ async def test_form_reauth_encrypted(hass: HomeAssistant) -> None: assert entry.data[CONF_SESSION_ID] == "1" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_incorrect_udn_matching_upnp_udn_unique_id_added_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test updating the wrong udn from ssdp via upnp udn match.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_OLD_ENTRY, - unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", + data=ENTRYDATA_LEGACY, + unique_id="068e7781-006e-1000-bbbf-84a4668d8423", ) entry.add_to_hass(hass) @@ -2002,14 +1979,16 @@ async def test_update_incorrect_udn_matching_upnp_udn_unique_id_added_from_ssdp( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures( + "remote_websocket", "rest_api", "remote_encrypted_websocket_failing" +) async def test_update_incorrect_udn_matching_mac_unique_id_added_from_ssdp( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test updating the wrong udn from ssdp via mac match.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_OLD_ENTRY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_LEGACY, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, unique_id=None, ) entry.add_to_hass(hass) @@ -2028,19 +2007,25 @@ async def test_update_incorrect_udn_matching_mac_unique_id_added_from_ssdp( assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket") async def test_update_incorrect_udn_matching_mac_from_dhcp( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, rest_api: Mock, mock_setup_entry: AsyncMock ) -> None: """Test that DHCP updates the wrong udn from ssdp via mac match.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_ENTRYDATA_WS, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, + data={**ENTRYDATA_WEBSOCKET, CONF_MAC: "aa:bb:aa:aa:aa:aa"}, source=config_entries.SOURCE_SSDP, unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) + assert entry.data[CONF_HOST] == MOCK_DHCP_DATA.ip + assert entry.data[CONF_MAC] == dr.format_mac( + rest_api.rest_device_info.return_value["device"]["wifiMac"] + ) + assert entry.unique_id != _strip_uuid(rest_api.rest_device_info.return_value["id"]) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -2051,23 +2036,30 @@ async def test_update_incorrect_udn_matching_mac_from_dhcp( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert entry.data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" + + # Same IP + same MAC => unique id updated assert entry.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remotews", "rest_api", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket") async def test_no_update_incorrect_udn_not_matching_mac_from_dhcp( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, rest_api: Mock, mock_setup_entry: AsyncMock ) -> None: """Test that DHCP does not update the wrong udn from ssdp via host match.""" entry = MockConfigEntry( domain=DOMAIN, - data={**MOCK_ENTRYDATA_WS, CONF_MAC: "aa:bb:ss:ss:dd:pp"}, + data={**ENTRYDATA_WEBSOCKET, CONF_MAC: "aa:bb:ss:ss:dd:pp"}, source=config_entries.SOURCE_SSDP, unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) + assert entry.data[CONF_HOST] == MOCK_DHCP_DATA.ip + assert entry.data[CONF_MAC] != dr.format_mac( + rest_api.rest_device_info.return_value["device"]["wifiMac"] + ) + assert entry.unique_id != _strip_uuid(rest_api.rest_device_info.return_value["id"]) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -2078,11 +2070,12 @@ async def test_no_update_incorrect_udn_not_matching_mac_from_dhcp( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" - assert entry.data[CONF_MAC] == "aa:bb:ss:ss:dd:pp" + + # Same IP + different MAC => unique id not updated assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" -@pytest.mark.usefixtures("remotews", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket", "remote_encrypted_websocket_failing") async def test_ssdp_update_mac(hass: HomeAssistant) -> None: """Ensure that MAC address is correctly updated from SSDP.""" with patch( diff --git a/tests/components/samsungtv/test_device_trigger.py b/tests/components/samsungtv/test_device_trigger.py index fa6efd08076..adb80293744 100644 --- a/tests/components/samsungtv/test_device_trigger.py +++ b/tests/components/samsungtv/test_device_trigger.py @@ -16,19 +16,21 @@ from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry -from .const import MOCK_ENTRYDATA_ENCRYPTED_WS +from .const import ENTRYDATA_ENCRYPTED_WEBSOCKET from tests.common import MockConfigEntry, async_get_device_automations -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_get_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test we get the expected triggers.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} + ) turn_on_trigger = { "platform": "device", @@ -44,17 +46,19 @@ async def test_get_triggers( assert turn_on_trigger in triggers -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_if_fires_on_turn_on_request( hass: HomeAssistant, device_registry: dr.DeviceRegistry, service_calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - entity_id = "media_player.fake" + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) + entity_id = "media_player.mock_title" - device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} + ) assert await async_setup_component( hass, @@ -105,12 +109,12 @@ async def test_if_fires_on_turn_on_request( assert service_calls[2].data["id"] == 0 -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_failure_scenarios( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test failure scenarios.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) # Test wrong trigger platform type with pytest.raises(HomeAssistantError): diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index e8e0b699a7e..1704b0c0422 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -4,138 +4,63 @@ from unittest.mock import Mock import pytest from samsungtvws.exceptions import HttpApiError +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.samsungtv.const import DOMAIN from homeassistant.core import HomeAssistant from . import setup_samsungtv_entry -from .const import ( - MOCK_ENTRY_WS_WITH_MAC, - MOCK_ENTRYDATA_ENCRYPTED_WS, - SAMPLE_DEVICE_INFO_UE48JU6400, - SAMPLE_DEVICE_INFO_WIFI, -) +from .const import ENTRYDATA_ENCRYPTED_WEBSOCKET, ENTRYDATA_WEBSOCKET -from tests.common import ANY +from tests.common import load_json_object_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - config_entry = await setup_samsungtv_entry(hass, MOCK_ENTRY_WS_WITH_MAC) + config_entry = await setup_samsungtv_entry(hass, ENTRYDATA_WEBSOCKET) - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "created_at": ANY, - "data": { - "host": "fake_host", - "ip_address": "test", - "mac": "aa:bb:cc:dd:ee:ff", - "method": "websocket", - "model": "82GXARRS", - "name": "fake", - "port": 8002, - "token": REDACTED, - }, - "disabled_by": None, - "discovery_keys": {}, - "domain": "samsungtv", - "entry_id": "123456", - "minor_version": 2, - "modified_at": ANY, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "subentries": [], - "title": "Mock Title", - "unique_id": "any", - "version": 2, - }, - "device_info": SAMPLE_DEVICE_INFO_WIFI, - } + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=props("created_at", "modified_at")) -@pytest.mark.usefixtures("remoteencws") +@pytest.mark.usefixtures("remote_encrypted_websocket") async def test_entry_diagnostics_encrypted( - hass: HomeAssistant, rest_api: Mock, hass_client: ClientSessionGenerator + hass: HomeAssistant, + rest_api: Mock, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - rest_api.rest_device_info.return_value = SAMPLE_DEVICE_INFO_UE48JU6400 - config_entry = await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + rest_api.rest_device_info.return_value = load_json_object_fixture( + "device_info_UE48JU6400.json", DOMAIN + ) + config_entry = await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "created_at": ANY, - "data": { - "host": "fake_host", - "ip_address": "test", - "mac": "aa:bb:cc:dd:ee:ff", - "method": "encrypted", - "model": "UE48JU6400", - "name": "fake", - "port": 8000, - "token": REDACTED, - "session_id": REDACTED, - }, - "disabled_by": None, - "discovery_keys": {}, - "domain": "samsungtv", - "entry_id": "123456", - "minor_version": 2, - "modified_at": ANY, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "subentries": [], - "title": "Mock Title", - "unique_id": "any", - "version": 2, - }, - "device_info": SAMPLE_DEVICE_INFO_UE48JU6400, - } + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=props("created_at", "modified_at")) -@pytest.mark.usefixtures("remoteencws") +@pytest.mark.usefixtures("remote_encrypted_websocket") async def test_entry_diagnostics_encrypte_offline( - hass: HomeAssistant, rest_api: Mock, hass_client: ClientSessionGenerator + hass: HomeAssistant, + rest_api: Mock, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" rest_api.rest_device_info.side_effect = HttpApiError - config_entry = await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + config_entry = await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "created_at": ANY, - "data": { - "host": "fake_host", - "ip_address": "test", - "mac": "aa:bb:cc:dd:ee:ff", - "method": "encrypted", - "name": "fake", - "port": 8000, - "token": REDACTED, - "session_id": REDACTED, - }, - "disabled_by": None, - "discovery_keys": {}, - "domain": "samsungtv", - "entry_id": "123456", - "minor_version": 2, - "modified_at": ANY, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "subentries": [], - "title": "Mock Title", - "unique_id": "any", - "version": 2, - }, - "device_info": None, - } + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 5715bd4b0aa..74af1b72c1c 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -1,140 +1,93 @@ """Tests for the Samsung TV Integration.""" -from unittest.mock import AsyncMock, Mock, patch +from typing import Any +from unittest.mock import Mock, patch import pytest -from samsungtvws.async_remote import SamsungTVWSAsyncRemote from syrupy.assertion import SnapshotAssertion -from homeassistant.components.media_player import ( - DOMAIN as MP_DOMAIN, - MediaPlayerEntityFeature, -) from homeassistant.components.samsungtv.const import ( - CONF_MANUFACTURER, CONF_SESSION_ID, CONF_SSDP_MAIN_TV_AGENT_LOCATION, CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, - LEGACY_PORT, + METHOD_ENCRYPTED_WEBSOCKET, METHOD_LEGACY, METHOD_WEBSOCKET, UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, ) -from homeassistant.components.samsungtv.media_player import SUPPORT_SAMSUNGTV from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - CONF_HOST, - CONF_MAC, - CONF_METHOD, - CONF_NAME, - CONF_PORT, - CONF_TOKEN, - SERVICE_VOLUME_UP, -) +from homeassistant.const import CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from . import setup_samsungtv_entry from .const import ( - MOCK_ENTRY_WS_WITH_MAC, - MOCK_ENTRYDATA_ENCRYPTED_WS, - MOCK_ENTRYDATA_WS, + ENTRYDATA_ENCRYPTED_WEBSOCKET, + ENTRYDATA_LEGACY, + ENTRYDATA_WEBSOCKET, MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - SAMPLE_DEVICE_INFO_UE48JU6400, ) -from tests.common import MockConfigEntry - -ENTITY_ID = f"{MP_DOMAIN}.fake_name" -MOCK_CONFIG = { - CONF_HOST: "fake_host", - CONF_NAME: "fake_name", - CONF_METHOD: METHOD_WEBSOCKET, -} +from tests.common import MockConfigEntry, load_json_object_fixture -@pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup(hass: HomeAssistant) -> None: - """Test Samsung TV integration is setup.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - state = hass.states.get(ENTITY_ID) +@pytest.mark.parametrize( + "entry_data", + [ENTRYDATA_LEGACY, ENTRYDATA_ENCRYPTED_WEBSOCKET, ENTRYDATA_WEBSOCKET], + ids=[METHOD_LEGACY, METHOD_ENCRYPTED_WEBSOCKET, METHOD_WEBSOCKET], +) +@pytest.mark.usefixtures( + "remote_encrypted_websocket", + "remote_legacy", + "remote_websocket", + "rest_api_failing", +) +async def test_setup( + hass: HomeAssistant, + entry_data: dict[str, Any], + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test Samsung TV integration loads and fill device registry.""" + entry = await setup_samsungtv_entry(hass, entry_data) - # test name and turn_on - assert state - assert state.name == "fake_name" - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == SUPPORT_SAMSUNGTV | MediaPlayerEntityFeature.TURN_ON - ) + assert entry.state is ConfigEntryState.LOADED - # test host and port - await hass.services.async_call( - MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True - ) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + assert device_entries == snapshot -async def test_setup_without_port_device_offline(hass: HomeAssistant) -> None: - """Test import from yaml when the device is offline.""" - with ( - patch("homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError), - patch( - "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", - side_effect=OSError, - ), - patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote.open", - side_effect=OSError, - ), - patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", - return_value=None, - ), - ): - await setup_samsungtv_entry(hass, MOCK_CONFIG) - - config_entries_domain = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries_domain) == 1 - assert config_entries_domain[0].state is ConfigEntryState.SETUP_RETRY - - -@pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_without_port_device_online(hass: HomeAssistant) -> None: - """Test import from yaml when the device is online.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - - config_entries_domain = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries_domain) == 1 - assert config_entries_domain[0].data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" - - -@pytest.mark.usefixtures("remotews", "remoteencws_failing") +@pytest.mark.usefixtures("remote_websocket") async def test_setup_h_j_model( hass: HomeAssistant, rest_api: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test Samsung TV integration is setup.""" - rest_api.rest_device_info.return_value = SAMPLE_DEVICE_INFO_UE48JU6400 - await setup_samsungtv_entry(hass, MOCK_CONFIG) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) - assert state + rest_api.rest_device_info.return_value = load_json_object_fixture( + "device_info_UE48JU6400.json", DOMAIN + ) + entry = await setup_samsungtv_entry( + hass, {**ENTRYDATA_WEBSOCKET, CONF_MODEL: "UE48JU6400"} + ) + + assert entry.state is ConfigEntryState.LOADED + assert "H and J series use an encrypted protocol" in caplog.text -@pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_updates_from_ssdp( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion -) -> None: +@pytest.mark.usefixtures("remote_websocket") +async def test_setup_updates_from_ssdp(hass: HomeAssistant) -> None: """Test setting up the entry fetches data from ssdp cache.""" entry = MockConfigEntry( - domain="samsungtv", data=MOCK_ENTRYDATA_WS, entry_id="sample-entry-id" + domain="samsungtv", data=ENTRYDATA_WEBSOCKET, entry_id="sample-entry-id" ) entry.add_to_hass(hass) + assert not entry.data.get(CONF_SSDP_MAIN_TV_AGENT_LOCATION) + assert not entry.data.get(CONF_SSDP_RENDERING_CONTROL_LOCATION) + async def _mock_async_get_discovery_info_by_st(hass: HomeAssistant, mock_st: str): if mock_st == UPNP_SVC_RENDERING_CONTROL: return [MOCK_SSDP_DATA_RENDERING_CONTROL_ST] @@ -147,25 +100,20 @@ async def test_setup_updates_from_ssdp( _mock_async_get_discovery_info_by_st, ): await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done() - assert hass.states.get("media_player.any") == snapshot - assert entity_registry.async_get("media_player.any") == snapshot assert ( - entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] - == "https://fake_host:12345/tv_agent" + entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] == "http://10.10.12.34:7676/smp_2_" ) assert ( entry.data[CONF_SSDP_RENDERING_CONTROL_LOCATION] - == "https://fake_host:12345/test" + == "http://10.10.12.34:7676/smp_15_" ) -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_reauth_triggered_encrypted(hass: HomeAssistant) -> None: """Test reauth flow is triggered for encrypted TVs.""" - encrypted_entry_data = {**MOCK_ENTRYDATA_ENCRYPTED_WS} + encrypted_entry_data = {**ENTRYDATA_ENCRYPTED_WEBSOCKET} del encrypted_entry_data[CONF_TOKEN] del encrypted_entry_data[CONF_SESSION_ID] @@ -179,95 +127,16 @@ async def test_reauth_triggered_encrypted(hass: HomeAssistant) -> None: assert len(flows_in_progress) == 1 -@pytest.mark.usefixtures("remote", "remotews", "rest_api_failing") -async def test_update_imported_legacy_without_method(hass: HomeAssistant) -> None: - """Test updating an imported legacy entry without a method.""" - await setup_samsungtv_entry( - hass, {CONF_HOST: "fake_host", CONF_MANUFACTURER: "Samsung"} - ) - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].data[CONF_METHOD] == METHOD_LEGACY - assert entries[0].data[CONF_PORT] == LEGACY_PORT - - -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_incorrectly_formatted_mac_fixed(hass: HomeAssistant) -> None: """Test incorrectly formatted mac is corrected.""" - with patch( - "homeassistant.components.samsungtv.bridge.SamsungTVWSAsyncRemote" - ) as remote_class: - remote = Mock(SamsungTVWSAsyncRemote) - remote.__aenter__ = AsyncMock(return_value=remote) - remote.__aexit__ = AsyncMock() - remote.token = "123456789" - remote_class.return_value = remote - - await setup_samsungtv_entry( - hass, - { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 8001, - CONF_TOKEN: "123456789", - CONF_METHOD: METHOD_WEBSOCKET, - CONF_MAC: "aabbaaaaaaaa", - }, - ) - await hass.async_block_till_done() - - config_entries = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries) == 1 - assert config_entries[0].data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" - - -@pytest.mark.usefixtures("remotews", "rest_api") -@pytest.mark.xfail -async def test_cleanup_mac( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion -) -> None: - """Test for `none` mac cleanup #103512. - - Reverted due to device registry collisions in #119249 / #119082 - """ - entry = MockConfigEntry( - domain=DOMAIN, - data=MOCK_ENTRY_WS_WITH_MAC, - entry_id="123456", - unique_id="any", - version=2, - minor_version=1, + # Incorrect MAC cleanup introduced in #110599, can be removed in 2026.3 + await setup_samsungtv_entry( + hass, + {**ENTRYDATA_WEBSOCKET, CONF_MAC: "aabbaaaaaaaa"}, ) - entry.add_to_hass(hass) - - # Setup initial device registry, with incorrect MAC - device_registry.async_get_or_create( - config_entry_id="123456", - connections={ - (dr.CONNECTION_NETWORK_MAC, "none"), - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), - }, - identifiers={("samsungtv", "any")}, - model="82GXARRS", - name="fake", - ) - device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) - assert device_entries == snapshot - assert device_entries[0].connections == { - (dr.CONNECTION_NETWORK_MAC, "none"), - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), - } - - # Run setup, and ensure the NONE mac is removed - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) - assert device_entries == snapshot - assert device_entries[0].connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") - } - - assert entry.version == 2 - assert entry.minor_version == 2 + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0].data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 3d9633bbf96..58797b67423 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -1,7 +1,7 @@ """Tests for samsungtv component.""" from copy import deepcopy -from datetime import datetime, timedelta +from datetime import timedelta import logging from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, Mock, call, patch @@ -41,6 +41,7 @@ from homeassistant.components.samsungtv.const import ( CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, ENCRYPTED_WEBSOCKET_PORT, + ENTRY_RELOAD_COOLDOWN, METHOD_ENCRYPTED_WEBSOCKET, METHOD_WEBSOCKET, TIMEOUT_WEBSOCKET, @@ -52,7 +53,6 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, CONF_HOST, - CONF_IP_ADDRESS, CONF_MAC, CONF_METHOD, CONF_MODEL, @@ -76,23 +76,24 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotSupported +from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util -from . import async_wait_config_entry_reload, setup_samsungtv_entry +from . import setup_samsungtv_entry from .const import ( - MOCK_CONFIG, - MOCK_ENTRY_WS_WITH_MAC, - MOCK_ENTRYDATA_ENCRYPTED_WS, - SAMPLE_DEVICE_INFO_FRAME, + ENTRYDATA_ENCRYPTED_WEBSOCKET, + ENTRYDATA_LEGACY, + ENTRYDATA_WEBSOCKET, SAMPLE_DEVICE_INFO_WIFI, - SAMPLE_EVENT_ED_INSTALLED_APP, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) -ENTITY_ID = f"{MP_DOMAIN}.fake" +ENTITY_ID = f"{MP_DOMAIN}.mock_title" MOCK_CONFIGWS = { CONF_HOST: "fake_host", CONF_NAME: "fake", @@ -109,7 +110,6 @@ MOCK_CALLS_WS = { } MOCK_ENTRY_WS = { - CONF_IP_ADDRESS: "test", CONF_HOST: "fake_host", CONF_METHOD: "websocket", CONF_NAME: "fake", @@ -119,14 +119,14 @@ MOCK_ENTRY_WS = { } -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_setup(hass: HomeAssistant) -> None: """Test setup of platform.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) assert hass.states.get(ENTITY_ID) -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_setup_websocket(hass: HomeAssistant) -> None: """Test setup of platform.""" with patch( @@ -153,15 +153,12 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") async def test_setup_websocket_2( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test setup of platform from config entry.""" - entity_id = f"{MP_DOMAIN}.fake" - entry = MockConfigEntry( domain=DOMAIN, data=MOCK_ENTRY_WS, - unique_id=entity_id, ) entry.add_to_hass(hass) @@ -182,19 +179,18 @@ async def test_setup_websocket_2( assert config_entries[0].data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(entity_id) + state = hass.states.get(ENTITY_ID) assert state remote_class.assert_called_once_with(**MOCK_CALLS_WS) @pytest.mark.usefixtures("rest_api") async def test_setup_encrypted_websocket( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test setup of platform from config entry.""" with patch( @@ -205,11 +201,10 @@ async def test_setup_encrypted_websocket( remote.__aexit__ = AsyncMock() remote_class.return_value = remote - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -217,36 +212,30 @@ async def test_setup_encrypted_websocket( remote_class.assert_called_once() -@pytest.mark.usefixtures("remote") -async def test_update_on( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime -) -> None: +@pytest.mark.usefixtures("remote_legacy") +async def test_update_on(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Testing update tv on.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON -@pytest.mark.usefixtures("remote") -async def test_update_off( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime -) -> None: +@pytest.mark.usefixtures("remote_legacy") +async def test_update_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Testing update tv off.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -256,9 +245,8 @@ async def test_update_off( async def test_update_off_ws_no_power_state( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - remotews: Mock, + remote_websocket: Mock, rest_api: Mock, - mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -269,12 +257,11 @@ async def test_update_off_ws_no_power_state( state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON - remotews.start_listening = Mock(side_effect=WebSocketException("Boom")) - remotews.is_alive.return_value = False + remote_websocket.start_listening = Mock(side_effect=WebSocketException("Boom")) + remote_websocket.is_alive.return_value = False - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -282,13 +269,12 @@ async def test_update_off_ws_no_power_state( rest_api.rest_device_info.assert_not_called() -@pytest.mark.usefixtures("remotews") +@pytest.mark.usefixtures("remote_websocket") async def test_update_off_ws_with_power_state( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - remotews: Mock, + remote_websocket: Mock, rest_api: Mock, - mock_now: datetime, ) -> None: """Testing update tv off.""" with ( @@ -296,7 +282,7 @@ async def test_update_off_ws_with_power_state( rest_api, "rest_device_info", side_effect=HttpApiError ) as mock_device_info, patch.object( - remotews, "start_listening", side_effect=WebSocketException("Boom") + remote_websocket, "start_listening", side_effect=WebSocketException("Boom") ) as mock_start_listening, ): await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -311,25 +297,25 @@ async def test_update_off_ws_with_power_state( device_info = deepcopy(SAMPLE_DEVICE_INFO_WIFI) device_info["device"]["PowerState"] = "on" rest_api.rest_device_info.return_value = device_info - next_update = mock_now + timedelta(minutes=1) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - remotews.start_listening.assert_called_once() + remote_websocket.start_listening.assert_called_once() rest_api.rest_device_info.assert_called_once() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON # After initial update, start_listening shouldn't be called - remotews.start_listening.reset_mock() + remote_websocket.start_listening.reset_mock() # Second update uses device_info(ON) rest_api.rest_device_info.reset_mock() - next_update = mock_now + timedelta(minutes=2) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) rest_api.rest_device_info.assert_called_once() @@ -340,9 +326,9 @@ async def test_update_off_ws_with_power_state( # Third update uses device_info (OFF) rest_api.rest_device_info.reset_mock() device_info["device"]["PowerState"] = "off" - next_update = mock_now + timedelta(minutes=3) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) rest_api.rest_device_info.assert_called_once() @@ -350,30 +336,30 @@ async def test_update_off_ws_with_power_state( state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE - remotews.start_listening.assert_not_called() + remote_websocket.start_listening.assert_not_called() async def test_update_off_encryptedws( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - remoteencws: Mock, + remote_encrypted_websocket: Mock, rest_api: Mock, - mock_now: datetime, ) -> None: """Testing update tv off.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) rest_api.rest_device_info.assert_called_once() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON - remoteencws.start_listening = Mock(side_effect=WebSocketException("Boom")) - remoteencws.is_alive.return_value = False + remote_encrypted_websocket.start_listening = Mock( + side_effect=WebSocketException("Boom") + ) + remote_encrypted_websocket.is_alive.return_value = False - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -381,25 +367,23 @@ async def test_update_off_encryptedws( rest_api.rest_device_info.assert_called_once() -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_update_access_denied( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv access denied exception.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=exceptions.AccessDenied("Boom"), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - next_update = mock_now + timedelta(minutes=10) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert [ @@ -415,8 +399,7 @@ async def test_update_access_denied( async def test_update_ws_connection_failure( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - mock_now: datetime, - remotews: Mock, + remote_websocket: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Testing update tv connection failure exception.""" @@ -424,15 +407,14 @@ async def test_update_ws_connection_failure( with ( patch.object( - remotews, + remote_websocket, "start_listening", side_effect=ConnectionFailure('{"event": "ms.voiceApp.hide"}'), ), - patch.object(remotews, "is_alive", return_value=False), + patch.object(remote_websocket, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert ( @@ -447,23 +429,21 @@ async def test_update_ws_connection_failure( @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_closed( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_now: datetime, - remotews: Mock, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remote_websocket: Mock ) -> None: """Testing update tv connection failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with ( patch.object( - remotews, "start_listening", side_effect=ConnectionClosedError(None, None) + remote_websocket, + "start_listening", + side_effect=ConnectionClosedError(None, None), ), - patch.object(remotews, "is_alive", return_value=False), + patch.object(remote_websocket, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -472,21 +452,19 @@ async def test_update_ws_connection_closed( @pytest.mark.usefixtures("rest_api") async def test_update_ws_unauthorized_error( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_now: datetime, - remotews: Mock, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, remote_websocket: Mock ) -> None: """Testing update tv unauthorized failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with ( - patch.object(remotews, "start_listening", side_effect=UnauthorizedError), - patch.object(remotews, "is_alive", return_value=False), + patch.object( + remote_websocket, "start_listening", side_effect=UnauthorizedError + ), + patch.object(remote_websocket, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert [ @@ -498,71 +476,68 @@ async def test_update_ws_unauthorized_error( assert state.state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_update_unhandled_response( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv unhandled response exception.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[exceptions.UnhandledResponse("Boom"), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_connection_closed_during_update_can_recover( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Testing update tv connection closed exception can recover.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[exceptions.ConnectionClosed(), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE - next_update = mock_now + timedelta(minutes=10) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON -async def test_send_key(hass: HomeAssistant, remote: Mock) -> None: +async def test_send_key(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for send key.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_VOLUP")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_VOLUP")] assert state.state == STATE_ON -async def test_send_key_broken_pipe(hass: HomeAssistant, remote: Mock) -> None: +async def test_send_key_broken_pipe(hass: HomeAssistant, remote_legacy: Mock) -> None: """Testing broken pipe Exception.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - remote.control = Mock(side_effect=BrokenPipeError("Boom")) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) + remote_legacy.control = Mock(side_effect=BrokenPipeError("Boom")) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -571,11 +546,11 @@ async def test_send_key_broken_pipe(hass: HomeAssistant, remote: Mock) -> None: async def test_send_key_connection_closed_retry_succeed( - hass: HomeAssistant, remote: Mock + hass: HomeAssistant, remote_legacy: Mock ) -> None: """Test retry on connection closed.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - remote.control = Mock( + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) + remote_legacy.control = Mock( side_effect=[exceptions.ConnectionClosed("Boom"), DEFAULT_MOCK, DEFAULT_MOCK] ) await hass.services.async_call( @@ -583,30 +558,36 @@ async def test_send_key_connection_closed_retry_succeed( ) state = hass.states.get(ENTITY_ID) # key because of retry two times - assert remote.control.call_count == 2 - assert remote.control.call_args_list == [ + assert remote_legacy.control.call_count == 2 + assert remote_legacy.control.call_args_list == [ call("KEY_VOLUP"), call("KEY_VOLUP"), ] assert state.state == STATE_ON -async def test_send_key_unhandled_response(hass: HomeAssistant, remote: Mock) -> None: +async def test_send_key_unhandled_response( + hass: HomeAssistant, remote_legacy: Mock +) -> None: """Testing unhandled response exception.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - remote.control = Mock(side_effect=exceptions.UnhandledResponse("Boom")) - await hass.services.async_call( - MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True - ) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) + remote_legacy.control = Mock(side_effect=exceptions.UnhandledResponse("Boom")) + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True + ) + assert err.value.translation_key == "error_sending_command" state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @pytest.mark.usefixtures("rest_api") -async def test_send_key_websocketexception(hass: HomeAssistant, remotews: Mock) -> None: +async def test_send_key_websocketexception( + hass: HomeAssistant, remote_websocket: Mock +) -> None: """Testing unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands = Mock(side_effect=WebSocketException("Boom")) + remote_websocket.send_commands = Mock(side_effect=WebSocketException("Boom")) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -616,11 +597,13 @@ async def test_send_key_websocketexception(hass: HomeAssistant, remotews: Mock) @pytest.mark.usefixtures("rest_api") async def test_send_key_websocketexception_encrypted( - hass: HomeAssistant, remoteencws: Mock + hass: HomeAssistant, remote_encrypted_websocket: Mock ) -> None: """Testing unhandled response exception.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - remoteencws.send_commands = Mock(side_effect=WebSocketException("Boom")) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) + remote_encrypted_websocket.send_commands = Mock( + side_effect=WebSocketException("Boom") + ) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -629,10 +612,12 @@ async def test_send_key_websocketexception_encrypted( @pytest.mark.usefixtures("rest_api") -async def test_send_key_os_error_ws(hass: HomeAssistant, remotews: Mock) -> None: +async def test_send_key_os_error_ws( + hass: HomeAssistant, remote_websocket: Mock +) -> None: """Testing unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands = Mock(side_effect=OSError("Boom")) + remote_websocket.send_commands = Mock(side_effect=OSError("Boom")) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -642,11 +627,11 @@ async def test_send_key_os_error_ws(hass: HomeAssistant, remotews: Mock) -> None @pytest.mark.usefixtures("rest_api") async def test_send_key_os_error_ws_encrypted( - hass: HomeAssistant, remoteencws: Mock + hass: HomeAssistant, remote_encrypted_websocket: Mock ) -> None: """Testing unhandled response exception.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - remoteencws.send_commands = Mock(side_effect=OSError("Boom")) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) + remote_encrypted_websocket.send_commands = Mock(side_effect=OSError("Boom")) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -654,10 +639,10 @@ async def test_send_key_os_error_ws_encrypted( assert state.state == STATE_ON -async def test_send_key_os_error(hass: HomeAssistant, remote: Mock) -> None: +async def test_send_key_os_error(hass: HomeAssistant, remote_legacy: Mock) -> None: """Testing broken pipe Exception.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - remote.control = Mock(side_effect=OSError("Boom")) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) + remote_legacy.control = Mock(side_effect=OSError("Boom")) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -665,18 +650,18 @@ async def test_send_key_os_error(hass: HomeAssistant, remote: Mock) -> None: assert state.state == STATE_ON -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_name(hass: HomeAssistant) -> None: """Test for name property.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake" + assert state.attributes[ATTR_FRIENDLY_NAME] == "Mock Title" -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test for state property.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -689,13 +674,12 @@ async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> Non # Should be STATE_UNAVAILABLE after the timer expires assert state.state == STATE_OFF - next_update = dt_util.utcnow() + timedelta(seconds=20) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError, ): - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -703,48 +687,50 @@ async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> Non assert state.state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_supported_features(hass: HomeAssistant) -> None: """Test for supported_features property.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_SAMSUNGTV -@pytest.mark.usefixtures("remote") +@pytest.mark.usefixtures("remote_legacy") async def test_device_class(hass: HomeAssistant) -> None: """Test for device_class property.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_DEVICE_CLASS] == MediaPlayerDeviceClass.TV @pytest.mark.usefixtures("rest_api") async def test_turn_off_websocket( - hass: HomeAssistant, remotews: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, remote_websocket: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test for turn_off.""" - remotews.app_list_data = SAMPLE_EVENT_ED_INSTALLED_APP + remote_websocket.app_list_data = load_json_object_fixture( + "ws_installed_app_event.json", DOMAIN + ) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remotews.send_commands.call_count == 1 - commands = remotews.send_commands.call_args_list[0].args[0] + assert remote_websocket.send_commands.call_count == 1 + commands = remote_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(commands[0], SendRemoteKey) assert commands[0].params["DataOfCmd"] == "KEY_POWER" # commands not sent : power off in progress - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -756,28 +742,30 @@ async def test_turn_off_websocket( True, ) assert "TV is powering off, not sending launch_app command" in caplog.text - remotews.send_commands.assert_not_called() + remote_websocket.send_commands.assert_not_called() async def test_turn_off_websocket_frame( - hass: HomeAssistant, remotews: Mock, rest_api: Mock + hass: HomeAssistant, remote_websocket: Mock, rest_api: Mock ) -> None: """Test for turn_off.""" - rest_api.rest_device_info.return_value = SAMPLE_DEVICE_INFO_FRAME + rest_api.rest_device_info.return_value = load_json_object_fixture( + "device_info_UE43LS003.json", DOMAIN + ) with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remotews.send_commands.call_count == 1 - commands = remotews.send_commands.call_args_list[0].args[0] + assert remote_websocket.send_commands.call_count == 1 + commands = remote_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 3 assert isinstance(commands[0], SendRemoteKey) assert commands[0].params["Cmd"] == "Press" @@ -790,36 +778,38 @@ async def test_turn_off_websocket_frame( async def test_turn_off_encrypted_websocket( - hass: HomeAssistant, remoteencws: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + remote_encrypted_websocket: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test for turn_off.""" - entry_data = deepcopy(MOCK_ENTRYDATA_ENCRYPTED_WS) + entry_data = deepcopy(ENTRYDATA_ENCRYPTED_WEBSOCKET) entry_data[CONF_MODEL] = "UE48UNKNOWN" await setup_samsungtv_entry(hass, entry_data) - remoteencws.send_commands.reset_mock() + remote_encrypted_websocket.send_commands.reset_mock() caplog.clear() await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remoteencws.send_commands.call_count == 1 - commands = remoteencws.send_commands.call_args_list[0].args[0] + assert remote_encrypted_websocket.send_commands.call_count == 1 + commands = remote_encrypted_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 2 assert isinstance(command := commands[0], SamsungTVEncryptedCommand) assert command.body["param3"] == "KEY_POWEROFF" assert isinstance(command := commands[1], SamsungTVEncryptedCommand) assert command.body["param3"] == "KEY_POWER" - assert "Unknown power_off command for UE48UNKNOWN (fake_host)" in caplog.text + assert "Unknown power_off command for UE48UNKNOWN (10.10.12.34)" in caplog.text # commands not sent : power off in progress - remoteencws.send_commands.reset_mock() + remote_encrypted_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert "TV is powering off, not sending keys: ['KEY_VOLUP']" in caplog.text - remoteencws.send_commands.assert_not_called() + remote_encrypted_websocket.send_commands.assert_not_called() @pytest.mark.parametrize( @@ -828,49 +818,49 @@ async def test_turn_off_encrypted_websocket( ) async def test_turn_off_encrypted_websocket_key_type( hass: HomeAssistant, - remoteencws: Mock, + remote_encrypted_websocket: Mock, caplog: pytest.LogCaptureFixture, model: str, expected_key_type: str, ) -> None: """Test for turn_off.""" - entry_data = deepcopy(MOCK_ENTRYDATA_ENCRYPTED_WS) + entry_data = deepcopy(ENTRYDATA_ENCRYPTED_WEBSOCKET) entry_data[CONF_MODEL] = model await setup_samsungtv_entry(hass, entry_data) - remoteencws.send_commands.reset_mock() + remote_encrypted_websocket.send_commands.reset_mock() caplog.clear() await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remoteencws.send_commands.call_count == 1 - commands = remoteencws.send_commands.call_args_list[0].args[0] + assert remote_encrypted_websocket.send_commands.call_count == 1 + commands = remote_encrypted_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(command := commands[0], SamsungTVEncryptedCommand) assert command.body["param3"] == expected_key_type assert "Unknown power_off command for" not in caplog.text -async def test_turn_off_legacy(hass: HomeAssistant, remote: Mock) -> None: +async def test_turn_off_legacy(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for turn_off.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_POWEROFF")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_POWEROFF")] async def test_turn_off_os_error( - hass: HomeAssistant, remote: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, remote_legacy: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) - await setup_samsungtv_entry(hass, MOCK_CONFIG) - remote.close = Mock(side_effect=OSError("BOOM")) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) + remote_legacy.close = Mock(side_effect=OSError("BOOM")) await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -879,12 +869,12 @@ async def test_turn_off_os_error( @pytest.mark.usefixtures("rest_api") async def test_turn_off_ws_os_error( - hass: HomeAssistant, remotews: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, remote_websocket: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.close = Mock(side_effect=OSError("BOOM")) + remote_websocket.close = Mock(side_effect=OSError("BOOM")) await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -893,43 +883,45 @@ async def test_turn_off_ws_os_error( @pytest.mark.usefixtures("rest_api") async def test_turn_off_encryptedws_os_error( - hass: HomeAssistant, remoteencws: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + remote_encrypted_websocket: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - remoteencws.close = Mock(side_effect=OSError("BOOM")) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) + remote_encrypted_websocket.close = Mock(side_effect=OSError("BOOM")) await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert "Error closing connection" in caplog.text -async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: +async def test_volume_up(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for volume_up.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_VOLUP")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_VOLUP")] -async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: +async def test_volume_down(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for volume_down.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_VOLDOWN")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_VOLDOWN")] -async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: +async def test_mute_volume(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for mute_volume.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_MUTE, @@ -937,75 +929,75 @@ async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: True, ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_MUTE")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_MUTE")] -async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: +async def test_media_play(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for media_play.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_PLAY")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_PLAY")] await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 2 - assert remote.control.call_args_list == [call("KEY_PLAY"), call("KEY_PAUSE")] + assert remote_legacy.control.call_count == 2 + assert remote_legacy.control.call_args_list == [call("KEY_PLAY"), call("KEY_PAUSE")] -async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: +async def test_media_pause(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for media_pause.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_PAUSE")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_PAUSE")] await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 2 - assert remote.control.call_args_list == [call("KEY_PAUSE"), call("KEY_PLAY")] + assert remote_legacy.control.call_count == 2 + assert remote_legacy.control.call_args_list == [call("KEY_PAUSE"), call("KEY_PLAY")] -async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: +async def test_media_next_track(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for media_next_track.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_CHUP")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_CHUP")] -async def test_media_previous_track(hass: HomeAssistant, remote: Mock) -> None: +async def test_media_previous_track(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for media_previous_track.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_CHDOWN")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_CHDOWN")] -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_turn_on_wol(hass: HomeAssistant) -> None: """Test turn on.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_ENTRY_WS_WITH_MAC, - unique_id="any", + data=ENTRYDATA_WEBSOCKET, + unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1020,21 +1012,21 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: assert mock_send_magic_packet.called -async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: +async def test_turn_on_without_turnon(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test turn on.""" await async_setup_component(hass, "homeassistant", {}) - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) 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 ) # nothing called as not supported feature - assert remote.control.call_count == 0 + assert remote_legacy.control.call_count == 0 -async def test_play_media(hass: HomeAssistant, remote: Mock) -> None: +async def test_play_media(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for play_media.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) with patch("homeassistant.components.samsungtv.bridge.asyncio.sleep") as sleep: await hass.services.async_call( MP_DOMAIN, @@ -1047,8 +1039,8 @@ async def test_play_media(hass: HomeAssistant, remote: Mock) -> None: True, ) # keys and update called - assert remote.control.call_count == 4 - assert remote.control.call_args_list == [ + assert remote_legacy.control.call_count == 4 + assert remote_legacy.control.call_args_list == [ call("KEY_5"), call("KEY_7"), call("KEY_6"), @@ -1061,7 +1053,7 @@ async def test_play_media_invalid_type(hass: HomeAssistant) -> None: """Test for play_media with invalid media type.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: url = "https://example.com" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) remote.reset_mock() await hass.services.async_call( MP_DOMAIN, @@ -1081,7 +1073,7 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: """Test for play_media with invalid channel as string.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: url = "https://example.com" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) remote.reset_mock() await hass.services.async_call( MP_DOMAIN, @@ -1100,7 +1092,7 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: """Test for play_media with invalid channel as non positive integer.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) remote.reset_mock() await hass.services.async_call( MP_DOMAIN, @@ -1116,9 +1108,9 @@ async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: assert remote.control.call_count == 0 -async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: +async def test_select_source(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test for select_source.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) await hass.services.async_call( MP_DOMAIN, SERVICE_SELECT_SOURCE, @@ -1126,30 +1118,40 @@ async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: True, ) # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_HDMI")] + assert remote_legacy.control.call_count == 1 + assert remote_legacy.control.call_args_list == [call("KEY_HDMI")] async def test_select_source_invalid_source(hass: HomeAssistant) -> None: """Test for select_source with invalid source.""" + + source = "INVALID" + with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: - await setup_samsungtv_entry(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) remote.reset_mock() - await hass.services.async_call( - MP_DOMAIN, - SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "INVALID"}, - True, - ) + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: source}, + True, + ) # control not called assert remote.control.call_count == 0 + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "source_unsupported" + assert exc_info.value.translation_placeholders == { + "entity": ENTITY_ID, + "source": source, + } @pytest.mark.usefixtures("rest_api") -async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: +async def test_play_media_app(hass: HomeAssistant, remote_websocket: Mock) -> None: """Test for play_media.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, @@ -1161,19 +1163,21 @@ async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: }, True, ) - assert remotews.send_commands.call_count == 1 - commands = remotews.send_commands.call_args_list[0].args[0] + assert remote_websocket.send_commands.call_count == 1 + commands = remote_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(commands[0], ChannelEmitCommand) assert commands[0].params["data"]["appId"] == "3201608010191" @pytest.mark.usefixtures("rest_api") -async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: +async def test_select_source_app(hass: HomeAssistant, remote_websocket: Mock) -> None: """Test for select_source.""" - remotews.app_list_data = SAMPLE_EVENT_ED_INSTALLED_APP + remote_websocket.app_list_data = load_json_object_fixture( + "ws_installed_app_event.json", DOMAIN + ) await setup_samsungtv_entry(hass, MOCK_CONFIGWS) - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, @@ -1181,8 +1185,8 @@ async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "Deezer"}, True, ) - assert remotews.send_commands.call_count == 1 - commands = remotews.send_commands.call_args_list[0].args[0] + assert remote_websocket.send_commands.call_count == 1 + commands = remote_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(commands[0], ChannelEmitCommand) assert commands[0].params["data"]["appId"] == "3201608010191" @@ -1190,7 +1194,10 @@ async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: @pytest.mark.usefixtures("rest_api") async def test_websocket_unsupported_remote_control( - hass: HomeAssistant, remotews: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + remote_websocket: Mock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test for turn_off.""" entry = await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1198,12 +1205,12 @@ async def test_websocket_unsupported_remote_control( assert entry.data[CONF_METHOD] == METHOD_WEBSOCKET assert entry.data[CONF_PORT] == 8001 - remotews.send_commands.reset_mock() + remote_websocket.send_commands.reset_mock() await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - remotews.raise_mock_ws_event_callback( + remote_websocket.raise_mock_ws_event_callback( "ms.error", { "event": "ms.error", @@ -1212,8 +1219,8 @@ async def test_websocket_unsupported_remote_control( ) # key called - assert remotews.send_commands.call_count == 1 - commands = remotews.send_commands.call_args_list[0].args[0] + assert remote_websocket.send_commands.call_count == 1 + commands = remote_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(commands[0], SendRemoteKey) assert commands[0].params["DataOfCmd"] == "KEY_POWER" @@ -1224,7 +1231,12 @@ async def test_websocket_unsupported_remote_control( "'unrecognized method value : ms.remote.control'" in caplog.text ) - await async_wait_config_entry_reload(hass) + # Wait config_entry reload + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + # ensure reauth triggered, and method/port updated assert [ flow @@ -1237,10 +1249,8 @@ async def test_websocket_unsupported_remote_control( assert state.state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("remotews", "rest_api", "upnp_notify_server") -async def test_volume_control_upnp( - hass: HomeAssistant, dmr_device: Mock, caplog: pytest.LogCaptureFixture -) -> None: +@pytest.mark.usefixtures("remote_websocket", "rest_api", "upnp_notify_server") +async def test_volume_control_upnp(hass: HomeAssistant, dmr_device: Mock) -> None: """Test for Upnp volume control.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1256,24 +1266,24 @@ async def test_volume_control_upnp( True, ) dmr_device.async_set_volume_level.assert_called_once_with(0.5) - assert "Unable to set volume level on" not in caplog.text # Upnp action failed dmr_device.async_set_volume_level.reset_mock() dmr_device.async_set_volume_level.side_effect = UpnpActionResponseError( status=500, error_code=501, error_desc="Action Failed" ) - await hass.services.async_call( - MP_DOMAIN, - SERVICE_VOLUME_SET, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6}, - True, - ) + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.6}, + True, + ) + assert err.value.translation_key == "error_set_volume" dmr_device.async_set_volume_level.assert_called_once_with(0.6) - assert "Unable to set volume level on" in caplog.text -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_upnp_not_available( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1291,7 +1301,7 @@ async def test_upnp_not_available( assert "Upnp services are not available" in caplog.text -@pytest.mark.usefixtures("remotews", "rest_api", "upnp_factory") +@pytest.mark.usefixtures("remote_websocket", "rest_api", "upnp_factory") async def test_upnp_missing_service( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1309,7 +1319,7 @@ async def test_upnp_missing_service( assert "Upnp services are not available" in caplog.text -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_upnp_shutdown( hass: HomeAssistant, dmr_device: Mock, @@ -1330,7 +1340,7 @@ async def test_upnp_shutdown( upnp_notify_server.async_stop_server.assert_called_once() -@pytest.mark.usefixtures("remotews", "rest_api", "upnp_notify_server") +@pytest.mark.usefixtures("remote_websocket", "rest_api", "upnp_notify_server") async def test_upnp_subscribe_events(hass: HomeAssistant, dmr_device: Mock) -> None: """Test for Upnp event feedback.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1350,7 +1360,7 @@ async def test_upnp_subscribe_events(hass: HomeAssistant, dmr_device: Mock) -> N assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is True -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_upnp_subscribe_events_upnperror( hass: HomeAssistant, dmr_device: Mock, @@ -1365,7 +1375,7 @@ async def test_upnp_subscribe_events_upnperror( assert "Error while subscribing during device connect" in caplog.text -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_upnp_subscribe_events_upnpresponseerror( hass: HomeAssistant, dmr_device: Mock, @@ -1388,9 +1398,8 @@ async def test_upnp_subscribe_events_upnpresponseerror( async def test_upnp_re_subscribe_events( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - remotews: Mock, + remote_websocket: Mock, dmr_device: Mock, - mock_now: datetime, ) -> None: """Test for Upnp event feedback.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1402,13 +1411,12 @@ async def test_upnp_re_subscribe_events( with ( patch.object( - remotews, "start_listening", side_effect=WebSocketException("Boom") + remote_websocket, "start_listening", side_effect=WebSocketException("Boom") ), - patch.object(remotews, "is_alive", return_value=False), + patch.object(remote_websocket, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -1416,9 +1424,8 @@ async def test_upnp_re_subscribe_events( assert dmr_device.async_subscribe_services.call_count == 1 assert dmr_device.async_unsubscribe_services.call_count == 1 - next_update = mock_now + timedelta(minutes=10) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -1435,9 +1442,8 @@ async def test_upnp_re_subscribe_events( async def test_upnp_failed_re_subscribe_events( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - remotews: Mock, + remote_websocket: Mock, dmr_device: Mock, - mock_now: datetime, caplog: pytest.LogCaptureFixture, error: Exception, ) -> None: @@ -1451,13 +1457,12 @@ async def test_upnp_failed_re_subscribe_events( with ( patch.object( - remotews, "start_listening", side_effect=WebSocketException("Boom") + remote_websocket, "start_listening", side_effect=WebSocketException("Boom") ), - patch.object(remotews, "is_alive", return_value=False), + patch.object(remote_websocket, "is_alive", return_value=False), ): - next_update = mock_now + timedelta(minutes=5) - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) @@ -1465,10 +1470,9 @@ async def test_upnp_failed_re_subscribe_events( assert dmr_device.async_subscribe_services.call_count == 1 assert dmr_device.async_unsubscribe_services.call_count == 1 - next_update = mock_now + timedelta(minutes=10) with patch.object(dmr_device, "async_subscribe_services", side_effect=error): - freezer.move_to(next_update) - async_fire_time_changed(hass, next_update) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) diff --git a/tests/components/samsungtv/test_remote.py b/tests/components/samsungtv/test_remote.py index 854c92207bf..ec161773c1e 100644 --- a/tests/components/samsungtv/test_remote.py +++ b/tests/components/samsungtv/test_remote.py @@ -17,39 +17,41 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_samsungtv_entry -from .const import MOCK_CONFIG, MOCK_ENTRY_WS_WITH_MAC, MOCK_ENTRYDATA_ENCRYPTED_WS +from .const import ENTRYDATA_ENCRYPTED_WEBSOCKET, ENTRYDATA_LEGACY, ENTRYDATA_WEBSOCKET from tests.common import MockConfigEntry -ENTITY_ID = f"{REMOTE_DOMAIN}.fake" +ENTITY_ID = f"{REMOTE_DOMAIN}.mock_title" -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_setup(hass: HomeAssistant) -> None: """Test setup with basic config.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) assert hass.states.get(ENTITY_ID) -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test unique id.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) main = entity_registry.async_get(ENTITY_ID) - assert main.unique_id == "any" + assert main.unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") async def test_main_services( - hass: HomeAssistant, remoteencws: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + remote_encrypted_websocket: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test for turn_off.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - remoteencws.send_commands.reset_mock() + remote_encrypted_websocket.send_commands.reset_mock() await hass.services.async_call( REMOTE_DOMAIN, @@ -59,8 +61,8 @@ async def test_main_services( ) # key called - assert remoteencws.send_commands.call_count == 1 - commands = remoteencws.send_commands.call_args_list[0].args[0] + assert remote_encrypted_websocket.send_commands.call_count == 1 + commands = remote_encrypted_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 2 assert isinstance(command := commands[0], SamsungTVEncryptedCommand) assert command.body["param3"] == "KEY_POWEROFF" @@ -68,7 +70,7 @@ async def test_main_services( assert command.body["param3"] == "KEY_POWER" # commands not sent : power off in progress - remoteencws.send_commands.reset_mock() + remote_encrypted_websocket.send_commands.reset_mock() await hass.services.async_call( REMOTE_DOMAIN, SERVICE_SEND_COMMAND, @@ -76,13 +78,15 @@ async def test_main_services( blocking=True, ) assert "TV is powering off, not sending keys: ['dash']" in caplog.text - remoteencws.send_commands.assert_not_called() + remote_encrypted_websocket.send_commands.assert_not_called() -@pytest.mark.usefixtures("remoteencws", "rest_api") -async def test_send_command_service(hass: HomeAssistant, remoteencws: Mock) -> None: +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") +async def test_send_command_service( + hass: HomeAssistant, remote_encrypted_websocket: Mock +) -> None: """Test the send command.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) await hass.services.async_call( REMOTE_DOMAIN, @@ -91,20 +95,20 @@ async def test_send_command_service(hass: HomeAssistant, remoteencws: Mock) -> N blocking=True, ) - assert remoteencws.send_commands.call_count == 1 - commands = remoteencws.send_commands.call_args_list[0].args[0] + assert remote_encrypted_websocket.send_commands.call_count == 1 + commands = remote_encrypted_websocket.send_commands.call_args_list[0].args[0] assert len(commands) == 1 assert isinstance(command := commands[0], SamsungTVEncryptedCommand) assert command.body["param3"] == "dash" -@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.usefixtures("remote_websocket", "rest_api") async def test_turn_on_wol(hass: HomeAssistant) -> None: """Test turn on.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_ENTRY_WS_WITH_MAC, - unique_id="any", + data=ENTRYDATA_WEBSOCKET, + unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) @@ -119,12 +123,17 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: assert mock_send_magic_packet.called -async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: +async def test_turn_on_without_turnon(hass: HomeAssistant, remote_legacy: Mock) -> None: """Test turn on.""" - await setup_samsungtv_entry(hass, MOCK_CONFIG) - with pytest.raises(HomeAssistantError, match="does not support this service"): + await setup_samsungtv_entry(hass, ENTRYDATA_LEGACY) + with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( REMOTE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # nothing called as not supported feature - assert remote.control.call_count == 0 + assert remote_legacy.control.call_count == 0 + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "service_unsupported" + assert exc_info.value.translation_placeholders == { + "entity": ENTITY_ID, + } diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index e1d26043bb0..e2155bca834 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -12,12 +12,12 @@ from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry -from .const import MOCK_ENTRYDATA_ENCRYPTED_WS +from .const import ENTRYDATA_ENCRYPTED_WEBSOCKET from tests.common import MockEntity, MockEntityPlatform -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") @pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_turn_on_trigger_device_id( hass: HomeAssistant, @@ -26,11 +26,13 @@ async def test_turn_on_trigger_device_id( entity_domain: str, ) -> None: """Test for turn_on triggers by device_id firing.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - entity_id = f"{entity_domain}.fake" + entity_id = f"{entity_domain}.mock_title" - device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4")} + ) assert device, repr(device_registry.devices) assert await async_setup_component( @@ -82,15 +84,15 @@ async def test_turn_on_trigger_device_id( mock_send_magic_packet.assert_called() -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") @pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_turn_on_trigger_entity_id( hass: HomeAssistant, service_calls: list[ServiceCall], entity_domain: str ) -> None: """Test for turn_on triggers by entity_id firing.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) - entity_id = f"{entity_domain}.fake" + entity_id = f"{entity_domain}.mock_title" assert await async_setup_component( hass, @@ -124,13 +126,13 @@ async def test_turn_on_trigger_entity_id( assert service_calls[1].data["id"] == 0 -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") @pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_wrong_trigger_platform_type( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, entity_domain: str ) -> None: """Test wrong trigger platform type.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) entity_id = f"{entity_domain}.fake" await async_setup_component( @@ -161,13 +163,13 @@ async def test_wrong_trigger_platform_type( ) -@pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.usefixtures("remote_encrypted_websocket", "rest_api") @pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_trigger_invalid_entity_id( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, entity_domain: str ) -> None: """Test turn on trigger using invalid entity_id.""" - await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + await setup_samsungtv_entry(hass, ENTRYDATA_ENCRYPTED_WEBSOCKET) entity_id = f"{entity_domain}.fake" platform = MockEntityPlatform(hass) diff --git a/tests/components/sanix/test_sensor.py b/tests/components/sanix/test_sensor.py index d9729ca3c25..f7fbfa61f3f 100644 --- a/tests/components/sanix/test_sensor.py +++ b/tests/components/sanix/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/schlage/test_select.py b/tests/components/schlage/test_select.py index 59ff065d449..c18ceb0ec8e 100644 --- a/tests/components/schlage/test_select.py +++ b/tests/components/schlage/test_select.py @@ -2,13 +2,17 @@ from unittest.mock import Mock +from pyschlage.lock import AUTO_LOCK_TIMES + +from homeassistant.components.schlage.const import DOMAIN from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.translation import LOCALE_EN, async_get_translations from . import MockSchlageConfigEntry @@ -32,3 +36,12 @@ async def test_select( blocking=True, ) mock_lock.set_auto_lock_time.assert_called_once_with(30) + + +async def test_auto_lock_time_translations(hass: HomeAssistant) -> None: + """Test all auto_lock_time select options are translated.""" + prefix = f"component.{DOMAIN}.entity.{Platform.SELECT.value}.auto_lock_time.state." + translations = await async_get_translations(hass, LOCALE_EN, "entity", [DOMAIN]) + got_translation_states = {k for k in translations if k.startswith(prefix)} + want_translation_states = {f"{prefix}{t}" for t in AUTO_LOCK_TIMES} + assert want_translation_states == got_translation_states diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index dc9bf912c2d..c97e2cd3716 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -594,6 +594,8 @@ async def test_templates_with_yaml(hass: HomeAssistant) -> None: CONF_INDEX: 0, CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}', + CONF_ICON: 'mdi:o{{ "n" if states("sensor.input1")=="on" else "ff" }}', + CONF_PICTURE: 'o{{ "n" if states("sensor.input1")=="on" else "ff" }}.jpg', } ], } @@ -613,6 +615,8 @@ async def test_availability( state = hass.states.get("sensor.current_version") assert state.state == "2021.12.10" + assert state.attributes["icon"] == "mdi:on" + assert state.attributes["entity_picture"] == "on.jpg" hass.states.async_set("sensor.input1", "off") await hass.async_block_till_done() @@ -623,3 +627,93 @@ async def test_availability( state = hass.states.get("sensor.current_version") assert state.state == STATE_UNAVAILABLE + assert "icon" not in state.attributes + assert "entity_picture" not in state.attributes + + +async def test_template_render_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test availability template render with syntax errors.""" + config = { + DOMAIN: [ + return_integration_config( + sensors=[ + { + "select": ".current-version h1", + "name": "Current version", + "unique_id": "ha_version_unique_id", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_AVAILABILITY: "{{ what_the_heck == 2 }}", + } + ] + ) + ] + } + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.rest.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.current_version") + assert state.state == "2021.12.10" + + assert ( + "Error rendering availability template for sensor.current_version: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.current_version: 'x' is undefined" + config = { + DOMAIN: [ + return_integration_config( + sensors=[ + { + "select": ".current-version h1", + "name": "Current version", + "unique_id": "ha_version_unique_id", + CONF_VALUE_TEMPLATE: "{{ x - 1 }}", + CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}', + } + ] + ) + ] + } + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.rest.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("sensor.current_version") + assert state + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.input1", "on") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=10), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/sensibo/test_coordinator.py b/tests/components/sensibo/test_coordinator.py index 6cb8e6fe923..2d56fc4c51c 100644 --- a/tests/components/sensibo/test_coordinator.py +++ b/tests/components/sensibo/test_coordinator.py @@ -9,6 +9,7 @@ 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 @@ -25,6 +26,7 @@ async def test_coordinator( mock_client: MagicMock, get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the Sensibo coordinator with errors.""" config_entry = MockConfigEntry( @@ -87,3 +89,5 @@ async def test_coordinator( mock_data.assert_called_once() state = hass.states.get("climate.hallway") assert state.state == STATE_UNAVAILABLE + + assert "Platform sensibo does not generate unique IDs" not in caplog.text diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 8ea76036123..7b7450b97a4 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -11,7 +11,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -45,3 +45,14 @@ async def test_sensor( state = hass.states.get("sensor.kitchen_pure_aqi") assert state.state == "moderate" + + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].pm25_pure = PureAQI(0) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.kitchen_pure_aqi") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/sensor/common.py b/tests/components/sensor/common.py index 458009b2690..4fb9a1e4f7f 100644 --- a/tests/components/sensor/common.py +++ b/tests/components/sensor/common.py @@ -14,6 +14,7 @@ from homeassistant.const import ( UnitOfApparentPower, UnitOfFrequency, UnitOfPressure, + UnitOfReactiveEnergy, UnitOfReactivePower, UnitOfVolume, ) @@ -44,6 +45,7 @@ UNITS_OF_MEASUREMENT = { SensorDeviceClass.ENERGY: "kWh", # energy (Wh/kWh/MWh) SensorDeviceClass.FREQUENCY: UnitOfFrequency.GIGAHERTZ, # energy (Hz/kHz/MHz/GHz) SensorDeviceClass.POWER_FACTOR: PERCENTAGE, # power factor (no unit, min: -1.0, max: 1.0) + SensorDeviceClass.REACTIVE_ENERGY: UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, # reactive energy (varh) SensorDeviceClass.REACTIVE_POWER: UnitOfReactivePower.VOLT_AMPERE_REACTIVE, # reactive power (var) SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of vocs SensorDeviceClass.VOLTAGE: "V", # voltage (V) diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index a9781e0b800..68488d29c67 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -119,7 +119,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert len(conditions) == 27 + assert len(conditions) == 28 assert conditions == unordered(expected_conditions) diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index f35c9520f71..bf7147e30e1 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -121,7 +121,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 27 + assert len(triggers) == 28 assert triggers == unordered(expected_triggers) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index b162200f95e..e8daff09b7c 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -24,6 +24,7 @@ from homeassistant.components.sensor import ( async_rounded_state, async_update_suggested_units, ) +from homeassistant.components.sensor.const import STATE_CLASS_UNITS from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -1993,6 +1994,7 @@ async def test_non_numeric_device_class_with_unit_of_measurement( SensorDeviceClass.PRECIPITATION_INTENSITY, SensorDeviceClass.PRECIPITATION, SensorDeviceClass.PRESSURE, + SensorDeviceClass.REACTIVE_ENERGY, SensorDeviceClass.REACTIVE_POWER, SensorDeviceClass.SIGNAL_STRENGTH, SensorDeviceClass.SOUND_PRESSURE, @@ -2005,6 +2007,7 @@ async def test_non_numeric_device_class_with_unit_of_measurement( SensorDeviceClass.VOLUME, SensorDeviceClass.WATER, SensorDeviceClass.WEIGHT, + SensorDeviceClass.WIND_DIRECTION, SensorDeviceClass.WIND_SPEED, ], ) @@ -2035,6 +2038,37 @@ async def test_device_classes_with_invalid_unit_of_measurement( ) in caplog.text +@pytest.mark.parametrize( + "state_class", + [SensorStateClass.MEASUREMENT_ANGLE], +) +async def test_state_classes_with_invalid_unit_of_measurement( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + state_class: SensorStateClass, +) -> None: + """Test error when unit of measurement is not valid for used state class.""" + entity0 = MockSensor( + name="Test", + native_value="1.0", + state_class=state_class, + native_unit_of_measurement="INVALID!", + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + units = { + str(unit) if unit else "no unit of measurement" + for unit in STATE_CLASS_UNITS.get(state_class, set()) + } + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + f"Sensor sensor.test ({entity0.__class__}) is using native unit of " + "measurement 'INVALID!' which is not a valid unit " + f"for the state class ('{state_class}') it is using; expected one of {units};" + ) in caplog.text + + @pytest.mark.parametrize( ("device_class", "state_class", "unit"), [ diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index a5b6a07dde5..43f185f939a 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,7 +1,8 @@ """The tests for sensor recorder platform.""" -from collections.abc import Iterable +from collections.abc import Callable, Iterable from datetime import datetime, timedelta +import logging import math from statistics import mean from typing import Any, Literal @@ -26,17 +27,30 @@ from homeassistant.components.recorder.db_schema import ( ) from homeassistant.components.recorder.models import ( StatisticData, + StatisticMeanType, StatisticMetaData, process_timestamp, ) from homeassistant.components.recorder.statistics import ( + DEG_TO_RAD, + RAD_TO_DEG, async_import_statistics, get_metadata, list_statistic_ids, ) from homeassistant.components.recorder.util import get_instance, session_scope -from homeassistant.components.sensor import ATTR_OPTIONS, DOMAIN, SensorDeviceClass -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE +from homeassistant.components.sensor import ( + ATTR_OPTIONS, + DOMAIN, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.components.sensor.recorder import ( + MEAN_TYPE_CHANGED_ISSUE, + STATE_CLASS_REMOVED_ISSUE, + UNITS_CHANGED_ISSUE, +) +from homeassistant.const import ATTR_FRIENDLY_NAME, DEGREE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -98,6 +112,13 @@ KW_SENSOR_ATTRIBUTES = { "state_class": "measurement", "unit_of_measurement": "kW", } +WIND_DIRECTION_ATTRIBUTES = { + "device_class": SensorDeviceClass.WIND_DIRECTION, + "state_class": SensorStateClass.MEASUREMENT_ANGLE, + "unit_of_measurement": DEGREE, +} +WIND_DIRECTION_STATES_SEQ = [350, 0, 15] +TEMP_STATES_SEQ = [-10, 15, 30, 60] @pytest.fixture @@ -281,6 +302,7 @@ async def test_compile_hourly_statistics( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -306,6 +328,64 @@ async def test_compile_hourly_statistics( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics for measurement_angle.""" + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + with freeze_time(zero) as freezer: + four, states = await async_record_states( + hass, + freezer, + zero, + "sensor.test1", + WIND_DIRECTION_ATTRIBUTES, + seq=WIND_DIRECTION_STATES_SEQ, + ) + await async_wait_recording_done(hass) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + await async_wait_recording_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(0.5802544), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( ( "device_class", @@ -349,7 +429,7 @@ async def test_compile_hourly_statistics_with_some_same_last_updated( "unit_of_measurement": state_unit, } attributes = dict(attributes) - seq = [-10, 15, 30, 60] + seq = TEMP_STATES_SEQ async def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -395,6 +475,7 @@ async def test_compile_hourly_statistics_with_some_same_last_updated( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -420,33 +501,167 @@ async def test_compile_hourly_statistics_with_some_same_last_updated( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_with_some_same_last_updated_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics with the some of the same last updated value for measurement_angle. + + If the last updated value is the same we will have a zero duration. + """ + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + entity_id = "sensor.test1" + seq = [350, 2, 15, 345] + + async def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.async_set(entity_id, state, **kwargs) + await async_wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + + states = {entity_id: []} + with freeze_time(one) as freezer: + states[entity_id].append( + await set_state( + entity_id, str(seq[0]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + + # Record two states at the exact same time + freezer.move_to(two) + states[entity_id].append( + await set_state( + entity_id, str(seq[1]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + states[entity_id].append( + await set_state( + entity_id, str(seq[2]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + + freezer.move_to(three) + states[entity_id].append( + await set_state( + entity_id, str(seq[3]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + await async_wait_recording_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(6.274605), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( ( - "device_class", - "state_unit", + "attributes", "display_unit", "statistics_unit", "unit_class", "mean", "min", "max", + "mean_type", + "seq", ), [ - ("temperature", "°C", "°C", "°C", "temperature", 60, -10, 60), - ("temperature", "°F", "°F", "°F", "temperature", 60, -10, 60), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°C", + }, + "°C", + "°C", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°F", + }, + "°F", + "°F", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + WIND_DIRECTION_ATTRIBUTES, + DEGREE, + DEGREE, + None, + 15, + None, + None, + StatisticMeanType.CIRCULAR, + [350, 0, 355, 15], + ), ], ) async def test_compile_hourly_statistics_with_all_same_last_updated( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - device_class, - state_unit, - display_unit, - statistics_unit, - unit_class, - mean, - min, - max, + attributes: dict[str, Any], + display_unit: str, + statistics_unit: str, + unit_class: str | None, + mean: float | None, + min: float | None, + max: float | None, + mean_type: StatisticMeanType, + seq: list[float], ) -> None: """Test compiling hourly statistics with the all of the same last updated value. @@ -457,13 +672,6 @@ async def test_compile_hourly_statistics_with_all_same_last_updated( # Wait for the sensor recorder platform to be added await async_recorder_block_till_done(hass) entity_id = "sensor.test1" - attributes = { - "device_class": device_class, - "state_class": "measurement", - "unit_of_measurement": state_unit, - } - attributes = dict(attributes) - seq = [-10, 15, 30, 60] async def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -503,7 +711,8 @@ async def test_compile_hourly_statistics_with_all_same_last_updated( { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": True, + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": False, "name": None, "source": "recorder", @@ -531,45 +740,79 @@ async def test_compile_hourly_statistics_with_all_same_last_updated( @pytest.mark.parametrize( ( - "device_class", - "state_unit", + "attributes", "display_unit", "statistics_unit", "unit_class", "mean", "min", "max", + "mean_type", + "seq", ), [ - ("temperature", "°C", "°C", "°C", "temperature", 0, 60, 60), - ("temperature", "°F", "°F", "°F", "temperature", 0, 60, 60), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°C", + }, + "°C", + "°C", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°F", + }, + "°F", + "°F", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + WIND_DIRECTION_ATTRIBUTES, + DEGREE, + DEGREE, + None, + 15, + None, + None, + StatisticMeanType.CIRCULAR, + [350, 0, 355, 15], + ), ], ) -async def test_compile_hourly_statistics_only_state_is_and_end_of_period( +async def test_compile_hourly_statistics_only_state_is_at_end_of_period( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - device_class, - state_unit, - display_unit, - statistics_unit, - unit_class, - mean, - min, - max, + attributes: dict[str, Any], + display_unit: str, + statistics_unit: str, + unit_class: str | None, + mean: float | None, + min: float | None, + max: float | None, + mean_type: StatisticMeanType, + seq: list[float], ) -> None: - """Test compiling hourly statistics when the only state at end of period.""" + """Test compiling hourly statistics when the only states are at end of period.""" zero = get_start_time(dt_util.utcnow()) await async_setup_component(hass, "sensor", {}) # Wait for the sensor recorder platform to be added await async_recorder_block_till_done(hass) entity_id = "sensor.test1" - attributes = { - "device_class": device_class, - "state_class": "measurement", - "unit_of_measurement": state_unit, - } - attributes = dict(attributes) - seq = [-10, 15, 30, 60] async def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -604,13 +847,15 @@ async def test_compile_hourly_statistics_only_state_is_and_end_of_period( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) + do_adhoc_statistics(hass, start=zero + timedelta(minutes=5)) await async_wait_recording_done(hass) statistic_ids = await async_list_statistic_ids(hass) assert statistic_ids == [ { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": True, + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": False, "name": None, "source": "recorder", @@ -622,8 +867,8 @@ async def test_compile_hourly_statistics_only_state_is_and_end_of_period( assert stats == { "sensor.test1": [ { - "start": process_timestamp(zero).timestamp(), - "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "start": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=10)).timestamp(), "mean": pytest.approx(mean), "min": pytest.approx(min), "max": pytest.approx(max), @@ -651,7 +896,10 @@ async def test_compile_hourly_statistics_purged_state_changes( statistics_unit, unit_class, ) -> None: - """Test compiling hourly statistics.""" + """Test compiling hourly statistics. + + This tests statistics falls back to the state machine when states are purged. + """ zero = get_start_time(dt_util.utcnow()) await async_setup_component(hass, "sensor", {}) # Wait for the sensor recorder platform to be added @@ -691,6 +939,7 @@ async def test_compile_hourly_statistics_purged_state_changes( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -716,6 +965,94 @@ async def test_compile_hourly_statistics_purged_state_changes( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + ( + "device_class", + "state_unit", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + (None, "%", "%", "%", "unitless", 13.050847, -10, 30), + ], +) +async def test_compile_hourly_statistics_ignore_future_state( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics. + + This tests statistics does not fall back to the state machine if the state + in the state machine is newer than the end of the statistics period. + """ + zero = get_start_time(dt_util.utcnow() + timedelta(minutes=5)) + previous_period = zero - timedelta(minutes=5) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + with freeze_time(zero) as freezer: + four, states = await async_record_states( + hass, freezer, zero, "sensor.test1", attributes + ) + await async_wait_recording_done(hass) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=previous_period) + do_adhoc_statistics(hass, start=zero) + await async_wait_recording_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, previous_period, period="5minute") + # Check we get no stats from the previous period + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES]) async def test_compile_hourly_statistics_wrong_unit( hass: HomeAssistant, @@ -782,6 +1119,7 @@ async def test_compile_hourly_statistics_wrong_unit( "statistic_id": "sensor.test1", "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -791,6 +1129,7 @@ async def test_compile_hourly_statistics_wrong_unit( { "display_unit_of_measurement": "invalid", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -801,6 +1140,7 @@ async def test_compile_hourly_statistics_wrong_unit( { "display_unit_of_measurement": None, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -812,6 +1152,7 @@ async def test_compile_hourly_statistics_wrong_unit( "statistic_id": "sensor.test6", "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -822,6 +1163,7 @@ async def test_compile_hourly_statistics_wrong_unit( "statistic_id": "sensor.test7", "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -993,6 +1335,7 @@ async def test_compile_hourly_sum_statistics_amount( "statistic_id": "sensor.test1", "display_unit_of_measurement": statistics_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1197,6 +1540,7 @@ async def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1306,6 +1650,7 @@ async def test_compile_hourly_sum_statistics_amount_invalid_last_reset( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1402,6 +1747,7 @@ async def test_compile_hourly_sum_statistics_nan_inf_state( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1545,6 +1891,7 @@ async def test_compile_hourly_sum_statistics_negative_state( assert { "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1646,6 +1993,7 @@ async def test_compile_hourly_sum_statistics_total_no_reset( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1759,6 +2107,7 @@ async def test_compile_hourly_sum_statistics_total_increasing( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1885,6 +2234,7 @@ async def test_compile_hourly_sum_statistics_total_increasing_small_dip( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1989,6 +2339,7 @@ async def test_compile_hourly_energy_statistics_unsupported( "statistic_id": "sensor.test1", "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2091,6 +2442,7 @@ async def test_compile_hourly_energy_statistics_multiple( "statistic_id": "sensor.test1", "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2101,6 +2453,7 @@ async def test_compile_hourly_energy_statistics_multiple( "statistic_id": "sensor.test2", "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2111,6 +2464,7 @@ async def test_compile_hourly_energy_statistics_multiple( "statistic_id": "sensor.test3", "display_unit_of_measurement": "Wh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2293,8 +2647,64 @@ async def test_compile_hourly_statistics_unchanged( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_unchanged_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics, with no changes during the hour for measurement_angle.""" + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + with freeze_time(zero) as freezer: + four, states = await async_record_states( + hass, + freezer, + zero, + "sensor.test1", + WIND_DIRECTION_ATTRIBUTES, + seq=WIND_DIRECTION_STATES_SEQ, + ) + await async_wait_recording_done(hass) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=four) + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, four, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(four).timestamp(), + "end": process_timestamp(four + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(15), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + ("attributes", "expected_mean", "expected_min", "expected_max"), + [ + (TEMPERATURE_SENSOR_ATTRIBUTES, 21.1864406779661, 10.0, 25.0), + (WIND_DIRECTION_ATTRIBUTES, 21.202479155239875, None, None), + ], +) async def test_compile_hourly_statistics_partially_unavailable( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + attributes: dict, + expected_mean: float, + expected_min: float | None, + expected_max: float | None, ) -> None: """Test compiling hourly statistics, with the sensor being partially unavailable.""" zero = get_start_time(dt_util.utcnow()) @@ -2302,7 +2712,7 @@ async def test_compile_hourly_statistics_partially_unavailable( # Wait for the sensor recorder platform to be added await async_recorder_block_till_done(hass) four, states = await async_record_states_partially_unavailable( - hass, zero, "sensor.test1", TEMPERATURE_SENSOR_ATTRIBUTES + hass, zero, "sensor.test1", attributes ) await async_wait_recording_done(hass) hist = history.get_significant_states( @@ -2318,9 +2728,9 @@ async def test_compile_hourly_statistics_partially_unavailable( { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), - "mean": pytest.approx(21.1864406779661), - "min": pytest.approx(10.0), - "max": pytest.approx(25.0), + "mean": pytest.approx(expected_mean), + "min": pytest.approx(expected_min), + "max": pytest.approx(expected_max), "last_reset": None, "state": None, "sum": None, @@ -2411,6 +2821,58 @@ async def test_compile_hourly_statistics_unavailable( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_unavailable_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics, with one sensor being unavailable for measurement_angle. + + sensor.test1 is unavailable and should not have statistics generated + sensor.test2 should have statistics generated + """ + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + four, states = await async_record_states_partially_unavailable( + hass, zero, "sensor.test1", WIND_DIRECTION_ATTRIBUTES + ) + with freeze_time(zero) as freezer: + _, _states = await async_record_states( + hass, + freezer, + zero, + "sensor.test2", + WIND_DIRECTION_ATTRIBUTES, + seq=WIND_DIRECTION_STATES_SEQ, + ) + await async_wait_recording_done(hass) + states = {**states, **_states} + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=four) + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, four, period="5minute") + assert stats == { + "sensor.test2": [ + { + "start": process_timestamp(four).timestamp(), + "end": process_timestamp(four + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(15), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + async def test_compile_hourly_statistics_fails( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -2439,59 +2901,267 @@ async def test_compile_hourly_statistics_fails( "statistic_type", ), [ - ("measurement", "area", "m²", "m²", "m²", "area", "mean"), - ("measurement", "area", "mi²", "mi²", "mi²", "area", "mean"), + ("measurement", "area", "m²", "m²", "m²", "area", StatisticMeanType.ARITHMETIC), + ( + "measurement", + "area", + "mi²", + "mi²", + "mi²", + "area", + StatisticMeanType.ARITHMETIC, + ), ("total", "area", "m²", "m²", "m²", "area", "sum"), ("total", "area", "mi²", "mi²", "mi²", "area", "sum"), - ("measurement", "battery", "%", "%", "%", "unitless", "mean"), - ("measurement", "battery", None, None, None, "unitless", "mean"), - ("measurement", "distance", "m", "m", "m", "distance", "mean"), - ("measurement", "distance", "mi", "mi", "mi", "distance", "mean"), + ( + "measurement", + "battery", + "%", + "%", + "%", + "unitless", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "battery", + None, + None, + None, + "unitless", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "distance", + "m", + "m", + "m", + "distance", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "distance", + "mi", + "mi", + "mi", + "distance", + StatisticMeanType.ARITHMETIC, + ), ("total", "distance", "m", "m", "m", "distance", "sum"), ("total", "distance", "mi", "mi", "mi", "distance", "sum"), ("total", "energy", "Wh", "Wh", "Wh", "energy", "sum"), ("total", "energy", "kWh", "kWh", "kWh", "energy", "sum"), - ("measurement", "energy", "Wh", "Wh", "Wh", "energy", "mean"), - ("measurement", "energy", "kWh", "kWh", "kWh", "energy", "mean"), - ("measurement", "humidity", "%", "%", "%", "unitless", "mean"), - ("measurement", "humidity", None, None, None, "unitless", "mean"), + ( + "measurement", + "energy", + "Wh", + "Wh", + "Wh", + "energy", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "energy", + "kWh", + "kWh", + "kWh", + "energy", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "humidity", + "%", + "%", + "%", + "unitless", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "humidity", + None, + None, + None, + "unitless", + StatisticMeanType.ARITHMETIC, + ), ("total", "monetary", "USD", "USD", "USD", None, "sum"), ("total", "monetary", "None", "None", "None", None, "sum"), ("total", "gas", "m³", "m³", "m³", "volume", "sum"), ("total", "gas", "ft³", "ft³", "ft³", "volume", "sum"), - ("measurement", "monetary", "USD", "USD", "USD", None, "mean"), - ("measurement", "monetary", "None", "None", "None", None, "mean"), - ("measurement", "gas", "m³", "m³", "m³", "volume", "mean"), - ("measurement", "gas", "ft³", "ft³", "ft³", "volume", "mean"), - ("measurement", "pressure", "Pa", "Pa", "Pa", "pressure", "mean"), - ("measurement", "pressure", "hPa", "hPa", "hPa", "pressure", "mean"), - ("measurement", "pressure", "mbar", "mbar", "mbar", "pressure", "mean"), - ("measurement", "pressure", "inHg", "inHg", "inHg", "pressure", "mean"), - ("measurement", "pressure", "psi", "psi", "psi", "pressure", "mean"), - ("measurement", "speed", "m/s", "m/s", "m/s", "speed", "mean"), - ("measurement", "speed", "mph", "mph", "mph", "speed", "mean"), - ("measurement", "temperature", "°C", "°C", "°C", "temperature", "mean"), - ("measurement", "temperature", "°F", "°F", "°F", "temperature", "mean"), - ("measurement", "volume", "m³", "m³", "m³", "volume", "mean"), - ("measurement", "volume", "ft³", "ft³", "ft³", "volume", "mean"), + ( + "measurement", + "monetary", + "USD", + "USD", + "USD", + None, + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "monetary", + "None", + "None", + "None", + None, + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "gas", + "m³", + "m³", + "m³", + "volume", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "gas", + "ft³", + "ft³", + "ft³", + "volume", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "Pa", + "Pa", + "Pa", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "hPa", + "hPa", + "hPa", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "mbar", + "mbar", + "mbar", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "inHg", + "inHg", + "inHg", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "psi", + "psi", + "psi", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "speed", + "m/s", + "m/s", + "m/s", + "speed", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "speed", + "mph", + "mph", + "mph", + "speed", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "temperature", + "°C", + "°C", + "°C", + "temperature", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "temperature", + "°F", + "°F", + "°F", + "temperature", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "volume", + "m³", + "m³", + "m³", + "volume", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "volume", + "ft³", + "ft³", + "ft³", + "volume", + StatisticMeanType.ARITHMETIC, + ), ("total", "volume", "m³", "m³", "m³", "volume", "sum"), ("total", "volume", "ft³", "ft³", "ft³", "volume", "sum"), - ("measurement", "weight", "g", "g", "g", "mass", "mean"), - ("measurement", "weight", "oz", "oz", "oz", "mass", "mean"), + ("measurement", "weight", "g", "g", "g", "mass", StatisticMeanType.ARITHMETIC), + ( + "measurement", + "weight", + "oz", + "oz", + "oz", + "mass", + StatisticMeanType.ARITHMETIC, + ), ("total", "weight", "g", "g", "g", "mass", "sum"), ("total", "weight", "oz", "oz", "oz", "mass", "sum"), + ( + SensorStateClass.MEASUREMENT_ANGLE, + SensorDeviceClass.WIND_DIRECTION, + DEGREE, + DEGREE, + DEGREE, + None, + StatisticMeanType.CIRCULAR, + ), ], ) async def test_list_statistic_ids( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - state_class, - device_class, - state_unit, - display_unit, - statistics_unit, - unit_class, - statistic_type, + state_class: str | SensorStateClass, + device_class: str | SensorDeviceClass, + state_unit: str, + display_unit: str, + statistics_unit: str, + unit_class: str | None, + statistic_type: str | StatisticMeanType, ) -> None: """Test listing future statistic ids.""" await async_setup_component(hass, "sensor", {}) @@ -2505,11 +3175,20 @@ async def test_list_statistic_ids( } hass.states.async_set("sensor.test1", 0, attributes=attributes) statistic_ids = await async_list_statistic_ids(hass) + mean_type = ( + statistic_type + if isinstance(statistic_type, StatisticMeanType) + else StatisticMeanType.NONE + ) + statistic_type = ( + statistic_type if not isinstance(statistic_type, StatisticMeanType) else "mean" + ) assert statistic_ids == [ { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": statistic_type == "mean", + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": statistic_type == "sum", "name": None, "source": "recorder", @@ -2517,6 +3196,7 @@ async def test_list_statistic_ids( "unit_class": unit_class, }, ] + for stat_type in ("mean", "sum", "dogs"): statistic_ids = await async_list_statistic_ids(hass, statistic_type=stat_type) if statistic_type == stat_type: @@ -2524,7 +3204,8 @@ async def test_list_statistic_ids( { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": statistic_type == "mean", + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": statistic_type == "sum", "name": None, "source": "recorder", @@ -2632,6 +3313,7 @@ async def test_compile_hourly_statistics_changing_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2667,6 +3349,7 @@ async def test_compile_hourly_statistics_changing_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2758,6 +3441,7 @@ async def test_compile_hourly_statistics_changing_units_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": "cats", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2840,6 +3524,7 @@ async def test_compile_hourly_statistics_changing_units_3( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2875,6 +3560,7 @@ async def test_compile_hourly_statistics_changing_units_3( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2957,6 +3643,7 @@ async def test_compile_hourly_statistics_convert_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit_1, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3004,6 +3691,7 @@ async def test_compile_hourly_statistics_convert_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit_2, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3120,6 +3808,7 @@ async def test_compile_hourly_statistics_equivalent_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3151,6 +3840,7 @@ async def test_compile_hourly_statistics_equivalent_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit2, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3242,6 +3932,7 @@ async def test_compile_hourly_statistics_equivalent_units_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3326,6 +4017,7 @@ async def test_compile_hourly_statistics_changing_device_class_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3375,6 +4067,7 @@ async def test_compile_hourly_statistics_changing_device_class_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3434,6 +4127,7 @@ async def test_compile_hourly_statistics_changing_device_class_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3538,6 +4232,7 @@ async def test_compile_hourly_statistics_changing_device_class_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3587,6 +4282,7 @@ async def test_compile_hourly_statistics_changing_device_class_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3626,15 +4322,13 @@ async def test_compile_hourly_statistics_changing_device_class_2( ( "device_class", "state_unit", - "display_unit", - "statistics_unit", "unit_class", "mean", "min", "max", ), [ - (None, None, None, None, "unitless", 13.050847, -10, 30), + (None, None, "unitless", 13.050847, -10, 30), ], ) async def test_compile_hourly_statistics_changing_state_class( @@ -3642,8 +4336,6 @@ async def test_compile_hourly_statistics_changing_state_class( caplog: pytest.LogCaptureFixture, device_class, state_unit, - display_unit, - statistics_unit, unit_class, mean, min, @@ -3679,6 +4371,7 @@ async def test_compile_hourly_statistics_changing_state_class( "statistic_id": "sensor.test1", "display_unit_of_measurement": None, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3692,6 +4385,7 @@ async def test_compile_hourly_statistics_changing_state_class( 1, { "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3721,6 +4415,7 @@ async def test_compile_hourly_statistics_changing_state_class( "statistic_id": "sensor.test1", "display_unit_of_measurement": None, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -3734,6 +4429,7 @@ async def test_compile_hourly_statistics_changing_state_class( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -3799,10 +4495,11 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "unit_of_measurement": "EUR", } + durations = [50, 200, 45] + def _weighted_average(seq, i, last_state): total = 0 duration = 0 - durations = [50, 200, 45] if i > 0: total += last_state * 5 duration += 5 @@ -3811,6 +4508,20 @@ async def test_compile_statistics_hourly_daily_monthly_summary( duration += dur return total / duration + def _weighted_circular_mean( + values: Iterable[tuple[float, float]], + ) -> tuple[float, float]: + sin_sum = 0 + cos_sum = 0 + for x, weight in values: + sin_sum += math.sin(x * DEG_TO_RAD) * weight + cos_sum += math.cos(x * DEG_TO_RAD) * weight + + return ( + (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360, + math.sqrt(sin_sum**2 + cos_sum**2), + ) + def _min(seq, last_state): if last_state is None: return min(seq) @@ -3832,17 +4543,24 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "sensor.test2": [], "sensor.test3": [], "sensor.test4": [], + "sensor.test5": [], } expected_minima = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} expected_maxima = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} - expected_averages = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} + expected_means = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test5": [], + } expected_states = {"sensor.test4": []} expected_sums = {"sensor.test4": []} - last_states = { + last_states: dict[str, float | None] = { "sensor.test1": None, "sensor.test2": None, "sensor.test3": None, "sensor.test4": None, + "sensor.test5": None, } start = zero for i in range(24): @@ -3855,7 +4573,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( last_state = last_states["sensor.test1"] expected_minima["sensor.test1"].append(_min(seq, last_state)) expected_maxima["sensor.test1"].append(_max(seq, last_state)) - expected_averages["sensor.test1"].append(_weighted_average(seq, i, last_state)) + expected_means["sensor.test1"].append(_weighted_average(seq, i, last_state)) last_states["sensor.test1"] = seq[-1] # test2 values change: min/max at the last state seq = [-10 * (i + 1), 15 * (i + 1), 30 * (i + 1)] @@ -3866,7 +4584,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( last_state = last_states["sensor.test2"] expected_minima["sensor.test2"].append(_min(seq, last_state)) expected_maxima["sensor.test2"].append(_max(seq, last_state)) - expected_averages["sensor.test2"].append(_weighted_average(seq, i, last_state)) + expected_means["sensor.test2"].append(_weighted_average(seq, i, last_state)) last_states["sensor.test2"] = seq[-1] # test3 values change: min/max at the first state seq = [-10 * (23 - i + 1), 15 * (23 - i + 1), 30 * (23 - i + 1)] @@ -3877,7 +4595,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( last_state = last_states["sensor.test3"] expected_minima["sensor.test3"].append(_min(seq, last_state)) expected_maxima["sensor.test3"].append(_max(seq, last_state)) - expected_averages["sensor.test3"].append(_weighted_average(seq, i, last_state)) + expected_means["sensor.test3"].append(_weighted_average(seq, i, last_state)) last_states["sensor.test3"] = seq[-1] # test4 values grow seq = [i, i + 0.5, i + 0.75] @@ -3900,6 +4618,18 @@ async def test_compile_statistics_hourly_daily_monthly_summary( ) last_states["sensor.test4"] = seq[-1] + # test5 circular mean + seq = [350 - i, 0 + (i / 2.0), 15 + i] + four, _states = await async_record_states( + hass, freezer, start, "sensor.test5", WIND_DIRECTION_ATTRIBUTES, seq + ) + states["sensor.test5"] += _states["sensor.test5"] + values = [(seq, durations[j]) for j, seq in enumerate(seq)] + if (state := last_states["sensor.test5"]) is not None: + values.append((state, 5)) + expected_means["sensor.test5"].append(_weighted_circular_mean(values)) + last_states["sensor.test5"] = seq[-1] + start += timedelta(minutes=5) await async_wait_recording_done(hass) hist = history.get_significant_states( @@ -3925,6 +4655,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test1", "display_unit_of_measurement": "%", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3935,6 +4666,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test2", "display_unit_of_measurement": "%", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3945,6 +4677,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test3", "display_unit_of_measurement": "%", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3955,12 +4688,24 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test4", "display_unit_of_measurement": "EUR", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", "statistics_unit_of_measurement": "EUR", "unit_class": None, }, + { + "statistic_id": "sensor.test5", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + }, ] # Adjust the inserted statistics @@ -3979,19 +4724,21 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "sensor.test2": [], "sensor.test3": [], "sensor.test4": [], + "sensor.test5": [], } start = zero end = zero + timedelta(minutes=5) for i in range(24): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", + for entity_id, mean_extractor in ( + ("sensor.test1", lambda x: x), + ("sensor.test2", lambda x: x), + ("sensor.test3", lambda x: x), + ("sensor.test4", lambda x: x), + ("sensor.test5", lambda x: x[0]), ): expected_average = ( - expected_averages[entity_id][i] - if entity_id in expected_averages + mean_extractor(expected_means[entity_id][i]) + if entity_id in expected_means else None ) expected_minimum = ( @@ -4022,176 +4769,78 @@ async def test_compile_statistics_hourly_daily_monthly_summary( end += timedelta(minutes=5) assert stats == expected_stats - stats = statistics_during_period(hass, zero, period="hour") - expected_stats = { - "sensor.test1": [], - "sensor.test2": [], - "sensor.test3": [], - "sensor.test4": [], - } - start = zero - end = zero + timedelta(hours=1) - for i in range(2): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", - ): - expected_average = ( - mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_averages - else None - ) - expected_minimum = ( - min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_minima - else None - ) - expected_maximum = ( - max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_maxima - else None - ) - expected_state = ( - expected_states[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_states - else None - ) - expected_sum = ( - expected_sums[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_sums - else None - ) - expected_stats[entity_id].append( - { - "start": process_timestamp(start).timestamp(), - "end": process_timestamp(end).timestamp(), - "mean": pytest.approx(expected_average), - "min": pytest.approx(expected_minimum), - "max": pytest.approx(expected_maximum), - "last_reset": None, - "state": expected_state, - "sum": expected_sum, - } - ) - start += timedelta(hours=1) - end += timedelta(hours=1) - assert stats == expected_stats + def verify_stats( + period: Literal["hour", "day", "week", "month"], + start: datetime, + next_datetime: Callable[[datetime], datetime], + ) -> None: + stats = statistics_during_period(hass, zero, period=period) + expected_stats = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test4": [], + "sensor.test5": [], + } + end = next_datetime(start) + for i in range(2): + for entity_id, mean_fn in ( + ("sensor.test1", mean), + ("sensor.test2", mean), + ("sensor.test3", mean), + ("sensor.test4", mean), + ("sensor.test5", lambda x: _weighted_circular_mean(x)[0]), + ): + expected_average = ( + mean_fn(expected_means[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_means + else None + ) + expected_minimum = ( + min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_minima + else None + ) + expected_maximum = ( + max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_maxima + else None + ) + expected_state = ( + expected_states[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_states + else None + ) + expected_sum = ( + expected_sums[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_sums + else None + ) + expected_stats[entity_id].append( + { + "start": process_timestamp(start).timestamp(), + "end": process_timestamp(end).timestamp(), + "mean": pytest.approx(expected_average), + "min": pytest.approx(expected_minimum), + "max": pytest.approx(expected_maximum), + "last_reset": None, + "state": expected_state, + "sum": expected_sum, + } + ) + start = next_datetime(start) + end = next_datetime(end) + assert stats == expected_stats + + verify_stats("hour", zero, lambda v: v + timedelta(hours=1)) - stats = statistics_during_period(hass, zero, period="day") - expected_stats = { - "sensor.test1": [], - "sensor.test2": [], - "sensor.test3": [], - "sensor.test4": [], - } start = dt_util.parse_datetime("2021-08-31T06:00:00+00:00") - end = start + timedelta(days=1) - for i in range(2): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", - ): - expected_average = ( - mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_averages - else None - ) - expected_minimum = ( - min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_minima - else None - ) - expected_maximum = ( - max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_maxima - else None - ) - expected_state = ( - expected_states[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_states - else None - ) - expected_sum = ( - expected_sums[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_sums - else None - ) - expected_stats[entity_id].append( - { - "start": process_timestamp(start).timestamp(), - "end": process_timestamp(end).timestamp(), - "mean": pytest.approx(expected_average), - "min": pytest.approx(expected_minimum), - "max": pytest.approx(expected_maximum), - "last_reset": None, - "state": expected_state, - "sum": expected_sum, - } - ) - start += timedelta(days=1) - end += timedelta(days=1) - assert stats == expected_stats + assert start + verify_stats("day", start, lambda v: v + timedelta(days=1)) - stats = statistics_during_period(hass, zero, period="month") - expected_stats = { - "sensor.test1": [], - "sensor.test2": [], - "sensor.test3": [], - "sensor.test4": [], - } start = dt_util.parse_datetime("2021-08-01T06:00:00+00:00") - end = dt_util.parse_datetime("2021-09-01T06:00:00+00:00") - for i in range(2): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", - ): - expected_average = ( - mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_averages - else None - ) - expected_minimum = ( - min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_minima - else None - ) - expected_maximum = ( - max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_maxima - else None - ) - expected_state = ( - expected_states[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_states - else None - ) - expected_sum = ( - expected_sums[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_sums - else None - ) - expected_stats[entity_id].append( - { - "start": process_timestamp(start).timestamp(), - "end": process_timestamp(end).timestamp(), - "mean": pytest.approx(expected_average), - "min": pytest.approx(expected_minimum), - "max": pytest.approx(expected_maximum), - "last_reset": None, - "state": expected_state, - "sum": expected_sum, - } - ) - start = (start + timedelta(days=31)).replace(day=1) - end = (end + timedelta(days=31)).replace(day=1) - assert stats == expected_stats + assert start + verify_stats("month", start, lambda v: (v + timedelta(days=31)).replace(day=1)) assert "Error while processing event StatisticsTask" not in caplog.text @@ -4337,11 +4986,11 @@ async def test_validate_unit_change_convertible( "statistic_id": "sensor.test", "supported_unit": supported_unit, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -4562,11 +5211,11 @@ async def test_validate_statistics_unit_change_no_device_class( "statistic_id": "sensor.test", "supported_unit": supported_unit, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -4678,11 +5327,11 @@ async def test_validate_statistics_state_class_removed( "sensor.test": [ { "data": {"statistic_id": "sensor.test"}, - "type": "state_class_removed", + "type": STATE_CLASS_REMOVED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"state_class_removed"}) + await assert_validation_result(hass, client, expected, {STATE_CLASS_REMOVED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -4746,11 +5395,11 @@ async def test_validate_statistics_state_class_removed_issue_cleaned_up( "sensor.test": [ { "data": {"statistic_id": "sensor.test"}, - "type": "state_class_removed", + "type": STATE_CLASS_REMOVED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"state_class_removed"}) + await assert_validation_result(hass, client, expected, {STATE_CLASS_REMOVED_ISSUE}) # Remove the statistics - empty response get_instance(hass).async_clear_statistics(["sensor.test"]) @@ -4995,11 +5644,11 @@ async def test_validate_statistics_unit_change_no_conversion( "statistic_id": "sensor.test", "supported_unit": unit1, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -5176,11 +5825,11 @@ async def test_validate_statistics_unit_change_equivalent_units_2( "statistic_id": "sensor.test", "supported_unit": supported_unit, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Run statistics one hour later, metadata will not be updated await async_recorder_block_till_done(hass) @@ -5189,7 +5838,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2( await assert_statistic_ids( hass, [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] ) - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) async def test_validate_statistics_other_domain( @@ -5278,7 +5927,7 @@ async def test_update_statistics_issues( now = await one_hour_stats(now) expected = { "state_class_removed_sensor.test": { - "issue_type": "state_class_removed", + "issue_type": STATE_CLASS_REMOVED_ISSUE, "statistic_id": "sensor.test", } } @@ -5482,8 +6131,9 @@ async def test_clean_up_repairs( create_issue("test", "test_issue", None) create_issue(DOMAIN, "test_issue_1", None) create_issue(DOMAIN, "test_issue_2", {"issue_type": "another_issue"}) - create_issue(DOMAIN, "test_issue_3", {"issue_type": "state_class_removed"}) - create_issue(DOMAIN, "test_issue_4", {"issue_type": "units_changed"}) + create_issue(DOMAIN, "test_issue_3", {"issue_type": STATE_CLASS_REMOVED_ISSUE}) + create_issue(DOMAIN, "test_issue_4", {"issue_type": UNITS_CHANGED_ISSUE}) + create_issue(DOMAIN, "test_issue_5", {"issue_type": MEAN_TYPE_CHANGED_ISSUE}) # Check the issues assert set(issue_registry.issues) == { @@ -5492,6 +6142,7 @@ async def test_clean_up_repairs( ("sensor", "test_issue_2"), ("sensor", "test_issue_3"), ("sensor", "test_issue_4"), + ("sensor", "test_issue_5"), } # Request update of issues @@ -5505,3 +6156,140 @@ async def test_clean_up_repairs( ("sensor", "test_issue_1"), ("sensor", "test_issue_2"), } + + +async def test_validate_statistics_mean_type_changed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validate_statistics. + + This tests a validation issue is created when a the mean type is changed. + """ + now = get_start_time(dt_util.utcnow()) + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(hass, client, {}, {}) + + # No statistics, original unit - empty response + hass.states.async_set( + "sensor.wind_direction", + 10, + attributes=WIND_DIRECTION_ATTRIBUTES, + timestamp=now.timestamp(), + ) + await assert_validation_result(hass, client, {}, {}) + + # Run statistics + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now) + await async_recorder_block_till_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.wind_direction", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + + expected_log_entry = ( + "homeassistant.components.sensor.recorder", + logging.WARNING, + ( + "The statistics mean algorithm for sensor.wind_direction have changed from" + " CIRCULAR to ARITHMETIC. Generation of long term statistics will be " + "suppressed unless it changes back or go to " + "https://my.home-assistant.io/redirect/developer_statistics " + "to delete the old statistics" + ), + ) + # Valid stats, no log entry + assert expected_log_entry not in caplog.record_tuples + + # State class changed + hass.states.async_set( + "sensor.wind_direction", + 5, + attributes={ + **WIND_DIRECTION_ATTRIBUTES, + "state_class": SensorStateClass.MEASUREMENT, + }, + timestamp=now.timestamp(), + ) + expected = { + "sensor.wind_direction": [ + { + "data": { + "statistic_id": "sensor.wind_direction", + "metadata_mean_type": StatisticMeanType.CIRCULAR, + "state_mean_type": StatisticMeanType.ARITHMETIC, + }, + "type": MEAN_TYPE_CHANGED_ISSUE, + } + ], + } + await assert_validation_result(hass, client, expected, {MEAN_TYPE_CHANGED_ISSUE}) + + # Run statistics one hour later, metadata will not be updated + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now + timedelta(hours=1)) + await async_recorder_block_till_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.wind_direction", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + await assert_validation_result(hass, client, expected, {MEAN_TYPE_CHANGED_ISSUE}) + assert expected_log_entry in caplog.record_tuples + + # State class changed back + hass.states.async_set( + "sensor.wind_direction", + 350, + attributes=WIND_DIRECTION_ATTRIBUTES, + timestamp=now.timestamp(), + ) + await assert_validation_result(hass, client, {}, {}) + + # Run statistics + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now) + await async_recorder_block_till_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.wind_direction", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + + # Issue should be resolved + await assert_validation_result(hass, client, {}, {}) diff --git a/tests/components/sensorpro/__init__.py b/tests/components/sensorpro/__init__.py index da40ff9a3f7..a63bdbe08dc 100644 --- a/tests/components/sensorpro/__init__.py +++ b/tests/components/sensorpro/__init__.py @@ -1,8 +1,48 @@ """Tests for the SensorPro integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_SENSORPRO_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +NOT_SENSORPRO_SERVICE_INFO = make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -12,7 +52,7 @@ NOT_SENSORPRO_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -SENSORPRO_SERVICE_INFO = BluetoothServiceInfo( +SENSORPRO_SERVICE_INFO = make_bluetooth_service_info( name="T201", address="aa:bb:cc:dd:ee:ff", rssi=-60, diff --git a/tests/components/sensorpush/__init__.py b/tests/components/sensorpush/__init__.py index aae960970dd..88fb2072961 100644 --- a/tests/components/sensorpush/__init__.py +++ b/tests/components/sensorpush/__init__.py @@ -1,8 +1,48 @@ """Tests for the SensorPush integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +NOT_SENSOR_PUSH_SERVICE_INFO = make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -12,7 +52,7 @@ NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -HTW_SERVICE_INFO = BluetoothServiceInfo( +HTW_SERVICE_INFO = make_bluetooth_service_info( name="SensorPush HT.w 0CA1", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -22,7 +62,7 @@ HTW_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -HTPWX_SERVICE_INFO = BluetoothServiceInfo( +HTPWX_SERVICE_INFO = make_bluetooth_service_info( name="SensorPush HTP.xw F4D", address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", rssi=-56, @@ -33,7 +73,7 @@ HTPWX_SERVICE_INFO = BluetoothServiceInfo( ) -HTPWX_EMPTY_SERVICE_INFO = BluetoothServiceInfo( +HTPWX_EMPTY_SERVICE_INFO = make_bluetooth_service_info( name="SensorPush HTP.xw F4D", address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", rssi=-56, diff --git a/tests/components/sensorpush_cloud/test_sensor.py b/tests/components/sensorpush_cloud/test_sensor.py index c35d40f1bc2..775fb788836 100644 --- a/tests/components/sensorpush_cloud/test_sensor.py +++ b/tests/components/sensorpush_cloud/test_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry diff --git a/tests/components/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py index 5367fabba9e..11ed9904eae 100644 --- a/tests/components/seventeentrack/test_sensor.py +++ b/tests/components/seventeentrack/test_sensor.py @@ -4,20 +4,12 @@ from __future__ import annotations from unittest.mock import AsyncMock -from freezegun.api import FrozenDateTimeFactory from pyseventeentrack.errors import SeventeenTrackError from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from . import goto_future, init_integration -from .conftest import ( - DEFAULT_SUMMARY, - DEFAULT_SUMMARY_LENGTH, - NEW_SUMMARY_DATA, - VALID_PLATFORM_CONFIG_FULL, - get_package, -) +from . import init_integration +from .conftest import DEFAULT_SUMMARY, get_package from tests.common import MockConfigEntry @@ -78,38 +70,6 @@ async def test_package_error( assert hass.states.get("sensor.17track_package_friendly_name_1") is None -async def test_summary_correctly_updated( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure summary entities are not duplicated.""" - package = get_package(status=30) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - - 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 - assert len(state_ready_picked.attributes["packages"]) == 1 - - mock_seventeentrack.return_value.profile.packages.return_value = [] - mock_seventeentrack.return_value.profile.summary.return_value = NEW_SUMMARY_DATA - - await goto_future(hass, freezer) - - assert len(hass.states.async_entity_ids()) == len(NEW_SUMMARY_DATA) - for state in hass.states.async_all(): - assert state.state == "1" - - state_ready_picked = hass.states.get("sensor.17track_ready_to_be_picked_up") - assert state_ready_picked is not None - assert len(state_ready_picked.attributes["packages"]) == 0 - - async def test_summary_error( hass: HomeAssistant, mock_seventeentrack: AsyncMock, @@ -129,13 +89,3 @@ async def test_summary_error( assert ( hass.states.get("sensor.seventeentrack_packages_ready_to_be_picked_up") is None ) - - -async def test_non_valid_platform_config( - hass: HomeAssistant, mock_seventeentrack: AsyncMock -) -> None: - """Test if login fails.""" - mock_seventeentrack.return_value.profile.login.return_value = False - assert await async_setup_component(hass, "sensor", VALID_PLATFORM_CONFIG_FULL) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 0 diff --git a/tests/components/seventeentrack/test_services.py b/tests/components/seventeentrack/test_services.py index bbd5644ad63..2147ce994e0 100644 --- a/tests/components/seventeentrack/test_services.py +++ b/tests/components/seventeentrack/test_services.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.seventeentrack import DOMAIN from homeassistant.components.seventeentrack.const import ( diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 56745c8be8e..3ad7395caad 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -79,7 +79,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -111,7 +113,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -591,6 +595,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'SFR Box Voltage', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -604,6 +609,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'SFR Box Temperature', + 'state_class': , 'unit_of_measurement': , }), 'context': , diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 7a20560e25f..ec2d3d2c829 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -18,7 +18,7 @@ from homeassistant.components.shelly.const import ( RPC_SENSORS_POLLING_INTERVAL, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import ( @@ -40,13 +40,15 @@ async def init_integration( sleep_period=0, options: dict[str, Any] | None = None, skip_setup: bool = False, + data: dict[str, Any] | None = None, ) -> MockConfigEntry: """Set up the Shelly integration in Home Assistant.""" - data = { - CONF_HOST: "192.168.1.37", - CONF_SLEEP_PERIOD: sleep_period, - "model": model, - } + if data is None: + data = { + CONF_HOST: "192.168.1.37", + CONF_SLEEP_PERIOD: sleep_period, + CONF_MODEL: model, + } if gen is not None: data[CONF_GEN] = gen @@ -141,20 +143,6 @@ def get_entity( ) -def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: - """Return entity state.""" - entity = hass.states.get(entity_id) - assert entity - 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 a332d16f95d..dd17fe34cc8 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -1,5 +1,6 @@ """Test configuration for Shelly.""" +from collections.abc import Generator from copy import deepcopy from unittest.mock import AsyncMock, Mock, PropertyMock, patch @@ -101,12 +102,15 @@ MOCK_BLOCKS = [ "overpower": 0, "power": 53.4, "energy": 1234567.89, + "output": True, + "totalWorkTime": 3600, }, channel="0", type="relay", overpower=0, power=53.4, energy=1234567.89, + totalWorkTime=3600, description="relay_0", set_state=AsyncMock(side_effect=lambda turn: {"ison": turn == "on"}), ), @@ -133,11 +137,20 @@ MOCK_BLOCKS = [ set_state=AsyncMock(side_effect=mock_light_set_state), ), Mock( - sensor_ids={"motion": 0, "temp": 22.1, "gas": "mild", "motionActive": 1}, + sensor_ids={ + "motion": 0, + "temp": 22.1, + "gas": "mild", + "motionActive": 1, + "sensorOp": "normal", + "selfTest": "pending", + }, channel="0", motion=0, temp=22.1, gas="mild", + sensorOp="normal", + selfTest="pending", targetTemp=4, description="sensor_0", type="sensor", @@ -207,7 +220,7 @@ MOCK_CONFIG = { }, "sys": { "ui_data": {}, - "device": {"name": "Test name"}, + "device": {"name": "Test name", "mac": MOCK_MAC}, }, "wifi": {"sta": {"enable": True}, "sta1": {"enable": False}}, "ws": {"enable": False, "server": None}, @@ -312,7 +325,11 @@ MOCK_STATUS_COAP = { MOCK_STATUS_RPC = { - "switch:0": {"output": True}, + "switch:0": { + "id": 0, + "output": True, + "apower": 85.3, + }, "input:0": {"id": 0, "state": None}, "input:1": {"id": 1, "percent": 89, "xpercent": 8.9}, "input:2": { @@ -476,9 +493,13 @@ def _mock_rpc_device(version: str | None = None): initialized=True, connected=True, script_getcode=AsyncMock( - side_effect=lambda script_id: {"data": MOCK_SCRIPTS[script_id - 1]} + side_effect=lambda script_id, bytes_to_read: { + "data": MOCK_SCRIPTS[script_id - 1] + } ), xmod_info={}, + zigbee_enabled=False, + ip_address="10.10.10.10", ) type(device).name = PropertyMock(return_value="Test name") return device @@ -497,6 +518,12 @@ def _mock_blu_rtv_device(version: str | None = None): firmware_version="some fw string", initialized=True, connected=True, + script_getcode=AsyncMock( + side_effect=lambda script_id, bytes_to_read: { + "data": MOCK_SCRIPTS[script_id - 1] + } + ), + xmod_info={}, ) type(device).name = PropertyMock(return_value="Test name") return device @@ -666,3 +693,21 @@ async def mock_sleepy_rpc_device(): rpc_device_mock.return_value.mock_initialized = Mock(side_effect=initialized) yield rpc_device_mock.return_value + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.shelly.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_setup() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.shelly.async_setup", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/shelly/snapshots/test_button.ambr b/tests/components/shelly/snapshots/test_button.ambr new file mode 100644 index 00000000000..f5a38f1b847 --- /dev/null +++ b/tests/components/shelly/snapshots/test_button.ambr @@ -0,0 +1,96 @@ +# serializer version: 1 +# name: test_rpc_blu_trv_button[button.trv_name_calibrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.trv_name_calibrate', + '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 Calibrate', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibrate', + 'unique_id': 'f8:44:77:25:f0:dd_calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_blu_trv_button[button.trv_name_calibrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name Calibrate', + }), + 'context': , + 'entity_id': 'button.trv_name_calibrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_rpc_button[button.test_name_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_name_reboot', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Test name Reboot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_button[button.test_name_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test name Reboot', + }), + 'context': , + 'entity_id': 'button.test_name_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/shelly/snapshots/test_climate.ambr b/tests/components/shelly/snapshots/test_climate.ambr new file mode 100644 index 00000000000..991c570172e --- /dev/null +++ b/tests/components/shelly/snapshots/test_climate.ambr @@ -0,0 +1,276 @@ +# serializer version: 1 +# name: test_blu_trv_climate_set_temperature[climate.trv_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 4, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.trv_name', + 'has_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': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'f8:44:77:25:f0:dd-blutrv:200', + 'unit_of_measurement': None, + }) +# --- +# name: test_blu_trv_climate_set_temperature[climate.trv_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 15.2, + 'friendly_name': 'TRV-Name', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 4, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 17.1, + }), + 'context': , + 'entity_id': 'climate.trv_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_hvac_mode[climate.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 31, + 'min_temp': 4, + 'preset_modes': list([ + 'none', + 'Profile1', + 'Profile2', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name', + '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': 'Test name', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sensor_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_hvac_mode[climate.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.1, + 'friendly_name': 'Test name', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 31, + 'min_temp': 4, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'Profile1', + 'Profile2', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_rpc_climate_hvac_mode[climate.test_name_thermostat_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name_thermostat_0', + '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': 'Test name Thermostat 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-thermostat:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_climate_hvac_mode[climate.test_name_thermostat_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 44.4, + 'current_temperature': 12.3, + 'friendly_name': 'Test name Thermostat 0', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.test_name_thermostat_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_wall_display_thermostat_mode[climate.test_name_thermostat_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name_thermostat_0', + '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': 'Test name Thermostat 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-thermostat:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_thermostat_mode[climate.test_name_thermostat_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 44.4, + 'current_temperature': 12.3, + 'friendly_name': 'Test name Thermostat 0', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.test_name_thermostat_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 1e7c54320e8..fc79853f29e 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import Mock from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_MOTION from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.shelly.const import UPDATE_PERIOD_MULTIPLIER @@ -39,15 +39,16 @@ async def test_block_binary_sensor( entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_channel_1_overpowering" await init_integration(hass, 1) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "overpower", 1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-overpower" @@ -61,19 +62,18 @@ async def test_block_binary_sensor_extra_state_attr( entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_gas" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes.get("detected") == "mild" monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "gas", "none") mock_block_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes.get("detected") == "none" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-gas" @@ -89,15 +89,16 @@ async def test_block_rest_binary_sensor( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setitem(mock_block_device.status["cloud"], "connected", True) await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cloud" @@ -115,20 +116,22 @@ async def test_block_rest_binary_sensor_connected_battery_devices( monkeypatch.setitem(mock_block_device.settings["coiot"], "update_period", 3600) await init_integration(hass, 1, model=MODEL_MOTION) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setitem(mock_block_device.status["cloud"], "connected", True) # Verify no update on fast intervals await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF # Verify update on slow intervals await mock_rest_update(hass, freezer, seconds=UPDATE_PERIOD_MULTIPLIER * 3600) - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cloud" @@ -149,15 +152,16 @@ async def test_block_sleeping_binary_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "motion", 1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-motion" @@ -183,14 +187,16 @@ async def test_block_restored_sleeping_binary_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_block_restored_sleeping_binary_sensor_no_last_state( @@ -214,14 +220,16 @@ async def test_block_restored_sleeping_binary_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_binary_sensor( @@ -234,17 +242,18 @@ async def test_rpc_binary_sensor( entity_id = f"{BINARY_SENSOR_DOMAIN}.test_cover_0_overpowering" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF mutate_rpc_device_status( monkeypatch, mock_rpc_device, "cover:0", "errors", "overpower" ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cover:0-overpower" @@ -290,20 +299,22 @@ async def test_rpc_sleeping_binary_sensor( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cloud", "connected", True) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON - - # test external power sensor - state = hass.states.get("binary_sensor.test_name_external_power") - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get("binary_sensor.test_name_external_power") - assert entry + # test external power sensor + assert (state := hass.states.get("binary_sensor.test_name_external_power")) + assert state.state == STATE_ON + + assert ( + entry := entity_registry.async_get("binary_sensor.test_name_external_power") + ) assert entry.unique_id == "123456789ABC-devicepower:0-external_power" @@ -331,14 +342,16 @@ async def test_rpc_restored_sleeping_binary_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_restored_sleeping_binary_sensor_no_last_state( @@ -364,7 +377,8 @@ async def test_rpc_restored_sleeping_binary_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) @@ -375,7 +389,8 @@ async def test_rpc_restored_sleeping_binary_sensor_no_last_state( mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF @pytest.mark.parametrize( @@ -407,17 +422,17 @@ async def test_rpc_device_virtual_binary_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-boolean:203-boolean" monkeypatch.setitem(mock_rpc_device.status["boolean:203"], "value", False) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_OFF + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( @@ -450,8 +465,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_binary_sensor_when_orphaned( @@ -475,8 +489,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_blu_trv_binary_sensor_entity( diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 14349411670..8d355098463 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -2,12 +2,17 @@ from unittest.mock import Mock +from aioshelly.const import MODEL_BLU_GATEWAY_G3 +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.shelly.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_registry import EntityRegistry from . import init_integration @@ -22,10 +27,10 @@ async def test_block_button( entity_id = "button.test_name_reboot" # reboot button - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC_reboot" await hass.services.async_call( @@ -38,7 +43,10 @@ async def test_block_button( async def test_rpc_button( - hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test rpc device OTA button.""" await init_integration(hass, 2) @@ -46,11 +54,11 @@ async def test_rpc_button( entity_id = "button.test_name_reboot" # reboot button - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == "123456789ABC_reboot" + assert (entry := entity_registry.async_get(entity_id)) + assert entry == snapshot(name=f"{entity_id}-entry") await hass.services.async_call( BUTTON_DOMAIN, @@ -61,6 +69,68 @@ async def test_rpc_button( assert mock_rpc_device.trigger_reboot.call_count == 1 +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for button.test_name_reboot of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for button.test_name_reboot of Test name", + ), + ], +) +async def test_rpc_button_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + exception: Exception, + error: str, +) -> None: + """Test RPC button with exception.""" + await init_integration(hass, 2) + + mock_rpc_device.trigger_reboot.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_reboot"}, + blocking=True, + ) + + +async def test_rpc_button_reauth_error( + hass: HomeAssistant, mock_rpc_device: Mock +) -> None: + """Test rpc device OTA button with authentication error.""" + entry = await init_integration(hass, 2) + + mock_rpc_device.trigger_reboot.side_effect = InvalidAuthError + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_reboot"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + 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") == entry.entry_id + + @pytest.mark.parametrize( ("gen", "old_unique_id", "new_unique_id", "migration"), [ @@ -104,3 +174,107 @@ async def test_migrate_unique_id( bool("Migrating unique_id for button.test_name_reboot" in caplog.text) == migration ) + + +async def test_rpc_blu_trv_button( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test RPC BLU TRV button.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + entity_id = "button.trv_name_calibrate" + + 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") + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert mock_blu_trv.trigger_blu_trv_calibration.call_count == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for button.trv_name_calibrate of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for button.trv_name_calibrate of Test name", + ), + ], +) +async def test_rpc_blu_trv_button_exc( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, + error: str, +) -> None: + """Test RPC BLU TRV button with exception.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.trigger_blu_trv_calibration.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.trv_name_calibrate"}, + blocking=True, + ) + + +async def test_rpc_blu_trv_button_auth_error( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC BLU TRV button with authentication error.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.trigger_blu_trv_calibration.side_effect = InvalidAuthError + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.trv_name_calibrate"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + 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") == entry.entry_id diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 040d67cb9c4..eddd9ab6fd0 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -9,8 +9,9 @@ from aioshelly.const import ( MODEL_VALVE, MODEL_WALL_DISPLAY, ) -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -26,7 +27,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN +from homeassistant.components.shelly.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -42,13 +43,7 @@ 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, - get_entity_attribute, - init_integration, - register_device, - register_entity, -) +from . import MOCK_MAC, 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 @@ -65,6 +60,7 @@ async def test_climate_hvac_mode( mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test climate hvac mode service.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") @@ -83,12 +79,9 @@ async def test_climate_hvac_mode( await hass.async_block_till_done(wait_background_tasks=True) # Test initial hvac mode - off - state = hass.states.get(ENTITY_ID) - assert state.state == HVACMode.OFF + assert hass.states.get(ENTITY_ID) == snapshot(name=f"{ENTITY_ID}-state") - entry = entity_registry.async_get(ENTITY_ID) - assert entry - assert entry.unique_id == "123456789ABC-sensor_0" + assert entity_registry.async_get(ENTITY_ID) == snapshot(name=f"{ENTITY_ID}-entry") # Test set hvac mode heat await hass.services.async_call( @@ -103,7 +96,8 @@ async def test_climate_hvac_mode( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 20.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + + assert (state := hass.states.get(ENTITY_ID)) assert state.state == HVACMode.HEAT # Test set hvac mode off @@ -120,13 +114,13 @@ async def test_climate_hvac_mode( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 4.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == HVACMode.OFF # Test unavailable on error monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 1) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_UNAVAILABLE @@ -143,7 +137,7 @@ async def test_climate_set_temperature( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == HVACMode.OFF assert state.attributes[ATTR_TEMPERATURE] == 4 @@ -197,7 +191,7 @@ async def test_climate_set_preset_mode( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE # Test set Profile2 @@ -215,7 +209,7 @@ async def test_climate_set_preset_mode( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", 2) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.attributes[ATTR_PRESET_MODE] == "Profile2" # Set preset to none @@ -234,7 +228,7 @@ async def test_climate_set_preset_mode( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", 0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE @@ -269,23 +263,26 @@ async def test_block_restored_climate( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 4.0 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 4.0 # Partial update, should not change state mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 4.0 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 4.0 # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 4.0 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 4.0 # Test set hvac mode heat, target temp should be set to last target temp (22) await hass.services.async_call( @@ -300,9 +297,10 @@ async def test_block_restored_climate( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 22.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.HEAT - assert hass.states.get(entity_id).attributes.get("temperature") == 22.0 + assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 async def test_block_restored_climate_us_customary( @@ -337,17 +335,19 @@ async def test_block_restored_climate_us_customary( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 39 - assert hass.states.get(entity_id).attributes.get("current_temperature") == 67 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 39 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 67 # Partial update, should not change state mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 39 - assert hass.states.get(entity_id).attributes.get("current_temperature") == 67 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 39 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 67 # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) @@ -356,9 +356,10 @@ async def test_block_restored_climate_us_customary( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 39 - assert hass.states.get(entity_id).attributes.get("current_temperature") == 65 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 39 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 65 # Test set hvac mode heat, target temp should be set to last target temp (10.0/50) await hass.services.async_call( @@ -373,9 +374,10 @@ async def test_block_restored_climate_us_customary( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 10.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.HEAT - assert hass.states.get(entity_id).attributes.get("temperature") == 50 + assert state.attributes.get(ATTR_TEMPERATURE) == 50 async def test_block_restored_climate_unavailable( @@ -403,7 +405,8 @@ async def test_block_restored_climate_unavailable( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF async def test_block_restored_climate_set_preset_before_online( @@ -431,7 +434,8 @@ async def test_block_restored_climate_set_preset_before_online( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.HEAT + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.HEAT with pytest.raises(ServiceValidationError): await hass.services.async_call( @@ -460,7 +464,10 @@ async def test_block_set_mode_connection_error( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while calling action for climate.test_name of Test name", + ): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -603,27 +610,21 @@ async def test_rpc_climate_hvac_mode( entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, ) -> None: """Test climate hvac mode service.""" entity_id = "climate.test_name_thermostat_0" await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_CURRENT_HUMIDITY] == 44.4 + assert (state := hass.states.get(entity_id)) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == "123456789ABC-thermostat:0" + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "output", False) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE assert state.attributes[ATTR_CURRENT_HUMIDITY] == 44.4 @@ -639,7 +640,7 @@ async def test_rpc_climate_hvac_mode( mock_rpc_device.call_rpc.assert_called_once_with( "Thermostat.SetConfig", {"config": {"id": 0, "enable": False}} ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.OFF @@ -657,15 +658,14 @@ async def test_rpc_climate_without_humidity( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.HEAT assert state.attributes[ATTR_TEMPERATURE] == 23 assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING assert ATTR_CURRENT_HUMIDITY not in state.attributes - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-thermostat:0" @@ -677,7 +677,7 @@ async def test_rpc_climate_set_temperature( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_TEMPERATURE] == 23 monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "target_C", 28) @@ -692,7 +692,7 @@ async def test_rpc_climate_set_temperature( mock_rpc_device.call_rpc.assert_called_once_with( "Thermostat.SetConfig", {"config": {"id": 0, "target_C": 28}} ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_TEMPERATURE] == 28 @@ -707,7 +707,7 @@ async def test_rpc_climate_hvac_mode_cool( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.COOL assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING @@ -717,6 +717,7 @@ async def test_wall_display_thermostat_mode( mock_rpc_device: Mock, entity_registry: EntityRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, ) -> None: """Test Wall Display in thermostat mode.""" climate_entity_id = "climate.test_name_thermostat_0" @@ -730,13 +731,11 @@ async def test_wall_display_thermostat_mode( # the climate entity should be created state = hass.states.get(climate_entity_id) - assert state - assert state.state == HVACMode.HEAT + assert state == snapshot(name=f"{climate_entity_id}-state") assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 entry = entity_registry.async_get(climate_entity_id) - assert entry - assert entry.unique_id == "123456789ABC-thermostat:0" + assert entry == snapshot(name=f"{climate_entity_id}-entry") async def test_wall_display_thermostat_mode_external_actuator( @@ -751,31 +750,31 @@ async def test_wall_display_thermostat_mode_external_actuator( new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False + new_status.pop("cover:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) # the switch entity should be created - state = hass.states.get(switch_entity_id) - assert state + assert (state := hass.states.get(switch_entity_id)) assert state.state == STATE_ON assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 # the climate entity should be created - state = hass.states.get(climate_entity_id) - assert state + assert (state := hass.states.get(climate_entity_id)) assert state.state == HVACMode.HEAT assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 - entry = entity_registry.async_get(climate_entity_id) - assert entry + assert (entry := entity_registry.async_get(climate_entity_id)) assert entry.unique_id == "123456789ABC-thermostat:0" async def test_blu_trv_climate_set_temperature( hass: HomeAssistant, mock_blu_trv: Mock, + entity_registry: EntityRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, ) -> None: """Test BLU TRV set target temperature.""" @@ -784,7 +783,9 @@ async def test_blu_trv_climate_set_temperature( await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) - assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + assert (state := hass.states.get(entity_id)) == snapshot(name=f"{entity_id}-state") + + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") monkeypatch.setitem( mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "target_C", 28 @@ -797,17 +798,10 @@ async def test_blu_trv_climate_set_temperature( ) 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}, - }, - BLU_TRV_TIMEOUT, - ) + mock_blu_trv.blu_trv_set_target_temperature.assert_called_once_with(200, 28.0) - assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 28 + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_TEMPERATURE] == 28 async def test_blu_trv_climate_disabled( @@ -822,14 +816,16 @@ async def test_blu_trv_climate_disabled( await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) - assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + assert (state := hass.states.get(entity_id)) + assert state.attributes[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 + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_TEMPERATURE] is None async def test_blu_trv_climate_hvac_action( @@ -844,9 +840,74 @@ async def test_blu_trv_climate_hvac_action( await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) - assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.IDLE + assert (state := hass.states.get(entity_id)) + assert state.attributes[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 + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for climate.trv_name of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for climate.trv_name of Test name", + ), + ], +) +async def test_blu_trv_set_target_temp_exc( + hass: HomeAssistant, + mock_blu_trv: Mock, + exception: Exception, + error: str, +) -> None: + """BLU TRV target temperature setting test with excepton.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_target_temperature.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.trv_name", ATTR_TEMPERATURE: 28}, + blocking=True, + ) + + +async def test_blu_trv_set_target_temp_auth_error( + hass: HomeAssistant, + mock_blu_trv: Mock, +) -> None: + """BLU TRV target temperature setting test with authentication error.""" + entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_target_temperature.side_effect = InvalidAuthError + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.trv_name", ATTR_TEMPERATURE: 28}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + 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") == entry.entry_id diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 50b8b552268..93893035a3e 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -11,6 +11,7 @@ from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, InvalidAuthError, + InvalidHostError, ) import pytest @@ -18,10 +19,20 @@ from homeassistant import config_entries from homeassistant.components.shelly import MacAddressMismatchError, config_flow from homeassistant.components.shelly.const import ( CONF_BLE_SCANNER_MODE, + CONF_GEN, + CONF_SLEEP_PERIOD, DOMAIN, BLEScannerMode, ) from homeassistant.components.shelly.coordinator import ENTRY_RELOAD_COOLDOWN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ( @@ -71,6 +82,8 @@ async def test_form( port: int, mock_block_device: Mock, mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -90,28 +103,20 @@ async def test_form( "port": port, }, ), - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1", "port": port}, + {CONF_HOST: "1.1.1.1", CONF_PORT: port}, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test name" - assert result2["data"] == { - "host": "1.1.1.1", - "port": port, - "model": model, - "sleep_period": 0, - "gen": gen, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: port, + CONF_MODEL: model, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: gen, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -120,26 +125,19 @@ async def test_form( async def test_user_flow_overrides_existing_discovery( hass: HomeAssistant, mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test setting up from the user flow when the devices is already discovered.""" - with ( - patch( - "homeassistant.components.shelly.config_flow.get_info", - return_value={ - "mac": "AABBCCDDEEFF", - "model": MODEL_PLUS_2PM, - "auth": False, - "gen": 2, - "port": 80, - }, - ), - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={ + "mac": "AABBCCDDEEFF", + "model": MODEL_PLUS_2PM, + "auth": False, + "gen": 2, + "port": 80, + }, ): discovery_result = await hass.config_entries.flow.async_init( DOMAIN, @@ -161,22 +159,21 @@ async def test_user_flow_overrides_existing_discovery( ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1", "port": 80}, + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test name" - assert result2["data"] == { - "host": "1.1.1.1", - "port": 80, - "model": MODEL_PLUS_2PM, - "sleep_period": 0, - "gen": 2, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_MODEL: MODEL_PLUS_2PM, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 2, } - assert result2["context"]["unique_id"] == "AABBCCDDEEFF" + assert result["context"]["unique_id"] == "AABBCCDDEEFF" assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -187,6 +184,8 @@ async def test_user_flow_overrides_existing_discovery( async def test_form_gen1_custom_port( hass: HomeAssistant, mock_block_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -205,13 +204,35 @@ async def test_form_gen1_custom_port( side_effect=CustomPortNotSupported, ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1", "port": "1100"}, + {CONF_HOST: "1.1.1.1", CONF_PORT: "1100"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"]["base"] == "custom_port_not_supported" + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "custom_port_not_supported" + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "gen": 1}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: DEFAULT_HTTP_PORT}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 1, + } + assert result["context"]["unique_id"] == "test-mac" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( @@ -220,19 +241,19 @@ async def test_form_gen1_custom_port( ( 1, MODEL_1, - {"username": "test user", "password": "test1 password"}, + {CONF_USERNAME: "test user", CONF_PASSWORD: "test1 password"}, "test user", ), ( 2, MODEL_PLUS_2PM, - {"password": "test2 password"}, + {CONF_PASSWORD: "test2 password"}, "admin", ), ( 3, MODEL_PLUS_2PM, - {"password": "test2 password"}, + {CONF_PASSWORD: "test2 password"}, "admin", ), ], @@ -245,6 +266,8 @@ async def test_form_auth( username: str, mock_block_device: Mock, mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test manual configuration if auth is required.""" result = await hass.config_entries.flow.async_init( @@ -257,38 +280,28 @@ async def test_form_auth( "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], user_input - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Test name" - assert result3["data"] == { - "host": "1.1.1.1", - "port": DEFAULT_HTTP_PORT, - "model": model, - "sleep_period": 0, - "gen": gen, - "username": username, - "password": user_input["password"], + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: model, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: gen, + CONF_USERNAME: username, + CONF_PASSWORD: user_input[CONF_PASSWORD], } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -298,11 +311,17 @@ async def test_form_auth( ("exc", "base_error"), [ (DeviceConnectionError, "cannot_connect"), + (InvalidHostError, "invalid_host"), (ValueError, "unknown"), ], ) async def test_form_errors_get_info( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, + mock_block_device: Mock, + mock_setup: AsyncMock, + mock_setup_entry: AsyncMock, + exc: Exception, + base_error: str, ) -> None: """Test we handle errors.""" result = await hass.config_entries.flow.async_init( @@ -310,13 +329,35 @@ async def test_form_errors_get_info( ) with patch("homeassistant.components.shelly.config_flow.get_info", side_effect=exc): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": base_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "gen": 1}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 1, + } + assert result["context"]["unique_id"] == "test-mac" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 async def test_form_missing_model_key( @@ -331,13 +372,13 @@ async def test_form_missing_model_key( "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "auth": False, "gen": "2"}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "firmware_not_fully_provisioned"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "firmware_not_fully_provisioned" async def test_form_missing_model_key_auth_enabled( @@ -354,20 +395,20 @@ async def test_form_missing_model_key_auth_enabled( "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "auth": True, "gen": 2}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} monkeypatch.setattr(mock_rpc_device, "shelly", {"gen": 2}) - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {"password": "1234"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "1234"} ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": "firmware_not_fully_provisioned"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "firmware_not_fully_provisioned" async def test_form_missing_model_key_zeroconf( @@ -386,15 +427,9 @@ async def test_form_missing_model_key_zeroconf( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "firmware_not_fully_provisioned"} - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "firmware_not_fully_provisioned"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "firmware_not_fully_provisioned" @pytest.mark.parametrize( @@ -406,7 +441,12 @@ async def test_form_missing_model_key_zeroconf( ], ) async def test_form_errors_test_connection( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, + mock_block_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, + exc: Exception, + base_error: str, ) -> None: """Test we handle errors.""" result = await hass.config_entries.flow.async_init( @@ -422,20 +462,42 @@ async def test_form_errors_test_connection( "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc) ), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": base_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "auth": False}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 1, + } + assert result["context"]["unique_id"] == "test-mac" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 async def test_form_already_configured(hass: HomeAssistant) -> None: """Test we get the form.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0"} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0"} ) entry.add_to_hass(hass) @@ -447,27 +509,30 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" # Test config entry got updated with latest IP - assert entry.data["host"] == "1.1.1.1" + assert entry.data[CONF_HOST] == "1.1.1.1" async def test_user_setup_ignored_device( - hass: HomeAssistant, mock_block_device: Mock + hass: HomeAssistant, + mock_block_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test user can successfully setup an ignored device.""" entry = MockConfigEntry( domain="shelly", unique_id="test-mac", - data={"host": "0.0.0.0"}, + data={CONF_HOST: "0.0.0.0"}, source=config_entries.SOURCE_IGNORE, ) entry.add_to_hass(hass) @@ -476,28 +541,19 @@ async def test_user_setup_ignored_device( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, - ), - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY # Test config entry got updated with latest IP - assert entry.data["host"] == "1.1.1.1" + assert entry.data[CONF_HOST] == "1.1.1.1" assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -512,7 +568,12 @@ async def test_user_setup_ignored_device( ], ) async def test_form_auth_errors_test_connection_gen1( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, + mock_block_device: Mock, + mock_setup: AsyncMock, + mock_setup_entry: AsyncMock, + exc: Exception, + base_error: str, ) -> None: """Test we handle errors in Gen1 authenticated devices.""" result = await hass.config_entries.flow.async_init( @@ -523,21 +584,45 @@ async def test_form_auth_errors_test_connection_gen1( "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "auth": True}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) with patch( "aioshelly.block_device.BlockDevice.create", - new=AsyncMock(side_effect=exc), + side_effect=exc, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {"username": "test username", "password": "test password"}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "test username", CONF_PASSWORD: "test password"}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": base_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "auth": True}, + ): + 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 name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 1, + CONF_USERNAME: "test username", + CONF_PASSWORD: "test password", + } + assert result["context"]["unique_id"] == "test-mac" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( @@ -550,7 +635,12 @@ async def test_form_auth_errors_test_connection_gen1( ], ) async def test_form_auth_errors_test_connection_gen2( - hass: HomeAssistant, exc: Exception, base_error: str + hass: HomeAssistant, + mock_rpc_device: Mock, + mock_setup: AsyncMock, + mock_setup_entry: AsyncMock, + exc: Exception, + base_error: str, ) -> None: """Test we handle errors in Gen2 authenticated devices.""" result = await hass.config_entries.flow.async_init( @@ -561,20 +651,44 @@ async def test_form_auth_errors_test_connection_gen2( "homeassistant.components.shelly.config_flow.get_info", return_value={"mac": "test-mac", "auth": True, "gen": 2}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) with patch( "aioshelly.rpc_device.RpcDevice.create", - new=AsyncMock(side_effect=exc), + side_effect=exc, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {"password": "test password"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "test password"} ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": base_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "auth": True, "gen": 2}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "test password"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: "SNSW-002P16EU", + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 2, + CONF_USERNAME: "admin", + CONF_PASSWORD: "test password", + } + assert result["context"]["unique_id"] == "test-mac" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( @@ -604,6 +718,8 @@ async def test_zeroconf( get_info: dict[str, Any], mock_block_device: Mock, mock_rpc_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test we get the form.""" @@ -624,35 +740,30 @@ async def test_zeroconf( ) assert context["title_placeholders"]["name"] == "shelly1pm-12345" assert context["confirm_only"] is True - with ( - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test name" - assert result2["data"] == { - "host": "1.1.1.1", - "model": model, - "sleep_period": 0, - "gen": gen, + 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.1.1.1", + CONF_MODEL: model, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: gen, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 async def test_zeroconf_sleeping_device( - hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test sleeping device configuration via zeroconf.""" monkeypatch.setitem( @@ -682,28 +793,19 @@ async def test_zeroconf_sleeping_device( if flow["flow_id"] == result["flow_id"] ) assert context["title_placeholders"]["name"] == "shelly1pm-12345" - with ( - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test name" - assert result2["data"] == { - "host": "1.1.1.1", - "model": MODEL_1, - "sleep_period": 600, - "gen": 1, + 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.1.1.1", + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 600, + CONF_GEN: 1, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -731,15 +833,61 @@ async def test_zeroconf_sleeping_device_error(hass: HomeAssistant) -> None: data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_options_flow_abort_setup_retry( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test ble options abort if device is in setup retry.""" + monkeypatch.setattr( + mock_rpc_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + ) + entry = await init_integration(hass, 2) + + assert entry.state is ConfigEntryState.SETUP_RETRY + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_options_flow_abort_no_scripts_support( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test ble options abort if device does not support scripts.""" + monkeypatch.setattr( + mock_rpc_device, "supports_scripts", AsyncMock(return_value=False) + ) + entry = await init_integration(hass, 2) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_scripts_support" + + +async def test_options_flow_abort_zigbee_enabled( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test ble options abort if Zigbee is enabled for the device.""" + monkeypatch.setattr(mock_rpc_device, "zigbee_enabled", True) + entry = await init_integration(hass, 4) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "zigbee_enabled" async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: """Test we get the form.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0"} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0"} ) entry.add_to_hass(hass) @@ -752,11 +900,12 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" # Test config entry got updated with latest IP - assert entry.data["host"] == "1.1.1.1" + assert entry.data[CONF_HOST] == "1.1.1.1" async def test_zeroconf_ignored(hass: HomeAssistant) -> None: @@ -779,15 +928,16 @@ async def test_zeroconf_ignored(hass: HomeAssistant) -> None: data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: """Test we ignore the Wi-FI AP IP.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "2.2.2.2"} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "2.2.2.2"} ) entry.add_to_hass(hass) @@ -802,11 +952,12 @@ async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: ), context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" # Test config entry was not updated with the wifi ap ip - assert entry.data["host"] == "2.2.2.2" + assert entry.data[CONF_HOST] == "2.2.2.2" async def test_zeroconf_cannot_connect(hass: HomeAssistant) -> None: @@ -820,12 +971,16 @@ async def test_zeroconf_cannot_connect(hass: HomeAssistant) -> None: data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" async def test_zeroconf_require_auth( - hass: HomeAssistant, mock_block_device: Mock + hass: HomeAssistant, + mock_block_device: Mock, + mock_setup_entry: AsyncMock, + mock_setup: AsyncMock, ) -> None: """Test zeroconf if auth is required.""" @@ -838,34 +993,25 @@ async def test_zeroconf_require_auth( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - with ( - patch( - "homeassistant.components.shelly.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.shelly.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test username", "password": "test password"}, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test name" - assert result2["data"] == { - "host": "1.1.1.1", - "port": DEFAULT_HTTP_PORT, - "model": MODEL_1, - "sleep_period": 0, - "gen": 1, - "username": "test username", - "password": "test password", + 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 name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 1, + CONF_USERNAME: "test username", + CONF_PASSWORD: "test password", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -874,9 +1020,9 @@ async def test_zeroconf_require_auth( @pytest.mark.parametrize( ("gen", "user_input"), [ - (1, {"username": "test user", "password": "test1 password"}), - (2, {"password": "test2 password"}), - (3, {"password": "test2 password"}), + (1, {CONF_USERNAME: "test user", CONF_PASSWORD: "test1 password"}), + (2, {CONF_PASSWORD: "test2 password"}), + (3, {CONF_PASSWORD: "test2 password"}), ], ) async def test_reauth_successful( @@ -888,7 +1034,9 @@ async def test_reauth_successful( ) -> None: """Test starting a reauthentication flow.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -905,16 +1053,16 @@ async def test_reauth_successful( user_input=user_input, ) - 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( ("gen", "user_input"), [ - (1, {"username": "test user", "password": "test1 password"}), - (2, {"password": "test2 password"}), - (3, {"password": "test2 password"}), + (1, {CONF_USERNAME: "test user", CONF_PASSWORD: "test1 password"}), + (2, {CONF_PASSWORD: "test2 password"}), + (3, {CONF_PASSWORD: "test2 password"}), ], ) @pytest.mark.parametrize( @@ -933,7 +1081,9 @@ async def test_reauth_unsuccessful( ) -> None: """Test reauthentication flow failed.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -943,7 +1093,12 @@ async def test_reauth_unsuccessful( with ( patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, + return_value={ + "mac": "test-mac", + "type": MODEL_1, + "auth": True, + "gen": gen, + }, ), patch( "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc) @@ -955,14 +1110,14 @@ async def test_reauth_unsuccessful( user_input=user_input, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == abort_reason + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == abort_reason async def test_reauth_get_info_error(hass: HomeAssistant) -> None: """Test reauthentication flow failed with error in get_info().""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": 2} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0", CONF_GEN: 2} ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -975,11 +1130,11 @@ async def test_reauth_get_info_error(hass: HomeAssistant) -> None: ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"password": "test2 password"}, + user_input={CONF_PASSWORD: "test2 password"}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_unsuccessful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unsuccessful" async def test_options_flow_disabled_gen_1( @@ -1059,10 +1214,9 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED + assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.DISABLED result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM @@ -1075,10 +1229,9 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.ACTIVE + assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.ACTIVE result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM @@ -1091,10 +1244,9 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N CONF_BLE_SCANNER_MODE: BLEScannerMode.PASSIVE, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.PASSIVE + assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.PASSIVE await hass.config_entries.async_unload(entry.entry_id) @@ -1106,7 +1258,12 @@ async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 0, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1122,8 +1279,9 @@ async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( data=DISCOVERY_INFO_WITH_MAC, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" monkeypatch.setattr(mock_rpc_device, "connected", False) mock_rpc_device.mock_disconnected() @@ -1141,7 +1299,12 @@ async def test_zeroconf_already_configured_triggers_refresh( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 0, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1157,8 +1320,9 @@ async def test_zeroconf_already_configured_triggers_refresh( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" monkeypatch.setattr(mock_rpc_device, "connected", False) mock_rpc_device.mock_disconnected() @@ -1181,7 +1345,12 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1202,8 +1371,9 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" monkeypatch.setattr(mock_rpc_device, "connected", False) mock_rpc_device.mock_disconnected() @@ -1228,7 +1398,12 @@ async def test_zeroconf_sleeping_device_attempts_configure( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1251,8 +1426,9 @@ async def test_zeroconf_sleeping_device_attempts_configure( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_rpc_device.update_outbound_websocket.mock_calls == [] @@ -1288,7 +1464,12 @@ async def test_zeroconf_sleeping_device_attempts_configure_ws_disabled( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1311,8 +1492,9 @@ async def test_zeroconf_sleeping_device_attempts_configure_ws_disabled( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_rpc_device.update_outbound_websocket.mock_calls == [] @@ -1348,7 +1530,12 @@ async def test_zeroconf_sleeping_device_attempts_configure_no_url_available( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1371,8 +1558,9 @@ async def test_zeroconf_sleeping_device_attempts_configure_no_url_available( data=DISCOVERY_INFO, context={"source": config_entries.SOURCE_ZEROCONF}, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" assert mock_rpc_device.update_outbound_websocket.mock_calls == [] @@ -1415,20 +1603,20 @@ async def test_sleeping_device_gen2_with_new_firmware( ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "host": "1.1.1.1", - "port": DEFAULT_HTTP_PORT, - "model": MODEL_PLUS_2PM, - "sleep_period": 666, - "gen": 2, + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_PLUS_2PM, + CONF_SLEEP_PERIOD: 666, + CONF_GEN: 2, } -@pytest.mark.parametrize("gen", [1, 2, 3]) +@pytest.mark.parametrize(CONF_GEN, [1, 2, 3]) async def test_reconfigure_successful( hass: HomeAssistant, gen: int, @@ -1437,7 +1625,9 @@ async def test_reconfigure_successful( ) -> None: """Test starting a reconfiguration flow.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) @@ -1452,12 +1642,12 @@ async def test_reconfigure_successful( ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"host": "10.10.10.10", "port": 99}, + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: 99}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert entry.data == {"host": "10.10.10.10", "port": 99, "gen": gen} + assert entry.data == {CONF_HOST: "10.10.10.10", CONF_PORT: 99, CONF_GEN: gen} @pytest.mark.parametrize("gen", [1, 2, 3]) @@ -1469,7 +1659,9 @@ async def test_reconfigure_unsuccessful( ) -> None: """Test reconfiguration flow failed.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) @@ -1480,11 +1672,16 @@ async def test_reconfigure_unsuccessful( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "another-mac", "type": MODEL_1, "auth": False, "gen": gen}, + return_value={ + "mac": "another-mac", + "type": MODEL_1, + "auth": False, + "gen": gen, + }, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"host": "10.10.10.10", "port": 99}, + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: 99}, ) assert result["type"] is FlowResultType.ABORT @@ -1506,7 +1703,7 @@ async def test_reconfigure_with_exception( ) -> None: """Test reconfiguration flow when an exception is raised.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": 2} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0", CONF_GEN: 2} ) entry.add_to_hass(hass) @@ -1518,11 +1715,24 @@ async def test_reconfigure_with_exception( with patch("homeassistant.components.shelly.config_flow.get_info", side_effect=exc): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"host": "10.10.10.10", "port": 99}, + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: 99}, ) assert result["errors"] == {"base": base_error} + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False, "gen": 2}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: 99}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == {CONF_HOST: "10.10.10.10", CONF_PORT: 99, CONF_GEN: 2} + async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: """Test zeroconf discovery rejects ipv6.""" diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8c011e4ad0d..cf7f82014a0 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -32,7 +32,6 @@ from homeassistant.helpers import device_registry as dr, issue_registry as ir from . import ( MOCK_MAC, - get_entity_state, init_integration, inject_rpc_device_event, mock_polling_rpc_update, @@ -72,7 +71,7 @@ async def test_block_reload_on_cfg_change( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Generate config change from switch to light monkeypatch.setitem( @@ -82,7 +81,7 @@ async def test_block_reload_on_cfg_change( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) @@ -114,14 +113,14 @@ async def test_block_no_reload_on_bulb_changes( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Test no reload on effect change monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "effect", 1) @@ -129,14 +128,14 @@ async def test_block_no_reload_on_bulb_changes( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") async def test_block_polling_auth_error( @@ -245,14 +244,16 @@ async def test_block_polling_connection_error( ) await init_integration(hass, 1) - assert get_entity_state(hass, "switch.test_name_channel_1") == STATE_ON + assert (state := hass.states.get("switch.test_name_channel_1")) + assert state.state == STATE_ON # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert get_entity_state(hass, "switch.test_name_channel_1") == STATE_UNAVAILABLE + assert (state := hass.states.get("switch.test_name_channel_1")) + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("exc", [DeviceConnectionError, MacAddressMismatchError]) @@ -270,12 +271,14 @@ async def test_block_rest_update_connection_error( await init_integration(hass, 1) await mock_rest_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON monkeypatch.setattr(mock_block_device, "update_shelly", AsyncMock(side_effect=exc)) await mock_rest_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_block_sleeping_device_no_periodic_updates( @@ -297,14 +300,16 @@ async def test_block_sleeping_device_no_periodic_updates( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 3600)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_block_device_push_updates_failure( @@ -386,6 +391,8 @@ async def test_rpc_reload_on_cfg_change( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC reload on config change.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) # Generate config change from switch to light @@ -414,7 +421,7 @@ async def test_rpc_reload_on_cfg_change( ) await hass.async_block_till_done() - assert hass.states.get("switch.test_switch_0") is not None + assert hass.states.get("switch.test_switch_0") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) @@ -560,7 +567,7 @@ async def test_rpc_update_entry_sleep_period( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert entry.data["sleep_period"] == 600 + assert entry.data[CONF_SLEEP_PERIOD] == 600 # Move time to generate sleep period update monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 3600) @@ -568,7 +575,7 @@ async def test_rpc_update_entry_sleep_period( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert entry.data["sleep_period"] == 3600 + assert entry.data[CONF_SLEEP_PERIOD] == 3600 async def test_rpc_sleeping_device_no_periodic_updates( @@ -594,14 +601,16 @@ async def test_rpc_sleeping_device_no_periodic_updates( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 1000)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) is STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_sleeping_device_firmware_unsupported( @@ -710,9 +719,12 @@ async def test_rpc_reconnect_error( exc: Exception, ) -> None: """Test RPC reconnect error.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setattr(mock_rpc_device, "initialize", AsyncMock(side_effect=exc)) @@ -722,16 +734,20 @@ async def test_rpc_reconnect_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_UNAVAILABLE async def test_rpc_error_running_connected_events( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Test RPC error while running connected events.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) with patch( "homeassistant.components.shelly.coordinator.async_ensure_ble_enabled", side_effect=DeviceConnectionError, @@ -741,14 +757,17 @@ async def test_rpc_error_running_connected_events( ) assert "Error running connected events for device" in caplog.text - assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE + + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_UNAVAILABLE # Move time to generate reconnect without error freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON async def test_rpc_polling_connection_error( @@ -769,11 +788,13 @@ async def test_rpc_polling_connection_error( ), ) - assert get_entity_state(hass, entity_id) == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" await mock_polling_rpc_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_polling_disconnected( @@ -788,11 +809,13 @@ async def test_rpc_polling_disconnected( monkeypatch.setattr(mock_rpc_device, "connected", False) - assert get_entity_state(hass, entity_id) == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" await mock_polling_rpc_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_update_entry_fw_ver( @@ -830,12 +853,28 @@ async def test_rpc_update_entry_fw_ver( assert device.sw_version == "99.0.0" +@pytest.mark.parametrize( + ("supports_scripts", "zigbee_enabled", "result"), + [ + (True, False, True), + (True, True, False), + (False, True, False), + (False, False, False), + ], +) async def test_rpc_runs_connected_events_when_initialized( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, + supports_scripts: bool, + zigbee_enabled: bool, + result: bool, ) -> None: """Test RPC runs connected events when initialized.""" + monkeypatch.setattr( + mock_rpc_device, "supports_scripts", AsyncMock(return_value=supports_scripts) + ) + monkeypatch.setattr(mock_rpc_device, "zigbee_enabled", zigbee_enabled) monkeypatch.setattr(mock_rpc_device, "initialized", False) await init_integration(hass, 2) @@ -846,8 +885,10 @@ async def test_rpc_runs_connected_events_when_initialized( mock_rpc_device.mock_initialized() await hass.async_block_till_done() - # BLE script list is called during connected events - assert call.script_list() in mock_rpc_device.mock_calls + assert call.supports_scripts() in mock_rpc_device.mock_calls + # BLE script list is called during connected events if device supports scripts + # and Zigbee is disabled + assert bool(call.script_list() in mock_rpc_device.mock_calls) == result async def test_rpc_sleeping_device_unload_ignore_ble_scanner( @@ -896,7 +937,8 @@ async def test_block_sleeping_device_connection_error( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online event with connection error monkeypatch.setattr( @@ -910,7 +952,8 @@ async def test_block_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Error connecting to Shelly device" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Move time to generate sleep period update freezer.tick(timedelta(seconds=sleep_period * UPDATE_PERIOD_MULTIPLIER)) @@ -918,7 +961,8 @@ async def test_block_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Sleeping device did not update" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_sleeping_device_connection_error( @@ -947,7 +991,8 @@ async def test_rpc_sleeping_device_connection_error( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online event with connection error monkeypatch.setattr( @@ -961,7 +1006,8 @@ async def test_rpc_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Error connecting to Shelly device" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Move time to generate sleep period update freezer.tick(timedelta(seconds=sleep_period * UPDATE_PERIOD_MULTIPLIER)) @@ -969,7 +1015,8 @@ async def test_rpc_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Sleeping device did not update" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_sleeping_device_late_setup( @@ -994,7 +1041,8 @@ async def test_rpc_sleeping_device_late_setup( monkeypatch.setattr(mock_rpc_device, "connected", True) mock_rpc_device.mock_initialized() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get("sensor.test_name_temperature") is not None + + assert hass.states.get("sensor.test_name_temperature") async def test_rpc_already_connected( diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index 40a364fd435..df3ab4f288d 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -47,7 +47,7 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, blocking=True, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_POSITION] == 50 await hass.services.async_call( @@ -56,7 +56,8 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.OPENING + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.OPENING await hass.services.async_call( COVER_DOMAIN, @@ -64,7 +65,8 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.CLOSING + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSING await hass.services.async_call( COVER_DOMAIN, @@ -72,10 +74,10 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.CLOSED + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSED - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-roller_0" @@ -86,11 +88,15 @@ async def test_block_device_update( monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "rollerPos", 0) await init_integration(hass, 1) - assert hass.states.get("cover.test_name").state == CoverState.CLOSED + state = hass.states.get("cover.test_name") + assert state + assert state.state == CoverState.CLOSED monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "rollerPos", 100) mock_block_device.mock_update() - assert hass.states.get("cover.test_name").state == CoverState.OPEN + state = hass.states.get("cover.test_name") + assert state + assert state.state == CoverState.OPEN async def test_block_device_no_roller_blocks( @@ -99,6 +105,7 @@ async def test_block_device_no_roller_blocks( """Test block device without roller blocks.""" monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "type", None) await init_integration(hass, 1) + assert hass.states.get("cover.test_name") is None @@ -118,7 +125,7 @@ async def test_rpc_device_services( {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, blocking=True, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_POSITION] == 50 mutate_rpc_device_status( @@ -131,7 +138,9 @@ async def test_rpc_device_services( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == CoverState.OPENING + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.OPENING mutate_rpc_device_status( monkeypatch, mock_rpc_device, "cover:0", "state", "closing" @@ -143,7 +152,9 @@ async def test_rpc_device_services( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == CoverState.CLOSING + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSING mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "closed") await hass.services.async_call( @@ -153,10 +164,10 @@ async def test_rpc_device_services( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == CoverState.CLOSED + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSED - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cover:0" @@ -166,6 +177,7 @@ async def test_rpc_device_no_cover_keys( """Test RPC device without cover keys.""" monkeypatch.delitem(mock_rpc_device.status, "cover:0") await init_integration(hass, 2) + assert hass.states.get("cover.test_cover_0") is None @@ -175,11 +187,16 @@ async def test_rpc_device_update( """Test RPC device update.""" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "closed") await init_integration(hass, 2) - assert hass.states.get("cover.test_cover_0").state == CoverState.CLOSED + + state = hass.states.get("cover.test_cover_0") + assert state + assert state.state == CoverState.CLOSED mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "open") mock_rpc_device.mock_update() - assert hass.states.get("cover.test_cover_0").state == CoverState.OPEN + state = hass.states.get("cover.test_cover_0") + assert state + assert state.state == CoverState.OPEN async def test_rpc_device_no_position_control( @@ -190,7 +207,10 @@ async def test_rpc_device_no_position_control( monkeypatch, mock_rpc_device, "cover:0", "pos_control", False ) await init_integration(hass, 2) - assert hass.states.get("cover.test_cover_0").state == CoverState.OPEN + + state = hass.states.get("cover.test_cover_0") + assert state + assert state.state == CoverState.OPEN async def test_rpc_cover_tilt( @@ -212,11 +232,10 @@ async def test_rpc_cover_tilt( await init_integration(hass, 3) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cover:0" await hass.services.async_call( @@ -228,7 +247,7 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 50) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( @@ -240,7 +259,7 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 100) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 await hass.services.async_call( @@ -258,5 +277,5 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 10) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index fb68393304b..ca9edb19fa7 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -25,7 +25,7 @@ from homeassistant.setup import async_setup_component from . import init_integration -from tests.common import MockConfigEntry, async_get_device_automations +from tests.common import async_get_device_automations @pytest.mark.parametrize( @@ -162,14 +162,16 @@ async def test_get_triggers_for_invalid_device_id( ) -> None: """Test error raised for invalid shelly device_id.""" await init_integration(hass, 1) - config_entry = MockConfigEntry(domain=DOMAIN, data={}) - config_entry.add_to_hass(hass) + config_entry = await init_integration(hass, 1, data={}, skip_setup=True) invalid_device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - with pytest.raises(InvalidDeviceAutomationConfig): + with pytest.raises( + InvalidDeviceAutomationConfig, + match="not found while configuring device automation triggers", + ): await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, invalid_device.id ) @@ -385,7 +387,10 @@ async def test_validate_trigger_invalid_triggers( }, ) - assert "Invalid (type,subtype): ('single', 'button3')" in caplog.text + assert ( + "Invalid device automation trigger (type, subtype): ('single', 'button3')" + in caplog.text + ) async def test_rpc_no_runtime_data( diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index c0f78d48d9b..84ebd50c425 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -109,8 +109,14 @@ async def test_rpc_config_entry_diagnostics( "bluetooth": { "scanner": { "connectable": False, - "current_mode": None, - "requested_mode": None, + "current_mode": { + "__type": "", + "repr": "", + }, + "requested_mode": { + "__type": "", + "repr": "", + }, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY}, "discovered_devices_and_advertisement_data": [ { @@ -134,17 +140,17 @@ async def test_rpc_config_entry_diagnostics( -62, [], ], - "details": {"source": "12:34:56:78:9A:BC"}, + "details": {"source": "12:34:56:78:9A:BE"}, "name": None, "rssi": -62, } ], "last_detection": ANY, "monotonic_time": ANY, - "name": "Mock Title (12:34:56:78:9A:BC)", + "name": "Mock Title (12:34:56:78:9A:BE)", "scanning": True, "start_time": ANY, - "source": "12:34:56:78:9A:BC", + "source": "12:34:56:78:9A:BE", "time_since_last_device_detection": {"AA:BB:CC:DD:EE:FF": ANY}, "type": "ShellyBLEScanner", } @@ -194,3 +200,21 @@ async def test_rpc_config_entry_diagnostics_ws_outbound( result["device_settings"]["ws_outbound_server_valid"] == ws_outbound_server_valid ) + + +async def test_rpc_config_entry_diagnostics_no_ws( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test config entry diagnostics for rpc device which doesn't support ws outbound.""" + config = deepcopy(mock_rpc_device.config) + config.pop("ws") + monkeypatch.setattr(mock_rpc_device, "config", config) + + entry = await init_integration(hass, 3) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result["device_settings"]["ws_outbound"] == "not supported" diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index e184c154697..a3c96b6b247 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -6,7 +6,7 @@ from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.const import MODEL_I3 import pytest from pytest_unordered import unordered -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.event import ( ATTR_EVENT_TYPE, @@ -33,8 +33,7 @@ async def test_rpc_button( await init_integration(hass, 2) entity_id = "event.test_name_input_0" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( ["btn_down", "btn_up", "double_push", "long_push", "single_push", "triple_push"] @@ -42,8 +41,7 @@ async def test_rpc_button( assert state.attributes.get(ATTR_EVENT_TYPE) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == EventDeviceClass.BUTTON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:0" inject_rpc_device_event( @@ -62,7 +60,7 @@ async def test_rpc_button( ) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) == "single_push" @@ -78,11 +76,9 @@ async def test_rpc_script_1_event( await init_integration(hass, 2) entity_id = "event.test_name_test_script_js" - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") inject_rpc_device_event( monkeypatch, @@ -101,7 +97,7 @@ async def test_rpc_script_1_event( ) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) == "script_start" inject_rpc_device_event( @@ -121,7 +117,7 @@ async def test_rpc_script_1_event( ) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) != "unknown_event" @@ -135,11 +131,9 @@ async def test_rpc_script_2_event( await init_integration(hass, 2) entity_id = "event.test_name_test_script_2_js" - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -152,11 +146,9 @@ async def test_rpc_script_ble_event( await init_integration(hass, 2) entity_id = f"event.test_name_{BLE_SCRIPT_NAME}" - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") async def test_rpc_event_removal( @@ -186,15 +178,13 @@ async def test_block_event( await init_integration(hass, 1) entity_id = "event.test_name_channel_1" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_EVENT_TYPES) == unordered(["single", "long"]) assert state.attributes.get(ATTR_EVENT_TYPE) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == EventDeviceClass.BUTTON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-1" monkeypatch.setattr( @@ -206,7 +196,7 @@ async def test_block_event( mock_block_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) == "long" @@ -217,8 +207,7 @@ async def test_block_event_shix3_1( await init_integration(hass, 1, model=MODEL_I3) entity_id = "event.test_name_channel_1" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( ["double", "long", "long_single", "single", "single_long", "triple"] ) diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index b05bce76728..4cf49a2dab8 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -10,10 +10,14 @@ from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, MacAddressMismatchError, + RpcCallError, ) +from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac import pytest from homeassistant.components.shelly.const import ( + BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, + BLE_SCANNER_MIN_FIRMWARE, BLOCK_EXPECTED_SLEEP_PERIOD, BLOCK_WRONG_SLEEP_PERIOD, CONF_BLE_SCANNER_MODE, @@ -24,20 +28,20 @@ from homeassistant.components.shelly.const import ( BLEScannerMode, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PORT, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceRegistry, - format_mac, -) +from homeassistant.helpers.device_registry import DeviceRegistry, format_mac from homeassistant.setup import async_setup_component from . import MOCK_MAC, init_integration, mutate_rpc_device_status -from tests.common import MockConfigEntry - async def test_custom_coap_port( hass: HomeAssistant, mock_block_device: Mock, caplog: pytest.LogCaptureFixture @@ -121,12 +125,6 @@ async def test_shared_device_mac( caplog: pytest.LogCaptureFixture, ) -> None: """Test first time shared device with another domain.""" - config_entry = MockConfigEntry(domain="test", data={}, unique_id="some_id") - config_entry.add_to_hass(hass) - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, - ) await init_integration(hass, gen, sleep_period=1000) assert "will resume when device is online" in caplog.text @@ -135,12 +133,7 @@ async def test_setup_entry_not_shelly( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test not Shelly entry.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) is False - await hass.async_block_till_done() - + await init_integration(hass, 1, data={}) assert "probably comes from a custom integration" in caplog.text @@ -247,12 +240,7 @@ async def test_sleeping_block_device_online( caplog: pytest.LogCaptureFixture, ) -> None: """Test sleeping block device online.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id="shelly") - config_entry.add_to_hass(hass) - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, - ) + await init_integration(hass, 1, data={}) monkeypatch.setitem( mock_block_device.settings, @@ -266,7 +254,7 @@ async def test_sleeping_block_device_online( await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text - assert entry.data["sleep_period"] == device_sleep + assert entry.data[CONF_SLEEP_PERIOD] == device_sleep @pytest.mark.parametrize(("entry_sleep", "device_sleep"), [(None, 0), (1000, 1000)]) @@ -288,7 +276,7 @@ async def test_sleeping_rpc_device_online( await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text - assert entry.data["sleep_period"] == device_sleep + assert entry.data[CONF_SLEEP_PERIOD] == device_sleep async def test_sleeping_rpc_device_online_new_firmware( @@ -307,7 +295,7 @@ async def test_sleeping_rpc_device_online_new_firmware( await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text - assert entry.data["sleep_period"] == 1500 + assert entry.data[CONF_SLEEP_PERIOD] == 1500 async def test_sleeping_rpc_device_online_during_setup( @@ -321,7 +309,8 @@ async def test_sleeping_rpc_device_online_during_setup( assert "will resume when device is online" in caplog.text assert "is online (source: setup)" in caplog.text - assert hass.states.get("sensor.test_name_temperature") is not None + + assert hass.states.get("sensor.test_name_temperature") async def test_sleeping_rpc_device_offline_during_setup( @@ -350,7 +339,7 @@ async def test_sleeping_rpc_device_offline_during_setup( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get("sensor.test_name_temperature") is not None + assert hass.states.get("sensor.test_name_temperature") @pytest.mark.parametrize( @@ -366,18 +355,23 @@ async def test_entry_unload( entity_id: str, mock_block_device: Mock, mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test entry unload.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) entry = await init_integration(hass, gen) assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(entity_id).state is STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert hass.states.get(entity_id).state is STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -395,9 +389,9 @@ async def test_entry_unload_device_not_ready( mock_rpc_device: Mock, ) -> None: """Test entry unload when device is not ready.""" - entry = await init_integration(hass, gen, sleep_period=1000) - + assert (entry := await init_integration(hass, gen, sleep_period=1000)) assert entry.state is ConfigEntryState.LOADED + assert hass.states.get(entity_id) is None await hass.config_entries.async_unload(entry.entry_id) @@ -410,16 +404,21 @@ async def test_entry_unload_not_connected( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test entry unload when not connected.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner" ) as mock_stop_scanner: - entry = await init_integration( - hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + assert ( + entry := await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) ) - entity_id = "switch.test_switch_0" - assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(entity_id).state is STATE_ON + + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON assert not mock_stop_scanner.call_count monkeypatch.setattr(mock_rpc_device, "connected", False) @@ -435,17 +434,22 @@ async def test_entry_unload_not_connected_but_we_think_we_are( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test entry unload when not connected but we think we are still connected.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner", side_effect=DeviceConnectionError, ) as mock_stop_scanner: - entry = await init_integration( - hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + assert ( + entry := await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) ) - entity_id = "switch.test_switch_0" - assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(entity_id).state is STATE_ON + + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON assert not mock_stop_scanner.call_count monkeypatch.setattr(mock_rpc_device, "connected", False) @@ -478,7 +482,9 @@ async def test_entry_missing_gen(hass: HomeAssistant, mock_block_device: Mock) - entry = await init_integration(hass, None) assert entry.state is ConfigEntryState.LOADED - assert hass.states.get("switch.test_name_channel_1").state is STATE_ON + + assert (state := hass.states.get("switch.test_name_channel_1")) + assert state.state == STATE_ON async def test_entry_missing_port(hass: HomeAssistant) -> None: @@ -486,11 +492,10 @@ async def test_entry_missing_port(hass: HomeAssistant) -> None: data = { CONF_HOST: "192.168.1.37", CONF_SLEEP_PERIOD: 0, - "model": MODEL_PLUS_2PM, + CONF_MODEL: MODEL_PLUS_2PM, CONF_GEN: 2, } - entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) - entry.add_to_hass(hass) + entry = await init_integration(hass, 2, data=data, skip_setup=True) with ( patch("homeassistant.components.shelly.RpcDevice.initialize"), patch( @@ -510,12 +515,11 @@ async def test_rpc_entry_custom_port(hass: HomeAssistant) -> None: data = { CONF_HOST: "192.168.1.37", CONF_SLEEP_PERIOD: 0, - "model": MODEL_PLUS_2PM, + CONF_MODEL: MODEL_PLUS_2PM, CONF_GEN: 2, CONF_PORT: 8001, } - entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) - entry.add_to_hass(hass) + entry = await init_integration(hass, 2, data=data, skip_setup=True) with ( patch("homeassistant.components.shelly.RpcDevice.initialize"), patch( @@ -560,4 +564,44 @@ async def test_bluetooth_cleanup_on_remove_entry( 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()) + remove_mock.assert_called_once_with( + hass, format_mac(bluetooth_mac_from_primary_mac(entry.unique_id)).upper() + ) + + +async def test_device_script_getcode_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test device script get code error.""" + monkeypatch.setattr( + mock_rpc_device, "script_getcode", AsyncMock(side_effect=RpcCallError(0)) + ) + + entry = await init_integration(hass, 2) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_ble_scanner_unsupported_firmware_fixed( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + issue_registry: ir.IssueRegistry, +) -> None: + """Test device init with unsupported firmware.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + entry = await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + monkeypatch.setitem(mock_rpc_device.shelly, "ver", BLE_SCANNER_MIN_FIRMWARE) + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index 482821aa966..0dab06f53a9 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -65,18 +65,17 @@ async def test_block_device_rgbw_bulb( await init_integration(hass, 1, model=MODEL_BULB) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGBW_COLOR] == (45, 55, 65, 70) - assert attributes[ATTR_BRIGHTNESS] == 48 - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + assert state.attributes[ATTR_RGBW_COLOR] == (45, 55, 65, 70) + assert state.attributes[ATTR_BRIGHTNESS] == 48 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ ColorMode.COLOR_TEMP, ColorMode.RGBW, ] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT - assert len(attributes[ATTR_EFFECT_LIST]) == 7 - assert attributes[ATTR_EFFECT] == "Off" + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert len(state.attributes[ATTR_EFFECT_LIST]) == 7 + assert state.attributes[ATTR_EFFECT] == "Off" # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -89,7 +88,7 @@ async def test_block_device_rgbw_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, RGBW = [70, 80, 90, 20], brightness = 33, effect = Flash @@ -108,13 +107,12 @@ async def test_block_device_rgbw_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", gain=13, brightness=13, red=70, green=80, blue=90, white=30, effect=3 ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGBW - assert attributes[ATTR_RGBW_COLOR] == (70, 80, 90, 30) - assert attributes[ATTR_BRIGHTNESS] == 33 - assert attributes[ATTR_EFFECT] == "Flash" + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGBW + assert state.attributes[ATTR_RGBW_COLOR] == (70, 80, 90, 30) + assert state.attributes[ATTR_BRIGHTNESS] == 33 + assert state.attributes[ATTR_EFFECT] == "Flash" # Turn on, COLOR_TEMP_KELVIN = 3500 mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -127,14 +125,12 @@ async def test_block_device_rgbw_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", temp=3500, mode="white" ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_0" @@ -154,21 +150,20 @@ async def test_block_device_rgb_bulb( await init_integration(hass, 1, model=MODEL_BULB_RGBW) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGB_COLOR] == (45, 55, 65) - assert attributes[ATTR_BRIGHTNESS] == 48 - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + assert state.attributes[ATTR_RGB_COLOR] == (45, 55, 65) + assert state.attributes[ATTR_BRIGHTNESS] == 48 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ ColorMode.COLOR_TEMP, ColorMode.RGB, ] assert ( - attributes[ATTR_SUPPORTED_FEATURES] + state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION ) - assert len(attributes[ATTR_EFFECT_LIST]) == 4 - assert attributes[ATTR_EFFECT] == "Off" + assert len(state.attributes[ATTR_EFFECT_LIST]) == 4 + assert state.attributes[ATTR_EFFECT] == "Off" # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -181,7 +176,7 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, RGB = [70, 80, 90], brightness = 33, effect = Flash @@ -200,13 +195,12 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", gain=13, brightness=13, red=70, green=80, blue=90, effect=3 ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGB - assert attributes[ATTR_RGB_COLOR] == (70, 80, 90) - assert attributes[ATTR_BRIGHTNESS] == 33 - assert attributes[ATTR_EFFECT] == "Flash" + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGB + assert state.attributes[ATTR_RGB_COLOR] == (70, 80, 90) + assert state.attributes[ATTR_BRIGHTNESS] == 33 + assert state.attributes[ATTR_EFFECT] == "Flash" # Turn on, COLOR_TEMP_KELVIN = 3500 mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -219,11 +213,10 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", temp=3500, mode="white" ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 # Turn on with unsupported effect mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -236,14 +229,13 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", mode="color" ) - state = hass.states.get(entity_id) - attributes = state.attributes + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_EFFECT] == "Off" + assert state.attributes[ATTR_EFFECT] == "Off" assert "Effect 'Breath' not supported" in caplog.text - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_1" @@ -272,12 +264,11 @@ async def test_block_device_white_bulb( await init_integration(hass, 1, model=MODEL_VINTAGE_V2) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_BRIGHTNESS] == 128 - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION + assert state.attributes[ATTR_BRIGHTNESS] == 128 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -290,7 +281,7 @@ async def test_block_device_white_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, brightness = 33 @@ -304,13 +295,11 @@ async def test_block_device_white_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", gain=13, brightness=13 ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_BRIGHTNESS] == 33 + assert state.attributes[ATTR_BRIGHTNESS] == 33 - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_1" @@ -343,9 +332,8 @@ async def test_block_device_support_transition( await init_integration(hass, 1, model=model) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes - assert attributes[ATTR_SUPPORTED_FEATURES] & LightEntityFeature.TRANSITION + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_SUPPORTED_FEATURES] & LightEntityFeature.TRANSITION # Turn on, TRANSITION = 4 mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -358,7 +346,7 @@ async def test_block_device_support_transition( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", transition=4000 ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON # Turn off, TRANSITION = 6, limit to 5000ms @@ -372,11 +360,10 @@ async def test_block_device_support_transition( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off", transition=5000 ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_1" @@ -403,14 +390,14 @@ async def test_block_device_relay_app_type_light( mock_block_device.blocks[RELAY_BLOCK_ID], "description", "relay_1" ) await init_integration(hass, 1) + assert hass.states.get("switch.test_name_channel_1") is None # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] - assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 # Turn off mock_block_device.blocks[RELAY_BLOCK_ID].set_state.reset_mock() @@ -423,7 +410,7 @@ async def test_block_device_relay_app_type_light( mock_block_device.blocks[RELAY_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on @@ -437,11 +424,10 @@ async def test_block_device_relay_app_type_light( mock_block_device.blocks[RELAY_BLOCK_ID].set_state.assert_called_once_with( turn="on" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_1" @@ -451,6 +437,7 @@ async def test_block_device_no_light_blocks( """Test block device without light blocks.""" monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "type", "roller") await init_integration(hass, 1) + assert hass.states.get("light.test_name_channel_1") is None @@ -473,7 +460,9 @@ async def test_rpc_device_switch_type_lights_mode( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == STATE_ON + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON mutate_rpc_device_status(monkeypatch, mock_rpc_device, "switch:0", "output", False) await hass.services.async_call( @@ -483,10 +472,11 @@ async def test_rpc_device_switch_type_lights_mode( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_OFF - entry = entity_registry.async_get(entity_id) - assert entry + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-switch:0" @@ -510,7 +500,8 @@ async def test_rpc_light( ) mock_rpc_device.call_rpc.assert_called_once_with("Light.Set", {"id": 0, "on": True}) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 135 @@ -528,7 +519,8 @@ async def test_rpc_light( mock_rpc_device.call_rpc.assert_called_once_with( "Light.Set", {"id": 0, "on": False} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, brightness = 33 @@ -547,7 +539,8 @@ async def test_rpc_light( mock_rpc_device.call_rpc.assert_called_once_with( "Light.Set", {"id": 0, "on": True, "brightness": 13} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 33 @@ -565,7 +558,8 @@ async def test_rpc_light( mock_rpc_device.call_rpc.assert_called_once_with( "Light.Set", {"id": 0, "on": True, "transition_duration": 10.1} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON # Turn off, transition = 0.4, should be limited to 0.5 @@ -584,11 +578,10 @@ async def test_rpc_light( "Light.Set", {"id": 0, "on": False, "transition_duration": 0.5} ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light:0" @@ -606,12 +599,11 @@ async def test_rpc_device_rgb_profile( await init_integration(hass, 2) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGB_COLOR] == (45, 55, 65) - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGB] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION + assert state.attributes[ATTR_RGB_COLOR] == (45, 55, 65) + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGB] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn on, RGB = [70, 80, 90] await hass.services.async_call( @@ -628,14 +620,12 @@ async def test_rpc_device_rgb_profile( "RGB.Set", {"id": 0, "on": True, "rgb": [70, 80, 90]} ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGB - assert attributes[ATTR_RGB_COLOR] == (70, 80, 90) + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGB + assert state.attributes[ATTR_RGB_COLOR] == (70, 80, 90) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-rgb:0" @@ -653,12 +643,11 @@ async def test_rpc_device_rgbw_profile( await init_integration(hass, 2) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGBW_COLOR] == (21, 22, 23, 120) - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBW] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION + assert state.attributes[ATTR_RGBW_COLOR] == (21, 22, 23, 120) + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBW] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn on, RGBW = [72, 82, 92, 128] await hass.services.async_call( @@ -678,14 +667,12 @@ async def test_rpc_device_rgbw_profile( "RGBW.Set", {"id": 0, "on": True, "rgb": [72, 82, 92], "white": 128} ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGBW - assert attributes[ATTR_RGBW_COLOR] == (72, 82, 92, 128) + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGBW + assert state.attributes[ATTR_RGBW_COLOR] == (72, 82, 92, 128) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-rgbw:0" @@ -730,9 +717,11 @@ async def test_rpc_rgbw_device_light_mode_remove_others( # verify we have 4 lights for i in range(SHELLY_PLUS_RGBW_CHANNELS): entity_id = f"light.test_light_{i}" - assert hass.states.get(entity_id).state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-light:{i}" # verify RGB & RGBW entities removed @@ -793,9 +782,11 @@ async def test_rpc_rgbw_device_rgb_w_modes_remove_others( # verify we have RGB/w light entity_id = f"light.test_{active_mode}_0" - assert hass.states.get(entity_id).state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{active_mode}:0" # verify light & RGB/W entities removed @@ -823,8 +814,7 @@ async def test_rpc_cct_light( await init_integration(hass, 2) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cct:0" # Turn off @@ -836,7 +826,8 @@ async def test_rpc_cct_light( ) mock_rpc_device.call_rpc.assert_called_once_with("CCT.Set", {"id": 0, "on": False}) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on @@ -851,7 +842,8 @@ async def test_rpc_cct_light( mock_rpc_device.mock_update() mock_rpc_device.call_rpc.assert_called_once_with("CCT.Set", {"id": 0, "on": True}) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP assert state.attributes[ATTR_BRIGHTNESS] == 196 # 77% of 255 @@ -874,7 +866,8 @@ async def test_rpc_cct_light( mock_rpc_device.call_rpc.assert_called_once_with( "CCT.Set", {"id": 0, "on": True, "brightness": 88} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 224 # 88% of 255 @@ -894,7 +887,8 @@ async def test_rpc_cct_light( mock_rpc_device.call_rpc.assert_called_once_with( "CCT.Set", {"id": 0, "on": True, "ct": 4444} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4444 diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 6bddd1eeb23..e33b04721cc 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -4,9 +4,9 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock from aioshelly.const import MODEL_BLU_GATEWAY_G3 -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_MAX, @@ -18,7 +18,7 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, NumberMode, ) -from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN +from homeassistant.components.shelly.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State @@ -54,15 +54,16 @@ async def test_block_number_update( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "50" + assert (state := hass.states.get(entity_id)) + assert state.state == "50" monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valvePos", 30) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == "30" + assert (state := hass.states.get(entity_id)) + assert state.state == "30" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-device_0-valvePos" @@ -103,14 +104,16 @@ async def test_block_restored_number( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "40" + assert (state := hass.states.get(entity_id)) + assert state.state == "40" # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "50" + assert (state := hass.states.get(entity_id)) + assert state.state == "50" async def test_block_restored_number_no_last_state( @@ -141,14 +144,16 @@ async def test_block_restored_number_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "50" + assert (state := hass.states.get(entity_id)) + assert state.state == "50" async def test_block_number_set_value( @@ -200,7 +205,10 @@ async def test_block_set_value_connection_error( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while calling action for number.test_name_valve_position of Test name", + ): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -302,8 +310,7 @@ async def test_rpc_device_virtual_number( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "12.3" assert state.attributes.get(ATTR_MIN) == 0 assert state.attributes.get(ATTR_MAX) == 100 @@ -311,13 +318,13 @@ async def test_rpc_device_virtual_number( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit assert state.attributes.get(ATTR_MODE) is mode - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-number:203-number" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 78.9) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "78.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "78.9" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 56.7) await hass.services.async_call( @@ -327,7 +334,10 @@ async def test_rpc_device_virtual_number( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "56.7" + mock_rpc_device.number_set.assert_called_once_with(203, 56.7) + + assert (state := hass.states.get(entity_id)) + assert state.state == "56.7" async def test_rpc_remove_virtual_number_when_mode_label( @@ -365,8 +375,7 @@ async def test_rpc_remove_virtual_number_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_number_when_orphaned( @@ -390,8 +399,7 @@ async def test_rpc_remove_virtual_number_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_blu_trv_number_entity( @@ -427,7 +435,8 @@ async def test_blu_trv_ext_temp_set_value( # After HA start the state should be unknown because there was no previous external # temperature report - assert hass.states.get(entity_id).state is STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN await hass.services.async_call( NUMBER_DOMAIN, @@ -439,17 +448,10 @@ async def test_blu_trv_ext_temp_set_value( blocking=True, ) mock_blu_trv.mock_update() - mock_blu_trv.call_rpc.assert_called_once_with( - "BluTRV.Call", - { - "id": 200, - "method": "Trv.SetExternalTemperature", - "params": {"id": 0, "t_C": 22.2}, - }, - BLU_TRV_TIMEOUT, - ) + mock_blu_trv.blu_trv_set_external_temperature.assert_called_once_with(200, 22.2) - assert hass.states.get(entity_id).state == "22.2" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.2" async def test_blu_trv_valve_pos_set_value( @@ -465,7 +467,8 @@ async def test_blu_trv_valve_pos_set_value( entity_id = f"{NUMBER_DOMAIN}.trv_name_valve_position" - assert hass.states.get(entity_id).state == "0" + assert (state := hass.states.get(entity_id)) + assert state.state == "0" monkeypatch.setitem(mock_blu_trv.status["blutrv:200"], "pos", 20) await hass.services.async_call( @@ -478,16 +481,77 @@ async def test_blu_trv_valve_pos_set_value( blocking=True, ) mock_blu_trv.mock_update() - mock_blu_trv.call_rpc.assert_called_once_with( - "BluTRV.Call", - { - "id": 200, - "method": "Trv.SetPosition", - "params": {"id": 0, "pos": 20}, - }, - BLU_TRV_TIMEOUT, - ) - # device only accepts int for 'pos' value - assert isinstance(mock_blu_trv.call_rpc.call_args[0][1]["params"]["pos"], int) + mock_blu_trv.blu_trv_set_valve_position.assert_called_once_with(200, 20.0) - assert hass.states.get(entity_id).state == "20" + assert (state := hass.states.get(entity_id)) + assert state.state == "20" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for number.trv_name_external_temperature of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for number.trv_name_external_temperature of Test name", + ), + ], +) +async def test_blu_trv_number_exc( + hass: HomeAssistant, + mock_blu_trv: Mock, + exception: Exception, + error: str, +) -> None: + """Test RPC/BLU TRV number with exception.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_external_temperature.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.trv_name_external_temperature", + ATTR_VALUE: 20.0, + }, + blocking=True, + ) + + +async def test_blu_trv_number_reauth_error( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC/BLU TRV number with authentication error.""" + entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.blu_trv_set_external_temperature.side_effect = InvalidAuthError + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.trv_name_external_temperature", + ATTR_VALUE: 20.0, + }, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + 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") == entry.entry_id diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py new file mode 100644 index 00000000000..f68d2f82f1b --- /dev/null +++ b/tests/components/shelly/test_repairs.py @@ -0,0 +1,131 @@ +"""Test repairs handling for Shelly.""" + +from unittest.mock import Mock + +from aioshelly.exceptions import DeviceConnectionError, RpcCallError +import pytest + +from homeassistant.components.shelly.const import ( + BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, + CONF_BLE_SCANNER_MODE, + DOMAIN, + BLEScannerMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from . import MOCK_MAC, init_integration + +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +async def test_ble_scanner_unsupported_firmware_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issues handling for BLE scanner with unsupported firmware.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + assert mock_rpc_device.trigger_ota_update.call_count == 1 + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +async def test_unsupported_firmware_issue_update_not_available( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issues handling when firmware update is not available.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + monkeypatch.setitem(mock_rpc_device.status, "sys", {"available_updates": {}}) + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "abort" + assert result["reason"] == "update_not_available" + assert mock_rpc_device.trigger_ota_update.call_count == 0 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + +@pytest.mark.parametrize( + "exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")] +) +async def test_unsupported_firmware_issue_exc( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + exception: Exception, +) -> None: + """Test repair issues handling when OTA update ends with an exception.""" + issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + mock_rpc_device.trigger_ota_update.side_effect = exception + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + assert mock_rpc_device.trigger_ota_update.call_count == 1 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 diff --git a/tests/components/shelly/test_select.py b/tests/components/shelly/test_select.py index 0a6eb2a5843..bb68edd1961 100644 --- a/tests/components/shelly/test_select.py +++ b/tests/components/shelly/test_select.py @@ -3,6 +3,7 @@ from copy import deepcopy from unittest.mock import Mock +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest from homeassistant.components.select import ( @@ -11,8 +12,11 @@ from homeassistant.components.select import ( DOMAIN as SELECT_PLATFORM, SERVICE_SELECT_OPTION, ) +from homeassistant.components.shelly.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -56,8 +60,7 @@ async def test_rpc_device_virtual_enum( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == expected_state assert state.attributes.get(ATTR_OPTIONS) == [ "Title 1", @@ -65,13 +68,14 @@ async def test_rpc_device_virtual_enum( "option 3", ] - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-enum:203-enum" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "option 2") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "option 2" + + assert (state := hass.states.get(entity_id)) + assert state.state == "option 2" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "option 1") await hass.services.async_call( @@ -81,9 +85,11 @@ async def test_rpc_device_virtual_enum( blocking=True, ) # 'Title 1' corresponds to 'option 1' - assert mock_rpc_device.call_rpc.call_args[0][1] == {"id": 203, "value": "option 1"} + mock_rpc_device.enum_set.assert_called_once_with(203, "option 1") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "Title 1" + + assert (state := hass.states.get(entity_id)) + assert state.state == "Title 1" async def test_rpc_remove_virtual_enum_when_mode_label( @@ -122,8 +128,7 @@ async def test_rpc_remove_virtual_enum_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_enum_when_orphaned( @@ -147,5 +152,109 @@ async def test_rpc_remove_virtual_enum_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for select.test_name_enum_203 of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for select.test_name_enum_203 of Test name", + ), + ], +) +async def test_select_set_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, + error: str, +) -> None: + """Test select setting with exception.""" + config = deepcopy(mock_rpc_device.config) + config["enum:203"] = { + "name": None, + "options": ["option 1", "option 2", "option 3"], + "meta": { + "ui": { + "view": "dropdown", + "titles": {"option 1": "Title 1", "option 2": None}, + } + }, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["enum:203"] = {"value": "option 1"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + mock_rpc_device.enum_set.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + SELECT_PLATFORM, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"{SELECT_PLATFORM}.test_name_enum_203", + ATTR_OPTION: "option 2", + }, + blocking=True, + ) + + +async def test_select_set_reauth_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test select setting with authentication error.""" + config = deepcopy(mock_rpc_device.config) + config["enum:203"] = { + "name": None, + "options": ["option 1", "option 2", "option 3"], + "meta": { + "ui": { + "view": "dropdown", + "titles": {"option 1": "Title 1", "option 2": None}, + } + }, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["enum:203"] = {"value": "option 1"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + entry = await init_integration(hass, 3) + + mock_rpc_device.enum_set.side_effect = InvalidAuthError + + await hass.services.async_call( + SELECT_PLATFORM, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"{SELECT_PLATFORM}.test_name_enum_203", + ATTR_OPTION: "option 2", + }, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + 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") == entry.entry_id diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index d0fec65c7de..3bf63546419 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import Mock from aioshelly.const import MODEL_BLU_GATEWAY_G3 from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, @@ -40,7 +40,6 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from . import ( - get_entity_state, init_integration, mock_polling_rpc_update, mock_rest_update, @@ -66,15 +65,16 @@ async def test_block_sensor( entity_id = f"{SENSOR_DOMAIN}.test_name_channel_1_power" await init_integration(hass, 1) - assert hass.states.get(entity_id).state == "53.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "53.4" monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "power", 60.1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == "60.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "60.1" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-power" @@ -85,14 +85,13 @@ async def test_energy_sensor( entity_id = f"{SENSOR_DOMAIN}.test_name_channel_1_energy" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) # 1234567.89 Wmin / 60 / 1000 = 20.5761315 kWh assert state.state == "20.5761315" # suggested unit is KWh assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-energy" @@ -111,13 +110,12 @@ async def test_power_factory_unit_migration( entity_id = f"{SENSOR_DOMAIN}.test_name_power_factor" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) # Value of 0.98 is converted to 98.0% assert state.state == "98.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-emeter_0-powerFactor" @@ -128,12 +126,11 @@ async def test_power_factory_without_unit_migration( entity_id = f"{SENSOR_DOMAIN}.test_name_power_factor" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "0.98" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-emeter_0-powerFactor" @@ -147,12 +144,14 @@ async def test_block_rest_sensor( entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "rssi") await init_integration(hass, 1) - assert hass.states.get(entity_id).state == "-64" + assert (state := hass.states.get(entity_id)) + assert state.state == "-64" monkeypatch.setitem(mock_block_device.status["wifi_sta"], "rssi", -71) await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == "-71" + assert (state := hass.states.get(entity_id)) + assert state.state == "-71" async def test_block_sleeping_sensor( @@ -175,15 +174,16 @@ async def test_block_sleeping_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "temp", 23.4) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == "23.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "23.4" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-temp" @@ -211,8 +211,7 @@ async def test_block_restored_sleeping_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "20.4" assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE @@ -222,7 +221,8 @@ async def test_block_restored_sleeping_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" async def test_block_restored_sleeping_sensor_no_last_state( @@ -246,14 +246,16 @@ async def test_block_restored_sleeping_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" async def test_block_sensor_error( @@ -266,15 +268,16 @@ async def test_block_sensor_error( entity_id = f"{SENSOR_DOMAIN}.test_name_battery" await init_integration(hass, 1) - assert hass.states.get(entity_id).state == "98" + assert (state := hass.states.get(entity_id)) + assert state.state == "98" monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "battery", -1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-device_0-battery" @@ -321,7 +324,8 @@ async def test_block_not_matched_restored_sleeping_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "20.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "20.4" # Make device online monkeypatch.setattr( @@ -331,7 +335,8 @@ async def test_block_not_matched_restored_sleeping_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "20.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "20.4" async def test_block_sensor_without_value( @@ -345,17 +350,94 @@ async def test_block_sensor_without_value( assert hass.states.get(entity_id) is None -async def test_block_sensor_unknown_value( - hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch +@pytest.mark.parametrize( + ("entity", "initial_state", "block_id", "attribute", "value", "final_value"), + [ + ("test_name_battery", "98", DEVICE_BLOCK_ID, "battery", None, STATE_UNKNOWN), + ( + "test_name_operation", + "normal", + SENSOR_BLOCK_ID, + "sensorOp", + None, + STATE_UNKNOWN, + ), + ( + "test_name_operation", + "normal", + SENSOR_BLOCK_ID, + "sensorOp", + "normal", + "normal", + ), + ( + "test_name_self_test", + "pending", + SENSOR_BLOCK_ID, + "selfTest", + "completed", + "completed", + ), + ( + "test_name_gas_detected", + "mild", + SENSOR_BLOCK_ID, + "gas", + "heavy", + "heavy", + ), + ], +) +async def test_block_sensor_values( + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity: str, + initial_state: str, + block_id: int, + attribute: str, + value: str | None, + final_value: str, ) -> None: """Test block sensor unknown value.""" - entity_id = f"{SENSOR_DOMAIN}.test_name_battery" + entity_id = f"{SENSOR_DOMAIN}.{entity}" await init_integration(hass, 1) - monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "battery", None) + assert hass.states.get(entity_id).state == initial_state + + monkeypatch.setattr(mock_block_device.blocks[block_id], attribute, value) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == final_value + + +@pytest.mark.parametrize( + ("lamp_life_seconds", "percentage"), + [ + (0 * 3600, "100.0"), # 0 hours, 100% remaining + (16 * 3600, "99.8222222222222"), + (4500 * 3600, "50.0"), # 4500 hours, 50% remaining + (9000 * 3600, "0.0"), # 9000 hours, 0% remaining + (10000 * 3600, "0.0"), # > 9000 hours, 0% remaining + ], +) +async def test_block_shelly_air_lamp_life( + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, + lamp_life_seconds: int, + percentage: float, +) -> None: + """Test block Shelly Air lamp life percentage sensor.""" + entity_id = f"{SENSOR_DOMAIN}.{'test_name_channel_1_lamp_life'}" + monkeypatch.setattr( + mock_block_device.blocks[RELAY_BLOCK_ID], "totalWorkTime", lamp_life_seconds + ) + await init_integration(hass, 1) + + assert (state := hass.states.get(entity_id)) + assert state.state == percentage async def test_rpc_sensor( @@ -365,17 +447,20 @@ async def test_rpc_sensor( entity_id = f"{SENSOR_DOMAIN}.test_cover_0_power" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "85.3" + assert (state := hass.states.get(entity_id)) + assert state.state == "85.3" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "apower", "88.2") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "88.2" + assert (state := hass.states.get(entity_id)) + assert state.state == "88.2" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "apower", None) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -389,7 +474,8 @@ async def test_rpc_rssi_sensor_removal( entry = await init_integration(hass, 2) # WiFi1 enabled, do not remove sensor - assert get_entity_state(hass, entity_id) == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" # WiFi1 & WiFi2 disabled - remove sensor monkeypatch.setitem(mock_rpc_device.config["wifi"]["sta"], "enable", False) @@ -401,7 +487,9 @@ async def test_rpc_rssi_sensor_removal( monkeypatch.setitem(mock_rpc_device.config["wifi"]["sta1"], "enable", True) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == "-63" + + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" async def test_rpc_illuminance_sensor( @@ -411,10 +499,10 @@ async def test_rpc_illuminance_sensor( entity_id = f"{SENSOR_DOMAIN}.test_name_illuminance" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "345" + assert (state := hass.states.get(entity_id)) + assert state.state == "345" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-illuminance:0-illuminance" @@ -428,17 +516,18 @@ async def test_rpc_sensor_error( entity_id = f"{SENSOR_DOMAIN}.test_name_voltmeter" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "4.321" + assert (state := hass.states.get(entity_id)) + assert state.state == "4.321" 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 + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-voltmeter:100-voltmeter" @@ -453,15 +542,16 @@ async def test_rpc_polling_sensor( entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "wifi-rssi") await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "wifi", "rssi", "-70") await mock_polling_rpc_update(hass, freezer) - assert hass.states.get(entity_id).state == "-70" + assert (state := hass.states.get(entity_id)) + assert state.state == "-70" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-wifi-rssi" @@ -492,12 +582,14 @@ async def test_rpc_sleeping_sensor( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "temperature:0", "tC", 23.4) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "23.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "23.4" async def test_rpc_restored_sleeping_sensor( @@ -525,7 +617,8 @@ async def test_rpc_restored_sleeping_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "21.0" + assert (state := hass.states.get(entity_id)) + assert state.state == "21.0" # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) @@ -536,7 +629,8 @@ async def test_rpc_restored_sleeping_sensor( mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" async def test_rpc_restored_sleeping_sensor_no_last_state( @@ -562,7 +656,8 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) @@ -573,7 +668,8 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -583,36 +679,32 @@ async def test_rpc_em1_sensors( """Test RPC sensors for EM1 component.""" await init_integration(hass, 2) - state = hass.states.get("sensor.test_name_em0_power") - assert state + assert (state := hass.states.get("sensor.test_name_em0_power")) assert state.state == "85.3" - entry = entity_registry.async_get("sensor.test_name_em0_power") - assert entry + assert (entry := entity_registry.async_get("sensor.test_name_em0_power")) assert entry.unique_id == "123456789ABC-em1:0-power_em1" - state = hass.states.get("sensor.test_name_em1_power") - assert state + assert (state := hass.states.get("sensor.test_name_em1_power")) assert state.state == "123.3" - entry = entity_registry.async_get("sensor.test_name_em1_power") - assert entry + assert (entry := entity_registry.async_get("sensor.test_name_em1_power")) assert entry.unique_id == "123456789ABC-em1:1-power_em1" - state = hass.states.get("sensor.test_name_em0_total_active_energy") - assert state + assert (state := hass.states.get("sensor.test_name_em0_total_active_energy")) assert state.state == "123.4564" - entry = entity_registry.async_get("sensor.test_name_em0_total_active_energy") - assert entry + assert ( + entry := entity_registry.async_get("sensor.test_name_em0_total_active_energy") + ) assert entry.unique_id == "123456789ABC-em1data:0-total_act_energy" - state = hass.states.get("sensor.test_name_em1_total_active_energy") - assert state + assert (state := hass.states.get("sensor.test_name_em1_total_active_energy")) assert state.state == "987.6543" - entry = entity_registry.async_get("sensor.test_name_em1_total_active_energy") - assert entry + assert ( + entry := entity_registry.async_get("sensor.test_name_em1_total_active_energy") + ) assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" @@ -638,7 +730,7 @@ async def test_rpc_sleeping_update_entity_service( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "22.9" await hass.services.async_call( @@ -649,11 +741,10 @@ async def test_rpc_sleeping_update_entity_service( ) # Entity should be available after update_entity service call - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "22.9" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-temperature:0-temperature_0" assert ( @@ -687,7 +778,8 @@ async def test_block_sleeping_update_entity_service( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" await hass.services.async_call( HA_DOMAIN, @@ -697,11 +789,10 @@ async def test_block_sleeping_update_entity_service( ) # Entity should be available after update_entity service call - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "22.1" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-temp" assert ( @@ -734,20 +825,18 @@ async def test_rpc_analog_input_sensors( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog" - assert hass.states.get(entity_id).state == "89" + assert (state := hass.states.get(entity_id)) + assert state.state == "89" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:1-analoginput" entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog_value" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "8.9" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:1-analoginput_xpercent" @@ -782,7 +871,8 @@ async def test_rpc_disabled_xpercent( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog" - assert hass.states.get(entity_id).state == "89" + assert (state := hass.states.get(entity_id)) + assert state.state == "89" entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog_value" assert hass.states.get(entity_id) is None @@ -812,23 +902,20 @@ async def test_rpc_pulse_counter_sensors( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter" - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "56174" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "pulse" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-pulse_counter" entity_id = f"{SENSOR_DOMAIN}.gas_counter_value" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "561.74" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-counter_value" @@ -863,7 +950,8 @@ async def test_rpc_disabled_xtotal_counter( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter" - assert hass.states.get(entity_id).state == "20635" + assert (state := hass.states.get(entity_id)) + assert state.state == "20635" entity_id = f"{SENSOR_DOMAIN}.gas_counter_value" assert hass.states.get(entity_id) is None @@ -893,23 +981,20 @@ async def test_rpc_pulse_counter_frequency_sensors( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency" - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "208.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfFrequency.HERTZ assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-counter_frequency" entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency_value" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "6.11" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-counter_frequency_value" @@ -932,11 +1017,9 @@ async def test_rpc_disabled_xfreq( entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency_value" - state = hass.states.get(entity_id) - assert not state + assert hass.states.get(entity_id) is None - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.parametrize( @@ -968,17 +1051,16 @@ async def test_rpc_device_virtual_text_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "lorem ipsum" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-text:203-text" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "dolor sit amet") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "dolor sit amet" + assert (state := hass.states.get(entity_id)) + assert state.state == "dolor sit amet" async def test_rpc_remove_text_virtual_sensor_when_mode_field( @@ -1011,8 +1093,7 @@ async def test_rpc_remove_text_virtual_sensor_when_mode_field( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_text_virtual_sensor_when_orphaned( @@ -1036,8 +1117,7 @@ async def test_rpc_remove_text_virtual_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.parametrize( @@ -1073,18 +1153,17 @@ async def test_rpc_device_virtual_number_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "34.5" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-number:203-number" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 56.7) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "56.7" + assert (state := hass.states.get(entity_id)) + assert state.state == "56.7" async def test_rpc_remove_number_virtual_sensor_when_mode_field( @@ -1122,8 +1201,7 @@ async def test_rpc_remove_number_virtual_sensor_when_mode_field( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_number_virtual_sensor_when_orphaned( @@ -1147,8 +1225,7 @@ async def test_rpc_remove_number_virtual_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.parametrize( @@ -1188,19 +1265,18 @@ async def test_rpc_device_virtual_enum_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == expected_state assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM assert state.attributes.get(ATTR_OPTIONS) == ["Title 1", "two", "three"] - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-enum:203-enum" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "two") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "two" + assert (state := hass.states.get(entity_id)) + assert state.state == "two" async def test_rpc_remove_enum_virtual_sensor_when_mode_dropdown( @@ -1242,8 +1318,7 @@ async def test_rpc_remove_enum_virtual_sensor_when_mode_dropdown( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_enum_virtual_sensor_when_orphaned( @@ -1267,8 +1342,7 @@ async def test_rpc_remove_enum_virtual_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -1299,61 +1373,51 @@ async def test_rpc_rgbw_sensors( entity_id = f"sensor.test_name_{light_type}_light_0_power" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "12.2" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-power_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_energy" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "0.045141" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-energy_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_current" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "0.23" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricCurrent.AMPERE ) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-current_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_voltage" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "12.4" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT ) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-voltage_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_device_temperature" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "54.3" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-temperature_{light_type}" @@ -1366,15 +1430,17 @@ async def test_rpc_device_sensor_goes_unavailable_on_disconnect( ) -> None: """Test RPC device with sensor goes unavailable on disconnect.""" await init_integration(hass, 2) - temp_sensor_state = hass.states.get("sensor.test_name_temperature") - assert temp_sensor_state is not None - assert temp_sensor_state.state != STATE_UNAVAILABLE + + assert (state := hass.states.get("sensor.test_name_temperature")) + assert state.state != STATE_UNAVAILABLE + monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setattr(mock_rpc_device, "initialized", False) mock_rpc_device.mock_disconnected() await hass.async_block_till_done() - temp_sensor_state = hass.states.get("sensor.test_name_temperature") - assert temp_sensor_state.state == STATE_UNAVAILABLE + + assert (state := hass.states.get("sensor.test_name_temperature")) + assert state.state == STATE_UNAVAILABLE freezer.tick(60) async_fire_time_changed(hass) @@ -1385,8 +1451,9 @@ async def test_rpc_device_sensor_goes_unavailable_on_disconnect( monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_initialized() await hass.async_block_till_done() - temp_sensor_state = hass.states.get("sensor.test_name_temperature") - assert temp_sensor_state.state != STATE_UNAVAILABLE + + assert (state := hass.states.get("sensor.test_name_temperature")) + assert state.state != STATE_UNAVAILABLE async def test_rpc_voltmeter_value( @@ -1399,13 +1466,11 @@ async def test_rpc_voltmeter_value( await init_integration(hass, 2) - state = hass.states.get(entity_id) - + assert (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 := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-voltmeter:100-voltmeter_value" @@ -1450,8 +1515,7 @@ async def test_rpc_device_virtual_number_sensor_with_device_class( await init_integration(hass, 3) - state = hass.states.get("sensor.test_name_current_humidity") - assert state + assert (state := hass.states.get("sensor.test_name_current_humidity")) assert state.state == "34" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 5aae9dfffc9..824742d1798 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock -from aioshelly.const import MODEL_GAS +from aioshelly.const import MODEL_1PM, MODEL_GAS, MODEL_MOTION from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest @@ -28,7 +28,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import get_entity_state, init_integration, register_device, register_entity +from . import init_integration, register_device, register_entity from tests.common import mock_restore_cache @@ -42,22 +42,25 @@ async def test_block_device_services( ) -> None: """Test block device turn on/off services.""" await init_integration(hass, 1) + entity_id = "switch.test_name_channel_1" await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_name_channel_1"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get("switch.test_name_channel_1").state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_name_channel_1"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF @pytest.mark.parametrize("model", MOTION_MODELS) @@ -75,7 +78,8 @@ async def test_block_motion_switch( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # turn off await hass.services.async_call( @@ -88,7 +92,9 @@ async def test_block_motion_switch( mock_block_device.mock_update() mock_block_device.set_shelly_motion_detection.assert_called_once_with(False) - assert get_entity_state(hass, entity_id) == STATE_OFF + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF # turn on mock_block_device.set_shelly_motion_detection.reset_mock() @@ -102,7 +108,9 @@ async def test_block_motion_switch( mock_block_device.mock_update() mock_block_device.set_shelly_motion_detection.assert_called_once_with(True) - assert get_entity_state(hass, entity_id) == STATE_ON + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON @pytest.mark.parametrize("model", MOTION_MODELS) @@ -132,14 +140,16 @@ async def test_block_restored_motion_switch( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON @pytest.mark.parametrize("model", MOTION_MODELS) @@ -167,25 +177,48 @@ async def test_block_restored_motion_switch_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON +@pytest.mark.parametrize( + ("model", "sleep", "entity", "unique_id"), + [ + (MODEL_1PM, 0, "switch.test_name_channel_1", "123456789ABC-relay_0"), + ( + MODEL_MOTION, + 1000, + "switch.test_name_motion_detection", + "123456789ABC-sensor_0-motionActive", + ), + ], +) async def test_block_device_unique_ids( - hass: HomeAssistant, entity_registry: EntityRegistry, mock_block_device: Mock + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_block_device: Mock, + model: str, + sleep: int, + entity: str, + unique_id: str, ) -> None: """Test block device unique_ids.""" - await init_integration(hass, 1) + await init_integration(hass, 1, model=model, sleep_period=sleep) - entry = entity_registry.async_get("switch.test_name_channel_1") - assert entry - assert entry.unique_id == "123456789ABC-relay_0" + if sleep: + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert (entry := entity_registry.async_get(entity)) + assert entry.unique_id == unique_id async def test_block_set_state_connection_error( @@ -199,7 +232,10 @@ async def test_block_set_state_connection_error( ) await init_integration(hass, 1) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while calling action for switch.test_name_channel_1 of Test name", + ): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -248,11 +284,15 @@ async def test_block_device_update( """Test block device update.""" monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "output", False) await init_integration(hass, 1) - assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF + + entity_id = "switch.test_name_channel_1" + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "output", True) mock_block_device.mock_update() - assert hass.states.get("switch.test_name_channel_1").state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON async def test_block_device_no_relay_blocks( @@ -288,35 +328,44 @@ async def test_rpc_device_services( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test RPC device turn on/off services.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) + entity_id = "switch.test_switch_0" await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_switch_0"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get("switch.test_switch_0").state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON monkeypatch.setitem(mock_rpc_device.status["switch:0"], "output", False) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_switch_0"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get("switch.test_switch_0").state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_device_unique_ids( - hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, ) -> None: """Test RPC device unique_ids.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - entry = entity_registry.async_get("switch.test_switch_0") - assert entry + assert (entry := entity_registry.async_get("switch.test_switch_0")) assert entry.unique_id == "123456789ABC-switch:0" @@ -328,21 +377,37 @@ async def test_rpc_device_switch_type_lights_mode( mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) await init_integration(hass, 2) + assert hass.states.get("switch.test_switch_0") is None -@pytest.mark.parametrize("exc", [DeviceConnectionError, RpcCallError(-1, "error")]) +@pytest.mark.parametrize( + ("exc", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for switch.test_switch_0 of Test name", + ), + ( + RpcCallError(-1, "error"), + "RPC call error occurred while calling action for switch.test_switch_0 of Test name", + ), + ], +) async def test_rpc_set_state_errors( hass: HomeAssistant, exc: Exception, + error: str, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC device set state connection/call errors.""" monkeypatch.setattr(mock_rpc_device, "call_rpc", AsyncMock(side_effect=exc)) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match=error): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -360,6 +425,8 @@ async def test_rpc_auth_error( "call_rpc", AsyncMock(side_effect=InvalidAuthError), ) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) entry = await init_integration(hass, 2) assert entry.state is ConfigEntryState.LOADED @@ -409,28 +476,34 @@ async def test_wall_display_relay_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test Wall Display in relay mode.""" - climate_entity_id = "climate.test_name" + climate_entity_id = "climate.test_name_thermostat_0" switch_entity_id = "switch.test_switch_0" + config_entry = await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + assert (state := hass.states.get(climate_entity_id)) + assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 + new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False new_status.pop("thermostat:0") + new_status.pop("cover:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) - await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() # the climate entity should be removed + assert hass.states.get(climate_entity_id) is None assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 0 # the switch entity should be created - state = hass.states.get(switch_entity_id) - assert state + assert (state := hass.states.get(switch_entity_id)) assert state.state == STATE_ON assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - entry = entity_registry.async_get(switch_entity_id) - assert entry + assert (entry := entity_registry.async_get(switch_entity_id)) assert entry.unique_id == "123456789ABC-switch:0" @@ -463,12 +536,10 @@ async def test_rpc_device_virtual_switch( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-boolean:200-boolean" monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", False) @@ -479,7 +550,8 @@ async def test_rpc_device_virtual_switch( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", True) await hass.services.async_call( @@ -489,7 +561,8 @@ async def test_rpc_device_virtual_switch( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON async def test_rpc_device_virtual_binary_sensor( @@ -510,8 +583,7 @@ async def test_rpc_device_virtual_binary_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert not state + assert hass.states.get(entity_id) is None async def test_rpc_remove_virtual_switch_when_mode_label( @@ -544,8 +616,7 @@ async def test_rpc_remove_virtual_switch_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_switch_when_orphaned( @@ -569,8 +640,7 @@ async def test_rpc_remove_virtual_switch_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -600,11 +670,10 @@ async def test_rpc_device_script_switch( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{key}-script" monkeypatch.setitem(mock_rpc_device.status[key], "running", False) @@ -615,8 +684,8 @@ async def test_rpc_device_script_switch( blocking=True, ) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) - assert state + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF monkeypatch.setitem(mock_rpc_device.status[key], "running", True) @@ -627,6 +696,6 @@ async def test_rpc_device_script_switch( blocking=True, ) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) - assert state + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON diff --git a/tests/components/shelly/test_text.py b/tests/components/shelly/test_text.py index 19acb856f35..165272313cb 100644 --- a/tests/components/shelly/test_text.py +++ b/tests/components/shelly/test_text.py @@ -3,15 +3,19 @@ from copy import deepcopy from unittest.mock import Mock +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest +from homeassistant.components.shelly.const import DOMAIN from homeassistant.components.text import ( ATTR_VALUE, DOMAIN as TEXT_PLATFORM, SERVICE_SET_VALUE, ) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -47,17 +51,17 @@ async def test_rpc_device_virtual_text( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "lorem ipsum" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-text:203-text" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "dolor sit amet") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "dolor sit amet" + + assert (state := hass.states.get(entity_id)) + assert state.state == "dolor sit amet" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "sed do eiusmod") await hass.services.async_call( @@ -67,7 +71,10 @@ async def test_rpc_device_virtual_text( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "sed do eiusmod" + mock_rpc_device.text_set.assert_called_once_with(203, "sed do eiusmod") + + assert (state := hass.states.get(entity_id)) + assert state.state == "sed do eiusmod" async def test_rpc_remove_virtual_text_when_mode_label( @@ -100,8 +107,7 @@ async def test_rpc_remove_virtual_text_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_text_when_orphaned( @@ -125,5 +131,97 @@ async def test_rpc_remove_virtual_text_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for text.test_name_text_203 of Test name", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling action for text.test_name_text_203 of Test name", + ), + ], +) +async def test_text_set_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, + error: str, +) -> None: + """Test text setting with exception.""" + config = deepcopy(mock_rpc_device.config) + config["text:203"] = { + "name": None, + "meta": {"ui": {"view": "field"}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["text:203"] = {"value": "lorem ipsum"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + mock_rpc_device.text_set.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + TEXT_PLATFORM, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{TEXT_PLATFORM}.test_name_text_203", + ATTR_VALUE: "new value", + }, + blocking=True, + ) + + +async def test_text_set_reauth_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test text setting with authentication error.""" + config = deepcopy(mock_rpc_device.config) + config["text:203"] = { + "name": None, + "meta": {"ui": {"view": "field"}}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["text:203"] = {"value": "lorem ipsum"} + monkeypatch.setattr(mock_rpc_device, "status", status) + + entry = await init_integration(hass, 3) + + mock_rpc_device.text_set.side_effect = InvalidAuthError + + await hass.services.async_call( + TEXT_PLATFORM, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{TEXT_PLATFORM}.test_name_text_203", + ATTR_VALUE: "new value", + }, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + 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") == entry.entry_id diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 9ea66c1acb7..51016f0cdaa 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -61,14 +61,16 @@ async def test_block_update( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - supported_feat = state.attributes[ATTR_SUPPORTED_FEATURES] - assert supported_feat == UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) await hass.services.async_call( UPDATE_DOMAIN, @@ -78,7 +80,7 @@ async def test_block_update( ) assert mock_block_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" @@ -89,15 +91,14 @@ async def test_block_update( monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2.0.0") await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-fwupdate" @@ -117,7 +118,7 @@ async def test_block_beta_update( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" @@ -129,7 +130,7 @@ async def test_block_beta_update( ) await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" @@ -145,7 +146,7 @@ async def test_block_beta_update( ) assert mock_block_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" @@ -155,15 +156,14 @@ async def test_block_beta_update( monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2.0.0-beta") await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2.0.0-beta" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-fwupdate_beta" @@ -184,14 +184,16 @@ async def test_block_update_connection_error( ) await init_integration(hass, 1) - with pytest.raises(HomeAssistantError) as excinfo: + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while triggering OTA update for Test name", + ): await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.test_name_firmware"}, blocking=True, ) - assert "Error starting OTA update" in str(excinfo.value) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -254,11 +256,12 @@ async def test_block_version_compare( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - state = hass.states.get(entity_id_latest) + assert (state := hass.states.get(entity_id_latest)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == STABLE assert state.attributes[ATTR_LATEST_VERSION] == STABLE - state = hass.states.get(entity_id_beta) + + assert (state := hass.states.get(entity_id_beta)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == STABLE assert state.attributes[ATTR_LATEST_VERSION] == BETA @@ -268,11 +271,12 @@ async def test_block_version_compare( monkeypatch.setitem(mock_block_device.status["update"], "beta_version", BETA) await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id_latest) + assert (state := hass.states.get(entity_id_latest)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == BETA assert state.attributes[ATTR_LATEST_VERSION] == STABLE - state = hass.states.get(entity_id_beta) + + assert (state := hass.states.get(entity_id_beta)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == BETA assert state.attributes[ATTR_LATEST_VERSION] == BETA @@ -296,7 +300,7 @@ async def test_rpc_update( ) await init_integration(hass, 2) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -314,7 +318,7 @@ async def test_rpc_update( assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -337,7 +341,7 @@ async def test_rpc_update( }, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 0 @@ -357,7 +361,7 @@ async def test_rpc_update( }, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 50 @@ -378,15 +382,14 @@ async def test_rpc_update( monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sys-fwupdate" @@ -417,7 +420,7 @@ async def test_rpc_sleeping_update( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -429,7 +432,7 @@ async def test_rpc_sleeping_update( monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -437,8 +440,7 @@ async def test_rpc_sleeping_update( assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None assert state.attributes[ATTR_SUPPORTED_FEATURES] == UpdateEntityFeature(0) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sys-fwupdate" @@ -469,7 +471,7 @@ async def test_rpc_restored_sleeping_update( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -486,7 +488,7 @@ async def test_rpc_restored_sleeping_update( mock_rpc_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -525,7 +527,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN # Make device online @@ -537,7 +539,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( mock_rpc_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -567,7 +569,7 @@ async def test_rpc_beta_update( ) await init_integration(hass, 2) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "1" @@ -584,7 +586,7 @@ async def test_rpc_beta_update( ) await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" @@ -614,7 +616,7 @@ async def test_rpc_beta_update( assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" @@ -637,7 +639,7 @@ async def test_rpc_beta_update( }, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 40 @@ -658,23 +660,28 @@ async def test_rpc_beta_update( monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2b") await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2b" assert state.attributes[ATTR_LATEST_VERSION] == "2b" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sys-fwupdate_beta" @pytest.mark.parametrize( ("exc", "error"), [ - (DeviceConnectionError, "OTA update connection error: DeviceConnectionError()"), - (RpcCallError(-1, "error"), "OTA update request error"), + ( + DeviceConnectionError, + "Device communication error occurred while triggering OTA update for Test name", + ), + ( + RpcCallError(-1, "error"), + "RPC call error occurred while triggering OTA update for Test name", + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -701,14 +708,13 @@ async def test_rpc_update_errors( ) await init_integration(hass, 2) - with pytest.raises(HomeAssistantError) as excinfo: + with pytest.raises(HomeAssistantError, match=error): await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.test_name_firmware"}, blocking=True, ) - assert error in str(excinfo.value) @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index b7c3dff10f6..ae3caa93825 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -21,6 +21,7 @@ from homeassistant.components.shelly.const import ( GEN1_RELEASE_URL, GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, + UPTIME_DEVIATION, ) from homeassistant.components.shelly.utils import ( get_block_channel_name, @@ -188,8 +189,9 @@ async def test_get_device_uptime() -> None: ) == dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) assert get_device_uptime( - 50, dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) - ) == dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:10+00:00")) + 55 - UPTIME_DEVIATION, + dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")), + ) == dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:43:05+00:00")) async def test_get_block_input_triggers( diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py index 9dc8597120a..7bf9e3b5f1a 100644 --- a/tests/components/shelly/test_valve.py +++ b/tests/components/shelly/test_valve.py @@ -25,11 +25,11 @@ async def test_block_device_gas_valve( await init_integration(hass, 1, MODEL_GAS) entity_id = "valve.test_name_valve" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-valve_0-valve" - assert hass.states.get(entity_id).state == ValveState.CLOSED + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.CLOSED await hass.services.async_call( VALVE_DOMAIN, @@ -38,16 +38,14 @@ async def test_block_device_gas_valve( blocking=True, ) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.OPENING monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "opened") mock_block_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.OPEN await hass.services.async_call( @@ -57,14 +55,12 @@ async def test_block_device_gas_valve( blocking=True, ) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.CLOSING monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "closed") mock_block_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.CLOSED diff --git a/tests/components/simplefin/test_binary_sensor.py b/tests/components/simplefin/test_binary_sensor.py index 40c6882153d..58b0319d71f 100644 --- a/tests/components/simplefin/test_binary_sensor.py +++ b/tests/components/simplefin/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/simplefin/test_sensor.py b/tests/components/simplefin/test_sensor.py index 495f249d4e1..b26cd620a69 100644 --- a/tests/components/simplefin/test_sensor.py +++ b/tests/components/simplefin/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from simplefin4py.exceptions import SimpleFinAuthError, SimpleFinPaymentRequiredError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/sleepiq/test_init.py b/tests/components/sleepiq/test_init.py index 216d0e49b08..65e9e63a372 100644 --- a/tests/components/sleepiq/test_init.py +++ b/tests/components/sleepiq/test_init.py @@ -29,7 +29,12 @@ from .conftest import ( setup_platform, ) -from tests.common import MockConfigEntry, async_fire_time_changed, mock_registry +from tests.common import ( + MockConfigEntry, + RegistryEntryWithDefaults, + async_fire_time_changed, + mock_registry, +) ENTITY_IS_IN_BED = f"sensor.sleepnumber_{BED_ID}_{SLEEPER_L_NAME_LOWER}_{IS_IN_BED}" ENTITY_PRESSURE = f"sensor.sleepnumber_{BED_ID}_{SLEEPER_L_NAME_LOWER}_{PRESSURE}" @@ -103,19 +108,19 @@ async def test_unique_id_migration(hass: HomeAssistant, mock_asyncsleepiq) -> No mock_registry( hass, { - ENTITY_IS_IN_BED: er.RegistryEntry( + ENTITY_IS_IN_BED: RegistryEntryWithDefaults( entity_id=ENTITY_IS_IN_BED, unique_id=f"{BED_ID}_{SLEEPER_L_NAME}_{IS_IN_BED}", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), - ENTITY_PRESSURE: er.RegistryEntry( + ENTITY_PRESSURE: RegistryEntryWithDefaults( entity_id=ENTITY_PRESSURE, unique_id=f"{BED_ID}_{SLEEPER_L_NAME}_{PRESSURE}", platform=DOMAIN, config_entry_id=mock_entry.entry_id, ), - ENTITY_SLEEP_NUMBER: er.RegistryEntry( + ENTITY_SLEEP_NUMBER: RegistryEntryWithDefaults( entity_id=ENTITY_SLEEP_NUMBER, unique_id=f"{BED_ID}_{SLEEPER_L_NAME}_{SLEEP_NUMBER}", platform=DOMAIN, diff --git a/tests/components/slide_local/test_button.py b/tests/components/slide_local/test_button.py index c232affbb99..d4bf955ad58 100644 --- a/tests/components/slide_local/test_button.py +++ b/tests/components/slide_local/test_button.py @@ -9,7 +9,7 @@ from goslideapi.goslideapi import ( DigestAuthCalcError, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/slide_local/test_cover.py b/tests/components/slide_local/test_cover.py index e0e4a0741d8..793f9d9513d 100644 --- a/tests/components/slide_local/test_cover.py +++ b/tests/components/slide_local/test_cover.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from goslideapi.goslideapi import ClientConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( ATTR_POSITION, diff --git a/tests/components/slide_local/test_diagnostics.py b/tests/components/slide_local/test_diagnostics.py index 3e11af378c5..cebc4443882 100644 --- a/tests/components/slide_local/test_diagnostics.py +++ b/tests/components/slide_local/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.const import Platform diff --git a/tests/components/slide_local/test_init.py b/tests/components/slide_local/test_init.py index ec9a12f9eeb..27aba115cf8 100644 --- a/tests/components/slide_local/test_init.py +++ b/tests/components/slide_local/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from goslideapi.goslideapi import ClientConnectionError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform diff --git a/tests/components/slide_local/test_switch.py b/tests/components/slide_local/test_switch.py index 9d0d8274aa5..85f90974ce6 100644 --- a/tests/components/slide_local/test_switch.py +++ b/tests/components/slide_local/test_switch.py @@ -9,7 +9,7 @@ from goslideapi.goslideapi import ( DigestAuthCalcError, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 80837c718a9..61d3f81a9fc 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -1,25 +1,57 @@ """Tests for the sma integration.""" -from unittest.mock import patch +from homeassistant.components.sma.const import CONF_GROUP +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry MOCK_DEVICE = { "manufacturer": "SMA", "name": "SMA Device Name", "type": "Sunny Boy 3.6", "serial": 123456789, + "sw_version": "1.0.0", } MOCK_USER_INPUT = { - "host": "1.1.1.1", - "ssl": True, - "verify_ssl": False, - "group": "user", - "password": "password", + CONF_HOST: "1.1.1.1", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", +} + +MOCK_USER_REAUTH = { + CONF_PASSWORD: "new_password", +} + +MOCK_DHCP_DISCOVERY_INPUT = { + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", +} + +MOCK_DHCP_DISCOVERY = { + CONF_HOST: "1.1.1.1", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", + CONF_MAC: "00:15:bb:00:ab:cd", } -def _patch_async_setup_entry(return_value=True): - return patch( - "homeassistant.components.sma.async_setup_entry", - return_value=return_value, - ) +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/sma/conftest.py b/tests/components/sma/conftest.py index dd47a0f1055..5b4ab23213c 100644 --- a/tests/components/sma/conftest.py +++ b/tests/components/sma/conftest.py @@ -1,13 +1,17 @@ """Fixtures for sma tests.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch -from pysma.const import GENERIC_SENSORS +from pysma.const import ( + ENERGY_METER_VIA_INVERTER, + GENERIC_SENSORS, + OPTIMIZERS_VIA_INVERTER, +) from pysma.definitions import sensor_map from pysma.sensor import Sensors import pytest -from homeassistant import config_entries from homeassistant.components.sma.const import DOMAIN from homeassistant.core import HomeAssistant @@ -17,31 +21,56 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" + return MockConfigEntry( domain=DOMAIN, title=MOCK_DEVICE["name"], unique_id=str(MOCK_DEVICE["serial"]), data=MOCK_USER_INPUT, - source=config_entries.SOURCE_IMPORT, minor_version=2, + entry_id="sma_entry_123", ) @pytest.fixture -async def init_integration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> MockConfigEntry: - """Create a fake SMA Config Entry.""" - mock_config_entry.add_to_hass(hass) +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock the setup entry.""" + with patch( + "homeassistant.components.sma.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry - with ( - patch("pysma.SMA.read"), - patch( - "pysma.SMA.get_sensors", return_value=Sensors(sensor_map[GENERIC_SENSORS]) - ), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - return mock_config_entry + +@pytest.fixture +def mock_sma_client() -> Generator[MagicMock]: + """Mock the SMA client.""" + with patch("homeassistant.components.sma.pysma.SMA", autospec=True) as client: + client.return_value.device_info.return_value = MOCK_DEVICE + client.new_session.return_value = True + client.return_value.get_sensors.return_value = Sensors( + sensor_map[GENERIC_SENSORS] + + sensor_map[OPTIMIZERS_VIA_INVERTER] + + sensor_map[ENERGY_METER_VIA_INVERTER] + ) + + default_sensor_values = { + "6100_00499100": 5000, + "6100_00499500": 230, + "6100_00499200": 20, + "6100_00499300": 50, + "6100_00499400": 100, + "6100_00499600": 10, + "6100_00499700": 1000, + } + + def mock_read(sensors): + for sensor in sensors: + if sensor.key in default_sensor_values: + sensor.value = default_sensor_values[sensor.key] + return True + + client.return_value.read.side_effect = mock_read + + yield client diff --git a/tests/components/sma/snapshots/test_diagnostics.ambr b/tests/components/sma/snapshots/test_diagnostics.ambr index 14b0d120190..e8a119291d4 100644 --- a/tests/components/sma/snapshots/test_diagnostics.ambr +++ b/tests/components/sma/snapshots/test_diagnostics.ambr @@ -20,7 +20,7 @@ }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, - 'source': 'import', + 'source': 'user', 'subentries': list([ ]), 'title': 'SMA Device Name', diff --git a/tests/components/sma/snapshots/test_sensor.ambr b/tests/components/sma/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8911df46169 --- /dev/null +++ b/tests/components/sma/snapshots/test_sensor.ambr @@ -0,0 +1,5554 @@ +# serializer version: 1 +# name: test_all_entities[sensor.sma_device_name_battery_capacity_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_capacity_a', + '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': 'SMA Device Name Battery Capacity A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499100_0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Battery Capacity A', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_capacity_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_capacity_b', + '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': 'SMA Device Name Battery Capacity B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499100_1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Battery Capacity B', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_capacity_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_capacity_c', + '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': 'SMA Device Name Battery Capacity C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499100_2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Battery Capacity C', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_capacity_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_capacity_total', + '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': 'SMA Device Name Battery Capacity Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00696E00_0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_capacity_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Battery Capacity Total', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_capacity_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_charge_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Charge A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Charge A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charge_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_charge_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Charge B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499500_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Charge B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charge_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_charge_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Charge C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499500_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Charge C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charge_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_charge_total', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Charge Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00496700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Charge Total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_charging_voltage_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Charging Voltage A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6102_00493500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Charging Voltage A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charging_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_charging_voltage_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Charging Voltage B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6102_00493500_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Charging Voltage B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charging_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_charging_voltage_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Charging Voltage C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6102_00493500_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_charging_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Charging Voltage C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_charging_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_current_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Current A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495D00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Battery Current A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_current_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_current_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Current B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495D00_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Battery Current B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_current_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_current_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Current C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495D00_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_current_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Battery Current C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_current_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_discharge_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Discharge A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499600_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Discharge A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_discharge_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_discharge_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Discharge B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499600_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Discharge B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_discharge_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_discharge_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Discharge C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00499600_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Discharge C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_discharge_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_discharge_total', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Discharge Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00496800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_discharge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Battery Discharge Total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_discharge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_power_charge_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Charge A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499300_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Charge A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_charge_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_power_charge_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Charge B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499300_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Charge B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_charge_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_power_charge_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Charge C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499300_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Charge C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_charge_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_power_charge_total', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Charge Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00496900_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_charge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Charge Total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_charge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Discharge A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499400_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Discharge A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Discharge B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499400_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Discharge B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Discharge C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00499400_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Discharge C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_total', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Power Discharge Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00496A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_power_discharge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Battery Power Discharge Total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_power_discharge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_soc_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery SOC A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00498F00_0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'SMA Device Name Battery SOC A', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_soc_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_soc_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery SOC B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00498F00_1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'SMA Device Name Battery SOC B', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_soc_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_soc_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery SOC C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00498F00_2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'SMA Device Name Battery SOC C', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_soc_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_soc_total', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery SOC Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00295A00_0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_soc_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'SMA Device Name Battery SOC Total', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_soc_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_status_operating_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_status_operating_mode', + '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': 'SMA Device Name Battery Status Operating Mode', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08495E00_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_status_operating_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Battery Status Operating Mode', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_status_operating_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_temp_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Temp A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495B00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SMA Device Name Battery Temp A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_temp_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_temp_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Temp B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495B00_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SMA Device Name Battery Temp B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_temp_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_temp_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Temp C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40495B00_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_temp_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SMA Device Name Battery Temp C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_temp_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_voltage_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Voltage A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00495C00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Voltage A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_voltage_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Voltage B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00495C00_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Voltage B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_battery_voltage_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Battery Voltage C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00495C00_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_battery_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Battery Voltage C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_battery_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_current_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Current L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40465300_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Current L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_current_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Current L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40465400_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Current L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_current_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Current L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40465500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Current L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_current_total', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Current Total', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00664F00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_current_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Current Total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_current_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_daily_yield-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_daily_yield', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Daily Yield', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00262200_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_daily_yield-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Daily Yield', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_daily_yield', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_frequency', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Frequency', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00465700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'SMA Device Name Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_grid_apparent_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Apparent Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'SMA Device Name Grid Apparent Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Apparent Power L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'SMA Device Name Grid Apparent Power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Apparent Power L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666900_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'SMA Device Name Grid Apparent Power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Apparent Power L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_apparent_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'SMA Device Name Grid Apparent Power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_apparent_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sma_device_name_grid_connection_status', + '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': 'SMA Device Name Grid Connection Status', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_0846A700_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Grid Connection Status', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_grid_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40263F00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Grid Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_power_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_grid_power_factor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Power Factor', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00665900_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'SMA Device Name Grid Power Factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_power_factor_excitation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sma_device_name_grid_power_factor_excitation', + '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': 'SMA Device Name Grid Power Factor Excitation', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08465A00_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_power_factor_excitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Grid Power Factor Excitation', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_power_factor_excitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_grid_reactive_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Reactive Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40265F00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'SMA Device Name Grid Reactive Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_reactive_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Reactive Power L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666000_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'SMA Device Name Grid Reactive Power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Reactive Power L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'SMA Device Name Grid Reactive Power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Grid Reactive Power L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40666200_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_reactive_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'SMA Device Name Grid Reactive Power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_reactive_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_relay_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sma_device_name_grid_relay_status', + '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': 'SMA Device Name Grid Relay Status', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08416400_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_grid_relay_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Grid Relay Status', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_grid_relay_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_insulation_residual_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_insulation_residual_current', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Insulation Residual Current', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6102_40254E00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_insulation_residual_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Insulation Residual Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_insulation_residual_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sma_device_name_inverter_condition', + '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': 'SMA Device Name Inverter Condition', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08414C00_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Inverter Condition', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_inverter_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_inverter_power_limit', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Inverter Power Limit', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6800_00832A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Inverter Power Limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_inverter_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_system_init-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sma_device_name_inverter_system_init', + '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': 'SMA Device Name Inverter System Init', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6800_08811F00_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_inverter_system_init-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Inverter System Init', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_inverter_system_init', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Draw L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046EB00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Draw L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Draw L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046EC00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Draw L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Draw L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046ED00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_draw_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Draw L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_draw_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Feed L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046E800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Feed L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Feed L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046E900_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Feed L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Active Power Feed L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046EA00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_active_power_feed_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Active Power Feed L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_active_power_feed_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_current_consumption', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Current Consumption', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00543100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Current Consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_current_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_current_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Current L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40466500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Metering Current L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_current_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Current L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40466600_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Metering Current L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_current_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Current L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40466B00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Metering Current L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_frequency', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Frequency', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00468100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'SMA Device Name Metering Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_power_absorbed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_power_absorbed', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Power Absorbed', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40463700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_power_absorbed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Power Absorbed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_power_absorbed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_power_supplied-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_power_supplied', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Power Supplied', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40463600_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_power_supplied-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Metering Power Supplied', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_power_supplied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_absorbed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_total_absorbed', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Total Absorbed', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00462500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_absorbed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Metering Total Absorbed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_total_absorbed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_total_consumption', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Total Consumption', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00543A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Metering Total Consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_total_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_yield-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_total_yield', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Total Yield', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00462400_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_total_yield-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Metering Total Yield', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_total_yield', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_voltage_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_voltage_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Voltage L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046E500_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Metering Voltage L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_voltage_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_voltage_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Voltage L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046E600_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Metering Voltage L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_voltage_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_metering_voltage_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Metering Voltage L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046E700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_metering_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Metering Voltage L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_metering_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_operating_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sma_device_name_operating_status', + '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': 'SMA Device Name Operating Status', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08412B00_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_operating_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Operating Status', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_operating_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_operating_status_general-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sma_device_name_operating_status_general', + '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': 'SMA Device Name Operating Status General', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08412800_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_operating_status_general-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Operating Status General', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_operating_status_general', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_optimizer_current', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Optimizer Current', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40652900_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Optimizer Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_optimizer_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_optimizer_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Optimizer Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40652A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Optimizer Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_optimizer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_temp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_optimizer_temp', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Optimizer Temp', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40652B00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_temp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SMA Device Name Optimizer Temp', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_optimizer_temp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_optimizer_voltage', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Optimizer Voltage', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40652800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_optimizer_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Optimizer Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_optimizer_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_power_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_power_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Power L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40464000_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_power_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Power L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_power_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_power_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_power_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Power L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40464100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_power_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Power L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_power_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_power_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_power_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Power L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_40464200_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_power_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Power L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_power_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_current_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Current A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40452100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name PV Current A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_current_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_current_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Current B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40452100_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name PV Current B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_current_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_current_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Current C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40452100_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_current_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name PV Current C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_current_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_gen_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_gen_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Gen Meter', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_0046C300_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_gen_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name PV Gen Meter', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_gen_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_isolation_resistance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_isolation_resistance', + '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': 'SMA Device Name PV Isolation Resistance', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6102_00254F00_0', + 'unit_of_measurement': 'kOhms', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_isolation_resistance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name PV Isolation Resistance', + 'state_class': , + 'unit_of_measurement': 'kOhms', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_isolation_resistance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046C200_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name PV Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_power_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Power A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40251E00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name PV Power A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_power_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_power_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Power B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40251E00_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name PV Power B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_power_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_power_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Power C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40251E00_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_power_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name PV Power C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_power_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_voltage_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Voltage A', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40451F00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name PV Voltage A', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_voltage_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Voltage B', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40451F00_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name PV Voltage B', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_pv_voltage_c', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name PV Voltage C', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6380_40451F00_2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_pv_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name PV Voltage C', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_pv_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_secure_power_supply_current', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Secure Power Supply Current', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046C700_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'SMA Device Name Secure Power Supply Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_secure_power_supply_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_secure_power_supply_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Secure Power Supply Power', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046C800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SMA Device Name Secure Power Supply Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_secure_power_supply_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_secure_power_supply_voltage', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Secure Power Supply Voltage', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_0046C600_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_secure_power_supply_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Secure Power Supply Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_secure_power_supply_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sma_device_name_status', + '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': 'SMA Device Name Status', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6180_08214800_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sma_device_name_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SMA Device Name Status', + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_total_yield-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_total_yield', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Total Yield', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6400_00260100_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_total_yield-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SMA Device Name Total Yield', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_total_yield', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_voltage_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_voltage_l1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Voltage L1', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00464800_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Voltage L1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_voltage_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_voltage_l2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Voltage L2', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00464900_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Voltage L2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.sma_device_name_voltage_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sma_device_name_voltage_l3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SMA Device Name Voltage L3', + 'platform': 'sma', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789-6100_00464A00_0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sma_device_name_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SMA Device Name Voltage L3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sma_device_name_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 93ac1783e09..c8939ef2d64 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -1,22 +1,46 @@ """Test the sma config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pysma.exceptions import ( SmaAuthenticationException, SmaConnectionException, SmaReadException, ) +import pytest from homeassistant.components.sma.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.service_info.dhcp import DhcpServiceInfo -from . import MOCK_DEVICE, MOCK_USER_INPUT, _patch_async_setup_entry +from . import ( + MOCK_DEVICE, + MOCK_DHCP_DISCOVERY, + MOCK_DHCP_DISCOVERY_INPUT, + MOCK_USER_INPUT, + MOCK_USER_REAUTH, +) + +from tests.conftest import MockConfigEntry + +DHCP_DISCOVERY = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456", + macaddress="0015BB00abcd", +) + +DHCP_DISCOVERY_DUPLICATE = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456789", + macaddress="0015BB00abcd", +) -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_sma_client: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -25,16 +49,11 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch("pysma.SMA.new_session", return_value=True), - patch("pysma.SMA.device_info", return_value=MOCK_DEVICE), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_USER_INPUT["host"] @@ -43,15 +62,28 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SmaConnectionException, "cannot_connect"), + (SmaAuthenticationException, "invalid_auth"), + (SmaReadException, "cannot_retrieve_device_info"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_setup_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - with ( - patch("pysma.SMA.new_session", side_effect=SmaConnectionException), - _patch_async_setup_entry() as mock_setup_entry, + with patch( + "homeassistant.components.sma.pysma.SMA.new_session", side_effect=exception ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -59,90 +91,193 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - assert len(mock_setup_entry.mock_calls) == 0 + assert result["errors"] == {"base": error} -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", side_effect=SmaAuthenticationException), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_cannot_retrieve_device_info(hass: HomeAssistant) -> None: - """Test we handle cannot retrieve device info error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", return_value=True), - patch("pysma.SMA.read", side_effect=SmaReadException), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_retrieve_device_info"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_unexpected_exception(hass: HomeAssistant) -> None: - """Test we handle unexpected exception.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", side_effect=Exception), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_already_configured(hass: HomeAssistant, mock_config_entry) -> None: +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_sma_client: AsyncMock +) -> None: """Test starting a flow by user when already configured.""" - mock_config_entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, unique_id="123456789") + 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" - with ( - patch("pysma.SMA.new_session", return_value=True), - patch("pysma.SMA.device_info", return_value=MOCK_DEVICE), - patch("pysma.SMA.close_session", return_value=True), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, + ) + 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_dhcp_discovery( + hass: HomeAssistant, mock_setup_entry: MockConfigEntry, mock_sma_client: 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"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DHCP_DISCOVERY["host"] + assert result["data"] == MOCK_DHCP_DISCOVERY + assert result["result"].unique_id == DHCP_DISCOVERY.hostname.replace("SMA", "") + + +async def test_dhcp_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by dhcp when already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY_DUPLICATE + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SmaConnectionException, "cannot_connect"), + (SmaAuthenticationException, "invalid_auth"), + (SmaReadException, "cannot_retrieve_device_info"), + (Exception, "unknown"), + ], +) +async def test_dhcp_exceptions( + hass: HomeAssistant, + mock_setup_entry: MockConfigEntry, + mock_sma_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle cannot connect error in DHCP flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + with patch("homeassistant.components.sma.pysma.SMA") as mock_sma: + mock_sma_instance = mock_sma.return_value + mock_sma_instance.new_session = AsyncMock(side_effect=exception) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + with patch("homeassistant.components.sma.pysma.SMA") as mock_sma: + mock_sma_instance = mock_sma.return_value + mock_sma_instance.new_session = AsyncMock(return_value=True) + mock_sma_instance.device_info = AsyncMock(return_value=MOCK_DEVICE) + mock_sma_instance.close_session = AsyncMock(return_value=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DHCP_DISCOVERY["host"] + assert result["data"] == MOCK_DHCP_DISCOVERY + assert result["result"].unique_id == DHCP_DISCOVERY.hostname.replace("SMA", "") + + +async def test_full_flow_reauth( + hass: HomeAssistant, mock_setup_entry: MockConfigEntry, mock_sma_client: AsyncMock +) -> None: + """Test the full flow of the config flow.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, unique_id="123456789") + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # There is no user input + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_REAUTH, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SmaConnectionException, "cannot_connect"), + (SmaAuthenticationException, "invalid_auth"), + (SmaReadException, "cannot_retrieve_device_info"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test we handle errors during reauth flow properly.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, unique_id="123456789") + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + + with patch("homeassistant.components.sma.pysma.SMA") as mock_sma: + mock_sma_instance = mock_sma.return_value + mock_sma_instance.new_session = AsyncMock(side_effect=exception) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_REAUTH, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + assert result["step_id"] == "reauth_confirm" + + mock_sma_instance.new_session = AsyncMock(return_value=True) + mock_sma_instance.device_info = AsyncMock(return_value=MOCK_DEVICE) + mock_sma_instance.close_session = AsyncMock(return_value=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_REAUTH, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/sma/test_diagnostics.py b/tests/components/sma/test_diagnostics.py index 6c1fe0dc5cb..fa65ca049be 100644 --- a/tests/components/sma/test_diagnostics.py +++ b/tests/components/sma/test_diagnostics.py @@ -1,6 +1,6 @@ """Test the SMA diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/sma/test_init.py b/tests/components/sma/test_init.py index 0cc82f49a41..57c3cab33e7 100644 --- a/tests/components/sma/test_init.py +++ b/tests/components/sma/test_init.py @@ -1,27 +1,32 @@ """Test the sma init file.""" +from collections.abc import AsyncGenerator + from homeassistant.components.sma.const import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.core import HomeAssistant -from . import MOCK_DEVICE, MOCK_USER_INPUT, _patch_async_setup_entry +from . import MOCK_DEVICE, MOCK_USER_INPUT from tests.common import MockConfigEntry -async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: +async def test_migrate_entry_minor_version_1_2( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_sma_client: AsyncGenerator, +) -> None: """Test migrating a 1.1 config entry to 1.2.""" - with _patch_async_setup_entry(): - entry = MockConfigEntry( - domain=DOMAIN, - title=MOCK_DEVICE["name"], - unique_id=MOCK_DEVICE["serial"], # Not converted to str - data=MOCK_USER_INPUT, - source=SOURCE_IMPORT, - minor_version=1, - ) - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - assert entry.version == 1 - assert entry.minor_version == 2 - assert entry.unique_id == str(MOCK_DEVICE["serial"]) + entry = MockConfigEntry( + domain=DOMAIN, + title=MOCK_DEVICE["name"], + unique_id=MOCK_DEVICE["serial"], # Not converted to str + data=MOCK_USER_INPUT, + source=SOURCE_IMPORT, + minor_version=1, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == str(MOCK_DEVICE["serial"]) diff --git a/tests/components/sma/test_sensor.py b/tests/components/sma/test_sensor.py index de7e1167f1f..8199e8fc163 100644 --- a/tests/components/sma/test_sensor.py +++ b/tests/components/sma/test_sensor.py @@ -1,31 +1,34 @@ -"""Test the sma sensor platform.""" +"""Test the SMA sensor platform.""" -from pysma.const import ( - ENERGY_METER_VIA_INVERTER, - GENERIC_SENSORS, - OPTIMIZERS_VIA_INVERTER, -) -from pysma.definitions import sensor_map +from collections.abc import Generator +from unittest.mock import patch -from homeassistant.components.sma.sensor import SENSOR_ENTITIES -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower +import pytest +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 setup_integration + +from tests.common import MockConfigEntry, snapshot_platform -async def test_sensors(hass: HomeAssistant, init_integration) -> None: - """Test states of the sensors.""" - state = hass.states.get("sensor.sma_device_grid_power") - assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - - -async def test_sensor_entities(hass: HomeAssistant, init_integration) -> None: - """Test SENSOR_ENTITIES contains a SensorEntityDescription for each pysma sensor.""" - pysma_sensor_definitions = ( - sensor_map[GENERIC_SENSORS] - + sensor_map[OPTIMIZERS_VIA_INVERTER] - + sensor_map[ENERGY_METER_VIA_INVERTER] - ) - - for sensor in pysma_sensor_definitions: - assert sensor.name in SENSOR_ENTITIES +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_sma_client: Generator, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.sma.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/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 94a2e7512f2..3395f7f4673 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -3,8 +3,9 @@ from typing import Any from unittest.mock import AsyncMock -from pysmartthings.models import Attribute, Capability, DeviceEvent -from syrupy import SnapshotAssertion +from pysmartthings import Attribute, Capability, DeviceEvent, DeviceHealthEvent +from pysmartthings.models import HealthStatus +from syrupy.assertion import SnapshotAssertion from homeassistant.components.smartthings.const import MAIN from homeassistant.const import Platform @@ -55,21 +56,37 @@ async def trigger_update( attribute: Attribute, value: str | float | dict[str, Any] | list[Any] | None, data: dict[str, Any] | None = None, + component: str = MAIN, ) -> None: """Trigger an update.""" + event = DeviceEvent( + "abc", + "abc", + "abc", + device_id, + component, + capability, + attribute, + value, + data, + ) + for call in mock.add_unspecified_device_event_listener.call_args_list: + call[0][0](event) for call in mock.add_device_event_listener.call_args_list: + if call[0][0] == device_id: + call[0][3](event) + for call in mock.add_device_capability_event_listener.call_args_list: if call[0][0] == device_id and call[0][2] == capability: - call[0][3]( - DeviceEvent( - "abc", - "abc", - "abc", - device_id, - MAIN, - capability, - attribute, - value, - data, - ) - ) + call[0][3](event) + await hass.async_block_till_done() + + +async def trigger_health_update( + hass: HomeAssistant, mock: AsyncMock, device_id: str, status: HealthStatus +) -> None: + """Trigger a health update.""" + event = DeviceHealthEvent("abc", "abc", status) + for call in mock.add_device_availability_event_listener.call_args_list: + if call[0][0] == device_id: + call[0][1](event) await hass.async_block_till_done() diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index b7d0cb61607..ab6c6031d5e 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -4,12 +4,16 @@ from collections.abc import Generator import time from unittest.mock import AsyncMock, patch -from pysmartthings.models import ( +from pysmartthings import ( + DeviceHealth, DeviceResponse, DeviceStatus, LocationResponse, + RoomResponse, SceneResponse, + Subscription, ) +from pysmartthings.models import HealthStatus import pytest from homeassistant.components.application_credentials import ( @@ -78,12 +82,24 @@ def mock_smartthings() -> Generator[AsyncMock]: client.get_locations.return_value = LocationResponse.from_json( load_fixture("locations.json", DOMAIN) ).items + client.get_rooms.return_value = RoomResponse.from_json( + load_fixture("rooms.json", DOMAIN) + ).items + client.create_subscription.return_value = Subscription.from_json( + load_fixture("subscription.json", DOMAIN) + ) + client.get_device_health.return_value = DeviceHealth.from_json( + load_fixture("device_health.json", DOMAIN) + ) yield client @pytest.fixture( params=[ + "da_ac_airsensor_01001", "da_ac_rac_000001", + "da_ac_rac_000003", + "da_ac_rac_100001", "da_ac_rac_01001", "multipurpose_sensor", "contact_sensor", @@ -95,13 +111,29 @@ def mock_smartthings() -> Generator[AsyncMock]: "ge_in_wall_smart_dimmer", "centralite", "da_ref_normal_000001", + "da_ref_normal_01011", + "da_ref_normal_01001", "vd_network_audio_002s", + "vd_network_audio_003s", + "vd_sensor_light_2023", "iphone", + "da_sac_ehs_000001_sub", + "da_sac_ehs_000001_sub_1", + "da_sac_ehs_000002_sub", + "da_ac_ehs_01001", "da_wm_dw_000001", "da_wm_wd_000001", + "da_wm_wd_000001_1", + "da_wm_wm_01011", + "da_wm_wm_100001", "da_wm_wm_000001", + "da_wm_wm_000001_1", + "da_wm_sc_000001", "da_rvc_normal_000001", "da_ks_microwave_0101x", + "da_ks_cooktop_31001", + "da_ks_range_0101x", + "da_ks_oven_01061", "hue_color_temperature_bulb", "hue_rgbw_color_bulb", "c2c_shade", @@ -113,7 +145,22 @@ def mock_smartthings() -> Generator[AsyncMock]: "sensibo_airconditioner_1", "ecobee_sensor", "ecobee_thermostat", + "ecobee_thermostat_offline", "fake_fan", + "generic_fan_3_speed", + "heatit_ztrm3_thermostat", + "heatit_zpushwall", + "generic_ef00_v1", + "bosch_radiator_thermostat_ii", + "im_speaker_ai_0001", + "im_smarttag2_ble_uwb", + "abl_light_b_001", + "tplink_p110", + "ikea_kadrilj", + "aux_ac", + "hw_q80r_soundbar", + "gas_meter", + "lumi", ] ) def device_fixture( @@ -135,6 +182,13 @@ def devices(mock_smartthings: AsyncMock, device_fixture: str) -> Generator[Async return mock_smartthings +@pytest.fixture +def unavailable_device(devices: AsyncMock) -> AsyncMock: + """Mock an unavailable device.""" + devices.get_device_health.return_value.state = HealthStatus.OFFLINE + return devices + + @pytest.fixture def mock_config_entry(expires_at: int) -> MockConfigEntry: """Mock a config entry.""" @@ -156,6 +210,7 @@ def mock_config_entry(expires_at: int) -> MockConfigEntry: CONF_INSTALLED_APP_ID: "123", }, version=3, + minor_version=2, ) diff --git a/tests/components/smartthings/fixtures/device_health.json b/tests/components/smartthings/fixtures/device_health.json new file mode 100644 index 00000000000..7ae42d6206e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_health.json @@ -0,0 +1,5 @@ +{ + "deviceId": "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", + "state": "ONLINE", + "lastUpdatedDate": "2025-04-28T11:43:31.600Z" +} diff --git a/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json b/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json new file mode 100644 index 00000000000..6dba85d7dc4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json @@ -0,0 +1,27 @@ +{ + "components": { + "main": { + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": null + }, + "colorTemperature": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/aux_ac.json b/tests/components/smartthings/fixtures/device_status/aux_ac.json new file mode 100644 index 00000000000..a3ebede7a10 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/aux_ac.json @@ -0,0 +1,69 @@ +{ + "components": { + "main": { + "partyvoice23922.vtempset": { + "vtemp": { + "value": 20, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.161Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "auto", + "timestamp": "2024-12-05T20:03:32.930Z" + }, + "supportedAcFanModes": { + "value": null + }, + "availableAcFanModes": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 20.0, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.066Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2024-12-05T20:03:32.845Z" + } + }, + "fanSpeed": { + "fanSpeed": { + "value": 0, + "timestamp": "2024-12-05T20:03:33.334Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 20.0, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.243Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2024-12-05T20:03:32.662Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json b/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json new file mode 100644 index 00000000000..6248eb05e93 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json @@ -0,0 +1,89 @@ +{ + "components": { + "main": { + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 23.9, + "unit": "C", + "timestamp": "2025-03-07T19:55:13.328Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 22.0, + "unit": "C", + "timestamp": "2025-03-05T03:05:26.510Z" + }, + "heatingSetpointRange": { + "value": { + "minimum": 5.0, + "maximum": 40.0, + "step": 0.1 + }, + "unit": "C", + "timestamp": "2025-03-05T03:05:26.510Z" + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "heat"] + }, + "timestamp": "2025-03-05T03:05:26.489Z" + }, + "supportedThermostatModes": { + "value": ["off", "heat"], + "timestamp": "2025-03-05T03:05:26.509Z" + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 94, + "unit": "%", + "timestamp": "2025-03-07T20:47:27.362Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "2.00.09 (20009)", + "timestamp": "2024-11-29T19:55:02.005Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2024-11-29T19:55:02.009Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2024-11-29T19:55:02.004Z" + }, + "currentVersion": { + "value": "2.00.09 (20009)", + "timestamp": "2024-11-29T19:55:02.037Z" + }, + "lastUpdateTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/button.json b/tests/components/smartthings/fixtures/device_status/button.json new file mode 100644 index 00000000000..93e320bcb7b --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/button.json @@ -0,0 +1,21 @@ +{ + "components": { + "main": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-03-07T12:20:43.363Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2025-03-07T12:20:43.363Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "pushed_2x"], + "timestamp": "2025-03-07T12:20:43.363Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json index 371a779f83c..a3d2cabe837 100644 --- a/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json +++ b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json @@ -58,6 +58,15 @@ "timestamp": "2025-02-08T21:56:09.761Z" } }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "start": "2025-02-09T15:45:29Z", + "end": "2025-02-09T16:15:33Z" + }, + "timestamp": "2025-02-09T16:15:33.639Z" + } + }, "battery": { "quantity": { "value": null diff --git a/tests/components/smartthings/fixtures/device_status/contact_sensor.json b/tests/components/smartthings/fixtures/device_status/contact_sensor.json index fa158d41b39..ca8c2628c99 100644 --- a/tests/components/smartthings/fixtures/device_status/contact_sensor.json +++ b/tests/components/smartthings/fixtures/device_status/contact_sensor.json @@ -36,7 +36,7 @@ "value": null }, "availableVersion": { - "value": "00000103", + "value": "00000104", "timestamp": "2025-02-09T13:59:19.101Z" }, "lastUpdateStatus": { diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json new file mode 100644 index 00000000000..903b5163335 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json @@ -0,0 +1,362 @@ +{ + "components": { + "main": { + "samsungce.rechargeableBattery": { + "chargingStatus": { + "value": "charging", + "timestamp": "2025-02-18T05:20:27.966Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-22T04:50:19.633Z" + }, + "resolution": { + "value": 1, + "timestamp": "2024-12-20T14:38:31.662Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 54, + "unit": "%", + "timestamp": "2025-03-21T07:26:16.872Z" + } + }, + "refresh": {}, + "carbonDioxideHealthConcern": { + "carbonDioxideHealthConcern": { + "value": "moderate", + "timestamp": "2025-03-21T13:40:56.560Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.sensors"], + "if": ["oic.if.baseline", "oic.if.s"], + "x.com.samsung.da.cleanLevel": "2", + "x.com.samsung.da.refresh": "Off", + "x.com.samsung.da.lastSensingTime": "1740829045", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": ["2"] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": ["2"] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": ["29", "1"] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": ["7", "1"] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": ["6", "1"] + }, + { + "x.com.samsung.da.id": "5", + "x.com.samsung.da.description": "Sensor for CO2", + "x.com.samsung.da.type": "CO2", + "x.com.samsung.da.value": ["2527", "3"] + } + ] + } + }, + "data": { + "href": "/sensors/vs/0" + }, + "timestamp": "2025-03-01T11:37:26.334Z" + } + }, + "carbonDioxideMeasurement": { + "carbonDioxide": { + "value": 1045, + "unit": "ppm", + "timestamp": "2025-03-21T15:05:44.312Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "ASM-KR-TP1-22-ACMB1M", + "timestamp": "2025-03-20T23:08:07.388Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": 2, + "unit": "CAQI", + "timestamp": "2025-03-21T15:06:39.609Z" + } + }, + "fineDustHealthConcern": { + "fineDustHealthConcern": { + "value": "good", + "timestamp": "2025-03-21T10:25:04.548Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ASM-KR-TP1-22-ACMB1M_16240426", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "di": { + "value": "a3a970ea-e09c-9c04-161b-94c934e21666", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "n": { + "value": "Samsung AirMonitor", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnmo": { + "value": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "vid": { + "value": "DA-AC-AIRSENSOR-01001", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnos": { + "value": "TizenRT 4.0", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "pi": { + "value": "a3a970ea-e09c-9c04-161b-94c934e21666", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-08-19T07:28:01.277Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": 1, + "timestamp": "2025-03-21T13:29:15.650Z" + } + }, + "veryFineDustHealthConcern": { + "veryFineDustHealthConcern": { + "value": "good", + "timestamp": "2025-03-21T02:56:21.007Z" + } + }, + "samsungce.doNotDisturb": { + "settable": { + "value": true, + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "dayOfWeek": { + "value": null + }, + "repeatMode": { + "value": null + }, + "startTime": { + "value": "14:00:00Z", + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "endTime": { + "value": "22:00:00Z", + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "activated": { + "value": false, + "timestamp": "2024-12-20T14:38:31.895Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2025-03-01T11:37:26.334Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2023-12-09T04:05:59.505Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "EXCHUODPSCTZY", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "AM0", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2024-12-20T14:38:31.716Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 23.0, + "unit": "C", + "timestamp": "2025-03-21T04:40:33.951Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": 31, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:39.609Z" + }, + "fineDustLevel": { + "value": 7, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:28.515Z" + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": 6, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:28.515Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2024-12-20T14:38:31.769Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-03-20T22:02:48.215Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2024-12-20T14:38:31.769Z" + } + }, + "dustHealthConcern": { + "dustHealthConcern": { + "value": "moderate", + "timestamp": "2025-03-21T15:06:39.609Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_ehs_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_ehs_01001.json new file mode 100644 index 00000000000..2214ed3c3e6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_ehs_01001.json @@ -0,0 +1,744 @@ +{ + "components": { + "main": { + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 38, + "unit": "C", + "timestamp": "2025-05-14T19:29:59.586Z" + }, + "maximumSetpoint": { + "value": 69, + "unit": "C", + "timestamp": "2025-05-14T19:29:59.586Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["eco", "std", "power", "force"], + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "airConditionerMode": { + "value": "std", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_DA_AC_EHS_01001_0000", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AEH-WW-TP1-22-AE6000_17240903", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "di": { + "value": "4165c51e-bf6b-c5b6-fd53-127d6248754b", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "n": { + "value": "Samsung EHS", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnmo": { + "value": "TP1X_DA_AC_EHS_01001_0000|10250141|60070110001711034A00010000002000", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "vid": { + "value": "DA-AC-EHS-01001", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "pi": { + "value": "4165c51e-bf6b-c5b6-fd53-127d6248754b", + "timestamp": "2025-04-13T13:07:05.925Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-04-13T13:07:05.925Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.alwaysOnSensing", + "samsungce.sacDisplayCondition" + ], + "timestamp": "2025-04-13T13:07:09.182Z" + } + }, + "samsungce.sensingOnSuspendMode": { + "sensingOnSuspendMode": { + "value": "unavailable", + "timestamp": "2025-04-13T13:00:53.287Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25010101, + "timestamp": "2025-04-13T13:00:53.287Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "AE0", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 57, + "unit": "C", + "timestamp": "2025-05-14T19:51:09.752Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 56, + "unit": "C", + "timestamp": "2025-05-14T19:29:59.586Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "duration": 0, + "override": false + }, + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 4053792, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-05-13T23:00:23Z", + "end": "2025-05-14T13:26:17Z" + }, + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.ehsCycleData": { + "outdoor": { + "value": [ + { + "timestamp": "2025-05-13T22:45:05Z", + "data": "0000000050624249410207D002580000FFFF00350032A05A00000000" + }, + { + "timestamp": "2025-05-13T22:50:07Z", + "data": "001400145B683E414102015A02120002FFFF002F007CA06200000000" + }, + { + "timestamp": "2025-05-13T22:55:06Z", + "data": "00000000586643494102000000000000FFFF003D003BA06200000000" + } + ], + "unit": "C", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "indoor": { + "value": [ + { + "timestamp": "2025-05-13T22:45:05Z", + "data": "4B0559590505014264000000000000000001000000021F1C0000007505054B" + }, + { + "timestamp": "2025-05-13T22:50:07Z", + "data": "5C055D5E0505013A64000000000000000001000000021F210000007505054B" + }, + { + "timestamp": "2025-05-13T22:55:06Z", + "data": "49055D5D0505000000000000000000000000000000021F260000007505054B" + } + ], + "unit": "C", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "custom.outingMode": { + "outingMode": { + "value": "off", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.individualControlLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.alwaysOnSensing": { + "origins": { + "value": null + }, + "alwaysOn": { + "value": null + } + }, + "refresh": {}, + "samsungce.ehsFsvSettings": { + "fsvSettings": { + "value": [ + { + "id": "1031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 37, + "maxValue": 75, + "value": 65, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 15, + "maxValue": 37, + "value": 26, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1051", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 50, + "maxValue": 70, + "value": 69, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1052", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 30, + "maxValue": 40, + "value": 38, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2011", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -20, + "maxValue": 5, + "value": -5, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 10, + "maxValue": 20, + "value": 10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2021", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 70, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2022", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 45, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 70, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2091", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2092", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2093", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 1, + "maxValue": 4, + "value": 2, + "isValid": true + }, + { + "id": "3011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 1, + "isValid": true + }, + { + "id": "3071", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 1, + "isValid": true + }, + { + "id": "4012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -15, + "maxValue": 20, + "value": 0, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4021", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 0, + "isValid": true + }, + { + "id": "4042", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 5, + "maxValue": 15, + "value": 10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4061", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + } + ], + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "02504A240903", + "description": "Version" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "02501A24062401,FFFFFFFFFFFFFF", + "description": "Version" + }, + { + "id": "2", + "swType": "Outdoor", + "versionNumber": "02572A23081000,02549A10000800", + "description": "Version" + } + ], + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "custom.energyType": { + "energyType": { + "value": null + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-04-13T13:00:53.287Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-04-13T13:00:53.287Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-04-13T13:00:53.287Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": { + "newVersion": "00000000", + "currentVersion": "00000000", + "moduleType": "mainController" + }, + "timestamp": "2025-05-11T20:13:06.918Z" + }, + "otnDUID": { + "value": "7XCFUCFWT6VB4", + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-04-13T13:00:53.287Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-05-11T20:13:06.918Z" + }, + "progress": { + "value": null + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-05-14T19:29:59.586Z" + } + } + }, + "INDOOR1": { + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 18.5, + "unit": "C", + "timestamp": "2025-05-14T19:54:55.948Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 26, + "unit": "C", + "timestamp": "2025-05-14T19:54:55.948Z" + }, + "maximumSetpoint": { + "value": 65, + "unit": "C", + "timestamp": "2025-05-14T19:54:55.948Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "heat", "auto"], + "timestamp": "2025-05-14T13:26:17.184Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-05-14T13:26:17.184Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-05-14T19:54:55.948Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 35, + "unit": "C", + "timestamp": "2025-05-14T19:54:55.948Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-05-14T13:26:17.184Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json index c80fcf9c298..f6cdd661a99 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json @@ -473,7 +473,7 @@ "timestamp": "2024-09-10T10:26:28.781Z" }, "acOptionalMode": { - "value": "off", + "value": "windFree", "timestamp": "2025-02-09T09:14:39.642Z" } }, diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json new file mode 100644 index 00000000000..98434aa2c5a --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json @@ -0,0 +1,585 @@ +{ + "components": { + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 48, + "unit": "%", + "timestamp": "2025-03-27T05:12:16.158Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null + }, + "airConditionerOdorControllerState": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-03-13T09:29:37.008Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2024-06-21T13:45:16.785Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto"], + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-03-13T09:29:36.789Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": "off", + "timestamp": "2025-02-08T08:54:15.661Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "ARTIK051_PRAC_20K", + "timestamp": "2025-03-27T05:12:15.284Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": [ + "off", + "sleep", + "quiet", + "smart", + "speed", + "windFree", + "windFreeSleep" + ], + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-03-26T12:20:41.095Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-27T05:41:42.291Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 0, + "timestamp": "2025-02-08T08:54:15.789Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ARTIK051_PRAC_20K_11230313", + "timestamp": "2024-06-21T13:58:04.085Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2024-06-21T13:51:35.294Z" + }, + "di": { + "value": "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-06-21T13:51:35.980Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-06-21T13:58:04.698Z" + }, + "n": { + "value": "Samsung Room A/C", + "timestamp": "2024-06-21T13:58:04.085Z" + }, + "mnmo": { + "value": "ARTIK051_PRAC_20K|10256941|60010534001411014600003200800000", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "vid": { + "value": "DA-AC-RAC-000003", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-06-21T13:51:35.294Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-06-21T13:51:35.294Z" + }, + "pi": { + "value": "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "timestamp": "2024-06-21T13:45:16.329Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-06-21T13:45:16.329Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "low", + "timestamp": "2025-03-26T12:20:41.393Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "samsungce.dustFilterAlarm": { + "alarmThreshold": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "supportedAlarmThresholds": { + "value": [180, 300, 500, 700], + "unit": "Hour", + "timestamp": "2025-02-08T08:54:15.473Z" + } + }, + "custom.electricHepaFilter": { + "electricHepaFilterCapacity": { + "value": null + }, + "electricHepaFilterUsageStep": { + "value": null + }, + "electricHepaFilterLastResetDate": { + "value": null + }, + "electricHepaFilterStatus": { + "value": null + }, + "electricHepaFilterUsage": { + "value": null + }, + "electricHepaFilterResetType": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.spiMode", + "custom.deodorFilter", + "custom.electricHepaFilter", + "custom.periodicSensing", + "custom.doNotDisturbMode", + "custom.airConditionerOdorController", + "samsungce.individualControlLock" + ], + "timestamp": "2025-02-08T08:54:15.355Z" + } + }, + "custom.ocfResourceVersion": { + "ocfResourceUpdatedTime": { + "value": null + }, + "ocfResourceVersion": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24040101, + "timestamp": "2024-06-21T13:45:16.348Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": ["fixed", "all", "vertical", "horizontal"], + "timestamp": "2025-02-08T08:54:15.797Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-25T15:40:11.773Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 26, + "unit": "C", + "timestamp": "2025-03-26T14:19:08.047Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null + }, + "fineDustLevel": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "disabled", + "timestamp": "2025-02-08T08:54:15.726Z" + }, + "reportStateRealtime": { + "value": { + "state": "enabled", + "duration": 10, + "unit": "minute" + }, + "timestamp": "2025-03-24T08:28:07.030Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-02-08T08:54:15.726Z" + } + }, + "custom.periodicSensing": { + "automaticExecutionSetting": { + "value": null + }, + "automaticExecutionMode": { + "value": null + }, + "supportedAutomaticExecutionSetting": { + "value": null + }, + "supportedAutomaticExecutionMode": { + "value": null + }, + "periodicSensing": { + "value": null + }, + "periodicSensingInterval": { + "value": null + }, + "lastSensingTime": { + "value": null + }, + "lastSensingLevel": { + "value": null + }, + "periodicSensingStatus": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 24, + "unit": "C", + "timestamp": "2025-03-26T12:20:41.346Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "duration": 0, + "override": false + }, + "timestamp": "2025-03-24T04:56:36.855Z" + } + }, + "audioVolume": { + "volume": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-08T08:54:15.789Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 602171, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 602171, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-03-27T05:29:22Z", + "end": "2025-03-27T05:40:02Z" + }, + "timestamp": "2025-03-27T05:40:02.686Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "off", + "timestamp": "2025-03-15T05:30:11.075Z" + } + }, + "samsungce.individualControlLock": { + "lockState": { + "value": null + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2024-06-21T13:45:16.348Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2025-02-08T08:54:15.048Z" + }, + "status": { + "value": null + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": 1, + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "dustFilterUsage": { + "value": 69, + "timestamp": "2025-03-26T10:57:41.097Z" + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": "normal", + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "dustFilterCapacity": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-08T08:54:15.473Z" + }, + "dustFilterResetType": { + "value": ["replaceable", "washable"], + "timestamp": "2025-02-08T08:54:15.473Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2024-06-21T13:45:16.785Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2024-06-21T13:58:08.419Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2024-06-21T13:51:39.304Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-02-08T08:54:16.767Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2025-03-24T04:56:36.855Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-08T08:54:16.685Z" + }, + "otnDUID": { + "value": "MTCPH4AI4MTYO", + "timestamp": "2025-02-08T08:54:15.626Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T08:54:15.626Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T08:54:15.626Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null + } + }, + "custom.veryFineDustFilter": { + "veryFineDustFilterStatus": { + "value": null + }, + "veryFineDustFilterResetType": { + "value": null + }, + "veryFineDustFilterUsage": { + "value": null + }, + "veryFineDustFilterLastResetDate": { + "value": null + }, + "veryFineDustFilterUsageStep": { + "value": null + }, + "veryFineDustFilterCapacity": { + "value": null + } + }, + "custom.doNotDisturbMode": { + "doNotDisturb": { + "value": null + }, + "startTime": { + "value": null + }, + "endTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json index 257d553cb9f..3982e1174f4 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json @@ -32,7 +32,7 @@ "timestamp": "2025-02-09T14:35:56.800Z" }, "supportedAcModes": { - "value": ["auto", "cool", "dry", "wind", "heat"], + "value": ["auto", "cool", "dry", "fan", "heat", "dryClean"], "timestamp": "2025-02-09T15:42:13.444Z" }, "airConditionerMode": { diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json new file mode 100644 index 00000000000..5c062d904bb --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json @@ -0,0 +1,171 @@ +{ + "components": { + "main": { + "refresh": {}, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "timestamp": "2024-11-25T22:17:38.251Z" + }, + "maximumSetpoint": { + "value": 30, + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto"], + "timestamp": "2025-03-02T10:16:19.519Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-03-02T10:16:19.519Z" + } + }, + "execute": { + "data": { + "value": null + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-02T06:54:52.852Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": null + }, + "mnhw": { + "value": null + }, + "di": { + "value": "F8042E25-0E53-0000-0000-000000000000", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnsl": { + "value": null + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "n": { + "value": "Room A/C", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnmo": { + "value": "TP6X_RAC_15K", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "vid": { + "value": "DA-AC-RAC-100001", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnml": { + "value": null + }, + "mnpv": { + "value": null + }, + "mnos": { + "value": null + }, + "pi": { + "value": "shp", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-28T21:15:28.920Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "auto", + "timestamp": "2025-02-28T21:15:28.941Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-28T21:15:28.941Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "samsungce.driverState": { + "driverState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["odorSensor"], + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22090101, + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 27, + "unit": "C", + "timestamp": "2025-03-02T08:28:39.409Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": 46, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-06T16:01:49.656000+00:00" + }, + "fineDustLevel": { + "value": 10, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-06T16:01:49.656000+00:00" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 18, + "unit": "C", + "timestamp": "2025-03-02T06:54:23.887Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json b/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json new file mode 100644 index 00000000000..ab836de52ad --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json @@ -0,0 +1,508 @@ +{ + "components": { + "burner-02": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 5, + "timestamp": "2025-03-26T05:57:23.203Z" + }, + "heatingMode": { + "value": "boost", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.550Z" + } + } + }, + "burner-01": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.518Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-26T05:57:23.203Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.518Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.518Z" + } + } + }, + "main": { + "custom.disabledComponents": { + "disabledComponents": { + "value": ["burner-05", "burner-6"], + "timestamp": "2025-03-25T18:18:28.464Z" + } + }, + "custom.userNotification": { + "message": { + "value": null + } + }, + "samsungce.remoteManagementData": { + "reportRawData": { + "value": "AgUBASCgAwAACaEDAAAM4AQAAAAA4QHwAw==", + "timestamp": "2025-03-26T07:27:58.282Z" + }, + "version": { + "value": "CT-31.0001", + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "5828", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "modelName": { + "value": "NZ64B5046GK", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "serialNumber": { + "value": "B8C878DX900290H", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "serialNumberExtra": { + "value": "N/A", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "modelClassificationCode": { + "value": "50000204001611000E00000000000000", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "description": { + "value": "N/A", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP2X_DA-KS-COOKTOP-31001", + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-26T07:27:58.478Z" + } + }, + "samsungce.errorAndAlarmState": { + "events": { + "value": [], + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "samsungce.cooktopFlexZone": { + "flexZones": { + "value": [], + "timestamp": "2025-03-26T05:57:23.671Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "Wifi", + "swType": "Wifi-Application", + "versionNumber": "80001A220811", + "description": "Aug 11 2022 08:38:36, Wifi:ws029_030, STDK : 1.7.4)" + }, + { + "id": "Micom", + "swType": "Micom Software", + "versionNumber": "240617", + "description": "Description for this micom version" + } + ], + "timestamp": "2025-03-25T18:18:28.482Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": null + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": null + } + }, + "custom.cooktopOperatingState": { + "supportedCooktopOperatingState": { + "value": ["ready", "run", "paused"], + "timestamp": "2025-03-26T07:26:39.690Z" + }, + "cooktopOperatingState": { + "value": "ready", + "timestamp": "2025-03-26T07:27:58.652Z" + } + }, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "EU", + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "modelCode": { + "value": "OZ8500B/EU2", + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "cooktop", + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "JHCB2ZD4E2KRY", + "timestamp": "2025-03-25T18:18:28.482Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.kidsLockControl": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-03-25T18:18:28.464Z" + } + } + }, + "burner-06": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.591Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.591Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "status": { + "value": null + } + } + }, + "hood": { + "samsungce.connectionState": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-03-25T18:18:28.650Z" + } + }, + "samsungce.hoodFanSpeed": { + "settableMaxFanSpeed": { + "value": 5, + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "hoodFanSpeed": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "supportedHoodFanSpeed": { + "value": [1, 2, 3, 4, 5], + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "settableMinFanSpeed": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.650Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.646Z" + }, + "status": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": null + }, + "supportedBrightnessLevel": { + "value": ["off", "mid"], + "timestamp": "2025-03-25T18:18:28.650Z" + } + } + }, + "burner-05": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.586Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.586Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "status": { + "value": null + } + } + }, + "burner-04": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.578Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-25T18:49:25.153Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.578Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.578Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.578Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.578Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.578Z" + } + } + }, + "burner-03": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 2, + "timestamp": "2025-03-26T07:27:58.652Z" + }, + "heatingMode": { + "value": "keepWarm", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.550Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json b/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json new file mode 100644 index 00000000000..b8b403ba908 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json @@ -0,0 +1,566 @@ +{ + "components": { + "main": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 220, + "timestamp": "2025-03-15T12:06:07.818Z" + } + }, + "refresh": {}, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-03-15T09:25:35.157Z" + } + }, + "samsungce.microwavePower": { + "supportedPowerLevels": { + "value": null + }, + "powerLevel": { + "value": "0W", + "timestamp": "2025-03-15T12:06:07.803Z" + } + }, + "samsungce.waterReservoir": { + "slotState": { + "value": null + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": null + }, + "defaultOvenMode": { + "value": "Convection", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "defaultOvenSetpoint": { + "value": null + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_DA-KS-OVEN-01061", + "timestamp": "2025-03-13T20:35:02.073Z" + } + }, + "samsungce.ovenDrainageRequirement": { + "drainageRequirement": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP1X-21-OVEN_40211229", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "di": { + "value": "9447959a-0dfa-6b27-d40d-650da525c53f", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "n": { + "value": "[oven] Samsung", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnmo": { + "value": "TP1X_DA-KS-OVEN-01061|40457041|50030018001611000A00000000000000", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "vid": { + "value": "DA-KS-OVEN-01061", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "pi": { + "value": "9447959a-0dfa-6b27-d40d-650da525c53f", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-08T17:29:14.260Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-15T09:47:55.406Z" + } + }, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "EU", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "modelCode": { + "value": "NQ7000B-/EU7", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "oven", + "timestamp": "2025-01-08T17:29:12.924Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "NoOperation", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "Autocook", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "Convection", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 160, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "FanConventional", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "LargeGrill", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 150, + "max": 230, + "default": 220, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "FanGrill", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "MicroWaveGrill", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 200, + "default": 200, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "300W", + "supportedValues": ["100W", "180W", "300W", "450W", "600W"] + } + } + }, + { + "mode": "MicroWaveConvection", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 200, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "300W", + "supportedValues": ["100W", "180W", "300W", "450W", "600W"] + } + } + }, + { + "mode": "AirFryer", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 150, + "max": 230, + "default": 220, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "MicroWave", + "supportedOperations": ["set"], + "supportedOptions": { + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "800W", + "supportedValues": [ + "100W", + "180W", + "300W", + "450W", + "600W", + "700W", + "800W" + ] + } + } + }, + { + "mode": "Deodorization", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "operationTime": { + "min": "00:00:10", + "max": "00:15:00", + "default": "00:05:00", + "resolution": "00:00:10" + } + } + }, + { + "mode": "KeepWarm", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 60, + "max": 100, + "default": 60, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "SteamClean", + "supportedOperations": ["set"], + "supportedOptions": {} + } + ] + }, + "timestamp": "2025-01-08T17:29:14.757Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.waterReservoir", + "samsungce.ovenDrainageRequirement" + ], + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.definedRecipe": { + "definedRecipe": { + "value": { + "cavityId": "0", + "recipeType": "0", + "categoryId": 0, + "itemId": 0, + "servingSize": 0, + "browingLevel": 0, + "option": 0 + }, + "timestamp": "2025-03-15T12:06:07.803Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2025-01-08T17:29:12.924Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "43CB2ZD4VUEGW", + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 30, + "unit": "C", + "timestamp": "2025-03-15T12:06:32.918Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-03-15T12:06:09.550Z", + "timestamp": "2025-03-15T12:06:09.554Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "progress": { + "value": 0, + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "ovenJobState": { + "value": "preheat", + "timestamp": "2025-03-15T12:06:07.803Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2025-03-15T12:06:07.866Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": ["Others", "Bake", "Broil", "ConvectionBroil", "warming"], + "timestamp": "2025-01-08T17:29:14.757Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-03-15T12:06:09.550Z", + "timestamp": "2025-03-15T12:06:09.554Z" + }, + "machineState": { + "value": "running", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "preheat", + "timestamp": "2025-03-15T12:06:07.803Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2025-03-15T12:06:07.866Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "NoOperation", + "Autocook", + "Convection", + "FanConventional", + "LargeGrill", + "FanGrill", + "MicroWaveGrill", + "MicroWaveConvection", + "AirFryer", + "MicroWave", + "Deodorization", + "KeepWarm", + "SteamClean" + ], + "timestamp": "2025-01-08T17:29:14.757Z" + }, + "ovenMode": { + "value": "Convection", + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "high", + "timestamp": "2025-03-15T12:06:07.956Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "high"], + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-13T20:35:02.170Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json b/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json new file mode 100644 index 00000000000..09c5a13613a --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json @@ -0,0 +1,688 @@ +{ + "components": { + "cavity-01": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 0, + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-09-07T22:35:34.197Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 175, + "unit": "F", + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2024-05-14T19:00:04.579Z", + "timestamp": "2024-05-14T19:00:04.584Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "progress": { + "value": 1, + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2022-02-21T22:37:05.415Z" + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": null + }, + "defaultOvenMode": { + "value": "ConvectionBake", + "timestamp": "2022-02-21T22:37:06.983Z" + }, + "defaultOvenSetpoint": { + "value": 350, + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "custom.ovenCavityStatus": { + "ovenCavityStatus": { + "value": "off", + "timestamp": "2025-03-12T20:38:01.259Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": ["Others"], + "timestamp": "2022-02-21T22:37:08.409Z" + }, + "ovenMode": { + "value": "Others", + "timestamp": "2022-02-21T22:37:06.983Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2024-05-14T19:00:04.579Z", + "timestamp": "2024-05-14T19:00:04.584Z" + }, + "machineState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2022-02-21T22:37:05.415Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": ["SelfClean", "SteamClean", "NoOperation"], + "timestamp": "2022-02-21T22:37:08.409Z" + }, + "ovenMode": { + "value": "NoOperation", + "timestamp": "2022-02-21T22:37:06.983Z" + } + } + }, + "main": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 425, + "timestamp": "2025-03-13T21:42:23.492Z" + } + }, + "samsungce.meatProbe": { + "temperatureSetpoint": { + "value": 0, + "unit": "F", + "timestamp": "2022-02-21T22:37:02.619Z" + }, + "temperature": { + "value": 0, + "unit": "F", + "timestamp": "2022-02-21T22:37:02.619Z" + }, + "status": { + "value": "disconnected", + "timestamp": "2022-02-21T22:37:02.679Z" + } + }, + "refresh": {}, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-03-12T20:38:01.255Z" + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": 3600, + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "defaultOvenMode": { + "value": "ConvectionBake", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "defaultOvenSetpoint": { + "value": 350, + "timestamp": "2025-03-13T21:23:27.596Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E03151101020000000000000", + "x.com.samsung.da.description": "TP1X_DA-KS-OVEN-01011", + "x.com.samsung.da.serialNum": "0J4D7DARB03393K", + "x.com.samsung.da.otnDUID": "ZPCNQWBWXI47Q", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A221005", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "20121600,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-11-28T22:49:09.333Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_DA-KS-RANGE-0101X", + "timestamp": "2025-03-12T20:40:29.034Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP1-20-OVEN-3-CR_40240205", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "di": { + "value": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2022-12-19T22:33:09.710Z" + }, + "n": { + "value": "Samsung Range", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnmo": { + "value": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E031511010200000000000000", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "vid": { + "value": "DA-KS-RANGE-0101X", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "pi": { + "value": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2022-02-21T22:37:02.282Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-13T21:42:23.615Z" + } + }, + "samsungce.customRecipe": {}, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "US", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "modelCode": { + "value": "NE6516A-/AA0", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "range", + "timestamp": "2022-02-21T22:37:02.487Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "Bake", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 175, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 350, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "Broil", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 61441, + "max": 61442, + "default": 61441, + "supportedValues": [61441, 61442] + }, + "F": { + "min": 61441, + "max": 61442, + "default": 61441, + "supportedValues": [61441, 61442] + } + } + } + }, + { + "mode": "ConvectionBake", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 160, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 325, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "ConvectionRoast", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 160, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 325, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "KeepWarm", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 80, + "default": 80, + "supportedValues": [80] + }, + "F": { + "min": 175, + "max": 175, + "default": 175, + "supportedValues": [175] + } + } + } + }, + { + "mode": "BreadProof", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 35, + "max": 35, + "default": 35, + "supportedValues": [35] + }, + "F": { + "min": 95, + "max": 95, + "default": 95, + "supportedValues": [95] + } + } + } + }, + { + "mode": "AirFryer", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 175, + "max": 260, + "default": 220, + "resolution": 0 + }, + "F": { + "min": 350, + "max": 500, + "default": 425, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "Dehydrate", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 105, + "default": 65, + "resolution": 0 + }, + "F": { + "min": 100, + "max": 225, + "default": 150, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "SelfClean", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "SteamClean", + "supportedOperations": [], + "supportedOptions": {} + } + ] + }, + "timestamp": "2024-05-14T19:00:30.062Z" + } + }, + "custom.cooktopOperatingState": { + "supportedCooktopOperatingState": { + "value": ["run", "ready"], + "timestamp": "2022-02-21T22:37:05.293Z" + }, + "cooktopOperatingState": { + "value": "ready", + "timestamp": "2025-03-12T20:38:01.402Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2022-11-01T21:37:51.304Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "ZPCNQWBWXI47Q", + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 425, + "unit": "F", + "timestamp": "2025-03-13T21:46:35.545Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-03-14T03:23:28.048Z", + "timestamp": "2025-03-13T22:09:29.052Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "progress": { + "value": 13, + "timestamp": "2025-03-13T22:06:35.591Z" + }, + "ovenJobState": { + "value": "cooking", + "timestamp": "2025-03-13T21:46:34.327Z" + }, + "operationTime": { + "value": "06:00:00", + "timestamp": "2025-03-13T21:23:24.771Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "warming", + "Others", + "Dehydrate" + ], + "timestamp": "2025-03-12T20:38:01.259Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-03-14T03:23:28.048Z", + "timestamp": "2025-03-13T22:09:29.052Z" + }, + "machineState": { + "value": "running", + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "progress": { + "value": 13, + "unit": "%", + "timestamp": "2025-03-13T22:06:35.591Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "cooking", + "timestamp": "2025-03-13T21:46:34.327Z" + }, + "operationTime": { + "value": 21600, + "timestamp": "2025-03-13T21:23:24.771Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "KeepWarm", + "BreadProof", + "AirFryer", + "Dehydrate", + "SelfClean", + "SteamClean" + ], + "timestamp": "2025-03-12T20:38:01.259Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "extraHigh", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "extraHigh"], + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-12T20:38:01.400Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01001.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01001.json new file mode 100644 index 00000000000..aa73068f8bd --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01001.json @@ -0,0 +1,929 @@ +{ + "components": { + "pantry-01": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.meatAging", "samsungce.foodDefrost"], + "timestamp": "2022-02-07T10:54:05.580Z" + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:54:05.580Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-07T12:01:52.528Z" + } + } + }, + "scale-10": { + "samsungce.connectionState": { + "connectionState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:54:05.580Z" + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + }, + "samsungce.scaleSettings": { + "enabled": { + "value": null + } + }, + "samsungce.weightMeasurementCalibration": {} + }, + "scale-11": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:54:05.580Z" + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + } + }, + "camera-01": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["switch"], + "timestamp": "2023-12-17T11:19:18.845Z" + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "cooler": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T00:23:41.655Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-06T12:35:50.411Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2024-06-17T06:16:33.918Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 37, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 34, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + }, + "maximumSetpoint": { + "value": 44, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 34, + "maximum": 44, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + }, + "coolingSetpoint": { + "value": 37, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + } + }, + "freezer": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T00:00:44.267Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-06T12:35:50.411Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode"], + "timestamp": "2024-11-06T09:00:29.743Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 0, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -8, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + }, + "maximumSetpoint": { + "value": 5, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": [], + "timestamp": "2025-02-01T19:39:00.448Z" + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": -8, + "maximum": 5, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + }, + "coolingSetpoint": { + "value": 0, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + } + }, + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T00:23:41.655Z" + } + }, + "samsungce.viewInside": { + "supportedFocusAreas": { + "value": ["mainShelves"], + "timestamp": "2025-02-01T19:39:00.946Z" + }, + "contents": { + "value": [ + { + "fileId": "d3e1f875-f8b3-a031-737b-366eaa227773", + "mimeType": "image/jpeg", + "expiredTime": "2025-01-20T16:17:04Z", + "focusArea": "mainShelves" + }, + { + "fileId": "9fccb6b4-e71f-6c7f-9935-f6082bb6ccfe", + "mimeType": "image/jpeg", + "expiredTime": "2025-01-20T16:17:04Z", + "focusArea": "mainShelves" + }, + { + "fileId": "20b57a4d-b7fc-17fc-3a03-0fb84fb4efab", + "mimeType": "image/jpeg", + "expiredTime": "2025-01-20T16:17:05Z", + "focusArea": "mainShelves" + } + ], + "timestamp": "2025-01-20T16:07:05.423Z" + }, + "lastUpdatedTime": { + "value": "2025-02-07T12:01:52Z", + "timestamp": "2025-02-07T12:01:52.585Z" + } + }, + "samsungce.fridgeFoodList": { + "outOfSyncChanges": { + "value": null + }, + "refreshResult": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 19, + "timestamp": "2024-11-06T09:00:29.743Z" + }, + "binaryId": { + "value": "24K_REF_LCD_FHUB9.0", + "timestamp": "2025-02-07T12:01:53.067Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-02-01T19:39:01.848Z" + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": "2024-11-08T11:56:59Z", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnfv": { + "value": "20240616.213423", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "di": { + "value": "7d3feb98-8a36-4351-c362-5e21ad3a78dd", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "n": { + "value": "Family Hub", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnmo": { + "value": "24K_REF_LCD_FHUB9.0|00113141|0002034e051324200103000000000000", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "vid": { + "value": "DA-REF-NORMAL-01001", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnpv": { + "value": "7.0", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "pi": { + "value": "7d3feb98-8a36-4351-c362-5e21ad3a78dd", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-02T12:37:43.756Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "thermostatCoolingSetpoint", + "temperatureMeasurement", + "custom.fridgeMode", + "custom.deviceReportStateConfiguration", + "samsungce.fridgeFoodList", + "samsungce.runestoneHomeContext", + "demandResponseLoadControl", + "samsungce.fridgeVacationMode", + "samsungce.sabbathMode" + ], + "timestamp": "2025-02-08T23:57:45.739Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24090102, + "timestamp": "2024-11-06T09:00:29.743Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "500", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-01T19:39:00.523Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-02-01T19:39:00.345Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-02-01T19:39:00.345Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker-02", + "icemaker-03", + "pantry-01", + "camera-01", + "scale-10", + "scale-11" + ], + "timestamp": "2025-02-07T12:01:52.638Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "duration": 0, + "override": false + }, + "timestamp": "2025-02-01T19:38:59.899Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": null + }, + "status": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 4381422, + "deltaEnergy": 27, + "power": 144, + "powerEnergy": 27.01890500307083, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-09T00:13:39Z", + "end": "2025-02-09T00:25:23Z" + }, + "timestamp": "2025-02-09T00:25:23.843Z" + } + }, + "refresh": {}, + "samsungce.runestoneHomeContext": { + "supportedContexts": { + "value": [ + { + "context": "HOME_IN", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "ASLEEP", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "AWAKE", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "COOKING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_COOKING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "EATING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_EATING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "DOING_LAUNDRY", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_DOING_LAUNDRY", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "CLEANING_HOUSE", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_CLEANING_HOUSE", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "MUSIC_LISTENING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_MUSIC_LISTENING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "AIR_CONDITIONING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_AIR_CONDITIONING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "WASHING_DISHES", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_WASHING_DISHES", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "CARING_CLOTHING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_CARING_CLOTHING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "WATCHING_TV", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_WATCHING_TV", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "BEFORE_BEDTIME", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "BEFORE_COOKING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "BEFORE_HOME_OUT", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "ORDERING_DELIVERY_FOOD", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_ORDERING_DELIVERY_FOOD", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "ONLINE_GROCERY_SHOPPING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_ONLINE_GROCERY_SHOPPING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + } + ], + "timestamp": "2025-02-01T19:39:02.150Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.fridge"], + "if": ["oic.if.a"], + "x.com.samsung.da.rapidFridge": "Off", + "x.com.samsung.da.rapidFreezing": "Off" + } + }, + "data": { + "href": "/refrigeration/vs/0" + }, + "timestamp": "2024-03-26T09:06:17.169Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-02-01T19:39:01.951Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-01T19:39:01.951Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G", "5G"], + "timestamp": "2025-02-01T19:39:01.951Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK"], + "timestamp": "2025-02-01T19:39:01.951Z" + }, + "protocolType": { + "value": ["helper_hotspot"], + "timestamp": "2025-02-01T19:39:01.951Z" + } + }, + "refrigeration": { + "defrost": { + "value": "off", + "timestamp": "2025-02-01T19:38:59.276Z" + }, + "rapidCooling": { + "value": "off", + "timestamp": "2025-02-01T19:39:00.497Z" + }, + "rapidFreezing": { + "value": "off", + "timestamp": "2025-02-01T19:39:00.497Z" + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2025-02-01T19:39:00.497Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-02-07T10:54:05.580Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-02-07T10:57:35.490Z" + }, + "drMaxDuration": { + "value": 1440, + "unit": "min", + "timestamp": "2022-02-07T11:50:40.228Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2022-02-07T11:50:40.228Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-01T19:39:00.200Z" + }, + "otnDUID": { + "value": "2DCEZFTFQZPMO", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-01T19:39:00.200Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": false, + "timestamp": "2025-02-01T19:39:00.497Z" + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": 1, + "timestamp": "2025-02-01T19:38:59.973Z" + }, + "waterFilterResetType": { + "value": ["replaceable"], + "timestamp": "2025-02-01T19:38:59.973Z" + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": 52, + "timestamp": "2025-02-08T05:06:45.769Z" + }, + "waterFilterStatus": { + "value": "normal", + "timestamp": "2025-02-01T19:38:59.973Z" + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": "CV_FDR_DELI", + "timestamp": "2025-02-01T19:39:00.448Z" + }, + "supportedFridgeModes": { + "value": [ + "CV_FDR_WINE", + "CV_FDR_DELI", + "CV_FDR_BEVERAGE", + "CV_FDR_MEAT" + ], + "timestamp": "2025-02-01T19:39:00.448Z" + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-08T23:22:04.631Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2021-07-27T01:19:43.145Z" + } + } + }, + "icemaker-02": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-07-28T18:47:07.039Z" + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "icemaker-03": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2023-12-15T01:05:09.803Z" + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json new file mode 100644 index 00000000000..350a0ee14bb --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json @@ -0,0 +1,933 @@ +{ + "components": { + "pantry-01": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "pantry-02": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "onedoor": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], + "timestamp": "2024-12-01T18:22:20.155Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "scale-10": { + "samsungce.connectionState": { + "connectionState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + }, + "samsungce.weightMeasurementCalibration": {} + }, + "scale-11": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + } + }, + "cooler": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-03-30T18:36:45.151Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode", "samsungce.temperatureSetting"], + "timestamp": "2024-12-01T18:22:22.081Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 6, + "unit": "C", + "timestamp": "2025-03-30T17:41:42.863Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 1, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "maximumSetpoint": { + "value": 7, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 1, + "maximum": 7, + "step": 1 + }, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "coolingSetpoint": { + "value": 6, + "unit": "C", + "timestamp": "2025-03-30T17:33:48.530Z" + } + } + }, + "freezer": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2024-12-01T18:22:19.331Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "custom.fridgeMode", + "samsungce.temperatureSetting", + "samsungce.freezerConvertMode" + ], + "timestamp": "2024-12-01T18:22:22.081Z" + } + }, + "samsungce.temperatureSetting": { + "supportedDesiredTemperatures": { + "value": null + }, + "desiredTemperature": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": -17, + "unit": "C", + "timestamp": "2025-03-30T17:35:48.599Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -23, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "maximumSetpoint": { + "value": -15, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": -23, + "maximum": -15, + "step": 1 + }, + "unit": "C", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "coolingSetpoint": { + "value": -17, + "unit": "C", + "timestamp": "2025-03-30T17:32:34.710Z" + } + } + }, + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-03-30T18:36:45.151Z" + } + }, + "samsungce.fridgeWelcomeLighting": { + "detectionProximity": { + "value": null + }, + "supportedDetectionProximities": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.viewInside": { + "supportedFocusAreas": { + "value": null + }, + "contents": { + "value": null + }, + "lastUpdatedTime": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_REF_21K", + "timestamp": "2025-03-23T21:53:15.900Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-02-12T21:52:01.494Z" + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "A-RFWW-TP1-22-REV1_20241030", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "di": { + "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "n": { + "value": "Samsung-Refrigerator", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnmo": { + "value": "TP1X_REF_21K|00156941|00050126001611304100000030010000", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "vid": { + "value": "DA-REF-NORMAL-01011", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "pi": { + "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", + "timestamp": "2025-02-12T21:51:58.927Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-12T21:51:58.927Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": "off", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "temperatureMeasurement", + "thermostatCoolingSetpoint", + "custom.fridgeMode", + "custom.deodorFilter", + "custom.waterFilter", + "custom.dustFilter", + "samsungce.viewInside", + "samsungce.fridgeWelcomeLighting", + "samsungce.sabbathMode" + ], + "timestamp": "2025-02-12T21:52:01.494Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24090102, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-02-12T21:52:00.460Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "RB0", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-02-12T21:52:00.460Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-02-12T21:52:00.460Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker", + "icemaker-02", + "icemaker-03", + "pantry-01", + "pantry-02", + "scale-10", + "scale-11", + "cvroom", + "onedoor" + ], + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "duration": 0, + "override": false + }, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": null + }, + "status": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 66571, + "deltaEnergy": 19, + "power": 61, + "powerEnergy": 18.91178222020467, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-03-30T18:21:37Z", + "end": "2025-03-30T18:38:18Z" + }, + "timestamp": "2025-03-30T18:38:18.219Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2024-12-01T18:22:19.331Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2024-12-01T18:22:19.331Z" + }, + "protocolType": { + "value": ["helper_hotspot"], + "timestamp": "2024-12-01T18:22:19.331Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": "passed", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "status": { + "value": "ready", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null + }, + "dustFilterUsage": { + "value": null + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": null + }, + "dustFilterCapacity": { + "value": null + }, + "dustFilterResetType": { + "value": null + } + }, + "refrigeration": { + "defrost": { + "value": null + }, + "rapidCooling": { + "value": "off", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "rapidFreezing": { + "value": "off", + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-03-06T23:10:37.429Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2024-12-01T18:22:20.756Z" + }, + "energySavingLevel": { + "value": 1, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": [1, 2], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "energySavingOperation": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2024-12-01T18:55:10.062Z" + }, + "otnDUID": { + "value": "MTCB2ZD4B6BT4", + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2024-12-01T18:28:40.492Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2024-12-01T18:43:42.645Z" + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": false, + "timestamp": "2024-12-01T18:22:19.337Z" + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": null + }, + "waterFilterResetType": { + "value": null + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": null + }, + "waterFilterStatus": { + "value": null + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "icemaker-02": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "icemaker-03": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json new file mode 100644 index 00000000000..a9a991f488c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json @@ -0,0 +1,753 @@ +{ + "components": { + "main": { + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-05-07T08:18:06.705Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 8901522.0, + "deltaEnergy": 0, + "power": 0.015, + "powerEnergy": 0.01082494583328565, + "persistedEnergy": 8901522.0, + "energySaved": 0, + "start": "2025-05-16T11:18:12Z", + "end": "2025-05-16T12:01:29Z" + }, + "timestamp": "2025-05-16T12:01:29.990Z" + } + }, + "samsungce.ehsCycleData": { + "outdoor": { + "value": [ + { + "timestamp": "2025-05-15T22:50:49Z", + "data": "0000000051FF4348450207D0000000000000" + }, + { + "timestamp": "2025-05-15T22:55:49Z", + "data": "0000000051FF4448450207D0000000000000" + } + ], + "unit": "C", + "timestamp": "2025-05-16T07:00:51.349Z" + }, + "indoor": { + "value": [ + { + "timestamp": "2025-05-15T22:50:49Z", + "data": "47054C0505050000000000000000000000000000000832EB" + }, + { + "timestamp": "2025-05-15T22:55:49Z", + "data": "47054C0505050000000000000000000000000000000832ED" + } + ], + "unit": "C", + "timestamp": "2025-05-16T07:00:51.349Z" + } + }, + "custom.outingMode": { + "outingMode": { + "value": "off", + "timestamp": "2025-05-14T20:05:40.503Z" + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-05-06T10:47:04.400Z" + } + }, + "refresh": {}, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 40, + "unit": "C", + "timestamp": "2025-05-15T02:34:53.575Z" + }, + "maximumSetpoint": { + "value": 55, + "unit": "C", + "timestamp": "2025-05-15T02:34:53.575Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["eco", "std", "force"], + "timestamp": "2025-05-07T08:18:06.705Z" + }, + "airConditionerMode": { + "value": "std", + "timestamp": "2025-05-06T10:47:04.400Z" + } + }, + "samsungce.ehsFsvSettings": { + "fsvSettings": { + "value": [ + { + "id": "1031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 37, + "maxValue": 70, + "value": 70, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 15, + "maxValue": 37, + "value": 25, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1051", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 50, + "maxValue": 70, + "value": 55, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1052", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 30, + "maxValue": 40, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2011", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -20, + "maxValue": 5, + "value": -3, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 10, + "maxValue": 20, + "value": 15, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2021", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 50, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2022", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 32, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 50, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 38, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2091", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2092", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2093", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 1, + "maxValue": 4, + "value": 4, + "isValid": true + }, + { + "id": "3011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 2, + "isValid": true + }, + { + "id": "3071", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -15, + "maxValue": 20, + "value": 0, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4021", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 0, + "isValid": true + }, + { + "id": "4042", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 5, + "maxValue": 15, + "value": 10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4061", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + } + ], + "timestamp": "2025-05-09T02:16:02.595Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.a"], + "x.com.samsung.da.modelNum": "SAC_EHS_MONO|220614|61007400001600000400000000000000", + "x.com.samsung.da.description": "EHS", + "x.com.samsung.da.serialNum": "", + "x.com.samsung.da.versionId": "Samsung Electronics", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.number": "DB91-02102A 2023-01-11", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version" + }, + { + "x.com.samsung.da.number": "DB91-02100A 2020-07-10", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version" + }, + { + "x.com.samsung.da.number": "DB91-02103B 2022-06-14", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "" + }, + { + "x.com.samsung.da.number": "DB91-02450A 2022-07-06", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "EHS MONO LOWTEMP" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-02T14:32:28.435Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "SAC_EHS_MONO", + "timestamp": "2025-05-16T08:18:08.723Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-05-06T12:30:02.413Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-05-16T12:01:29.844Z" + } + }, + "ocf": { + "st": { + "value": "2025-05-14T18:33:05Z", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "mnfv": { + "value": "20250317.1", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "di": { + "value": "1f98ebd0-ac48-d802-7f62-000001200100", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "n": { + "value": "Eco Heating System", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "mnmo": { + "value": "SAC_EHS_MONO|220614|61007400001600000400000000000000", + "timestamp": "2025-05-16T08:18:08.723Z" + }, + "vid": { + "value": "DA-SAC-EHS-000001-SUB", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "mnpv": { + "value": "4.0", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "pi": { + "value": "1f98ebd0-ac48-d802-7f62-000001200100", + "timestamp": "2025-05-16T08:18:07.449Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-05-16T08:18:07.449Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-05-07T08:18:06.705Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-03-22T08:18:04.803Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2023-08-02T14:36:25.480Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-05-16T07:00:23.689Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "samsungce.ehsCycleData", + "samsungce.systemAirConditionerReservation", + "demandResponseLoadControl" + ], + "timestamp": "2025-05-16T08:18:08.723Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25010101, + "timestamp": "2025-03-31T04:43:32.104Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": null + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-22T07:41:31.476Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-05-07T08:18:06.705Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 40.8, + "unit": "C", + "timestamp": "2025-05-16T12:12:59.016Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2025-05-08T03:03:38.391Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-05-14T20:25:52.192Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-05-08T03:03:38.391Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-05-06T10:47:04.249Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 48, + "unit": "C", + "timestamp": "2025-05-15T02:34:53.575Z" + } + }, + "samsungce.ehsBoosterHeater": { + "status": { + "value": "off", + "timestamp": "2025-05-15T02:34:53.185Z" + } + }, + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "samsungce.sensingOnSuspendMode": { + "sensingOnSuspendMode": { + "value": null + } + }, + "samsungce.ehsDiverterValve": { + "position": { + "value": "room", + "timestamp": "2025-05-16T02:17:59.268Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "DB91-02102A 2025-03-17", + "description": "Version" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "DB91-02100A 2020-07-10", + "description": "Version" + }, + { + "id": "2", + "swType": "Firmware", + "versionNumber": "DB91-02103B 2022-06-14", + "description": "" + }, + { + "id": "3", + "swType": "Firmware", + "versionNumber": "DB91-02450A 2022-07-06", + "description": "EHS MONO LOWTEMP" + } + ], + "timestamp": "2025-05-07T08:18:06.705Z" + } + } + }, + "INDOOR": { + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-05-07T08:18:06.705Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-05-14T20:05:45.533Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.systemAirConditionerReservation"], + "timestamp": "2025-03-31T04:03:40.028Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 23.1, + "unit": "C", + "timestamp": "2025-05-16T12:29:12.736Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-05-15T02:34:53.531Z" + }, + "maximumSetpoint": { + "value": 65, + "unit": "C", + "timestamp": "2025-05-06T10:23:24.471Z" + } + }, + "samsungce.ehsDefrostMode": { + "status": { + "value": "off", + "timestamp": "2025-05-07T08:18:06.705Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["auto", "cool", "heat"], + "timestamp": "2025-05-07T08:18:06.705Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-05-07T08:18:06.705Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-05-06T10:23:24.471Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-05-14T20:05:40.638Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-05-07T08:18:06.705Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-05-16T08:18:08.723Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub_1.json b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub_1.json new file mode 100644 index 00000000000..a6ced0e16e5 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub_1.json @@ -0,0 +1,704 @@ +{ + "components": { + "main": { + "samsungce.ehsBoosterHeater": { + "status": { + "value": "off", + "timestamp": "2025-05-14T22:47:01.955Z" + } + }, + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null + }, + "airConditionerMode": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 23, + "timestamp": "2025-04-14T15:04:59.182Z" + }, + "binaryId": { + "value": "SAC_EHS_MONO", + "timestamp": "2025-05-15T18:27:08.954Z" + } + }, + "switch": { + "switch": { + "value": null + } + }, + "ocf": { + "st": { + "value": "2025-05-14T23:22:43Z", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnfv": { + "value": "20250317.1", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "di": { + "value": "6a7d5349-0a66-0277-058d-000001200101", + "timestamp": "2025-05-14T22:47:01.717Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-05-14T22:47:01.717Z" + }, + "n": { + "value": "Heat Pump", + "timestamp": "2025-05-14T22:47:01.717Z" + }, + "mnmo": { + "value": "SAC_EHS_MONO|231215|61007400001700000400000000000000", + "timestamp": "2025-05-15T18:27:08.954Z" + }, + "vid": { + "value": "DA-SAC-EHS-000001-SUB", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnpv": { + "value": "4.0", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "pi": { + "value": "6a7d5349-0a66-0277-058d-000001200101", + "timestamp": "2025-05-14T22:47:01.715Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-05-14T22:47:01.717Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "samsungce.systemAirConditionerReservation", + "demandResponseLoadControl" + ], + "timestamp": "2025-05-12T23:01:07.651Z" + } + }, + "samsungce.sensingOnSuspendMode": { + "sensingOnSuspendMode": { + "value": "available", + "timestamp": "2025-04-14T15:04:59.182Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25010101, + "timestamp": "2025-04-14T15:04:59.182Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "samsungce.ehsDiverterValve": { + "position": { + "value": "room", + "timestamp": "2025-05-06T09:03:32.916Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2025-05-06T09:03:32.870Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-05-13T20:54:48.806Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-05-06T09:03:32.870Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-05-06T22:47:03.830Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 297584.0, + "deltaEnergy": 0, + "power": 0.015, + "powerEnergy": 0.004501854166388512, + "persistedEnergy": 297584.0, + "energySaved": 0, + "start": "2025-05-15T20:52:02Z", + "end": "2025-05-15T21:10:02Z" + }, + "timestamp": "2025-05-15T21:10:02.449Z" + } + }, + "samsungce.ehsCycleData": { + "outdoor": { + "value": [ + { + "timestamp": "2025-05-15T21:48:32Z", + "data": "000000005B62414A410207D0000000000000" + }, + { + "timestamp": "2025-05-15T21:53:32Z", + "data": "000000005A61414A410207D0000000000000" + }, + { + "timestamp": "2025-05-15T21:58:32Z", + "data": "000000005960424A420207D0000000000000" + } + ], + "unit": "C", + "timestamp": "2025-05-15T21:02:33.268Z" + }, + "indoor": { + "value": [ + { + "timestamp": "2025-05-15T21:48:32Z", + "data": "48055A050505000000000000000000000000000000008E85" + }, + { + "timestamp": "2025-05-15T21:53:32Z", + "data": "470559050505000000000000000000000000000000008E8B" + }, + { + "timestamp": "2025-05-15T21:58:32Z", + "data": "470559050505000000000000000000000000000000008E90" + } + ], + "unit": "C", + "timestamp": "2025-05-15T21:02:33.268Z" + } + }, + "custom.outingMode": { + "outingMode": { + "value": "off", + "timestamp": "2025-05-06T09:03:32.781Z" + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": null + } + }, + "refresh": {}, + "samsungce.ehsFsvSettings": { + "fsvSettings": { + "value": [ + { + "id": "1031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 37, + "maxValue": 75, + "value": 75, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 15, + "maxValue": 37, + "value": 25, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1051", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 50, + "maxValue": 70, + "value": 60, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1052", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 30, + "maxValue": 40, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2011", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -20, + "maxValue": 5, + "value": -2, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 10, + "maxValue": 20, + "value": 15, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2021", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 60, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2022", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 60, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 75, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2091", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 1, + "isValid": true + }, + { + "id": "2092", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 1, + "isValid": true + }, + { + "id": "2093", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 1, + "maxValue": 4, + "value": 4, + "isValid": true + }, + { + "id": "3011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 0, + "isValid": true + }, + { + "id": "3071", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 1, + "isValid": true + }, + { + "id": "4012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -15, + "maxValue": 20, + "value": 0, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4021", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 0, + "isValid": true + }, + { + "id": "4042", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 5, + "maxValue": 15, + "value": 10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4061", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + } + ], + "timestamp": "2025-05-07T18:12:08.200Z" + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": null + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "DB91-02102A 2025-03-17", + "description": "Version" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "DB91-02100A 2020-07-10", + "description": "Version" + }, + { + "id": "2", + "swType": "Firmware", + "versionNumber": "DB91-02501A 2023-12-15", + "description": "" + }, + { + "id": "3", + "swType": "Firmware", + "versionNumber": "DB91-02572A 2024-07-17", + "description": "EHS MONO LOWTEMP" + } + ], + "timestamp": "2025-05-13T06:57:54.491Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-05-06T09:03:32.949Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-04-14T15:04:59.439Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2025-04-14T15:04:59.418Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": null + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-04-14T15:04:59.272Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-05-06T09:03:32.778Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": null + } + } + }, + "INDOOR": { + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "connected", + "timestamp": "2025-05-06T09:03:32.830Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "on", + "timestamp": "2025-05-06T09:03:32.776Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.systemAirConditionerReservation"], + "timestamp": "2025-04-14T15:04:59.182Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 31, + "unit": "C", + "timestamp": "2025-05-15T21:08:08.464Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-05-14T22:23:55.963Z" + }, + "maximumSetpoint": { + "value": 65, + "unit": "C", + "timestamp": "2025-05-06T09:03:32.729Z" + } + }, + "samsungce.ehsDefrostMode": { + "status": { + "value": "off", + "timestamp": "2025-05-06T09:03:32.830Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["auto", "cool", "heat"], + "timestamp": "2025-05-06T09:03:32.830Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-05-06T09:03:32.830Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-05-06T09:03:32.729Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2025-05-14T22:23:55.326Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-05-06T09:03:32.776Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-05-15T18:27:08.950Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000002_sub.json b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000002_sub.json new file mode 100644 index 00000000000..06f91fbe8b3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000002_sub.json @@ -0,0 +1,868 @@ +{ + "components": { + "main": { + "samsungce.ehsBoosterHeater": { + "status": { + "value": "off", + "timestamp": "2025-05-08T10:20:02.885Z" + } + }, + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 40, + "unit": "C", + "timestamp": "2025-05-05T03:39:24.310Z" + }, + "maximumSetpoint": { + "value": 57, + "unit": "C", + "timestamp": "2025-05-05T03:39:24.310Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["eco", "std", "power", "force"], + "timestamp": "2025-01-16T18:03:09.830Z" + }, + "airConditionerMode": { + "value": "std", + "timestamp": "2025-05-09T02:59:47.311Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 22, + "timestamp": "2025-03-31T04:25:24.686Z" + }, + "binaryId": { + "value": "SAC_EHS_SPLIT", + "timestamp": "2025-05-08T18:03:08.376Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-05-09T04:25:00.539Z" + } + }, + "ocf": { + "st": { + "value": "2025-05-04T18:37:15Z", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnfv": { + "value": "20250317.1", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "di": { + "value": "3810e5ad-5351-d9f9-12ff-000001200000", + "timestamp": "2025-05-08T18:03:08.220Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-05-08T18:03:08.220Z" + }, + "n": { + "value": "Eco Heating System", + "timestamp": "2025-05-08T18:03:08.220Z" + }, + "mnmo": { + "value": "SAC_EHS_SPLIT|220614|61007300001600000400000000000000", + "timestamp": "2025-05-08T18:03:08.376Z" + }, + "vid": { + "value": "DA-SAC-EHS-000002-SUB", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnpv": { + "value": "4.0", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "pi": { + "value": "3810e5ad-5351-d9f9-12ff-000001200000", + "timestamp": "2025-05-08T18:03:08.223Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-05-08T18:03:08.220Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "on", + "timestamp": "2025-01-18T15:00:57.101Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "thermostatHeatingSetpoint", + "samsungce.systemAirConditionerReservation", + "demandResponseLoadControl" + ], + "timestamp": "2025-04-01T04:45:26.332Z" + } + }, + "samsungce.sensingOnSuspendMode": { + "sensingOnSuspendMode": { + "value": "available", + "timestamp": "2025-03-31T04:25:24.686Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25010101, + "timestamp": "2025-03-31T05:10:13.818Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 49.6, + "unit": "C", + "timestamp": "2025-05-09T04:55:51.712Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": null + }, + "heatingSetpointRange": { + "value": null + } + }, + "samsungce.ehsDiverterValve": { + "position": { + "value": "room", + "timestamp": "2025-05-09T03:33:56.476Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2025-01-16T11:17:32.484Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-05-08T20:17:09.388Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-01-16T11:17:32.484Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 52, + "unit": "C", + "timestamp": "2025-05-05T03:39:24.310Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-01-16T18:03:09.830Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 9575308.0, + "deltaEnergy": 45.0, + "power": 0.015, + "powerEnergy": 0.22207609332044917, + "persistedEnergy": 9575308.0, + "energySaved": 0, + "start": "2025-05-09T04:39:01Z", + "end": "2025-05-09T05:02:01Z" + }, + "timestamp": "2025-05-09T05:02:01.788Z" + } + }, + "samsungce.ehsCycleData": { + "outdoor": { + "value": [ + { + "timestamp": "2025-05-08T19:43:06Z", + "data": "0000000063753CFF3C020050027600000000" + }, + { + "timestamp": "2025-05-08T19:48:06Z", + "data": "000000005A7442FF3F0201E0000000000000" + }, + { + "timestamp": "2025-05-08T19:53:06Z", + "data": "00000000577441FF3E0201E0000000000000" + } + ], + "unit": "C", + "timestamp": "2025-05-09T04:57:00.361Z" + }, + "indoor": { + "value": [ + { + "timestamp": "2025-05-08T19:43:06Z", + "data": "565856575805002B640000000101000000000000000E0BB2" + }, + { + "timestamp": "2025-05-08T19:48:06Z", + "data": "5155575757050000000000000101000000000000000E0BB7" + }, + { + "timestamp": "2025-05-08T19:53:06Z", + "data": "535556565705002B640000000101000000000000000E0BBA" + } + ], + "unit": "C", + "timestamp": "2025-05-09T04:57:00.361Z" + } + }, + "custom.outingMode": { + "outingMode": { + "value": "off", + "timestamp": "2025-01-16T11:17:32.257Z" + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-01-16T11:17:32.210Z" + } + }, + "refresh": {}, + "samsungce.ehsFsvSettings": { + "fsvSettings": { + "value": [ + { + "id": "1031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 37, + "maxValue": 65, + "value": 43, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 15, + "maxValue": 37, + "value": 25, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1051", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 50, + "maxValue": 70, + "value": 57, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1052", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 30, + "maxValue": 40, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2011", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -20, + "maxValue": 5, + "value": -10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 10, + "maxValue": 20, + "value": 20, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2021", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 65, + "value": 37, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2022", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 65, + "value": 25, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 65, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 65, + "value": 25, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2091", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2092", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2093", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 1, + "maxValue": 4, + "value": 4, + "isValid": true + }, + { + "id": "3011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 1, + "isValid": true + }, + { + "id": "3071", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -15, + "maxValue": 20, + "value": 0, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4021", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 0, + "isValid": true + }, + { + "id": "4042", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 5, + "maxValue": 15, + "value": 10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4061", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 1, + "isValid": true + } + ], + "timestamp": "2025-04-25T02:52:46.974Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.a"], + "x.com.samsung.da.modelNum": "SAC_EHS_SPLIT|220614|61007300001600000400000000000000", + "x.com.samsung.da.description": "EHS_TANK", + "x.com.samsung.da.serialNum": "0TYZPAOTC00301P", + "x.com.samsung.da.versionId": "Samsung Electronics", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.number": "DB91-02102A 2023-09-14", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version" + }, + { + "x.com.samsung.da.number": "DB91-02100A 2020-07-10", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version" + }, + { + "x.com.samsung.da.number": "DB91-02103B 2022-06-14", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "" + }, + { + "x.com.samsung.da.number": "DB91-02091B 2022-08-02", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "EHS SPLIT" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2024-03-25T19:40:05.820Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-01-16T11:17:32.301Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "DB91-02102A 2025-03-17", + "description": "Version" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "DB91-02100A 2020-07-10", + "description": "Version" + }, + { + "id": "2", + "swType": "Firmware", + "versionNumber": "DB91-02103B 2022-06-14", + "description": "" + }, + { + "id": "3", + "swType": "Firmware", + "versionNumber": "DB91-02091B 2022-08-02", + "description": "EHS SPLIT" + } + ], + "timestamp": "2025-04-28T03:40:34.481Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-01-16T11:17:32.469Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-01-16T18:03:09.830Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2023-10-05T18:12:48.916Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": null + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-01-16T11:17:32.328Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-01-16T11:17:32.328Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-01-16T11:17:32.266Z" + } + } + }, + "INDOOR1": { + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-01-16T11:17:32.378Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "on", + "timestamp": "2025-01-16T11:17:32.176Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.systemAirConditionerReservation"], + "timestamp": "2025-03-31T04:25:24.686Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 31.2, + "unit": "C", + "timestamp": "2025-05-09T04:57:52.869Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -1000, + "unit": "C", + "timestamp": "2025-01-22T11:43:49.976Z" + }, + "maximumSetpoint": { + "value": -1000, + "unit": "C", + "timestamp": "2025-01-22T11:43:49.976Z" + } + }, + "samsungce.ehsDefrostMode": { + "status": { + "value": "off", + "timestamp": "2025-05-07T01:00:50.612Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["auto", "cool", "heat"], + "timestamp": "2025-01-16T11:17:32.378Z" + }, + "airConditionerMode": { + "value": "auto", + "timestamp": "2025-01-22T11:43:43.266Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-01-16T11:17:32.225Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 24, + "unit": "C", + "timestamp": "2025-01-22T11:43:49.976Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-01-16T11:17:32.176Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-05-08T18:03:08.376Z" + } + } + }, + "INDOOR2": { + "samsungce.systemAirConditionerReservation": { + "reservations": { + "value": null + }, + "maxNumberOfReservations": { + "value": null + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-01-16T11:17:32.378Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-01-16T11:17:32.247Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.systemAirConditionerReservation"], + "timestamp": "2025-03-31T04:25:24.686Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 29.1, + "unit": "C", + "timestamp": "2025-05-09T04:47:04.597Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -1000, + "unit": "C", + "timestamp": "2025-01-22T11:43:54.947Z" + }, + "maximumSetpoint": { + "value": -1000, + "unit": "C", + "timestamp": "2025-01-22T11:43:54.947Z" + } + }, + "samsungce.ehsDefrostMode": { + "status": { + "value": "off", + "timestamp": "2025-05-07T01:00:50.612Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["auto", "cool", "heat"], + "timestamp": "2025-01-16T11:17:32.378Z" + }, + "airConditionerMode": { + "value": "auto", + "timestamp": "2025-01-22T11:43:43.266Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-01-16T11:17:32.413Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 24, + "unit": "C", + "timestamp": "2025-01-22T11:43:54.947Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-01-16T11:17:32.247Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-05-08T18:03:08.376Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_sc_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_sc_000001.json new file mode 100644 index 00000000000..d52b5186db3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_sc_000001.json @@ -0,0 +1,929 @@ +{ + "components": { + "main": { + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20299141", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "3801010200151107020100FF00000000", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "description": { + "value": "DA_DF_TP2_20_COMMON_DF8500A/DC92-02995A_0010", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_DF_TP2_20_COMMON", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.steamClosetCycle": { + "supportedCycles": { + "value": [ + { + "cycle": "22", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6106", + "default": "off", + "options": ["off", "on"] + } + } + }, + { + "cycle": "23", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "32", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "09", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "12", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "0C", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "31", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "0B", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "10", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "0A", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6106", + "default": "off", + "options": ["off", "on"] + } + } + }, + { + "cycle": "14", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "13", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6106", + "default": "off", + "options": ["off", "on"] + } + } + }, + { + "cycle": "16", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "24", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6206", + "default": "on", + "options": ["off", "on"] + } + } + }, + { + "cycle": "25", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6206", + "default": "on", + "options": ["off", "on"] + } + } + }, + { + "cycle": "2F", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6206", + "default": "on", + "options": ["off", "on"] + } + } + }, + { + "cycle": "20", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6204", + "default": "on", + "options": ["on"] + } + } + }, + { + "cycle": "0F", + "supportedOptions": { + "keepFresh": { + "raw": "66F0", + "default": "off", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6204", + "default": "on", + "options": ["on"] + } + } + }, + { + "cycle": "27", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "30", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "15", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "1A", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "1B", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "1C", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "2D", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "07", + "supportedOptions": { + "keepFresh": { + "raw": "66F0", + "default": "off", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "08", + "supportedOptions": { + "keepFresh": { + "raw": "66F0", + "default": "off", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + } + ], + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "steamClosetCycle": { + "value": "Table_00_Course_22", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_DF_TP2_20_COMMON_30230807", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "di": { + "value": "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "n": { + "value": "[airdresser] Samsung", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnmo": { + "value": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "vid": { + "value": "DA-WM-SC-000001", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnos": { + "value": "TizenRT 2.0 + IPv6", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "pi": { + "value": "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-14T01:42:53.834Z" + } + }, + "samsungce.steamClosetCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "presets": { + "value": { + "F1": {}, + "F2": {}, + "F3": {}, + "F4": {}, + "F5": {}, + "F6": {}, + "F7": {}, + "F8": {}, + "F9": {}, + "FA": {} + }, + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "custom.steamClosetWrinklePrevent", + "custom.veryFineDustFilter", + "demandResponseLoadControl", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate" + ], + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-02T07:55:47.237Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "A00", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.steamClosetKeepFreshMode": { + "operatingState": { + "value": "ready", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "status": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 207500, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-10T22:51:59Z", + "end": "2025-02-11T08:21:17Z" + }, + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "dryerOperatingState": { + "completionTime": { + "value": "2025-02-11T09:00:17Z", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "refresh": {}, + "samsungce.steamClosetSanitizeMode": { + "status": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON_DF8500A/DC92-02995A_0010", + "x.com.samsung.da.serialNum": "1EG158TW400002M", + "x.com.samsung.da.otnDUID": "MTCHUODP5V4FA", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "A00", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02673A230807(F821)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_20299141210618090FFFFF202995412203111604FFFF(015E2029914120299541_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "21061809,22031116", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_2023564319111852041FFFFFFFFFFFFFFFFFFFFFFFFE(015E20235643FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "19111852,FFFFFFFF" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2024-03-06T11:24:05.312Z" + } + }, + "samsungce.steamClosetDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.steamClosetAutoCycleLink": { + "steamClosetAutoCycleLink": { + "value": "on", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "custom.steamClosetWrinklePrevent": { + "steamClosetWrinklePrevent": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "supportedCourses": { + "value": [ + "22", + "23", + "32", + "09", + "12", + "0C", + "31", + "0B", + "10", + "0A", + "14", + "13", + "16", + "24", + "25", + "2F", + "20", + "0F", + "27", + "30", + "15", + "1A", + "1B", + "1C", + "2D", + "07", + "08" + ], + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.steamClosetOperatingState": { + "supportedSteamClosetJobState": { + "value": ["none", "steaming", "airwashing", "drying", "finish"], + "timestamp": "2025-02-09T22:16:19.221Z" + }, + "completionTime": { + "value": "2025-02-11T09:00:17Z", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "steamClosetMachineState": { + "value": "stop", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "supportedSteamClosetMachineState": { + "value": ["stop", "run", "pause"], + "timestamp": "2023-06-23T16:00:41.238Z" + }, + "steamClosetJobState": { + "value": "none", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-10T22:53:25.928Z" + }, + "remainingTimeStr": { + "value": "00:39", + "timestamp": "2025-02-10T22:53:25.928Z" + }, + "steamClosetDelayEndTime": { + "value": null + }, + "remainingTime": { + "value": 39, + "unit": "min", + "timestamp": "2025-02-10T22:53:25.928Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2024-03-06T11:24:06.106Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2024-03-06T11:24:06.106Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-09T17:33:28.019Z" + }, + "otnDUID": { + "value": "MTCHUODP5V4FA", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2023-06-23T16:00:41.636Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-09T17:33:28.019Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.veryFineDustFilter": { + "veryFineDustFilterStatus": { + "value": null + }, + "veryFineDustFilterResetType": { + "value": null + }, + "veryFineDustFilterUsage": { + "value": null + }, + "veryFineDustFilterLastResetDate": { + "value": null + }, + "veryFineDustFilterUsageStep": { + "value": null + }, + "veryFineDustFilterCapacity": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json new file mode 100644 index 00000000000..b45bac95237 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json @@ -0,0 +1,692 @@ +{ + "components": { + "hca.main": { + "hca.dryerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedModes": { + "value": ["normal", "quickDry", "mix", "timeDry"], + "timestamp": "2025-03-09T16:31:40.486Z" + } + } + }, + "main": { + "custom.dryerWrinklePrevent": { + "operatingState": { + "value": "ready", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "dryerWrinklePrevent": { + "value": "off", + "timestamp": "2025-03-09T16:31:41.077Z" + } + }, + "samsungce.dryerDryingTemperature": { + "dryingTemperature": { + "value": null, + "timestamp": "2021-04-02T18:31:36.756Z" + }, + "supportedDryingTemperature": { + "value": null, + "timestamp": "2021-04-02T18:29:52.258Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null, + "timestamp": "2021-04-02T18:32:37.913Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-17T17:07:35.734Z" + } + }, + "samsungce.dryerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-03-09T16:31:41.229Z" + }, + "presets": { + "value": null, + "timestamp": "2021-04-02T18:30:36.772Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20221341", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "modelName": { + "value": null, + "timestamp": "2021-04-02T18:29:53.622Z" + }, + "serialNumber": { + "value": null, + "timestamp": "2021-04-02T18:29:52.641Z" + }, + "serialNumberExtra": { + "value": null, + "timestamp": "2021-04-02T18:29:51.653Z" + }, + "modelClassificationCode": { + "value": "30010102001211000103000000000000", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-03-09T19:07:40.295Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T19:47:36.549Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.dryerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-06-20T10:01:02.741Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-06-25T01:53:25.278Z" + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "di": { + "value": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "n": { + "value": "[dryer] Samsung", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "vid": { + "value": "DA-WM-WD-000001", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "pi": { + "value": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-15T10:53:49.561Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "2", + "timestamp": "2025-03-09T19:47:36.806Z" + }, + "supportedDryerDryLevel": { + "value": ["none", "1", "2", "3"], + "timestamp": "2020-11-18T20:16:43.428Z" + } + }, + "samsungce.dryerAutoCycleLink": { + "dryerAutoCycleLink": { + "value": null, + "timestamp": "2020-08-11T12:41:38.646Z" + } + }, + "samsungce.dryerCycle": { + "dryerCycle": { + "value": "Table_00_Course_9A", + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedCycles": { + "value": [ + { + "cycle": "9A", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "CA", + "supportedOptions": { + "dryingLevel": { + "raw": "D10E", + "default": "1", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "DB", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "99", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "93", + "supportedOptions": { + "dryingLevel": { + "raw": "D102", + "default": "1", + "options": ["1"] + } + } + }, + { + "cycle": "B5", + "supportedOptions": { + "dryingLevel": { + "raw": "D102", + "default": "1", + "options": ["1"] + } + } + }, + { + "cycle": "D7", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "A5", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "96", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "97", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "7F", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "98", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "EB", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "B6", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + } + ], + "timestamp": "2025-02-10T02:24:03.524Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.dryerDelayEnd", + "dryerOperatingState", + "samsungce.dryerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.dryerFreezePrevent", + "samsungce.dryerDryingTemperature", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-02T14:42:38.334Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-02T07:43:41.263Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-09T16:31:40.882Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 796400, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-03-09T19:47:26Z", + "end": "2025-03-09T19:47:37Z" + }, + "timestamp": "2025-03-09T19:47:37.283Z" + } + }, + "dryerOperatingState": { + "completionTime": { + "value": "2025-03-09T22:55:37Z", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-03-09T16:31:41.172Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-03-09T19:47:37.015Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-04-02T18:29:51.428Z" + } + }, + "samsungce.dryerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-09T16:31:41.172Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null, + "timestamp": "2020-06-25T01:53:34.974Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404", + "x.com.samsung.da.serialNum": "0T625AEN100200N", + "x.com.samsung.da.otnDUID": "SHCDM6YAPCCXC", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A220728(E256)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "17111305,19060420", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-07T00:06:05.984Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-03-09T16:31:41.180Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedCourses": { + "value": [ + "9A", + "CA", + "DB", + "99", + "93", + "B5", + "D7", + "A5", + "96", + "97", + "7F", + "98", + "EB", + "B6" + ], + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-17T17:07:35.734Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-17T17:07:35.734Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.dryerOperatingState": { + "operatingState": { + "value": "ready", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-01T12:48:22.390Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "drying", + "timeInMin": 192 + }, + { + "jobName": "cooling", + "timeInMin": 1 + } + ], + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "remainingTimeStr": { + "value": "03:08", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "remainingTime": { + "value": 188, + "unit": "min", + "timestamp": "2025-03-09T19:47:37.015Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "SHCDM6YAPCCXC", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T21:16:50.598Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-01T21:16:50.598Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.dryerDryingTime": { + "supportedDryingTime": { + "value": ["0", "30", "60", "90", "120", "150"], + "timestamp": "2021-04-02T18:29:51.428Z" + }, + "dryingTime": { + "value": "0", + "unit": "min", + "timestamp": "2025-03-09T16:31:41.077Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json new file mode 100644 index 00000000000..157e5496625 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json @@ -0,0 +1,1416 @@ +{ + "components": { + "hca.main": { + "hca.washerMode": { + "mode": { + "value": "mix", + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "supportedModes": { + "value": ["normal", "quickWash", "mix", "eco"], + "timestamp": "2025-03-07T06:06:08.613Z" + } + } + }, + "main": { + "samsungce.washerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-07T06:06:08.806Z" + }, + "minimumReservableTime": { + "value": 49, + "unit": "min", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerWaterLevel": { + "supportedWaterLevel": { + "value": null, + "timestamp": "2021-03-31T22:35:35.010Z" + }, + "waterLevel": { + "value": null, + "timestamp": "2021-04-17T09:56:20.618Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null, + "timestamp": "2021-04-01T23:43:08.541Z" + } + }, + "custom.washerWaterTemperature": { + "supportedWasherWaterTemperature": { + "value": ["none", "cold", "20", "30", "40", "60", "90"], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerWaterTemperature": { + "value": "40", + "timestamp": "2025-03-07T06:06:08.901Z" + } + }, + "samsungce.softenerAutoReplenishment": { + "regularSoftenerType": { + "value": null + }, + "regularSoftenerAlarmEnabled": { + "value": null + }, + "regularSoftenerInitialAmount": { + "value": null + }, + "regularSoftenerRemainingAmount": { + "value": null + }, + "regularSoftenerDosage": { + "value": null + }, + "regularSoftenerOrderThreshold": { + "value": null + } + }, + "samsungce.autoDispenseSoftener": { + "remainingAmount": { + "value": null, + "timestamp": "2021-01-29T10:38:25.844Z" + }, + "amount": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "supportedDensity": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "density": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "supportedAmount": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-14T22:23:10.096Z" + } + }, + "samsungce.autoDispenseDetergent": { + "remainingAmount": { + "value": null, + "timestamp": "2021-01-26T01:49:50.635Z" + }, + "amount": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "supportedDensity": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "density": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "supportedAmount": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "availableTypes": { + "value": null + }, + "type": { + "value": null + }, + "recommendedAmount": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20224941", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "modelName": { + "value": null, + "timestamp": "2021-04-01T23:32:40.512Z" + }, + "serialNumber": { + "value": null, + "timestamp": "2021-04-01T23:32:38.884Z" + }, + "serialNumberExtra": { + "value": null, + "timestamp": "2021-04-01T23:32:36.541Z" + }, + "modelClassificationCode": { + "value": "20010102011211030203000000000000", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_\u0018WD7800N/DC92-02249A_08CC", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-03-07T06:06:08.719Z" + } + }, + "samsungce.washerWaterValve": { + "waterValve": { + "value": null, + "timestamp": "2021-04-01T23:43:07.144Z" + }, + "supportedWaterValve": { + "value": null, + "timestamp": "2021-03-31T22:35:34.371Z" + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-03-07T07:01:12Z", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "machineState": { + "value": "run", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "washerJobState": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-03-07T06:06:08.806Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-07T06:06:08.856Z" + } + }, + "custom.washerAutoSoftener": { + "washerAutoSoftener": { + "value": null, + "timestamp": "2020-08-07T21:22:34.172Z" + } + }, + "samsungce.washerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.washerCycle": { + "supportedCycles": { + "value": [ + { + "cycle": "D0", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "847E", + "default": "40", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "DC", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A33F", + "default": "800", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "811E", + "default": "cold", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "E3", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "E4", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "943F", + "default": "4", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8560", + "default": "60", + "options": ["60", "90"] + } + } + }, + { + "cycle": "50", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B11E", + "default": "cupboard", + "options": ["cupboard", "30", "60", "90", "210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "51", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B11E", + "default": "cupboard", + "options": ["cupboard", "30", "60", "90", "210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "CA", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "E7", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B100", + "default": "cupboard", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A640", + "default": "1400", + "options": ["1400"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "C7", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A520", + "default": "1200", + "options": ["1200"] + }, + "rinseCycle": { + "raw": "9204", + "default": "2", + "options": ["2"] + }, + "waterTemperature": { + "raw": "8520", + "default": "60", + "options": ["60"] + } + } + }, + { + "cycle": "D8", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "D4", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "D3", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "DA", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8102", + "default": "cold", + "options": ["cold"] + } + } + }, + { + "cycle": "D2", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "40", + "options": ["cold", "20", "30", "40", "60"] + } + } + } + ], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerCycle": { + "value": "Table_00_Course_E3", + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2021-12-01T23:55:08.740Z" + }, + "specializedFunctionClassification": { + "value": 7, + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null, + "timestamp": "2021-03-31T22:35:33.802Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-08-11T22:47:36.523Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-08-11T22:47:41.693Z" + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "di": { + "value": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2024-12-27T04:48:02.896Z" + }, + "n": { + "value": "[washer] Samsung", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "vid": { + "value": "DA-WM-WM-000001", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "pi": { + "value": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-27T04:48:02.896Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "none", + "timestamp": "2025-03-07T06:06:08.901Z" + }, + "supportedDryerDryLevel": { + "value": [ + "none", + "cupboard", + "30", + "60", + "90", + "120", + "150", + "180", + "210", + "240", + "270" + ], + "timestamp": "2024-12-09T22:01:38.311Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.washerDelayEnd", + "washerOperatingState", + "samsungce.autoDispenseDetergent", + "samsungce.autoDispenseSoftener", + "samsungce.waterConsumptionReport", + "samsungce.washerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "samsungce.energyPlanner", + "demandResponseLoadControl", + "samsungce.softenerAutoReplenishment", + "samsungce.softenerOrder", + "samsungce.softenerState", + "samsungce.washerFreezePrevent", + "custom.washerSoilLevel", + "samsungce.washerWaterLevel", + "samsungce.washerWaterValve", + "samsungce.washerWashingTime", + "custom.washerAutoDetergent", + "custom.washerAutoSoftener", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-03T08:44:32.524Z" + } + }, + "custom.washerRinseCycles": { + "supportedWasherRinseCycles": { + "value": ["0", "1", "2", "3", "4", "5"], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerRinseCycles": { + "value": "2", + "timestamp": "2025-03-07T06:06:08.901Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:08:44.235Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.washerOperatingState": { + "washerJobState": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-02T21:35:52.935Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "wash", + "timeInMin": 21 + }, + { + "jobName": "rinse", + "timeInMin": 16 + }, + { + "jobName": "spin", + "timeInMin": 11 + } + ], + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "scheduledPhases": { + "value": [ + { + "phaseName": "wash", + "timeInMin": 21 + }, + { + "phaseName": "rinse", + "timeInMin": 16 + }, + { + "phaseName": "spin", + "timeInMin": 11 + } + ], + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "progress": { + "value": 36, + "unit": "%", + "timestamp": "2025-03-07T06:30:10.639Z" + }, + "remainingTimeStr": { + "value": "00:31", + "timestamp": "2025-03-07T06:30:10.639Z" + }, + "washerJobPhase": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "operationTime": { + "value": 49, + "unit": "min", + "timestamp": "2025-03-06T02:24:50.104Z" + }, + "remainingTime": { + "value": 31, + "unit": "min", + "timestamp": "2025-03-07T06:30:10.639Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-07T06:06:08.688Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 1323600, + "deltaEnergy": 100, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-03-07T06:21:09Z", + "end": "2025-03-07T06:23:21Z" + }, + "timestamp": "2025-03-07T06:23:21.062Z" + } + }, + "samsungce.detergentAutoReplenishment": { + "neutralDetergentType": { + "value": null + }, + "regularDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "babyDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentAlarmEnabled": { + "value": null + }, + "neutralDetergentOrderThreshold": { + "value": null + }, + "babyDetergentInitialAmount": { + "value": null + }, + "babyDetergentType": { + "value": null + }, + "neutralDetergentInitialAmount": { + "value": null + }, + "regularDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "babyDetergentDosage": { + "value": null + }, + "regularDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentType": { + "value": "none", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "neutralDetergentDosage": { + "value": null + }, + "babyDetergentOrderThreshold": { + "value": null + }, + "babyDetergentAlarmEnabled": { + "value": null + } + }, + "samsungce.softenerOrder": { + "alarmEnabled": { + "value": null, + "timestamp": "2020-12-28T11:12:47.109Z" + }, + "orderThreshold": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T11:12:47.109Z" + } + }, + "custom.washerSoilLevel": { + "supportedWasherSoilLevel": { + "value": null, + "timestamp": "2020-08-11T22:49:08.023Z" + }, + "washerSoilLevel": { + "value": null, + "timestamp": "2020-08-11T22:49:08.023Z" + } + }, + "samsungce.washerBubbleSoak": { + "status": { + "value": "off", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-03-07T06:06:08.957Z" + }, + "presets": { + "value": null, + "timestamp": "2021-03-31T08:11:41.657Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-03-31T22:35:33.949Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null, + "timestamp": "2020-08-11T22:48:26.262Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_\u0018WD7800N/DC92-02249A_08CC", + "x.com.samsung.da.serialNum": "0TE65ADMC00093F", + "x.com.samsung.da.otnDUID": "EXCEZFTFQ53G2", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A220728(E256)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18072525,18090310", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T02:14:23.034Z" + } + }, + "samsungce.softenerState": { + "remainingAmount": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T07:11:13.285Z" + }, + "dosage": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T01:14:27.011Z" + }, + "softenerType": { + "value": null, + "timestamp": "2020-11-19T21:57:19.712Z" + }, + "initialAmount": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T00:45:40.863Z" + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-07T06:06:08.819Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "supportedCourses": { + "value": [ + "D0", + "DC", + "E3", + "E4", + "50", + "51", + "CA", + "E7", + "C7", + "D8", + "D4", + "D3", + "DA", + "D2" + ], + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerWashingTime": { + "supportedWashingTimes": { + "value": null, + "timestamp": "2021-03-31T08:10:28.542Z" + }, + "washingTime": { + "value": null, + "unit": "min", + "timestamp": "2021-03-31T08:10:28.542Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-14T22:23:10.096Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-14T22:38:10.576Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "EXCEZFTFQ53G2", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T10:37:29.975Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-01T10:37:29.975Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.washerAutoDetergent": { + "washerAutoDetergent": { + "value": null, + "timestamp": "2020-08-11T22:47:34.372Z" + } + }, + "custom.washerSpinLevel": { + "washerSpinLevel": { + "value": "1400", + "timestamp": "2025-03-07T06:06:08.901Z" + }, + "supportedWasherSpinLevel": { + "value": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ], + "timestamp": "2024-12-09T22:01:38.311Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_01011.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_01011.json new file mode 100644 index 00000000000..21949e100f7 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_01011.json @@ -0,0 +1,1791 @@ +{ + "components": { + "hca.main": { + "hca.washerMode": { + "mode": { + "value": "others", + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "supportedModes": { + "value": ["normal", "quickWash", "mix", "eco", "spinOnly"], + "timestamp": "2025-04-25T07:40:12.944Z" + } + } + }, + "main": { + "custom.dryerWrinklePrevent": { + "operatingState": { + "value": null + }, + "dryerWrinklePrevent": { + "value": null + } + }, + "samsungce.washerWaterLevel": { + "supportedWaterLevel": { + "value": null + }, + "waterLevel": { + "value": null + } + }, + "custom.washerWaterTemperature": { + "supportedWasherWaterTemperature": { + "value": ["none", "cold", "20", "30", "40", "60", "90"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "washerWaterTemperature": { + "value": "40", + "timestamp": "2025-04-25T08:13:49.565Z" + } + }, + "samsungce.softenerAutoReplenishment": { + "regularSoftenerType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularSoftenerAlarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularSoftenerInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularSoftenerRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularSoftenerDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularSoftenerOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.autoDispenseSoftener": { + "remainingAmount": { + "value": null + }, + "amount": { + "value": null + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": null + } + }, + "samsungce.autoDispenseDetergent": { + "remainingAmount": { + "value": "normal", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "amount": { + "value": "standard", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "supportedDensity": { + "value": ["normal", "high", "extraHigh"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "density": { + "value": "high", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "supportedAmount": { + "value": ["none", "less", "standard", "extra"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "availableTypes": { + "value": ["regularDetergent"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "type": { + "value": "regularDetergent", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "supportedTypes": { + "value": null + }, + "recommendedAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.washerWaterValve": { + "waterValve": { + "value": null + }, + "supportedWaterValve": { + "value": null + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-04-25T10:34:12Z", + "timestamp": "2025-04-25T07:49:12.761Z" + }, + "machineState": { + "value": "run", + "timestamp": "2025-04-25T07:49:28.858Z" + }, + "washerJobState": { + "value": "wash", + "timestamp": "2025-04-25T07:50:32.365Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "custom.washerAutoSoftener": { + "washerAutoSoftener": { + "value": null + } + }, + "samsungce.washerCycle": { + "cycleType": { + "value": "washingOnly", + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "supportedCycles": { + "value": [ + { + "cycle": "1C", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "2B", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8410", + "default": "40", + "options": ["40"] + } + } + }, + { + "cycle": "1B", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "847E", + "default": "40", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "1E", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A53F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "1D", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "96", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A37F", + "default": "800", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "8F", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A57F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8102", + "default": "cold", + "options": ["cold"] + } + } + }, + { + "cycle": "25", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A57F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "40", + "options": ["cold", "20", "30", "40", "60"] + } + } + }, + { + "cycle": "26", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A207", + "default": "400", + "options": ["rinseHold", "noSpin", "400"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "33", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "857E", + "default": "60", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "24", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "930F", + "default": "3", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "32", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A37F", + "default": "800", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "833E", + "default": "30", + "options": ["cold", "20", "30", "40", "60"] + } + } + }, + { + "cycle": "20", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "943F", + "default": "4", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "857E", + "default": "60", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "22", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "23", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A57F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "930F", + "default": "3", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "2F", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "21", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A57F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "943F", + "default": "4", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "2A", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "2E", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "943F", + "default": "4", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "867E", + "default": "90", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "2D", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "30", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "40", + "options": ["cold", "20", "30", "40", "60"] + } + } + }, + { + "cycle": "29", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "waterTemperature": { + "raw": "8520", + "default": "70", + "options": ["70"] + }, + "spinLevel": { + "raw": "A520", + "default": "1200", + "options": ["1200"] + }, + "rinseCycle": { + "raw": "9204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "27", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "28", + "cycleType": "washingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "spinLevel": { + "raw": "A67E", + "default": "1400", + "options": ["noSpin", "400", "800", "1000", "1200", "1400"] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + } + ], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "washerCycle": { + "value": "Table_02_Course_1C", + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "referenceTable": { + "value": { + "id": "Table_02" + }, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": { + "cumulativeAmount": 1642200, + "delta": 0, + "start": "2025-04-25T08:28:43Z", + "end": "2025-04-25T08:43:46Z" + }, + "timestamp": "2025-04-25T08:43:46.404Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_WM_TP1_21_COMMON_30240927", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "di": { + "value": "b854ca5f-dc54-140d-6349-758b4d973c41", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "n": { + "value": "[washer] Samsung", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnmo": { + "value": "DA_WM_TP1_21_COMMON|20374641|20010002001811364AA30277008E0000", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "vid": { + "value": "DA-WM-WM-01011", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "pi": { + "value": "b854ca5f-dc54-140d-6349-758b4d973c41", + "timestamp": "2025-04-25T08:13:43.103Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-04-25T08:13:43.103Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": null + }, + "supportedDryerDryLevel": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.autoDispenseSoftener", + "samsungce.energyPlanner", + "logTrigger", + "sec.smartthingsHub", + "samsungce.washerFreezePrevent", + "custom.dryerDryLevel", + "samsungce.dryerDryingTime", + "custom.dryerWrinklePrevent", + "custom.washerSoilLevel", + "samsungce.washerWaterLevel", + "samsungce.washerWaterValve", + "samsungce.washerWashingTime", + "custom.washerAutoDetergent", + "custom.washerAutoSoftener" + ], + "timestamp": "2025-04-25T08:07:14.496Z" + } + }, + "logTrigger": { + "logState": { + "value": null + }, + "logRequestState": { + "value": null + }, + "logInfo": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25020102, + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "WFC", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.washerOperatingState": { + "washerJobState": { + "value": "wash", + "timestamp": "2025-04-25T07:50:32.365Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-04-25T07:49:28.858Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "wash", + "timeInMin": 133 + }, + { + "jobName": "rinse", + "timeInMin": 19 + }, + { + "jobName": "spin", + "timeInMin": 12 + } + ], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "scheduledPhases": { + "value": [ + { + "phaseName": "wash", + "timeInMin": 133 + }, + { + "phaseName": "rinse", + "timeInMin": 19 + }, + { + "phaseName": "spin", + "timeInMin": 12 + } + ], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "progress": { + "value": 40, + "unit": "%", + "timestamp": "2025-04-25T08:54:30.139Z" + }, + "remainingTimeStr": { + "value": "01:40", + "timestamp": "2025-04-25T08:54:30.139Z" + }, + "washerJobPhase": { + "value": "wash", + "timestamp": "2025-04-25T07:50:32.365Z" + }, + "operationTime": { + "value": 165, + "unit": "min", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "remainingTime": { + "value": 100, + "unit": "min", + "timestamp": "2025-04-25T08:54:30.139Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 26800, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-04-25T08:28:43Z", + "end": "2025-04-25T08:43:46Z" + }, + "timestamp": "2025-04-25T08:43:46.217Z" + } + }, + "samsungce.softenerOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "custom.washerSoilLevel": { + "supportedWasherSoilLevel": { + "value": null + }, + "washerSoilLevel": { + "value": null + } + }, + "samsungce.washerBubbleSoak": { + "status": { + "value": "off", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.washerLabelScanCyclePreset": { + "presets": { + "value": { + "FB": {} + }, + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.softenerState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "softenerType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "02986A240927(A159)", + "description": "DA_WM_TP1_21_COMMON|20374641|20010002001811364AA30277008E0000" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "03746A24030804,03724A24031617", + "description": "Firmware_1_DB_20374641240308040FFFFF203724412403161704FFFF(01672037464120372441_30000000)(FileDown:0)(Type:0)" + }, + { + "id": "2", + "swType": "Firmware", + "versionNumber": "03628B24030602,FFFFFFFFFFFFFF", + "description": "Firmware_2_DB_2036284224030602042FFFFFFFFFFFFFFFFFFFFFFFFE(016720362842FFFFFFFF_30000000)(FileDown:0)(Type:0)" + } + ], + "timestamp": "2025-04-25T08:13:47.726Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-04-25T07:48:54.109Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": "1C", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "referenceTable": { + "value": { + "id": "Table_02" + }, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "supportedCourses": { + "value": [ + "1C", + "2B", + "1B", + "1E", + "1D", + "96", + "8F", + "25", + "26", + "33", + "24", + "32", + "20", + "22", + "23", + "2F", + "21", + "2A", + "2E", + "2D", + "30", + "29", + "27", + "28" + ], + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.washerWashingTime": { + "supportedWashingTimes": { + "value": null + }, + "washingTime": { + "value": null + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-04-25T07:40:16.819Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": { + "newVersion": "00000000", + "currentVersion": "00000000", + "moduleType": "mainController" + }, + "timestamp": "2025-04-25T08:13:47.829Z" + }, + "otnDUID": { + "value": "2DCB2ZD44WHDW", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-04-25T07:40:13.556Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-04-25T07:40:13.556Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-04-25T07:40:13.556Z" + }, + "progress": { + "value": null + } + }, + "sec.smartthingsHub": { + "threadHardwareAvailability": { + "value": null + }, + "availability": { + "value": null + }, + "deviceId": { + "value": null + }, + "zigbeeHardwareAvailability": { + "value": null + }, + "version": { + "value": null + }, + "threadRequiresExternalHardware": { + "value": null + }, + "zigbeeRequiresExternalHardware": { + "value": null + }, + "eui": { + "value": null + }, + "lastOnboardingResult": { + "value": null + }, + "zwaveHardwareAvailability": { + "value": null + }, + "zwaveRequiresExternalHardware": { + "value": null + }, + "state": { + "value": null + }, + "onboardingProgress": { + "value": null + }, + "lastOnboardingErrorCode": { + "value": null + } + }, + "custom.washerSpinLevel": { + "washerSpinLevel": { + "value": "1000", + "timestamp": "2025-04-25T07:49:25.157Z" + }, + "supportedWasherSpinLevel": { + "value": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ], + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.dryerDryingTime": { + "supportedDryingTime": { + "value": null + }, + "dryingTime": { + "value": null + } + }, + "samsungce.washerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "minimumReservableTime": { + "value": 165, + "unit": "min", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "samsungce.clothingExtraCare": { + "operationMode": { + "value": "off", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "userLocation": { + "value": "indoor", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20374641", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "20010002001811364AA30277008E0000", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "description": { + "value": "DA_WM_TP1_21_COMMON_WD7000B/DC92-03724A_001A", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "releaseYear": { + "value": 24, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "binaryId": { + "value": "DA_WM_TP1_21_COMMON", + "timestamp": "2025-04-25T08:13:49.565Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-04-25T08:13:49.565Z" + } + }, + "samsungce.washerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-04-25T08:07:13.012Z" + } + }, + "samsungce.audioVolumeLevel": { + "volumeLevel": { + "value": 0, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "volumeLevelRange": { + "value": { + "minimum": 0, + "maximum": 1, + "step": 1 + }, + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "custom.washerRinseCycles": { + "supportedWasherRinseCycles": { + "value": ["0", "1", "2", "3", "4", "5"], + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "washerRinseCycles": { + "value": "2", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.detergentAutoReplenishment": { + "neutralDetergentType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "neutralDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "neutralDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "neutralDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "neutralDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "regularDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "neutralDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "babyDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "samsungce.washerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-04-25T07:40:12.942Z" + }, + "presets": { + "value": { + "F1": {}, + "F2": {}, + "F3": {}, + "F4": {}, + "F5": {}, + "F6": {}, + "F7": {}, + "F8": {}, + "F9": {}, + "FA": {} + }, + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": "None", + "timestamp": "2025-04-25T07:40:12.942Z" + } + }, + "samsungce.flexibleAutoDispenseDetergent": { + "remainingAmount": { + "value": "normal", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "amount": { + "value": "standard", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": ["none", "less", "standard", "extra"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "availableTypes": { + "value": ["regularSoftener", "regularDetergent"], + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "type": { + "value": "regularSoftener", + "timestamp": "2025-04-25T07:40:12.944Z" + }, + "supportedTypes": { + "value": null + }, + "recommendedAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-04-25T07:40:12.944Z" + } + }, + "custom.washerAutoDetergent": { + "washerAutoDetergent": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_100001.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_100001.json new file mode 100644 index 00000000000..b3b01762099 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_100001.json @@ -0,0 +1,154 @@ +{ + "components": { + "main": { + "ocf": { + "st": { + "value": null, + "timestamp": "2020-10-06T23:01:03.011Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-01-28T11:54:37.203Z" + }, + "mnfv": { + "value": null, + "timestamp": "2020-12-20T14:21:43.766Z" + }, + "mnhw": { + "value": null, + "timestamp": "2021-01-25T22:57:01.985Z" + }, + "di": { + "value": "C0972771-01D0-0000-0000-000000000000", + "timestamp": "2019-08-10T18:37:20.487Z" + }, + "mnsl": { + "value": null, + "timestamp": "2020-12-20T14:21:31.219Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2019-08-10T18:37:20.514Z" + }, + "n": { + "value": "Washer", + "timestamp": "2019-08-10T18:37:20.555Z" + }, + "mnmo": { + "value": "TP6X_WA54M8750AV|20183944|20000101001111000100000000000000", + "timestamp": "2019-08-10T18:37:20.409Z" + }, + "vid": { + "value": "DA-WM-WM-100001", + "timestamp": "2019-08-10T18:37:20.381Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2019-08-10T18:37:20.436Z" + }, + "mnml": { + "value": null, + "timestamp": "2021-01-28T11:54:37.092Z" + }, + "mnpv": { + "value": null, + "timestamp": "2021-01-26T20:55:28.663Z" + }, + "mnos": { + "value": null, + "timestamp": "2021-01-26T20:55:28.411Z" + }, + "pi": { + "value": "shp", + "timestamp": "2019-08-10T18:37:20.457Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2019-08-10T18:37:20.534Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-04-06T17:30:05.372Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100103, + "timestamp": "2022-11-01T11:53:01.255Z" + } + }, + "refresh": {}, + "samsungce.washerOperatingState": { + "washerJobState": { + "value": "none", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-01T11:53:01.255Z" + }, + "scheduledJobs": { + "value": null + }, + "scheduledPhases": { + "value": null + }, + "progress": { + "value": null + }, + "remainingTimeStr": { + "value": "00:57", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "washerJobPhase": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": 57, + "unit": "min", + "timestamp": "2025-04-18T13:17:00.432Z" + } + }, + "execute": { + "data": { + "value": null, + "data": {}, + "timestamp": "2020-10-05T02:10:50.602Z" + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-04-18T14:14:00Z", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "washerJobState": { + "value": "none", + "timestamp": "2025-04-18T13:17:00.432Z" + }, + "supportedMachineStates": { + "value": null, + "timestamp": "2020-08-14T14:25:00.803Z" + } + }, + "switch": { + "switch": { + "value": null, + "timestamp": "2020-09-13T18:32:28.637Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json new file mode 100644 index 00000000000..fdda31783f6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json @@ -0,0 +1,81 @@ +{ + "components": { + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": null + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": null + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-03-10T00:57:26.866Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "offline", + "data": { + "reason": "DEVICE-OFFLINE" + }, + "timestamp": "2025-03-11T10:22:17.013Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": null + }, + "heatingSetpointRange": { + "value": null + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": null + }, + "supportedThermostatFanModes": { + "value": null + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": null + }, + "supportedThermostatModes": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/gas_meter.json b/tests/components/smartthings/fixtures/device_status/gas_meter.json new file mode 100644 index 00000000000..dc7f9b2e0c3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/gas_meter.json @@ -0,0 +1,61 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-02-27T14:06:11.704Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-04-11T13:00:00.444Z" + } + }, + "refresh": {}, + "gasMeter": { + "gasMeterPrecision": { + "value": { + "volume": 5, + "calorific": 1, + "conversion": 1 + }, + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeterCalorific": { + "value": 40, + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeterTime": { + "value": "2025-04-11T13:30:00.028Z", + "timestamp": "2025-04-11T13:30:00.532Z" + }, + "gasMeterVolume": { + "value": 14, + "unit": "ccf", + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeterConversion": { + "value": 3.6, + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeter": { + "value": 450.5, + "unit": "kWh", + "timestamp": "2025-04-11T13:00:00.444Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json b/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json new file mode 100644 index 00000000000..cbfdf0d9092 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json @@ -0,0 +1,76 @@ +{ + "components": { + "main02": { + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 200.0, + "unit": "C", + "timestamp": "2024-12-02T20:18:52.095Z" + } + } + }, + "main": { + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": null + } + }, + "signalStrength": { + "rssi": { + "value": -84, + "unit": "dBm", + "timestamp": "2025-03-07T20:53:55.346Z" + }, + "lqi": { + "value": 255, + "timestamp": "2025-03-07T20:53:55.387Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 21.0, + "unit": "C", + "timestamp": "2025-03-07T16:58:23.773Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 23.0, + "unit": "C", + "timestamp": "2025-02-10T17:48:38.299Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "refresh": {}, + "valleyboard16460.debug": { + "value": { + "value": "\n \n \n \n \n \n \n \n
Actual_TZE200_rxntag7i
Expected_TZE200_4hbx5cvx
Profilenormal-thermostat-v3
ModeSimilarity
PreferencesModified
Exposes EF00Yes
Default DPNo
", + "timestamp": "2025-03-05T03:04:54.025Z" + } + }, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": {}, + "timestamp": "2024-12-30T08:22:19.273Z" + }, + "supportedThermostatModes": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json b/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json new file mode 100644 index 00000000000..9335bd8e042 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json @@ -0,0 +1,19 @@ +{ + "components": { + "main": { + "refresh": {}, + "fanSpeed": { + "fanSpeed": { + "value": 0, + "timestamp": "2025-03-06T11:47:32.683Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-06T11:47:32.697Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json b/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json new file mode 100644 index 00000000000..591d1128ea0 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json @@ -0,0 +1,116 @@ +{ + "components": { + "button4": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-02-10T08:01:11.326Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.695Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.717Z" + } + } + }, + "button5": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-03-09T16:37:40.792Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.762Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.813Z" + } + } + }, + "button2": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-02-10T08:00:57.171Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.861Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.906Z" + } + } + }, + "button3": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-01-30T05:53:00.663Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.852Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.848Z" + } + } + }, + "button6": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2024-10-02T13:11:07.346Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.816Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.848Z" + } + } + }, + "main": { + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-03-10T10:32:19.528Z" + }, + "type": { + "value": null + } + } + }, + "button1": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-01-30T05:52:46.718Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.717Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.767Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json b/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json new file mode 100644 index 00000000000..c49cc55d2cb --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json @@ -0,0 +1,60 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 368.17, + "unit": "W", + "timestamp": "2025-03-07T12:52:08.997Z" + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "heating", + "timestamp": "2025-03-07T12:49:53.638Z" + } + }, + "energyMeter": { + "energy": { + "value": 2339.5, + "unit": "kWh", + "timestamp": "2025-03-07T12:26:37.133Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 19.0, + "unit": "C", + "timestamp": "2025-03-07T12:52:39.210Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 19.0, + "unit": "C", + "timestamp": "2025-03-06T21:38:22.856Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "heat"] + }, + "timestamp": "2025-03-06T21:38:23.046Z" + }, + "supportedThermostatModes": { + "value": ["off", "heat"], + "timestamp": "2023-09-22T15:41:01.268Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/hub.json b/tests/components/smartthings/fixtures/device_status/hub.json new file mode 100644 index 00000000000..98ff4c3a8b4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hub.json @@ -0,0 +1,3 @@ +{ + "components": {} +} diff --git a/tests/components/smartthings/fixtures/device_status/hw_q80r_soundbar.json b/tests/components/smartthings/fixtures/device_status/hw_q80r_soundbar.json new file mode 100644 index 00000000000..8cd0d3e35a9 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hw_q80r_soundbar.json @@ -0,0 +1,173 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-03-23T01:10:02.207Z" + }, + "playbackStatus": { + "value": "playing", + "timestamp": "2025-03-23T01:19:44.622Z" + } + }, + "samsungvd.groupInfo": { + "role": { + "value": "none", + "timestamp": "2025-03-23T01:17:10.965Z" + }, + "channel": { + "value": "all", + "timestamp": "2025-03-23T01:17:10.965Z" + }, + "masterName": { + "value": "", + "timestamp": "2025-03-23T01:17:10.965Z" + }, + "status": { + "value": "single", + "timestamp": "2025-03-23T01:17:10.965Z" + } + }, + "audioVolume": { + "volume": { + "value": 1, + "unit": "%", + "timestamp": "2025-03-23T01:17:13.754Z" + } + }, + "ocf": { + "st": { + "value": "NONE", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mndt": { + "value": "2018-01-01", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnfv": { + "value": "HW-Q80RWWB-1012.6", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnhw": { + "value": "0-0", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "di": { + "value": "afcf3b91-48fe-4c3b-ab44-ddff2a0a6577", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnsl": { + "value": "http://www.samsung.com/sec/audio-video/", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "n": { + "value": "[AV] Samsung Soundbar Q80R", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnmo": { + "value": "Q80R", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "vid": { + "value": "VD-NetworkAudio-001S", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnpv": { + "value": "Tizen 4.0", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnos": { + "value": "4.1.10", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "pi": { + "value": "afcf3b91-48fe-4c3b-ab44-ddff2a0a6577", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-18T21:07:25.406Z" + } + }, + "mediaInputSource": { + "supportedInputSources": { + "value": ["wifi", "bluetooth", "HDMI1", "HDMI2", "digital"], + "timestamp": "2025-03-23T01:18:01.663Z" + }, + "inputSource": { + "value": "wifi", + "timestamp": "2025-03-23T01:18:01.663Z" + } + }, + "refresh": {}, + "audioNotification": {}, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-03-23T01:17:11.024Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.networkaudio.soundmode"], + "if": ["oic.if.a", "oic.if.baseline"], + "x.com.samsung.networkaudio.soundmode": "standard" + } + }, + "data": { + "href": "/sec/networkaudio/soundmode" + }, + "timestamp": "2023-07-16T23:16:55.582Z" + } + }, + "samsungvd.audioInputSource": { + "supportedInputSources": { + "value": ["wifi", "bluetooth", "HDMI1", "HDMI2", "digital"], + "timestamp": "2025-03-23T01:18:01.663Z" + }, + "inputSource": { + "value": "wificp", + "timestamp": "2025-03-23T01:18:01.663Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-23T01:19:44.837Z" + } + }, + "audioTrackData": { + "totalTime": { + "value": null, + "timestamp": "2020-07-30T16:09:09.109Z" + }, + "audioTrackData": { + "value": { + "title": "Never Gonna Give You Up", + "artist": "Rick Astley" + }, + "timestamp": "2025-03-23T01:19:15.067Z" + }, + "elapsedTime": { + "value": null, + "timestamp": "2020-07-30T16:09:09.109Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json b/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json new file mode 100644 index 00000000000..56a2d9e762d --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json @@ -0,0 +1,68 @@ +{ + "components": { + "main": { + "windowShadeLevel": { + "shadeLevel": { + "value": 32, + "unit": "%", + "timestamp": "2025-03-13T10:40:25.613Z" + } + }, + "refresh": {}, + "windowShadePreset": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 37, + "unit": "%", + "timestamp": "2025-03-13T07:09:05.149Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "22007631", + "timestamp": "2025-03-12T20:35:04.576Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "updateRequested", + "timestamp": "2025-03-12T20:35:03.879Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-03-12T20:35:04.577Z" + }, + "currentVersion": { + "value": "22007631", + "timestamp": "2025-03-12T20:35:04.508Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "windowShade": { + "supportedWindowShadeCommands": { + "value": ["open", "close", "pause"], + "timestamp": "2025-03-13T10:33:48.402Z" + }, + "windowShade": { + "value": "partially open", + "timestamp": "2025-03-13T10:55:58.205Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/im_smarttag2_ble_uwb.json b/tests/components/smartthings/fixtures/device_status/im_smarttag2_ble_uwb.json new file mode 100644 index 00000000000..e59db7476de --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/im_smarttag2_ble_uwb.json @@ -0,0 +1,129 @@ +{ + "components": { + "main": { + "tag.e2eEncryption": { + "encryption": { + "value": null + } + }, + "audioVolume": { + "volume": { + "value": null + } + }, + "geofence": { + "enableState": { + "value": null + }, + "geofence": { + "value": null + }, + "name": { + "value": null + } + }, + "tag.updatedInfo": { + "connection": { + "value": "connected", + "timestamp": "2024-02-27T17:44:57.638Z" + } + }, + "tag.factoryReset": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": null + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": null + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": null + }, + "updateAvailable": { + "value": false, + "timestamp": "2024-06-25T05:56:22.227Z" + }, + "currentVersion": { + "value": null + }, + "lastUpdateTime": { + "value": null + } + }, + "tag.searchingStatus": { + "searchingStatus": { + "value": null + } + }, + "tag.tagStatus": { + "connectedUserId": { + "value": null + }, + "tagStatus": { + "value": null + }, + "connectedDeviceId": { + "value": null + } + }, + "alarm": { + "alarm": { + "value": null + } + }, + "tag.tagButton": { + "tagButton": { + "value": null + } + }, + "tag.uwbActivation": { + "uwbActivation": { + "value": null + } + }, + "geolocation": { + "method": { + "value": null + }, + "heading": { + "value": null + }, + "latitude": { + "value": null + }, + "accuracy": { + "value": null + }, + "altitudeAccuracy": { + "value": null + }, + "speed": { + "value": null + }, + "longitude": { + "value": null + }, + "lastUpdateTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json b/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json new file mode 100644 index 00000000000..4b23ca7086f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json @@ -0,0 +1,222 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-03-08T12:06:24.496Z" + }, + "playbackStatus": { + "value": "stopped", + "timestamp": "2025-03-08T12:06:24.496Z" + } + }, + "audioVolume": { + "volume": { + "value": 52, + "unit": "%", + "timestamp": "2025-03-08T12:08:00.153Z" + } + }, + "mediaInputSource": { + "supportedInputSources": { + "value": null + }, + "inputSource": { + "value": null + } + }, + "audioTrackAddressing": {}, + "samsungim.networkAudioGroupInfo": { + "groupName": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "role": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "channel": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "stereoType": { + "value": "A", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "masterDi": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "acmMode": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "status": { + "value": "single", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "masterName": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + } + }, + "refresh": {}, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungim.networkAudioMode": { + "mode": { + "value": "wifi", + "timestamp": "2025-03-08T12:06:24.573Z" + } + }, + "mediaPlaybackRepeat": { + "playbackRepeatMode": { + "value": "off", + "timestamp": "2025-03-08T12:06:24.519Z" + } + }, + "musicPlayer": { + "trackDescription": { + "value": null + }, + "level": { + "value": null + }, + "mute": { + "value": null + }, + "trackData": { + "value": null + }, + "status": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "V310XXU1AWK1", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "di": { + "value": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnsl": { + "value": null + }, + "dmv": { + "value": "IoTivity1.2.1", + "timestamp": "2025-03-08T12:06:18.942Z" + }, + "n": { + "value": "Galaxy Home Mini (MQVL)", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnmo": { + "value": "SM-V310", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "vid": { + "value": "IM-SPEAKER-AI-0001", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnml": { + "value": null + }, + "mnpv": { + "value": "4.0.0.1", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "pi": { + "value": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "icv": { + "value": "core0.0.1", + "timestamp": "2025-03-08T12:06:18.942Z" + } + }, + "samsungim.announcement": { + "enableState": { + "value": null + }, + "supportedCategories": { + "value": null + }, + "supportedTypes": { + "value": null + }, + "supportedMimes": { + "value": null + } + }, + "samsungim.bixbyContent": { + "supportedModes": { + "value": ["news", "weather", "music", "search_all"], + "timestamp": "2025-03-08T12:06:24.817Z" + } + }, + "mediaPlaybackShuffle": { + "playbackShuffle": { + "value": "disabled", + "timestamp": "2025-03-08T12:06:24.592Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-03-08T12:06:24.478Z" + } + }, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": null + } + }, + "speechSynthesis": {}, + "samsungim.networkAudioTrackData": { + "appName": { + "value": null + }, + "source": { + "value": "wifi", + "timestamp": "2025-03-08T12:06:24.540Z" + } + }, + "audioTrackData": { + "totalTime": { + "value": null + }, + "audioTrackData": { + "value": null + }, + "elapsedTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/lumi.json b/tests/components/smartthings/fixtures/device_status/lumi.json new file mode 100644 index 00000000000..dc01671f4d9 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/lumi.json @@ -0,0 +1,56 @@ +{ + "components": { + "main": { + "configuration": {}, + "relativeHumidityMeasurement": { + "humidity": { + "value": 27.24, + "unit": "%", + "timestamp": "2025-05-11T23:31:11.979Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": { + "minimum": -58.0, + "maximum": 482.0 + }, + "unit": "F", + "timestamp": "2025-05-07T14:34:47.868Z" + }, + "temperature": { + "value": 76.0, + "unit": "F", + "timestamp": "2025-05-11T23:31:11.904Z" + } + }, + "atmosphericPressureMeasurement": { + "atmosphericPressure": { + "value": 100, + "unit": "kPa", + "timestamp": "2025-05-11T23:31:11.979Z" + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-05-11T23:11:16.463Z" + }, + "type": { + "value": null + } + }, + "legendabsolute60149.atmosPressure": { + "atmosPressure": { + "value": 1004, + "unit": "mBar", + "timestamp": "2025-05-11T23:31:11.979Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/tplink_p110.json b/tests/components/smartthings/fixtures/device_status/tplink_p110.json new file mode 100644 index 00000000000..9e1d41ed66e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/tplink_p110.json @@ -0,0 +1,46 @@ +{ + "components": { + "main": { + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "start": "2025-03-10T14:43:42.500Z", + "end": "2025-03-10T14:59:42.500Z", + "energy": 15720, + "deltaEnergy": 0 + }, + "timestamp": "2025-03-10T14:59:50.010Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2024-03-07T21:14:59.839Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-03-10T14:14:37.232Z" + } + }, + "refresh": {}, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-10T14:14:37.232Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_network_audio_003s.json b/tests/components/smartthings/fixtures/device_status/vd_network_audio_003s.json new file mode 100644 index 00000000000..e635f6c793a --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_network_audio_003s.json @@ -0,0 +1,231 @@ +{ + "components": { + "main": { + "samsungvd.soundFrom": { + "mode": { + "value": 29, + "timestamp": "2025-04-05T13:51:47.865Z" + }, + "detailName": { + "value": "None", + "timestamp": "2025-04-05T13:51:50.230Z" + } + }, + "audioVolume": { + "volume": { + "value": 6, + "unit": "%", + "timestamp": "2025-04-17T11:17:25.272Z" + } + }, + "samsungvd.audioGroupInfo": { + "role": { + "value": null + }, + "channel": { + "value": null + }, + "status": { + "value": null + } + }, + "refresh": {}, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungvd.audioInputSource": { + "supportedInputSources": { + "value": ["D.IN", "BT", "WIFI"], + "timestamp": "2025-03-18T19:11:54.071Z" + }, + "inputSource": { + "value": "D.IN", + "timestamp": "2025-04-17T11:18:02.048Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-04-17T14:42:04.704Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-03-18T19:11:54.484Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-03-18T19:11:54.484Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G", "5G"], + "timestamp": "2025-03-18T19:11:54.484Z" + }, + "supportedAuthType": { + "value": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "EAP", + "SAE", + "OWE", + "FT-PSK" + ], + "timestamp": "2025-03-18T19:11:54.484Z" + }, + "protocolType": { + "value": ["ble_ocf"], + "timestamp": "2025-03-18T19:11:54.484Z" + } + }, + "ocf": { + "st": { + "value": "1970-01-01T00:00:47Z", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mndt": { + "value": "2024-01-01", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnfv": { + "value": "SAT-MT8532D24WWC-1016.0", + "timestamp": "2025-02-21T16:47:38.134Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "di": { + "value": "a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "n": { + "value": "Soundbar", + "timestamp": "2025-02-21T16:47:38.134Z" + }, + "mnmo": { + "value": "HW-S60D", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "vid": { + "value": "VD-NetworkAudio-003S", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnpv": { + "value": "8.0", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "pi": { + "value": "a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-21T15:09:52.348Z" + } + }, + "samsungvd.supportsFeatures": { + "mediaOutputSupported": { + "value": null + }, + "imeAdvSupported": { + "value": null + }, + "wifiUpdateSupport": { + "value": true, + "timestamp": "2025-03-18T19:11:53.853Z" + }, + "executableServiceList": { + "value": null + }, + "remotelessSupported": { + "value": null + }, + "artSupported": { + "value": null + }, + "mobileCamSupported": { + "value": null + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "endpoint": { + "value": "PIPER", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "301", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "tsId": { + "value": "VD02", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "mnId": { + "value": "0AJK", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-03-18T19:11:54.336Z" + } + }, + "audioMute": { + "mute": { + "value": "muted", + "timestamp": "2025-04-17T11:36:04.814Z" + } + }, + "samsungvd.thingStatus": { + "updatedTime": { + "value": 1744900925, + "timestamp": "2025-04-17T14:42:04.770Z" + }, + "status": { + "value": "Idle", + "timestamp": "2025-03-18T19:11:54.101Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_sensor_light_2023.json b/tests/components/smartthings/fixtures/device_status/vd_sensor_light_2023.json new file mode 100644 index 00000000000..cffefa20c4a --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_sensor_light_2023.json @@ -0,0 +1,95 @@ +{ + "components": { + "main": { + "ocf": { + "st": { + "value": "2025-01-14T08:07:36Z", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mndt": { + "value": "2023-01-01", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnfv": { + "value": "latest", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "di": { + "value": "5cc1c096-98b9-460c-8f1c-1045509ec605", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "n": { + "value": "Light Sensor - 55 The Frame", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnmo": { + "value": "QE55LS03DAUXXN", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "vid": { + "value": "VD-Sensor.Light-2023", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnpv": { + "value": "8.0", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "pi": { + "value": "5cc1c096-98b9-460c-8f1c-1045509ec605", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-14T08:07:40.220Z" + } + }, + "samsungvd.deviceCategory": { + "category": { + "value": null + } + }, + "relativeBrightness": { + "brightnessIntensity": { + "value": 2, + "unit": "level", + "timestamp": "2025-02-11T19:08:25.539Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/abl_light_b_001.json b/tests/components/smartthings/fixtures/devices/abl_light_b_001.json new file mode 100644 index 00000000000..bb4970b6d5a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/abl_light_b_001.json @@ -0,0 +1,59 @@ +{ + "items": [ + { + "deviceId": "7c16163e-c94e-482f-95f6-139ae0cd9d5e", + "name": "ABL Wafer Down Light(BLE)", + "label": "Kitchen Light 5", + "manufacturerName": "Samsung Electronics", + "presentationId": "ABL-LIGHT-B-001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "6c314222-8baf-48a0-9442-5b1102a8757f", + "ownerId": "f24ff388-700c-7d1e-91f2-1c37ae68ce2b", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-08T22:40:25.073Z", + "profile": { + "id": "65f5db53-9a78-4b19-8e40-d32187cd59ab" + }, + "bleD2D": { + "encryptionKey": "f593369dcea915f6352a4a42cd4b2ea6", + "cipher": "AES_128-CBC-PKCS7Padding", + "advertisingId": "b13d7192", + "identifier": "88-57-1d-7c-cb-cf", + "configurationUrl": "https://apis.samsungiotcloud.com/v1/miniature/profile/65f5db53-9a78-4b19-8e40-d32187cd59ab", + "bleDeviceType": "BLE", + "metadata": null + }, + "type": "BLE_D2D", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json index 5ef0e2fd9eb..ab2fe41c678 100644 --- a/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json +++ b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json @@ -45,7 +45,6 @@ } ], "createTime": "2023-01-12T23:02:44.917Z", - "parentDeviceId": "6a2d07a4-dd77-48bc-9acf-017029aaf099", "profile": { "id": "6372c227-93c7-32ef-9be5-aef2221adff1" }, diff --git a/tests/components/smartthings/fixtures/devices/aux_ac.json b/tests/components/smartthings/fixtures/devices/aux_ac.json new file mode 100644 index 00000000000..fcdb581748c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/aux_ac.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "bf53a150-f8a4-45d1-aac4-86252475d551", + "name": "vedgeaircon.v1", + "label": "AUX A/C on-off", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "ab252042-5669-3c2c-8b1b-d606bbcc9e04", + "deviceManufacturerCode": "SmartThings Community", + "locationId": "5db1e3d8-ea26-44b4-8ed0-1ba9c841fd57", + "ownerId": "5404aa57-6a68-4fe2-83ff-168ef769d1c7", + "roomId": "564cdd9a-fa9f-4187-902f-95656ef22989", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "partyvoice23922.vtempset", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-06-19T20:18:45.407Z", + "parentDeviceId": "e699599d-30f8-4cf0-8de7-6dbdba6a665f", + "profile": { + "id": "87f0ac35-e024-3c0a-8153-78ca27a6fe0c" + }, + "lan": { + "networkId": "vEdge_A/C_1718828324.999", + "driverId": "0fd9a9a4-8863-4a83-97a7-5a288ff0f5a6", + "executingLocally": true, + "hubId": "e699599d-30f8-4cf0-8de7-6dbdba6a665f", + "provisioningState": "TYPED" + }, + "type": "LAN", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [130.0, 36.0, 378.0], + "rotation": [270.0, 0.0, 0.0], + "visible": true, + "data": null + }, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/base_electric_meter.json b/tests/components/smartthings/fixtures/devices/base_electric_meter.json index 9e0c130978c..a81ca788b29 100644 --- a/tests/components/smartthings/fixtures/devices/base_electric_meter.json +++ b/tests/components/smartthings/fixtures/devices/base_electric_meter.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "0086-0002-0009", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", @@ -37,7 +37,6 @@ } ], "createTime": "2023-06-03T16:23:57.284Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "d382796f-8ed5-3088-8735-eb03e962203b" }, diff --git a/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json b/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json new file mode 100644 index 00000000000..7a2e2d338cd --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json @@ -0,0 +1,102 @@ +{ + "items": [ + { + "deviceId": "286ba274-4093-4bcb-849c-a1a3efe7b1e5", + "name": "thermostat", + "label": "Radiator Thermostat II [+M] Wohnzimmer", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "2a1c9915-f61b-3f3a-a02b-703b8cccf3d6", + "deviceManufacturerCode": "BOSCH", + "locationId": "0b6618a6-c3ab-4b6e-968d-59cc8c2761bc", + "ownerId": "8a20b799-9d87-ecdc-39de-c93c6e4d3ea1", + "roomId": "11374ab5-9b4e-416b-91d1-745bbf9b6db4", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-29T19:55:00.910Z", + "parentDeviceId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "profile": { + "id": "4da5d086-111e-3084-a039-616974326833" + }, + "matter": { + "driverId": "5f3c42eb-5704-4c95-9705-c51c1a6764bf", + "hubId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "provisioningState": "PROVISIONED", + "networkId": "8EF2CF7A212285B2-46C6B9F266A4521A", + "executingLocally": true, + "uniqueId": "8475B3FEFF6748D4", + "vendorId": 4617, + "productId": 12306, + "serialNumber": "D44867FFFEB37584", + "listeningType": "SLEEPY", + "supportedNetworkInterfaces": ["THREAD"], + "version": { + "hardware": 18, + "hardwareLabel": "1.2.0", + "software": 20009, + "softwareLabel": "2.00.09" + }, + "endpoints": [ + { + "endpointId": 0, + "deviceTypes": [ + { + "deviceTypeId": 22 + } + ] + }, + { + "endpointId": 1, + "deviceTypes": [ + { + "deviceTypeId": 769 + } + ] + } + ], + "syncDrivers": true + }, + "type": "MATTER", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/button.json b/tests/components/smartthings/fixtures/devices/button.json new file mode 100644 index 00000000000..ba993ca6aa7 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/button.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "deviceId": "c4bdd19f-85d1-4d58-8f9c-e75ac3cf113b", + "name": "button", + "label": "button", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "238c483a-10e8-359b-b032-1be2b2fcdee7", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "button", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-07T12:20:43.273Z", + "profile": { + "id": "b045d731-4d01-35bc-8018-b3da711d8904" + }, + "virtual": { + "name": "button", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json index a9e3bddb2ca..21d4d475e7a 100644 --- a/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json +++ b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Arlo", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/centralite.json b/tests/components/smartthings/fixtures/devices/centralite.json index 68cdbdf4499..d94043efbc8 100644 --- a/tests/components/smartthings/fixtures/devices/centralite.json +++ b/tests/components/smartthings/fixtures/devices/centralite.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "CentraLite", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", @@ -45,7 +45,6 @@ } ], "createTime": "2024-08-15T22:16:37.926Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "24195ea4-635c-3450-a235-71bc78ab3d1c" }, diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json index a5de2e2cbfe..9823a70cb61 100644 --- a/tests/components/smartthings/fixtures/devices/contact_sensor.json +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Visonic", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", @@ -42,14 +42,13 @@ "categoryType": "manufacturer" }, { - "name": "ContactSensor", + "name": "GarageDoor", "categoryType": "user" } ] } ], "createTime": "2023-09-28T17:38:59.179Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "22aa5a07-ac33-365f-b2f1-5ecef8cdb0eb" }, diff --git a/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json new file mode 100644 index 00000000000..c8304e9c6d8 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json @@ -0,0 +1,145 @@ +{ + "items": [ + { + "deviceId": "a3a970ea-e09c-9c04-161b-94c934e21666", + "name": "Samsung AirMonitor", + "label": "\uc5d0\uc5b4\ubaa8\ub2c8\ud130 \ud50c\ub7ec\uc2a4", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-AIRSENSOR-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "33db9e71-abe9-43a0-acd3-3f0927bbe5b7", + "ownerId": "9a1ee192-04ba-46ca-9c3d-196d8dbcf807", + "roomId": "445c006d-1796-4dd6-8308-1c3cd045e8ff", + "deviceTypeName": "x.com.st.d.airqualitysensor", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "dustHealthConcern", + "version": 1 + }, + { + "id": "fineDustHealthConcern", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "veryFineDustHealthConcern", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "carbonDioxideMeasurement", + "version": 1 + }, + { + "id": "carbonDioxideHealthConcern", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.rechargeableBattery", + "version": 1 + }, + { + "id": "samsungce.doNotDisturb", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "AirQualityDetector", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-09T04:05:59.040Z", + "profile": { + "id": "1d34dd9d-6840-3df6-a6d0-5d9f4a4af2e1" + }, + "ocf": { + "ocfDeviceType": "x.com.st.d.airqualitysensor", + "name": "Samsung AirMonitor", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 4.0", + "hwVersion": "Realtek", + "firmwareVersion": "ASM-KR-TP1-22-ACMB1M_16240426", + "vendorId": "DA-AC-AIRSENSOR-01001", + "vendorResourceClientServerVersion": "MediaTek Release 240426", + "lastSignupTime": "2023-12-09T04:05:54.816486Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_ehs_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_ehs_01001.json new file mode 100644 index 00000000000..61313aac1ca --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_ehs_01001.json @@ -0,0 +1,229 @@ +{ + "items": [ + { + "deviceId": "4165c51e-bf6b-c5b6-fd53-127d6248754b", + "name": "Samsung EHS", + "label": "Heat pump", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-EHS-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "23dad822-0b66-4821-af2d-79ef502f5231", + "ownerId": "9dd8c4fa-c07c-f66d-ccdb-20eca3411b12", + "roomId": "a2d70c20-12aa-48bc-958b-3d47c9b6cffc", + "deviceTypeName": "oic.d.thermostat", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.outingMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.alwaysOnSensing", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.sensingOnSuspendMode", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.ehsFsvSettings", + "version": 1 + }, + { + "id": "samsungce.ehsCycleData", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.individualControlLock", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "INDOOR1", + "label": "INDOOR1", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-04-13T13:00:48.941Z", + "profile": { + "id": "e6f1cf68-e4bf-3e35-9f17-288a4e5ee0cb" + }, + "ocf": { + "ocfDeviceType": "oic.d.thermostat", + "name": "Samsung EHS", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_DA_AC_EHS_01001_0000|10250141|60070110001711034A00010000002000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "AEH-WW-TP1-22-AE6000_17240903", + "vendorId": "DA-AC-EHS-01001", + "vendorResourceClientServerVersion": "Realtek Release 3.1.240221", + "lastSignupTime": "2025-04-13T13:00:48.876846635Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [0.0, 0.0, 0.0], + "rotation": [0.0, 0.0, 0.0], + "visible": false, + "data": null + }, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json index ec7f16b090a..cc4e13784bf 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "58d3fd7c-c512-4da3-b500-ef269382756c", "ownerId": "f9a28d7c-1ed5-d9e9-a81c-18971ec081db", - "roomId": "85a79db4-9cf2-4f09-a5b2-cd70a5c0cef0", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Air Conditioner", "components": [ { @@ -286,18 +286,10 @@ "id": "60fbc713-8da5-315d-b31a-6d6dcde4be7b" }, "ocf": { - "ocfDeviceType": "oic.d.airconditioner", - "name": "[room a/c] Samsung", - "specVersion": "core.1.1.0", - "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "ocfDeviceType": "x.com.st.d.sensor.light", "manufacturerName": "Samsung Electronics", - "modelNumber": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", - "platformVersion": "0G3MPDCKA00010E", - "platformOS": "TizenRT2.0", - "hwVersion": "1.0", - "firmwareVersion": "0.1.0", - "vendorId": "DA-AC-RAC-000001", - "lastSignupTime": "2021-04-06T16:43:27.889445Z", + "vendorId": "VD-Sensor.Light-2023", + "lastSignupTime": "2025-01-08T02:32:04.631093137Z", "transferCandidate": false, "additionalAuthCodeRequired": false }, diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json new file mode 100644 index 00000000000..44dafc213f0 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json @@ -0,0 +1,217 @@ +{ + "items": [ + { + "deviceId": "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "name": "Samsung Room A/C", + "label": "Office AirFree", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-000003", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "403cd42e-f692-416c-91fd-1883c00e3262", + "ownerId": "dd474e5c-59c0-4bea-a319-ff5287fd3373", + "roomId": "dffe353e-b3c5-4a97-8a8a-797ccc649fab", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.veryFineDustFilter", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.electricHepaFilter", + "version": 1 + }, + { + "id": "custom.doNotDisturbMode", + "version": 1 + }, + { + "id": "custom.periodicSensing", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.ocfResourceVersion", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dustFilterAlarm", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.individualControlLock", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-06-21T13:45:16.238Z", + "profile": { + "id": "cedae6e3-1ec9-37e3-9aba-f717518156b8" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Samsung Room A/C", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ARTIK051_PRAC_20K|10256941|60010534001411014600003200800000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "ARTIK051_PRAC_20K_11230313", + "vendorId": "DA-AC-RAC-000003", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.211222.1", + "lastSignupTime": "2024-06-21T13:45:08.592221Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json index 8d9ebde5bcd..db6f8d09673 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", - "roomId": "1f66199a-1773-4d8f-97b7-44c312a62cf7", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Air Conditioner", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json new file mode 100644 index 00000000000..3938ffc9d9b --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json @@ -0,0 +1,112 @@ +{ + "items": [ + { + "deviceId": "F8042E25-0E53-0000-0000-000000000000", + "name": "Room A/C", + "label": "Corridor A/C", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-100001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "5df0730b-38ed-43e4-b291-ec14feb3224c", + "ownerId": "63b9c79b-90fe-5262-9a6a-5e24db90915e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.driverState", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-25T22:17:38.129Z", + "profile": { + "id": "9e3e03b1-7f8c-3ea2-8568-6902b79b99dd" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Room A/C", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP6X_RAC_15K", + "vendorId": "DA-AC-RAC-100001", + "lastSignupTime": "2024-11-25T22:17:37.928118320Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_cooktop_31001.json b/tests/components/smartthings/fixtures/devices/da_ks_cooktop_31001.json new file mode 100644 index 00000000000..433e45dae7a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_cooktop_31001.json @@ -0,0 +1,277 @@ +{ + "items": [ + { + "deviceId": "808dbd84-f357-47e2-a0cd-3b66fa22d584", + "name": "Builtin Cooktop", + "label": "Induction Hob", + "manufacturerName": "0A4H", + "presentationId": "DA-KS-COOKTOP-31001", + "deviceManufacturerCode": "0A4H", + "locationId": "7d27161a-0ef6-4294-91a0-80054ea5bc59", + "ownerId": "d52fb883-0f76-f4d9-0f6a-7ec2c0987b11", + "roomId": "afe14ff1-d444-420d-a766-4dd52f3e1c71", + "deviceTypeId": "Cooktop", + "deviceTypeName": "Samsung Cooktop", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.userNotification", + "version": 1 + }, + { + "id": "custom.cooktopOperatingState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.errorAndAlarmState", + "version": 1 + }, + { + "id": "samsungce.remoteManagementData", + "version": 1 + }, + { + "id": "samsungce.kidsLockControl", + "version": 1 + }, + { + "id": "samsungce.cooktopFlexZone", + "version": 1 + } + ], + "categories": [ + { + "name": "Cooktop", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-01", + "label": "burner-01", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-02", + "label": "burner-02", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-03", + "label": "burner-03", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-04", + "label": "burner-04", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-05", + "label": "burner-05", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-06", + "label": "burner-06", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hood", + "label": "hood", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "samsungce.hoodFanSpeed", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-25T18:18:23.576Z", + "profile": { + "id": "a99bbcb8-51c9-468d-b9d5-0ce6dca09d5a" + }, + "mqtt": { + "executingLocally": false, + "transferCandidate": false + }, + "type": "MQTT", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json index f6599fee461..f636b069e38 100644 --- a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json +++ b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "oic.d.microwave", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json b/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json new file mode 100644 index 00000000000..e82e28d2275 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json @@ -0,0 +1,153 @@ +{ + "items": [ + { + "deviceId": "9447959a-0dfa-6b27-d40d-650da525c53f", + "name": "[oven] Samsung", + "label": "Oven", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-OVEN-01061", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "a81dc8da-5a3f-43b6-8c8a-1309f37eeeb9", + "ownerId": "97ee2149-9de0-3287-8245-24d6fd1609aa", + "roomId": "eb2167dd-8b8d-4131-b59e-5dd391b2e151", + "deviceTypeName": "Samsung OCF Oven", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.definedRecipe", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.microwavePower", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.waterReservoir", + "version": 1 + }, + { + "id": "samsungce.ovenDrainageRequirement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Oven", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-08T17:29:12.549Z", + "profile": { + "id": "eb34598f-f96a-3420-a90a-71693052eaa3" + }, + "ocf": { + "ocfDeviceType": "oic.d.oven", + "name": "[oven] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_DA-KS-OVEN-01061|40457041|50030018001611000A00000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "AKS-WW-TP1X-21-OVEN_40211229", + "vendorId": "DA-KS-OVEN-01061", + "vendorResourceClientServerVersion": "Realtek Release 3.1.211122", + "lastSignupTime": "2025-01-08T17:29:08.536664213Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json new file mode 100644 index 00000000000..e918e2d77ca --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json @@ -0,0 +1,197 @@ +{ + "items": [ + { + "deviceId": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "name": "Samsung Range", + "label": "Vulcan", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-RANGE-0101X", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "597a4912-13c9-47ab-9956-7ebc38b61abd", + "ownerId": "c4478c70-9014-e5c9-993c-f62707fa1e61", + "roomId": "fc407cd9-3b32-4fc0-bf23-e0d4995101e9", + "deviceTypeName": "Samsung OCF Range", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.customRecipe", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.meatProbe", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.cooktopOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Range", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cavity-01", + "label": "cavity-01", + "capabilities": [ + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "custom.ovenCavityStatus", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-02-21T22:37:01.648Z", + "profile": { + "id": "8e479dd0-9719-337a-9fbe-2c4572f95c71" + }, + "ocf": { + "ocfDeviceType": "oic.d.range", + "name": "Samsung Range", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E031511010200000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "AKS-WW-TP1-20-OVEN-3-CR_40240205", + "vendorId": "DA-KS-RANGE-0101X", + "vendorResourceClientServerVersion": "Realtek Release 3.1.220727", + "lastSignupTime": "2023-11-28T22:49:01.876575Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json index 67afc0ad32c..29372cac23c 100644 --- a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "3a1f7e7c-4e59-4c29-adb0-0813be691efd", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Refrigerator", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_01001.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_01001.json new file mode 100644 index 00000000000..ade24657f26 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_01001.json @@ -0,0 +1,433 @@ +{ + "items": [ + { + "deviceId": "7d3feb98-8a36-4351-c362-5e21ad3a78dd", + "name": "Family Hub", + "label": "Refrigerator", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "2487472a-06c4-4bce-8f4c-700c5f8644f8", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "acaa060a-7c19-4579-8a4a-5ad891a2f0c1", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.fridgeFoodList", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.viewInside", + "version": 1 + }, + { + "id": "samsungce.runestoneHomeContext", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-03", + "label": "icemaker-03", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "scale-10", + "label": "scale-10", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "samsungce.weightMeasurementCalibration", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "samsungce.scaleSettings", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "scale-11", + "label": "scale-11", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "camera-01", + "label": "camera-01", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-07-27T01:19:42.051Z", + "profile": { + "id": "4c654f1b-8ef4-35b0-920e-c12568554213" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "Family Hub", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "24K_REF_LCD_FHUB9.0|00113141|0002034e051324200103000000000000", + "platformVersion": "7.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20240616.213423", + "vendorId": "DA-REF-NORMAL-01001", + "vendorResourceClientServerVersion": "4.0.22", + "locale": "", + "lastSignupTime": "2021-07-27T01:19:40.244392Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json new file mode 100644 index 00000000000..9be5db0bda9 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json @@ -0,0 +1,521 @@ +{ + "items": [ + { + "deviceId": "5758b2ec-563e-f39b-ec39-208e54aabf60", + "name": "Samsung-Refrigerator", + "label": "Frigo", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-01011", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "d91ee683-be36-4124-9200-c0030253fbc2", + "ownerId": "60b5179d-607f-f754-a648-6e1e21aeeb31", + "roomId": "c4f98377-534d-422f-b061-a4f3e281ddf5", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.viewInside", + "version": 1 + }, + { + "id": "samsungce.fridgeWelcomeLighting", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "onedoor", + "label": "onedoor", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.temperatureSetting", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-03", + "label": "icemaker-03", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "scale-10", + "label": "scale-10", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "samsungce.weightMeasurementCalibration", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "scale-11", + "label": "scale-11", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-02", + "label": "pantry-02", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-12-01T18:22:14.880Z", + "profile": { + "id": "37c7b355-bdaa-371b-b246-dbdf2a7f9c84" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "Samsung-Refrigerator", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_REF_21K|00156941|00050126001611304100000030010000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "A-RFWW-TP1-22-REV1_20241030", + "vendorId": "DA-REF-NORMAL-01011", + "vendorResourceClientServerVersion": "Realtek Release 3.1.240221", + "lastSignupTime": "2024-12-01T18:22:14.807976528Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json index b355eedb17a..b7f8ab2a42c 100644 --- a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "5d425f41-042a-4d9a-92c4-e43150a61bae", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Robot Vacuum", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json new file mode 100644 index 00000000000..25dff2ab2ac --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json @@ -0,0 +1,237 @@ +{ + "items": [ + { + "deviceId": "1f98ebd0-ac48-d802-7f62-000001200100", + "name": "Eco Heating System", + "label": "Eco Heating System", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-SAC-EHS-000001-SUB", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "d22d6401-6070-4928-8e7b-b724e2dbf425", + "ownerId": "35445a41-3ae2-4bc0-6f51-31705de6b96f", + "roomId": "169ef666-a51d-4d74-9b45-e660ecd4a8d7", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.outingMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.sensingOnSuspendMode", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.ehsBoosterHeater", + "version": 1 + }, + { + "id": "samsungce.ehsDiverterValve", + "version": 1 + }, + { + "id": "samsungce.ehsFsvSettings", + "version": 1 + }, + { + "id": "samsungce.ehsCycleData", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "INDOOR", + "label": "INDOOR", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.ehsDefrostMode", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2023-08-02T14:32:26.006Z", + "parentDeviceId": "1f98ebd0-ac48-d802-7f62-12592d8286b7", + "profile": { + "id": "89782721-6841-3ef6-a699-28e069d28b8b" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Eco Heating System", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "SAC_EHS_MONO|220614|61007400001600000400000000000000", + "platformVersion": "4.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20250317.1", + "vendorId": "DA-SAC-EHS-000001-SUB", + "vendorResourceClientServerVersion": "4.0.54", + "lastSignupTime": "2023-08-02T14:32:25.282882Z", + "transferCandidate": true, + "additionalAuthCodeRequired": false, + "modelCode": "" + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub_1.json b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub_1.json new file mode 100644 index 00000000000..fd1dd902b1e --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub_1.json @@ -0,0 +1,237 @@ +{ + "items": [ + { + "deviceId": "6a7d5349-0a66-0277-058d-000001200101", + "name": "Heat Pump", + "label": "Heat Pump Main", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-SAC-EHS-000001-SUB", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c411c5a8-ace8-4fa8-bb60-91525ac83273", + "ownerId": "d1da8ead-6b9d-64a2-ca29-2a25e4c259ca", + "roomId": "e6fa0aa4-08e7-45f7-8ec7-35c9c60908f9", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.outingMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.sensingOnSuspendMode", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.ehsBoosterHeater", + "version": 1 + }, + { + "id": "samsungce.ehsDiverterValve", + "version": 1 + }, + { + "id": "samsungce.ehsFsvSettings", + "version": 1 + }, + { + "id": "samsungce.ehsCycleData", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "INDOOR", + "label": "INDOOR", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.ehsDefrostMode", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-04-14T15:04:59.106Z", + "parentDeviceId": "6a7d5349-0a66-0277-058d-7c8a76501360", + "profile": { + "id": "89782721-6841-3ef6-a699-28e069d28b8b" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Heat Pump", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "SAC_EHS_MONO|231215|61007400001700000400000000000000", + "platformVersion": "4.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20250317.1", + "vendorId": "DA-SAC-EHS-000001-SUB", + "vendorResourceClientServerVersion": "4.0.54", + "lastSignupTime": "2025-04-14T15:04:58.476041486Z", + "transferCandidate": true, + "additionalAuthCodeRequired": false, + "modelCode": "" + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_sac_ehs_000002_sub.json b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000002_sub.json new file mode 100644 index 00000000000..9722c860519 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000002_sub.json @@ -0,0 +1,308 @@ +{ + "items": [ + { + "deviceId": "3810e5ad-5351-d9f9-12ff-000001200000", + "name": "Eco Heating System", + "label": "W\u00e4rmepumpe", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-SAC-EHS-000002-SUB", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "705633c1-64a2-4d54-9205-bbbd4f843d95", + "ownerId": "312d0773-efec-21c8-279f-5b8724f3ae57", + "roomId": "f9fef09a-b829-4eda-897b-dbaf6eebcac3", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.outingMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.sensingOnSuspendMode", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.ehsBoosterHeater", + "version": 1 + }, + { + "id": "samsungce.ehsDiverterValve", + "version": 1 + }, + { + "id": "samsungce.ehsFsvSettings", + "version": 1 + }, + { + "id": "samsungce.ehsCycleData", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "INDOOR1", + "label": "INDOOR1", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.ehsDefrostMode", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "INDOOR2", + "label": "INDOOR2", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.ehsDefrostMode", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + }, + { + "id": "samsungce.systemAirConditionerReservation", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2023-10-05T18:12:48.587Z", + "parentDeviceId": "3810e5ad-5351-d9f9-12ff-ed7c35d51a0c", + "profile": { + "id": "5dd2a4b2-981d-3571-96bb-eef6dc19d036" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Eco Heating System", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "SAC_EHS_SPLIT|220614|61007300001600000400000000000000", + "platformVersion": "4.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20250317.1", + "vendorId": "DA-SAC-EHS-000002-SUB", + "vendorResourceClientServerVersion": "4.0.54", + "lastSignupTime": "2023-10-05T18:12:47.561228Z", + "transferCandidate": true, + "additionalAuthCodeRequired": false, + "modelCode": "" + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [142.0, 36.0, 22.0], + "rotation": [270.0, 0.0, 0.0], + "visible": true, + "data": null + }, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json index 1c7024e153f..33392081bf5 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Dishwasher", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_sc_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_sc_000001.json new file mode 100644 index 00000000000..8b501cba9b7 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_sc_000001.json @@ -0,0 +1,172 @@ +{ + "items": [ + { + "deviceId": "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "name": "[airdresser] Samsung", + "label": "AirDresser", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-SC-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "df59873c-4e2c-43ba-bcd4-ade4efb0504a", + "ownerId": "71254e90-c144-45b6-aabe-709f78f48376", + "roomId": "4c9052ba-4430-4cb1-a788-f1e4449c43c9", + "deviceTypeName": "Samsung OCF Steam Closet", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "dryerOperatingState", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "custom.steamClosetOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.steamClosetWrinklePrevent", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.veryFineDustFilter", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.steamClosetDelayEnd", + "version": 1 + }, + { + "id": "samsungce.steamClosetKeepFreshMode", + "version": 1 + }, + { + "id": "samsungce.steamClosetSanitizeMode", + "version": 1 + }, + { + "id": "samsungce.steamClosetAutoCycleLink", + "version": 1 + }, + { + "id": "samsungce.steamClosetCycle", + "version": 1 + }, + { + "id": "samsungce.steamClosetCyclePreset", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "ClothingCareMachine", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-06-23T16:00:40.545Z", + "profile": { + "id": "a3623498-4747-3761-bac1-ba13f437d8ea" + }, + "ocf": { + "ocfDeviceType": "x.com.st.d.steamcloset", + "name": "[airdresser] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 2.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "DA_DF_TP2_20_COMMON_30230807", + "vendorId": "DA-WM-SC-000001", + "vendorResourceClientServerVersion": "MediaTek Release 2.211214.1", + "lastSignupTime": "2023-06-23T16:00:36.793123Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json index b9a650718e2..ef47260a989 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Dryer", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json new file mode 100644 index 00000000000..995646438c4 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json @@ -0,0 +1,205 @@ +{ + "items": [ + { + "deviceId": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "name": "[dryer] Samsung", + "label": "Seca-Roupa", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WD-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "06efa178-ad2f-4d22-838c-d63e05e5a58a", + "ownerId": "1a5f5619-e9ec-4302-beb9-633bb1657897", + "roomId": "dde24053-9707-49a5-ba0e-f19681514f37", + "deviceTypeName": "Samsung OCF Dryer", + "components": [ + { + "id": "main", + "label": "Seca-Roupa", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "dryerOperatingState", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.dryerWrinklePrevent", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dryerAutoCycleLink", + "version": 1 + }, + { + "id": "samsungce.dryerCycle", + "version": 1 + }, + { + "id": "samsungce.dryerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.dryerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTemperature", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTime", + "version": 1 + }, + { + "id": "samsungce.dryerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.dryerOperatingState", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Dryer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.dryerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-06-20T10:00:42Z", + "profile": { + "id": "53a1d049-eeda-396c-8324-e33438ef57be" + }, + "ocf": { + "ocfDeviceType": "oic.d.dryer", + "name": "[dryer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_WM_A51_20_COMMON_30230708", + "vendorId": "DA-WM-WD-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2020-11-19T04:43:50.736Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json index 852a2afa932..4996eebab96 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Washer", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json new file mode 100644 index 00000000000..bb1831d6f03 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json @@ -0,0 +1,261 @@ +{ + "items": [ + { + "deviceId": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "name": "[washer] Samsung", + "label": "Washing Machine", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WM-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "ca23214d-d9ae-41e5-9d26-f1a604c864d8", + "ownerId": "9b53a4ba-4422-b04d-f436-33c0490e7c37", + "roomId": "e226f1ae-1112-4794-bd3a-0beddf811645", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "Washing Machine", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "washerOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.washerAutoDetergent", + "version": 1 + }, + { + "id": "custom.washerAutoSoftener", + "version": 1 + }, + { + "id": "custom.washerRinseCycles", + "version": 1 + }, + { + "id": "custom.washerSoilLevel", + "version": 1 + }, + { + "id": "custom.washerSpinLevel", + "version": 1 + }, + { + "id": "custom.washerWaterTemperature", + "version": 1 + }, + { + "id": "samsungce.autoDispenseDetergent", + "version": 1 + }, + { + "id": "samsungce.autoDispenseSoftener", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.detergentAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.softenerAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softenerOrder", + "version": 1 + }, + { + "id": "samsungce.softenerState", + "version": 1 + }, + { + "id": "samsungce.washerBubbleSoak", + "version": 1 + }, + { + "id": "samsungce.washerCycle", + "version": 1 + }, + { + "id": "samsungce.washerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.washerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.washerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.washerOperatingState", + "version": 1 + }, + { + "id": "samsungce.washerWashingTime", + "version": 1 + }, + { + "id": "samsungce.washerWaterLevel", + "version": 1 + }, + { + "id": "samsungce.washerWaterValve", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Washer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.washerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-03-04T03:03:19Z", + "profile": { + "id": "3f221c79-d81c-315f-8e8b-b5742802a1e3" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "[washer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_WM_A51_20_COMMON_30230708", + "vendorId": "DA-WM-WM-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2024-12-27T04:47:59.763899737Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_01011.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_01011.json new file mode 100644 index 00000000000..0099d937b0e --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_01011.json @@ -0,0 +1,296 @@ +{ + "items": [ + { + "deviceId": "b854ca5f-dc54-140d-6349-758b4d973c41", + "name": "[washer] Samsung", + "label": "Machine \u00e0 Laver", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WM-01011", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "28a81a30-8fe2-4b9c-ab6b-5bccb73bce02", + "ownerId": "4c4ceeed-d4eb-01fd-6099-53ec206b5fd5", + "roomId": "fdb09f2a-38b5-4fb8-8d65-aee55e343948", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "logTrigger", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "washerOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.dryerWrinklePrevent", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.washerAutoDetergent", + "version": 1 + }, + { + "id": "custom.washerAutoSoftener", + "version": 1 + }, + { + "id": "custom.washerRinseCycles", + "version": 1 + }, + { + "id": "custom.washerSoilLevel", + "version": 1 + }, + { + "id": "custom.washerSpinLevel", + "version": 1 + }, + { + "id": "custom.washerWaterTemperature", + "version": 1 + }, + { + "id": "samsungce.audioVolumeLevel", + "version": 1 + }, + { + "id": "samsungce.autoDispenseDetergent", + "version": 1 + }, + { + "id": "samsungce.autoDispenseSoftener", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTime", + "version": 1 + }, + { + "id": "samsungce.detergentAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.softenerAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.flexibleAutoDispenseDetergent", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softenerOrder", + "version": 1 + }, + { + "id": "samsungce.softenerState", + "version": 1 + }, + { + "id": "samsungce.washerBubbleSoak", + "version": 1 + }, + { + "id": "samsungce.washerCycle", + "version": 1 + }, + { + "id": "samsungce.washerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.washerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.washerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.washerLabelScanCyclePreset", + "version": 1 + }, + { + "id": "samsungce.washerOperatingState", + "version": 1 + }, + { + "id": "samsungce.washerWashingTime", + "version": 1 + }, + { + "id": "samsungce.washerWaterLevel", + "version": 1 + }, + { + "id": "samsungce.washerWaterValve", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.clothingExtraCare", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "sec.smartthingsHub", + "version": 1, + "ephemeral": true + } + ], + "categories": [ + { + "name": "Washer", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.washerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-04-25T07:40:06.100Z", + "profile": { + "id": "76a4a88a-f715-34f8-961a-b31e4faccfda" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "[washer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_TP1_21_COMMON|20374641|20010002001811364AA30277008E0000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "DA_WM_TP1_21_COMMON_30240927", + "vendorId": "DA-WM-WM-01011", + "vendorResourceClientServerVersion": "Realtek Release 3.1.240801", + "lastSignupTime": "2025-04-25T07:40:05.863149341Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_100001.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_100001.json new file mode 100644 index 00000000000..c1a4cd12578 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_100001.json @@ -0,0 +1,84 @@ +{ + "items": [ + { + "deviceId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "Washer", + "label": "Washer", + "manufacturerName": "Samsung Electronics", + "presentationId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "ownerId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "Washer", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "washerOperatingState", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.washerOperatingState", + "version": 1 + } + ], + "categories": [ + { + "name": "Washer", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2019-08-10T18:37:20Z", + "profile": { + "id": "REDACTED" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "Washer", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP6X_WA54M8750AV|20183944|20000101001111000100000000000000", + "vendorId": "DA-WM-WM-100001", + "lastSignupTime": "2021-01-16T06:29:39.379382Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json b/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json new file mode 100644 index 00000000000..5fe8d8d28be --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json @@ -0,0 +1,82 @@ +{ + "items": [ + { + "deviceId": "1888b38f-6246-4f1e-911b-bfcfb66999db", + "name": "v4 - ecobee Thermostat - Heat and Cool (F)", + "label": "Downstairs", + "manufacturerName": "0A0b", + "presentationId": "ST_5334da38-8076-4b40-9f6c-ac3fccaa5d24", + "deviceManufacturerCode": "ecobee", + "locationId": "1030449a-22c1-4a80-9781-0bd4ab7f0f2f", + "ownerId": "e7dbb793-4351-4cdc-b037-e6e0b4f9df67", + "roomId": "d22e6f98-78fe-4a76-b904-6cad8628da59", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-10T00:57:26.760Z", + "profile": { + "id": "234d537d-d388-497f-b0f4-2e25025119ba" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "nikeSmart-thermostat", + "swVersion": "250308073247", + "hwVersion": "250308073247", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/fake_fan.json b/tests/components/smartthings/fixtures/devices/fake_fan.json index 7b8e174d420..6a447ae7aff 100644 --- a/tests/components/smartthings/fixtures/devices/fake_fan.json +++ b/tests/components/smartthings/fixtures/devices/fake_fan.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "0086-0002-005F", "locationId": "6f11ddf5-f0cb-4516-a06a-3a2a6ec22bca", "ownerId": "9f257fc4-6471-2566-b06e-2fe72dd979fa", - "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", @@ -36,7 +36,6 @@ } ], "createTime": "2023-01-12T23:02:44.917Z", - "parentDeviceId": "6a2dd7a4-dd77-48bc-9acf-017029aaf099", "profile": { "id": "6372cd27-93c7-32ef-9be5-aef2221adff1" }, diff --git a/tests/components/smartthings/fixtures/devices/gas_meter.json b/tests/components/smartthings/fixtures/devices/gas_meter.json new file mode 100644 index 00000000000..9bf8af654c7 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/gas_meter.json @@ -0,0 +1,56 @@ +{ + "items": [ + { + "deviceId": "3b57dca3-9a90-4f27-ba80-f947b1e60d58", + "name": "copper_gas_meter_v04", + "label": "Gas Meter", + "manufacturerName": "0A6v", + "presentationId": "ST_176e9efa-01d2-4d1b-8130-d37a4ef1b413", + "deviceManufacturerCode": "CopperLabs", + "locationId": "4e88bf74-3bed-4e6d-9fa7-6acb776a4df9", + "ownerId": "6fc21de5-123e-2f8c-2cc6-311635aeaaef", + "roomId": "fafae9db-a2b5-480f-8ff5-df8f913356df", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "gasMeter", + "version": 1 + } + ], + "categories": [ + { + "name": "GasMeter", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-27T14:06:11.522Z", + "profile": { + "id": "5cca2553-23d6-43c4-81ad-a1c6c43efa00" + }, + "viper": { + "manufacturerName": "CopperLabs", + "modelName": "Virtual Gas Meter", + "endpointAppId": "viper_1d5767a0-af08-11ed-a999-9f1f172a27ff" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json index 910eacec2cc..646196fa980 100644 --- a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json +++ b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json @@ -8,7 +8,7 @@ "presentationId": "31cf01ee-cb49-3d95-ac2d-2afab47f25c7", "deviceManufacturerCode": "0063-4944-3130", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", - "roomId": "e73dcd00-6953-431d-ae79-73fd2f2c528e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", @@ -40,7 +40,6 @@ } ], "createTime": "2020-05-25T18:18:01Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "ec5458c2-c011-3479-a59b-82b42820c2f7" }, diff --git a/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json b/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json new file mode 100644 index 00000000000..96937769b41 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json @@ -0,0 +1,95 @@ +{ + "items": [ + { + "deviceId": "656569c2-7976-4232-a789-34b4d1176c3a", + "name": "generic-ef00-v1", + "label": "Thermostat K\u00fcche", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "be641577-f796-315b-af6f-b3ad14dd7a58", + "deviceManufacturerCode": "_TZE200_rxntag7i", + "locationId": "0b6618a6-c3ab-4b6e-968d-59cc8c2761bc", + "ownerId": "8a20b799-9d87-ecdc-39de-c93c6e4d3ea1", + "roomId": "eeb2f9d2-19cc-4dad-9f23-28ec807de97e", + "components": [ + { + "id": "main", + "label": "Thermostat", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "signalStrength", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "valleyboard16460.debug", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "main02", + "label": "Floor", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-12-02T15:58:01.598Z", + "profile": { + "id": "3ad2e1e3-8867-332c-85b5-b291602c324f" + }, + "zigbee": { + "eui": "A4C1388B31017B5F", + "networkId": "162F", + "driverId": "585328e6-ac85-4ac5-bce4-286efd0ab980", + "executingLocally": true, + "hubId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "provisioningState": "DRIVER_SWITCH" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json b/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json new file mode 100644 index 00000000000..db218189c68 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json @@ -0,0 +1,63 @@ +{ + "items": [ + { + "deviceId": "6d95a8b7-4ee3-429a-a13a-00ec9354170c", + "name": "GE In-Wall Smart Dimmer", + "label": "Bedroom Fan", + "manufacturerName": "SmartThingsEdge", + "presentationId": "generic-fan-3-speed", + "deviceManufacturerCode": "0063-4944-3131", + "locationId": "f1313f27-6732-481d-a2a9-c7bbf900f867", + "ownerId": "e5216062-ac82-79b8-20db-ea65fa3d3fdd", + "roomId": "5f77f7cf-ece8-485e-a409-98f7b128a41a", + "components": [ + { + "id": "main", + "label": "Bedroom Fan", + "capabilities": [ + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Fan", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-01-12T22:12:15Z", + "parentDeviceId": "4ceb9b86-2f0d-4e98-ba4e-3fbe705f7805", + "profile": { + "id": "9bd81754-fc81-3ed1-86c2-d1094d6cbf6d" + }, + "zwave": { + "networkId": "02", + "driverId": "e7947a05-947d-4bb5-92c4-2aafaff6d69c", + "executingLocally": true, + "hubId": "4ceb9b86-2f0d-4e98-ba4e-3fbe705f7805", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "PROVISIONED", + "manufacturerId": 99, + "productType": 18756, + "productId": 12593 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json b/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json new file mode 100644 index 00000000000..0cd42e0e2ce --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json @@ -0,0 +1,155 @@ +{ + "items": [ + { + "deviceId": "5e5b97f3-3094-44e6-abc0-f61283412d6a", + "name": "heatit-zpushwall", + "label": "Livingroom smart switch", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "52933933-7123-3315-a441-92d65df5f031", + "deviceManufacturerCode": "019B-0004-2403", + "locationId": "c85a9f8a-5d2e-4cdd-8bdb-bc49ba4a3544", + "ownerId": "7b68139b-d068-45d8-bf27-961320350024", + "roomId": "56e43461-2f7d-4c43-ba7c-29465f991289", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "battery", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button1", + "label": "button1", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button2", + "label": "button2", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button3", + "label": "button3", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button4", + "label": "button4", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button5", + "label": "button5", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button6", + "label": "button6", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-04T16:51:15.774Z", + "parentDeviceId": "4869d882-e898-40c3-a198-7611b72187a5", + "profile": { + "id": "2d6e59af-63df-3102-8515-66f3d75c9323" + }, + "zwave": { + "networkId": "12", + "driverId": "1d39c140-ce10-490d-bf52-4de7b72caab6", + "executingLocally": true, + "hubId": "4869d882-e898-40c3-a198-7611b72187a5", + "networkSecurityLevel": "ZWAVE_S2_AUTHENTICATED", + "provisioningState": "NONFUNCTIONAL", + "manufacturerId": 411, + "productType": 4, + "productId": 9219 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json b/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json new file mode 100644 index 00000000000..e8928f6b3a8 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json @@ -0,0 +1,79 @@ +{ + "items": [ + { + "deviceId": "69a271f6-6537-4982-8cd9-979866872692", + "name": "heatit-ztrm3-thermostat", + "label": "Hall thermostat", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "8c5c0adc-73d6-33db-a1bd-67d746ab0e00", + "deviceManufacturerCode": "019B-0003-0203", + "locationId": "6cf6637b-9bc5-4e52-bc99-7497e322fb0d", + "ownerId": "7b68139b-d068-45d8-bf27-961320350024", + "roomId": "746b4d54-8026-44f1-b50f-8833dafdeea3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-09-22T15:40:58.942Z", + "parentDeviceId": "d04f5ba0-1430-4826-9aa4-fba4efb57c24", + "profile": { + "id": "2677e0e8-9241-3163-815e-6b1d6743f280" + }, + "zwave": { + "networkId": "28", + "driverId": "28198799-de20-4cfd-a9f3-67860a0877d5", + "executingLocally": true, + "hubId": "d04f5ba0-1430-4826-9aa4-fba4efb57c24", + "networkSecurityLevel": "ZWAVE_S2_AUTHENTICATED", + "provisioningState": "PROVISIONED", + "manufacturerId": 411, + "productType": 3, + "productId": 515 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hub.json b/tests/components/smartthings/fixtures/devices/hub.json new file mode 100644 index 00000000000..81046859db6 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hub.json @@ -0,0 +1,718 @@ +{ + "items": [ + { + "deviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "name": "SmartThings v2 Hub", + "label": "Home Hub", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "63f1469e-dc4a-3689-8cc5-69e293c1eb21", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "bridge", + "version": 1 + } + ], + "categories": [ + { + "name": "Hub", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2016-11-13T18:18:07Z", + "childDevices": [ + { + "deviceId": "0781c9d0-92cb-4c7b-bb5b-2f2dbe0c41f3", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "08ee0358-9f40-4afa-b5a0-3a6aba18c267", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "09076422-62cc-4b2d-8beb-b53bc451c704", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "0b5577db-5074-4b70-a2c5-efec286d264d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "115236ea-59e5-4cd4-bade-d67c409967bc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "1691801c-ae59-438b-89dc-f2c761fe937d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "1a987293-0962-4447-99d4-aa82655ffb55", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "2533fdd0-e064-4fa2-b77b-1e17260b58d7", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "265e653b-3c0b-4fa6-8e2a-f6a69c7040f0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "277e0a96-c8ec-41aa-b4cf-0bac57dc1cee", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "2d9a892b-1c93-45a5-84cb-0e81889498c6", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "374ba6fa-5a08-4ea2-969c-1fa43d86e21f", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "37c0cdda-9158-41ad-9635-4ca32df9fe5b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "3f82e13c-bd39-4043-bb54-7432a4e47113", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4339f999-1ad2-46fb-9103-cb628b30a022", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4a59f635-9f0a-4a6c-a2f0-ffb7ef182a7c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4c3469c9-3556-4f19-a2e1-1c0a598341dc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4fddedf0-2662-476e-b1fd-aceaec17ad3a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "550a1c72-65a0-4d55-b97b-75168e055398", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "630cf009-eb3b-409e-a77a-9b298540532f", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6356b240-c7d8-403c-883e-ae438d432abe", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "68e786a6-7f61-4c3a-9e13-70b803cf782b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6a2e5058-36f3-4668-aa43-49a66f8df93d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6b5535c7-c039-42ee-9970-8af86c6b0775", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6c1b7cfa-7429-4f35-9d02-ab1dfd2f1297", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6ca56087-481f-4e93-9727-fb91049fe396", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6e3e44b3-d84a-4efc-a97b-b5e0dae28ddc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6f4d2e72-7af4-4c96-97ab-d6b6a0d6bc4b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7111243f-39d6-4ed0-a277-f040e40a806d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7b9d924a-de0c-44f9-ac5c-f15869c59411", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7bedac4c-5681-4897-a2ef-e9153cb19ba0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7d246592-93db-4d72-a10d-5a51793ece8c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "803cb0d9-addd-4c2d-aaef-d4e20bf88228", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "829da938-6e92-4a93-8923-7c67f9663c03", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "84f1eaf0-592e-459a-a2b3-4fc43e004dae", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "8eacf25f-aa33-4d9e-ba90-0e4ac3ceb8e0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "8f873071-a9aa-4580-a736-8f5f696e044a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "91172212-e9ff-4ca6-9626-e7af0361c9ad", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "92138ee5-d3bf-4348-98e8-445dedc319cb", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "971b05df-6ed3-446e-b54f-5092eac01921", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9a9cb299-5279-4dea-9249-b5c153d22ba1", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9b479ba0-81e1-4877-87c5-c301a87cbdab", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9dd17f8f-cf5e-4647-a11c-d8f24cdf9b2a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a1e6525c-1e24-403c-b18c-eecb65e22ccf", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a9d42ef0-f972-44b0-86bc-efd6569a1aef", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a9f587c5-5d8b-4273-8907-e7f609af5158", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "aaedaf28-2ae0-4c1d-b57e-87f6a420c298", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "b3a84295-ac3c-4fb1-95e4-4a4bbb1b0bce", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "b90c085d-7d1f-4abc-a66d-d5ce3f96be02", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "bafc5147-2e48-498b-97ff-34c93fae7814", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c1107a0c-fa71-43c5-8ff9-a128ea6c4f20", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c5209cd2-fcb5-46be-b685-5b05f22dcb2c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c5699ff6-af09-4922-901d-bb81b8345bc3", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "cfcd9a21-a943-4519-9972-3c7890cd25b1", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d0268a69-abfb-4c92-a646-61cec2e510ad", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d20891e5-59b4-46ce-9184-b7fdf0c7ae4c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d48848b9-25b0-4423-8fcf-96a022ac571e", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "ea2aa187-40fd-4140-9742-453e691c4469", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f27d0b27-24fd-4d8c-b003-d3d7aaba1e70", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f3c18803-cbec-48e3-8f15-3c31f302d68b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f3e184b2-631a-47b2-b583-32ac2fec9e3c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f4e0517a-d94f-4bd6-a464-222c8c413a66", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + } + ], + "profile": { + "id": "d77ba2f6-c377-36f5-bb68-15db9d1aa0e1" + }, + "hub": { + "hubEui": "D052A872947A0001", + "firmwareVersion": "000.055.00005", + "hubDrivers": [ + { + "driverVersion": "2025-01-19T15:05:25.835006968", + "driverId": "00425c55-0932-416f-a1ba-78fae98ab614", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-12-17T18:00:36.611958104", + "driverId": "01976eca-e7ff-4d1b-91db-9c980ce668d7", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:48.572636846", + "driverId": "0f206d13-508e-4342-9cbb-937e02489141", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:07.735400483", + "driverId": "2cbf55e3-dbc2-48a2-8be5-4c3ce756b692", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-11-04T22:39:17.976631549", + "driverId": "3fb97b6c-f481-441b-a14e-f270d738764e", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:51.437710641", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:35.032104982", + "driverId": "4eb5b19a-7bbc-452f-859b-c6d7d857b2da", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2023-08-08T18:58:32.479650566", + "driverId": "4fb7ec02-2697-4d73-977d-2b1c65c4484f", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-17T18:00:47.743217473", + "driverId": "572a2641-2af8-47e4-bfe5-ad83748fd7a1", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2023-07-12T03:33:26.23424277", + "driverId": "5ad2cc83-5503-4040-a98b-b0fc9931b9fe", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2024-09-17T20:08:25.82515546", + "driverId": "5db3363a-d954-412f-93e0-2ee40572658b", + "channelId": "2423da55-101c-4b21-af58-0903656b85ca" + }, + { + "driverVersion": "2024-12-08T10:10:03.832334965", + "driverId": "6342be70-6da0-4535-afc1-ff6378d6c650", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2022-02-01T21:35:33.624882", + "driverId": "6a90f7a0-e275-4366-bbf2-2e8a502efc5d", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2024-09-28T21:56:32.002090649", + "driverId": "7333473f-722c-465d-9e5d-f3a6ca760489", + "channelId": "f8900c5e-d591-4979-9826-75a867e9e0bd" + }, + { + "driverVersion": "2025-02-03T22:38:47.582952919", + "driverId": "7beb8bc2-8dfa-45c2-8fdb-7373d4597b12", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-11-15T16:18:24.739596514", + "driverId": "7ca45ba9-7cfe-4547-b752-fe41a0efb848", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-02-06T21:13:39.427465986", + "driverId": "8bf71a5d-677b-4391-93c2-e76471f3d7eb", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-21T19:06:49.949052991", + "driverId": "9050ac53-358c-47a1-a927-9a70f5f28cee", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T19:30:29.754256377", + "driverId": "92f39ab3-7b2f-47ee-94a7-ba47c4ee8a47", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-17T18:00:21.846431345", + "driverId": "9870bccd-2b3d-4edf-8940-532fcb11e946", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-09T21:10:00.424854506", + "driverId": "a6994e70-93de-4a76-8b5d-42971a3427c9", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2022-01-03T08:19:45.80869", + "driverId": "a89371c4-8765-404b-9de9-e9882cc48bd8", + "channelId": "14bcc056-f80d-416b-9445-467b0db325e3" + }, + { + "driverVersion": "2025-01-11T20:03:43.842469565", + "driverId": "b1504ded-efa4-4ef0-acd5-ae24e7a92e6e", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-12-08T09:45:01.460678797", + "driverId": "bb1b3fd4-dcba-4d55-8d85-58ed7f1979fb", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-11-04T22:39:18.253781754", + "driverId": "c21a6c77-872c-474e-be5b-5f6f11a240ef", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-01-30T21:36:15.547412569", + "driverId": "c856a3fd-69ee-4478-a224-d7279b6d978f", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2025-01-13T18:55:57.509807915", + "driverId": "cd898d81-6c27-4d27-a529-dfadc8caae5a", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:48.892833142", + "driverId": "ce930ffd-8155-4dca-aaa9-6c4158fc4278", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T19:30:41.208767469", + "driverId": "d620900d-f7bc-4ab5-a171-6dd159872f7d", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-10-10T19:30:33.46670456", + "driverId": "d6b43c85-1561-446b-9e3e-15e2ad81a952", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2023-07-11T18:43:49.169154271", + "driverId": "d9c3f8b8-c3c3-4b77-9ddd-01d08102c84b", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:54.195543653", + "driverId": "dbe192cb-f6a1-4369-a843-d1c42e5c91ba", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2022-10-02T20:15:49.147522379", + "driverId": "e120daf2-8000-4a9d-93fa-653214ce70d1", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2023-08-15T20:08:28.115440571", + "driverId": "e7947a05-947d-4bb5-92c4-2aafaff6d69c", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2025-02-05T18:49:13.3338494", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + } + ], + "hubData": { + "zwaveStaticDsk": "13740-14339-50623-49310-29679-58685-46457-16097", + "zwaveS2": true, + "hardwareType": "V2_HUB", + "hardwareId": "000D", + "zigbeeFirmware": "5.7.10", + "zigbee3": true, + "zigbeeOta": "ENABLED_WITH_LIGHTS", + "otaEnable": "true", + "zigbeeUnsecureRejoin": true, + "zigbeeRequiresExternalHardware": false, + "threadRequiresExternalHardware": false, + "failoverAvailability": "Unsupported", + "primarySupportAvailability": "Unsupported", + "secondarySupportAvailability": "Unsupported", + "zigbeeAvailability": "Available", + "zwaveAvailability": "Available", + "lanAvailability": "Available", + "matterAvailability": "Available", + "localVirtualDeviceAvailability": "Available", + "childDeviceAvailability": "Unsupported", + "edgeDriversAvailability": "Available", + "hubReplaceAvailability": "Available", + "hubLocalApiAvailability": "Available", + "zigbeeManualFirmwareUpdateSupported": true, + "matterRendezvousHedgeSupported": true, + "matterSoftwareComponentVersion": "1.3-0", + "matterDeviceDiagnosticsAvailability": "Available", + "zigbeeDeviceDiagnosticsAvailability": "Available", + "hedgeTlsCertificate": "", + "zigbeeChannel": "14", + "zigbeePanId": "0EE7", + "zigbeeEui": "D052A872947A0001", + "zigbeeNodeID": "0000", + "zwaveNodeID": "01", + "zwaveHomeID": "CF0F089E", + "zwaveSucID": "01", + "zwaveVersion": "6.10", + "zwaveRegion": "US", + "macAddress": "D0:52:A8:72:91:02", + "localIP": "192.168.168.189", + "zigbeeRadioFunctional": true, + "zwaveRadioFunctional": true, + "zigbeeRadioEnabled": true, + "zwaveRadioEnabled": true, + "zigbeeRadioDetected": true, + "zwaveRadioDetected": true + } + }, + "type": "HUB", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + }, + { + "deviceId": "374ba6fa-5a08-4ea2-969c-1fa43d86e21f", + "name": "Multipurpose Sensor", + "label": "Mail Box", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", + "deviceManufacturerCode": "SmartThings", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "f7f39cf6-ff3a-4bcb-8d1b-00a3324c016d", + "components": [ + { + "id": "main", + "label": "Mail Box", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "threeAxis", + "version": 1 + }, + { + "id": "accelerationSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "MultiFunctionalSensor", + "categoryType": "manufacturer" + }, + { + "name": "MultiFunctionalSensor", + "categoryType": "user" + } + ] + } + ], + "createTime": "2022-08-16T21:08:09.983Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "4471213f-121b-38fd-b022-51df37ac1d4c" + }, + "zigbee": { + "eui": "24FD5B00010A3A95", + "networkId": "E71B", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hw_q80r_soundbar.json b/tests/components/smartthings/fixtures/devices/hw_q80r_soundbar.json new file mode 100644 index 00000000000..5f99cefddcb --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hw_q80r_soundbar.json @@ -0,0 +1,106 @@ +{ + "items": [ + { + "deviceId": "afcf3b91-0000-1111-2222-ddff2a0a6577", + "name": "[AV] Samsung Soundbar Q80R", + "label": "Soundbar", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-NetworkAudio-001S", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c7f8e400-0000-1111-2222-76463f4eb484", + "ownerId": "bd0d9288-0000-1111-2222-68310a42a709", + "roomId": "be09ff51-0000-1111-2222-e48e2dab37fd", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "Soundbar", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "mediaInputSource", + "version": 1 + }, + { + "id": "samsungvd.audioInputSource", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "samsungvd.groupInfo", + "version": 1 + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-10-19T01:35:08Z", + "profile": { + "id": "c1036d88-000-1111-2222-a361463fd53f" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "[AV] Samsung Soundbar Q80R", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "Q80R", + "platformVersion": "Tizen 4.0", + "platformOS": "4.1.10", + "hwVersion": "0-0", + "firmwareVersion": "HW-Q80RWWB-1012.6", + "vendorId": "VD-NetworkAudio-001S", + "vendorResourceClientServerVersion": "1.2", + "locale": "KO", + "lastSignupTime": "2021-01-16T07:05:02.184545Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json b/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json new file mode 100644 index 00000000000..36f9d40f7e4 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json @@ -0,0 +1,78 @@ +{ + "items": [ + { + "deviceId": "71afed1c-006d-4e48-b16e-e7f88f9fd638", + "name": "window-treatment-battery", + "label": "Kitchen IKEA KADRILJ Window blind", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "fa41d7d3-4c03-327f-b0ce-2edc829f0e34", + "deviceManufacturerCode": "IKEA of Sweden", + "locationId": "5b5f96b5-0286-4f4a-86ef-d5d5c1a78cb8", + "ownerId": "f43fd9e5-2ecd-4aae-aeac-73a8e5cb04da", + "roomId": "89f675a1-1f16-451c-8ab1-a7fdacc5852d", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "windowShade", + "version": 1 + }, + { + "id": "windowShadePreset", + "version": 1 + }, + { + "id": "windowShadeLevel", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Blind", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-04-26T18:19:06.792Z", + "parentDeviceId": "3ffe04c4-a12c-41f5-b83d-c1b28eca2b5f", + "profile": { + "id": "6d9804bc-9e56-3823-95be-4b315669c481" + }, + "zigbee": { + "eui": "000D6FFFFE2AD0E7", + "networkId": "3009", + "driverId": "46b8bada-1a55-4f84-8915-47ce2cad3621", + "executingLocally": true, + "hubId": "3ffe04c4-a12c-41f5-b83d-c1b28eca2b5f", + "provisioningState": "NONFUNCTIONAL" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [10.0, 36.0, 98.0], + "rotation": [270.0, 0.0, 0.0], + "visible": true, + "data": null + }, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/im_smarttag2_ble_uwb.json b/tests/components/smartthings/fixtures/devices/im_smarttag2_ble_uwb.json new file mode 100644 index 00000000000..802b4da1514 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/im_smarttag2_ble_uwb.json @@ -0,0 +1,184 @@ +{ + "items": [ + { + "deviceId": "83d660e4-b0c8-4881-a674-d9f1730366c1", + "name": "Tag(UWB)", + "label": "SmartTag+ black", + "manufacturerName": "Samsung Electronics", + "presentationId": "IM-SmartTag-BLE-UWB", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "redacted_locid", + "ownerId": "redacted", + "roomId": "redacted_roomid", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "alarm", + "version": 1 + }, + { + "id": "tag.tagButton", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "tag.factoryReset", + "version": 1 + }, + { + "id": "tag.e2eEncryption", + "version": 1 + }, + { + "id": "tag.tagStatus", + "version": 1 + }, + { + "id": "geolocation", + "version": 1 + }, + { + "id": "geofence", + "version": 1 + }, + { + "id": "tag.uwbActivation", + "version": 1 + }, + { + "id": "tag.updatedInfo", + "version": 1 + }, + { + "id": "tag.searchingStatus", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + } + ], + "categories": [ + { + "name": "BluetoothTracker", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-05-25T09:42:59.720Z", + "profile": { + "id": "e443f3e8-a926-3deb-917c-e5c6de3af70f" + }, + "bleD2D": { + "encryptionKey": "ZTbd_04NISrhQODE7_i8JdcG2ZWwqmUfY60taptK7J0=", + "cipher": "AES_128-CBC-PKCS7Padding", + "identifier": "415D4Y16F97F", + "configurationVersion": "2.0", + "configurationUrl": "https://apis.samsungiotcloud.com/v1/miniature/profile/b8e65e7e-6152-4704-b9f5-f16352034237", + "bleDeviceType": "BLE", + "metadata": { + "regionCode": 11, + "privacyIdPoolSize": 2000, + "privacyIdSeed": "AAAAAAAX8IQ=", + "privacyIdInitialVector": "ZfqZKLRGSeCwgNhdqHFRpw==", + "numAllowableConnections": 2, + "firmware": { + "version": "1.03.07", + "specVersion": "0.5.6", + "updateTime": 1685007914000, + "latestFirmware": { + "id": 581, + "version": "1.03.07", + "data": { + "checksum": "50E7", + "size": "586004", + "supportedVersion": "0.5.6" + } + } + }, + "currentServerTime": 1739095473, + "searchingStatus": "stop", + "lastKnownConnection": { + "updated": 1713422813, + "connectedUser": { + "id": "sk3oyvsbkm", + "name": "" + }, + "connectedDevice": { + "id": "4f3faa4c-976c-3bd8-b209-607f3a5a9814", + "name": "" + }, + "d2dStatus": "bleScanned", + "nearby": true, + "onDemand": false + }, + "e2eEncryption": { + "enabled": false + }, + "timer": 1713422675, + "category": { + "id": 0 + }, + "remoteRing": { + "enabled": false + }, + "petWalking": { + "enabled": false + }, + "onboardedBy": { + "saGuid": "sk3oyvsbkm" + }, + "shareable": { + "enabled": false + }, + "agingCounter": { + "status": "VALID", + "updated": 1713422675 + }, + "vendor": { + "mnId": "0AFD", + "setupId": "432", + "modelName": "EI-T7300" + }, + "priorityConnection": { + "lba": false, + "cameraShutter": false + }, + "createTime": 1685007780, + "updateTime": 1713422675, + "fmmSearch": false, + "ooTime": { + "currentOoTime": 8, + "defaultOoTime": 8 + }, + "pidPoolSize": { + "desiredPidPoolSize": 2000, + "currentPidPoolSize": 2000 + }, + "activeMode": { + "mode": 0 + }, + "itemConfig": { + "searchingStatus": "stop" + } + } + }, + "type": "BLE_D2D", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json b/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json new file mode 100644 index 00000000000..81fb1b07ff2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json @@ -0,0 +1,136 @@ +{ + "items": [ + { + "deviceId": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "name": "Galaxy Home Mini (MQVL)", + "label": "Galaxy Home Mini", + "manufacturerName": "Samsung Electronics", + "presentationId": "IM-SPEAKER-AI-0001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "33db9e71-abe9-43a0-acd3-3f0927bbe5b7", + "ownerId": "9a1ee192-04ba-46ca-9c3d-196d8dbcf807", + "roomId": "445c006d-1796-4dd6-8308-1c3cd045e8ff", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "mediaInputSource", + "version": 1 + }, + { + "id": "mediaPlaybackRepeat", + "version": 1 + }, + { + "id": "mediaPlaybackShuffle", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "audioTrackAddressing", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "musicPlayer", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "speechSynthesis", + "version": 1 + }, + { + "id": "samsungim.bixbyContent", + "version": 1 + }, + { + "id": "samsungim.announcement", + "version": 1 + }, + { + "id": "samsungim.networkAudioMode", + "version": 1 + }, + { + "id": "samsungim.networkAudioGroupInfo", + "version": 1 + }, + { + "id": "samsungim.networkAudioTrackData", + "version": 1 + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-08T12:06:18.865Z", + "profile": { + "id": "09df8e36-e94f-339c-9086-9639505e1fb2" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "Galaxy Home Mini (MQVL)", + "specVersion": "core0.0.1", + "verticalDomainSpecVersion": "IoTivity1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "SM-V310", + "platformVersion": "4.0.0.1", + "platformOS": "Tizen", + "hwVersion": "1.0", + "firmwareVersion": "V310XXU1AWK1", + "vendorId": "IM-SPEAKER-AI-0001", + "lastSignupTime": "2025-03-08T12:06:16.386696652Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/iphone.json b/tests/components/smartthings/fixtures/devices/iphone.json index 3fc26307c90..1ae79aa06ef 100644 --- a/tests/components/smartthings/fixtures/devices/iphone.json +++ b/tests/components/smartthings/fixtures/devices/iphone.json @@ -27,7 +27,6 @@ } ], "createTime": "2021-12-02T16:14:24.394Z", - "parentDeviceId": "b8e11599-5297-4574-8e62-885995fcaa20", "profile": { "id": "21d0f660-98b4-3f7b-8114-fe62e555628e" }, diff --git a/tests/components/smartthings/fixtures/devices/lumi.json b/tests/components/smartthings/fixtures/devices/lumi.json new file mode 100644 index 00000000000..2a5b90adfa1 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/lumi.json @@ -0,0 +1,75 @@ +{ + "items": [ + { + "deviceId": "692ea4e9-2022-4ed8-8a57-1b884a59cc38", + "name": "temp-humid-press-therm-battery-05", + "label": "Outdoor Temp", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "cea6ca21-a702-3c43-8fe5-a7872c7a963f", + "deviceManufacturerCode": "LUMI", + "locationId": "96fe7a00-c7f6-440a-940e-77aa81a9af4b", + "ownerId": "eabfbf0b-ba3f-40f5-8dcb-8aaba788f8e3", + "roomId": "1eca2d6d-d15d-4f0e-9e32-8709acb9b3fe", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "atmosphericPressureMeasurement", + "version": 1 + }, + { + "id": "legendabsolute60149.atmosPressure", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "configuration", + "version": 1 + }, + { + "id": "battery", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2024-06-12T21:27:55.959Z", + "parentDeviceId": "61f28b8c-b975-415a-9197-fbc4e441e77a", + "profile": { + "id": "fa7886ec-6139-3357-8f4a-07a66491c173" + }, + "zigbee": { + "eui": "00158D000967924A", + "networkId": "4B01", + "driverId": "c09c02d7-d05d-4bf4-831b-207a1adeae2f", + "executingLocally": true, + "hubId": "61f28b8c-b975-415a-9197-fbc4e441e77a", + "provisioningState": "NONFUNCTIONAL", + "fingerprintType": "ZIGBEE_MANUFACTURER", + "fingerprintId": "lumi.weather" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json index 3770614a366..c8088d6473d 100644 --- a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json +++ b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json @@ -8,7 +8,7 @@ "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", "deviceManufacturerCode": "SmartThings", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", - "roomId": "b277a3c0-b8fe-44de-9133-c1108747810c", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", @@ -56,7 +56,6 @@ } ], "createTime": "2019-02-23T16:53:57Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "4471213f-121b-38fd-b022-51df37ac1d4c" }, diff --git a/tests/components/smartthings/fixtures/devices/smart_plug.json b/tests/components/smartthings/fixtures/devices/smart_plug.json index 24d0fbc6e84..e5ec6c38dad 100644 --- a/tests/components/smartthings/fixtures/devices/smart_plug.json +++ b/tests/components/smartthings/fixtures/devices/smart_plug.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "LEDVANCE", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", @@ -37,7 +37,6 @@ } ], "createTime": "2018-10-05T12:23:14Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "daeff874-075a-32e3-8b11-bdb99d8e67c7" }, diff --git a/tests/components/smartthings/fixtures/devices/sonos_player.json b/tests/components/smartthings/fixtures/devices/sonos_player.json index 67d1ef24cf9..c84caf57475 100644 --- a/tests/components/smartthings/fixtures/devices/sonos_player.json +++ b/tests/components/smartthings/fixtures/devices/sonos_player.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Sonos", "locationId": "eed0e167-e793-459b-80cb-a0b02e2b86c2", "ownerId": "2c69cc36-85ae-c41a-9981-a4ee96cd9137", - "roomId": "105e6d1a-52a4-4797-a235-5a48d7d433c8", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", @@ -61,7 +61,6 @@ } ], "createTime": "2025-02-02T13:18:28.570Z", - "parentDeviceId": "2f7f7d2b-e683-48ae-86f7-e57df6a0bce2", "profile": { "id": "0443d359-3f76-383f-82a4-6fc4a879ef1d" }, diff --git a/tests/components/smartthings/fixtures/devices/tplink_p110.json b/tests/components/smartthings/fixtures/devices/tplink_p110.json new file mode 100644 index 00000000000..ffe7de5ff68 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/tplink_p110.json @@ -0,0 +1,73 @@ +{ + "items": [ + { + "deviceId": "6602696a-1e48-49e4-919f-69406f5b5da1", + "name": "plug-energy-usage-report", + "label": "Sp\u00fclmaschine", + "manufacturerName": "0AI2", + "presentationId": "ST_8f2be0ec-1113-46e0-ad56-3e92eb27410f", + "deviceManufacturerCode": "TP-Link", + "locationId": "70da36b0-bd25-410c-beed-7f0dbf658448", + "ownerId": "be5d4173-dd49-1eee-56f5-f98306ee872c", + "roomId": "bd13616d-b7e2-44ff-914c-eb38ea18c4b4", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartPlug", + "categoryType": "manufacturer" + }, + { + "name": "SmartPlug", + "categoryType": "user" + } + ] + } + ], + "createTime": "2024-03-07T21:14:59.762Z", + "profile": { + "id": "a25b207e-cbb9-40ae-8a88-906637c22ab6" + }, + "viper": { + "uniqueIdentifier": "8022F7F6FE0A6EACA52B5D89C0D667352136D8C6", + "manufacturerName": "TP-Link", + "modelName": "P110", + "swVersion": "1.3.1 Build 240621 Rel.162048", + "hwVersion": "1.0", + "endpointAppId": "viper_7ea6bb80-b876-11eb-be42-952f31ab3f7b" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [0.0, 0.0, 0.0], + "rotation": [0.0, 180.0, 0.0], + "visible": false, + "data": null + }, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json index 7fb07533810..20f4aa71fec 100644 --- a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json +++ b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", - "roomId": "db506ec3-83b1-4125-9c4c-eb597da5db6a", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Network Audio Player", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/vd_network_audio_003s.json b/tests/components/smartthings/fixtures/devices/vd_network_audio_003s.json new file mode 100644 index 00000000000..428b0e635d5 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_network_audio_003s.json @@ -0,0 +1,115 @@ +{ + "items": [ + { + "deviceId": "a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6", + "name": "Soundbar", + "label": "Soundbar", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-NetworkAudio-003S", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "6bdf6730-8167-488b-8645-d0c5046ff763", + "ownerId": "15f0ae72-da51-14e2-65cf-ef59ae867e7f", + "roomId": "3b0fe9a8-51d6-49cf-b64a-8a719013c0a7", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "samsungvd.audioInputSource", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "samsungvd.soundFrom", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "samsungvd.thingStatus", + "version": 1 + }, + { + "id": "samsungvd.supportsFeatures", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "samsungvd.audioGroupInfo", + "version": 1, + "ephemeral": true + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-02-21T14:25:21.843Z", + "profile": { + "id": "25504ad5-8563-3b07-8770-e52ad29a9c5a" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "Soundbar", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "HW-S60D", + "platformVersion": "8.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "SAT-MT8532D24WWC-1016.0", + "vendorId": "VD-NetworkAudio-003S", + "vendorResourceClientServerVersion": "4.0.26", + "lastSignupTime": "2025-03-18T19:11:51.176292902Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_sensor_light_2023.json b/tests/components/smartthings/fixtures/devices/vd_sensor_light_2023.json new file mode 100644 index 00000000000..ef1dd2e96bc --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_sensor_light_2023.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "5cc1c096-98b9-460c-8f1c-1045509ec605", + "name": "VD-Sensor.Light-2023", + "label": "Light Sensor - 55\" The Frame", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-Sensor.Light-2023", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "df59873c-4e2c-43ba-bcd4-ade4efb0504a", + "ownerId": "71254e90-c144-45b6-aabe-709f78f48376", + "roomId": "8a4fac38-48d1-4a8c-922b-92620442363b", + "deviceTypeName": "x.com.st.d.sensor.light", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "relativeBrightness", + "version": 1 + }, + { + "id": "samsungvd.deviceCategory", + "version": 1 + } + ], + "categories": [ + { + "name": "LightSensor", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-15T22:21:27.908Z", + "parentDeviceId": "425ac77a-f7c9-a62d-ff12-cdad144952e3", + "profile": { + "id": "5f1633fb-0c63-34d3-9d04-a314d393d225" + }, + "ocf": { + "ocfDeviceType": "x.com.st.d.sensor.light", + "name": "Light Sensor - 55 The Frame", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "QE55LS03DAUXXN", + "platformVersion": "8.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "latest", + "vendorId": "VD-Sensor.Light-2023", + "vendorResourceClientServerVersion": "4.0.26", + "lastSignupTime": "2024-11-15T22:21:27.933740026Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json index 3c22a214495..42630f452d5 100644 --- a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json +++ b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF TV", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json index d5bf3b32a0c..1b7a55d779d 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json +++ b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json @@ -8,7 +8,7 @@ "presentationId": "78906115-bf23-3c43-9cd6-f42ca3d5517a", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/virtual_valve.json b/tests/components/smartthings/fixtures/devices/virtual_valve.json index 1988617afad..e46b7846631 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_valve.json +++ b/tests/components/smartthings/fixtures/devices/virtual_valve.json @@ -8,7 +8,7 @@ "presentationId": "916408b6-c94e-38b8-9fbf-03c8a48af5c3", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json index ad3a45a0481..ffea2664c88 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json +++ b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json @@ -8,7 +8,7 @@ "presentationId": "838ae989-b832-3610-968c-2940491600f6", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json index e83a1be7644..20f0dd5ca26 100644 --- a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json +++ b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Yale", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", @@ -45,7 +45,6 @@ } ], "createTime": "2016-11-18T23:01:19Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "51b76691-3c3a-3fce-8c7c-4f9d50e5885a" }, diff --git a/tests/components/smartthings/fixtures/rooms.json b/tests/components/smartthings/fixtures/rooms.json new file mode 100644 index 00000000000..355db9a3423 --- /dev/null +++ b/tests/components/smartthings/fixtures/rooms.json @@ -0,0 +1,17 @@ +{ + "items": [ + { + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236b", + "name": "Theater", + "backgroundImage": null + }, + { + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236b", + "name": "Toilet", + "backgroundImage": null + } + ], + "_links": null +} diff --git a/tests/components/smartthings/fixtures/subscription.json b/tests/components/smartthings/fixtures/subscription.json new file mode 100644 index 00000000000..80f37445524 --- /dev/null +++ b/tests/components/smartthings/fixtures/subscription.json @@ -0,0 +1,16 @@ +{ + "subscriptionId": "f5768ce8-c9e5-4507-9020-912c0c60e0ab", + "registrationUrl": "https://spigot-regional.api.smartthings.com/filters/f5768ce8-c9e5-4507-9020-912c0c60e0ab/activate?filterRegion=eu-west-1", + "name": "My Home Assistant sub", + "version": 20250122, + "subscriptionFilters": [ + { + "type": "LOCATIONIDS", + "value": ["88a3a314-f0c8-40b4-bb44-44ba06c9c42e"], + "eventType": ["DEVICE_EVENT"], + "attribute": null, + "capability": null, + "component": null + } + ] +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 27a5e38a123..583c256042e 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.motion', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_motionSensor_motion_motion', 'unit_of_measurement': None, }) # --- @@ -77,7 +77,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.sound', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_soundSensor_sound_sound', 'unit_of_measurement': None, }) # --- @@ -95,7 +95,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-entry] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -108,7 +108,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'entity_id': 'binary_sensor.front_door_open_closed_sensor', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -118,32 +118,32 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Door', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.contact', + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_contactSensor_contact_contact', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-state] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': '.Front Door Open/Closed Sensor Door', + 'device_class': 'garage_door', + 'friendly_name': '.Front Door Open/Closed Sensor', }), 'context': , - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'entity_id': 'binary_sensor.front_door_open_closed_sensor', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-entry] +# name: test_all_entities[da_ks_cooktop_31001][binary_sensor.induction_hob_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -156,7 +156,529 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.refrigerator_door', + 'entity_id': 'binary_sensor.induction_hob_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][binary_sensor.induction_hob_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Induction Hob Power', + }), + 'context': , + 'entity_id': 'binary_sensor.induction_hob_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_samsungce.doorState_doorState_doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Microwave Door', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Microwave Power', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_samsungce.doorState_doorState_doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.vulcan_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.vulcan_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.vulcan_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_samsungce.doorState_doorState_doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Vulcan Door', + }), + 'context': , + 'entity_id': 'binary_sensor.vulcan_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.vulcan_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.vulcan_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_cooler_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_cooler_door', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -168,23 +690,1541 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Door', + 'original_name': 'Cooler door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_door', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_cooler_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_cooler_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Cooler door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_cooler_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_freezer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_freezer_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': 'Freezer door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_door', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_freezer_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_freezer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Freezer door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_freezer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_cooler_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_cooler_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': 'Cooler door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_door', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cooler_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_cooler_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Cooler door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_cooler_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_coolselect_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_coolselect_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': 'CoolSelect+ door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cool_select_plus_door', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cvroom_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_coolselect_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator CoolSelect+ door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_coolselect_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_freezer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_freezer_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': 'Freezer door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_door', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_freezer_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_freezer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Freezer door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_freezer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_cooler_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.frigo_cooler_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': 'Cooler door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_door', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_cooler_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_cooler_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Frigo Cooler door', + }), + 'context': , + 'entity_id': 'binary_sensor.frigo_cooler_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_freezer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.frigo_freezer_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': 'Freezer door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_door', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_freezer_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_freezer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Frigo Freezer door', + }), + 'context': , + 'entity_id': 'binary_sensor.frigo_freezer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dishwasher_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dishwasher_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': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.contact', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-state] +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Refrigerator Door', + 'device_class': 'power', + 'friendly_name': 'Dishwasher Power', }), 'context': , - 'entity_id': 'binary_sensor.refrigerator_door', + 'entity_id': 'binary_sensor.dishwasher_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dishwasher_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.airdresser_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.airdresser_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_keep_fresh_mode_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.airdresser_keep_fresh_mode_active', + 'has_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 fresh mode active', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'keep_fresh_mode_active', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.steamClosetKeepFreshMode_operatingState_operatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_keep_fresh_mode_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser Keep fresh mode active', + }), + 'context': , + 'entity_id': 'binary_sensor.airdresser_keep_fresh_mode_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.airdresser_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'AirDresser Power', + }), + 'context': , + 'entity_id': 'binary_sensor.airdresser_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.airdresser_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.airdresser_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dryer_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dryer_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dryer Power', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dryer_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_wrinkle_prevent_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dryer_wrinkle_prevent_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wrinkle prevent active', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_wrinkle_prevent_active', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_custom.dryerWrinklePrevent_operatingState_operatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_wrinkle_prevent_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Wrinkle prevent active', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_wrinkle_prevent_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.seca_roupa_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.seca_roupa_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Seca-Roupa Power', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.seca_roupa_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_wrinkle_prevent_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.seca_roupa_wrinkle_prevent_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wrinkle prevent active', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_wrinkle_prevent_active', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_custom.dryerWrinklePrevent_operatingState_operatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_wrinkle_prevent_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Wrinkle prevent active', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_wrinkle_prevent_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washer Power', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washing_machine_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washing_machine_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washing Machine Power', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washing_machine_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.machine_a_laver_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.machine_a_laver_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.machine_a_laver_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Machine à Laver Power', + }), + 'context': , + 'entity_id': 'binary_sensor.machine_a_laver_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.machine_a_laver_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][binary_sensor.machine_a_laver_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.machine_a_laver_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washer Power', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_remote_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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_remote_control', 'last_changed': , 'last_reported': , 'last_updated': , @@ -221,7 +2261,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.motion', + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_motionSensor_motion_motion', 'unit_of_measurement': None, }) # --- @@ -269,7 +2309,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.presence', + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_presenceSensor_presence_presence', 'unit_of_measurement': None, }) # --- @@ -317,7 +2357,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '184c67cc-69e2-44b6-8f73-55c963068ad9.presence', + 'unique_id': '184c67cc-69e2-44b6-8f73-55c963068ad9_main_presenceSensor_presence_presence', 'unit_of_measurement': None, }) # --- @@ -335,6 +2375,54 @@ 'state': 'on', }) # --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Deck Door', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -365,7 +2453,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'acceleration', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.acceleration', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_accelerationSensor_acceleration_acceleration', 'unit_of_measurement': None, }) # --- @@ -383,102 +2471,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.deck_door_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': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.contact', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Deck Door Door', - }), - 'context': , - 'entity_id': 'binary_sensor.deck_door_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.volvo_valve', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Valve', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'valve', - 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3.valve', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'opening', - 'friendly_name': 'volvo Valve', - }), - 'context': , - 'entity_id': 'binary_sensor.volvo_valve', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -509,7 +2501,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.water', + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116_main_waterSensor_water_water', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_button.ambr b/tests/components/smartthings/snapshots/test_button.ambr new file mode 100644 index 00000000000..4a7c582f608 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_button.ambr @@ -0,0 +1,236 @@ +# serializer version: 1 +# name: test_all_entities[da_ks_microwave_0101x][button.microwave_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.microwave_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][button.microwave_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Stop', + }), + 'context': , + 'entity_id': 'button.microwave_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][button.oven_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][button.vulcan_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.vulcan_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][button.vulcan_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Stop', + }), + 'context': , + 'entity_id': 'button.vulcan_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][button.refrigerator_reset_water_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.refrigerator_reset_water_filter', + 'has_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 water filter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_water_filter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_custom.waterFilter_resetWaterFilter', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][button.refrigerator_reset_water_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Reset water filter', + }), + 'context': , + 'entity_id': 'button.refrigerator_reset_water_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][button.refrigerator_reset_water_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.refrigerator_reset_water_filter', + 'has_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 water filter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_water_filter', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_custom.waterFilter_resetWaterFilter', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][button.refrigerator_reset_water_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Reset water filter', + }), + 'context': , + 'entity_id': 'button.refrigerator_reset_water_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index ba32776011a..6f4dd67d7f7 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -1,4 +1,133 @@ # serializer version: 1 +# name: test_all_entities[aux_ac][climate.aux_a_c_on_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': None, + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aux_a_c_on_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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aux_ac][climate.aux_a_c_on_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.0, + 'fan_mode': 'auto', + 'fan_modes': None, + 'friendly_name': 'AUX A/C on-off', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.aux_a_c_on_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][climate.radiator_thermostat_ii_m_wohnzimmer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.radiator_thermostat_ii_m_wohnzimmer', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][climate.radiator_thermostat_ii_m_wohnzimmer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 23.9, + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.radiator_thermostat_ii_m_wohnzimmer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -17,7 +146,7 @@ , , , - , + , , ]), 'max_temp': 35, @@ -51,7 +180,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main', 'unit_of_measurement': None, }) # --- @@ -77,12 +206,12 @@ , , , - , + , , ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': None, + 'preset_mode': 'windFree', 'preset_modes': list([ 'windFree', ]), @@ -99,6 +228,112 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ac_rac_000003][climate.office_airfree-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'windFree', + ]), + 'swing_modes': list([ + 'off', + 'both', + 'vertical', + 'horizontal', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.office_airfree', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_000003][climate.office_airfree-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26, + 'drlc_status_duration': 0, + 'drlc_status_override': False, + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'Office AirFree', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'windFree', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'both', + 'vertical', + 'horizontal', + ]), + 'temperature': 24, + }), + 'context': , + 'entity_id': 'climate.office_airfree', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- # name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -114,7 +349,7 @@ ]), 'hvac_modes': list([ , - , + , , , , @@ -156,7 +391,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main', 'unit_of_measurement': None, }) # --- @@ -179,7 +414,7 @@ 'friendly_name': 'Aire Dormitorio Principal', 'hvac_modes': list([ , - , + , , , , @@ -209,6 +444,90 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ac_rac_100001][climate.corridor_a_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.corridor_a_c', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_100001][climate.corridor_a_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'Corridor A/C', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': 18, + }), + 'context': , + 'entity_id': 'climate.corridor_a_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[ecobee_thermostat][climate.main_floor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -220,6 +539,7 @@ 'auto', ]), 'hvac_modes': list([ + , , , ]), @@ -250,7 +570,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main', 'unit_of_measurement': None, }) # --- @@ -267,6 +587,7 @@ 'friendly_name': 'Main Floor', 'hvac_action': , 'hvac_modes': list([ + , , , ]), @@ -285,6 +606,197 @@ 'state': 'heat', }) # --- +# name: test_all_entities[ecobee_thermostat_offline][climate.downstairs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': None, + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.downstairs', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][climate.downstairs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'fan_mode': None, + 'fan_modes': None, + 'friendly_name': 'Downstairs', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.downstairs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[generic_ef00_v1][climate.thermostat_kuche-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat_kuche', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_ef00_v1][climate.thermostat_kuche-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.0, + 'friendly_name': 'Thermostat Küche', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 23.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_kuche', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][climate.hall_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.hall_thermostat', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][climate.hall_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Hall thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 19.0, + }), + 'context': , + 'entity_id': 'climate.hall_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_all_entities[virtual_thermostat][climate.asd-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -295,6 +807,7 @@ 'on', ]), 'hvac_modes': list([ + , ]), 'max_temp': 35.0, 'min_temp': 7.0, @@ -323,7 +836,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6', + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main', 'unit_of_measurement': None, }) # --- @@ -338,6 +851,7 @@ 'friendly_name': 'asd', 'hvac_action': , 'hvac_modes': list([ + , ]), 'max_temp': 35.0, 'min_temp': 7.0, diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index aa928c09b7a..4b5cf705665 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '571af102-15db-4030-b76b-245a691f74a5', + 'unique_id': '571af102-15db-4030-b76b-245a691f74a5_main', 'unit_of_measurement': None, }) # --- @@ -49,3 +49,54 @@ 'state': 'open', }) # --- +# name: test_all_entities[ikea_kadrilj][cover.kitchen_ikea_kadrilj_window_blind-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.kitchen_ikea_kadrilj_window_blind', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ikea_kadrilj][cover.kitchen_ikea_kadrilj_window_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_level': 37, + 'current_position': 32, + 'device_class': 'shade', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_ikea_kadrilj_window_blind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..dc7f699de27 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -0,0 +1,1532 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics[da_ac_rac_000001] + dict({ + 'devices': list([ + dict({ + '_links': dict({ + }), + 'items': list([ + dict({ + 'allowed': list([ + ]), + 'components': list([ + dict({ + 'capabilities': list([ + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'refresh', + 'version': 1, + }), + dict({ + 'id': 'execute', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledComponents', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'samsungce.deviceIdentification', + 'version': 1, + }), + dict({ + 'id': 'samsungce.dongleSoftwareInstallation', + 'version': 1, + }), + dict({ + 'id': 'samsungce.softwareUpdate', + 'version': 1, + }), + dict({ + 'id': 'samsungce.selfCheck', + 'version': 1, + }), + dict({ + 'id': 'samsungce.driverVersion', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'AirConditioner', + }), + ]), + 'id': 'main', + 'label': 'main', + }), + dict({ + 'capabilities': list([ + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'odorSensor', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'Other', + }), + ]), + 'id': '1', + 'label': '1', + }), + ]), + 'createTime': '2021-04-06T16:43:34.753Z', + 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'deviceManufacturerCode': 'Samsung Electronics', + 'deviceTypeName': 'Samsung OCF Air Conditioner', + 'executionContext': 'CLOUD', + 'label': 'AC Office Granit', + 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', + 'manufacturerName': 'Samsung Electronics', + 'name': '[room a/c] Samsung', + 'ocf': dict({ + 'additionalAuthCodeRequired': False, + 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', + 'manufacturerName': 'Samsung Electronics', + 'ocfDeviceType': 'x.com.st.d.sensor.light', + 'transferCandidate': False, + 'vendorId': 'VD-Sensor.Light-2023', + }), + 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', + 'presentationId': 'DA-AC-RAC-000001', + 'profile': dict({ + 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', + }), + 'restrictionTier': 0, + 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'type': 'OCF', + }), + ]), + }), + ]), + }) +# --- +# name: test_device_diagnostics[da_ac_rac_000001] + dict({ + 'events': list([ + ]), + 'info': dict({ + 'allowed': list([ + ]), + 'components': list([ + dict({ + 'capabilities': list([ + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'refresh', + 'version': 1, + }), + dict({ + 'id': 'execute', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledComponents', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'samsungce.deviceIdentification', + 'version': 1, + }), + dict({ + 'id': 'samsungce.dongleSoftwareInstallation', + 'version': 1, + }), + dict({ + 'id': 'samsungce.softwareUpdate', + 'version': 1, + }), + dict({ + 'id': 'samsungce.selfCheck', + 'version': 1, + }), + dict({ + 'id': 'samsungce.driverVersion', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'AirConditioner', + }), + ]), + 'id': 'main', + 'label': 'main', + }), + dict({ + 'capabilities': list([ + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'odorSensor', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'Other', + }), + ]), + 'id': '1', + 'label': '1', + }), + ]), + 'createTime': '2021-04-06T16:43:34.753Z', + 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'deviceManufacturerCode': 'Samsung Electronics', + 'deviceTypeName': 'Samsung OCF Air Conditioner', + 'executionContext': 'CLOUD', + 'label': 'AC Office Granit', + 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', + 'manufacturerName': 'Samsung Electronics', + 'name': '[room a/c] Samsung', + 'ocf': dict({ + 'additionalAuthCodeRequired': False, + 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', + 'manufacturerName': 'Samsung Electronics', + 'ocfDeviceType': 'x.com.st.d.sensor.light', + 'transferCandidate': False, + 'vendorId': 'VD-Sensor.Light-2023', + }), + 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', + 'presentationId': 'DA-AC-RAC-000001', + 'profile': dict({ + 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', + }), + 'restrictionTier': 0, + 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'type': 'OCF', + }), + 'status': dict({ + 'components': dict({ + '1': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'value': None, + }), + 'fanMode': dict({ + 'timestamp': '2021-04-06T16:44:10.381Z', + 'value': None, + }), + 'supportedAcFanModes': dict({ + 'timestamp': '2024-09-10T10:26:28.605Z', + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'timestamp': '2021-04-08T03:50:50.930Z', + 'value': None, + }), + 'availableAcModes': dict({ + 'value': None, + }), + 'supportedAcModes': dict({ + 'timestamp': '2021-04-08T03:50:50.930Z', + 'value': None, + }), + }), + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'timestamp': '2021-04-06T16:57:57.602Z', + 'unit': 'CAQI', + 'value': None, + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'timestamp': '2021-04-06T16:43:53.541Z', + 'unit': '%', + 'value': None, + }), + }), + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'timestamp': '2021-04-08T04:11:38.269Z', + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'timestamp': '2021-04-08T04:11:38.269Z', + 'value': None, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'timestamp': '2021-04-06T16:57:57.659Z', + 'value': None, + }), + 'supportedAcOptionalMode': dict({ + 'timestamp': '2021-04-06T16:57:57.659Z', + 'value': None, + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.498Z', + 'value': None, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'timestamp': '2021-04-06T16:43:53.344Z', + 'value': None, + }), + 'operatingState': dict({ + 'value': None, + }), + 'progress': dict({ + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'value': None, + }), + 'timedCleanDuration': dict({ + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'value': None, + }), + }), + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + }), + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), + 'reportStateRealtime': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'timestamp': '2024-09-10T10:26:28.605Z', + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'odorSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'audioVolume', + 'custom.autoCleaningMode', + 'custom.airConditionerTropicalNightMode', + 'custom.airConditionerOdorController', + 'demandResponseLoadControl', + 'relativeHumidityMeasurement', + ]), + }), + }), + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'value': None, + }), + 'energySavingInfo': dict({ + 'value': None, + }), + 'energySavingLevel': dict({ + 'value': None, + }), + 'energySavingOperation': dict({ + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'value': None, + }), + 'energySavingSupport': dict({ + 'value': None, + }), + 'energyType': dict({ + 'timestamp': '2021-04-06T16:43:38.843Z', + 'value': None, + }), + 'notificationTemplateID': dict({ + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'timestamp': '2021-04-06T16:57:57.686Z', + 'value': None, + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'timestamp': '2021-04-08T04:04:19.901Z', + 'value': None, + }), + 'minimumSetpoint': dict({ + 'timestamp': '2021-04-08T04:04:19.901Z', + 'value': None, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'timestamp': '2021-04-06T16:43:54.748Z', + 'value': None, + }), + }), + 'dustSensor': dict({ + 'dustLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.122Z', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.122Z', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'value': None, + }), + 'fanOscillationMode': dict({ + 'timestamp': '2025-02-08T00:44:53.247Z', + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'timestamp': '2021-04-06T16:44:10.325Z', + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'dmv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'icv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mndt': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnfv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnhw': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnml': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnmn': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnmo': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnos': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnpv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnsl': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'n': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'pi': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'st': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'vid': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + }), + 'odorSensor': dict({ + 'odorLevel': dict({ + 'timestamp': '2021-04-06T16:43:38.992Z', + 'value': None, + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'timestamp': '2021-04-06T16:43:53.364Z', + 'value': None, + }), + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'timestamp': '2021-04-06T16:43:35.291Z', + 'unit': '%', + 'value': 0, + }), + }), + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'timestamp': '2021-04-06T16:43:39.097Z', + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'timestamp': '2021-04-06T16:44:10.518Z', + 'value': None, + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'timestamp': '2021-04-06T16:44:10.373Z', + 'value': None, + }), + 'temperatureRange': dict({ + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'timestamp': '2021-04-06T16:43:59.136Z', + 'value': None, + }), + 'coolingSetpointRange': dict({ + 'value': None, + }), + }), + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:38.529Z', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + }), + 'main': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'value': None, + }), + 'fanMode': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': 'low', + }), + 'supportedAcFanModes': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'heat', + }), + 'availableAcModes': dict({ + 'value': None, + }), + 'supportedAcModes': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': list([ + 'cool', + 'dry', + 'wind', + 'auto', + 'heat', + ]), + }), + }), + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'timestamp': '2021-04-06T16:43:37.208Z', + 'unit': 'CAQI', + 'value': None, + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'unit': '%', + 'value': 100, + }), + }), + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'timestamp': '2021-04-06T16:43:37.555Z', + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'timestamp': '2021-04-06T16:43:37.555Z', + 'value': None, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'windFree', + }), + 'supportedAcOptionalMode': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': list([ + 'off', + 'windFree', + ]), + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 0, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'off', + }), + 'operatingState': dict({ + 'value': None, + }), + 'progress': dict({ + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'value': None, + }), + 'timedCleanDuration': dict({ + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'value': None, + }), + }), + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + }), + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + 'reportStateRealtime': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'samsungce.dongleSoftwareInstallation', + 'demandResponseLoadControl', + 'custom.airConditionerOdorController', + ]), + }), + }), + 'custom.disabledComponents': dict({ + 'disabledComponents': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': list([ + '1', + ]), + }), + }), + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'value': None, + }), + 'energySavingInfo': dict({ + 'value': None, + }), + 'energySavingLevel': dict({ + 'value': None, + }), + 'energySavingOperation': dict({ + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'value': None, + }), + 'energySavingSupport': dict({ + 'timestamp': '2021-12-29T07:29:17.526Z', + 'value': False, + }), + 'energyType': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': '1.0', + }), + 'notificationTemplateID': dict({ + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'off', + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'unit': 'C', + 'value': 30, + }), + 'minimumSetpoint': dict({ + 'timestamp': '2025-01-08T06:30:58.307Z', + 'unit': 'C', + 'value': 16, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': dict({ + 'drlcLevel': -1, + 'drlcType': 1, + 'duration': 0, + 'override': False, + 'start': '1970-01-01T00:00:00Z', + }), + }), + }), + 'dustSensor': dict({ + 'dustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.665Z', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.665Z', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + 'execute': dict({ + 'data': dict({ + 'data': dict({ + 'href': '/temperature/desired/0', + }), + 'timestamp': '2023-07-19T03:07:43.270Z', + 'value': dict({ + 'payload': dict({ + 'if': list([ + 'oic.if.baseline', + 'oic.if.a', + ]), + 'range': list([ + 16.0, + 30.0, + ]), + 'rt': list([ + 'oic.r.temperature', + ]), + 'temperature': 22.0, + 'units': 'C', + }), + }), + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'value': None, + }), + 'fanOscillationMode': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'timestamp': '2021-04-06T16:43:35.782Z', + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'dmv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'res.1.1.0,sh.1.1.0', + }), + 'icv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'core.1.1.0', + }), + 'mndt': dict({ + 'timestamp': '2021-04-06T16:43:35.912Z', + 'value': None, + }), + 'mnfv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '0.1.0', + }), + 'mnhw': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '1.0', + }), + 'mnml': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'http://www.samsung.com', + }), + 'mnmn': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'Samsung Electronics', + }), + 'mnmo': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + }), + 'mnos': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'TizenRT2.0', + }), + 'mnpv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '0G3MPDCKA00010E', + }), + 'mnsl': dict({ + 'timestamp': '2021-04-06T16:43:35.803Z', + 'value': None, + }), + 'n': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '[room a/c] Samsung', + }), + 'pi': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'st': dict({ + 'timestamp': '2021-04-06T16:43:35.933Z', + 'value': None, + }), + 'vid': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'DA-AC-RAC-000001', + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'timestamp': '2025-02-09T16:15:33.639Z', + 'value': dict({ + 'deltaEnergy': 400, + 'end': '2025-02-09T16:15:33Z', + 'energy': 2247300, + 'energySaved': 0, + 'persistedEnergy': 2247300, + 'power': 0, + 'powerEnergy': 0.0, + 'start': '2025-02-09T15:45:29Z', + }), + }), + }), + 'refresh': dict({ + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'timestamp': '2024-12-30T13:10:23.759Z', + 'unit': '%', + 'value': 60, + }), + }), + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'timestamp': '2021-04-06T16:43:35.379Z', + 'value': None, + }), + }), + 'samsungce.deviceIdentification': dict({ + 'binaryId': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': 'ARTIK051_KRAC_18K', + }), + 'description': dict({ + 'value': None, + }), + 'micomAssayCode': dict({ + 'value': None, + }), + 'modelClassificationCode': dict({ + 'value': None, + }), + 'modelName': dict({ + 'value': None, + }), + 'releaseYear': dict({ + 'value': None, + }), + 'serialNumber': dict({ + 'value': None, + }), + 'serialNumberExtra': dict({ + 'value': None, + }), + }), + 'samsungce.dongleSoftwareInstallation': dict({ + 'status': dict({ + 'timestamp': '2021-12-29T01:36:51.289Z', + 'value': 'completed', + }), + }), + 'samsungce.driverVersion': dict({ + 'versionNumber': dict({ + 'timestamp': '2024-09-04T06:35:09.557Z', + 'value': 24070101, + }), + }), + 'samsungce.selfCheck': dict({ + 'errors': dict({ + 'timestamp': '2025-02-08T00:44:53.349Z', + 'value': list([ + ]), + }), + 'progress': dict({ + 'value': None, + }), + 'result': dict({ + 'value': None, + }), + 'status': dict({ + 'timestamp': '2025-02-08T00:44:53.549Z', + 'value': 'ready', + }), + 'supportedActions': dict({ + 'timestamp': '2024-09-04T06:35:09.557Z', + 'value': list([ + 'start', + ]), + }), + }), + 'samsungce.softwareUpdate': dict({ + 'availableModules': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': list([ + ]), + }), + 'lastUpdatedDate': dict({ + 'value': None, + }), + 'newVersionAvailable': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': False, + }), + 'operatingState': dict({ + 'value': None, + }), + 'otnDUID': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': '43CEZFTFFL7Z2', + }), + 'progress': dict({ + 'value': None, + }), + 'targetModule': dict({ + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'timestamp': '2025-02-09T16:37:54.072Z', + 'value': 'off', + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'timestamp': '2025-02-09T16:33:29.164Z', + 'unit': 'C', + 'value': 25, + }), + 'temperatureRange': dict({ + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'timestamp': '2025-02-09T09:15:11.608Z', + 'unit': 'C', + 'value': 25, + }), + 'coolingSetpointRange': dict({ + 'value': None, + }), + }), + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.363Z', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_event.ambr b/tests/components/smartthings/snapshots/test_event.ambr new file mode 100644 index 00000000000..79c57df5fd7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_event.ambr @@ -0,0 +1,361 @@ +# serializer version: 1 +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button1', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button1_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button1', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button2', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button2_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button2', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button3', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button3_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button3', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button4', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button4_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button4', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button5_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button5', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button6', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button6_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button6', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr index 33caffcacc6..1196118b3b5 100644 --- a/tests/components/smartthings/snapshots/test_fan.ambr +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -37,7 +37,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'f1af21a2-d5a1-437c-b10a-b34a87394b71', + 'unique_id': 'f1af21a2-d5a1-437c-b10a-b34a87394b71_main', 'unit_of_measurement': None, }) # --- @@ -62,6 +62,60 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', + }) +# --- +# name: test_all_entities[generic_fan_3_speed][fan.bedroom_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.bedroom_fan', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '6d95a8b7-4ee3-429a-a13a-00ec9354170c_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_fan_3_speed][fan.bedroom_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Fan', + 'percentage': 0, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.bedroom_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', }) # --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 546d99a967f..58b89099b11 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1,5 +1,8 @@ # serializer version: 1 -# name: test_devices[aeotec_home_energy_meter_gen5] +# name: test_button_event[button] + +# --- +# name: test_devices[abl_light_b_001] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -11,6 +14,39 @@ 'entry_type': None, 'hw_version': None, 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7c16163e-c94e-482f-95f6-139ae0cd9d5e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Kitchen Light 5', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[aeotec_home_energy_meter_gen5] + DeviceRegistryEntrySnapshot({ + 'area_id': 'toilet', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , 'identifiers': set({ tuple( 'smartthings', @@ -27,6 +63,39 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, + 'suggested_area': 'Toilet', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[aux_ac] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'bf53a150-f8a4-45d1-aac4-86252475d551', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'AUX A/C on-off', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -34,7 +103,7 @@ # --- # name: test_devices[base_electric_meter] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -60,6 +129,39 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[bosch_radiator_thermostat_ii] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Radiator Thermostat II [+M] Wohnzimmer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -67,7 +169,7 @@ # --- # name: test_devices[c2c_arlo_pro_3_switch] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -86,14 +188,14 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Arlo', + 'model': 'VMC4041PB', 'model_id': None, 'name': '2nd Floor Hallway', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -108,7 +210,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'WoCurtain3-WoCurtain3', 'id': , 'identifiers': set({ tuple( @@ -119,8 +221,8 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'WonderLabs Company', + 'model': 'WoCurtain3', 'model_id': None, 'name': 'Curtain 1A', 'name_by_user': None, @@ -133,7 +235,7 @@ # --- # name: test_devices[centralite] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -159,14 +261,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[contact_sensor] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -192,12 +294,12 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- -# name: test_devices[da_ac_rac_000001] +# name: test_devices[da_ac_airsensor_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -207,7 +309,73 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': '1.0', + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a3a970ea-e09c-9c04-161b-94c934e21666', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ASM-KR-TP1-22-ACMB1M', + 'model_id': None, + 'name': '에어모니터 플러스', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'ASM-KR-TP1-22-ACMB1M_16240426', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ac_ehs_01001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '4165c51e-bf6b-c5b6-fd53-127d6248754b', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_DA_AC_EHS_01001_0000', + 'model_id': None, + 'name': 'Heat pump', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AEH-WW-TP1-22-AE6000_17240903', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ac_rac_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, 'id': , 'identifiers': set({ tuple( @@ -219,20 +387,53 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + 'model': None, 'model_id': None, 'name': 'AC Office Granit', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ac_rac_000003] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'c76d6f38-1b7f-13dd-37b5-db18d5272783', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ARTIK051_PRAC_20K', + 'model_id': None, + 'name': 'Office AirFree', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, 'suggested_area': None, - 'sw_version': '0.1.0', + 'sw_version': 'ARTIK051_PRAC_20K_11230313', 'via_device_id': None, }) # --- # name: test_devices[da_ac_rac_01001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -252,20 +453,86 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000', + 'model': 'ARA-WW-TP1-22-COMMON', 'model_id': None, 'name': 'Aire Dormitorio Principal', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', 'via_device_id': None, }) # --- +# name: test_devices[da_ac_rac_100001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'F8042E25-0E53-0000-0000-000000000000', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP6X_RAC_15K', + 'model_id': None, + 'name': 'Corridor A/C', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ks_cooktop_31001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '808dbd84-f357-47e2-a0cd-3b66fa22d584', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Induction Hob', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -285,20 +552,86 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000', + 'model': 'TP2X_DA-KS-MICROWAVE-0101X', 'model_id': None, 'name': 'Microwave', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', 'via_device_id': None, }) # --- +# name: test_devices[da_ks_oven_01061] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '9447959a-0dfa-6b27-d40d-650da525c53f', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_DA-KS-OVEN-01061', + 'model_id': None, + 'name': 'Oven', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AKS-WW-TP1X-21-OVEN_40211229', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ks_range_0101x] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_DA-KS-RANGE-0101X', + 'model_id': None, + 'name': 'Vulcan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AKS-WW-TP1-20-OVEN-3-CR_40240205', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ref_normal_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -318,20 +651,86 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'TP2X_REF_20K|00115641|0004014D011411200103000020000000', + 'model': 'TP2X_REF_20K', + 'model_id': None, + 'name': 'Refrigerator', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ref_normal_01001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7d3feb98-8a36-4351-c362-5e21ad3a78dd', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': '24K_REF_LCD_FHUB9.0', 'model_id': None, 'name': 'Refrigerator', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', + 'sw_version': '20240616.213423', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ref_normal_01011] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '5758b2ec-563e-f39b-ec39-208e54aabf60', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_REF_21K', + 'model_id': None, + 'name': 'Frigo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'A-RFWW-TP1-22-REV1_20241030', 'via_device_id': None, }) # --- # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -351,20 +750,119 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'powerbot_7000_17M|50016055|80010404011141000100000000000000', + 'model': 'powerbot_7000_17M', 'model_id': None, 'name': 'Robot vacuum', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': '1.0', 'via_device_id': None, }) # --- +# name: test_devices[da_sac_ehs_000001_sub] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '1f98ebd0-ac48-d802-7f62-000001200100', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'SAC_EHS_MONO', + 'model_id': None, + 'name': 'Eco Heating System', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '20250317.1', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_sac_ehs_000001_sub_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '6a7d5349-0a66-0277-058d-000001200101', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'SAC_EHS_MONO', + 'model_id': None, + 'name': 'Heat Pump Main', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '20250317.1', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_sac_ehs_000002_sub] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3810e5ad-5351-d9f9-12ff-000001200000', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'SAC_EHS_SPLIT', + 'model_id': None, + 'name': 'Wärmepumpe', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '20250317.1', + 'via_device_id': None, + }) +# --- # name: test_devices[da_wm_dw_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -384,20 +882,53 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000', + 'model': 'DA_DW_A51_20_COMMON', 'model_id': None, 'name': 'Dishwasher', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'DA_DW_A51_20_COMMON_30230714', 'via_device_id': None, }) # --- +# name: test_devices[da_wm_sc_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_DF_TP2_20_COMMON', + 'model_id': None, + 'name': 'AirDresser', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_DF_TP2_20_COMMON_30230807', + 'via_device_id': None, + }) +# --- # name: test_devices[da_wm_wd_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -417,12 +948,45 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000', + 'model': 'DA_WM_A51_20_COMMON', 'model_id': None, 'name': 'Dryer', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': 'DA_WM_A51_20_COMMON_30230708', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wd_000001_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3a6c4e05-811d-5041-e956-3d04c424cbcd', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON', + 'model_id': None, + 'name': 'Seca-Roupa', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, 'suggested_area': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, @@ -430,7 +994,7 @@ # --- # name: test_devices[da_wm_wm_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -450,14 +1014,113 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000', + 'model': 'DA_WM_TP2_20_COMMON', + 'model_id': None, + 'name': 'Washer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wm_000001_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '63803fae-cbed-f356-a063-2cf148ae3ca7', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON', + 'model_id': None, + 'name': 'Washing Machine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_A51_20_COMMON_30230708', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wm_01011] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'b854ca5f-dc54-140d-6349-758b4d973c41', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_TP1_21_COMMON', + 'model_id': None, + 'name': 'Machine à Laver', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_TP1_21_COMMON_30240927', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wm_100001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP6X_WA54M8750AV', 'model_id': None, 'name': 'Washer', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', + 'sw_version': None, 'via_device_id': None, }) # --- @@ -471,7 +1134,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': '250206213001', 'id': , 'identifiers': set({ tuple( @@ -482,15 +1145,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'ecobee', + 'model': 'aresSmart-ecobee3_remote_sensor', 'model_id': None, 'name': 'Child Bedroom', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '250206213001', 'via_device_id': None, }) # --- @@ -504,7 +1167,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': '250206151734', 'id': , 'identifiers': set({ tuple( @@ -515,21 +1178,54 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'ecobee', + 'model': 'aresSmart-thermostat', 'model_id': None, 'name': 'Main Floor', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '250206151734', + 'via_device_id': None, + }) +# --- +# name: test_devices[ecobee_thermostat_offline] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '250308073247', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '1888b38f-6246-4f1e-911b-bfcfb66999db', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ecobee', + 'model': 'nikeSmart-thermostat', + 'model_id': None, + 'name': 'Downstairs', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '250308073247', 'via_device_id': None, }) # --- # name: test_devices[fake_fan] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -555,6 +1251,39 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[gas_meter] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3b57dca3-9a90-4f27-ba80-f947b1e60d58', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'CopperLabs', + 'model': 'Virtual Gas Meter', + 'model_id': None, + 'name': 'Gas Meter', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -562,7 +1291,7 @@ # --- # name: test_devices[ge_in_wall_smart_dimmer] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -588,6 +1317,138 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[generic_ef00_v1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '656569c2-7976-4232-a789-34b4d1176c3a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Thermostat Küche', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[generic_fan_3_speed] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '6d95a8b7-4ee3-429a-a13a-00ec9354170c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Bedroom Fan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[heatit_zpushwall] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '5e5b97f3-3094-44e6-abc0-f61283412d6a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Livingroom smart switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[heatit_ztrm3_thermostat] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '69a271f6-6537-4982-8cd9-979866872692', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Hall thermostat', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -603,7 +1464,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'LTG002', 'id': , 'identifiers': set({ tuple( @@ -614,19 +1475,85 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue ambiance spot', 'model_id': None, 'name': 'Bathroom spot', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '1.122.2', 'via_device_id': None, }) # --- # name: test_devices[hue_rgbw_color_bulb] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'LCA001', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'cb958955-b015-498c-9e62-fc0c51abd054', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue color lamp', + 'model_id': None, + 'name': 'Standing light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.122.2', + 'via_device_id': None, + }) +# --- +# name: test_devices[hw_q80r_soundbar] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0-0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'afcf3b91-0000-1111-2222-ddff2a0a6577', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'Q80R', + 'model_id': None, + 'name': 'Soundbar', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'HW-Q80RWWB-1012.6', + 'via_device_id': None, + }) +# --- +# name: test_devices[ikea_kadrilj] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -641,7 +1568,7 @@ 'identifiers': set({ tuple( 'smartthings', - 'cb958955-b015-498c-9e62-fc0c51abd054', + '71afed1c-006d-4e48-b16e-e7f88f9fd638', ), }), 'is_new': False, @@ -650,7 +1577,7 @@ 'manufacturer': None, 'model': None, 'model_id': None, - 'name': 'Standing light', + 'name': 'Kitchen IKEA KADRILJ Window blind', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, @@ -659,6 +1586,72 @@ 'via_device_id': None, }) # --- +# name: test_devices[im_smarttag2_ble_uwb] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '83d660e4-b0c8-4881-a674-d9f1730366c1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'SmartTag+ black', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[im_speaker_ai_0001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'SM-V310', + 'model_id': None, + 'name': 'Galaxy Home Mini', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'V310XXU1AWK1', + 'via_device_id': None, + }) +# --- # name: test_devices[iphone] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -692,7 +1685,7 @@ 'via_device_id': None, }) # --- -# name: test_devices[multipurpose_sensor] +# name: test_devices[lumi] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -704,6 +1697,39 @@ 'entry_type': None, 'hw_version': None, 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '692ea4e9-2022-4ed8-8a57-1b884a59cc38', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Outdoor Temp', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[multipurpose_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , 'identifiers': set({ tuple( 'smartthings', @@ -720,7 +1746,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -735,7 +1761,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'SKY40147', 'id': , 'identifiers': set({ tuple( @@ -746,21 +1772,21 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Sensibo', + 'model': 'skyplus', 'model_id': None, 'name': 'Office', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': 'SKY40147', 'via_device_id': None, }) # --- # name: test_devices[smart_plug] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -786,14 +1812,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[sonos_player] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -819,14 +1845,47 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- +# name: test_devices[tplink_p110] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '6602696a-1e48-49e4-919f-69406f5b5da1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'P110', + 'model_id': None, + 'name': 'Spülmaschine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.3.1 Build 240621 Rel.162048', + 'via_device_id': None, + }) +# --- # name: test_devices[vd_network_audio_002s] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -852,14 +1911,80 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'SAT-iMX8M23WWC-1010.5', 'via_device_id': None, }) # --- +# name: test_devices[vd_network_audio_003s] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'HW-S60D', + 'model_id': None, + 'name': 'Soundbar', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'SAT-MT8532D24WWC-1016.0', + 'via_device_id': None, + }) +# --- +# name: test_devices[vd_sensor_light_2023] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '5cc1c096-98b9-460c-8f1c-1045509ec605', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'QE55LS03DAUXXN', + 'model_id': None, + 'name': 'Light Sensor - 55" The Frame', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'latest', + 'via_device_id': None, + }) +# --- # name: test_devices[vd_stv_2017_k] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -885,14 +2010,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'T-KTMAKUC-1290.3', 'via_device_id': None, }) # --- # name: test_devices[virtual_thermostat] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -918,14 +2043,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[virtual_valve] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -951,14 +2076,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[virtual_water_sensor] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -984,14 +2109,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[yale_push_button_deadbolt_lock] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -1017,8 +2142,45 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- +# name: test_hub_via_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + tuple( + 'mac', + 'd0:52:a8:72:91:02', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '074fa784-8be8-4c70-8e22-6f5ed6f81b7e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'V2_HUB', + 'model_id': None, + 'name': 'Home Hub', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': '000.055.00005', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr index 8766811c443..6826a555f6a 100644 --- a/tests/components/smartthings/snapshots/test_light.ambr +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -1,4 +1,74 @@ # serializer version: 1 +# name: test_all_entities[abl_light_b_001][light.kitchen_light_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.kitchen_light_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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '7c16163e-c94e-482f-95f6-139ae0cd9d5e_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[abl_light_b_001][light.kitchen_light_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Kitchen Light 5', + 'hs_color': None, + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.kitchen_light_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[centralite][light.dimmer_debian-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -33,7 +103,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad', + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main', 'unit_of_measurement': None, }) # --- @@ -90,7 +160,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', + 'unique_id': 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298_main', 'unit_of_measurement': None, }) # --- @@ -151,7 +221,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '440063de-a200-40b5-8a6b-f3399eaa0370', + 'unique_id': '440063de-a200-40b5-8a6b-f3399eaa0370_main', 'unit_of_measurement': None, }) # --- @@ -232,7 +302,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'cb958955-b015-498c-9e62-fc0c51abd054', + 'unique_id': 'cb958955-b015-498c-9e62-fc0c51abd054_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_lock.ambr b/tests/components/smartthings/snapshots/test_lock.ambr index 2cf9688c3dd..325ce0cc677 100644 --- a/tests/components/smartthings/snapshots/test_lock.ambr +++ b/tests/components/smartthings/snapshots/test_lock.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158', + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_media_player.ambr b/tests/components/smartthings/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..8eca654abe3 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_media_player.ambr @@ -0,0 +1,348 @@ +# serializer version: 1 +# name: test_all_entities[hw_q80r_soundbar][media_player.soundbar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'wifi', + 'bluetooth', + 'HDMI1', + 'HDMI2', + 'digital', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.soundbar', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hw_q80r_soundbar][media_player.soundbar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Soundbar', + 'is_volume_muted': False, + 'media_artist': 'Rick Astley', + 'media_title': 'Never Gonna Give You Up', + 'source': 'wifi', + 'source_list': list([ + 'wifi', + 'bluetooth', + 'HDMI1', + 'HDMI2', + 'digital', + ]), + 'supported_features': , + 'volume_level': 0.01, + }), + 'context': , + 'entity_id': 'media_player.soundbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][media_player.galaxy_home_mini-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.galaxy_home_mini', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][media_player.galaxy_home_mini-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Galaxy Home Mini', + 'is_volume_muted': False, + 'repeat': , + 'shuffle': False, + 'supported_features': , + 'volume_level': 0.52, + }), + 'context': , + 'entity_id': 'media_player.galaxy_home_mini', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[sonos_player][media_player.elliots_rum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.elliots_rum', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sonos_player][media_player.elliots_rum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Elliots Rum', + 'is_volume_muted': False, + 'media_artist': 'David Guetta', + 'media_title': 'Forever Young', + 'supported_features': , + 'volume_level': 0.15, + }), + 'context': , + 'entity_id': 'media_player.elliots_rum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][media_player.soundbar_living-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.soundbar_living', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_002s][media_player.soundbar_living-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Soundbar Living', + 'is_volume_muted': False, + 'media_artist': '', + 'media_title': '', + 'source': 'HDMI1', + 'supported_features': , + 'volume_level': 0.17, + }), + 'context': , + 'entity_id': 'media_player.soundbar_living', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[vd_network_audio_003s][media_player.soundbar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.soundbar', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_003s][media_player.soundbar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Soundbar', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.soundbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][media_player.tv_samsung_8_series_49-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'digitalTv', + 'HDMI1', + 'HDMI4', + 'HDMI4', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tv_samsung_8_series_49', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][media_player.tv_samsung_8_series_49-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': '[TV] Samsung 8 Series (49)', + 'is_volume_muted': True, + 'source': 'HDMI1', + 'source_list': list([ + 'digitalTv', + 'HDMI1', + 'HDMI4', + 'HDMI4', + ]), + 'supported_features': , + 'volume_level': 0.13, + }), + 'context': , + 'entity_id': 'media_player.tv_samsung_8_series_49', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr new file mode 100644 index 00000000000..34073173861 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -0,0 +1,576 @@ +# serializer version: 1 +# name: test_all_entities[da_ks_microwave_0101x][number.microwave_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.microwave_fan_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': 'Fan speed', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hood_fan_speed', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_hood_samsungce.hoodFanSpeed_hoodFanSpeed_hoodFanSpeed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][number.microwave_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Fan speed', + 'max': 3, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.microwave_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][number.refrigerator_cooler_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 7.0, + 'min': 1.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.refrigerator_cooler_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': 'Cooler temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_temperature', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_cooler_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][number.refrigerator_cooler_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Cooler temperature', + 'max': 7.0, + 'min': 1.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_cooler_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][number.refrigerator_freezer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': -15.0, + 'min': -23.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.refrigerator_freezer_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': 'Freezer temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_temperature', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_freezer_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][number.refrigerator_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Freezer temperature', + 'max': -15.0, + 'min': -23.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][number.refrigerator_cooler_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 7.0, + 'min': 1.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.refrigerator_cooler_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': 'Cooler temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_temperature', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cooler_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][number.refrigerator_cooler_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Cooler temperature', + 'max': 7.0, + 'min': 1.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_cooler_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][number.refrigerator_freezer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': -15.0, + 'min': -23.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.refrigerator_freezer_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': 'Freezer temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_temperature', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_freezer_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][number.refrigerator_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Freezer temperature', + 'max': -15.0, + 'min': -23.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][number.frigo_cooler_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 7, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.frigo_cooler_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': 'Cooler temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_temperature', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_cooler_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][number.frigo_cooler_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frigo Cooler temperature', + 'max': 7, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.frigo_cooler_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][number.frigo_freezer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': -15, + 'min': -23, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.frigo_freezer_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': 'Freezer temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_temperature', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_freezer_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][number.frigo_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frigo Freezer temperature', + 'max': -15, + 'min': -23, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.frigo_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.washer_rinse_cycles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rinse cycles', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_rinse_cycles', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles', + 'unit_of_measurement': 'cycles', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Rinse cycles', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cycles', + }), + 'context': , + 'entity_id': 'number.washer_rinse_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][number.washing_machine_rinse_cycles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.washing_machine_rinse_cycles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rinse cycles', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_rinse_cycles', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles', + 'unit_of_measurement': 'cycles', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][number.washing_machine_rinse_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Rinse cycles', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cycles', + }), + 'context': , + 'entity_id': 'number.washing_machine_rinse_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][number.machine_a_laver_rinse_cycles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.machine_a_laver_rinse_cycles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rinse cycles', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_rinse_cycles', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles', + 'unit_of_measurement': 'cycles', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][number.machine_a_laver_rinse_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Rinse cycles', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cycles', + }), + 'context': , + 'entity_id': 'number.machine_a_laver_rinse_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr new file mode 100644 index 00000000000..c1093bbd209 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -0,0 +1,755 @@ +# serializer version: 1 +# name: test_all_entities[da_ks_microwave_0101x][select.microwave_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.microwave_lamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lamp', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lamp', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_hood_samsungce.lamp_brightnessLevel_brightnessLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][select.microwave_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Lamp', + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.microwave_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][select.oven_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.oven_lamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lamp', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lamp', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_samsungce.lamp_brightnessLevel_brightnessLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][select.oven_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Lamp', + 'options': list([ + 'off', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.oven_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][select.vulcan_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'extra_high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.vulcan_lamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lamp', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lamp', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_samsungce.lamp_brightnessLevel_brightnessLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][select.vulcan_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Lamp', + 'options': list([ + 'off', + 'extra_high', + ]), + }), + 'context': , + 'entity_id': 'select.vulcan_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'extra_high', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][select.dishwasher-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dishwasher', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][select.dishwasher-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.dishwasher', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][select.airdresser-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.airdresser', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][select.airdresser-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.airdresser', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][select.dryer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dryer', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][select.dryer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.dryer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][select.seca_roupa-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.seca_roupa', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][select.seca_roupa-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.seca_roupa', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][select.washer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.washer', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][select.washer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.washer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][select.washing_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.washing_machine', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][select.washing_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.washing_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.machine_a_laver', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.machine_a_laver', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver_detergent_dispense_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'less', + 'standard', + 'extra', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.machine_a_laver_detergent_dispense_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Detergent dispense amount', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'detergent_amount', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.autoDispenseDetergent_amount_amount', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver_detergent_dispense_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Detergent dispense amount', + 'options': list([ + 'none', + 'less', + 'standard', + 'extra', + ]), + }), + 'context': , + 'entity_id': 'select.machine_a_laver_detergent_dispense_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'standard', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver_flexible_compartment_dispense_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'less', + 'standard', + 'extra', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.machine_a_laver_flexible_compartment_dispense_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flexible compartment dispense amount', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flexible_detergent_amount', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.flexibleAutoDispenseDetergent_amount_amount', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][select.machine_a_laver_flexible_compartment_dispense_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Flexible compartment dispense amount', + 'options': list([ + 'none', + 'less', + 'standard', + 'extra', + ]), + }), + 'context': , + 'entity_id': 'select.machine_a_laver_flexible_compartment_dispense_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'standard', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][select.washer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'run', + 'pause', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.washer', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][select.washer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer', + 'options': list([ + 'run', + 'pause', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'select.washer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 778b05fa183..2884ded50af 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,7 +14,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'entity_id': 'sensor.aeotec_energy_monitor_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -31,11 +31,11 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.energy', + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_energyMeter_energy_energy', 'unit_of_measurement': 'kWh', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -44,7 +44,7 @@ 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'entity_id': 'sensor.aeotec_energy_monitor_energy', 'last_changed': , 'last_reported': , 'last_updated': , @@ -83,7 +83,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.power', + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_powerMeter_power_power', 'unit_of_measurement': 'W', }) # --- @@ -135,7 +135,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.voltage', + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_voltageMeasurement_voltage_voltage', 'unit_of_measurement': None, }) # --- @@ -154,6 +154,58 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[aux_ac][sensor.aux_a_c_on_off_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aux_a_c_on_off_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[aux_ac][sensor.aux_a_c_on_off_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AUX A/C on-off Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aux_a_c_on_off_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- # name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -186,7 +238,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.energy', + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b_main_energyMeter_energy_energy', 'unit_of_measurement': 'kWh', }) # --- @@ -238,7 +290,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.power', + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b_main_powerMeter_power_power', 'unit_of_measurement': 'W', }) # --- @@ -258,6 +310,107 @@ 'state': '938.3', }) # --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main_battery_battery_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '94', + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.9', + }) +# --- # name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -295,7 +448,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'alarm', - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.alarm', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_alarm_alarm_alarm', 'unit_of_measurement': None, }) # --- @@ -349,7 +502,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.battery', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -400,7 +553,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad.power', + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main_powerMeter_power_power', 'unit_of_measurement': 'W', }) # --- @@ -450,7 +603,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.battery', + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -501,7 +654,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.temperature', + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -521,6 +674,693 @@ 'state': '15.0', }) # --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_air_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_airQualitySensor_airQuality_airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '에어모니터 플러스 Air quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_carbonDioxideMeasurement_carbonDioxide_carbonDioxide', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': '에어모니터 플러스 Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1045', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': '에어모니터 플러스 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_odor_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_odor_sensor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Odor sensor', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'odor_sensor', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_odorSensor_odorLevel_odorLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_odor_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '에어모니터 플러스 Odor sensor', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_odor_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_veryFineDustSensor_veryFineDustLevel_veryFineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': '에어모니터 플러스 PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_dustSensor_dustLevel_dustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': '에어모니터 플러스 PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_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': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_dustSensor_fineDustLevel_fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': '에어모니터 플러스 PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '에어모니터 플러스 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.0', + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat pump Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4053.792', + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat pump Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat pump Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat pump Power', + 'power_consumption_end': '2025-05-14T13:26:17Z', + 'power_consumption_start': '2025-05-13T23:00:23Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][sensor.heat_pump_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat pump Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -545,6 +1385,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -553,7 +1396,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energy_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -579,7 +1422,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -597,6 +1440,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -605,7 +1451,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -614,7 +1460,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'AC Office Granit Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -649,6 +1495,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -657,7 +1506,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -709,7 +1558,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -753,6 +1602,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -761,7 +1613,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.power_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -789,7 +1641,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -807,6 +1659,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -815,7 +1670,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.powerEnergy_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -824,7 +1679,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'AC Office Granit Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -867,7 +1722,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.temperature', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -917,7 +1772,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.volume', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- @@ -935,6 +1790,435 @@ 'state': '100', }) # --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '602.171', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Office AirFree Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_airfree_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Office AirFree Power', + 'power_consumption_end': '2025-03-27T05:40:02Z', + 'power_consumption_start': '2025-03-27T05:29:22Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Office AirFree Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office AirFree Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_airfree_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_airfree_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_audioVolume_volume_volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office AirFree Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_airfree_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -959,6 +2243,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -967,7 +2254,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energy_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -993,7 +2280,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1011,6 +2298,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1019,7 +2309,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -1028,7 +2318,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Aire Dormitorio Principal Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1063,6 +2353,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1071,7 +2364,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -1123,7 +2416,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -1167,6 +2460,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1175,7 +2471,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.power_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -1203,7 +2499,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1221,6 +2517,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1229,7 +2528,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.powerEnergy_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -1238,7 +2537,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Aire Dormitorio Principal Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1281,7 +2580,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.temperature', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -1331,7 +2630,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.volume', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- @@ -1349,6 +2648,696 @@ 'state': '0', }) # --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_air_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_airQualitySensor_airQuality_airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Corridor A/C Air quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_dustSensor_dustLevel_dustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Corridor A/C PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '46', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_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': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_dustSensor_fineDustLevel_fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Corridor A/C PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Corridor A/C Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_1_heating_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_1_heating_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': 'Burner 1 heating mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heating_mode', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-01_samsungce.cooktopHeatingPower_heatingMode_heatingMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_1_heating_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Induction Hob Burner 1 heating mode', + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_1_heating_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_1_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_1_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': 'Burner 1 level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'manual_level', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-01_samsungce.cooktopHeatingPower_manualLevel_manualLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_1_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Induction Hob Burner 1 level', + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_1_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_2_heating_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_2_heating_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': 'Burner 2 heating mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heating_mode', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-02_samsungce.cooktopHeatingPower_heatingMode_heatingMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_2_heating_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Induction Hob Burner 2 heating mode', + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_2_heating_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'boost', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_2_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_2_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': 'Burner 2 level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'manual_level', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-02_samsungce.cooktopHeatingPower_manualLevel_manualLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_2_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Induction Hob Burner 2 level', + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_2_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_3_heating_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_3_heating_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': 'Burner 3 heating mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heating_mode', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-03_samsungce.cooktopHeatingPower_heatingMode_heatingMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_3_heating_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Induction Hob Burner 3 heating mode', + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_3_heating_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'keep_warm', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_3_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_3_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': 'Burner 3 level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'manual_level', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-03_samsungce.cooktopHeatingPower_manualLevel_manualLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_3_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Induction Hob Burner 3 level', + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_3_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_4_heating_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_4_heating_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': 'Burner 4 heating mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heating_mode', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-04_samsungce.cooktopHeatingPower_heatingMode_heatingMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_4_heating_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Induction Hob Burner 4 heating mode', + 'options': list([ + 'manual', + 'boost', + 'keep_warm', + ]), + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_4_heating_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_4_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_burner_4_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': 'Burner 4 level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'manual_level', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_burner-04_samsungce.cooktopHeatingPower_manualLevel_manualLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_burner_4_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Induction Hob Burner 4 level', + }), + 'context': , + 'entity_id': 'sensor.induction_hob_burner_4_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_operating_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'run', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.induction_hob_operating_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': 'Operating state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooktop_operating_state', + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_main_custom.cooktopOperatingState_cooktopOperatingState_cooktopOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][sensor.induction_hob_operating_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Induction Hob Operating state', + 'options': list([ + 'ready', + 'run', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.induction_hob_operating_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1372,20 +3361,21 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', 'friendly_name': 'Microwave Completion time', }), 'context': , @@ -1393,7 +3383,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2025-02-08T21:13:36.184Z', + 'state': '2025-02-08T21:13:36+00:00', }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-entry] @@ -1446,7 +3436,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_job_state', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_ovenJobState_ovenJobState', 'unit_of_measurement': None, }) # --- @@ -1519,7 +3509,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_machine_state', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -1600,7 +3590,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_mode', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenMode', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenMode_ovenMode_ovenMode', 'unit_of_measurement': None, }) # --- @@ -1668,28 +3658,30 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Set point', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_setpoint', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', - 'unit_of_measurement': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', + 'unit_of_measurement': , }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', 'friendly_name': 'Microwave Set point', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.microwave_set_point', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-entry] @@ -1724,7 +3716,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.temperature', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -1744,6 +3736,859 @@ 'state': '-17', }) # --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_completion_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': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_completionTime_completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Oven Completion time', + }), + 'context': , + 'entity_id': 'sensor.oven_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-15T12:06:09+00:00', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_job_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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_job_state', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_ovenJobState_ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Job state', + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'preheat', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_machine_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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_machine_state', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_oven_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': 'Oven mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_mode', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenMode_ovenMode_ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Oven mode', + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_set_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Set point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_setpoint', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Set point', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_completion_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': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_completionTime_completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Vulcan Completion time', + }), + 'context': , + 'entity_id': 'sensor.vulcan_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-14T03:23:28+00:00', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_job_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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_job_state', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_ovenJobState_ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Job state', + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cooking', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_machine_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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_machine_state', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_operating_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'run', + 'ready', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_operating_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': 'Operating state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooktop_operating_state', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_custom.cooktopOperatingState_cooktopOperatingState_cooktopOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_operating_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Operating state', + 'options': list([ + 'run', + 'ready', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_operating_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vulcan_oven_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': 'Oven mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_mode', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenMode_ovenMode_ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Oven mode', + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_set_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Set point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_setpoint', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Vulcan Set point', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vulcan_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Vulcan Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vulcan_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218', + }) +# --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1768,6 +4613,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1776,7 +4624,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energy_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -1802,7 +4650,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1820,6 +4668,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1828,7 +4679,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -1837,7 +4688,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Refrigerator Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1872,6 +4723,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1880,7 +4734,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -1924,6 +4778,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1932,7 +4789,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.power_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -1960,7 +4817,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1978,6 +4835,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1986,7 +4846,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.powerEnergy_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -1995,7 +4855,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Refrigerator Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2006,6 +4866,560 @@ 'state': '0.0135559777781698', }) # --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4381.422', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.027', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Refrigerator Power', + 'power_consumption_end': '2025-02-09T00:25:23Z', + 'power_consumption_start': '2025-02-09T00:13:39Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '144', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0270189050030708', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66.571', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.019', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Frigo Power', + 'power_consumption_end': '2025-03-30T18:38:18Z', + 'power_consumption_start': '2025-03-30T18:21:37Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frigo_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Frigo Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frigo_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0189117822202047', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2036,7 +5450,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.battery', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -2094,7 +5508,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_cleaning_mode', - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerCleaningMode', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerCleaningMode_robotCleanerCleaningMode_robotCleanerCleaningMode', 'unit_of_measurement': None, }) # --- @@ -2163,7 +5577,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_movement', - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerMovement', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerMovement_robotCleanerMovement_robotCleanerMovement', 'unit_of_measurement': None, }) # --- @@ -2230,7 +5644,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_turbo_mode', - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerTurboMode', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerTurboMode_robotCleanerTurboMode_robotCleanerTurboMode', 'unit_of_measurement': None, }) # --- @@ -2254,6 +5668,837 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8901.522', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Eco Heating System Power', + 'power_consumption_end': '2025-05-16T12:01:29Z', + 'power_consumption_start': '2025-05-16T11:18:12Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.015', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.08249458332857e-05', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_main_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Pump Main Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_main_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '297.584', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_main_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Pump Main Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_main_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_main_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Pump Main Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_main_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_main_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Pump Main Power', + 'power_consumption_end': '2025-05-15T21:10:02Z', + 'power_consumption_start': '2025-05-15T20:52:02Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_main_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.015', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_main_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '6a7d5349-0a66-0277-058d-000001200101_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Pump Main Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_main_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.50185416638851e-06', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.warmepumpe_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Wärmepumpe Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warmepumpe_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9575.308', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.warmepumpe_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Wärmepumpe Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warmepumpe_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.045', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.warmepumpe_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Wärmepumpe Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warmepumpe_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.warmepumpe_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Wärmepumpe Power', + 'power_consumption_end': '2025-05-09T05:02:01Z', + 'power_consumption_start': '2025-05-09T04:39:01Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warmepumpe_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.015', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.warmepumpe_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Wärmepumpe Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warmepumpe_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.000222076093320449', + }) +# --- # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2284,7 +6529,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -2326,6 +6571,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2334,7 +6582,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energy_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -2360,7 +6608,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2378,6 +6626,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2386,7 +6637,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -2395,7 +6646,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dishwasher Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2430,6 +6681,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2438,7 +6692,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -2501,7 +6755,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dishwasher_job_state', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_dishwasherJobState_dishwasherJobState', 'unit_of_measurement': None, }) # --- @@ -2567,7 +6821,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dishwasher_machine_state', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -2614,6 +6868,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2622,7 +6879,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.power_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -2650,7 +6907,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2668,6 +6925,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2676,7 +6936,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.powerEnergy_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -2685,7 +6945,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dishwasher Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2696,6 +6956,473 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_completion_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': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_completionTime_completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'AirDresser Completion time', + }), + 'context': , + 'entity_id': 'sensor.airdresser_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-11T09:00:17+00:00', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '207.5', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_job_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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_job_state', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_dryerJobState_dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'AirDresser Job state', + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'context': , + 'entity_id': 'sensor.airdresser_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_machine_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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_machine_state', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'AirDresser Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.airdresser_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'AirDresser Power', + 'power_consumption_end': '2025-02-11T08:21:17Z', + 'power_consumption_start': '2025-02-10T22:51:59Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[da_wm_wd_000001][sensor.dryer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2726,7 +7453,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -2768,6 +7495,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2776,7 +7506,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energy_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -2802,7 +7532,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2820,6 +7550,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2828,7 +7561,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -2837,7 +7570,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dryer Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2872,6 +7605,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2880,7 +7616,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -2948,7 +7684,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_job_state', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_dryerJobState_dryerJobState', 'unit_of_measurement': None, }) # --- @@ -3019,7 +7755,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_machine_state', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -3066,6 +7802,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3074,7 +7813,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.power_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -3102,7 +7841,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -3120,6 +7859,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3128,7 +7870,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.powerEnergy_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -3137,7 +7879,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dryer Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -3148,6 +7890,473 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_completion_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': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_completionTime_completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Seca-Roupa Completion time', + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-09T22:55:37+00:00', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '796.4', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_job_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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_job_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_dryerJobState_dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Seca-Roupa Job state', + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_machine_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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_machine_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Seca-Roupa Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Seca-Roupa Power', + 'power_consumption_end': '2025-03-09T19:47:37Z', + 'power_consumption_start': '2025-03-09T19:47:26Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3178,7 +8387,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -3220,6 +8429,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3228,7 +8440,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energy_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -3254,7 +8466,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -3272,6 +8484,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3280,7 +8495,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -3289,7 +8504,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Washer Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -3324,6 +8539,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3332,7 +8550,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -3401,7 +8619,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_job_state', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_washerJobState_washerJobState', 'unit_of_measurement': None, }) # --- @@ -3473,7 +8691,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_machine_state', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -3520,6 +8738,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3528,7 +8749,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.power_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -3556,7 +8777,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -3574,6 +8795,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3582,7 +8806,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.powerEnergy_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -3591,7 +8815,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Washer Power energy', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -3602,6 +8826,1188 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_completion_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': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_completionTime_completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washing Machine Completion time', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-07T07:01:12+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1323.6', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_job_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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_washerJobState_washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing Machine Job state', + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'wash', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_machine_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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing Machine Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washing Machine Power', + 'power_consumption_end': '2025-03-07T06:23:21Z', + 'power_consumption_start': '2025-03-07T06:21:09Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_completion_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': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_completionTime_completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Machine à Laver Completion time', + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-25T10:34:12+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Machine à Laver Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.8', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Machine à Laver Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Machine à Laver Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_job_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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_washerJobState_washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Machine à Laver Job state', + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'wash', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_machine_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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Machine à Laver Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Machine à Laver Power', + 'power_consumption_end': '2025-04-25T08:43:46Z', + 'power_consumption_start': '2025-04-25T08:28:43Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Machine à Laver Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_water_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.machine_a_laver_water_consumption', + '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 consumption', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_consumption', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.waterConsumptionReport_waterConsumption_waterConsumption', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_01011][sensor.machine_a_laver_water_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Machine à Laver Water consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.machine_a_laver_water_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1642.2', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_completion_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': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_completionTime_completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washer Completion time', + }), + 'context': , + 'entity_id': 'sensor.washer_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-18T14:14:00+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_job_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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_washerJobState_washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer Job state', + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_machine_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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_main_washerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_100001][sensor.washer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3634,7 +10040,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.temperature', + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -3686,7 +10092,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.humidity', + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -3738,7 +10144,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.temperature', + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -3758,6 +10164,927 @@ 'state': '22', }) # --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.downstairs_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Downstairs Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.downstairs_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.downstairs_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Downstairs Temperature', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.downstairs_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_gas', + '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': 'Gas', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterVolume_gasMeterVolume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas Meter Gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_gas_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_meter', + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeter_gasMeter', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Gas Meter Gas meter', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '450.5', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_calorific-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_gas_meter_calorific', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas meter calorific', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_meter_calorific', + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterCalorific_gasMeterCalorific', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_calorific-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas Meter Gas meter calorific', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas_meter_calorific', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_gas_meter_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': 'Gas meter time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_meter_time', + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterTime_gasMeterTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Gas Meter Gas meter time', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas_meter_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-11T13:30:00+00:00', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.thermostat_kuche_link_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_quality', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_signalStrength_lqi_lqi', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Thermostat Küche Link quality', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_link_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '255', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.thermostat_kuche_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_signalStrength_rssi_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Thermostat Küche Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-84', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_kuche_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostat Küche Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- +# name: test_all_entities[heatit_zpushwall][sensor.livingroom_smart_switch_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.livingroom_smart_switch_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_main_battery_battery_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[heatit_zpushwall][sensor.livingroom_smart_switch_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Livingroom smart switch Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.livingroom_smart_switch_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hall_thermostat_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_energyMeter_energy_energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Hall thermostat Energy', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2339.5', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hall_thermostat_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_powerMeter_power_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Hall thermostat Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '368.17', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hall_thermostat_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hall thermostat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.0', + }) +# --- +# name: test_all_entities[ikea_kadrilj][sensor.kitchen_ikea_kadrilj_window_blind_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_ikea_kadrilj_window_blind_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main_battery_battery_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ikea_kadrilj][sensor.kitchen_ikea_kadrilj_window_blind_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.kitchen_ikea_kadrilj_window_blind_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.outdoor_temp_atmospheric_pressure', + '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': 'Atmospheric pressure', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '692ea4e9-2022-4ed8-8a57-1b884a59cc38_main_atmosphericPressureMeasurement_atmosphericPressure_atmosphericPressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Outdoor Temp Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outdoor_temp_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.outdoor_temp_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '692ea4e9-2022-4ed8-8a57-1b884a59cc38_main_battery_battery_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Outdoor Temp Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.outdoor_temp_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.outdoor_temp_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '692ea4e9-2022-4ed8-8a57-1b884a59cc38_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Outdoor Temp Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.outdoor_temp_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.24', + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.outdoor_temp_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '692ea4e9-2022-4ed8-8a57-1b884a59cc38_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[lumi][sensor.outdoor_temp_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Outdoor Temp Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outdoor_temp_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.4', + }) +# --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3788,7 +11115,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.battery', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -3839,7 +11166,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.temperature', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -3889,7 +11216,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'x_coordinate', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c X Coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_x_coordinate', 'unit_of_measurement': None, }) # --- @@ -3936,7 +11263,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'y_coordinate', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_y_coordinate', 'unit_of_measurement': None, }) # --- @@ -3983,7 +11310,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'z_coordinate', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Z Coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_z_coordinate', 'unit_of_measurement': None, }) # --- @@ -4030,7 +11357,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_conditioner_mode', - 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.airConditionerMode', + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_airConditionerMode_airConditionerMode_airConditionerMode', 'unit_of_measurement': None, }) # --- @@ -4077,7 +11404,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thermostat_cooling_setpoint', - 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.coolingSetpoint', + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', 'unit_of_measurement': , }) # --- @@ -4096,20 +11423,13 @@ 'state': '20', }) # --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-entry] +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -4118,7 +11438,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.elliots_rum_media_playback_status', + 'entity_id': 'sensor.spulmaschine_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4127,46 +11447,45 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Media playback status', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.playbackStatus', - 'unit_of_measurement': None, + 'translation_key': None, + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , }) # --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Elliots Rum Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), + 'device_class': 'energy', + 'friendly_name': 'Spülmaschine Energy', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.elliots_rum_media_playback_status', + 'entity_id': 'sensor.spulmaschine_energy', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'playing', + 'state': '15.72', }) # --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-entry] +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4174,7 +11493,62 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.elliots_rum_volume', + 'entity_id': 'sensor.spulmaschine_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Spülmaschine Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spulmaschine_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[vd_sensor_light_2023][sensor.light_sensor_55_the_frame_brightness_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.light_sensor_55_the_frame_brightness_intensity', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4186,266 +11560,28 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Volume', + 'original_name': 'Brightness intensity', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.volume', - 'unit_of_measurement': '%', + 'translation_key': 'brightness_intensity', + 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605_main_relativeBrightness_brightnessIntensity_brightnessIntensity', + 'unit_of_measurement': 'level', }) # --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-state] +# name: test_all_entities[vd_sensor_light_2023][sensor.light_sensor_55_the_frame_brightness_intensity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Elliots Rum Volume', - 'unit_of_measurement': '%', + 'friendly_name': 'Light Sensor - 55" The Frame Brightness intensity', + 'state_class': , + 'unit_of_measurement': 'level', }), 'context': , - 'entity_id': 'sensor.elliots_rum_volume', + 'entity_id': 'sensor.light_sensor_55_the_frame_brightness_intensity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '15', - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.soundbar_living_media_playback_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': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Soundbar Living Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.soundbar_living_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stopped', - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.soundbar_living_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Living Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.soundbar_living_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'digitaltv', - 'hdmi1', - 'hdmi4', - 'hdmi4', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_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': 'Media input source', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_input_source', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.inputSource', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': '[TV] Samsung 8 Series (49) Media input source', - 'options': list([ - 'digitaltv', - 'hdmi1', - 'hdmi4', - 'hdmi4', - ]), - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'hdmi1', - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_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': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': '[TV] Samsung 8 Series (49) Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': '2', }) # --- # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-entry] @@ -4478,7 +11614,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'tv_channel', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannel', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_tvChannel_tvChannel_tvChannel', 'unit_of_measurement': None, }) # --- @@ -4525,7 +11661,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'tv_channel_name', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannelName', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_tvChannel_tvChannelName_tvChannelName', 'unit_of_measurement': None, }) # --- @@ -4542,54 +11678,6 @@ 'state': '', }) # --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.tv_samsung_8_series_49_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13', - }) -# --- # name: test_all_entities[virtual_thermostat][sensor.asd_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4620,7 +11708,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.battery', + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -4671,7 +11759,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.temperature', + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -4721,7 +11809,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.battery', + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -4770,7 +11858,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158.battery', + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index d12bd4ea5b6..be9253dd388 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -46,7 +46,7 @@ 'state': 'on', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-entry] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,7 +59,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.microwave', + 'entity_id': 'switch.refrigerator_ice_maker', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -71,28 +71,122 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Ice maker', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', + 'translation_key': 'ice_maker', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_icemaker_switch_switch_switch', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-state] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave', + 'friendly_name': 'Refrigerator Ice maker', }), 'context': , - 'entity_id': 'switch.microwave', + 'entity_id': 'switch.refrigerator_ice_maker', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_sabbath_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.refrigerator_sabbath_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': 'Sabbath mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sabbath_mode', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_samsungce.sabbathMode_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_sabbath_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Sabbath mode', + }), + 'context': , + 'entity_id': 'switch.refrigerator_sabbath_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.refrigerator_ice_maker', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ice maker', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ice_maker', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_icemaker_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Ice maker', + }), + 'context': , + 'entity_id': 'switch.refrigerator_ice_maker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -123,7 +217,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -140,7 +234,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-entry] +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_sanitize-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -152,8 +246,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.dishwasher', + 'entity_category': , + 'entity_id': 'switch.airdresser_sanitize', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -165,29 +259,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Sanitize', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', + 'translation_key': 'sanitize', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.steamClosetSanitizeMode_status_status', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-state] +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_sanitize-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher', + 'friendly_name': 'AirDresser Sanitize', }), 'context': , - 'entity_id': 'switch.dishwasher', + 'entity_id': 'switch.airdresser_sanitize', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_wm_wd_000001][switch.dryer-entry] +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_auto_cycle_link-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -199,8 +293,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.dryer', + 'entity_category': , + 'entity_id': 'switch.airdresser_auto_cycle_link', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -212,29 +306,264 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Auto cycle link', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b', + 'translation_key': 'auto_cycle_link', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.steamClosetAutoCycleLink_steamClosetAutoCycleLink_steamClosetAutoCycleLink', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_wd_000001][switch.dryer-state] +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_auto_cycle_link-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dryer', + 'friendly_name': 'AirDresser Auto cycle link', }), 'context': , - 'entity_id': 'switch.dryer', + 'entity_id': 'switch.airdresser_auto_cycle_link', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_keep_fresh_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airdresser_keep_fresh_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': 'Keep fresh mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'keep_fresh_mode', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.steamClosetKeepFreshMode_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][switch.airdresser_keep_fresh_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser Keep fresh mode', + }), + 'context': , + 'entity_id': 'switch.airdresser_keep_fresh_mode', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_wm_wm_000001][switch.washer-entry] +# name: test_all_entities[da_wm_wd_000001][switch.dryer_wrinkle_prevent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.dryer_wrinkle_prevent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wrinkle prevent', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wrinkle_prevent', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_custom.dryerWrinklePrevent_dryerWrinklePrevent_dryerWrinklePrevent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer_wrinkle_prevent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Wrinkle prevent', + }), + 'context': , + 'entity_id': 'switch.dryer_wrinkle_prevent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa_wrinkle_prevent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.seca_roupa_wrinkle_prevent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wrinkle prevent', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wrinkle_prevent', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_custom.dryerWrinklePrevent_dryerWrinklePrevent_dryerWrinklePrevent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa_wrinkle_prevent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Wrinkle prevent', + }), + 'context': , + 'entity_id': 'switch.seca_roupa_wrinkle_prevent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine_bubble_soak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.washing_machine_bubble_soak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bubble Soak', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bubble_soak', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_samsungce.washerBubbleSoak_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine_bubble_soak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Bubble Soak', + }), + 'context': , + 'entity_id': 'switch.washing_machine_bubble_soak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_01011][switch.machine_a_laver_bubble_soak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.machine_a_laver_bubble_soak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bubble Soak', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bubble_soak', + 'unique_id': 'b854ca5f-dc54-140d-6349-758b4d973c41_main_samsungce.washerBubbleSoak_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_01011][switch.machine_a_laver_bubble_soak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Machine à Laver Bubble Soak', + }), + 'context': , + 'entity_id': 'switch.machine_a_laver_bubble_soak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[generic_ef00_v1][switch.thermostat_kuche-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -247,7 +576,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.washer', + 'entity_id': 'switch.thermostat_kuche', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -264,17 +593,17 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_wm_000001][switch.washer-state] +# name: test_all_entities[generic_ef00_v1][switch.thermostat_kuche-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washer', + 'friendly_name': 'Thermostat Küche', }), 'context': , - 'entity_id': 'switch.washer', + 'entity_id': 'switch.thermostat_kuche', 'last_changed': , 'last_reported': , 'last_updated': , @@ -311,7 +640,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5', + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -358,7 +687,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398', + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -375,7 +704,7 @@ 'state': 'on', }) # --- -# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-entry] +# name: test_all_entities[tplink_p110][switch.spulmaschine-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -388,7 +717,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.soundbar_living', + 'entity_id': 'switch.spulmaschine', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -405,24 +734,24 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac', + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-state] +# name: test_all_entities[tplink_p110][switch.spulmaschine-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Living', + 'friendly_name': 'Spülmaschine', }), 'context': , - 'entity_id': 'switch.soundbar_living', + 'entity_id': 'switch.spulmaschine', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-entry] +# name: test_all_entities[vd_sensor_light_2023][switch.light_sensor_55_the_frame-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -435,7 +764,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.tv_samsung_8_series_49', + 'entity_id': 'switch.light_sensor_55_the_frame', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -452,20 +781,20 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-state] +# name: test_all_entities[vd_sensor_light_2023][switch.light_sensor_55_the_frame-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49)', + 'friendly_name': 'Light Sensor - 55" The Frame', }), 'context': , - 'entity_id': 'switch.tv_samsung_8_series_49', + 'entity_id': 'switch.light_sensor_55_the_frame', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- diff --git a/tests/components/smartthings/snapshots/test_update.ambr b/tests/components/smartthings/snapshots/test_update.ambr new file mode 100644 index 00000000000..c27a0b9f5fc --- /dev/null +++ b/tests/components/smartthings/snapshots/test_update.ambr @@ -0,0 +1,421 @@ +# serializer version: 1 +# name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.radiator_thermostat_ii_m_wohnzimmer_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Firmware', + 'in_progress': False, + 'installed_version': '2.00.09 (20009)', + 'latest_version': '2.00.09 (20009)', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.radiator_thermostat_ii_m_wohnzimmer_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[centralite][update.dimmer_debian_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.dimmer_debian_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[centralite][update.dimmer_debian_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Dimmer Debian Firmware', + 'in_progress': False, + 'installed_version': '16015010', + 'latest_version': '16015010', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.dimmer_debian_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[contact_sensor][update.front_door_open_closed_sensor_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.front_door_open_closed_sensor_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[contact_sensor][update.front_door_open_closed_sensor_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': '.Front Door Open/Closed Sensor Firmware', + 'in_progress': False, + 'installed_version': '00000103', + 'latest_version': '00000104', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.front_door_open_closed_sensor_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[ikea_kadrilj][update.kitchen_ikea_kadrilj_window_blind_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.kitchen_ikea_kadrilj_window_blind_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ikea_kadrilj][update.kitchen_ikea_kadrilj_window_blind_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind Firmware', + 'in_progress': False, + 'installed_version': '22007631', + 'latest_version': '22007631', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.kitchen_ikea_kadrilj_window_blind_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[multipurpose_sensor][update.deck_door_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.deck_door_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][update.deck_door_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Deck Door Firmware', + 'in_progress': False, + 'installed_version': '0000001B', + 'latest_version': '0000001B', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.deck_door_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[smart_plug][update.arlo_beta_basestation_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.arlo_beta_basestation_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[smart_plug][update.arlo_beta_basestation_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Arlo Beta Basestation Firmware', + 'in_progress': False, + 'installed_version': '00102101', + 'latest_version': '00102101', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.arlo_beta_basestation_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][update.basement_door_lock_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.basement_door_lock_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][update.basement_door_lock_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Basement Door Lock Firmware', + 'in_progress': False, + 'installed_version': '00840847', + 'latest_version': '00840847', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.basement_door_lock_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_valve.ambr b/tests/components/smartthings/snapshots/test_valve.ambr new file mode 100644 index 00000000000..f82155c8499 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_valve.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[virtual_valve][valve.volvo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.volvo', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_valve][valve.volvo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'volvo', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.volvo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_water_heater.ambr b/tests/components/smartthings/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..759a95220de --- /dev/null +++ b/tests/components/smartthings/snapshots/test_water_heater.ambr @@ -0,0 +1,218 @@ +# serializer version: 1 +# name: test_all_entities[da_ac_ehs_01001][water_heater.heat_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 69, + 'min_temp': 38, + 'operation_list': list([ + 'off', + 'eco', + 'standard', + 'power', + 'force', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.heat_pump', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'water_heater', + 'unique_id': '4165c51e-bf6b-c5b6-fd53-127d6248754b_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_ehs_01001][water_heater.heat_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': 57, + 'friendly_name': 'Heat pump', + 'max_temp': 69, + 'min_temp': 38, + 'operation_list': list([ + 'off', + 'eco', + 'standard', + 'power', + 'force', + ]), + 'operation_mode': 'off', + 'supported_features': , + 'target_temp_high': 69, + 'target_temp_low': 38, + 'temperature': 56, + }), + 'context': , + 'entity_id': 'water_heater.heat_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][water_heater.eco_heating_system-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 60.0, + 'min_temp': 40, + 'operation_list': list([ + 'off', + 'eco', + 'standard', + 'force', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.eco_heating_system', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'water_heater', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][water_heater.eco_heating_system-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': 40.8, + 'friendly_name': 'Eco Heating System', + 'max_temp': 60.0, + 'min_temp': 40, + 'operation_list': list([ + 'off', + 'eco', + 'standard', + 'force', + ]), + 'operation_mode': 'off', + 'supported_features': , + 'target_temp_high': 55, + 'target_temp_low': 40, + 'temperature': 48, + }), + 'context': , + 'entity_id': 'water_heater.eco_heating_system', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][water_heater.warmepumpe-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 60.0, + 'min_temp': 40, + 'operation_list': list([ + 'off', + 'eco', + 'standard', + 'power', + 'force', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.warmepumpe', + 'has_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'water_heater', + 'unique_id': '3810e5ad-5351-d9f9-12ff-000001200000_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000002_sub][water_heater.warmepumpe-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': 49.6, + 'friendly_name': 'Wärmepumpe', + 'max_temp': 60.0, + 'min_temp': 40, + 'operation_list': list([ + 'off', + 'eco', + 'standard', + 'power', + 'force', + ]), + 'operation_mode': 'standard', + 'supported_features': , + 'target_temp_high': 57, + 'target_temp_low': 40, + 'temperature': 52, + }), + 'context': , + 'entity_id': 'water_heater.warmepumpe', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'standard', + }) +# --- diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index f46be2edc89..42534e5b691 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -3,14 +3,26 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability +from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.smartthings import DOMAIN, MAIN +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 homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -39,7 +51,7 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_OFF + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_OFF await trigger_update( hass, @@ -48,6 +60,221 @@ async def test_state_update( Capability.CONTACT_SENSOR, Attribute.CONTACT, "open", + component="cooler", ) - assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_ON + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_ON + + +@pytest.mark.parametrize("device_fixture", ["da_ref_normal_000001"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_OFF + + await trigger_health_update( + hass, devices, "7db87911-7dce-1cf2-7119-b953432a2f09", HealthStatus.OFFLINE + ) + + assert ( + hass.states.get("binary_sensor.refrigerator_cooler_door").state + == STATE_UNAVAILABLE + ) + + await trigger_health_update( + hass, devices, "7db87911-7dce-1cf2-7119-b953432a2f09", HealthStatus.ONLINE + ) + + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["da_ref_normal_000001"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert ( + hass.states.get("binary_sensor.refrigerator_cooler_door").state + == STATE_UNAVAILABLE + ) + + +@pytest.mark.parametrize( + ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), + [ + ( + "virtual_valve", + f"612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_{MAIN}_{Capability.VALVE}_{Attribute.VALVE}_{Attribute.VALVE}", + "volvo_valve", + "valve", + "binary_sensor.volvo_valve", + ), + ( + "da_ref_normal_000001", + f"7db87911-7dce-1cf2-7119-b953432a2f09_{MAIN}_{Capability.CONTACT_SENSOR}_{Attribute.CONTACT}_{Attribute.CONTACT}", + "refrigerator_door", + "fridge_door", + "binary_sensor.refrigerator_door", + ), + ], +) +async def test_create_issue_with_items( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_binary_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test", + "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": [ + { + "condition": "state", + "entity_id": entity_id, + "state": "on", + }, + ], + } + } + }, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state == STATE_OFF + + 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 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_binary_{issue_string}_scripts" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_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 + + +@pytest.mark.parametrize( + ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), + [ + ( + "virtual_valve", + f"612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_{MAIN}_{Capability.VALVE}_{Attribute.VALVE}_{Attribute.VALVE}", + "volvo_valve", + "valve", + "binary_sensor.volvo_valve", + ), + ( + "da_ref_normal_000001", + f"7db87911-7dce-1cf2-7119-b953432a2f09_{MAIN}_{Capability.CONTACT_SENSOR}_{Attribute.CONTACT}_{Attribute.CONTACT}", + "refrigerator_door", + "fridge_door", + "binary_sensor.refrigerator_door", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_binary_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state == STATE_OFF + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_binary_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_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/smartthings/test_button.py b/tests/components/smartthings/test_button.py new file mode 100644 index 00000000000..daacee7def1 --- /dev/null +++ b/tests/components/smartthings/test_button.py @@ -0,0 +1,97 @@ +"""Test for the SmartThings button platform.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pysmartthings import Capability, Command +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.smartthings import MAIN +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, snapshot_smartthings_entities, trigger_health_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.BUTTON) + + +@pytest.mark.parametrize("device_fixture", ["da_ks_microwave_0101x"]) +async def test_press( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + freezer.move_to("2023-10-21") + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.microwave_stop"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + Capability.OVEN_OPERATING_STATE, + Command.STOP, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ks_microwave_0101x"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("button.microwave_stop").state == STATE_UNKNOWN + + await trigger_health_update( + hass, devices, "2bad3237-4886-e699-1b90-4a51a3d55c8a", HealthStatus.OFFLINE + ) + + assert hass.states.get("button.microwave_stop").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "2bad3237-4886-e699-1b90-4a51a3d55c8a", HealthStatus.ONLINE + ) + + assert hass.states.get("button.microwave_stop").state == STATE_UNKNOWN + + +@pytest.mark.parametrize("device_fixture", ["da_ks_microwave_0101x"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("button.microwave_stop").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 380c4072860..ff8b5277e20 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -4,8 +4,9 @@ from typing import Any from unittest.mock import AsyncMock, call from pysmartthings import Attribute, Capability, Command, Status +from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -36,6 +37,8 @@ from homeassistant.const import ( ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -45,6 +48,7 @@ from . import ( set_attribute_value, setup_integration, snapshot_smartthings_entities, + trigger_health_update, trigger_update, ) @@ -115,7 +119,7 @@ async def test_ac_set_hvac_mode_off( @pytest.mark.parametrize( ("hvac_mode", "argument"), [ - (HVACMode.HEAT_COOL, "auto"), + (HVACMode.AUTO, "auto"), (HVACMode.COOL, "cool"), (HVACMode.DRY, "dry"), (HVACMode.HEAT, "heat"), @@ -170,7 +174,7 @@ async def test_ac_set_hvac_mode_turns_on( SERVICE_SET_HVAC_MODE, { ATTR_ENTITY_ID: "climate.ac_office_granit", - ATTR_HVAC_MODE: HVACMode.HEAT_COOL, + ATTR_HVAC_MODE: HVACMode.AUTO, }, blocking=True, ) @@ -192,17 +196,19 @@ async def test_ac_set_hvac_mode_turns_on( @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) -async def test_ac_set_hvac_mode_wind( +@pytest.mark.parametrize("mode", ["fan", "wind"]) +async def test_ac_set_hvac_mode_fan( hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry, + mode: str, ) -> None: """Test setting AC HVAC mode to wind if the device supports it.""" set_attribute_value( devices, Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES, - ["auto", "cool", "dry", "heat", "wind"], + ["auto", "cool", "dry", "heat", mode], ) set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") @@ -219,7 +225,7 @@ async def test_ac_set_hvac_mode_wind( Capability.AIR_CONDITIONER_MODE, Command.SET_AIR_CONDITIONER_MODE, MAIN, - argument="wind", + argument=mode, ) @@ -262,7 +268,7 @@ async def test_ac_set_temperature_and_hvac_mode_while_off( { ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_TEMPERATURE: 23, - ATTR_HVAC_MODE: HVACMode.HEAT_COOL, + ATTR_HVAC_MODE: HVACMode.AUTO, }, blocking=True, ) @@ -312,7 +318,7 @@ async def test_ac_set_temperature_and_hvac_mode( { ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_TEMPERATURE: 23, - ATTR_HVAC_MODE: HVACMode.HEAT_COOL, + ATTR_HVAC_MODE: HVACMode.AUTO, }, blocking=True, ) @@ -817,10 +823,10 @@ async def test_updating_humidity( ( Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES, - ["coolClean", "dryClean"], + ["rush hour", "heat"], ATTR_HVAC_MODES, - [], - [HVACMode.COOL, HVACMode.DRY], + [HVACMode.AUTO], + [HVACMode.AUTO, HVACMode.HEAT], ), ], ids=[ @@ -857,3 +863,38 @@ async def test_thermostat_state_attributes_update( ) assert hass.states.get("climate.asd").attributes[state_attribute] == expected_value + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.ac_office_granit").state == STATE_OFF + + await trigger_health_update( + hass, devices, "96a5ef74-5832-a84b-f1f7-ca799957065d", HealthStatus.OFFLINE + ) + + assert hass.states.get("climate.ac_office_granit").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "96a5ef74-5832-a84b-f1f7-ca799957065d", HealthStatus.ONLINE + ) + + assert hass.states.get("climate.ac_office_granit").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("climate.ac_office_granit").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 647e0ea5284..d6e8ef03290 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -10,6 +10,7 @@ from homeassistant.components.smartthings.const import ( CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, + CONF_SUBSCRIPTION_ID, DOMAIN, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState @@ -28,7 +29,13 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -@pytest.mark.usefixtures("current_request_with_host") +@pytest.fixture +def use_cloud(hass: HomeAssistant) -> None: + """Set up the cloud component.""" + hass.config.components.add("cloud") + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -57,7 +64,80 @@ async def test_full_flow( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + result["data"]["token"].pop("expires_at") + assert result["data"][CONF_TOKEN] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + } + assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") +async def test_not_enough_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we abort if we don't have enough scopes.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + 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://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() @@ -76,7 +156,7 @@ async def test_full_flow( "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:installedapps w:installedapps", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -84,24 +164,11 @@ async def test_full_flow( result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - result["data"]["token"].pop("expires_at") - assert result["data"][CONF_TOKEN] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "token_type": "Bearer", - "expires_in": 82806, - "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " - "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", - "access_tier": 0, - "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", - } - assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_scopes" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_duplicate_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -132,7 +199,8 @@ async def test_duplicate_entry( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() @@ -150,8 +218,7 @@ async def test_duplicate_entry( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -164,6 +231,23 @@ async def test_duplicate_entry( @pytest.mark.usefixtures("current_request_with_host") +async def test_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Check we abort when cloud is not enabled.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -201,8 +285,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -220,14 +303,64 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") +async def test_reauthentication_wrong_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with wrong scopes.""" + 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"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_scopes" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_reauth_account_mismatch( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -266,8 +399,7 @@ async def test_reauth_account_mismatch( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -280,6 +412,29 @@ async def test_reauth_account_mismatch( @pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication without cloud.""" + 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"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_migration( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -318,8 +473,7 @@ async def test_migration( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -350,19 +504,19 @@ async def test_migration( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_SUBSCRIPTION_ID: "f5768ce8-c9e5-4507-9020-912c0c60e0ab", } assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" assert mock_old_config_entry.version == 3 - assert mock_old_config_entry.minor_version == 1 + assert mock_old_config_entry.minor_version == 2 -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_migration_wrong_location( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -405,8 +559,7 @@ async def test_migration_wrong_location( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -433,4 +586,28 @@ async def test_migration_wrong_location( == "appid123-2be1-4e40-b257-e4ef59083324_397678e5-9995-4a39-9d9f-ae6ba310236c" ) assert mock_old_config_entry.version == 3 - assert mock_old_config_entry.minor_version == 1 + assert mock_old_config_entry.minor_version == 2 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py index 37f12b44880..ad6fc762c3c 100644 --- a/tests/components/smartthings/test_cover.py +++ b/tests/components/smartthings/test_cover.py @@ -3,8 +3,9 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command, Status +from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -20,12 +21,18 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, STATE_OPEN, STATE_OPENING, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -190,3 +197,38 @@ async def test_position_update( ) assert hass.states.get("cover.curtain_1a").attributes[ATTR_CURRENT_POSITION] == 50 + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.curtain_1a").state == STATE_OPEN + + await trigger_health_update( + hass, devices, "571af102-15db-4030-b76b-245a691f74a5", HealthStatus.OFFLINE + ) + + assert hass.states.get("cover.curtain_1a").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "571af102-15db-4030-b76b-245a691f74a5", HealthStatus.ONLINE + ) + + assert hass.states.get("cover.curtain_1a").state == STATE_OPEN + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("cover.curtain_1a").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py new file mode 100644 index 00000000000..4eba6593a7f --- /dev/null +++ b/tests/components/smartthings/test_diagnostics.py @@ -0,0 +1,78 @@ +"""Test SmartThings diagnostics.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.smartthings.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, load_json_object_fixture +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + devices: AsyncMock, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a device entry.""" + mock_smartthings.get_raw_devices.return_value = [ + load_json_object_fixture("devices/da_ac_rac_000001.json", DOMAIN) + ] + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + devices: AsyncMock, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a device entry.""" + mock_smartthings.get_raw_device_status.return_value = load_json_object_fixture( + "device_status/da_ac_rac_000001.json", DOMAIN + ) + mock_smartthings.get_raw_device.return_value = load_json_object_fixture( + "devices/da_ac_rac_000001.json", DOMAIN + )["items"][0] + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} + ) + + mock_smartthings.get_raw_device_status.reset_mock() + + with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): + diag = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) + + assert diag == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) + mock_smartthings.get_raw_device_status.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d" + ) diff --git a/tests/components/smartthings/test_event.py b/tests/components/smartthings/test_event.py new file mode 100644 index 00000000000..96b66036906 --- /dev/null +++ b/tests/components/smartthings/test_event.py @@ -0,0 +1,150 @@ +"""Test for the SmartThings event platform.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pysmartthings import Attribute, Capability +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.event import ATTR_EVENT_TYPES +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, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.EVENT) + + +@pytest.mark.parametrize("device_fixture", ["heatit_zpushwall"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + freezer.move_to("2023-10-21") + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + + await trigger_update( + hass, + devices, + "5e5b97f3-3094-44e6-abc0-f61283412d6a", + Capability.BUTTON, + Attribute.BUTTON, + "pushed", + component="button1", + ) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state + == "2023-10-21T00:00:00.000+00:00" + ) + + +@pytest.mark.parametrize("device_fixture", ["heatit_zpushwall"]) +async def test_supported_button_values_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test supported button values update.""" + await setup_integration(hass, mock_config_entry) + + freezer.move_to("2023-10-21") + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + assert hass.states.get("event.livingroom_smart_switch_button1").attributes[ + ATTR_EVENT_TYPES + ] == ["pushed", "held", "down_hold"] + + await trigger_update( + hass, + devices, + "5e5b97f3-3094-44e6-abc0-f61283412d6a", + Capability.BUTTON, + Attribute.SUPPORTED_BUTTON_VALUES, + ["pushed", "held", "down_hold", "pushed_2x"], + component="button1", + ) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + assert hass.states.get("event.livingroom_smart_switch_button1").attributes[ + ATTR_EVENT_TYPES + ] == ["pushed", "held", "down_hold", "pushed_2x"] + + +@pytest.mark.parametrize("device_fixture", ["heatit_zpushwall"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + + await trigger_health_update( + hass, devices, "5e5b97f3-3094-44e6-abc0-f61283412d6a", HealthStatus.OFFLINE + ) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state + == STATE_UNAVAILABLE + ) + + await trigger_health_update( + hass, devices, "5e5b97f3-3094-44e6-abc0-f61283412d6a", HealthStatus.ONLINE + ) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + + +@pytest.mark.parametrize("device_fixture", ["heatit_zpushwall"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index 58287355381..36a453ff595 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -3,8 +3,9 @@ from unittest.mock import AsyncMock from pysmartthings import Capability, Command +from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PERCENTAGE, @@ -18,12 +19,14 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities +from . import setup_integration, snapshot_smartthings_entities, trigger_health_update from tests.common import MockConfigEntry @@ -166,3 +169,38 @@ async def test_set_preset_mode( MAIN, argument="turbo", ) + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("fan.fake_fan").state == STATE_OFF + + await trigger_health_update( + hass, devices, "f1af21a2-d5a1-437c-b10a-b34a87394b71", HealthStatus.OFFLINE + ) + + assert hass.states.get("fan.fake_fan").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "f1af21a2-d5a1-437c-b10a-b34a87394b71", HealthStatus.ONLINE + ) + + assert hass.states.get("fan.fake_fan").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("fan.fake_fan").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index be88f11903e..0b8d2e1e632 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -1,16 +1,44 @@ """Tests for the SmartThings component init module.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from aiohttp import ClientResponseError, RequestInfo +from pysmartthings import ( + Attribute, + Capability, + DeviceResponse, + DeviceStatus, + Lifecycle, + SmartThingsSinkError, + Subscription, +) +import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smartthings.const import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.smartthings import EVENT_BUTTON +from homeassistant.components.smartthings.const import ( + CONF_INSTALLED_APP_ID, + CONF_LOCATION_ID, + CONF_SUBSCRIPTION_ID, + DOMAIN, + SCOPES, +) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import setup_integration +from . import setup_integration, trigger_update -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture async def test_devices( @@ -29,3 +57,674 @@ async def test_devices( assert device is not None assert device == snapshot + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_device_not_resetting_area( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device not resetting area.""" + await setup_integration(hass, mock_config_entry) + + device_id = devices.get_devices.return_value[0].device_id + + device = device_registry.async_get_device({(DOMAIN, device_id)}) + + assert device.area_id == "theater" + + device_registry.async_update_device(device_id=device.id, area_id=None) + await hass.async_block_till_done() + + device = device_registry.async_get_device({(DOMAIN, device_id)}) + + assert device.area_id is None + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device({(DOMAIN, device_id)}) + assert device.area_id is None + + +@pytest.mark.parametrize("device_fixture", ["button"]) +async def test_button_event( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test button event.""" + await setup_integration(hass, mock_config_entry) + events = [] + + def capture_event(event: Event) -> None: + events.append(event) + + hass.bus.async_listen_once(EVENT_BUTTON, capture_event) + + await trigger_update( + hass, + devices, + "c4bdd19f-85d1-4d58-8f9c-e75ac3cf113b", + Capability.BUTTON, + Attribute.BUTTON, + "pushed", + ) + + assert len(events) == 1 + assert events[0] == snapshot + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_create_subscription( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a subscription.""" + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + await setup_integration(hass, mock_config_entry) + + devices.create_subscription.assert_called_once() + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.subscribe.assert_called_once_with( + "397678e5-9995-4a39-9d9f-ae6ba310236c", + "5aaaa925-2be1-4e40-b257-e4ef59083324", + Subscription.from_json(load_fixture("subscription.json", DOMAIN)), + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_create_subscription_sink_error( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test handling an error when creating a subscription.""" + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + devices.create_subscription.side_effect = SmartThingsSinkError("Sink error") + + await setup_integration(hass, mock_config_entry) + + devices.subscribe.assert_not_called() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_update_subscription_identifier( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating the subscription identifier.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.new_subscription_id_callback("abc") + + await hass.async_block_till_done() + + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] == "abc" + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_stale_subscription_id( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating the subscription identifier.""" + mock_config_entry.add_to_hass(hass) + + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_SUBSCRIPTION_ID: "test"}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + devices.delete_subscription.assert_called_once_with("test") + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_remove_subscription_identifier( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing the subscription identifier.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.new_subscription_id_callback(None) + + await hass.async_block_till_done() + + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_max_connections_handling( + hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test handling reaching max connections.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.create_subscription.side_effect = SmartThingsSinkError("Sink error") + + devices.max_connections_reached_callback() + + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_unloading( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + devices.delete_subscription.assert_called_once_with( + "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + # Deleting the subscription automatically deletes the subscription ID + devices.new_subscription_id_callback(None) + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_shutdown( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test shutting down Home Assistant.""" + await setup_integration(hass, mock_config_entry) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + devices.delete_subscription.assert_called_once_with( + "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + # Deleting the subscription automatically deletes the subscription ID + devices.new_subscription_id_callback(None) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_removing_stale_devices( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing stale devices.""" + mock_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "aaa-bbb-ccc")}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_refreshing_expired_token( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing stale devices.""" + with patch( + "homeassistant.components.smartthings.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + request_info=RequestInfo( + url="http://example.com", + method="GET", + headers={}, + real_url="http://example.com", + ), + status=400, + history=(), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert len(hass.config_entries.flow.async_progress()) == 1 + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_error_refreshing_token( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing stale devices.""" + with patch( + "homeassistant.components.smartthings.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + request_info=RequestInfo( + url="http://example.com", + method="GET", + headers={}, + real_url="http://example.com", + ), + status=500, + history=(), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_hub_via_device( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + mock_smartthings: AsyncMock, +) -> None: + """Test hub with child devices.""" + mock_smartthings.get_devices.return_value = DeviceResponse.from_json( + load_fixture("devices/hub.json", DOMAIN) + ).items + mock_smartthings.get_device_status.side_effect = [ + DeviceStatus.from_json( + load_fixture(f"device_status/{fixture}.json", DOMAIN) + ).components + for fixture in ("hub", "multipurpose_sensor") + ] + await setup_integration(hass, mock_config_entry) + + hub_device = device_registry.async_get_device( + {(DOMAIN, "074fa784-8be8-4c70-8e22-6f5ed6f81b7e")} + ) + assert hub_device == snapshot + assert ( + device_registry.async_get_device( + {(DOMAIN, "374ba6fa-5a08-4ea2-969c-1fa43d86e21f")} + ).via_device_id + == hub_device.id + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_deleted_device_runtime( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test devices that are deleted in runtime.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.OFF + + for call in devices.add_device_lifecycle_event_listener.call_args_list: + if call[0][0] == Lifecycle.DELETE: + call[0][1]("96a5ef74-5832-a84b-f1f7-ca799957065d") + await hass.async_block_till_done() + + assert hass.states.get("climate.ac_office_granit") is None + + +@pytest.mark.parametrize( + ( + "device_fixture", + "domain", + "old_unique_id", + "suggested_object_id", + "new_unique_id", + ), + [ + ( + "multipurpose_sensor", + BINARY_SENSOR_DOMAIN, + "7d246592-93db-4d72-a10d-5a51793ece8c.contact", + "deck_door", + "7d246592-93db-4d72-a10d-5a51793ece8c_main_contactSensor_contact_contact", + ), + ( + "multipurpose_sensor", + SENSOR_DOMAIN, + "7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate", + "deck_door_y_coordinate", + "7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_y_coordinate", + ), + ( + "da_ac_rac_000001", + SENSOR_DOMAIN, + "7d246592-93db-4d72-a10d-ca799957065d.energy_meter", + "ac_office_granit_energy", + "7d246592-93db-4d72-a10d-ca799957065d_main_powerConsumptionReport_powerConsumption_energy_meter", + ), + ( + "da_ac_rac_000001", + CLIMATE_DOMAIN, + "7d246592-93db-4d72-a10d-ca799957065d", + "ac_office_granit", + "7d246592-93db-4d72-a10d-ca799957065d_main", + ), + ( + "c2c_shade", + COVER_DOMAIN, + "571af102-15db-4030-b76b-245a691f74a5", + "curtain_1a", + "571af102-15db-4030-b76b-245a691f74a5_main", + ), + ( + "generic_fan_3_speed", + FAN_DOMAIN, + "6d95a8b7-4ee3-429a-a13a-00ec9354170c", + "bedroom_fan", + "6d95a8b7-4ee3-429a-a13a-00ec9354170c_main", + ), + ( + "hue_rgbw_color_bulb", + LIGHT_DOMAIN, + "cb958955-b015-498c-9e62-fc0c51abd054", + "standing_light", + "cb958955-b015-498c-9e62-fc0c51abd054_main", + ), + ( + "yale_push_button_deadbolt_lock", + LOCK_DOMAIN, + "a9f587c5-5d8b-4273-8907-e7f609af5158", + "basement_door_lock", + "a9f587c5-5d8b-4273-8907-e7f609af5158_main", + ), + ( + "smart_plug", + SWITCH_DOMAIN, + "550a1c72-65a0-4d55-b97b-75168e055398", + "arlo_beta_basestation", + "550a1c72-65a0-4d55-b97b-75168e055398_main_switch_switch_switch", + ), + ], +) +async def test_entity_unique_id_migration( + hass: HomeAssistant, + devices: AsyncMock, + expires_at: int, + entity_registry: er.EntityRegistry, + domain: str, + old_unique_id: str, + suggested_object_id: str, + new_unique_id: str, +) -> None: + """Test entity unique ID migration.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(SCOPES), + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123", + }, + version=3, + minor_version=1, + ) + mock_config_entry.add_to_hass(hass) + entry = entity_registry.async_get_or_create( + domain, + DOMAIN, + old_unique_id, + config_entry=mock_config_entry, + suggested_object_id=suggested_object_id, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entry.entity_id) + + assert entry.unique_id == new_unique_id + + +@pytest.mark.parametrize( + ( + "device_fixture", + "domain", + "other_unique_id", + "old_unique_id", + "suggested_object_id", + "new_unique_id", + ), + [ + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState", + "microwave_machine_state", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_machineState_machineState", + ), + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_ovenJobState_ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState", + "microwave_machine_state", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_machineState_machineState", + ), + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime", + "microwave_completion_time", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_completionTime_completionTime", + ), + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_ovenJobState_ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime", + "microwave_completion_time", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_completionTime_completionTime", + ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState", + "dishwasher_machine_state", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState", + ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_dishwasherJobState_dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState", + "dishwasher_machine_state", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState", + ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime", + "dishwasher_completion_time", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_completionTime_completionTime", + ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_dishwasherJobState_dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime", + "dishwasher_completion_time", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_completionTime_completionTime", + ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState", + "dryer_machine_state", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState", + ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_dryerJobState_dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState", + "dryer_machine_state", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState", + ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime", + "dryer_completion_time", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_completionTime_completionTime", + ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_dryerJobState_dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime", + "dryer_completion_time", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_completionTime_completionTime", + ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47.washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.machineState", + "washer_machine_state", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState", + ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_washerJobState_washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.machineState", + "washer_machine_state", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState", + ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47.washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.completionTime", + "washer_completion_time", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_completionTime_completionTime", + ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_washerJobState_washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.completionTime", + "washer_completion_time", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_completionTime_completionTime", + ), + ], +) +async def test_entity_unique_id_migration_machine_state( + hass: HomeAssistant, + devices: AsyncMock, + expires_at: int, + entity_registry: er.EntityRegistry, + domain: str, + other_unique_id: str, + old_unique_id: str, + suggested_object_id: str, + new_unique_id: str, +) -> None: + """Test entity unique ID migration.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(SCOPES), + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123", + }, + version=3, + minor_version=1, + ) + mock_config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + domain, + DOMAIN, + other_unique_id, + config_entry=mock_config_entry, + suggested_object_id="job_state", + ) + entry = entity_registry.async_get_or_create( + domain, + DOMAIN, + old_unique_id, + config_entry=mock_config_entry, + suggested_object_id=suggested_object_id, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entry.entity_id) + + assert entry.unique_id == new_unique_id diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 8d47e90c9f5..0aa818dd7f4 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -4,15 +4,21 @@ from typing import Any from unittest.mock import AsyncMock, call from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + 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, ColorMode, ) @@ -23,19 +29,21 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from . import ( set_attribute_value, setup_integration, snapshot_smartthings_entities, + trigger_health_update, trigger_update, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, mock_restore_cache_with_extra_data async def test_all_entities( @@ -228,6 +236,15 @@ async def test_updating_brightness( set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, + ) + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 178 await trigger_update( @@ -252,8 +269,17 @@ async def test_updating_hs( set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, + ) + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( - 218.906, + 144.0, 60, ) @@ -280,9 +306,17 @@ async def test_updating_color_temp( ) -> None: """Test color temperature update.""" set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") - set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 0) await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 3000, + ) + assert ( hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] is ColorMode.COLOR_TEMP @@ -305,3 +339,115 @@ async def test_updating_color_temp( hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] == 2000 ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_modes( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode changes.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 50) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 2000, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 20, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_mode_after_startup( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode after startup.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + RESTORE_DATA = { + ATTR_BRIGHTNESS: 178, + ATTR_COLOR_MODE: ColorMode.COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (144.0, 60), + ATTR_MAX_COLOR_TEMP_KELVIN: 9000, + ATTR_MIN_COLOR_TEMP_KELVIN: 2000, + ATTR_RGB_COLOR: (255, 128, 0), + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP, ColorMode.HS], + ATTR_XY_COLOR: (0.61, 0.35), + } + + mock_restore_cache_with_extra_data( + hass, ((State("light.standing_light", STATE_ON), RESTORE_DATA),) + ) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").state == STATE_OFF + + await trigger_health_update( + hass, devices, "cb958955-b015-498c-9e62-fc0c51abd054", HealthStatus.OFFLINE + ) + + assert hass.states.get("light.standing_light").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "cb958955-b015-498c-9e62-fc0c51abd054", HealthStatus.ONLINE + ) + + assert hass.states.get("light.standing_light").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("light.standing_light").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index 28191eceb9a..54932e1094e 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -3,16 +3,28 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.components.smartthings.const import MAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_UNLOCK, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -83,3 +95,38 @@ async def test_state_update( ) assert hass.states.get("lock.basement_door_lock").state == LockState.UNLOCKED + + +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("lock.basement_door_lock").state == LockState.LOCKED + + await trigger_health_update( + hass, devices, "a9f587c5-5d8b-4273-8907-e7f609af5158", HealthStatus.OFFLINE + ) + + assert hass.states.get("lock.basement_door_lock").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "a9f587c5-5d8b-4273-8907-e7f609af5158", HealthStatus.ONLINE + ) + + assert hass.states.get("lock.basement_door_lock").state == LockState.LOCKED + + +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("lock.basement_door_lock").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_media_player.py b/tests/components/smartthings/test_media_player.py new file mode 100644 index 00000000000..0fb53e642d4 --- /dev/null +++ b/tests/components/smartthings/test_media_player.py @@ -0,0 +1,474 @@ +"""Test for the SmartThings media player platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command, Status +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + RepeatMode, +) +from homeassistant.components.smartthings.const import MAIN +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_REPEAT_SET, + SERVICE_SHUFFLE_SET, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_OFF, + STATE_PLAYING, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities( + hass, entity_registry, snapshot, Platform.MEDIA_PLAYER + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test media player turn on and off command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + action, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", Capability.SWITCH, command, MAIN + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("muted", "argument"), + [ + (True, "muted"), + (False, "unmuted"), + ], +) +async def test_mute_unmute( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + muted: bool, + argument: str, +) -> None: + """Test media player mute and unmute command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_VOLUME_MUTED: muted}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_MUTE, + Command.SET_MUTE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_set_volume_level( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player set volume level command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_VOLUME_LEVEL: 0.31}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_VOLUME, + Command.SET_VOLUME, + MAIN, + argument=31, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_volume_up( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player increase volume level command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_UP, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_VOLUME, + Command.VOLUME_UP, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_volume_down( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player decrease volume level command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_DOWN, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_VOLUME, + Command.VOLUME_DOWN, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_play( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player play command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.PLAY, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_pause( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player pause command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.PAUSE, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_stop( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player stop command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.STOP, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_previous_track( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player previous track command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK] = { + Attribute.SUPPORTED_PLAYBACK_COMMANDS: Status(["rewind"]) + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.REWIND, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_next_track( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player next track command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK] = { + Attribute.SUPPORTED_PLAYBACK_COMMANDS: Status(["fastForward"]) + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.FAST_FORWARD, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_select_source( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player stop command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_INPUT_SOURCE: "digital"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_INPUT_SOURCE, + Command.SET_INPUT_SOURCE, + MAIN, + "digital", + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("shuffle", "argument"), + [ + (True, "enabled"), + (False, "disabled"), + ], +) +async def test_media_shuffle_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + shuffle: bool, + argument: bool, +) -> None: + """Test media player media shuffle command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK_SHUFFLE] = { + Attribute.PLAYBACK_SHUFFLE: Status(True) + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SHUFFLE_SET, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_SHUFFLE: shuffle}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK_SHUFFLE, + Command.SET_PLAYBACK_SHUFFLE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("repeat", "argument"), + [ + (RepeatMode.OFF, "off"), + (RepeatMode.ONE, "one"), + (RepeatMode.ALL, "all"), + ], +) +async def test_media_repeat_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + repeat: RepeatMode, + argument: bool, +) -> None: + """Test media player repeat mode command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK_REPEAT] = { + Attribute.REPEAT_MODE: Status("one") + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_REPEAT: repeat}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK_REPEAT, + Command.SET_PLAYBACK_REPEAT_MODE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("media_player.soundbar").state == STATE_PLAYING + + await trigger_update( + hass, + devices, + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.SWITCH, + Attribute.SWITCH, + "off", + ) + + assert hass.states.get("media_player.soundbar").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("media_player.soundbar").state == STATE_PLAYING + + await trigger_health_update( + hass, devices, "afcf3b91-0000-1111-2222-ddff2a0a6577", HealthStatus.OFFLINE + ) + + assert hass.states.get("media_player.soundbar").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "afcf3b91-0000-1111-2222-ddff2a0a6577", HealthStatus.ONLINE + ) + + assert hass.states.get("media_player.soundbar").state == STATE_PLAYING + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("media_player.soundbar").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_number.py b/tests/components/smartthings/test_number.py new file mode 100644 index 00000000000..f9dfe4d3228 --- /dev/null +++ b/tests/components/smartthings/test_number.py @@ -0,0 +1,122 @@ +"""Test for the SmartThings number platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.components.smartthings import MAIN +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, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.NUMBER) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +async def test_set_value( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting a value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.washer_rinse_cycles", ATTR_VALUE: 3}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "f984b91d-f250-9d42-3436-33f09a422a47", + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Command.SET_WASHER_RINSE_CYCLES, + MAIN, + argument="3", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("number.washer_rinse_cycles").state == "2" + + await trigger_update( + hass, + devices, + "f984b91d-f250-9d42-3436-33f09a422a47", + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Attribute.WASHER_RINSE_CYCLES, + "3", + ) + + assert hass.states.get("number.washer_rinse_cycles").state == "3" + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("number.washer_rinse_cycles").state == "2" + + await trigger_health_update( + hass, devices, "f984b91d-f250-9d42-3436-33f09a422a47", HealthStatus.OFFLINE + ) + + assert hass.states.get("number.washer_rinse_cycles").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "f984b91d-f250-9d42-3436-33f09a422a47", HealthStatus.ONLINE + ) + + assert hass.states.get("number.washer_rinse_cycles").state == "2" + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("number.washer_rinse_cycles").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_scene.py b/tests/components/smartthings/test_scene.py index 7ef287b9e96..5eb055f96f0 100644 --- a/tests/components/smartthings/test_scene.py +++ b/tests/components/smartthings/test_scene.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform diff --git a/tests/components/smartthings/test_select.py b/tests/components/smartthings/test_select.py new file mode 100644 index 00000000000..3e1746331f9 --- /dev/null +++ b/tests/components/smartthings/test_select.py @@ -0,0 +1,191 @@ +"""Test for the SmartThings select platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.smartthings import MAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SELECT) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("select.dryer").state == "stop" + + await trigger_update( + hass, + devices, + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.DRYER_OPERATING_STATE, + Attribute.MACHINE_STATE, + "run", + ) + + assert hass.states.get("select.dryer").state == "run" + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_select_option( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + set_attribute_value( + devices, + Capability.REMOTE_CONTROL_STATUS, + Attribute.REMOTE_CONTROL_ENABLED, + "true", + ) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.dryer", ATTR_OPTION: "run"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.DRYER_OPERATING_STATE, + Command.SET_MACHINE_STATE, + MAIN, + argument="run", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ks_range_0101x"]) +async def test_select_option_map( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("select.vulcan_lamp") + assert state + assert state.state == "extra_high" + assert state.attributes[ATTR_OPTIONS] == [ + "off", + "extra_high", + ] + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.vulcan_lamp", ATTR_OPTION: "extra_high"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + Capability.SAMSUNG_CE_LAMP, + Command.SET_BRIGHTNESS_LEVEL, + MAIN, + argument="extraHigh", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_select_option_without_remote_control( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + set_attribute_value( + devices, + Capability.REMOTE_CONTROL_STATUS, + Attribute.REMOTE_CONTROL_ENABLED, + "false", + ) + await setup_integration(hass, mock_config_entry) + + with pytest.raises( + ServiceValidationError, + match="Can only be updated when remote control is enabled", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.dryer", ATTR_OPTION: "run"}, + blocking=True, + ) + devices.execute_device_command.assert_not_called() + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("select.dryer").state == "stop" + + await trigger_health_update( + hass, devices, "02f7256e-8353-5bdd-547f-bd5b1647e01b", HealthStatus.OFFLINE + ) + + assert hass.states.get("select.dryer").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "02f7256e-8353-5bdd-547f-bd5b1647e01b", HealthStatus.ONLINE + ) + + assert hass.states.get("select.dryer").state == "stop" + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("select.dryer").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 8b8bb8930f4..bfb203c1485 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -3,14 +3,26 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability +from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components import automation, script +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 +from homeassistant.components.smartthings.const import DOMAIN, MAIN +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -28,7 +40,7 @@ async def test_all_entities( snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SENSOR) -@pytest.mark.parametrize("device_fixture", ["aeotec_home_energy_meter_gen5"]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_state_update( hass: HomeAssistant, devices: AsyncMock, @@ -37,15 +49,349 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "19978.536" + assert hass.states.get("sensor.ac_office_granit_temperature").state == "25" await trigger_update( hass, devices, - "f0af21a2-d5a1-437c-b10a-b34a87394b71", - Capability.ENERGY_METER, - Attribute.ENERGY, - 20000.0, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, ) - assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "20000.0" + assert hass.states.get("sensor.ac_office_granit_temperature").state == "20" + + +@pytest.mark.parametrize( + ( + "device_fixture", + "unique_id", + "suggested_object_id", + "issue_string", + "entity_id", + "expected_state", + "version", + ), + [ + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_PLAYBACK}_{Attribute.PLAYBACK_STATUS}_{Attribute.PLAYBACK_STATUS}", + "tv_samsung_8_series_49_media_playback_status", + "media_player", + "sensor.tv_samsung_8_series_49_media_playback_status", + STATE_UNKNOWN, + "2025.10.0", + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.AUDIO_VOLUME}_{Attribute.VOLUME}_{Attribute.VOLUME}", + "tv_samsung_8_series_49_volume", + "media_player", + "sensor.tv_samsung_8_series_49_volume", + "13", + "2025.10.0", + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_INPUT_SOURCE}_{Attribute.INPUT_SOURCE}_{Attribute.INPUT_SOURCE}", + "tv_samsung_8_series_49_media_input_source", + "media_player", + "sensor.tv_samsung_8_series_49_media_input_source", + "hdmi1", + "2025.10.0", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_REPEAT}_{Attribute.PLAYBACK_REPEAT_MODE}_{Attribute.PLAYBACK_REPEAT_MODE}", + "galaxy_home_mini_media_playback_repeat", + "media_player", + "sensor.galaxy_home_mini_media_playback_repeat", + "off", + "2025.10.0", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}", + "galaxy_home_mini_media_playback_shuffle", + "media_player", + "sensor.galaxy_home_mini_media_playback_shuffle", + "disabled", + "2025.10.0", + ), + ( + "da_ac_ehs_01001", + f"4165c51e-bf6b-c5b6-fd53-127d6248754b_{MAIN}_{Capability.TEMPERATURE_MEASUREMENT}_{Attribute.TEMPERATURE}_{Attribute.TEMPERATURE}", + "temperature", + "dhw", + "sensor.temperature", + "57", + "2025.12.0", + ), + ( + "da_ac_ehs_01001", + f"4165c51e-bf6b-c5b6-fd53-127d6248754b_{MAIN}_{Capability.THERMOSTAT_COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}", + "cooling_setpoint", + "dhw", + "sensor.cooling_setpoint", + "56", + "2025.12.0", + ), + ], +) +async def test_create_issue_with_items( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, + expected_state: str, + version: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test", + "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": [ + { + "condition": "state", + "entity_id": entity_id, + "state": "on", + }, + ], + } + } + }, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state == expected_state + + 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 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_{issue_string}_scripts" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", + } + assert issue.breaks_in_ha_version == version + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_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 + + +@pytest.mark.parametrize( + ( + "device_fixture", + "unique_id", + "suggested_object_id", + "issue_string", + "entity_id", + "expected_state", + "version", + ), + [ + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_PLAYBACK}_{Attribute.PLAYBACK_STATUS}_{Attribute.PLAYBACK_STATUS}", + "tv_samsung_8_series_49_media_playback_status", + "media_player", + "sensor.tv_samsung_8_series_49_media_playback_status", + STATE_UNKNOWN, + "2025.10.0", + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.AUDIO_VOLUME}_{Attribute.VOLUME}_{Attribute.VOLUME}", + "tv_samsung_8_series_49_volume", + "media_player", + "sensor.tv_samsung_8_series_49_volume", + "13", + "2025.10.0", + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_INPUT_SOURCE}_{Attribute.INPUT_SOURCE}_{Attribute.INPUT_SOURCE}", + "tv_samsung_8_series_49_media_input_source", + "media_player", + "sensor.tv_samsung_8_series_49_media_input_source", + "hdmi1", + "2025.10.0", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_REPEAT}_{Attribute.PLAYBACK_REPEAT_MODE}_{Attribute.PLAYBACK_REPEAT_MODE}", + "galaxy_home_mini_media_playback_repeat", + "media_player", + "sensor.galaxy_home_mini_media_playback_repeat", + "off", + "2025.10.0", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}", + "galaxy_home_mini_media_playback_shuffle", + "media_player", + "sensor.galaxy_home_mini_media_playback_shuffle", + "disabled", + "2025.10.0", + ), + ( + "da_ac_ehs_01001", + f"4165c51e-bf6b-c5b6-fd53-127d6248754b_{MAIN}_{Capability.TEMPERATURE_MEASUREMENT}_{Attribute.TEMPERATURE}_{Attribute.TEMPERATURE}", + "temperature", + "dhw", + "sensor.temperature", + "57", + "2025.12.0", + ), + ( + "da_ac_ehs_01001", + f"4165c51e-bf6b-c5b6-fd53-127d6248754b_{MAIN}_{Capability.THERMOSTAT_COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}_{Attribute.COOLING_SETPOINT}", + "cooling_setpoint", + "dhw", + "sensor.cooling_setpoint", + "56", + "2025.12.0", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, + expected_state: str, + version: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state == expected_state + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + assert issue.breaks_in_ha_version == version + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_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 + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.ac_office_granit_temperature").state == "25" + + await trigger_health_update( + hass, devices, "96a5ef74-5832-a84b-f1f7-ca799957065d", HealthStatus.OFFLINE + ) + + assert ( + hass.states.get("sensor.ac_office_granit_temperature").state + == STATE_UNAVAILABLE + ) + + await trigger_health_update( + hass, devices, "96a5ef74-5832-a84b-f1f7-ca799957065d", HealthStatus.ONLINE + ) + + assert hass.states.get("sensor.ac_office_granit_temperature").state == "25" + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert ( + hass.states.get("sensor.ac_office_granit_temperature").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index a1e420a8edb..09f710366d0 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -3,10 +3,14 @@ from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smartthings.const import MAIN +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.smartthings.const import DOMAIN, MAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -14,12 +18,19 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component -from . import setup_integration, snapshot_smartthings_entities, trigger_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -66,6 +77,39 @@ async def test_switch_turn_on_off( ) +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +@pytest.mark.parametrize( + ("action", "argument"), + [ + (SERVICE_TURN_ON, "on"), + (SERVICE_TURN_OFF, "off"), + ], +) +async def test_command_switch_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + argument: str, +) -> None: + """Test switch turn on and off command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: "switch.dryer_wrinkle_prevent"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.CUSTOM_DRYER_WRINKLE_PREVENT, + Command.SET_DRYER_WRINKLE_PREVENT, + MAIN, + argument, + ) + + @pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) async def test_state_update( hass: HomeAssistant, @@ -87,3 +131,309 @@ async def test_state_update( ) assert hass.states.get("switch.2nd_floor_hallway").state == STATE_OFF + + +@pytest.mark.parametrize( + ("device_fixture", "device_id", "suggested_object_id", "issue_string"), + [ + ( + "da_ks_cooktop_31001", + "808dbd84-f357-47e2-a0cd-3b66fa22d584", + "induction_hob", + "appliance", + ), + ( + "da_ks_microwave_0101x", + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "microwave", + "appliance", + ), + ( + "da_wm_dw_000001", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "dishwasher", + "appliance", + ), + ( + "da_wm_sc_000001", + "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "airdresser", + "appliance", + ), + ( + "da_wm_wd_000001", + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "dryer", + "appliance", + ), + ( + "da_wm_wm_000001", + "f984b91d-f250-9d42-3436-33f09a422a47", + "washer", + "appliance", + ), + ( + "hw_q80r_soundbar", + "afcf3b91-0000-1111-2222-ddff2a0a6577", + "soundbar", + "media_player", + ), + ( + "vd_network_audio_002s", + "0d94e5db-8501-2355-eb4f-214163702cac", + "soundbar_living", + "media_player", + ), + ( + "vd_stv_2017_k", + "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "tv_samsung_8_series_49", + "media_player", + ), + ], +) +async def test_create_issue_with_items( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + device_id: str, + suggested_object_id: str, + issue_string: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + entity_id = f"switch.{suggested_object_id}" + issue_id = f"deprecated_switch_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + f"{device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test", + "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": [ + { + "condition": "state", + "entity_id": entity_id, + "state": "on", + }, + ], + } + } + }, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state in [STATE_OFF, STATE_ON] + + 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 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_switch_{issue_string}_scripts" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_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 + + +@pytest.mark.parametrize( + ("device_fixture", "device_id", "suggested_object_id", "issue_string", "version"), + [ + ( + "da_ks_cooktop_31001", + "808dbd84-f357-47e2-a0cd-3b66fa22d584", + "induction_hob", + "appliance", + "2025.10.0", + ), + ( + "da_ks_microwave_0101x", + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "microwave", + "appliance", + "2025.10.0", + ), + ( + "da_wm_dw_000001", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "dishwasher", + "appliance", + "2025.10.0", + ), + ( + "da_wm_sc_000001", + "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "airdresser", + "appliance", + "2025.10.0", + ), + ( + "da_wm_wd_000001", + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "dryer", + "appliance", + "2025.10.0", + ), + ( + "da_wm_wm_000001", + "f984b91d-f250-9d42-3436-33f09a422a47", + "washer", + "appliance", + "2025.10.0", + ), + ( + "hw_q80r_soundbar", + "afcf3b91-0000-1111-2222-ddff2a0a6577", + "soundbar", + "media_player", + "2025.10.0", + ), + ( + "vd_network_audio_002s", + "0d94e5db-8501-2355-eb4f-214163702cac", + "soundbar_living", + "media_player", + "2025.10.0", + ), + ( + "vd_stv_2017_k", + "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "tv_samsung_8_series_49", + "media_player", + "2025.10.0", + ), + ( + "da_sac_ehs_000002_sub", + "3810e5ad-5351-d9f9-12ff-000001200000", + "warmepumpe", + "dhw", + "2025.12.0", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + device_id: str, + suggested_object_id: str, + issue_string: str, + version: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + entity_id = f"switch.{suggested_object_id}" + issue_id = f"deprecated_switch_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + f"{device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state in [STATE_OFF, STATE_ON] + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_switch_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + assert issue.breaks_in_ha_version == version + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_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 + + +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_ON + + await trigger_health_update( + hass, devices, "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", HealthStatus.OFFLINE + ) + + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", HealthStatus.ONLINE + ) + + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_ON + + +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_update.py b/tests/components/smartthings/test_update.py new file mode 100644 index 00000000000..960e8bfb6d7 --- /dev/null +++ b/tests/components/smartthings/test_update.py @@ -0,0 +1,189 @@ +"""Test for the SmartThings update platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smartthings.const import MAIN +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.UPDATE) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_installing_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test installing an update.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.front_door_open_closed_sensor_firmware"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Command.UPDATE_FIRMWARE, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").state + == STATE_ON + ) + + await trigger_update( + hass, + devices, + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Attribute.CURRENT_VERSION, + "00000104", + ) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").state + == STATE_OFF + ) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_state_progress_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state progress update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").attributes[ + ATTR_IN_PROGRESS + ] + is False + ) + + await trigger_update( + hass, + devices, + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Attribute.STATE, + "updateInProgress", + ) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").attributes[ + ATTR_IN_PROGRESS + ] + is True + ) + + +@pytest.mark.parametrize("device_fixture", ["centralite"]) +async def test_state_update_available( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update available.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_OFF + + await trigger_update( + hass, + devices, + "d0268a69-abfb-4c92-a646-61cec2e510ad", + Capability.FIRMWARE_UPDATE, + Attribute.AVAILABLE_VERSION, + "16015011", + ) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_ON + + +@pytest.mark.parametrize("device_fixture", ["centralite"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_OFF + + await trigger_health_update( + hass, devices, "d0268a69-abfb-4c92-a646-61cec2e510ad", HealthStatus.OFFLINE + ) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "d0268a69-abfb-4c92-a646-61cec2e510ad", HealthStatus.ONLINE + ) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_OFF + + +@pytest.mark.parametrize("device_fixture", ["centralite"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_valve.py b/tests/components/smartthings/test_valve.py new file mode 100644 index 00000000000..9aff2dc09be --- /dev/null +++ b/tests/components/smartthings/test_valve.py @@ -0,0 +1,129 @@ +"""Test for the SmartThings valve platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smartthings import MAIN +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.VALVE) + + +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_OPEN_VALVE, Command.OPEN), + (SERVICE_CLOSE_VALVE, Command.CLOSE), + ], +) +async def test_valve_open_close( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test valve open and close command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + VALVE_DOMAIN, + action, + {ATTR_ENTITY_ID: "valve.volvo"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", Capability.VALVE, command, MAIN + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("valve.volvo").state == ValveState.CLOSED + + await trigger_update( + hass, + devices, + "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", + Capability.VALVE, + Attribute.VALVE, + "open", + ) + + assert hass.states.get("valve.volvo").state == ValveState.OPEN + + +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("valve.volvo").state == ValveState.CLOSED + + await trigger_health_update( + hass, devices, "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", HealthStatus.OFFLINE + ) + + assert hass.states.get("valve.volvo").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", HealthStatus.ONLINE + ) + + assert hass.states.get("valve.volvo").state == ValveState.CLOSED + + +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("valve.volvo").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_water_heater.py b/tests/components/smartthings/test_water_heater.py new file mode 100644 index 00000000000..a12280e5c92 --- /dev/null +++ b/tests/components/smartthings/test_water_heater.py @@ -0,0 +1,542 @@ +"""Test for the SmartThings water heater platform.""" + +from unittest.mock import AsyncMock, call + +from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smartthings import MAIN +from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, + ATTR_CURRENT_TEMPERATURE, + ATTR_OPERATION_LIST, + ATTR_OPERATION_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + WaterHeaterEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities( + hass, entity_registry, snapshot, Platform.WATER_HEATER + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +@pytest.mark.parametrize( + ("operation_mode", "argument"), + [ + (STATE_ECO, "eco"), + ("standard", "std"), + ("force", "force"), + ("power", "power"), + ], +) +async def test_set_operation_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + operation_mode: str, + argument: str, +) -> None: + """Test set operation mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + ATTR_OPERATION_MODE: operation_mode, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_set_operation_mode_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set operation mode to off.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + ATTR_OPERATION_MODE: STATE_OFF, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.SWITCH, + Command.OFF, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000001_sub"]) +async def test_set_operation_mode_from_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set operation mode.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("water_heater.eco_heating_system").state == STATE_OFF + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.eco_heating_system", + ATTR_OPERATION_MODE: STATE_ECO, + }, + blocking=True, + ) + assert devices.execute_device_command.mock_calls == [ + call( + "1f98ebd0-ac48-d802-7f62-000001200100", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "1f98ebd0-ac48-d802-7f62-000001200100", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="eco", + ), + ] + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_set_operation_to_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set operation mode to off.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + ATTR_OPERATION_MODE: STATE_OFF, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.SWITCH, + Command.OFF, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +@pytest.mark.parametrize( + ("service", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + command: Command, +) -> None: + """Test turn on and off.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + service, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.SWITCH, + command, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_set_temperature( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set operation mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + ATTR_TEMPERATURE: 56, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=56, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +@pytest.mark.parametrize( + ("on", "argument"), + [ + (True, "on"), + (False, "off"), + ], +) +async def test_away_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + on: bool, + argument: str, +) -> None: + """Test set away mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + { + ATTR_ENTITY_ID: "water_heater.warmepumpe", + ATTR_AWAY_MODE: on, + }, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.CUSTOM_OUTING_MODE, + Command.SET_OUTING_MODE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_operation_list_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("water_heater.warmepumpe").attributes[ + ATTR_OPERATION_LIST + ] == [ + STATE_OFF, + STATE_ECO, + "standard", + "power", + "force", + ] + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["eco", "force", "power"], + ) + + assert hass.states.get("water_heater.warmepumpe").attributes[ + ATTR_OPERATION_LIST + ] == [ + STATE_OFF, + STATE_ECO, + "force", + "power", + ] + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_current_operation_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("water_heater.warmepumpe").state == "standard" + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.AIR_CONDITIONER_MODE, + Attribute.AIR_CONDITIONER_MODE, + "eco", + ) + + assert hass.states.get("water_heater.warmepumpe").state == STATE_ECO + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_switch_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("water_heater.warmepumpe") + assert state.state == "standard" + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + | WaterHeaterEntityFeature.AWAY_MODE + ) + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.SWITCH, + Attribute.SWITCH, + "off", + ) + + state = hass.states.get("water_heater.warmepumpe") + assert state.state == STATE_OFF + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE + | WaterHeaterEntityFeature.AWAY_MODE + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_current_temperature_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_CURRENT_TEMPERATURE] + == 49.6 + ) + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 50.0, + ) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_CURRENT_TEMPERATURE] + == 50.0 + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_target_temperature_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_TEMPERATURE] == 52.0 + ) + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.COOLING_SETPOINT, + 50.0, + ) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_TEMPERATURE] == 50.0 + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +@pytest.mark.parametrize( + ("attribute", "old_value", "state_attribute"), + [ + (Attribute.MINIMUM_SETPOINT, 40, ATTR_TARGET_TEMP_LOW), + (Attribute.MAXIMUM_SETPOINT, 57, ATTR_TARGET_TEMP_HIGH), + ], +) +async def test_target_temperature_bound_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + attribute: Attribute, + old_value: float, + state_attribute: str, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[state_attribute] + == old_value + ) + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + attribute, + 50.0, + ) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[state_attribute] == 50.0 + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_away_mode_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_AWAY_MODE] + == STATE_OFF + ) + + await trigger_update( + hass, + devices, + "3810e5ad-5351-d9f9-12ff-000001200000", + Capability.CUSTOM_OUTING_MODE, + Attribute.OUTING_MODE, + "on", + ) + + assert ( + hass.states.get("water_heater.warmepumpe").attributes[ATTR_AWAY_MODE] + == STATE_ON + ) + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("water_heater.warmepumpe").state == "standard" + + await trigger_health_update( + hass, devices, "3810e5ad-5351-d9f9-12ff-000001200000", HealthStatus.OFFLINE + ) + + assert hass.states.get("water_heater.warmepumpe").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "3810e5ad-5351-d9f9-12ff-000001200000", HealthStatus.ONLINE + ) + + assert hass.states.get("water_heater.warmepumpe").state == "standard" + + +@pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("water_heater.warmepumpe").state == STATE_UNAVAILABLE diff --git a/tests/components/smarty/conftest.py b/tests/components/smarty/conftest.py index a9b518d88f4..fe2fb4c7bab 100644 --- a/tests/components/smarty/conftest.py +++ b/tests/components/smarty/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.smarty import DOMAIN +from homeassistant.components.smarty.const import DOMAIN from homeassistant.const import CONF_HOST from tests.common import MockConfigEntry diff --git a/tests/components/smarty/test_binary_sensor.py b/tests/components/smarty/test_binary_sensor.py index d28fb44e1ce..5bc81eceb38 100644 --- a/tests/components/smarty/test_binary_sensor.py +++ b/tests/components/smarty/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/smarty/test_button.py b/tests/components/smarty/test_button.py index 0a7b67f2be6..3bb8da82201 100644 --- a/tests/components/smarty/test_button.py +++ b/tests/components/smarty/test_button.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/smarty/test_config_flow.py b/tests/components/smarty/test_config_flow.py index fad4f27ca1c..831aca52c73 100644 --- a/tests/components/smarty/test_config_flow.py +++ b/tests/components/smarty/test_config_flow.py @@ -3,8 +3,8 @@ from unittest.mock import AsyncMock from homeassistant.components.smarty.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME +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 @@ -114,52 +114,3 @@ async def test_existing_entry( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_import_flow( - hass: HomeAssistant, mock_smarty: 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.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Smarty" - assert result["data"] == {CONF_HOST: "192.168.0.2"} - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_cannot_connect( - hass: HomeAssistant, mock_smarty: AsyncMock -) -> None: - """Test we handle cannot connect error.""" - - mock_smarty.update.return_value = False - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_unknown_error( - hass: HomeAssistant, mock_smarty: AsyncMock -) -> None: - """Test we handle unknown error.""" - - mock_smarty.update.side_effect = Exception - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" diff --git a/tests/components/smarty/test_fan.py b/tests/components/smarty/test_fan.py index 2c0135b7aa2..557a1977017 100644 --- a/tests/components/smarty/test_fan.py +++ b/tests/components/smarty/test_fan.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/smarty/test_init.py b/tests/components/smarty/test_init.py index 0366ea9eade..27c4e0f5145 100644 --- a/tests/components/smarty/test_init.py +++ b/tests/components/smarty/test_init.py @@ -2,70 +2,17 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smarty import DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import device_registry as dr, issue_registry as ir -from homeassistant.setup import async_setup_component +from homeassistant.components.smarty.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_import_flow( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, -) -> None: - """Test import flow.""" - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues - - -async def test_import_flow_already_exists( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test import flow when entry already exists.""" - mock_config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues - - -async def test_import_flow_error( - hass: HomeAssistant, - mock_smarty: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_setup_entry: AsyncMock, -) -> None: - """Test import flow when error occurs.""" - mock_smarty.update.return_value = False - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}} - ) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 0 - assert ( - DOMAIN, - "deprecated_yaml_import_issue_cannot_connect", - ) in issue_registry.issues - - async def test_device( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/smarty/test_sensor.py b/tests/components/smarty/test_sensor.py index a534a2ebb0f..7ec44886952 100644 --- a/tests/components/smarty/test_sensor.py +++ b/tests/components/smarty/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/smarty/test_switch.py b/tests/components/smarty/test_switch.py index 1a6748e2d23..e90eb09fc39 100644 --- a/tests/components/smarty/test_switch.py +++ b/tests/components/smarty/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 7a1b16f1d6b..6c056c95fd9 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -21,6 +21,7 @@ from tests.common import ( MOCK_DEVICE_NAME = "slzb-06" MOCK_HOST = "192.168.1.161" +MOCK_HOSTNAME = "slzb-06p7.lan" MOCK_USERNAME = "test-user" MOCK_PASSWORD = "test-pass" diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index c8933029ce6..497cb8d9484 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -15,7 +15,13 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .conftest import MOCK_DEVICE_NAME, MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME +from .conftest import ( + MOCK_DEVICE_NAME, + MOCK_HOST, + MOCK_HOSTNAME, + MOCK_PASSWORD, + MOCK_USERNAME, +) from tests.common import MockConfigEntry @@ -53,14 +59,14 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: "slzb-06p7.local", + CONF_HOST: MOCK_HOSTNAME, }, ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "SLZB-06p7" assert result2["data"] == { - CONF_HOST: MOCK_HOST, + CONF_HOST: MOCK_HOSTNAME, } assert result2["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" assert len(mock_setup_entry.mock_calls) == 1 @@ -82,7 +88,7 @@ async def test_user_flow_auth( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: "slzb-06p7.local", + CONF_HOST: MOCK_HOSTNAME, }, ) assert result2["type"] is FlowResultType.FORM @@ -100,7 +106,7 @@ async def test_user_flow_auth( assert result3["data"] == { CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, - CONF_HOST: MOCK_HOST, + CONF_HOST: MOCK_HOSTNAME, } assert result3["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" assert len(mock_setup_entry.mock_calls) == 1 @@ -193,7 +199,7 @@ async def test_zeroconf_flow_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 3 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_zeroconf_unsupported_abort( @@ -406,7 +412,7 @@ async def test_user_invalid_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 3 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_user_cannot_connect( @@ -443,7 +449,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) == 3 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_auth_cannot_connect( diff --git a/tests/components/smlight/test_diagnostics.py b/tests/components/smlight/test_diagnostics.py index d0c756bfd87..778ef8e5811 100644 --- a/tests/components/smlight/test_diagnostics.py +++ b/tests/components/smlight/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.smlight.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/smlight/test_sensor.py b/tests/components/smlight/test_sensor.py index f130d7ccf30..bec73bc514a 100644 --- a/tests/components/smlight/test_sensor.py +++ b/tests/components/smlight/test_sensor.py @@ -2,17 +2,18 @@ from unittest.mock import MagicMock -from pysmlight import Sensors +from pysmlight import Info, Sensors import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.smlight.const import DOMAIN from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform pytestmark = [ pytest.mark.usefixtures( @@ -73,3 +74,38 @@ async def test_zigbee_uptime_disconnected( state = hass.states.get("sensor.mock_title_zigbee_uptime") assert state.state == STATE_UNKNOWN + + +async def test_zigbee2_temp_sensor( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test for zb_temp2 if device has second radio.""" + mock_smlight_client.get_sensors.return_value = Sensors(zb_temp2=20.45) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.mock_title_zigbee_chip_temp_2") + assert state + assert state.state == "20.45" + + +async def test_zigbee_type_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test for zigbee type sensor with second radio.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info.from_dict( + load_json_object_fixture("info-MR1.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.mock_title_zigbee_type") + assert state + assert state.state == "coordinator" + + state = hass.states.get("sensor.mock_title_zigbee_type_2") + assert state + assert state.state == "router" diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 86d19968910..d120a08d519 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -154,10 +154,9 @@ async def test_update_zigbee2_firmware( mock_smlight_client: MagicMock, ) -> None: """Test update of zigbee2 firmware where available.""" + mock_info = Info.from_dict(load_json_object_fixture("info-MR1.json", DOMAIN)) mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = Info.from_dict( - load_json_object_fixture("info-MR1.json", DOMAIN) - ) + mock_smlight_client.get_info.return_value = mock_info await setup_integration(hass, mock_config_entry) entity_id = "update.mock_title_zigbee_firmware_2" state = hass.states.get(entity_id) @@ -177,17 +176,17 @@ async def test_update_zigbee2_firmware( event_function = get_mock_event_function(mock_smlight_client, SmEvents.FW_UPD_done) event_function(MOCK_FIRMWARE_DONE) - with patch( - "homeassistant.components.smlight.update.get_radio", return_value=MOCK_RADIO - ): - freezer.tick(timedelta(seconds=5)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "20240716" - assert state.attributes[ATTR_LATEST_VERSION] == "20240716" + mock_info.radios[1] = MOCK_RADIO + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "20240716" + assert state.attributes[ATTR_LATEST_VERSION] == "20240716" async def test_update_legacy_firmware_v2( diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 901d7e547fe..0eb8fda09c5 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -14,6 +14,7 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component +from homeassistant.util.ssl import create_client_context from tests.common import get_fixture_path @@ -84,6 +85,7 @@ def message(): "Home Assistant", 0, True, + create_client_context(), ) diff --git a/tests/components/snoo/__init__.py b/tests/components/snoo/__init__.py index f8529251720..b4692e6f08b 100644 --- a/tests/components/snoo/__init__.py +++ b/tests/components/snoo/__init__.py @@ -1,5 +1,11 @@ """Tests for the Happiest Baby Snoo integration.""" +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +import pytest +from python_snoo.containers import SnooData + from homeassistant.components.snoo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -36,3 +42,13 @@ async def async_init_integration(hass: HomeAssistant) -> ConfigEntry: await hass.async_block_till_done() return entry + + +def find_update_callback( + mock: AsyncMock, serial_number: str +) -> Callable[[SnooData], Awaitable[None]]: + """Find the update callback for a specific identifier.""" + for call in mock.subscribe.call_args_list: + if call[0][0].serialNumber == serial_number: + return call[0][1] + pytest.fail(f"Callback for identifier {serial_number} not found") diff --git a/tests/components/snoo/conftest.py b/tests/components/snoo/conftest.py index 33642e67ff5..6163fa56b7f 100644 --- a/tests/components/snoo/conftest.py +++ b/tests/components/snoo/conftest.py @@ -5,9 +5,8 @@ from unittest.mock import AsyncMock, patch import pytest from python_snoo.containers import SnooDevice -from python_snoo.snoo import Snoo -from .const import MOCK_AMAZON_AUTH, MOCK_SNOO_AUTH, MOCK_SNOO_DEVICES +from .const import MOCK_SNOO_DEVICES, MOCKED_AUTH @pytest.fixture @@ -19,55 +18,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -class MockedSnoo(Snoo): - """Mock the Snoo object.""" - - def __init__(self, email, password, clientsession) -> None: - """Set up a Mocked Snoo.""" - super().__init__(email, password, clientsession) - self.auth_error = None - - async def subscribe(self, device: SnooDevice, function): - """Mock the subscribe function.""" - return AsyncMock() - - async def send_command(self, command: str, device: SnooDevice, **kwargs): - """Mock the send command function.""" - return AsyncMock() - - async def authorize(self): - """Do normal auth flow unless error is patched.""" - if self.auth_error: - raise self.auth_error - return await super().authorize() - - def set_auth_error(self, error: Exception | None): - """Set an error for authentication.""" - self.auth_error = error - - async def auth_amazon(self): - """Mock the amazon auth.""" - return MOCK_AMAZON_AUTH - - async def auth_snoo(self, id_token): - """Mock the snoo auth.""" - return MOCK_SNOO_AUTH - - async def schedule_reauthorization(self, snoo_expiry: int): - """Mock scheduling reauth.""" - return AsyncMock() - - async def get_devices(self) -> list[SnooDevice]: - """Move getting devices.""" - return [SnooDevice.from_dict(dev) for dev in MOCK_SNOO_DEVICES] - - @pytest.fixture(name="bypass_api") -def bypass_api() -> MockedSnoo: +def bypass_api() -> Generator[AsyncMock]: """Bypass the Snoo api.""" - api = MockedSnoo("email", "password", AsyncMock()) with ( - patch("homeassistant.components.snoo.Snoo", return_value=api), - patch("homeassistant.components.snoo.config_flow.Snoo", return_value=api), + patch("homeassistant.components.snoo.Snoo", autospec=True) as mock_client, + patch("homeassistant.components.snoo.config_flow.Snoo", new=mock_client), ): - yield api + client = mock_client.return_value + client.get_devices.return_value = [SnooDevice.from_dict(MOCK_SNOO_DEVICES[0])] + client.authorize.return_value = MOCKED_AUTH + yield client diff --git a/tests/components/snoo/const.py b/tests/components/snoo/const.py index c5d53780fa1..2657048afb8 100644 --- a/tests/components/snoo/const.py +++ b/tests/components/snoo/const.py @@ -1,5 +1,9 @@ """Snoo constants for testing.""" +import time + +from python_snoo.containers import AuthorizationInfo, SnooData + MOCK_AMAZON_AUTH = { # This is a JWT with random values. "AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2" @@ -32,3 +36,36 @@ MOCK_SNOO_DEVICES = [ "provisionedAt": "random_time", } ] + +MOCK_SNOO_DATA = SnooData.from_dict( + { + "system_state": "normal", + "sw_version": "v1.14.27", + "state_machine": { + "session_id": "0", + "state": "ONLINE", + "is_active_session": "false", + "since_session_start_ms": -1, + "time_left": -1, + "hold": "off", + "weaning": "off", + "audio": "on", + "up_transition": "NONE", + "down_transition": "NONE", + "sticky_white_noise": "off", + }, + "left_safety_clip": 1, + "right_safety_clip": 1, + "event": "status_requested", + "event_time_ms": int(time.time()), + "rx_signal": {"rssi": -45, "strength": 100}, + } +) + + +MOCKED_AUTH = AuthorizationInfo( + snoo=MOCK_SNOO_AUTH, + aws_access=MOCK_AMAZON_AUTH["AccessToken"], + aws_id=MOCK_AMAZON_AUTH["IdToken"], + aws_refresh=MOCK_AMAZON_AUTH["RefreshToken"], +) diff --git a/tests/components/snoo/test_binary_sensor.py b/tests/components/snoo/test_binary_sensor.py new file mode 100644 index 00000000000..77b2e36c1fe --- /dev/null +++ b/tests/components/snoo/test_binary_sensor.py @@ -0,0 +1,30 @@ +"""Test Snoo Binary Sensors.""" + +from unittest.mock import AsyncMock + +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_binary_sensors(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test binary sensors and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("binary_sensor")) == 2 + assert ( + hass.states.get("binary_sensor.test_snoo_left_safety_clip").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get("binary_sensor.test_snoo_right_safety_clip").state + == STATE_UNAVAILABLE + ) + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("binary_sensor")) == 2 + assert hass.states.get("binary_sensor.test_snoo_left_safety_clip").state == STATE_ON + assert ( + hass.states.get("binary_sensor.test_snoo_right_safety_clip").state == STATE_ON + ) diff --git a/tests/components/snoo/test_config_flow.py b/tests/components/snoo/test_config_flow.py index ffdfb22142d..9e07f011cd4 100644 --- a/tests/components/snoo/test_config_flow.py +++ b/tests/components/snoo/test_config_flow.py @@ -13,11 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import create_entry -from .conftest import MockedSnoo async def test_config_flow_success( - hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: MockedSnoo + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: AsyncMock ) -> None: """Test we create the entry successfully.""" result = await hass.config_entries.flow.async_init( @@ -55,7 +54,7 @@ async def test_config_flow_success( async def test_form_auth_issues( hass: HomeAssistant, mock_setup_entry: AsyncMock, - bypass_api: MockedSnoo, + bypass_api: AsyncMock, exception, error_msg, ) -> None: @@ -64,7 +63,7 @@ async def test_form_auth_issues( DOMAIN, context={"source": config_entries.SOURCE_USER} ) # Set Authorize to fail. - bypass_api.set_auth_error(exception) + bypass_api.authorize.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -73,10 +72,9 @@ async def test_form_auth_issues( }, ) # Reset auth back to the original - bypass_api.set_auth_error(None) assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": error_msg} - + bypass_api.authorize.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -84,7 +82,6 @@ async def test_form_auth_issues( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" @@ -96,7 +93,7 @@ async def test_form_auth_issues( async def test_account_already_configured( - hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: AsyncMock ) -> None: """Ensure we abort if the config flow already exists.""" create_entry(hass) diff --git a/tests/components/snoo/test_event.py b/tests/components/snoo/test_event.py new file mode 100644 index 00000000000..41cb386a599 --- /dev/null +++ b/tests/components/snoo/test_event.py @@ -0,0 +1,45 @@ +"""Test Snoo Events.""" + +from unittest.mock import AsyncMock + +from freezegun import freeze_time + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +@freeze_time("2025-01-01 12:00:00") +async def test_events(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test events and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("event")) == 1 + assert hass.states.get("event.test_snoo_snoo_event").state == STATE_UNAVAILABLE + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("event")) == 1 + assert ( + hass.states.get("event.test_snoo_snoo_event").state + == "2025-01-01T12:00:00.000+00:00" + ) + + +@freeze_time("2025-01-01 12:00:00") +async def test_events_data_on_startup( + hass: HomeAssistant, bypass_api: AsyncMock +) -> None: + """Test events and check test values are correctly set if data exists on first update.""" + + def update_status(_): + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + + bypass_api.get_status.side_effect = update_status + await async_init_integration(hass) + await hass.async_block_till_done() + assert len(hass.states.async_all("event")) == 1 + assert ( + hass.states.get("event.test_snoo_snoo_event").state + == "2025-01-01T12:00:00.000+00:00" + ) diff --git a/tests/components/snoo/test_init.py b/tests/components/snoo/test_init.py index 06f420b6518..72c4b6fb8ab 100644 --- a/tests/components/snoo/test_init.py +++ b/tests/components/snoo/test_init.py @@ -1,14 +1,32 @@ """Test init for Snoo.""" +from unittest.mock import AsyncMock + +from python_snoo.exceptions import SnooAuthException + +from homeassistant.components.snoo import SnooDeviceError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import async_init_integration -from .conftest import MockedSnoo -async def test_async_setup_entry(hass: HomeAssistant, bypass_api: MockedSnoo) -> None: +async def test_async_setup_entry(hass: HomeAssistant, bypass_api: AsyncMock) -> None: """Test a successful setup entry.""" entry = await async_init_integration(hass) assert len(hass.states.async_all("sensor")) == 2 assert entry.state == ConfigEntryState.LOADED + + +async def test_cannot_auth(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test that we are put into retry when we fail to auth.""" + bypass_api.authorize.side_effect = SnooAuthException + entry = await async_init_integration(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_failed_devices(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test that we are put into retry when we fail to get devices.""" + bypass_api.get_devices.side_effect = SnooDeviceError + entry = await async_init_integration(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/snoo/test_select.py b/tests/components/snoo/test_select.py new file mode 100644 index 00000000000..e00721b2ab8 --- /dev/null +++ b/tests/components/snoo/test_select.py @@ -0,0 +1,75 @@ +"""Test Snoo Selects.""" + +import copy +from unittest.mock import AsyncMock + +import pytest +from python_snoo.containers import SnooDevice, SnooLevels, SnooStates + +from homeassistant.components.select import SERVICE_SELECT_OPTION +from homeassistant.components.snoo.select import SnooCommandException +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_select(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test select and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("select")) == 1 + assert hass.states.get("select.test_snoo_intensity").state == STATE_UNAVAILABLE + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("select")) == 1 + assert hass.states.get("select.test_snoo_intensity").state == "stop" + + +async def test_update_success(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test changing values for select entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("select.test_snoo_intensity").state == "stop" + + async def update_level(device: SnooDevice, level: SnooStates, _hold: bool = False): + new_data = copy.deepcopy(MOCK_SNOO_DATA) + new_data.state_machine.level = SnooLevels(level.value) + find_update_callback(bypass_api, device.serialNumber)(new_data) + + bypass_api.set_level.side_effect = update_level + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "level1"}, + blocking=True, + target={"entity_id": "select.test_snoo_intensity"}, + ) + + assert bypass_api.set_level.assert_called_once + assert hass.states.get("select.test_snoo_intensity").state == "level1" + + +async def test_update_failed(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test failing to change values for select entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("select.test_snoo_intensity").state == "stop" + + bypass_api.set_level.side_effect = SnooCommandException + with pytest.raises( + HomeAssistantError, match="Error while updating Intensity to level1" + ): + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "level1"}, + blocking=True, + target={"entity_id": "select.test_snoo_intensity"}, + ) + + assert bypass_api.set_level.assert_called_once + assert hass.states.get("select.test_snoo_intensity").state == "stop" diff --git a/tests/components/snoo/test_sensor.py b/tests/components/snoo/test_sensor.py new file mode 100644 index 00000000000..96a22e548b8 --- /dev/null +++ b/tests/components/snoo/test_sensor.py @@ -0,0 +1,22 @@ +"""Test Snoo Sensors.""" + +from unittest.mock import AsyncMock + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_sensors(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test sensors and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("sensor")) == 2 + assert hass.states.get("sensor.test_snoo_state").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.test_snoo_time_left").state == STATE_UNAVAILABLE + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 2 + assert hass.states.get("sensor.test_snoo_state").state == "stop" + assert hass.states.get("sensor.test_snoo_time_left").state == STATE_UNKNOWN diff --git a/tests/components/snoo/test_switch.py b/tests/components/snoo/test_switch.py new file mode 100644 index 00000000000..2343ff6c0d8 --- /dev/null +++ b/tests/components/snoo/test_switch.py @@ -0,0 +1,88 @@ +"""Test Snoo Switches.""" + +import copy +from unittest.mock import AsyncMock + +import pytest +from python_snoo.containers import SnooDevice +from python_snoo.exceptions import SnooCommandException + +from homeassistant.components.switch import ( + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_switch(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test switch and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("switch")) == 2 + assert hass.states.get("switch.test_snoo_level_lock").state == STATE_UNAVAILABLE + assert ( + hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_UNAVAILABLE + ) + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("switch")) == 2 + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF + assert hass.states.get("switch.test_snoo_level_lock").state == STATE_OFF + + +async def test_update_success(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test changing values for switch entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF + + async def set_sticky_white_noise(device: SnooDevice, state: bool): + new_data = copy.deepcopy(MOCK_SNOO_DATA) + new_data.state_machine.sticky_white_noise = "off" if not state else "on" + find_update_callback(bypass_api, device.serialNumber)(new_data) + + bypass_api.set_sticky_white_noise.side_effect = set_sticky_white_noise + await hass.services.async_call( + "switch", + SERVICE_TOGGLE, + blocking=True, + target={"entity_id": "switch.test_snoo_sleepytime_sounds"}, + ) + + assert bypass_api.set_sticky_white_noise.assert_called_once + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_ON + + +@pytest.mark.parametrize( + ("command", "error_str"), + [ + (SERVICE_TURN_ON, "Turning Sleepytime sounds on failed"), + (SERVICE_TURN_OFF, "Turning Sleepytime sounds off failed"), + ], +) +async def test_update_failed( + hass: HomeAssistant, bypass_api: AsyncMock, command: str, error_str: str +) -> None: + """Test failing to change values for switch entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF + + bypass_api.set_sticky_white_noise.side_effect = SnooCommandException + with pytest.raises(HomeAssistantError, match=error_str): + await hass.services.async_call( + "switch", + command, + blocking=True, + target={"entity_id": "switch.test_snoo_sleepytime_sounds"}, + ) + + assert bypass_api.set_level.assert_called_once + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF diff --git a/tests/components/solarlog/test_diagnostics.py b/tests/components/solarlog/test_diagnostics.py index bc0b020462d..b129f5265be 100644 --- a/tests/components/solarlog/test_diagnostics.py +++ b/tests/components/solarlog/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.const import Platform diff --git a/tests/components/solarlog/test_sensor.py b/tests/components/solarlog/test_sensor.py index 77aa0308cda..132220c6261 100644 --- a/tests/components/solarlog/test_sensor.py +++ b/tests/components/solarlog/test_sensor.py @@ -10,7 +10,7 @@ from solarlog_cli.solarlog_exceptions import ( SolarLogUpdateError, ) from solarlog_cli.solarlog_models import InverterData -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index e22f18c6d77..b33151678a5 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -505,7 +505,7 @@ def mock_browse_by_idstring( def mock_get_music_library_information( - search_type: str, search_term: str, full_album_art_uri: bool = True + search_type: str, search_term: str | None = None, full_album_art_uri: bool = True ) -> list[MockMusicServiceItem]: """Mock the call to get music library information.""" if search_type == "albums" and search_term == "Abbey Road": @@ -517,6 +517,10 @@ def mock_get_music_library_information( "object.container.album.musicAlbum", ) ] + if search_type == "sonos_playlists": + playlists = load_json_value_fixture("sonos_playlists.json", "sonos") + playlists_list = [DidlPlaylistContainer.from_dict(pl) for pl in playlists] + return SearchResult(playlists_list, "sonos_playlists", 1, 1, 0) return [] diff --git a/tests/components/sonos/fixtures/sonos_favorites.json b/tests/components/sonos/fixtures/sonos_favorites.json index 21ee68f4872..40213ea8715 100644 --- a/tests/components/sonos/fixtures/sonos_favorites.json +++ b/tests/components/sonos/fixtures/sonos_favorites.json @@ -27,6 +27,7 @@ "title": "1984", "parent_id": "FV:2", "item_id": "FV:2/8", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.2%2fmusic%2fiTunes%2520Music%2fAerosmith%2f1984&v=742", "resource_meta_data": "1984object.container.album.musicAlbumRINCON_AssociatedZPUDN", "resources": [ { @@ -34,5 +35,23 @@ "protocol_info": "a:b:c:d" } ] + }, + { + "title": "American Tall Tales", + "parent_id": "FV:2", + "item_id": "FV:2/66", + "restricted": false, + "resource_meta_data": "American Tall Talesobject.item.audioItem.audioBookSA_RINCON61191_X_#Svc6-0-Token", + "resources": [ + { + "uri": "x-rincon-cpcontainer:101340c8reftitle%C9F27_com?sid=239&flags=16584&sn=5", + "protocol_info": "x-rincon-cpcontainer:*:*:*" + } + ], + "desc": null, + "album_art_uri": "https://m.media-amazon.com/images/I/810lqLo5m0L._SL600_.jpg", + "type": "instantPlay", + "description": "Audible", + "favorite_nr": "0" } ] diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr index ae8e813ae5d..07992c4474c 100644 --- a/tests/components/sonos/snapshots/test_media_browser.ambr +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -1,9 +1,124 @@ # serializer version: 1 +# name: test_browse_media_favorites[-favorites] + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'object.container.album.musicAlbum', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Albums', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'playlist', + 'media_content_id': 'object.container.playlistContainer', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Playlists', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'object.item.audioItem.audioBook', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Audio Book', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'genre', + 'media_content_id': 'object.item.audioItem.audioBroadcast', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Radio', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Favorites', + }) +# --- +# name: test_browse_media_favorites[object.container.album.musicAlbum-favorites_folder] + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'FV:2/8', + 'media_content_type': 'favorite_item_id', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.2/music/iTunes%20Music/Aerosmith/1984&v=742', + 'title': '1984', + }), + ]), + 'children_media_class': 'album', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Albums', + }) +# --- +# name: test_browse_media_favorites[object.item.audioItem.audioBook-favorites_folder] + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'FV:2/66', + 'media_content_type': 'favorite_item_id', + 'thumbnail': 'https://m.media-amazon.com/images/I/810lqLo5m0L._SL600_.jpg', + 'title': 'American Tall Tales', + }), + ]), + 'children_media_class': 'track', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Audio Book', + }) +# --- # name: test_browse_media_library list([ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'contributing_artist', 'media_content_id': 'A:ARTIST', @@ -14,6 +129,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'artist', 'media_content_id': 'A:ALBUMARTIST', @@ -24,6 +140,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': 'A:ALBUM', @@ -34,6 +151,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'genre', 'media_content_id': 'A:GENRE', @@ -44,6 +162,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'composer', 'media_content_id': 'A:COMPOSER', @@ -54,6 +173,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'track', 'media_content_id': 'A:TRACKS', @@ -64,6 +184,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'playlist', 'media_content_id': 'A:PLAYLISTS', @@ -78,6 +199,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': "A:ALBUM/A%20Hard%20Day's%20Night", @@ -88,6 +210,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': 'A:ALBUM/Abbey%20Road', @@ -98,6 +221,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': 'A:ALBUM/Between%20Good%20And%20Evil', @@ -108,6 +232,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': 'album', 'media_content_id': "A:ALBUM/Special%20Characters,'()+", @@ -122,6 +247,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'directory', 'media_content_id': '', @@ -132,6 +258,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': 'directory', 'media_content_id': '', diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 70605092da1..8454b4ad673 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -123,6 +123,22 @@ async def test_zeroconf_form( assert len(mock_manager.mock_calls) == 2 +async def test_zeroconf_form_not_ipv4( + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo +) -> None: + """Test we pass Zeroconf discoveries to the manager.""" + mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock() + zeroconf_payload.ip_address = ip_address("2001:db8:3333:4444:5555:6666:7777:8888") + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_payload, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_ipv4_address" + assert mock_manager.call_count == 0 + + async def test_ssdp_discovery(hass: HomeAssistant, soco) -> None: """Test that SSDP discoveries create a config flow.""" diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index a7ad2f4cb82..c6be606eb20 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -455,3 +455,32 @@ async def test_async_poll_manual_hosts_8( assert "media_player.garage" in entity_registry.entities assert "media_player.studio" in entity_registry.entities await hass.async_block_till_done(wait_background_tasks=True) + + +async def _setup_hass_ipv6_address_not_supported(hass: HomeAssistant): + await async_setup_component( + hass, + sonos.DOMAIN, + { + "sonos": { + "media_player": { + "interface_addr": "127.0.0.1", + "hosts": ["2001:db8:3333:4444:5555:6666:7777:8888"], + } + } + }, + ) + await hass.async_block_till_done() + + +async def test_ipv6_not_supported( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Tests that invalid ipv4 addresses do not generate stack dump.""" + with caplog.at_level(logging.DEBUG): + caplog.clear() + await _setup_hass_ipv6_address_not_supported(hass) + await hass.async_block_till_done() + assert "invalid ip_address received" in caplog.text + assert "2001:db8:3333:4444:5555:6666:7777:8888" in caplog.text diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index 6e03935f7f6..669e9168297 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -2,7 +2,8 @@ from functools import partial -from syrupy import SnapshotAssertion +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType from homeassistant.components.sonos.media_browser import ( @@ -176,3 +177,43 @@ async def test_browse_media_library_albums( assert response["success"] assert response["result"]["children"] == snapshot assert soco_mock.music_library.browse_by_idstring.call_count == 1 + + +@pytest.mark.parametrize( + ("media_content_id", "media_content_type"), + [ + ( + "", + "favorites", + ), + ( + "object.item.audioItem.audioBook", + "favorites_folder", + ), + ( + "object.container.album.musicAlbum", + "favorites_folder", + ), + ], +) +async def test_browse_media_favorites( + async_autosetup_sonos, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + media_content_id, + media_content_type, +) -> None: + """Test the async_browse_media method.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + "media_content_id": media_content_id, + "media_content_type": media_content_type, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index cec40c997a7..aaaaac6a4ba 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest from soco.data_structures import SearchResult from sonos_websocket.exception import SonosWebsocketError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, @@ -692,6 +692,7 @@ async def test_select_source_line_in_tv( "play_uri": 1, "play_uri_uri": "x-sonosapi-radio:ST%3aetc", "play_uri_title": "James Taylor Radio", + "play_uri_meta": 'James Taylor Radioobject.item.audioItem.audioBroadcast.#stationSA_RINCON60423_X_#Svc60423-99999999-Token', }, ), ( @@ -700,6 +701,16 @@ async def test_select_source_line_in_tv( "play_uri": 1, "play_uri_uri": "x-sonosapi-hls:Api%3atune%3aliveAudio%3ajazzcafe%3aetc", "play_uri_title": "66 - Watercolors", + "play_uri_meta": '66 - Watercolorsobject.item.audioItem.audioBroadcastSA_RINCON9479_X_#Svc9479-99999999-Token', + }, + ), + ( + "American Tall Tales", + { + "play_uri": 1, + "play_uri_uri": "x-rincon-cpcontainer:101340c8reftitle%C9F27_com?sid=239&flags=16584&sn=5", + "play_uri_title": "American Tall Tales", + "play_uri_meta": 'American Tall Talesobject.item.audioItem.audioBookSA_RINCON61191_X_#Svc6-0-Token', }, ), ], @@ -726,6 +737,7 @@ async def test_select_source_play_uri( soco_mock.play_uri.assert_called_with( result.get("play_uri_uri"), title=result.get("play_uri_title"), + meta=result.get("play_uri_meta"), timeout=LONG_SERVICE_TIMEOUT, ) diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index 8c0e897947a..154ddb9253e 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -94,10 +94,12 @@ SENSOR_OUTPUT = { @pytest.fixture -def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: +async def mock_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Start the Home Assistant HTTP component.""" with patch("homeassistant.components.spaceapi", return_value=True): - hass.loop.run_until_complete(async_setup_component(hass, "spaceapi", CONFIG)) + await async_setup_component(hass, "spaceapi", CONFIG) hass.states.async_set( "test.temp1", @@ -126,7 +128,7 @@ def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> Tes "test.hum1", 88, attributes={ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE} ) - return hass.loop.run_until_complete(hass_client()) + return await hass_client() async def test_spaceapi_get(hass: HomeAssistant, mock_client) -> None: diff --git a/tests/components/spotify/snapshots/test_media_browser.ambr b/tests/components/spotify/snapshots/test_media_browser.ambr index 6b217977227..e241893df3b 100644 --- a/tests/components/spotify/snapshots/test_media_browser.ambr +++ b/tests/components/spotify/snapshots/test_media_browser.ambr @@ -3,10 +3,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_playlists', @@ -17,6 +19,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_followed_artists', @@ -27,6 +30,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_saved_albums', @@ -37,6 +41,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_saved_tracks', @@ -47,6 +52,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_saved_shows', @@ -57,6 +63,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_recently_played', @@ -67,6 +74,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_top_artists', @@ -77,6 +85,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/current_user_top_tracks', @@ -87,6 +96,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/new_releases', @@ -108,10 +118,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:4WkWJ0EjHEFASDevhM8oPw', @@ -122,6 +134,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:1RHirWgH1weMsBLi4KOK9d', @@ -143,10 +156,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://32oesphrnacjcf7vw5bf6odx3/spotify:playlist:4WkWJ0EjHEFASDevhM8oPw', @@ -157,6 +172,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://32oesphrnacjcf7vw5bf6odx3/spotify:playlist:1RHirWgH1weMsBLi4KOK9d', @@ -178,10 +194,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01J5TX5A0FF6G5V0QJX6HBC94T', @@ -192,6 +210,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://32oesphrnacjcf7vw5bf6odx3', @@ -213,10 +232,12 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:6akJGriy4njdP8fZTPGjwz', @@ -227,6 +248,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:7N02bJK1amhplZ8yAapRS5', @@ -248,10 +270,12 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:56jg3KJcYmfL7RzYmG2O1Q', @@ -262,6 +286,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:1l86t4bTNT2j1X0ZBCIv6R', @@ -283,10 +308,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:artist:0lLY20XpZ9yDobkbHI7u1y', @@ -297,6 +324,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:artist:0p4nmQO2msCgU4IF37Wi3j', @@ -318,10 +346,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:4WkWJ0EjHEFASDevhM8oPw', @@ -332,6 +362,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:playlist:1RHirWgH1weMsBLi4KOK9d', @@ -353,10 +384,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:71dMjqJ8UJV700zYs5YZCh', @@ -367,6 +400,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:71dMjqJ8UJV700zYs5YZCh', @@ -388,10 +422,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:57MSBg5pBQZH5bfLVDmeuP', @@ -402,6 +438,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:3DQueEd1Ft9PHWgovDzPKh', @@ -423,10 +460,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:show:5OzkclFjD6iAjtAuo7aIYt', @@ -437,6 +476,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:show:6XYRres0KZtnTqKcLavWR2', @@ -458,10 +498,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:2pj2A25YQK4uMxhZheNx7R', @@ -472,6 +514,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:2lKOI1nwP5qZtZC7TGQVY8', @@ -493,10 +536,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:artist:74Yus6IHfa3tWZzXXAYtS2', @@ -507,6 +552,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:artist:6s5ubAp65wXoTZefE01RNR', @@ -528,10 +574,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:3oRoMXsP2NRzm51lldj1RO', @@ -542,6 +590,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:69zgu5rlAie3IPZOEXLxyS', @@ -563,10 +612,12 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children': list([ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:5SGtrmYbIo0Dsg4kJ4qjM6', @@ -577,6 +628,7 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:album:713lZ7AF55fEFSQgcttj9y', @@ -598,10 +650,12 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:4rzfv0JLZfVhOhbSQ8o5jZ', @@ -612,6 +666,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:5o3jMYOSbaVz3tkgwhELSV', @@ -622,6 +677,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:4Cy0NHJ8Gh0xMdwyM9RkQm', @@ -632,6 +688,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:6hvFrZNocdt2FcKGCSY5NI', @@ -642,6 +699,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:track:2E2znCPaS8anQe21GLxcvJ', @@ -652,6 +710,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:episode:3o0RYoo5iOMKSmEbunsbvW', @@ -673,10 +732,12 @@ dict({ 'can_expand': True, 'can_play': True, + 'can_search': False, 'children': list([ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:episode:3ssmxnilHYaKhwRWoBGMbU', @@ -687,6 +748,7 @@ dict({ 'can_expand': False, 'can_play': True, + 'can_search': False, 'children_media_class': None, 'media_class': , 'media_content_id': 'spotify://01j5tx5a0ff6g5v0qjx6hbc94t/spotify:episode:1bbj9aqeeZ3UMUlcWN0S03', diff --git a/tests/components/spotify/test_diagnostics.py b/tests/components/spotify/test_diagnostics.py index 6744ca11a00..80ef136e779 100644 --- a/tests/components/spotify/test_diagnostics.py +++ b/tests/components/spotify/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/spotify/test_media_browser.py b/tests/components/spotify/test_media_browser.py index ff3404dcfe9..603bc70c7c5 100644 --- a/tests/components/spotify/test_media_browser.py +++ b/tests/components/spotify/test_media_browser.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import BrowseError from homeassistant.components.spotify import DOMAIN diff --git a/tests/components/spotify/test_media_player.py b/tests/components/spotify/test_media_player.py index 456af43d411..913034b9636 100644 --- a/tests/components/spotify/test_media_player.py +++ b/tests/components/spotify/test_media_player.py @@ -12,7 +12,7 @@ from spotifyaio import ( SpotifyConnectionError, SpotifyNotFoundError, ) -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 6b4032323d0..354840c518e 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -317,6 +317,8 @@ async def test_templates_with_yaml( state = hass.states.get("sensor.get_values_with_template") assert state.state == STATE_UNAVAILABLE + assert CONF_ICON not in state.attributes + assert "entity_picture" not in state.attributes hass.states.async_set("sensor.input1", "on") hass.states.async_set("sensor.input2", "on") @@ -660,3 +662,37 @@ async def test_setup_without_recorder(hass: HomeAssistant) -> None: state = hass.states.get("sensor.get_value") assert state.state == "5" + + +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.get_value: 'x' is undefined" + config = YAML_CONFIG + config["sql"]["value_template"] = "{{ x - 0 }}" + config["sql"]["availability"] = '{{ states("sensor.input1")=="on" }}' + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("sensor.get_value") + assert state + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.input1", "on") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 9ca750808c5..fb2428ba758 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -25,6 +25,8 @@ from homeassistant.components.squeezebox.const import ( STATUS_SENSOR_OTHER_PLAYER_COUNT, STATUS_SENSOR_PLAYER_COUNT, STATUS_SENSOR_RESCAN, + STATUS_UPDATE_NEWPLUGINS, + STATUS_UPDATE_NEWVERSION, ) from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant @@ -69,6 +71,9 @@ FAKE_QUERY_RESPONSE = { STATUS_SENSOR_INFO_TOTAL_SONGS: 42, STATUS_SENSOR_PLAYER_COUNT: 10, STATUS_SENSOR_OTHER_PLAYER_COUNT: 0, + STATUS_UPDATE_NEWVERSION: 'A new version of Logitech Media Server is available (8.5.2 - 0). Click here for further information.', + STATUS_UPDATE_NEWPLUGINS: "Plugins have been updated - Restart Required (Big Sounds)", + "_can": 1, "players_loop": [ { "isplaying": 0, @@ -163,7 +168,7 @@ async def mock_async_browse( "title": "Fake Item 2", "id": FAKE_VALID_ITEM_ID + "_2", "hasitems": media_type == "favorites", - "isaudio": True, + "isaudio": False, "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", @@ -269,6 +274,7 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.title = None mock_player.image_url = None mock_player.model = "SqueezeLite" + mock_player.creator = "Ralph Irving & Adrian Smith" return mock_player @@ -298,7 +304,9 @@ def mock_pysqueezebox_server( mock_lms.uuid = uuid mock_lms.name = TEST_SERVER_NAME mock_lms.async_query = AsyncMock(return_value={"uuid": format_mac(uuid)}) - mock_lms.async_status = AsyncMock(return_value={"uuid": format_mac(uuid)}) + mock_lms.async_status = AsyncMock( + return_value={"uuid": format_mac(uuid), "version": FAKE_VERSION} + ) return mock_lms @@ -309,7 +317,27 @@ async def configure_squeezebox_media_player_platform( ) -> None: """Configure a squeezebox config entry with appropriate mocks for media_player.""" with ( - patch("homeassistant.components.squeezebox.PLATFORMS", [Platform.MEDIA_PLAYER]), + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.MEDIA_PLAYER], + ), + patch("homeassistant.components.squeezebox.Server", return_value=lms), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + +async def configure_squeezebox_media_player_button_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + lms: MagicMock, +) -> None: + """Configure a squeezebox config entry with appropriate mocks for media_player.""" + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.BUTTON], + ), patch("homeassistant.components.squeezebox.Server", return_value=lms), ): await hass.config_entries.async_setup(config_entry.entry_id) @@ -325,6 +353,15 @@ async def configured_player( return (await lms.async_get_players())[0] +@pytest.fixture +async def configured_player_with_button( + hass: HomeAssistant, config_entry: MockConfigEntry, lms: MagicMock +) -> MagicMock: + """Fixture mocking calls to pysqueezebox Player from a configured squeezebox.""" + await configure_squeezebox_media_player_button_platform(hass, config_entry, lms) + return (await lms.async_get_players())[0] + + @pytest.fixture async def configured_players( hass: HomeAssistant, config_entry: MockConfigEntry, lms_factory: MagicMock diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index 34d6ae16af8..7540a448882 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -24,7 +24,7 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': 'Ralph Irving', + 'manufacturer': 'Ralph Irving & Adrian Smith', 'model': 'SqueezeLite', 'model_id': None, 'name': 'Test Player', @@ -78,12 +78,8 @@ 'group_members': list([ ]), 'is_volume_muted': True, - 'media_album_name': 'None', - 'media_artist': 'None', - 'media_channel': 'None', 'media_duration': 1, 'media_position': 1, - 'media_title': 'None', 'query_result': dict({ }), 'repeat': , diff --git a/tests/components/squeezebox/test_button.py b/tests/components/squeezebox/test_button.py new file mode 100644 index 00000000000..16ced65be61 --- /dev/null +++ b/tests/components/squeezebox/test_button.py @@ -0,0 +1,23 @@ +"""Tests for the squeezebox button component.""" + +from unittest.mock import MagicMock + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + + +async def test_squeezebox_press( + hass: HomeAssistant, configured_player_with_button: MagicMock +) -> None: + """Test press service call.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_player_preset_1"}, + blocking=True, + ) + + configured_player_with_button.async_query.assert_called_with( + "button", "preset_1.single" + ) diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index 7b11ef30a87..f1ba187a699 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -65,21 +65,21 @@ async def test_async_browse_media_root( assert response["success"] result = response["result"] for idx, item in enumerate(result["children"]): - assert item["title"] == LIBRARY[idx] + assert item["title"].lower() == LIBRARY[idx] @pytest.mark.parametrize( ("category", "child_count"), [ - ("Favorites", 4), - ("Artists", 4), - ("Albums", 4), - ("Playlists", 4), - ("Genres", 4), - ("New Music", 4), - ("Album Artists", 4), - ("Apps", 3), - ("Radios", 3), + ("favorites", 4), + ("artists", 4), + ("albums", 4), + ("playlists", 4), + ("genres", 4), + ("new music", 4), + ("album artists", 4), + ("apps", 3), + ("radios", 3), ], ) async def test_async_browse_media_with_subitems( diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index f3292f1b469..bbdad374bcf 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, @@ -799,6 +799,8 @@ async def test_squeezebox_server_discovery( """Mock the async_discover function of pysqueezebox.""" return callback(lms_factory(2)) + lms.async_prepared_status.return_value = {} + with ( patch( "homeassistant.components.squeezebox.Server", diff --git a/tests/components/squeezebox/test_update.py b/tests/components/squeezebox/test_update.py new file mode 100644 index 00000000000..b233afbcde1 --- /dev/null +++ b/tests/components/squeezebox/test_update.py @@ -0,0 +1,232 @@ +"""Test squeezebox update platform.""" + +import copy +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.components.squeezebox.const import ( + SENSOR_UPDATE_INTERVAL, + STATUS_UPDATE_NEWPLUGINS, +) +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util + +from .conftest import FAKE_QUERY_RESPONSE + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_update_lms( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("update.fakelib_lyrion_music_server") + + assert state is not None + assert state.state == STATE_ON + + +async def test_update_plugins_install_fallback( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + entity_id = "update.fakelib_updated_plugins" + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + polltime = 30 + with ( + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=False, + ), + patch( + "homeassistant.components.squeezebox.update.POLL_AFTER_INSTALL", + polltime, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + attrs = state.attributes + assert attrs[ATTR_IN_PROGRESS] + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_status", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=polltime + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + attrs = state.attributes + assert not attrs[ATTR_IN_PROGRESS] + + +async def test_update_plugins_install_restart_fail( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + entity_id = "update.fakelib_updated_plugins" + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=True, + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + attrs = state.attributes + assert not attrs[ATTR_IN_PROGRESS] + + +async def test_update_plugins_install_ok( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states and attributes.""" + + entity_id = "update.fakelib_updated_plugins" + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.UPDATE], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=False, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + attrs = state.attributes + assert attrs[ATTR_IN_PROGRESS] + + resp = copy.deepcopy(FAKE_QUERY_RESPONSE) + del resp[STATUS_UPDATE_NEWPLUGINS] + + with ( + patch( + "homeassistant.components.squeezebox.Server.async_status", + return_value=resp, + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + ), + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=SENSOR_UPDATE_INTERVAL + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + attrs = state.attributes + assert not attrs[ATTR_IN_PROGRESS] diff --git a/tests/components/ssdp/__init__.py b/tests/components/ssdp/__init__.py index b6dcb9d49b5..e01136e051a 100644 --- a/tests/components/ssdp/__init__.py +++ b/tests/components/ssdp/__init__.py @@ -1 +1,27 @@ """Tests for the SSDP integration.""" + +from __future__ import annotations + +from datetime import datetime + +from async_upnp_client.ssdp import udn_from_headers +from async_upnp_client.ssdp_listener import SsdpListener +from async_upnp_client.utils import CaseInsensitiveDict + +from homeassistant.components import ssdp +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def init_ssdp_component(hass: HomeAssistant) -> SsdpListener: + """Initialize ssdp component and get SsdpListener.""" + await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + return hass.data[ssdp.DOMAIN][ssdp.SSDP_SCANNER]._ssdp_listeners[0] + + +def _ssdp_headers(headers) -> CaseInsensitiveDict: + """Create a CaseInsensitiveDict with headers and a timestamp.""" + ssdp_headers = CaseInsensitiveDict(headers, _timestamp=datetime.now()) + ssdp_headers["_udn"] = udn_from_headers(ssdp_headers) + return ssdp_headers diff --git a/tests/components/ssdp/conftest.py b/tests/components/ssdp/conftest.py index ac0ac7298a8..61c763ce7d4 100644 --- a/tests/components/ssdp/conftest.py +++ b/tests/components/ssdp/conftest.py @@ -14,9 +14,9 @@ from homeassistant.core import HomeAssistant async def silent_ssdp_listener(): """Patch SsdpListener class, preventing any actual SSDP traffic.""" with ( - patch("homeassistant.components.ssdp.SsdpListener.async_start"), - patch("homeassistant.components.ssdp.SsdpListener.async_stop"), - patch("homeassistant.components.ssdp.SsdpListener.async_search"), + patch("homeassistant.components.ssdp.scanner.SsdpListener.async_start"), + patch("homeassistant.components.ssdp.scanner.SsdpListener.async_stop"), + patch("homeassistant.components.ssdp.scanner.SsdpListener.async_search"), ): # Fixtures are initialized before patches. When the component is started here, # certain functions/methods might not be patched in time. @@ -27,9 +27,9 @@ async def silent_ssdp_listener(): async def disabled_upnp_server(): """Disable UPnpServer.""" with ( - patch("homeassistant.components.ssdp.UpnpServer.async_start"), - patch("homeassistant.components.ssdp.UpnpServer.async_stop"), - patch("homeassistant.components.ssdp._async_find_next_available_port"), + patch("homeassistant.components.ssdp.server.UpnpServer.async_start"), + patch("homeassistant.components.ssdp.server.UpnpServer.async_stop"), + patch("homeassistant.components.ssdp.server._async_find_next_available_port"), ): yield UpnpServer diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 56623b51bb5..a3cc4d9d2bf 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -1,18 +1,16 @@ """Test the SSDP integration.""" -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 -from async_upnp_client.ssdp import udn_from_headers from async_upnp_client.ssdp_listener import SsdpListener -from async_upnp_client.utils import CaseInsensitiveDict import pytest from homeassistant import config_entries from homeassistant.components import ssdp +from homeassistant.components.ssdp import scanner from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, @@ -38,9 +36,10 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_UPC, SsdpServiceInfo, ) -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from . import _ssdp_headers, init_ssdp_component + from tests.common import ( MockConfigEntry, MockModule, @@ -51,19 +50,6 @@ from tests.common import ( from tests.test_util.aiohttp import AiohttpClientMocker -def _ssdp_headers(headers): - ssdp_headers = CaseInsensitiveDict(headers, _timestamp=datetime.now()) - ssdp_headers["_udn"] = udn_from_headers(ssdp_headers) - return ssdp_headers - - -async def init_ssdp_component(hass: HomeAssistant) -> SsdpListener: - """Initialize ssdp component and get SsdpListener.""" - await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) - await hass.async_block_till_done() - return hass.data[ssdp.DOMAIN][ssdp.SSDP_SCANNER]._ssdp_listeners[0] - - @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={"mock-domain": [{"st": "mock-st"}]}, @@ -481,7 +467,7 @@ async def test_discovery_from_advertisement_sets_ssdp_st( @patch( - "homeassistant.components.ssdp.async_build_source_set", + "homeassistant.components.ssdp.common.async_build_source_set", return_value={IPv4Address("192.168.1.1")}, ) async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: @@ -490,7 +476,7 @@ async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + scanner.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_start.call_count == 1 assert ssdp_listener.async_search.call_count == 4 @@ -498,7 +484,7 @@ async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + scanner.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_start.call_count == 1 assert ssdp_listener.async_search.call_count == 4 @@ -739,7 +725,7 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ }, ) @patch( - "homeassistant.components.ssdp.network.async_get_adapters", + "homeassistant.components.ssdp.common.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ) async def test_async_detect_interfaces_setting_empty_route( @@ -764,7 +750,7 @@ async def test_async_detect_interfaces_setting_empty_route( }, ) @patch( - "homeassistant.components.ssdp.network.async_get_adapters", + "homeassistant.components.ssdp.common.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ) async def test_bind_failure_skips_adapter( @@ -813,7 +799,7 @@ async def test_bind_failure_skips_adapter( }, ) @patch( - "homeassistant.components.ssdp.network.async_get_adapters", + "homeassistant.components.ssdp.common.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ) async def test_ipv4_does_additional_search_for_sonos( @@ -824,7 +810,7 @@ async def test_ipv4_does_additional_search_for_sonos( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + scanner.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_search.call_count == 6 diff --git a/tests/components/ssdp/test_websocket_api.py b/tests/components/ssdp/test_websocket_api.py new file mode 100644 index 00000000000..eb71c33a690 --- /dev/null +++ b/tests/components/ssdp/test_websocket_api.py @@ -0,0 +1,147 @@ +"""The tests for the ssdp WebSocket API.""" + +import asyncio +from unittest.mock import ANY, AsyncMock, Mock, patch + +from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant + +from . import _ssdp_headers, init_ssdp_component + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator + + +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"deviceType": "Paulus"}]}, +) +async def test_subscribe_discovery( + mock_get_ssdp: Mock, + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_flow_init: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test ssdp subscribe_discovery.""" + aioclient_mock.get( + "http://1.1.1.1", + text=""" + + + Paulus + Bedroom TV + + + """, + ) + ssdp_listener = await init_ssdp_component(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "_source": "search", + } + ) + ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done(wait_background_tasks=True) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "ssdp/subscribe_discovery", + } + ) + 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"] == [ + { + "ssdp_all_locations": ["http://1.1.1.1"], + "ssdp_ext": None, + "ssdp_headers": { + "_source": "search", + "_timestamp": ANY, + "_udn": "uuid:mock-udn", + "location": "http://1.1.1.1", + "st": "mock-st", + "usn": "uuid:mock-udn::mock-st", + }, + "ssdp_location": "http://1.1.1.1", + "ssdp_nt": None, + "ssdp_server": None, + "ssdp_st": "mock-st", + "ssdp_udn": "uuid:mock-udn", + "ssdp_usn": "uuid:mock-udn::mock-st", + "upnp": { + "UDN": "uuid:mock-udn", + "deviceType": "Paulus", + "friendlyName": "Bedroom TV", + }, + "name": "Bedroom TV", + "x_homeassistant_matching_domains": [], + } + ] + + mock_ssdp_advertisement = _ssdp_headers( + { + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "nt": "upnp:rootdevice", + "nts": "ssdp:alive", + "_source": "advertisement", + } + ) + ssdp_listener._on_alive(mock_ssdp_advertisement) + await hass.async_block_till_done(wait_background_tasks=True) + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"]["add"] == [ + { + "ssdp_all_locations": ["http://1.1.1.1"], + "ssdp_ext": None, + "ssdp_headers": { + "_source": "advertisement", + "_timestamp": ANY, + "_udn": "uuid:mock-udn", + "location": "http://1.1.1.1", + "nt": "upnp:rootdevice", + "nts": "ssdp:alive", + "usn": "uuid:mock-udn::mock-st", + }, + "ssdp_location": "http://1.1.1.1", + "ssdp_nt": "upnp:rootdevice", + "ssdp_server": None, + "ssdp_st": "upnp:rootdevice", + "ssdp_udn": "uuid:mock-udn", + "ssdp_usn": "uuid:mock-udn::mock-st", + "upnp": { + "UDN": "uuid:mock-udn", + "deviceType": "Paulus", + "friendlyName": "Bedroom TV", + }, + "name": "Bedroom TV", + "x_homeassistant_matching_domains": ["mock-domain"], + } + ] + + mock_ssdp_advertisement["nts"] = "ssdp:byebye" + ssdp_listener._on_byebye(mock_ssdp_advertisement) + await hass.async_block_till_done(wait_background_tasks=True) + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"]["remove"] == [ + {"ssdp_location": "http://1.1.1.1", "ssdp_st": "upnp:rootdevice"} + ] diff --git a/tests/components/statistics/test_config_flow.py b/tests/components/statistics/test_config_flow.py index 77ccba5ba4c..fd82e688ee0 100644 --- a/tests/components/statistics/test_config_flow.py +++ b/tests/components/statistics/test_config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.recorder import Recorder diff --git a/tests/components/stiebel_eltron/__init__.py b/tests/components/stiebel_eltron/__init__.py new file mode 100644 index 00000000000..eaddd4c578b --- /dev/null +++ b/tests/components/stiebel_eltron/__init__.py @@ -0,0 +1 @@ +"""Tests for the STIEBEL ELTRON integration.""" diff --git a/tests/components/stiebel_eltron/conftest.py b/tests/components/stiebel_eltron/conftest.py new file mode 100644 index 00000000000..7ee2612efa7 --- /dev/null +++ b/tests/components/stiebel_eltron/conftest.py @@ -0,0 +1,55 @@ +"""Common fixtures for the STIEBEL ELTRON tests.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.stiebel_eltron import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_stiebel_eltron_client() -> Generator[MagicMock]: + """Mock a stiebel eltron client.""" + with ( + patch( + "homeassistant.components.stiebel_eltron.StiebelEltronAPI", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.stiebel_eltron.config_flow.StiebelEltronAPI", + new=mock_client, + ), + ): + client = mock_client.return_value + client.update.return_value = True + yield client + + +@pytest.fixture(autouse=True) +def mock_modbus() -> Generator[MagicMock]: + """Mock a modbus client.""" + with ( + patch( + "homeassistant.components.stiebel_eltron.ModbusTcpClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.stiebel_eltron.config_flow.ModbusTcpClient", + new=mock_client, + ), + ): + yield mock_client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Stiebel Eltron", + data={CONF_HOST: "1.1.1.1", CONF_PORT: 502}, + ) diff --git a/tests/components/stiebel_eltron/test_config_flow.py b/tests/components/stiebel_eltron/test_config_flow.py new file mode 100644 index 00000000000..278ab6eea6f --- /dev/null +++ b/tests/components/stiebel_eltron/test_config_flow.py @@ -0,0 +1,209 @@ +"""Test the STIEBEL ELTRON config flow.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.stiebel_eltron.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +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["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stiebel Eltron" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + + +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_stiebel_eltron_client.update.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_stiebel_eltron_client.update.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_unknown_exception( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_stiebel_eltron_client.update.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_stiebel_eltron_client.update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle already configured.""" + mock_config_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_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +async def test_import(hass: HomeAssistant) -> None: + """Test import step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stiebel Eltron" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + + +async def test_import_cannot_connect( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + mock_stiebel_eltron_client.update.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_unknown_exception( + hass: HomeAssistant, + mock_stiebel_eltron_client: MagicMock, +) -> None: + """Test we handle cannot connect error.""" + mock_stiebel_eltron_client.update.side_effect = Exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_import_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle already configured.""" + mock_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.1", + CONF_PORT: 502, + CONF_NAME: "Stiebel Eltron", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/stiebel_eltron/test_init.py b/tests/components/stiebel_eltron/test_init.py new file mode 100644 index 00000000000..f8413c41461 --- /dev/null +++ b/tests/components/stiebel_eltron/test_init.py @@ -0,0 +1,177 @@ +"""Tests for the STIEBEL ELTRON integration.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.stiebel_eltron.const import CONF_HUB, DEFAULT_HUB, DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +async def test_async_setup_success( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test successful async_setup.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + ], + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue + assert issue.active is True + assert issue.severity == ir.IssueSeverity.WARNING + + +@pytest.mark.usefixtures("mock_stiebel_eltron_client") +async def test_async_setup_already_configured( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_config_entry, +) -> None: + """Test we handle already configured.""" + mock_config_entry.add_to_hass(hass) + + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "1.1.1.1", + CONF_PORT: 502, + } + ], + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue + assert issue.active is True + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_async_setup_with_non_existing_hub( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test async_setup with non-existing modbus hub.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: "non_existing_hub", + }, + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_missing_hub" + ) + assert issue + assert issue.active is True + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.translation_key == "deprecated_yaml_import_issue_missing_hub" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_async_setup_import_failure( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_stiebel_eltron_client: AsyncMock, +) -> None: + """Test async_setup with import failure.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "invalid_host", + CONF_PORT: 502, + } + ], + } + + # Simulate an import failure + mock_stiebel_eltron_client.update.side_effect = Exception("Import failure") + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_unknown" + ) + assert issue + assert issue.active is True + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.translation_key == "deprecated_yaml_import_issue_unknown" + assert issue.severity == ir.IssueSeverity.WARNING + + +@pytest.mark.usefixtures("mock_modbus") +async def test_async_setup_cannot_connect( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_stiebel_eltron_client: AsyncMock, +) -> None: + """Test async_setup with import failure.""" + config = { + DOMAIN: { + CONF_NAME: "Stiebel Eltron", + CONF_HUB: DEFAULT_HUB, + }, + "modbus": [ + { + CONF_NAME: DEFAULT_HUB, + CONF_HOST: "invalid_host", + CONF_PORT: 502, + } + ], + } + + # Simulate a cannot connect error + mock_stiebel_eltron_client.update.return_value = False + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Verify the issue is created + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_cannot_connect" + ) + assert issue + assert issue.active is True + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + assert issue.severity == ir.IssueSeverity.WARNING diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 40582dc4be3..dd7f2a7bbc3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -1,6 +1,7 @@ """Fixtures for Stookwijzer integration tests.""" from collections.abc import Generator +from typing import Required, TypedDict from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -12,6 +13,14 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +class Forecast(TypedDict): + """Typed Stookwijzer forecast dict.""" + + datetime: Required[str] + advice: str | None + final: bool | None + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -80,6 +89,28 @@ def mock_stookwijzer() -> Generator[MagicMock]: client.windspeed_ms = 2.5 client.windspeed_bft = 2 client.advice = "code_yellow" + client.async_get_forecast.return_value = ( + Forecast( + datetime="2025-02-12T17:00:00+01:00", + advice="code_yellow", + final=True, + ), + Forecast( + datetime="2025-02-12T23:00:00+01:00", + advice="code_yellow", + final=True, + ), + Forecast( + datetime="2025-02-13T05:00:00+01:00", + advice="code_orange", + final=False, + ), + Forecast( + datetime="2025-02-13T11:00:00+01:00", + advice="code_orange", + final=False, + ), + ) yield stookwijzer_mock diff --git a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr index e2535d54466..452b5bd0a30 100644 --- a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr +++ b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr @@ -3,6 +3,28 @@ dict({ 'advice': 'code_yellow', 'air_quality_index': 2, + 'forecast': list([ + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T17:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T23:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T05:00:00+01:00', + 'final': False, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T11:00:00+01:00', + 'final': False, + }), + ]), 'windspeed_ms': 2.5, }) # --- diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index 39e4de13fed..296505271c0 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -27,6 +27,8 @@ from homeassistant.components.stream.worker import StreamState from .common import generate_h264_video, stream_teardown +_LOGGER = logging.getLogger(__name__) + TEST_TIMEOUT = 7.0 # Lower than 9s home assistant timeout @@ -44,7 +46,7 @@ class WorkerSync: def resume(self): """Allow the worker thread to finalize the stream.""" - logging.debug("waking blocked worker") + _LOGGER.debug("waking blocked worker") self._event.set() def blocking_discontinuity(self, stream_state: StreamState): @@ -52,7 +54,7 @@ class WorkerSync: # Worker is ending the stream, which clears all output buffers. # Block the worker thread until the test has a chance to verify # the segments under test. - logging.debug("blocking worker") + _LOGGER.debug("blocking worker") if self._event: self._event.wait() diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 443103fdf92..1eb638237af 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -202,7 +202,7 @@ async def test_ll_hls_stream( datetime_re = re.compile(r"#EXT-X-PROGRAM-DATE-TIME:(?P.+)") inf_re = re.compile(r"#EXTINF:(?P[0-9]{1,}.[0-9]{3,}),") # keep track of which tests were done (indexed by re) - tested = {regex: False for regex in (part_re, datetime_re, inf_re)} + tested = dict.fromkeys((part_re, datetime_re, inf_re), False) # keep track of times and durations along playlist for checking consistency part_durations = [] segment_duration = 0 diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 2be972cc6a2..276b4109652 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -56,6 +56,8 @@ from .test_ll_hls import TEST_PART_DURATION from tests.components.camera.common import EMPTY_8_6_JPEG, mock_turbo_jpeg +_LOGGER = logging.getLogger(__name__) + STREAM_SOURCE = "some-stream-source" # Formats here are arbitrary, not exercised by tests AUDIO_STREAM_FORMAT = "mp3" @@ -229,7 +231,7 @@ class FakePyAvBuffer: return def mux(self, packet): - logging.debug("Muxed packet: %s", packet) + _LOGGER.debug("Muxed packet: %s", packet) self.capture_packets.append(packet) def __str__(self) -> str: diff --git a/tests/components/streamlabswater/test_binary_sensor.py b/tests/components/streamlabswater/test_binary_sensor.py index 7beb088d498..e9f899409a2 100644 --- a/tests/components/streamlabswater/test_binary_sensor.py +++ b/tests/components/streamlabswater/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/streamlabswater/test_sensor.py b/tests/components/streamlabswater/test_sensor.py index 6afb71f3fd7..ddae5ba3a9f 100644 --- a/tests/components/streamlabswater/test_sensor.py +++ b/tests/components/streamlabswater/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/suez_water/test_sensor.py b/tests/components/suez_water/test_sensor.py index 950d5d8393d..f9e7ff1f9e6 100644 --- a/tests/components/suez_water/test_sensor.py +++ b/tests/components/suez_water/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.suez_water.const import DATA_REFRESH_INTERVAL from homeassistant.components.suez_water.coordinator import PySuezError diff --git a/tests/components/sun/test_condition.py b/tests/components/sun/test_condition.py new file mode 100644 index 00000000000..52c0d885461 --- /dev/null +++ b/tests/components/sun/test_condition.py @@ -0,0 +1,1235 @@ +"""The tests for sun conditions.""" + +from datetime import datetime + +from freezegun import freeze_time +import pytest + +from homeassistant.components import automation +from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import trace +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def prepare_condition_trace() -> None: + """Clear previous trace.""" + trace.trace_clear() + + +def _find_run_id(traces, trace_type, item_id): + """Find newest run_id for a script or automation.""" + for _trace in reversed(traces): + if _trace["domain"] == trace_type and _trace["item_id"] == item_id: + return _trace["run_id"] + + return None + + +async def assert_automation_condition_trace(hass_ws_client, automation_id, expected): + """Test the result of automation condition.""" + msg_id = 1 + + def next_id(): + nonlocal msg_id + msg_id += 1 + return msg_id + + client = await hass_ws_client() + + # List traces + await client.send_json( + {"id": next_id(), "type": "trace/list", "domain": "automation"} + ) + response = await client.receive_json() + assert response["success"] + run_id = _find_run_id(response["result"], "automation", automation_id) + + # Get trace + await client.send_json( + { + "id": next_id(), + "type": "trace/get", + "domain": "automation", + "item_id": "sun", + "run_id": run_id, + } + ) + response = await client.receive_json() + assert response["success"] + trace = response["result"] + assert len(trace["trace"]["condition/0"]) == 1 + condition_trace = trace["trace"]["condition/0"][0]["result"] + assert condition_trace == expected + + +async def test_if_action_before_sunrise_no_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunrise. + + Before sunrise is true from midnight until sunset, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise + 1s -> 'before sunrise' not true + now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = sunrise -> 'before sunrise' true + now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = local midnight -> 'before sunrise' true + now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = local midnight - 1s -> 'before sunrise' not true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, + ) + + +async def test_if_action_after_sunrise_no_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunrise. + + After sunrise is true from sunrise until midnight, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise - 1s -> 'after sunrise' not true + now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = sunrise + 1s -> 'after sunrise' true + now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = local midnight -> 'after sunrise' not true + now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, + ) + + # now = local midnight - 1s -> 'after sunrise' true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, + ) + + +async def test_if_action_before_sunrise_with_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunrise with offset. + + Before sunrise is true from midnight until sunset, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "before": SUN_EVENT_SUNRISE, + "before_offset": "+1:00:00", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise + 1s + 1h -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 16, 14, 33, 19, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunrise + 1h -> 'before sunrise' with offset +1h true + now = datetime(2015, 9, 16, 14, 33, 18, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = UTC midnight -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = UTC midnight - 1s -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local midnight -> 'before sunrise' with offset +1h true + now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local midnight - 1s -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunset -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunset -1s -> 'before sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, + ) + + +async def test_if_action_before_sunset_with_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunset with offset. + + Before sunset is true from midnight until sunset, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "before": "sunset", + "before_offset": "+1:00:00", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = local midnight -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = sunset + 1s + 1h -> 'before sunset' with offset +1h not true + now = datetime(2015, 9, 17, 2, 53, 46, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = sunset + 1h -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = UTC midnight -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 3 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = UTC midnight - 1s -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 4 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = sunrise -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 5 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = sunrise -1s -> 'before sunset' with offset +1h true + now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 6 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = local midnight-1s -> 'after sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 6 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, + ) + + +async def test_if_action_after_sunrise_with_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunrise with offset. + + After sunrise is true from sunrise until midnight, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "after": SUN_EVENT_SUNRISE, + "after_offset": "+1:00:00", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise - 1s + 1h -> 'after sunrise' with offset +1h not true + now = datetime(2015, 9, 16, 14, 33, 17, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunrise + 1h -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 16, 14, 33, 58, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = UTC noon -> 'after sunrise' with offset +1h not true + now = datetime(2015, 9, 16, 12, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = UTC noon - 1s -> 'after sunrise' with offset +1h not true + now = datetime(2015, 9, 16, 11, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local noon -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 16, 19, 1, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local noon - 1s -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 16, 18, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 3 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunset -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 4 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = sunset + 1s -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 5 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local midnight-1s -> 'after sunrise' with offset +1h true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 6 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, + ) + + # now = local midnight -> 'after sunrise' with offset +1h not true + now = datetime(2015, 9, 17, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 6 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-17T14:33:57.053037+00:00"}, + ) + + +async def test_if_action_after_sunset_with_offset( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunset with offset. + + After sunset is true from sunset until midnight, local time. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "after": "sunset", + "after_offset": "+1:00:00", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunset - 1s + 1h -> 'after sunset' with offset +1h not true + now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = sunset + 1h -> 'after sunset' with offset +1h true + now = datetime(2015, 9, 17, 2, 53, 45, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, + ) + + # now = midnight-1s -> 'after sunset' with offset +1h true + now = datetime(2015, 9, 16, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-09-16T02:55:06.099767+00:00"}, + ) + + # now = midnight -> 'after sunset' with offset +1h not true + now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, + ) + + +async def test_if_action_after_and_before_during( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunrise and before sunset. + + This is true from sunrise until sunset. + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "after": SUN_EVENT_SUNRISE, + "before": SUN_EVENT_SUNSET, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise - 1s -> 'after sunrise' + 'before sunset' not true + now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": False, + "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunset + 1s -> 'after sunrise' + 'before sunset' not true + now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-09-17T01:53:44.723614+00:00"}, + ) + + # now = sunrise + 1s -> 'after sunrise' + 'before sunset' true + now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunset - 1s -> 'after sunrise' + 'before sunset' true + now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = 9AM local -> 'after sunrise' + 'before sunset' true + now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 3 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + +async def test_if_action_before_or_after_during( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunrise or after sunset. + + This is true from midnight until sunrise and from sunset until midnight + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "before": SUN_EVENT_SUNRISE, + "after": SUN_EVENT_SUNSET, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise - 1s -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunset + 1s -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunrise + 1s -> 'before sunrise' | 'after sunset' false + now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": False, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunset - 1s -> 'before sunrise' | 'after sunset' false + now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": False, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = midnight + 1s local -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 16, 7, 0, 1, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 3 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = midnight - 1s local -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 4 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + +async def test_if_action_before_sunrise_no_offset_kotzebue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunrise. + + Local timezone: Alaska time + Location: Kotzebue, which has a very skewed local timezone with sunrise + at 7 AM and sunset at 3AM during summer + After sunrise is true from sunrise until midnight, local time. + """ + await hass.config.async_set_time_zone("America/Anchorage") + hass.config.latitude = 66.5 + hass.config.longitude = 162.4 + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local + # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC + # now = sunrise + 1s -> 'before sunrise' not true + now = datetime(2015, 7, 24, 15, 21, 13, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = sunrise - 1h -> 'before sunrise' true + now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = local midnight -> 'before sunrise' true + now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = local midnight - 1s -> 'before sunrise' not true + now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-07-23T15:12:19.155123+00:00"}, + ) + + +async def test_if_action_after_sunrise_no_offset_kotzebue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunrise. + + Local timezone: Alaska time + Location: Kotzebue, which has a very skewed local timezone with sunrise + at 7 AM and sunset at 3AM during summer + Before sunrise is true from midnight until sunrise, local time. + """ + await hass.config.async_set_time_zone("America/Anchorage") + hass.config.latitude = 66.5 + hass.config.longitude = 162.4 + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local + # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC + # now = sunrise -> 'after sunrise' true + now = datetime(2015, 7, 24, 15, 21, 12, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = sunrise - 1h -> 'after sunrise' not true + now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = local midnight -> 'after sunrise' not true + now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, + ) + + # now = local midnight - 1s -> 'after sunrise' true + now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-07-23T15:12:19.155123+00:00"}, + ) + + +async def test_if_action_before_sunset_no_offset_kotzebue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was before sunrise. + + Local timezone: Alaska time + Location: Kotzebue, which has a very skewed local timezone with sunrise + at 7 AM and sunset at 3AM during summer + Before sunset is true from midnight until sunset, local time. + """ + await hass.config.async_set_time_zone("America/Anchorage") + hass.config.latitude = 66.5 + hass.config.longitude = 162.4 + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "before": SUN_EVENT_SUNSET}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local + # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC + # now = sunset + 1s -> 'before sunset' not true + now = datetime(2015, 7, 25, 11, 13, 34, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 0 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-07-25T11:13:32.501837+00:00"}, + ) + + # now = sunset - 1h-> 'before sunset' true + now = datetime(2015, 7, 25, 10, 13, 33, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-07-25T11:13:32.501837+00:00"}, + ) + + # now = local midnight -> 'before sunrise' true + now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_before": "2015-07-24T11:17:54.446913+00:00"}, + ) + + # now = local midnight - 1s -> 'before sunrise' not true + now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_before": "2015-07-23T11:22:18.467277+00:00"}, + ) + + +async def test_if_action_after_sunset_no_offset_kotzebue( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], +) -> None: + """Test if action was after sunrise. + + Local timezone: Alaska time + Location: Kotzebue, which has a very skewed local timezone with sunrise + at 7 AM and sunset at 3AM during summer + After sunset is true from sunset until midnight, local time. + """ + await hass.config.async_set_time_zone("America/Anchorage") + hass.config.latitude = 66.5 + hass.config.longitude = 162.4 + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "sun", "after": SUN_EVENT_SUNSET}, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local + # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC + # now = sunset -> 'after sunset' true + now = datetime(2015, 7, 25, 11, 13, 33, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-07-25T11:13:32.501837+00:00"}, + ) + + # now = sunset - 1s -> 'after sunset' not true + now = datetime(2015, 7, 25, 11, 13, 32, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-07-25T11:13:32.501837+00:00"}, + ) + + # now = local midnight -> 'after sunset' not true + now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": False, "wanted_time_after": "2015-07-24T11:17:54.446913+00:00"}, + ) + + # now = local midnight - 1s -> 'after sunset' true + now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) + with freeze_time(now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + {"result": True, "wanted_time_after": "2015-07-23T11:22:18.467277+00:00"}, + ) diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index a7aeae25ac7..ec848c61338 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -27,12 +27,10 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) - ) + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) async def test_sunset_trigger( diff --git a/tests/components/sunweg/__init__.py b/tests/components/sunweg/__init__.py index 1453483a3fd..d9dac10eeb6 100644 --- a/tests/components/sunweg/__init__.py +++ b/tests/components/sunweg/__init__.py @@ -1 +1 @@ -"""Tests for the sunweg component.""" +"""Tests for the Sun WEG integration.""" diff --git a/tests/components/sunweg/common.py b/tests/components/sunweg/common.py deleted file mode 100644 index 096113f6609..00000000000 --- a/tests/components/sunweg/common.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Common functions needed to setup tests for Sun WEG.""" - -from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME - -from tests.common import MockConfigEntry - -SUNWEG_USER_INPUT = { - CONF_USERNAME: "username", - CONF_PASSWORD: "password", -} - -SUNWEG_MOCK_ENTRY = MockConfigEntry( - domain=DOMAIN, - unique_id=0, - data={ - CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", - CONF_PLANT_ID: 0, - CONF_NAME: "Name", - }, -) diff --git a/tests/components/sunweg/conftest.py b/tests/components/sunweg/conftest.py deleted file mode 100644 index db94b9cc5c8..00000000000 --- a/tests/components/sunweg/conftest.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Conftest for SunWEG tests.""" - -from datetime import datetime - -import pytest -from sunweg.device import MPPT, Inverter, Phase, String -from sunweg.plant import Plant - - -@pytest.fixture -def string_fixture() -> String: - """Define String fixture.""" - return String("STR1", 450.3, 23.4, 0) - - -@pytest.fixture -def mppt_fixture(string_fixture) -> MPPT: - """Define MPPT fixture.""" - mppt = MPPT("mppt") - mppt.strings.append(string_fixture) - return mppt - - -@pytest.fixture -def phase_fixture() -> Phase: - """Define Phase fixture.""" - return Phase("PhaseA", 120.0, 3.2, 0, 0) - - -@pytest.fixture -def inverter_fixture(phase_fixture, mppt_fixture) -> Inverter: - """Define inverter fixture.""" - inverter = Inverter( - 21255, - "INVERSOR01", - "J63T233018RE074", - 23.2, - 0.0, - 0.0, - "MWh", - 0, - "kWh", - 0.0, - 1, - 0, - "kW", - ) - inverter.phases.append(phase_fixture) - inverter.mppts.append(mppt_fixture) - return inverter - - -@pytest.fixture -def plant_fixture(inverter_fixture) -> Plant: - """Define Plant fixture.""" - plant = Plant( - 123456, - "Plant #123", - 29.5, - 0.5, - 0, - 12.786912, - 24.0, - "kWh", - 332.2, - 0.012296, - datetime(2023, 2, 16, 14, 22, 37), - ) - plant.inverters.append(inverter_fixture) - return plant - - -@pytest.fixture -def plant_fixture_alternative(inverter_fixture) -> Plant: - """Define Plant fixture.""" - plant = Plant( - 123456, - "Plant #123", - 29.5, - 0.5, - 0, - 12.786912, - 24.0, - "kWh", - 332.2, - 0.012296, - None, - ) - plant.inverters.append(inverter_fixture) - return plant diff --git a/tests/components/sunweg/test_config_flow.py b/tests/components/sunweg/test_config_flow.py deleted file mode 100644 index 8103003d7fb..00000000000 --- a/tests/components/sunweg/test_config_flow.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Tests for the Sun WEG server config flow.""" - -from unittest.mock import patch - -from sunweg.api import APIHelper, SunWegApiError - -from homeassistant import config_entries -from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from .common import SUNWEG_MOCK_ENTRY, SUNWEG_USER_INPUT - -from tests.common import MockConfigEntry - - -async def test_show_authenticate_form(hass: HomeAssistant) -> None: - """Test that the setup form is served.""" - 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_incorrect_login(hass: HomeAssistant) -> None: - """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch.object(APIHelper, "authenticate", return_value=False): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_server_unavailable(hass: HomeAssistant) -> None: - """Test when the SunWEG server don't respond.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch.object( - APIHelper, "authenticate", side_effect=SunWegApiError("Internal Server Error") - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "timeout_connect"} - - -async def test_reauth(hass: HomeAssistant, plant_fixture, inverter_fixture) -> None: - """Test reauth flow.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - - entries = hass.config_entries.async_entries() - assert len(entries) == 1 - assert entries[0].data[CONF_USERNAME] == SUNWEG_MOCK_ENTRY.data[CONF_USERNAME] - assert entries[0].data[CONF_PASSWORD] == SUNWEG_MOCK_ENTRY.data[CONF_PASSWORD] - - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - with patch.object(APIHelper, "authenticate", return_value=False): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=SUNWEG_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_auth"} - - with patch.object( - APIHelper, "authenticate", side_effect=SunWegApiError("Internal Server Error") - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=SUNWEG_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "timeout_connect"} - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object(APIHelper, "listPlants", return_value=[plant_fixture]), - patch.object(APIHelper, "plant", return_value=plant_fixture), - patch.object(APIHelper, "inverter", return_value=inverter_fixture), - patch.object(APIHelper, "complete_inverter"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=SUNWEG_USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - - entries = hass.config_entries.async_entries() - - assert len(entries) == 1 - assert entries[0].data[CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] - assert entries[0].data[CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] - - -async def test_no_plants_on_account(hass: HomeAssistant) -> None: - """Test registering an integration with wrong auth then with no plants available.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch.object(APIHelper, "authenticate", return_value=False): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object(APIHelper, "listPlants", return_value=[]), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_plants" - - -async def test_multiple_plant_ids(hass: HomeAssistant, plant_fixture) -> None: - """Test registering an integration and finishing flow with an selected plant_id.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object( - APIHelper, "listPlants", return_value=[plant_fixture, plant_fixture] - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "plant" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PLANT_ID: 123456} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] - assert result["data"][CONF_PLANT_ID] == 123456 - - -async def test_one_plant_on_account(hass: HomeAssistant, plant_fixture) -> None: - """Test registering an integration and finishing flow with current plant_id.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object( - APIHelper, - "listPlants", - return_value=[plant_fixture], - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] - assert result["data"][CONF_PLANT_ID] == 123456 - - -async def test_existing_plant_configured(hass: HomeAssistant, plant_fixture) -> None: - """Test entering an existing plant_id.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=123456) - entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object( - APIHelper, - "listPlants", - return_value=[plant_fixture], - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], SUNWEG_USER_INPUT - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py index 6cbe38a128b..964b48aebcb 100644 --- a/tests/components/sunweg/test_init.py +++ b/tests/components/sunweg/test_init.py @@ -1,209 +1,79 @@ -"""Tests for the Sun WEG init.""" +"""Tests for the Sun WEG integration.""" -import json -from unittest.mock import MagicMock, patch - -from sunweg.api import APIHelper, SunWegApiError - -from homeassistant.components.sunweg import SunWEGData -from homeassistant.components.sunweg.const import DOMAIN, DeviceType -from homeassistant.components.sunweg.sensor.sensor_entity_description import ( - SunWEGSensorEntityDescription, +from homeassistant.components.sunweg import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, ) -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers import issue_registry as ir -from .common import SUNWEG_MOCK_ENTRY +from tests.common import MockConfigEntry -async def test_methods(hass: HomeAssistant, plant_fixture, inverter_fixture) -> None: - """Test methods.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - - with ( - patch.object(APIHelper, "authenticate", return_value=True), - patch.object(APIHelper, "listPlants", return_value=[plant_fixture]), - patch.object(APIHelper, "plant", return_value=plant_fixture), - patch.object(APIHelper, "inverter", return_value=inverter_fixture), - patch.object(APIHelper, "complete_inverter"), - ): - assert await async_setup_component(hass, DOMAIN, mock_entry.data) - await hass.async_block_till_done() - assert await hass.config_entries.async_unload(mock_entry.entry_id) - - -async def test_setup_wrongpass(hass: HomeAssistant) -> None: - """Test setup with wrong pass.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - with patch.object(APIHelper, "authenticate", return_value=False): - assert await async_setup_component(hass, DOMAIN, mock_entry.data) - await hass.async_block_till_done() - - -async def test_setup_error_500(hass: HomeAssistant) -> None: - """Test setup with wrong pass.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - with patch.object( - APIHelper, "authenticate", side_effect=SunWegApiError("Error 500") - ): - assert await async_setup_component(hass, DOMAIN, mock_entry.data) - await hass.async_block_till_done() - - -async def test_sunwegdata_update_exception() -> None: - """Test SunWEGData exception on update.""" - api = MagicMock() - api.plant = MagicMock(side_effect=json.decoder.JSONDecodeError("Message", "Doc", 1)) - data = SunWEGData(api, 0) - data.update() - assert data.data is None - - -async def test_sunwegdata_update_success(plant_fixture) -> None: - """Test SunWEGData success on update.""" - api = MagicMock() - api.plant = MagicMock(return_value=plant_fixture) - api.complete_inverter = MagicMock() - data = SunWEGData(api, 0) - data.update() - assert data.data.id == plant_fixture.id - assert data.data.name == plant_fixture.name - assert data.data.kwh_per_kwp == plant_fixture.kwh_per_kwp - assert data.data.last_update == plant_fixture.last_update - assert data.data.performance_rate == plant_fixture.performance_rate - assert data.data.saving == plant_fixture.saving - assert len(data.data.inverters) == 1 - - -async def test_sunwegdata_update_success_alternative(plant_fixture_alternative) -> None: - """Test SunWEGData success on update.""" - api = MagicMock() - api.plant = MagicMock(return_value=plant_fixture_alternative) - api.complete_inverter = MagicMock() - data = SunWEGData(api, 0) - data.update() - assert data.data.id == plant_fixture_alternative.id - assert data.data.name == plant_fixture_alternative.name - assert data.data.kwh_per_kwp == plant_fixture_alternative.kwh_per_kwp - assert data.data.last_update == plant_fixture_alternative.last_update - assert data.data.performance_rate == plant_fixture_alternative.performance_rate - assert data.data.saving == plant_fixture_alternative.saving - assert len(data.data.inverters) == 1 - - -async def test_sunwegdata_get_api_value_none(plant_fixture) -> None: - """Test SunWEGData none return on get_api_value.""" - api = MagicMock() - data = SunWEGData(api, 123456) - data.data = plant_fixture - assert data.get_api_value("variable", DeviceType.INVERTER, 0, "deep_name") is None - assert data.get_api_value("variable", DeviceType.STRING, 21255, "deep_name") is None - - -async def test_sunwegdata_get_data_drop_threshold() -> None: - """Test SunWEGData get_data with drop threshold.""" - api = MagicMock() - data = SunWEGData(api, 123456) - data.get_api_value = MagicMock() - entity_description = SunWEGSensorEntityDescription( - api_variable_key="variable", key="key", previous_value_drop_threshold=0.1 +async def test_sunweg_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Sun WEG configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, ) - data.get_api_value.return_value = 3.0 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 2.91 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 2.8 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (2.8, None) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED - -async def test_sunwegdata_get_data_never_reset() -> None: - """Test SunWEGData get_data with never reset.""" - api = MagicMock() - data = SunWEGData(api, 123456) - data.get_api_value = MagicMock() - entity_description = SunWEGSensorEntityDescription( - api_variable_key="variable", key="key", never_resets=True + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, ) - data.get_api_value.return_value = 3.0 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 0 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (3.0, None) - data.get_api_value.return_value = 2.8 - assert data.get_data( - api_variable_key=entity_description.api_variable_key, - api_variable_unit=entity_description.api_variable_unit, - deep_name=None, - device_type=DeviceType.TOTAL, - inverter_id=0, - name=entity_description.name, - native_unit_of_measurement=entity_description.native_unit_of_measurement, - never_resets=entity_description.never_resets, - previous_value_drop_threshold=entity_description.previous_value_drop_threshold, - ) == (2.8, None) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) -async def test_reauth_started(hass: HomeAssistant) -> None: - """Test reauth flow started.""" - mock_entry = SUNWEG_MOCK_ENTRY - mock_entry.add_to_hass(hass) - with patch.object(APIHelper, "authenticate", return_value=False): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.SETUP_ERROR - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "reauth_confirm" + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/surepetcare/__init__.py b/tests/components/surepetcare/__init__.py index 9bf84889368..c34e3ecc923 100644 --- a/tests/components/surepetcare/__init__.py +++ b/tests/components/surepetcare/__init__.py @@ -8,7 +8,11 @@ MOCK_HUB = { "product_id": 1, "household_id": HOUSEHOLD_ID, "name": "Hub", - "status": {"online": True, "led_mode": 0, "pairing_mode": 0}, + "status": { + "led_mode": 0, + "pairing_mode": 0, + "online": True, + }, } MOCK_FEEDER = { @@ -22,6 +26,7 @@ MOCK_FEEDER = { "locking": {"mode": 0}, "learn_mode": 0, "signal": {"device_rssi": 60, "hub_rssi": 65}, + "online": True, }, } diff --git a/tests/components/swiss_public_transport/test_sensor.py b/tests/components/swiss_public_transport/test_sensor.py index 6e832728277..4922941002e 100644 --- a/tests/components/swiss_public_transport/test_sensor.py +++ b/tests/components/swiss_public_transport/test_sensor.py @@ -8,7 +8,7 @@ from opendata_transport.exceptions import ( OpendataTransportError, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.swiss_public_transport.const import ( diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index 2da4c52c7f9..a371cdea63b 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -20,7 +20,7 @@ from homeassistant.helpers import entity_registry as er from . import PLATFORMS_TO_TEST, STATE_MAP -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) @@ -160,9 +160,7 @@ async def test_options( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - schema = result["data_schema"].schema - schema_key = next(k for k in schema if k == CONF_INVERT) - assert schema_key.description["suggested_value"] is True + assert get_schema_suggested_value(result["data_schema"].schema, CONF_INVERT) is True result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 4d6794b962f..8ba242823f6 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -47,6 +47,14 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: return entry +def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak | None): + """Patch async ble device from address to return a given value.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + WOHAND_SERVICE_INFO = BluetoothServiceInfoBleak( name="WoHand", manufacturer_data={89: b"\xfd`0U\x92W"}, @@ -294,3 +302,389 @@ REMOTE_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=False, tx_power=-127, ) + + +WOHUB2_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoHub2", + manufacturer_data={ + 2409: b"\xe7\x06\x1dx\x99y\x00\xffg\xe2\xbf]\x84\x04\x9a,\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\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="WoHub2", + manufacturer_data={ + 2409: b"\xe7\x06\x1dx\x99y\x00\xffg\xe2\xbf]\x84\x04\x9a,\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoHub2"), + time=0, + connectable=True, + tx_power=-127, +) + + +WOCURTAIN3_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoCurtain3", + address="AA:BB:CC:DD:EE:FF", + manufacturer_data={2409: b"\xcf;Zwu\x0c\x19\x0b\x00\x11D\x006"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"{\xc06\x00\x11D"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoCurtain3", + manufacturer_data={2409: b"\xcf;Zwu\x0c\x19\x0b\x00\x11D\x006"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"{\xc06\x00\x11D"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoCurtain3"), + time=0, + connectable=True, + tx_power=-127, +) + + +WOBLINDTILT_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoBlindTilt", + address="AA:BB:CC:DD:EE:FF", + manufacturer_data={2409: b"\xfbgA`\x98\xe8\x1d%2\x11\x84"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"x\x00*"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoBlindTilt", + manufacturer_data={2409: b"\xfbgA`\x98\xe8\x1d%2\x11\x84"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"x\x00*"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoBlindTilt"), + time=0, + connectable=True, + tx_power=-127, +) + + +def make_advertisement( + address: str, manufacturer_data: bytes, service_data: bytes +) -> BluetoothServiceInfoBleak: + """Make a dummy advertisement.""" + return BluetoothServiceInfoBleak( + name="Test Device", + address=address, + manufacturer_data={2409: manufacturer_data}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": service_data}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Test Device", + manufacturer_data={2409: manufacturer_data}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": service_data}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device(address, "Test Device"), + time=0, + connectable=True, + tx_power=-127, + ) + + +HUBMINI_MATTER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"%\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="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "HubMini Matter"), + time=0, + connectable=True, + tx_power=-127, +) + + +ROLLER_SHADE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "RollerShade"), + time=0, + connectable=True, + tx_power=-127, +) + + +HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Humidifier", + manufacturer_data={ + 741: b"\xacg\xb2\xcd\xfa\xbe", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"e\x80\x00\xf9\x80Bc\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="Humidifier", + manufacturer_data={ + 741: b"\xacg\xb2\xcd\xfa\xbe", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"e\x80\x00\xf9\x80Bc\x00" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Humidifier"), + time=0, + connectable=True, + tx_power=-127, +) + + +WOSTRIP_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoStrip", + address="AA:BB:CC:DD:EE:FF", + manufacturer_data={ + 2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00' + }, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoStrip", + manufacturer_data={ + 2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00' + }, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoStrip"), + time=0, + connectable=True, + tx_power=-127, +) + + +WOLOCKPRO_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoLockPro", + manufacturer_data={2409: b"\xf7a\x07H\xe6\xe8-\x80\x00d\x00\x08"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"$\x80d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoLockPro", + manufacturer_data={2409: b"\xf7a\x07H\xe6\xe8-\x80\x00d\x00\x08"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"$\x80d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoLockPro"), + time=0, + connectable=True, + tx_power=-127, +) + + +LOCK_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoLock", + manufacturer_data={2409: b"\xca\xbaP\xddv;\x03\x03\x00 "}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"o\x80d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoLock", + manufacturer_data={2409: b"\xca\xbaP\xddv;\x03\x03\x00 "}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"o\x80d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoLock"), + time=0, + connectable=True, + tx_power=-127, +) + + +CIRCULATOR_FAN_SERVICE_INFO = BluetoothServiceInfoBleak( + name="CirculatorFan", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeXY\xa8~LR9", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"~\x00R"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="CirculatorFan", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeXY\xa8~LR9", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"~\x00R"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "CirculatorFan"), + time=0, + connectable=True, + tx_power=-127, +) + + +K20_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="K20 Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x01\xf3\x8f'\x01\x11S\x00\x10d\x0f", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b".\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="K20 Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x01\xf3\x8f'\x01\x11S\x00\x10d\x0f", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b".\x00d"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "K20 Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) + + +K10_PRO_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="K10 Pro Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeP\x8d\x8d\x02 d", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"(\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="K10 Pro Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeP\x8d\x8d\x02 d", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"(\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "K10 Pro Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) + + +K10_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="K10 Vacuum", + manufacturer_data={ + 2409: b"\xca8\x06\xa9_\xf1\x02 d", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"}\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="K10 Vacuum", + manufacturer_data={ + 2409: b"\xca8\x06\xa9_\xf1\x02 d", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"}\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "K10 Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) + + +K10_POR_COMBO_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="K10 Pro Combo Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x01\xf4\x1d\x0b\x01\x01\xb1\x03\x118\x01", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"3\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="K10 Pro Combo Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x01\xf4\x1d\x0b\x01\x01\xb1\x03\x118\x01", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"3\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "K10 Pro Combo Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) + + +S10_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="S10 Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x00\x08|\n\x01\x11\x05\x00\x10M\x02", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"z\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="S10 Vacuum", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x00\x08|\n\x01\x11\x05\x00\x10M\x02", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"z\x00\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "S10 Vacuum"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py index 44f68a1c8ae..45bd069e9bd 100644 --- a/tests/components/switchbot/conftest.py +++ b/tests/components/switchbot/conftest.py @@ -2,7 +2,46 @@ import pytest +from homeassistant.components.switchbot.const import ( + CONF_ENCRYPTION_KEY, + CONF_KEY_ID, + DOMAIN, +) +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_SENSOR_TYPE + +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" + + +@pytest.fixture +def mock_entry_factory(): + """Fixture to create a MockConfigEntry with a customizable sensor type.""" + return lambda sensor_type="curtain": MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: sensor_type, + }, + unique_id="aabbccddeeff", + ) + + +@pytest.fixture +def mock_entry_encrypted_factory(): + """Fixture to create a MockConfigEntry with an encryption key and a customizable sensor type.""" + return lambda sensor_type="lock": MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: sensor_type, + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + unique_id="aabbccddeeff", + ) diff --git a/tests/components/switchbot/snapshots/test_diagnostics.ambr b/tests/components/switchbot/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e9cdfe3152c --- /dev/null +++ b/tests/components/switchbot/snapshots/test_diagnostics.ambr @@ -0,0 +1,82 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'entry': dict({ + 'data': dict({ + 'address': 'aa:bb:cc:dd:ee:ff', + 'encryption_key': '**REDACTED**', + 'key_id': '**REDACTED**', + 'name': 'test-name', + 'sensor_type': 'relay_switch_1pm', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'switchbot', + 'minor_version': 1, + 'options': dict({ + 'retry_count': 3, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'aabbccddeeaa', + 'version': 1, + }), + 'service_info': dict({ + 'address': 'AA:BB:CC:DD:EE:FF', + 'advertisement': list([ + 'W1080000', + dict({ + '2409': dict({ + '__type': "", + 'repr': "b'$X|\\x0866G\\x81\\x00\\x00\\x001\\x00\\x00\\x00\\x00'", + }), + }), + dict({ + '0000fd3d-0000-1000-8000-00805f9b34fb': dict({ + '__type': "", + 'repr': "b'<\\x00\\x00\\x00'", + }), + }), + list([ + 'cba20d00-224d-11e6-9fb8-0002a5d5c51b', + ]), + -127, + -60, + list([ + list([ + ]), + ]), + ]), + 'connectable': True, + 'device': dict({ + '__type': "", + 'repr': 'BLEDevice(AA:BB:CC:DD:EE:FF, W1080000)', + }), + 'manufacturer_data': dict({ + '2409': dict({ + '__type': "", + 'repr': "b'$X|\\x0866G\\x81\\x00\\x00\\x001\\x00\\x00\\x00\\x00'", + }), + }), + 'name': 'W1080000', + 'raw': None, + 'rssi': -60, + 'service_data': dict({ + '0000fd3d-0000-1000-8000-00805f9b34fb': dict({ + '__type': "", + 'repr': "b'<\\x00\\x00\\x00'", + }), + }), + 'service_uuids': list([ + 'cba20d00-224d-11e6-9fb8-0002a5d5c51b', + ]), + 'source': 'local', + 'tx_power': -127, + }), + }) +# --- diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py new file mode 100644 index 00000000000..9430a45d106 --- /dev/null +++ b/tests/components/switchbot/test_cover.py @@ -0,0 +1,650 @@ +"""Test the switchbot covers.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest +from switchbot.devices.device import SwitchbotOperationError + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError + +from . import ( + ROLLER_SHADE_SERVICE_INFO, + WOBLINDTILT_SERVICE_INFO, + WOCURTAIN3_SERVICE_INFO, + make_advertisement, +) + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_curtain3_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the Curtain3.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="curtain") + + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 50}, + ) + ], + ) + + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + +async def test_curtain3_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test Curtain3 controlling.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="curtain") + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b"{\xc06\x00\x11D" + + # Test open + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b\x05\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 95 + + # Test close + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b\x58\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 12 + + # Test stop + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b\x3c\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 40 + + # Test set position + manufacturer_data = b"\xcf;Zwu\x0c\x19\x0b(\x11D\x006" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + +async def test_blindtilt_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the blindtilt.""" + inject_bluetooth_service_info(hass, WOBLINDTILT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="blind_tilt") + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 40}, + ) + ], + ) + + entry.add_to_hass(hass) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.update", + new=AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 40 + + +async def test_blindtilt_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test blindtilt controlling.""" + inject_bluetooth_service_info(hass, WOBLINDTILT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="blind_tilt") + entry.add_to_hass(hass) + info = { + "motionDirection": { + "opening": False, + "closing": False, + "up": False, + "down": False, + }, + } + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + new=AsyncMock(return_value=info), + ), + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b"x\x00*" + + # Test open + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%F\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 70 + + # Test close + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%\x0f\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 15 + + # Test stop + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%\n\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 + + # Test set position + manufacturer_data = b"\xfbgA`\x98\xe8\x1d%2\x12\x85" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_TILT_POSITION: 50}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + +async def test_roller_shade_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the RollerShade.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 60}, + ) + ], + ) + + entry.add_to_hass(hass) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.update", + new=AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + +async def test_roller_shade_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test Roller Shade controlling.""" + inject_bluetooth_service_info(hass, ROLLER_SHADE_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + entry.add_to_hass(hass) + info = {"battery": 39} + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ), + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b",\x00'\x9f\x11\x04" + + # Test open + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\xa0\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 68 + + # Test close + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5a\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 10 + + # Test stop + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5f\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 5 + + # Test set position + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x32\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +@pytest.mark.parametrize( + ( + "sensor_type", + "service_info", + "class_name", + "service", + "service_data", + "mock_method", + ), + [ + ( + "curtain", + WOCURTAIN3_SERVICE_INFO, + "SwitchbotCurtain", + SERVICE_CLOSE_COVER, + {}, + "close", + ), + ( + "curtain", + WOCURTAIN3_SERVICE_INFO, + "SwitchbotCurtain", + SERVICE_OPEN_COVER, + {}, + "open", + ), + ( + "curtain", + WOCURTAIN3_SERVICE_INFO, + "SwitchbotCurtain", + SERVICE_STOP_COVER, + {}, + "stop", + ), + ( + "curtain", + WOCURTAIN3_SERVICE_INFO, + "SwitchbotCurtain", + SERVICE_SET_COVER_POSITION, + {ATTR_POSITION: 50}, + "set_position", + ), + ( + "roller_shade", + ROLLER_SHADE_SERVICE_INFO, + "SwitchbotRollerShade", + SERVICE_SET_COVER_POSITION, + {ATTR_POSITION: 50}, + "set_position", + ), + ( + "roller_shade", + ROLLER_SHADE_SERVICE_INFO, + "SwitchbotRollerShade", + SERVICE_OPEN_COVER, + {}, + "open", + ), + ( + "roller_shade", + ROLLER_SHADE_SERVICE_INFO, + "SwitchbotRollerShade", + SERVICE_CLOSE_COVER, + {}, + "close", + ), + ( + "roller_shade", + ROLLER_SHADE_SERVICE_INFO, + "SwitchbotRollerShade", + SERVICE_STOP_COVER, + {}, + "stop", + ), + ( + "blind_tilt", + WOBLINDTILT_SERVICE_INFO, + "SwitchbotBlindTilt", + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_TILT_POSITION: 50}, + "set_position", + ), + ( + "blind_tilt", + WOBLINDTILT_SERVICE_INFO, + "SwitchbotBlindTilt", + SERVICE_OPEN_COVER_TILT, + {}, + "open", + ), + ( + "blind_tilt", + WOBLINDTILT_SERVICE_INFO, + "SwitchbotBlindTilt", + SERVICE_CLOSE_COVER_TILT, + {}, + "close", + ), + ( + "blind_tilt", + WOBLINDTILT_SERVICE_INFO, + "SwitchbotBlindTilt", + SERVICE_STOP_COVER_TILT, + {}, + "stop", + ), + ], +) +async def test_exception_handling_cover_service( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + class_name: str, + service: str, + service_data: dict, + mock_method: str, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for cover service with exception.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + entity_id = "cover.test_name" + + with patch.multiple( + f"homeassistant.components.switchbot.cover.switchbot.{class_name}", + update=AsyncMock(return_value=None), + **{mock_method: AsyncMock(side_effect=exception)}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + COVER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_diagnostics.py b/tests/components/switchbot/test_diagnostics.py new file mode 100644 index 00000000000..7b7617498fd --- /dev/null +++ b/tests/components/switchbot/test_diagnostics.py @@ -0,0 +1,63 @@ +"""Tests for the diagnostics data provided by the Switchbot integration.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.switchbot.const import ( + CONF_ENCRYPTION_KEY, + CONF_KEY_ID, + CONF_RETRY_COUNT, + DEFAULT_RETRY_COUNT, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_SENSOR_TYPE +from homeassistant.core import HomeAssistant + +from . import WORELAY_SWITCH_1PM_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + + inject_bluetooth_service_info(hass, WORELAY_SWITCH_1PM_SERVICE_INFO) + + with patch( + "homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch.update", + return_value=None, + ): + mock_config_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", + options={CONF_RETRY_COUNT: DEFAULT_RETRY_COUNT}, + ) + 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 + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert result == snapshot( + exclude=props("created_at", "modified_at", "entry_id", "time") + ) diff --git a/tests/components/switchbot/test_fan.py b/tests/components/switchbot/test_fan.py new file mode 100644 index 00000000000..815d3aceda3 --- /dev/null +++ b/tests/components/switchbot/test_fan.py @@ -0,0 +1,91 @@ +"""Test the switchbot fan.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.fan import ( + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_OSCILLATE, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant + +from . import CIRCULATOR_FAN_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ( + "service", + "service_data", + "mock_method", + ), + [ + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: "baby"}, + "set_preset_mode", + ), + ( + SERVICE_SET_PERCENTAGE, + {ATTR_PERCENTAGE: 27}, + "set_percentage", + ), + ( + SERVICE_OSCILLATE, + {ATTR_OSCILLATING: True}, + "set_oscillation", + ), + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + ), + ( + SERVICE_TURN_ON, + {}, + "turn_on", + ), + ], +) +async def test_circulator_fan_controlling( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, +) -> None: + """Test controlling the circulator fan with different services.""" + inject_bluetooth_service_info(hass, CIRCULATOR_FAN_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="circulator_fan") + entity_id = "fan.test_name" + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + mcoked_none_instance = AsyncMock(return_value=None) + with patch.multiple( + "homeassistant.components.switchbot.fan.switchbot.SwitchbotFan", + get_basic_info=mcoked_none_instance, + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + FAN_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() diff --git a/tests/components/switchbot/test_humidifier.py b/tests/components/switchbot/test_humidifier.py new file mode 100644 index 00000000000..fa9efac0bfd --- /dev/null +++ b/tests/components/switchbot/test_humidifier.py @@ -0,0 +1,175 @@ +"""Test the switchbot humidifiers.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest +from switchbot.devices.device import SwitchbotOperationError + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + MODE_AUTO, + MODE_NORMAL, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import HUMIDIFIER_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ( + "service", + "service_data", + "mock_method", + "expected_args", + ), + [ + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + (), + ), + ( + SERVICE_TURN_ON, + {}, + "turn_on", + (), + ), + ( + SERVICE_SET_HUMIDITY, + {ATTR_HUMIDITY: 50}, + "set_humidity_level", + (50,), + ), + ( + SERVICE_SET_MODE, + {ATTR_MODE: MODE_AUTO}, + "set_auto_mode", + (), + ), + ( + SERVICE_SET_MODE, + {ATTR_MODE: MODE_NORMAL}, + "set_manual_mode", + (), + ), + ], +) +async def test_humidifier_services( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: tuple, +) -> None: + """Test all humidifier services with proper parameters.""" + inject_bluetooth_service_info(hass, HUMIDIFIER_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="humidifier") + entry.add_to_hass(hass) + entity_id = "humidifier.test_name" + + with ( + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.set_level", + new=AsyncMock(return_value=True), + ) as mock_set_humidity_level, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.async_set_auto", + new=AsyncMock(return_value=True), + ) as mock_set_auto_mode, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.async_set_manual", + new=AsyncMock(return_value=True), + ) as mock_set_manual_mode, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.turn_off", + new=AsyncMock(return_value=True), + ) as mock_turn_off, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.turn_on", + new=AsyncMock(return_value=True), + ) as mock_turn_on, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_map = { + "turn_off": mock_turn_off, + "turn_on": mock_turn_on, + "set_humidity_level": mock_set_humidity_level, + "set_auto_mode": mock_set_auto_mode, + "set_manual_mode": mock_set_manual_mode, + } + mock_instance = mock_map[mock_method] + mock_instance.assert_awaited_once_with(*expected_args) + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +@pytest.mark.parametrize( + ("service", "service_data", "mock_method"), + [ + (SERVICE_TURN_ON, {}, "turn_on"), + (SERVICE_TURN_OFF, {}, "turn_off"), + (SERVICE_SET_HUMIDITY, {ATTR_HUMIDITY: 60}, "set_level"), + (SERVICE_SET_MODE, {ATTR_MODE: MODE_AUTO}, "async_set_auto"), + (SERVICE_SET_MODE, {ATTR_MODE: MODE_NORMAL}, "async_set_manual"), + ], +) +async def test_exception_handling_humidifier_service( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for humidifier service with exception.""" + inject_bluetooth_service_info(hass, HUMIDIFIER_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="humidifier") + entry.add_to_hass(hass) + entity_id = "humidifier.test_name" + + patch_target = f"homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.{mock_method}" + + with patch(patch_target, new=AsyncMock(side_effect=exception)): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_init.py b/tests/components/switchbot/test_init.py new file mode 100644 index 00000000000..8969557bc0f --- /dev/null +++ b/tests/components/switchbot/test_init.py @@ -0,0 +1,91 @@ +"""Test the switchbot init.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.core import HomeAssistant + +from . import ( + HUBMINI_MATTER_SERVICE_INFO, + LOCK_SERVICE_INFO, + patch_async_ble_device_from_address, +) + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + ValueError("wrong model"), + "Switchbot device initialization failed because of incorrect configuration parameters: wrong model", + ), + ], +) +async def test_exception_handling_for_device_initialization( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + exception: Exception, + error_message: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test exception handling for lock initialization.""" + inject_bluetooth_service_info(hass, LOCK_SERVICE_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="lock") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.lock.switchbot.SwitchbotLock.__init__", + side_effect=exception, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert error_message in caplog.text + + +async def test_setup_entry_without_ble_device( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup entry without ble device.""" + + entry = mock_entry_factory("hygrometer_co2") + entry.add_to_hass(hass) + + with patch_async_ble_device_from_address(None): + await hass.config_entries.async_setup(entry.entry_id) + + assert ( + "Could not find Switchbot hygrometer_co2 with address aa:bb:cc:dd:ee:ff" + in caplog.text + ) + + +async def test_coordinator_wait_ready_timeout( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the coordinator async_wait_ready timeout by calling it directly.""" + + inject_bluetooth_service_info(hass, HUBMINI_MATTER_SERVICE_INFO) + + entry = mock_entry_factory("hubmini_matter") + entry.add_to_hass(hass) + + timeout_mock = AsyncMock() + timeout_mock.__aenter__.side_effect = TimeoutError + timeout_mock.__aexit__.return_value = None + + with patch( + "homeassistant.components.switchbot.coordinator.asyncio.timeout", + return_value=timeout_mock, + ): + await hass.config_entries.async_setup(entry.entry_id) + + assert "aa:bb:cc:dd:ee:ff is not advertising state" in caplog.text diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py new file mode 100644 index 00000000000..957d56411da --- /dev/null +++ b/tests/components/switchbot/test_light.py @@ -0,0 +1,203 @@ +"""Test the switchbot lights.""" + +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from switchbot import ColorMode as switchbotColorMode +from switchbot.devices.device import SwitchbotOperationError + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import WOSTRIP_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ( + "service", + "service_data", + "mock_method", + "expected_args", + "color_modes", + "color_mode", + ), + [ + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + (), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {}, + "turn_on", + (), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128}, + "set_brightness", + (round(128 / 255 * 100),), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_RGB_COLOR: (255, 0, 0)}, + "set_rgb", + (round(255 / 255 * 100), 255, 0, 0), + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_COLOR_TEMP_KELVIN: 4000}, + "set_color_temp", + (100, 4000), + {switchbotColorMode.COLOR_TEMP}, + switchbotColorMode.COLOR_TEMP, + ), + ], +) +async def test_light_strip_services( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, + color_modes: set | None, + color_mode: switchbotColorMode | None, +) -> None: + """Test all SwitchBot light strip services with proper parameters.""" + inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="light_strip") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip", + color_modes=color_modes, + color_mode=color_mode, + update=AsyncMock(return_value=None), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once_with(*expected_args) + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +@pytest.mark.parametrize( + ("service", "service_data", "mock_method", "color_modes", "color_mode"), + [ + ( + SERVICE_TURN_ON, + {}, + "turn_on", + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128}, + "set_brightness", + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_RGB_COLOR: (255, 0, 0)}, + "set_rgb", + {switchbotColorMode.RGB}, + switchbotColorMode.RGB, + ), + ( + SERVICE_TURN_ON, + {ATTR_COLOR_TEMP_KELVIN: 4000}, + "set_color_temp", + {switchbotColorMode.COLOR_TEMP}, + switchbotColorMode.COLOR_TEMP, + ), + ], +) +async def test_exception_handling_light_service( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + color_modes: set | None, + color_mode: switchbotColorMode | None, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for light service with exception.""" + inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="light_strip") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip", + color_modes=color_modes, + color_mode=color_mode, + update=AsyncMock(return_value=None), + **{mock_method: AsyncMock(side_effect=exception)}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_lock.py b/tests/components/switchbot/test_lock.py new file mode 100644 index 00000000000..ea8939c8e41 --- /dev/null +++ b/tests/components/switchbot/test_lock.py @@ -0,0 +1,156 @@ +"""Test the switchbot locks.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from switchbot.devices.device import SwitchbotOperationError + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import LOCK_SERVICE_INFO, WOLOCKPRO_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [("lock_pro", WOLOCKPRO_SERVICE_INFO), ("lock", LOCK_SERVICE_INFO)], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [(SERVICE_UNLOCK, "unlock"), (SERVICE_LOCK, "lock")], +) +async def test_lock_services( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service: str, + mock_method: str, + service_info: BluetoothServiceInfoBleak, +) -> None: + """Test lock and unlock services on lock and lockpro devices.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.switchbot.lock.switchbot.SwitchbotLock.{mock_method}", + ) as mocked_instance: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "lock.test_name" + + await hass.services.async_call( + LOCK_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [("lock_pro", WOLOCKPRO_SERVICE_INFO), ("lock", LOCK_SERVICE_INFO)], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [(SERVICE_UNLOCK, "unlock_without_unlatch"), (SERVICE_OPEN, "unlock")], +) +async def test_lock_services_with_night_latch_enabled( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service: str, + mock_method: str, + service_info: BluetoothServiceInfoBleak, +) -> None: + """Test lock service when night latch enabled.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.lock.switchbot.SwitchbotLock", + is_night_latch_enabled=MagicMock(return_value=True), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "lock.test_name" + + await hass.services.async_call( + LOCK_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_LOCK, "lock"), + (SERVICE_OPEN, "unlock"), + (SERVICE_UNLOCK, "unlock_without_unlatch"), + ], +) +async def test_exception_handling_lock_service( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service: str, + mock_method: str, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for lock service with exception.""" + inject_bluetooth_service_info(hass, LOCK_SERVICE_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="lock") + entry.add_to_hass(hass) + entity_id = "lock.test_name" + + with patch.multiple( + "homeassistant.components.switchbot.lock.switchbot.SwitchbotLock", + is_night_latch_enabled=MagicMock(return_value=True), + **{mock_method: AsyncMock(side_effect=exception)}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + LOCK_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 6a7111a054e..8b1e6c83f21 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -22,9 +22,12 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( + CIRCULATOR_FAN_SERVICE_INFO, + HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, + WOHUB2_SERVICE_INFO, WOMETERTHPC_SERVICE_INFO, WORELAY_SWITCH_1PM_SERVICE_INFO, ) @@ -234,3 +237,151 @@ async def test_remote(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_hub2_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for WoHub2.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, WOHUB2_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "hub2", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 5 + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "26.4" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "44" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + light_level_sensor = hass.states.get("sensor.test_name_light_level") + light_level_sensor_attrs = light_level_sensor.attributes + assert light_level_sensor.state == "4" + assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Light level" + assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "Level" + + light_level_sensor = hass.states.get("sensor.test_name_illuminance") + light_level_sensor_attrs = light_level_sensor.attributes + assert light_level_sensor.state == "30" + assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Illuminance" + assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "lx" + + 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" + + 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_hubmini_matter_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for HubMini Matter.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, HUBMINI_MATTER_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "hubmini_matter", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 3 + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "24.1" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "53" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_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" + + 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_fan_sensors(hass: HomeAssistant) -> None: + """Test setting up creates the sensors.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, CIRCULATOR_FAN_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_SENSOR_TYPE: "circulator_fan", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.fan.switchbot.SwitchbotFan.update", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + + battery_sensor = hass.states.get("sensor.test_name_battery") + battery_sensor_attrs = battery_sensor.attributes + assert battery_sensor.state == "82" + 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" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/switchbot/test_switch.py b/tests/components/switchbot/test_switch.py new file mode 100644 index 00000000000..be28b2a02a8 --- /dev/null +++ b/tests/components/switchbot/test_switch.py @@ -0,0 +1,105 @@ +"""Test the switchbot switches.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest +from switchbot.devices.device import SwitchbotOperationError + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError + +from . import WOHAND_SERVICE_INFO + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_switchbot_switch_with_restore_state( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], +) -> None: + """Test that Switchbot Switch restores state correctly after reboot.""" + inject_bluetooth_service_info(hass, WOHAND_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="bot") + entity_id = "switch.test_name" + + mock_restore_cache( + hass, + [ + State( + entity_id, + STATE_ON, + {"last_run_success": True}, + ) + ], + ) + + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.switch.switchbot.Switchbot.switch_mode", + return_value=False, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes["last_run_success"] is True + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + SwitchbotOperationError("Operation failed"), + "An error occurred while performing the action: Operation failed", + ), + ], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_TURN_ON, "turn_on"), + (SERVICE_TURN_OFF, "turn_off"), + ], +) +async def test_exception_handling_switch( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + mock_method: str, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling for switch service with exception.""" + inject_bluetooth_service_info(hass, WOHAND_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="bot") + entry.add_to_hass(hass) + entity_id = "switch.test_name" + + patch_target = ( + f"homeassistant.components.switchbot.switch.switchbot.Switchbot.{mock_method}" + ) + + with patch(patch_target, new=AsyncMock(side_effect=exception)): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_vacuum.py b/tests/components/switchbot/test_vacuum.py new file mode 100644 index 00000000000..7822bda15db --- /dev/null +++ b/tests/components/switchbot/test_vacuum.py @@ -0,0 +1,77 @@ +"""Tests for switchbot vacuum.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, + SERVICE_START, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import ( + K10_POR_COMBO_VACUUM_SERVICE_INFO, + K10_PRO_VACUUM_SERVICE_INFO, + K10_VACUUM_SERVICE_INFO, + K20_VACUUM_SERVICE_INFO, + S10_VACUUM_SERVICE_INFO, +) + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [ + ("k20_vacuum", K20_VACUUM_SERVICE_INFO), + ("s10_vacuum", S10_VACUUM_SERVICE_INFO), + ("k10_pro_combo_vacumm", K10_POR_COMBO_VACUUM_SERVICE_INFO), + ("k10_vacuum", K10_VACUUM_SERVICE_INFO), + ("k10_pro_vacuum", K10_PRO_VACUUM_SERVICE_INFO), + ], +) +@pytest.mark.parametrize( + ("service", "mock_method"), + [(SERVICE_START, "clean_up"), (SERVICE_RETURN_TO_BASE, "return_to_dock")], +) +async def test_vacuum_controlling( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service: str, + mock_method: str, + service_info: BluetoothServiceInfoBleak, +) -> None: + """Test switchbot vacuum controlling.""" + + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_factory(sensor_type) + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.vacuum.switchbot.SwitchbotVacuum", + update=MagicMock(return_value=None), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "vacuum.test_name" + + await hass.services.async_call( + VACUUM_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py index 0779e54ee03..8c74709fdf5 100644 --- a/tests/components/switchbot_cloud/test_button.py +++ b/tests/components/switchbot_cloud/test_button.py @@ -19,6 +19,7 @@ async def test_pressmode_bot( """Test press.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", @@ -51,6 +52,7 @@ async def test_switchmode_bot_no_button_entity( """Test a switchMode bot isn't added as a button.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index f4837c4e97e..b2d1cff6679 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -33,30 +33,35 @@ async def test_setup_entry_success( """Test successful setup of entry.""" mock_list_devices.return_value = [ Remote( + version="V1.0", deviceId="air-conditonner-id-1", deviceName="air-conditonner-name-1", remoteType="Air Conditioner", hubDeviceId="test-hub-id", ), Device( + version="V1.0", deviceId="plug-id-1", deviceName="plug-name-1", deviceType="Plug", hubDeviceId="test-hub-id", ), Remote( + version="V1.0", deviceId="plug-id-2", deviceName="plug-name-2", remoteType="DIY Plug", hubDeviceId="test-hub-id", ), Remote( + version="V1.0", deviceId="meter-pro-1", deviceName="meter-pro-name-1", deviceType="MeterPro(CO2)", hubDeviceId="test-hub-id", ), Remote( + version="V1.0", deviceId="hub2-1", deviceName="hub2-name-1", deviceType="Hub 2", @@ -104,6 +109,7 @@ async def test_setup_entry_fails_when_refreshing( """Test error handling in get_status in setup of entry.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="test-id", deviceName="test-name", deviceType="Plug", diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index fcb81abfc51..ca41f6eb99f 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -17,6 +17,7 @@ async def test_lock(hass: HomeAssistant, mock_list_devices, mock_get_status) -> """Test locking and unlocking.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="lock-id-1", deviceName="lock-1", deviceType="Smart Lock", diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index 6b0a52800f3..0927e3cf1ea 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import patch from switchbot_api import Device -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switchbot_cloud.const import DOMAIN from homeassistant.const import Platform @@ -26,6 +26,7 @@ async def test_meter( mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="meter-id-1", deviceName="meter-1", deviceType="Meter", @@ -50,6 +51,7 @@ async def test_meter_no_coordinator_data( """Test meter sensors are unknown without coordinator data.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="meter-id-1", deviceName="meter-1", deviceType="Meter", diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index 99e0f50aa53..9bd93342bae 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -25,6 +25,7 @@ async def test_relay_switch( """Test turn on and turn off.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="relay-switch-id-1", deviceName="relay-switch-1", deviceType="Relay Switch 1", @@ -59,6 +60,7 @@ async def test_switchmode_bot( """Test turn on and turn off.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", @@ -93,6 +95,7 @@ async def test_pressmode_bot_no_switch_entity( """Test a pressMode bot isn't added as a switch.""" mock_list_devices.return_value = [ Device( + version="V1.0", deviceId="bot-id-1", deviceName="bot-1", deviceType="Bot", diff --git a/tests/components/switcher_kis/test_button.py b/tests/components/switcher_kis/test_button.py index 6ebd82363e4..bf48647176b 100644 --- a/tests/components/switcher_kis/test_button.py +++ b/tests/components/switcher_kis/test_button.py @@ -2,7 +2,8 @@ from unittest.mock import ANY, patch -from aioswitcher.api import DeviceState, SwitcherBaseResponse, ThermostatSwing +from aioswitcher.api.messages import SwitcherBaseResponse +from aioswitcher.device import DeviceState, ThermostatSwing import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS diff --git a/tests/components/switcher_kis/test_climate.py b/tests/components/switcher_kis/test_climate.py index 72a25d20d04..426c52640c1 100644 --- a/tests/components/switcher_kis/test_climate.py +++ b/tests/components/switcher_kis/test_climate.py @@ -2,7 +2,7 @@ from unittest.mock import ANY, patch -from aioswitcher.api import SwitcherBaseResponse +from aioswitcher.api.messages import SwitcherBaseResponse from aioswitcher.device import ( DeviceState, ThermostatFanLevel, diff --git a/tests/components/switcher_kis/test_cover.py b/tests/components/switcher_kis/test_cover.py index 5829d6345ef..767389a3352 100644 --- a/tests/components/switcher_kis/test_cover.py +++ b/tests/components/switcher_kis/test_cover.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioswitcher.api import SwitcherBaseResponse +from aioswitcher.api.messages import SwitcherBaseResponse from aioswitcher.device import ShutterDirection import pytest diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index a652348463e..afef28dec7b 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.switcher_kis.const import DOMAIN, MAX_UPDATE_INTERVAL_SEC @@ -20,7 +21,10 @@ from tests.typing import WebSocketGenerator async def test_update_fail( - hass: HomeAssistant, mock_bridge, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mock_bridge, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test entities state unavailable when updates fail..""" entry = await init_integration(hass) @@ -32,9 +36,8 @@ async def test_update_fail( assert mock_bridge.is_running is True assert len(entry.runtime_data) == 2 - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1) - ) + freezer.tick(timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() for device in DUMMY_SWITCHER_DEVICES: @@ -84,7 +87,10 @@ async def test_entry_unload(hass: HomeAssistant, mock_bridge) -> None: async def test_remove_device( - hass: HomeAssistant, mock_bridge, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + mock_bridge, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test being able to remove a disconnected device.""" assert await async_setup_component(hass, "config", {}) @@ -98,7 +104,6 @@ async def test_remove_device( assert mock_bridge.is_running is True assert len(entry.runtime_data) == 2 - device_registry = dr.async_get(hass) live_device_id = DUMMY_DEVICE_ID1 dead_device_id = DUMMY_DEVICE_ID4 diff --git a/tests/components/switcher_kis/test_light.py b/tests/components/switcher_kis/test_light.py index 51d0eb6332f..715110fb02b 100644 --- a/tests/components/switcher_kis/test_light.py +++ b/tests/components/switcher_kis/test_light.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioswitcher.api import SwitcherBaseResponse +from aioswitcher.api.messages import SwitcherBaseResponse from aioswitcher.device import DeviceState import pytest @@ -111,6 +111,44 @@ async def test_light( assert state.state == STATE_OFF +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_light_ignore_previous_async_state( + hass: HomeAssistant, mock_bridge, mock_api +) -> None: + """Test light ignores previous async state.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + entity_id = f"{LIGHT_DOMAIN}.{slugify(DEVICE.name)}_light_1" + + # Test initial state - light on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off light + with patch( + "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 + ) + + # Push old state and makge sure it is ignored + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_set_light.assert_called_once_with(DeviceState.OFF, 0) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Verify new state is not ignored + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + @pytest.mark.parametrize( ("device", "entity_id", "light_id", "device_state"), [ @@ -133,7 +171,6 @@ async def test_light_control_fail( mock_bridge, mock_api, monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, device, entity_id: str, light_id: int, diff --git a/tests/components/switcher_kis/test_sensor.py b/tests/components/switcher_kis/test_sensor.py index f99d91bd9a3..1a6c2ccb687 100644 --- a/tests/components/switcher_kis/test_sensor.py +++ b/tests/components/switcher_kis/test_sensor.py @@ -3,7 +3,6 @@ import pytest from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify from . import init_integration @@ -33,7 +32,7 @@ DEVICE_SENSORS_TUPLE = ( ( DUMMY_THERMOSTAT_DEVICE, [ - ("current_temperature", "temperature"), + ("temperature", "temperature"), ], ), ) @@ -55,35 +54,6 @@ async def test_sensor_platform(hass: HomeAssistant, mock_bridge) -> None: assert state.state == str(getattr(device, field)) -async def test_sensor_disabled( - hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_bridge -) -> None: - """Test sensor disabled by default.""" - await init_integration(hass) - assert mock_bridge - - mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) - await hass.async_block_till_done() - - device = DUMMY_WATER_HEATER_DEVICE - unique_id = f"{device.device_id}-{device.mac_address}-auto_off_set" - entity_id = f"sensor.{slugify(device.name)}_auto_shutdown" - entry = entity_registry.async_get(entity_id) - - assert entry - assert entry.unique_id == unique_id - assert entry.disabled is True - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Test enabling entity - updated_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - - assert updated_entry != entry - assert updated_entry.disabled is False - - @pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) async def test_sensor_update( hass: HomeAssistant, mock_bridge, monkeypatch: pytest.MonkeyPatch diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index c20149de074..52391f4dd08 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -2,8 +2,9 @@ from unittest.mock import patch -from aioswitcher.api import Command, ShutterChildLock, SwitcherBaseResponse -from aioswitcher.device import DeviceState +from aioswitcher.api import Command +from aioswitcher.api.messages import SwitcherBaseResponse +from aioswitcher.device import DeviceState, ShutterChildLock import pytest from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -86,6 +87,45 @@ async def test_switch( assert state.state == STATE_OFF +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_switch_ignore_previous_async_state( + hass: HomeAssistant, mock_bridge, mock_api +) -> None: + """Test switch ignores previous async state.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + # Test initial state - on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off + with patch( + "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 + ) + + # Push old state and makge sure it is ignored + mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(Command.OFF) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Verify new state is not ignored + mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + @pytest.mark.parametrize("mock_bridge", [[DUMMY_PLUG_DEVICE]], indirect=True) async def test_switch_control_fail( hass: HomeAssistant, @@ -240,6 +280,44 @@ async def test_child_lock_switch( assert state.state == STATE_OFF +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_child_lock_switch_ignore_previous_async_state( + hass: HomeAssistant, mock_bridge, mock_api +) -> None: + """Test child lock switch ignores previous async state.""" + await init_integration(hass) + assert mock_bridge + + entity_id = f"{SWITCH_DOMAIN}.{slugify(DEVICE.name)}_child_lock" + + # Test initial state - on + 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 + ) + + # Push old state and makge sure it is ignored + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.OFF, 0) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Verify new state is not ignored + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + @pytest.mark.parametrize( ( "device", diff --git a/tests/components/syncthru/__init__.py b/tests/components/syncthru/__init__.py index d113c11fc19..c9105c6f2b5 100644 --- a/tests/components/syncthru/__init__.py +++ b/tests/components/syncthru/__init__.py @@ -1 +1,13 @@ """Tests for the syncthru 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/syncthru/conftest.py b/tests/components/syncthru/conftest.py new file mode 100644 index 00000000000..61b91d815a2 --- /dev/null +++ b/tests/components/syncthru/conftest.py @@ -0,0 +1,80 @@ +"""Conftest for the SyncThru integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pysyncthru import SyncthruState +import pytest + +from homeassistant.components.syncthru.const import DOMAIN +from homeassistant.const import CONF_NAME, CONF_URL + +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.syncthru.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_syncthru() -> Generator[AsyncMock]: + """Mock the SyncThru class.""" + with ( + patch( + "homeassistant.components.syncthru.coordinator.SyncThru", + autospec=True, + ) as mock_syncthru, + patch( + "homeassistant.components.syncthru.config_flow.SyncThru", new=mock_syncthru + ), + ): + client = mock_syncthru.return_value + client.model.return_value = "C430W" + client.is_unknown_state.return_value = False + client.url = "http://192.168.1.2" + client.model.return_value = "C430W" + client.hostname.return_value = "SEC84251907C415" + client.serial_number.return_value = "08HRB8GJ3F019DD" + client.device_status.return_value = SyncthruState(3) + client.device_status_details.return_value = "" + client.is_online.return_value = True + client.toner_status.return_value = { + "black": {"opt": 1, "remaining": 8, "cnt": 1176, "newError": "C1-5110"}, + "cyan": {"opt": 1, "remaining": 98, "cnt": 25, "newError": ""}, + "magenta": {"opt": 1, "remaining": 98, "cnt": 25, "newError": ""}, + "yellow": {"opt": 1, "remaining": 97, "cnt": 27, "newError": ""}, + } + client.drum_status.return_value = {} + client.input_tray_status.return_value = { + "tray_1": { + "opt": 1, + "paper_size1": 4, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 150, + "newError": "", + } + } + client.output_tray_status.return_value = { + 1: {"name": 1, "capacity": 50, "status": ""} + } + client.raw.return_value = load_json_object_fixture("state.json", DOMAIN) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="C430W", + data={CONF_URL: "http://192.168.1.2/", CONF_NAME: "My Printer"}, + ) diff --git a/tests/components/syncthru/fixtures/state.json b/tests/components/syncthru/fixtures/state.json new file mode 100644 index 00000000000..2e4a6202700 --- /dev/null +++ b/tests/components/syncthru/fixtures/state.json @@ -0,0 +1,182 @@ +{ + "status": { + "hrDeviceStatus": 3, + "status1": "", + "status2": "", + "status3": "", + "status4": "" + }, + "identity": { + "model_name": "C430W", + "device_name": "Samsung C430W", + "host_name": "SEC84251907C415", + "location": "Living room", + "serial_num": "08HRB8GJ3F019DD", + "ip_addr": "192.168.0.251", + "ipv6_link_addr": "", + "mac_addr": "84:25:19:07:C4:15", + "admin_email": "", + "admin_name": "", + "admin_phone": "", + "customer_support": "" + }, + "toner_black": { + "opt": 1, + "remaining": 8, + "cnt": 1176, + "newError": "C1-5110" + }, + "toner_cyan": { + "opt": 1, + "remaining": 98, + "cnt": 25, + "newError": "" + }, + "toner_magenta": { + "opt": 1, + "remaining": 98, + "cnt": 25, + "newError": "" + }, + "toner_yellow": { + "opt": 1, + "remaining": 97, + "cnt": 27, + "newError": "" + }, + "drum_black": { + "opt": 0, + "remaining": 44, + "newError": "" + }, + "drum_cyan": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_magenta": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_yellow": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_color": { + "opt": 1, + "remaining": 44, + "newError": "" + }, + "tray1": { + "opt": 1, + "paper_size1": 4, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 150, + "newError": "" + }, + "tray2": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray3": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray4": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray5": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "0" + }, + "mp": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "manual": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "capa": 0, + "newError": "" + }, + "GXI_INTRAY_MANUALFEEDING_TRAY_SUPPORT": 0, + "GXI_INSTALL_OPTION_MULTIBIN": 0, + "multibin": [0], + "outputTray": [[1, 50, ""]], + "capability": { + "hdd": { + "opt": 2, + "capa": 40 + }, + "ram": { + "opt": 65536, + "capa": 65536 + }, + "scanner": { + "opt": 0, + "capa": 0 + } + }, + "options": { + "hdd": 0, + "wlan": 1 + }, + "GXI_ACTIVE_ALERT_TOTAL": 2, + "GXI_ADMIN_WUI_HAS_DEFAULT_PASS": 0, + "GXI_SUPPORT_COLOR": 1, + "GXI_SYS_LUI_SUPPORT": 0, + "GXI_A3_SUPPORT": 0, + "GXI_TRAY2_MANDATORY_SUPPORT": 0, + "GXI_SWS_ADMIN_USE_AAA": 0, + "GXI_TONER_BLACK_VALID": 1, + "GXI_TONER_CYAN_VALID": 1, + "GXI_TONER_MAGENTA_VALID": 1, + "GXI_TONER_YELLOW_VALID": 1, + "GXI_IMAGING_BLACK_VALID": 1, + "GXI_IMAGING_CYAN_VALID": 1, + "GXI_IMAGING_MAGENTA_VALID": 1, + "GXI_IMAGING_YELLOW_VALID": 1, + "GXI_IMAGING_COLOR_VALID": 1, + "GXI_SUPPORT_PAPER_SETTING": 1, + "GXI_SUPPORT_PAPER_LEVEL": 0, + "GXI_SUPPORT_MULTI_PASS": 1 +} diff --git a/tests/components/syncthru/snapshots/test_binary_sensor.ambr b/tests/components/syncthru/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..4f8809fd984 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.sec84251907c415_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.sec84251907c415_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': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.sec84251907c415_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SEC84251907C415 Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.sec84251907c415_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.sec84251907c415_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.sec84251907c415_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_problem', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.sec84251907c415_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'SEC84251907C415 Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.sec84251907c415_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/syncthru/snapshots/test_diagnostics.ambr b/tests/components/syncthru/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..9b22561a2d6 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_diagnostics.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'GXI_A3_SUPPORT': 0, + 'GXI_ACTIVE_ALERT_TOTAL': 2, + 'GXI_ADMIN_WUI_HAS_DEFAULT_PASS': 0, + 'GXI_IMAGING_BLACK_VALID': 1, + 'GXI_IMAGING_COLOR_VALID': 1, + 'GXI_IMAGING_CYAN_VALID': 1, + 'GXI_IMAGING_MAGENTA_VALID': 1, + 'GXI_IMAGING_YELLOW_VALID': 1, + 'GXI_INSTALL_OPTION_MULTIBIN': 0, + 'GXI_INTRAY_MANUALFEEDING_TRAY_SUPPORT': 0, + 'GXI_SUPPORT_COLOR': 1, + 'GXI_SUPPORT_MULTI_PASS': 1, + 'GXI_SUPPORT_PAPER_LEVEL': 0, + 'GXI_SUPPORT_PAPER_SETTING': 1, + 'GXI_SWS_ADMIN_USE_AAA': 0, + 'GXI_SYS_LUI_SUPPORT': 0, + 'GXI_TONER_BLACK_VALID': 1, + 'GXI_TONER_CYAN_VALID': 1, + 'GXI_TONER_MAGENTA_VALID': 1, + 'GXI_TONER_YELLOW_VALID': 1, + 'GXI_TRAY2_MANDATORY_SUPPORT': 0, + 'capability': dict({ + 'hdd': dict({ + 'capa': 40, + 'opt': 2, + }), + 'ram': dict({ + 'capa': 65536, + 'opt': 65536, + }), + 'scanner': dict({ + 'capa': 0, + 'opt': 0, + }), + }), + 'drum_black': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 44, + }), + 'drum_color': dict({ + 'newError': '', + 'opt': 1, + 'remaining': 44, + }), + 'drum_cyan': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'drum_magenta': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'drum_yellow': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'identity': dict({ + 'admin_email': '', + 'admin_name': '', + 'admin_phone': '', + 'customer_support': '', + 'device_name': 'Samsung C430W', + 'host_name': 'SEC84251907C415', + 'ip_addr': '192.168.0.251', + 'ipv6_link_addr': '', + 'location': 'Living room', + 'mac_addr': '84:25:19:07:C4:15', + 'model_name': 'C430W', + 'serial_num': '08HRB8GJ3F019DD', + }), + 'manual': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'mp': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'multibin': list([ + 0, + ]), + 'options': dict({ + 'hdd': 0, + 'wlan': 1, + }), + 'outputTray': list([ + list([ + 1, + 50, + '', + ]), + ]), + 'status': dict({ + 'hrDeviceStatus': 3, + 'status1': '', + 'status2': '', + 'status3': '', + 'status4': '', + }), + 'toner_black': dict({ + 'cnt': 1176, + 'newError': 'C1-5110', + 'opt': 1, + 'remaining': 8, + }), + 'toner_cyan': dict({ + 'cnt': 25, + 'newError': '', + 'opt': 1, + 'remaining': 98, + }), + 'toner_magenta': dict({ + 'cnt': 25, + 'newError': '', + 'opt': 1, + 'remaining': 98, + }), + 'toner_yellow': dict({ + 'cnt': 27, + 'newError': '', + 'opt': 1, + 'remaining': 97, + }), + 'tray1': dict({ + 'capa': 150, + 'newError': '', + 'opt': 1, + 'paper_level': 0, + 'paper_size1': 4, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray2': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray3': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray4': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray5': dict({ + 'capa': 0, + 'newError': '0', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + }) +# --- diff --git a/tests/components/syncthru/snapshots/test_sensor.ambr b/tests/components/syncthru/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..b7edc046879 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_sensor.ambr @@ -0,0 +1,418 @@ +# serializer version: 1 +# name: test_all_entities[sensor.sec84251907c415-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sec84251907c415', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': None, + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sec84251907c415-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'display_text': '', + 'friendly_name': 'SEC84251907C415', + 'icon': 'mdi:printer', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'warning', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_active_alerts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sec84251907c415_active_alerts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Active alerts', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_alerts', + 'unique_id': '08HRB8GJ3F019DD_active_alerts', + 'unit_of_measurement': 'alerts', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_active_alerts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SEC84251907C415 Active alerts', + 'icon': 'mdi:printer', + 'unit_of_measurement': 'alerts', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_active_alerts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_black_toner_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_black_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Black toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_black', + 'unique_id': '08HRB8GJ3F019DD_toner_black', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_black_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 1176, + 'friendly_name': 'SEC84251907C415 Black toner level', + 'icon': 'mdi:printer', + 'newError': 'C1-5110', + 'opt': 1, + 'remaining': 8, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_black_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_cyan_toner_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_cyan_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Cyan toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_cyan', + 'unique_id': '08HRB8GJ3F019DD_toner_cyan', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_cyan_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 25, + 'friendly_name': 'SEC84251907C415 Cyan toner level', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 98, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_cyan_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_input_tray_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_input_tray_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Input tray 1', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tray', + 'unique_id': '08HRB8GJ3F019DD_tray_tray_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_input_tray_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'capa': 150, + 'friendly_name': 'SEC84251907C415 Input tray 1', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'paper_level': 0, + 'paper_size1': 4, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_input_tray_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ready', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_magenta_toner_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_magenta_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Magenta toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_magenta', + 'unique_id': '08HRB8GJ3F019DD_toner_magenta', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_magenta_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 25, + 'friendly_name': 'SEC84251907C415 Magenta toner level', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 98, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_magenta_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_output_tray_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_output_tray_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Output tray 1', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_tray', + 'unique_id': '08HRB8GJ3F019DD_output_tray_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_output_tray_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'capacity': 50, + 'friendly_name': 'SEC84251907C415 Output tray 1', + 'icon': 'mdi:printer', + 'name': 1, + 'status': '', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_output_tray_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ready', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_yellow_toner_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sec84251907c415_yellow_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Yellow toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_yellow', + 'unique_id': '08HRB8GJ3F019DD_toner_yellow', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_yellow_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 27, + 'friendly_name': 'SEC84251907C415 Yellow toner level', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 97, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_yellow_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97', + }) +# --- diff --git a/tests/components/syncthru/test_binary_sensor.py b/tests/components/syncthru/test_binary_sensor.py new file mode 100644 index 00000000000..7067f553807 --- /dev/null +++ b/tests/components/syncthru/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the Syncthru binary sensor platform.""" + +from unittest.mock import AsyncMock, 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 setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_syncthru: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.syncthru.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/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index 727b95563cc..e535ba50470 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -1,13 +1,12 @@ """Tests for syncthru config flow.""" -import re -from unittest.mock import patch +from unittest.mock import AsyncMock from pysyncthru import SyncThruAPINotSupported from homeassistant import config_entries -from homeassistant.components.syncthru.config_flow import SyncThru from homeassistant.components.syncthru.const import DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -21,7 +20,6 @@ from homeassistant.helpers.service_info.ssdp import ( ) from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker FIXTURE_USER_INPUT = { CONF_URL: "http://192.168.1.2/", @@ -29,36 +27,29 @@ FIXTURE_USER_INPUT = { } -def mock_connection(aioclient_mock): - """Mock syncthru connection.""" - aioclient_mock.get( - re.compile("."), - text=""" -{ -\tstatus: { -\thrDeviceStatus: 2, -\tstatus1: " Sleeping... " -\t}, -\tidentity: { -\tserial_num: "000000000000000", -\t} -} - """, - ) - - -async def test_show_setup_form(hass: HomeAssistant) -> None: - """Test that the setup form is served.""" +async def test_full_flow( + hass: HomeAssistant, mock_syncthru: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + 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=FIXTURE_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == FIXTURE_USER_INPUT + assert result["result"].unique_id is None + async def test_already_configured_by_url( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, mock_syncthru: AsyncMock ) -> None: """Test we match and update already configured devices by URL.""" @@ -69,7 +60,6 @@ async def test_already_configured_by_url( title="Already configured", unique_id=udn, ).add_to_hass(hass) - mock_connection(aioclient_mock) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -83,68 +73,61 @@ async def test_already_configured_by_url( assert result["result"].unique_id == udn -async def test_syncthru_not_supported(hass: HomeAssistant) -> None: +async def test_syncthru_not_supported( + hass: HomeAssistant, mock_syncthru: AsyncMock +) -> None: """Test we show user form on unsupported device.""" - with patch.object(SyncThru, "update", side_effect=SyncThruAPINotSupported): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) + mock_syncthru.update.side_effect = SyncThruAPINotSupported + 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"] == {CONF_URL: "syncthru_not_supported"} -async def test_unknown_state(hass: HomeAssistant) -> None: +async def test_unknown_state(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: """Test we show user form on unsupported device.""" - with ( - patch.object(SyncThru, "update"), - patch.object(SyncThru, "is_unknown_state", return_value=True), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) + mock_syncthru.is_unknown_state.return_value = True + 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 not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FIXTURE_USER_INPUT, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_URL: "unknown_state"} + mock_syncthru.is_unknown_state.return_value = False -async def test_success( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test successful flow provides entry creation data.""" - - mock_connection(aioclient_mock) - - with patch( - "homeassistant.components.syncthru.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) - await hass.async_block_till_done() - + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=FIXTURE_USER_INPUT, + ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL] - assert len(mock_setup_entry.mock_calls) == 1 -async def test_ssdp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_ssdp( + hass: HomeAssistant, mock_syncthru: AsyncMock, mock_setup_entry: AsyncMock +) -> None: """Test SSDP discovery initiates config properly.""" - mock_connection(aioclient_mock) - url = "http://192.168.1.2/" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, + context={"source": SOURCE_SSDP}, data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", @@ -165,3 +148,44 @@ async def test_ssdp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> for k in result["data_schema"].schema: if k == CONF_URL: assert k.default() == url + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: url, CONF_NAME: "Printer"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_URL: url, CONF_NAME: "Printer"} + assert result["result"].unique_id == "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + + +async def test_ssdp_already_configured( + hass: HomeAssistant, mock_syncthru: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test SSDP discovery initiates config properly.""" + + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, unique_id="uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + ) + + url = "http://192.168.1.2/" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.2:5200/Printer.xml", + upnp={ + 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", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/syncthru/test_diagnostics.py b/tests/components/syncthru/test_diagnostics.py new file mode 100644 index 00000000000..3ff4bc8cc08 --- /dev/null +++ b/tests/components/syncthru/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the Syncthru integration.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion 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( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_syncthru: 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 + ) diff --git a/tests/components/syncthru/test_sensor.py b/tests/components/syncthru/test_sensor.py new file mode 100644 index 00000000000..78641739c8f --- /dev/null +++ b/tests/components/syncthru/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the Syncthru sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +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 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_syncthru: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.syncthru.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/synology_dsm/common.py b/tests/components/synology_dsm/common.py index e98b0d21d66..3b069d04ebe 100644 --- a/tests/components/synology_dsm/common.py +++ b/tests/components/synology_dsm/common.py @@ -12,11 +12,21 @@ from .consts import SERIAL def mock_dsm_information( serial: str | None = SERIAL, update_result: bool = True, - awesome_version: str = "7.2", + awesome_version: str = "7.2.2", + model: str = "DS1821+", + version_string: str = "DSM 7.2.2-72806 Update 3", + ram: int = 32768, + temperature: int = 58, + uptime: int = 123456, ) -> Mock: """Mock SynologyDSM information.""" return Mock( serial=serial, update=AsyncMock(return_value=update_result), awesome_version=AwesomeVersion(awesome_version), + model=model, + version_string=version_string, + ram=ram, + temperature=temperature, + uptime=uptime, ) diff --git a/tests/components/synology_dsm/snapshots/test_diagnostics.ambr b/tests/components/synology_dsm/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..cd8b1be42b2 --- /dev/null +++ b/tests/components/synology_dsm/snapshots/test_diagnostics.ambr @@ -0,0 +1,130 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'device_info': dict({ + 'model': 'DS1821+', + 'ram': 32768, + 'temperature': 58, + 'uptime': 123456, + 'version': 'DSM 7.2.2-72806 Update 3', + }), + 'entry': dict({ + 'data': dict({ + 'host': 'nas.meontheinternet.com', + 'mac': '00-11-32-XX-XX-59', + 'password': '**REDACTED**', + 'port': 1234, + 'ssl': True, + 'username': '**REDACTED**', + 'verify_ssl': False, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'synology_dsm', + 'minor_version': 1, + 'options': dict({ + 'backup_path': None, + 'backup_share': None, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'mySerial', + 'version': 1, + }), + 'external_usb': dict({ + 'devices': dict({ + 'usb1': dict({ + 'manufacturer': 'Western Digital Technologies, Inc.', + 'model': 'easystore 264D', + 'name': 'USB Disk 1', + 'size_total': 16000900661248, + 'status': 'normal', + 'type': 'usbDisk', + }), + }), + 'partitions': dict({ + 'usb1p1': dict({ + 'filesystem': 'ntfs', + 'name': 'USB Disk 1 Partition 1', + 'share_name': 'usbshare1', + 'size_total': 16000898564096, + 'size_used': 6231101014016, + }), + }), + }), + 'is_system_loaded': True, + 'network': dict({ + 'interfaces': dict({ + 'ovs_eth0': dict({ + 'ip': list([ + dict({ + 'address': '127.0.0.1', + 'netmask': '255.255.255.0', + }), + ]), + 'type': 'ovseth', + }), + }), + }), + 'storage': dict({ + 'disks': dict({ + }), + 'volumes': dict({ + }), + }), + 'surveillance_station': dict({ + 'camera_diagnostics': dict({ + }), + 'cameras': dict({ + }), + }), + 'upgrade': dict({ + 'available_version': None, + 'reboot_needed': None, + 'service_restarts': None, + 'update_available': False, + }), + 'utilisation': dict({ + 'cpu': dict({ + '15min_load': 461, + '1min_load': 410, + '5min_load': 404, + 'device': 'System', + 'other_load': 5, + 'system_load': 11, + 'user_load': 11, + }), + 'memory': dict({ + 'avail_real': 463628, + 'avail_swap': 0, + 'buffer': 10556600, + 'cached': 5297776, + 'device': 'Memory', + 'memory_size': 33554432, + 'real_usage': 50, + 'si_disk': 0, + 'so_disk': 0, + 'swap_usage': 100, + 'total_real': 32841680, + 'total_swap': 2097084, + }), + 'network': list([ + dict({ + 'device': 'total', + 'rx': 1065612, + 'tx': 36311, + }), + dict({ + 'device': 'eth0', + 'rx': 1065612, + 'tx': 36311, + }), + ]), + }), + }) +# --- diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 24cfe29f52b..db0062b45bf 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -4,9 +4,13 @@ from io import StringIO from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from synology_dsm.api.file_station.models import SynoFileFile, SynoFileSharedFolder -from synology_dsm.exceptions import SynologyDSMAPIErrorException +from synology_dsm.exceptions import ( + SynologyDSMAPIErrorException, + SynologyDSMRequestException, +) from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -279,6 +283,50 @@ async def test_agents_on_unload( } +async def test_agents_on_changed_update_success( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test backup agent on changed update success of coordintaor.""" + client = await hass_ws_client(hass) + + # config entry is loaded + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 2 + + # coordinator update was successful + freezer.tick(910) # 15 min interval + 10s + await hass.async_block_till_done(wait_background_tasks=True) + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 2 + + # coordinator update was un-successful + setup_dsm_with_filestation.update.side_effect = SynologyDSMRequestException( + OSError() + ) + freezer.tick(910) + await hass.async_block_till_done(wait_background_tasks=True) + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 1 + + # coordinator update was successful again + setup_dsm_with_filestation.update.side_effect = None + freezer.tick(910) + await hass.async_block_till_done(wait_background_tasks=True) + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 2 + + async def test_agents_list_backups( hass: HomeAssistant, setup_dsm_with_filestation: MagicMock, @@ -338,7 +386,7 @@ async def test_agents_list_backups_error( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 932cf057d3d..f2aa6df802e 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -12,7 +12,7 @@ from synology_dsm.exceptions import ( SynologyDSMLoginInvalidException, SynologyDSMRequestException, ) -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( diff --git a/tests/components/synology_dsm/test_diagnostics.py b/tests/components/synology_dsm/test_diagnostics.py new file mode 100644 index 00000000000..f2bb35f488d --- /dev/null +++ b/tests/components/synology_dsm/test_diagnostics.py @@ -0,0 +1,199 @@ +"""Test Synology DSM diagnostics.""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice +from synology_dsm.api.dsm.network import NetworkInterface +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.synology_dsm.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant + +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.fixture +def mock_dsm_with_usb(): + """Mock a successful service with USB support.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade = Mock( + update_available=False, + available_version=None, + reboot_needed=None, + service_restarts=None, + update=AsyncMock(return_value=True), + ) + dsm.utilisation = Mock( + cpu={ + "15min_load": 461, + "1min_load": 410, + "5min_load": 404, + "device": "System", + "other_load": 5, + "system_load": 11, + "user_load": 11, + }, + memory={ + "avail_real": 463628, + "avail_swap": 0, + "buffer": 10556600, + "cached": 5297776, + "device": "Memory", + "memory_size": 33554432, + "real_usage": 50, + "si_disk": 0, + "so_disk": 0, + "swap_usage": 100, + "total_real": 32841680, + "total_swap": 2097084, + }, + network=[ + {"device": "total", "rx": 1065612, "tx": 36311}, + {"device": "eth0", "rx": 1065612, "tx": 36311}, + ], + memory_available_swap=Mock(return_value=0), + memory_available_real=Mock(return_value=463628), + memory_total_swap=Mock(return_value=2097084), + memory_total_real=Mock(return_value=32841680), + network_up=Mock(return_value=1065612), + network_down=Mock(return_value=36311), + update=AsyncMock(return_value=True), + ) + dsm.network = Mock( + update=AsyncMock(return_value=True), + macs=MACS, + hostname=HOST, + interfaces=[ + NetworkInterface( + { + "id": "ovs_eth0", + "ip": [{"address": "127.0.0.1", "netmask": "255.255.255.0"}], + "type": "ovseth", + } + ) + ], + ) + dsm.information = mock_dsm_information() + dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None)) + dsm.external_usb = Mock( + update=AsyncMock(return_value=True), + get_device=Mock( + return_value=SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare1", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ) + ), + get_devices={ + "usb1": SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare1", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ) + }, + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +async def setup_dsm_with_usb( + hass: HomeAssistant, + mock_dsm_with_usb: MagicMock, +): + """Mock setup of synology dsm config entry with USB.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_with_usb, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + yield mock_dsm_with_usb + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_dsm_with_usb: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for Synology DSM config entry.""" + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot( + exclude=props("api_details", "created_at", "modified_at", "entry_id") + ) diff --git a/tests/components/synology_dsm/test_sensor.py b/tests/components/synology_dsm/test_sensor.py new file mode 100644 index 00000000000..654cade2462 --- /dev/null +++ b/tests/components/synology_dsm/test_sensor.py @@ -0,0 +1,242 @@ +"""Tests for Synology DSM USB.""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice + +from homeassistant.components.synology_dsm.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_dsm_with_usb(): + """Mock a successful service with USB support.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) + dsm.information = mock_dsm_information() + dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None)) + dsm.external_usb = Mock( + update=AsyncMock(return_value=True), + get_device=Mock( + return_value=SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare1", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ) + ), + get_devices={ + "usb1": SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare1", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ) + }, + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +def mock_dsm_without_usb(): + """Mock a successful service without USB devices.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.network = Mock( + update=AsyncMock(return_value=True), macs=MACS, hostname=HOST + ) + dsm.information = mock_dsm_information() + dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None)) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +async def setup_dsm_with_usb( + hass: HomeAssistant, + mock_dsm_with_usb: MagicMock, +): + """Mock setup of synology dsm config entry with USB.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_with_usb, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + yield mock_dsm_with_usb + + +@pytest.fixture +async def setup_dsm_without_usb( + hass: HomeAssistant, + mock_dsm_without_usb: MagicMock, +): + """Mock setup of synology dsm config entry without USB.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_without_usb, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + yield mock_dsm_without_usb + + +async def test_external_usb( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test Synology DSM USB sensors.""" + # test disabled device size sensor + entity_id = "sensor.nas_meontheinternet_com_usb_disk_1_device_size" + entity_entry = entity_registry.async_get(entity_id) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # test partition size sensor + sensor = hass.states.get( + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size" + ) + assert sensor is not None + assert sensor.state == "14901.998046875" + assert ( + sensor.attributes["friendly_name"] + == "nas.meontheinternet.com (USB Disk 1 Partition 1) Partition size" + ) + assert sensor.attributes["device_class"] == "data_size" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "GiB" + assert sensor.attributes["attribution"] == "Data provided by Synology" + + # test partition used space sensor + sensor = hass.states.get( + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space" + ) + assert sensor is not None + assert sensor.state == "5803.1650390625" + assert ( + sensor.attributes["friendly_name"] + == "nas.meontheinternet.com (USB Disk 1 Partition 1) Partition used space" + ) + assert sensor.attributes["device_class"] == "data_size" + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "GiB" + assert sensor.attributes["attribution"] == "Data provided by Synology" + + # test partition used sensor + sensor = hass.states.get( + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used" + ) + assert sensor is not None + assert sensor.state == "38.9" + assert ( + sensor.attributes["friendly_name"] + == "nas.meontheinternet.com (USB Disk 1 Partition 1) Partition used" + ) + assert sensor.attributes["state_class"] == "measurement" + assert sensor.attributes["unit_of_measurement"] == "%" + assert sensor.attributes["attribution"] == "Data provided by Synology" + + +async def test_no_external_usb( + hass: HomeAssistant, + setup_dsm_without_usb: MagicMock, +) -> None: + """Test Synology DSM without USB.""" + sensor = hass.states.get("sensor.nas_meontheinternet_com_usb_disk_1_device_size") + assert sensor is None diff --git a/tests/components/system_bridge/snapshots/test_media_source.ambr b/tests/components/system_bridge/snapshots/test_media_source.ambr index 53e0e8416e9..954332c932a 100644 --- a/tests/components/system_bridge/snapshots/test_media_source.ambr +++ b/tests/components/system_bridge/snapshots/test_media_source.ambr @@ -3,6 +3,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_type': '', @@ -15,6 +16,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_type': '', @@ -39,6 +41,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_type': '', @@ -51,6 +54,7 @@ dict({ 'can_expand': True, 'can_play': False, + 'can_search': False, 'children_media_class': , 'media_class': , 'media_content_type': '', diff --git a/tests/components/system_bridge/test_init.py b/tests/components/system_bridge/test_init.py index 7632a0c8157..25ccbdeb46c 100644 --- a/tests/components/system_bridge/test_init.py +++ b/tests/components/system_bridge/test_init.py @@ -81,3 +81,53 @@ async def test_migration_minor_future_version(hass: HomeAssistant) -> None: assert config_entry.minor_version == config_entry_minor_version assert config_entry.data == config_entry_data assert config_entry.state is ConfigEntryState.LOADED + + +async def test_setup_timeout(hass: HomeAssistant) -> None: + """Test setup with timeout error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FIXTURE_UUID, + data=FIXTURE_USER_INPUT, + version=SystemBridgeConfigFlow.VERSION, + minor_version=SystemBridgeConfigFlow.MINOR_VERSION, + ) + + with patch( + "systembridgeconnector.version.Version.check_supported", + side_effect=TimeoutError, + ): + config_entry.add_to_hass(hass) + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_get_data_timeout(hass: HomeAssistant) -> None: + """Test coordinator handling timeout during get_data.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FIXTURE_UUID, + data=FIXTURE_USER_INPUT, + version=SystemBridgeConfigFlow.VERSION, + minor_version=SystemBridgeConfigFlow.MINOR_VERSION, + ) + + with ( + patch( + "systembridgeconnector.version.Version.check_supported", + return_value=True, + ), + patch( + "homeassistant.components.system_bridge.coordinator.SystemBridgeDataUpdateCoordinator.async_get_data", + side_effect=TimeoutError, + ), + ): + config_entry.add_to_hass(hass) + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr index 1ee9067a528..8108e4777c8 100644 --- a/tests/components/systemmonitor/snapshots/test_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -114,34 +114,34 @@ # name: test_sensor[System Monitor Last boot - state] '2024-02-24T15:00:00+00:00' # --- -# name: test_sensor[System Monitor Load (15m) - attributes] +# name: test_sensor[System Monitor Load (15 min) - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (15m)', + 'friendly_name': 'System Monitor Load (15 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (15m) - state] +# name: test_sensor[System Monitor Load (15 min) - state] '3' # --- -# name: test_sensor[System Monitor Load (1m) - attributes] +# name: test_sensor[System Monitor Load (1 min) - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (1m)', + 'friendly_name': 'System Monitor Load (1 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (1m) - state] +# name: test_sensor[System Monitor Load (1 min) - state] '1' # --- -# name: test_sensor[System Monitor Load (5m) - attributes] +# name: test_sensor[System Monitor Load (5 min) - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (5m)', + 'friendly_name': 'System Monitor Load (5 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (5m) - state] +# name: test_sensor[System Monitor Load (5 min) - state] '2' # --- # name: test_sensor[System Monitor Memory free - attributes] diff --git a/tests/components/systemmonitor/test_diagnostics.py b/tests/components/systemmonitor/test_diagnostics.py index 26e421e6574..f9bde984399 100644 --- a/tests/components/systemmonitor/test_diagnostics.py +++ b/tests/components/systemmonitor/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/tado/__init__.py b/tests/components/tado/__init__.py index 11d199f01a1..e6b6257e6ea 100644 --- a/tests/components/tado/__init__.py +++ b/tests/components/tado/__init__.py @@ -1 +1 @@ -"""Tests for the tado integration.""" +"""Tests for the Tado integration.""" diff --git a/tests/components/tado/conftest.py b/tests/components/tado/conftest.py new file mode 100644 index 00000000000..1aa62b218a2 --- /dev/null +++ b/tests/components/tado/conftest.py @@ -0,0 +1,50 @@ +"""Fixtures for Tado tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from PyTado.http import DeviceActivationStatus +import pytest + +from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_tado_api() -> Generator[MagicMock]: + """Mock the Tado API.""" + with ( + patch("homeassistant.components.tado.Tado") as mock_tado, + patch("homeassistant.components.tado.config_flow.Tado", new=mock_tado), + ): + client = mock_tado.return_value + client.device_verification_url.return_value = ( + "https://login.tado.com/oauth2/device?user_code=TEST" + ) + client.device_activation_status.return_value = DeviceActivationStatus.COMPLETED + client.get_me.return_value = load_json_object_fixture("me.json", DOMAIN) + client.get_refresh_token.return_value = "refresh" + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock the setup entry.""" + with patch( + "homeassistant.components.tado.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, + data={ + CONF_REFRESH_TOKEN: "refresh", + }, + unique_id="1", + version=2, + ) diff --git a/tests/components/tado/fixtures/device_authorize.json b/tests/components/tado/fixtures/device_authorize.json new file mode 100644 index 00000000000..aacd171fafd --- /dev/null +++ b/tests/components/tado/fixtures/device_authorize.json @@ -0,0 +1,8 @@ +{ + "device_code": "ABCD", + "expires_in": 300, + "interval": 5, + "user_code": "TEST", + "verification_uri": "https://login.tado.com/oauth2/device", + "verification_uri_complete": "https://login.tado.com/oauth2/device?user_code=TEST" +} diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..eefb818a88c --- /dev/null +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -0,0 +1,143 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'data': dict({ + 'device': dict({ + 'WR1': dict({ + 'accessPointWiFi': dict({ + 'ssid': 'tado8480', + }), + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'commandTableUploadState': 'FINISHED', + 'connectionState': dict({ + 'timestamp': '2020-03-23T18:30:07.377Z', + 'value': True, + }), + 'currentFwVersion': '59.4', + 'deviceType': 'WR02', + 'serialNo': 'WR1', + 'shortSerialNo': 'WR1', + 'temperatureOffset': dict({ + 'celsius': -1.0, + 'fahrenheit': -1.8, + }), + }), + 'WR4': dict({ + 'accessPointWiFi': dict({ + 'ssid': 'tado8480', + }), + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'commandTableUploadState': 'FINISHED', + 'connectionState': dict({ + 'timestamp': '2020-03-23T18:30:07.377Z', + 'value': True, + }), + 'currentFwVersion': '59.4', + 'deviceType': 'WR02', + 'duties': list([ + 'ZONE_UI', + 'ZONE_DRIVER', + 'ZONE_LEADER', + ]), + 'serialNo': 'WR4', + 'shortSerialNo': 'WR4', + 'temperatureOffset': dict({ + 'celsius': -1.0, + 'fahrenheit': -1.8, + }), + }), + }), + 'geofence': dict({ + 'presence': 'HOME', + 'presenceLocked': False, + }), + 'weather': dict({ + 'outsideTemperature': dict({ + 'celsius': 7.46, + 'fahrenheit': 45.43, + 'precision': dict({ + 'celsius': 0.01, + 'fahrenheit': 0.01, + }), + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'TEMPERATURE', + }), + 'solarIntensity': dict({ + 'percentage': 2.1, + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'PERCENTAGE', + }), + 'weatherState': dict({ + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'WEATHER_STATE', + 'value': 'FOGGY', + }), + }), + 'zone': dict({ + '1': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=1, current_temp=20.65, connection=None, current_temp_timestamp='2020-03-10T07:44:11.947Z', current_humidity=45.2, current_humidity_timestamp='2020-03-10T07:44:11.947Z', is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=20.5, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp='2020-03-10T07:47:45.978Z', 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)", + }), + '2': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=2, current_temp=None, connection=None, current_temp_timestamp=None, current_humidity=None, current_humidity_timestamp=None, is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='SMART_SCHEDULE', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=65.0, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp=None, ac_power=None, heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type=None, 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)", + }), + '3': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=3, current_temp=24.76, connection=None, current_temp_timestamp='2020-03-05T03:57:38.850Z', current_humidity=60.9, current_humidity_timestamp='2020-03-05T03:57:38.850Z', is_away=False, current_hvac_action='COOLING', current_fan_speed='AUTO', current_fan_level=None, current_hvac_mode='COOL', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=17.78, available=True, power='ON', link='ONLINE', ac_power_timestamp='2020-03-05T04:01:07.162Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='TADO_MODE', 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)", + }), + '4': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=4, current_temp=None, connection=None, current_temp_timestamp=None, current_humidity=None, current_humidity_timestamp=None, is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='HEATING', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=30.0, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp=None, ac_power=None, heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='TADO_MODE', 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)", + }), + '5': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=5, current_temp=20.88, connection=None, current_temp_timestamp='2020-03-28T02:09:27.830Z', current_humidity=42.3, current_humidity_timestamp='2020-03-28T02:09:27.830Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level=None, current_hvac_mode='SMART_SCHEDULE', current_swing_mode='ON', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=20.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2020-03-27T23:02:22.260Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type=None, 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)", + }), + '6': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=6, current_temp=24.3, connection=None, current_temp_timestamp='2024-06-28T22: 23: 15.679Z', current_humidity=70.9, current_humidity_timestamp='2024-06-28T22: 23: 15.679Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level='LEVEL3', current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='ON', current_horizontal_swing_mode='ON', target_temp=25.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2022-07-13T18: 06: 58.183Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, 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)", + }), + }), + }), + 'mobile_devices': dict({ + 'mobile_device': dict({ + '123456': dict({ + 'deviceMetadata': dict({ + 'locale': 'nl', + 'model': 'Samsung', + 'osVersion': '14', + 'platform': 'Android', + }), + 'id': 123456, + 'name': 'Home', + 'settings': dict({ + 'geoTrackingEnabled': False, + 'onDemandLogRetrievalEnabled': False, + 'pushNotifications': dict({ + 'awayModeReminder': True, + 'energyIqReminder': False, + 'energySavingsReportReminder': True, + 'homeModeReminder': True, + 'incidentDetection': True, + 'lowBatteryReminder': True, + 'openWindowReminder': True, + }), + 'specialOffersEnabled': False, + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 19acb0aecbd..2fd8e6a0468 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -1,20 +1,20 @@ """Test the Tado config flow.""" -from http import HTTPStatus from ipaddress import ip_address -from unittest.mock import MagicMock, patch +import threading +from unittest.mock import AsyncMock, MagicMock, patch -import PyTado +from PyTado.http import DeviceActivationStatus import pytest -import requests -from homeassistant import config_entries -from homeassistant.components.tado.config_flow import NoHomes +from homeassistant.components.tado.config_flow import TadoException from homeassistant.components.tado.const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, DOMAIN, ) +from homeassistant.config_entries import SOURCE_HOMEKIT, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -26,92 +26,186 @@ from homeassistant.helpers.service_info.zeroconf import ( from tests.common import MockConfigEntry -def _get_mock_tado_api(get_me=None) -> MagicMock: - mock_tado = MagicMock() - if isinstance(get_me, Exception): - type(mock_tado).get_me = MagicMock(side_effect=get_me) - else: - type(mock_tado).get_me = MagicMock(return_value=get_me) - return mock_tado +async def test_full_flow( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full flow of the config flow.""" + + event = threading.Event() + + def mock_tado_api_device_activation() -> None: + # Simulate the device activation process + event.wait(timeout=5) + + mock_tado_api.device_activation = mock_tado_api_device_activation + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + + event.set() + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_full_flow_reauth( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full flow of the config when reauthticating.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="ABC-123-DEF-456", + data={CONF_REFRESH_TOKEN: "totally_refresh_for_reauth"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # The no user input + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + 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={} + ) + + event = threading.Event() + + def mock_tado_api_device_activation() -> None: + # Simulate the device activation process + event.wait(timeout=5) + + mock_tado_api.device_activation = mock_tado_api_device_activation + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + + event.set() + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + + +async def test_auth_timeout( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the auth timeout.""" + mock_tado_api.device_activation_status.return_value = DeviceActivationStatus.PENDING + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "timeout" + + mock_tado_api.device_activation_status.return_value = ( + DeviceActivationStatus.COMPLETED + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "timeout" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_no_homes(hass: HomeAssistant, mock_tado_api: MagicMock) -> None: + """Test the full flow of the config flow.""" + mock_tado_api.get_me.return_value["homes"] = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "finish_login" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_homes" + + +async def test_tado_creation(hass: HomeAssistant) -> None: + """Test we handle Form Exceptions.""" + + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=TadoException("Test exception"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" @pytest.mark.parametrize( ("exception", "error"), [ - (KeyError, "invalid_auth"), - (RuntimeError, "cannot_connect"), - (ValueError, "unknown"), + (Exception, "timeout"), + (TadoException, "timeout"), ], ) -async def test_form_exceptions( - hass: HomeAssistant, exception: Exception, error: str +async def test_wait_for_login_exception( + hass: HomeAssistant, + mock_tado_api: MagicMock, + exception: Exception, + error: str, ) -> None: - """Test we handle Form Exceptions.""" + """Test that an exception in wait for login is handled properly.""" + mock_tado_api.device_activation.side_effect = exception + result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": error} - - # Test a retry to recover, upon failure - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) - - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "myhome" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 + # @joostlek: I think the timeout step is not rightfully named, but heck, it works + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == error -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN, data={"username": "test-username"}) - entry.add_to_hass(hass) + 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.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ): - 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, context={"source": config_entries.SOURCE_USER} - ) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -119,125 +213,17 @@ async def test_options_flow(hass: HomeAssistant) -> None: result["flow_id"], {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT} -async def test_create_entry(hass: HomeAssistant) -> None: - """Test we can setup though the user path.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) - - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "myhome" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -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": config_entries.SOURCE_USER} - ) - - response_mock = MagicMock() - type(response_mock).status_code = HTTPStatus.UNAUTHORIZED - mock_tado_api = _get_mock_tado_api( - get_me=requests.HTTPError(response=response_mock) - ) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - response_mock = MagicMock() - type(response_mock).status_code = HTTPStatus.INTERNAL_SERVER_ERROR - mock_tado_api = _get_mock_tado_api( - get_me=requests.HTTPError(response=response_mock) - ) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_no_homes(hass: HomeAssistant) -> None: - """Test we handle no homes error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - mock_tado_api = _get_mock_tado_api(get_me={"homes": []}) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "no_homes"} - - -async def test_form_homekit(hass: HomeAssistant) -> None: +async def test_homekit(hass: HomeAssistant, mock_tado_api: MagicMock) -> None: """Test that we abort from homekit if tado is already setup.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, + context={"source": SOURCE_HOMEKIT}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], @@ -249,13 +235,18 @@ async def test_form_homekit(hass: HomeAssistant) -> None: ), ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - flow = next( - flow - for flow in hass.config_entries.flow.async_progress() - if flow["flow_id"] == result["flow_id"] - ) - assert flow["context"]["unique_id"] == "AA:BB:CC:DD:EE:FF" + assert result["step_id"] == "homekit_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "1" + + +async def test_homekit_already_setup( + hass: HomeAssistant, mock_tado_api: MagicMock +) -> None: + """Test that we abort from homekit if tado is already setup.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} @@ -264,7 +255,7 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, + context={"source": SOURCE_HOMEKIT}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], @@ -276,77 +267,4 @@ async def test_form_homekit(hass: HomeAssistant) -> None: ), ) assert result["type"] is FlowResultType.ABORT - - -@pytest.mark.parametrize( - ("exception", "error"), - [ - (PyTado.exceptions.TadoWrongCredentialsException, "invalid_auth"), - (RuntimeError, "cannot_connect"), - (NoHomes, "no_homes"), - (ValueError, "unknown"), - ], -) -async def test_reconfigure_flow( - hass: HomeAssistant, exception: Exception, error: str -) -> None: - """Test re-configuration flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "username": "test-username", - "password": "test-password", - "home_id": 1, - }, - unique_id="unique_id", - ) - entry.add_to_hass(hass) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.FORM - - with patch( - "homeassistant.components.tado.config_flow.Tado", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": error} - - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - 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 - assert entry.title == "Mock Title" - assert entry.data == { - "username": "test-username", - "password": "test-password", - "home_id": 1, - } + assert result["reason"] == "already_configured" diff --git a/tests/components/tado/test_diagnostics.py b/tests/components/tado/test_diagnostics.py new file mode 100644 index 00000000000..36d136d5d77 --- /dev/null +++ b/tests/components/tado/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test the Tado component diagnostics.""" + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.tado.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +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, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + await async_init_integration(hass) + + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + assert diagnostics == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/tado/test_helper.py b/tests/components/tado/test_helper.py index da959c2124a..7f798e3797c 100644 --- a/tests/components/tado/test_helper.py +++ b/tests/components/tado/test_helper.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from PyTado.interface import Tado import pytest -from homeassistant.components.tado import TadoDataUpdateCoordinator +from homeassistant.components.tado import CONF_REFRESH_TOKEN, TadoDataUpdateCoordinator from homeassistant.components.tado.const import ( CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_DEFAULT, @@ -28,13 +28,13 @@ def entry(request: pytest.FixtureRequest) -> MockConfigEntry: request.param if hasattr(request, "param") else CONST_OVERLAY_TADO_DEFAULT ) return MockConfigEntry( - version=1, - minor_version=1, + version=2, domain=DOMAIN, title="Tado", data={ CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", + CONF_REFRESH_TOKEN: "test-refresh", }, options={ "fallback": fallback, diff --git a/tests/components/tado/test_init.py b/tests/components/tado/test_init.py new file mode 100644 index 00000000000..10acd8eef59 --- /dev/null +++ b/tests/components/tado/test_init.py @@ -0,0 +1,66 @@ +"""Test the Tado integration.""" + +import asyncio +import threading +import time +from unittest.mock import patch + +from PyTado.http import Http + +from homeassistant.components.tado import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_v1_migration(hass: HomeAssistant) -> None: + """Test migration from v1 to v2 config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test", + CONF_PASSWORD: "test", + }, + unique_id="1", + version=1, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.version == 2 + assert CONF_USERNAME not in entry.data + + +async def test_refresh_token_threading_lock(hass: HomeAssistant) -> None: + """Test that threading.Lock in Http._refresh_token serializes concurrent calls.""" + + timestamps: list[tuple[str, float]] = [] + lock = threading.Lock() + + def fake_refresh_token(*args, **kwargs) -> bool: + """Simulate the refresh token process with a threading lock.""" + with lock: + timestamps.append(("start", time.monotonic())) + time.sleep(0.2) + timestamps.append(("end", time.monotonic())) + return True + + with ( + patch("PyTado.http.Http._refresh_token", side_effect=fake_refresh_token), + patch("PyTado.http.Http.__init__", return_value=None), + ): + http_instance = Http() + + # Run two concurrent refresh token calls, should do the trick + await asyncio.gather( + hass.async_add_executor_job(http_instance._refresh_token), + hass.async_add_executor_job(http_instance._refresh_token), + ) + + end1 = timestamps[1][1] + start2 = timestamps[2][1] + + assert start2 >= end1, ( + f"Second refresh started before first ended: start2={start2}, end1={end1}." + ) diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 5bf87dbed33..6fd333dff51 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -2,8 +2,7 @@ import requests_mock -from homeassistant.components.tado import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -178,9 +177,16 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones/1/state", text=load_fixture(zone_1_state_fixture), ) + m.post( + "https://login.tado.com/oauth2/token", + text=load_fixture(token_fixture), + ) entry = MockConfigEntry( domain=DOMAIN, - data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}, + version=2, + data={ + CONF_REFRESH_TOKEN: "mock-token", + }, options={"fallback": "NEXT_TIME_BLOCK"}, ) entry.add_to_hass(hass) diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index ac862e59f2d..25b1e116c04 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -5,7 +5,7 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.tag import DOMAIN, _create_entry, async_scan_tag diff --git a/tests/components/tailscale/fixtures/devices.json b/tests/components/tailscale/fixtures/devices.json index 66dc262127a..fdfd1d9259a 100644 --- a/tests/components/tailscale/fixtures/devices.json +++ b/tests/components/tailscale/fixtures/devices.json @@ -104,6 +104,28 @@ "upnp": false } } + }, + { + "addresses": ["100.11.11.113"], + "id": "123458", + "user": "frenck", + "name": "host-no-connectivity.homeassistant.github", + "hostname": "host-no-connectivity", + "clientVersion": "1.14.0-t5cff36945-g809e87bba", + "updateAvailable": true, + "os": "linux", + "created": "2021-08-29T09:49:06Z", + "lastSeen": "2021-11-15T20:37:03Z", + "keyExpiryDisabled": false, + "expires": "2022-02-25T09:49:06Z", + "authorized": true, + "isExternal": false, + "machineKey": "mkey:mock", + "nodeKey": "nodekey:mock", + "blocksIncomingConnections": false, + "enabledRoutes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], + "advertisedRoutes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], + "clientConnectivity": null } ] } diff --git a/tests/components/tailscale/snapshots/test_diagnostics.ambr b/tests/components/tailscale/snapshots/test_diagnostics.ambr index eba8d9bd145..f3f90c641ea 100644 --- a/tests/components/tailscale/snapshots/test_diagnostics.ambr +++ b/tests/components/tailscale/snapshots/test_diagnostics.ambr @@ -82,6 +82,38 @@ 'update_available': True, 'user': '**REDACTED**', }), + dict({ + 'addresses': '**REDACTED**', + 'advertised_routes': list([ + '0.0.0.0/0', + '10.10.10.0/23', + '::/0', + ]), + 'authorized': True, + 'blocks_incoming_connections': False, + 'client_connectivity': None, + 'client_version': '1.14.0-t5cff36945-g809e87bba', + 'created': '2021-08-29T09:49:06+00:00', + 'device_id': '**REDACTED**', + 'enabled_routes': list([ + '0.0.0.0/0', + '10.10.10.0/23', + '::/0', + ]), + 'expires': '2022-02-25T09:49:06+00:00', + 'hostname': '**REDACTED**', + 'is_external': False, + 'key_expiry_disabled': False, + 'last_seen': '2021-11-15T20:37:03+00:00', + 'machine_key': '**REDACTED**', + 'name': '**REDACTED**', + 'node_key': '**REDACTED**', + 'os': 'linux', + 'tags': list([ + ]), + 'update_available': True, + 'user': '**REDACTED**', + }), ]), }) # --- diff --git a/tests/components/tailscale/test_binary_sensor.py b/tests/components/tailscale/test_binary_sensor.py index b2b593101d7..e0ac97865f0 100644 --- a/tests/components/tailscale/test_binary_sensor.py +++ b/tests/components/tailscale/test_binary_sensor.py @@ -6,7 +6,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, ) from homeassistant.components.tailscale.const import DOMAIN -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, EntityCategory +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + STATE_UNKNOWN, + EntityCategory, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -122,3 +127,19 @@ async def test_tailscale_binary_sensors( device_entry.configuration_url == "https://login.tailscale.com/admin/machines/100.11.11.111" ) + + # Check host without client connectivity attribute + state = hass.states.get("binary_sensor.host_no_connectivity_supports_hairpinning") + entry = entity_registry.async_get( + "binary_sensor.host_no_connectivity_supports_hairpinning" + ) + assert entry + assert state + assert entry.unique_id == "123458_client_supports_hair_pinning" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_UNKNOWN + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "host-no-connectivity Supports hairpinning" + ) + assert ATTR_DEVICE_CLASS not in state.attributes diff --git a/tests/components/tailscale/test_diagnostics.py b/tests/components/tailscale/test_diagnostics.py index 26ba611438c..7dcf94f8ce8 100644 --- a/tests/components/tailscale/test_diagnostics.py +++ b/tests/components/tailscale/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Tailscale integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/tankerkoenig/const.py b/tests/components/tankerkoenig/const.py index 2c28753a7f3..9a2ecb3a2be 100644 --- a/tests/components/tankerkoenig/const.py +++ b/tests/components/tankerkoenig/const.py @@ -2,7 +2,7 @@ from aiotankerkoenig import PriceInfo, Station, Status -from homeassistant.components.tankerkoenig.const import CONF_FUEL_TYPES, CONF_STATIONS +from homeassistant.components.tankerkoenig.const import CONF_STATIONS from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -98,7 +98,6 @@ PRICES_MISSING_FUELTYPE = { CONFIG_DATA = { CONF_NAME: "Home", CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", - CONF_FUEL_TYPES: ["e5"], CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, CONF_RADIUS: 2.0, CONF_STATIONS: [ diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index b5b33d7c246..71d9d9c75f8 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -12,9 +12,6 @@ 'entry': dict({ 'data': dict({ 'api_key': '**REDACTED**', - 'fuel_types': list([ - 'e5', - ]), 'location': dict({ 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', diff --git a/tests/components/tankerkoenig/test_binary_sensor.py b/tests/components/tankerkoenig/test_binary_sensor.py index c103f2d26ff..880eb0e2f8c 100644 --- a/tests/components/tankerkoenig/test_binary_sensor.py +++ b/tests/components/tankerkoenig/test_binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index bb1e943bbb9..967470c2c16 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -4,11 +4,7 @@ from unittest.mock import AsyncMock, patch from aiotankerkoenig.exceptions import TankerkoenigInvalidKeyError -from homeassistant.components.tankerkoenig.const import ( - CONF_FUEL_TYPES, - CONF_STATIONS, - DOMAIN, -) +from homeassistant.components.tankerkoenig.const import CONF_STATIONS, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, @@ -30,7 +26,6 @@ from tests.common import MockConfigEntry MOCK_USER_DATA = { CONF_NAME: "Home", CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", - CONF_FUEL_TYPES: ["e5"], CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, CONF_RADIUS: 2.0, } @@ -81,7 +76,6 @@ async def test_user(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_NAME] == "Home" assert result["data"][CONF_API_KEY] == "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx" - assert result["data"][CONF_FUEL_TYPES] == ["e5"] assert result["data"][CONF_LOCATION] == {"latitude": 51.0, "longitude": 13.0} assert result["data"][CONF_RADIUS] == 2.0 assert result["data"][CONF_STATIONS] == [ diff --git a/tests/components/tankerkoenig/test_diagnostics.py b/tests/components/tankerkoenig/test_diagnostics.py index e7b479a0c32..6e1c81fa2c4 100644 --- a/tests/components/tankerkoenig/test_diagnostics.py +++ b/tests/components/tankerkoenig/test_diagnostics.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/tankerkoenig/test_sensor.py b/tests/components/tankerkoenig/test_sensor.py index 788c1de7021..27c2324662c 100644 --- a/tests/components/tankerkoenig/test_sensor.py +++ b/tests/components/tankerkoenig/test_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tankerkoenig import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 78235f7ebf5..098cdbbf8d1 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -13,7 +13,7 @@ from hatasmota.utils import ( get_topic_tele_will, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.tasmota.const import DEFAULT_PREFIX diff --git a/tests/components/technove/snapshots/test_switch.ambr b/tests/components/technove/snapshots/test_switch.ambr index a5f8411747b..0e93143ffed 100644 --- a/tests/components/technove/snapshots/test_switch.ambr +++ b/tests/components/technove/snapshots/test_switch.ambr @@ -24,7 +24,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Auto charge', + 'original_name': 'Auto-charge', 'platform': 'technove', 'previous_unique_id': None, 'supported_features': 0, @@ -36,7 +36,7 @@ # name: test_switches[switch.technove_station_auto_charge-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'TechnoVE Station Auto charge', + 'friendly_name': 'TechnoVE Station Auto-charge', }), 'context': , 'entity_id': 'switch.technove_station_auto_charge', @@ -71,7 +71,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Charging Enabled', + 'original_name': 'Charging enabled', 'platform': 'technove', 'previous_unique_id': None, 'supported_features': 0, @@ -83,7 +83,7 @@ # name: test_switches[switch.technove_station_charging_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'TechnoVE Station Charging Enabled', + 'friendly_name': 'TechnoVE Station Charging enabled', }), 'context': , 'entity_id': 'switch.technove_station_charging_enabled', diff --git a/tests/components/technove/test_binary_sensor.py b/tests/components/technove/test_binary_sensor.py index 93d4805cecb..cbc34534480 100644 --- a/tests/components/technove/test_binary_sensor.py +++ b/tests/components/technove/test_binary_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from technove import TechnoVEError from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform diff --git a/tests/components/technove/test_sensor.py b/tests/components/technove/test_sensor.py index 9cf80a659eb..48c59c80197 100644 --- a/tests/components/technove/test_sensor.py +++ b/tests/components/technove/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from technove import Station, Status, TechnoVEError from homeassistant.components.technove.const import DOMAIN diff --git a/tests/components/tedee/test_binary_sensor.py b/tests/components/tedee/test_binary_sensor.py index ccfd12440ea..cc931bb0c7c 100644 --- a/tests/components/tedee/test_binary_sensor.py +++ b/tests/components/tedee/test_binary_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tedee/test_diagnostics.py b/tests/components/tedee/test_diagnostics.py index 1487645572f..2cb18407432 100644 --- a/tests/components/tedee/test_diagnostics.py +++ b/tests/components/tedee/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Tedee integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py index 71bf5262f00..7f1f52c7977 100644 --- a/tests/components/tedee/test_init.py +++ b/tests/components/tedee/test_init.py @@ -11,7 +11,7 @@ from aiotedee.exception import ( TedeeWebhookException, ) import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN from homeassistant.components.webhook import async_generate_url diff --git a/tests/components/tedee/test_sensor.py b/tests/components/tedee/test_sensor.py index 3c03d340100..4c8a3775443 100644 --- a/tests/components/tedee/test_sensor.py +++ b/tests/components/tedee/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index bdca84ba071..c69c9e9e9a4 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -1,5 +1,7 @@ """template conftest.""" +from enum import Enum + import pytest from homeassistant.core import HomeAssistant, ServiceCall @@ -9,6 +11,14 @@ from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +class ConfigurationStyle(Enum): + """Configuration Styles for template testing.""" + + LEGACY = "Legacy" + MODERN = "Modern" + TRIGGER = "Trigger" + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/template/snapshots/test_switch.ambr b/tests/components/template/snapshots/test_switch.ambr index c240a9436a0..909110fdbc8 100644 --- a/tests/components/template/snapshots/test_switch.ambr +++ b/tests/components/template/snapshots/test_switch.ambr @@ -1,5 +1,18 @@ # serializer version: 1 -# name: test_setup_config_entry +# name: test_setup_config_entry[state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'switch.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_config_entry[value_template] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'My template', diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 4b259fabac2..f9820243600 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -1,5 +1,7 @@ """The tests for the Template alarm control panel platform.""" +from typing import Any + import pytest from syrupy.assertion import SnapshotAssertion @@ -13,6 +15,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, + STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -20,10 +23,13 @@ from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component +from .conftest import ConfigurationStyle + from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache -TEMPLATE_NAME = "alarm_control_panel.test_template_panel" -PANEL_NAME = "alarm_control_panel.test" +TEST_OBJECT_ID = "test_template_panel" +TEST_ENTITY_ID = f"alarm_control_panel.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "alarm_control_panel.test" @pytest.fixture @@ -82,6 +88,21 @@ OPTIMISTIC_TEMPLATE_ALARM_CONFIG = { "data": {"code": "{{ this.entity_id }}"}, }, } +EMPTY_ACTIONS = { + "arm_away": [], + "arm_home": [], + "arm_night": [], + "arm_vacation": [], + "arm_custom_bypass": [], + "disarm": [], + "trigger": [], +} + + +UNIQUE_ID_CONFIG = { + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "unique_id": "not-so-unique-anymore", +} TEMPLATE_ALARM_CONFIG = { @@ -90,44 +111,283 @@ TEMPLATE_ALARM_CONFIG = { } -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, panel_config: dict[str, Any] +) -> None: + """Do setup of alarm control panel integration via legacy format.""" + config = {"alarm_control_panel": {"platform": "template", "panels": panel_config}} + + with assert_setup_component(count, ALARM_DOMAIN): + assert await async_setup_component( + hass, + ALARM_DOMAIN, + config, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, panel_config: dict[str, Any] +) -> None: + """Do setup of alarm control panel integration via modern format.""" + config = {"template": {"alarm_control_panel": panel_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_panel( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + panel_config: dict[str, Any], +) -> None: + """Do setup of alarm control panel integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, panel_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, panel_config) + + +async def async_setup_state_panel( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of alarm control panel integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + "value_template": state_template, + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + }, + ) + + +@pytest.fixture +async def setup_state_panel( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of alarm control panel integration using a state template.""" + await async_setup_state_panel(hass, count, style, state_template) + + +@pytest.fixture +async def setup_base_panel( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str | None, + panel_config: str, +): + """Do setup of alarm control panel integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + extra = {"value_template": state_template} if state_template else {} + await async_setup_legacy_format( + hass, + count, + {TEST_OBJECT_ID: {**extra, **panel_config}}, + ) + elif style == ConfigurationStyle.MODERN: + extra = {"state": state_template} if state_template else {} + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **extra, + **panel_config, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_state_panel( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of alarm control panel integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "value_template": state_template, + **extra, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "state": state_template, + **extra, + }, + ) + + @pytest.mark.parametrize( - "config", - [ - { - "alarm_control_panel": { - "platform": "template", - "panels": {"test_template_panel": TEMPLATE_ALARM_CONFIG}, - } - }, - ], + ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_panel") async def test_template_state_text(hass: HomeAssistant) -> None: """Test the state text of a template.""" for set_state in ( - AlarmControlPanelState.ARMED_HOME, AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + AlarmControlPanelState.ARMED_HOME, AlarmControlPanelState.ARMED_NIGHT, AlarmControlPanelState.ARMED_VACATION, - AlarmControlPanelState.ARMED_CUSTOM_BYPASS, AlarmControlPanelState.ARMING, AlarmControlPanelState.DISARMED, + AlarmControlPanelState.DISARMING, AlarmControlPanelState.PENDING, AlarmControlPanelState.TRIGGERED, ): - hass.states.async_set(PANEL_NAME, set_state) + hass.states.async_set(TEST_STATE_ENTITY_ID, set_state) await hass.async_block_till_done() - state = hass.states.get(TEMPLATE_NAME) + state = hass.states.get(TEST_ENTITY_ID) assert state.state == set_state - hass.states.async_set(PANEL_NAME, "invalid_state") + hass.states.async_set(TEST_STATE_ENTITY_ID, "invalid_state") await hass.async_block_till_done() - state = hass.states.get(TEMPLATE_NAME) + state = hass.states.get(TEST_ENTITY_ID) assert state.state == "unknown" +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("state_template", "expected"), + [ + ("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED), + ("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME), + ("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY), + ("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT), + ("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION), + ("{{ 'armed_custom_bypass' }}", AlarmControlPanelState.ARMED_CUSTOM_BYPASS), + ("{{ 'pending' }}", AlarmControlPanelState.PENDING), + ("{{ 'arming' }}", AlarmControlPanelState.ARMING), + ("{{ 'disarming' }}", AlarmControlPanelState.DISARMING), + ("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED), + ("{{ x - 1 }}", STATE_UNKNOWN), + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_panel") +async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: + """Test the state template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + "{{ 'disarmed' }}", + "{% if states.switch.test_state.state %}mdi:check{% endif %}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "icon"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_panel") +async def test_icon_template( + hass: HomeAssistant, +) -> None: + """Test icon template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") in ("", None) + + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["icon"] == "mdi:check" + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + "{{ 'disarmed' }}", + "{% if states.switch.test_state.state %}local/panel.png{% endif %}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "picture"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_panel") +async def test_picture_template( + hass: HomeAssistant, +) -> None: + """Test icon template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") in ("", None) + + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["entity_picture"] == "local/panel.png" + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion ) -> None: @@ -163,23 +423,18 @@ async def test_setup_config_entry( assert state.state == AlarmControlPanelState.DISARMED -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) +@pytest.mark.parametrize(("count", "state_template"), [(1, None)]) @pytest.mark.parametrize( - "config", - [ - { - "alarm_control_panel": { - "platform": "template", - "panels": {"test_template_panel": OPTIMISTIC_TEMPLATE_ALARM_CONFIG}, - } - }, - ], + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "panel_config", [OPTIMISTIC_TEMPLATE_ALARM_CONFIG, EMPTY_ACTIONS] +) +@pytest.mark.usefixtures("setup_base_panel") async def test_optimistic_states(hass: HomeAssistant) -> None: """Test the optimistic state.""" - state = hass.states.get(TEMPLATE_NAME) + state = hass.states.get(TEST_ENTITY_ID) await hass.async_block_till_done() assert state.state == "unknown" @@ -195,31 +450,45 @@ async def test_optimistic_states(hass: HomeAssistant) -> None: await hass.services.async_call( ALARM_DOMAIN, service, - {"entity_id": TEMPLATE_NAME, "code": "1234"}, + {"entity_id": TEST_ENTITY_ID, "code": "1234"}, blocking=True, ) await hass.async_block_till_done() - assert hass.states.get(TEMPLATE_NAME).state == set_state + assert hass.states.get(TEST_ENTITY_ID).state == set_state + + +@pytest.mark.parametrize("count", [0]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("panel_config", "state_template", "msg"), + [ + ( + OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "{% if blah %}", + "invalid template", + ), + ( + {"code_format": "bad_format", **OPTIMISTIC_TEMPLATE_ALARM_CONFIG}, + "disarmed", + "value must be one of ['no_code', 'number', 'text']", + ), + ], +) +@pytest.mark.usefixtures("setup_base_panel") +async def test_template_syntax_error( + hass: HomeAssistant, msg, caplog_setup_text +) -> None: + """Test templating syntax error.""" + assert len(hass.states.async_all("alarm_control_panel")) == 0 + assert (msg) in caplog_setup_text @pytest.mark.parametrize(("count", "domain"), [(0, "alarm_control_panel")]) @pytest.mark.parametrize( ("config", "msg"), [ - ( - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{% if blah %}", - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - } - }, - } - }, - "invalid template", - ), ( { "alarm_control_panel": { @@ -249,25 +518,10 @@ async def test_optimistic_states(hass: HomeAssistant) -> None: }, "required key 'panels' not provided", ), - ( - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "disarmed", - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - "code_format": "bad_format", - } - }, - } - }, - "value must be one of ['no_code', 'number', 'text']", - ), ], ) @pytest.mark.usefixtures("start_ha") -async def test_template_syntax_error( +async def test_legacy_template_syntax_error( hass: HomeAssistant, msg, caplog_setup_text ) -> None: """Test templating syntax error.""" @@ -275,43 +529,30 @@ async def test_template_syntax_error( assert (msg) in caplog_setup_text -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute", "attribute_template"), + [(1, "disarmed", "name", '{{ "Template Alarm Panel" }}')], +) +@pytest.mark.parametrize( + ("style", "test_entity_id"), [ - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "name": '{{ "Template Alarm Panel" }}', - "value_template": "disarmed", - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - } - }, - } - }, + (ConfigurationStyle.LEGACY, TEST_ENTITY_ID), + (ConfigurationStyle.MODERN, "alarm_control_panel.template_alarm_panel"), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_name(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("setup_single_attribute_state_panel") +async def test_name(hass: HomeAssistant, test_entity_id: str) -> None: """Test the accessibility of the name attribute.""" - state = hass.states.get(TEMPLATE_NAME) + state = hass.states.get(test_entity_id) assert state is not None assert state.attributes.get("friendly_name") == "Template Alarm Panel" -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) @pytest.mark.parametrize( - "config", - [ - { - "alarm_control_panel": { - "platform": "template", - "panels": {"test_template_panel": TEMPLATE_ALARM_CONFIG}, - } - }, - ], + ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) @pytest.mark.parametrize( "service", @@ -325,7 +566,7 @@ async def test_name(hass: HomeAssistant) -> None: "alarm_trigger", ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_panel") async def test_actions( hass: HomeAssistant, service, call_service_events: list[Event] ) -> None: @@ -333,128 +574,147 @@ async def test_actions( await hass.services.async_call( ALARM_DOMAIN, service, - {"entity_id": TEMPLATE_NAME, "code": "1234"}, + {"entity_id": TEST_ENTITY_ID, "code": "1234"}, blocking=True, ) await hass.async_block_till_done() assert len(call_service_events) == 1 assert call_service_events[0].data["service"] == service - assert call_service_events[0].data["service_data"]["code"] == TEMPLATE_NAME + assert call_service_events[0].data["service_data"]["code"] == TEST_ENTITY_ID -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("panel_config", "style"), [ - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_alarm_control_panel_01": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - }, - "test_template_alarm_control_panel_02": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - }, + ( + { + "test_template_alarm_control_panel_01": { + "value_template": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + "test_template_alarm_control_panel_02": { + "value_template": "{{ false }}", + **UNIQUE_ID_CONFIG, }, }, - }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_alarm_control_panel_01", + "state": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_alarm_control_panel_02", + "state": "{{ false }}", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_panel") async def test_unique_id(hass: HomeAssistant) -> None: """Test unique_id option only creates one alarm control panel per id.""" assert len(hass.states.async_all()) == 1 -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a template unique_id propagates to alarm_control_panel unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "alarm_control_panel": [ + { + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "name": "test_a", + "unique_id": "a", + "state": "{{ true }}", + }, + { + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "name": "test_b", + "unique_id": "b", + "state": "{{ true }}", + }, + ], + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("alarm_control_panel")) == 2 + + entry = entity_registry.async_get("alarm_control_panel.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("alarm_control_panel.test_b") + assert entry + assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "disarmed")]) @pytest.mark.parametrize( - ("config", "code_format", "code_arm_required"), + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("panel_config", "code_format", "code_arm_required"), [ ( - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "disarmed", - } - }, - } - }, + {}, "number", True, ), ( - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "disarmed", - "code_format": "text", - } - }, - } - }, + {"code_format": "text"}, "text", True, ), ( { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "disarmed", - "code_format": "no_code", - "code_arm_required": False, - } - }, - } + "code_format": "no_code", + "code_arm_required": False, }, None, False, ), ( { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "disarmed", - "code_format": "text", - "code_arm_required": False, - } - }, - } + "code_format": "text", + "code_arm_required": False, }, "text", False, ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_base_panel") async def test_code_config(hass: HomeAssistant, code_format, code_arm_required) -> None: """Test configuration options related to alarm code.""" - state = hass.states.get(TEMPLATE_NAME) + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("code_format") == code_format assert state.attributes.get("code_arm_required") == code_arm_required -@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) @pytest.mark.parametrize( - "config", - [ - { - "alarm_control_panel": { - "platform": "template", - "panels": {"test_template_panel": TEMPLATE_ALARM_CONFIG}, - } - }, - ], + ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) @pytest.mark.parametrize( ("restored_state", "initial_state"), @@ -493,11 +753,11 @@ async def test_code_config(hass: HomeAssistant, code_format, code_arm_required) ) async def test_restore_state( hass: HomeAssistant, - count, - domain, - config, - restored_state, - initial_state, + count: int, + state_template: str, + style: ConfigurationStyle, + restored_state: str, + initial_state: str, ) -> None: """Test restoring template alarm control panel.""" @@ -507,17 +767,7 @@ async def test_restore_state( {}, ) mock_restore_cache(hass, (fake_state,)) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) - - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() + await async_setup_state_panel(hass, count, style, state_template) state = hass.states.get("alarm_control_panel.test_template_panel") assert state.state == initial_state diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index dd008a27822..312c04b670c 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -16,10 +16,10 @@ from homeassistant.components.blueprint import ( DomainBlueprints, ) from homeassistant.components.template import DOMAIN, SERVICE_RELOAD -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import yaml as yaml_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import async_mock_service @@ -212,6 +212,161 @@ async def test_reload_template_when_blueprint_changes(hass: HomeAssistant) -> No assert not_inverted.state == "on" +@pytest.mark.parametrize( + ("blueprint"), + ["test_event_sensor.yaml", "test_event_sensor_legacy_schema.yaml"], +) +async def test_trigger_event_sensor( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + blueprint: str, +) -> None: + """Test event sensor blueprint.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "use_blueprint": { + "path": blueprint, + "input": { + "event_type": "my_custom_event", + "event_data": {"foo": "bar"}, + }, + }, + "name": "My Custom Event", + }, + ] + }, + ) + + context = Context() + now = dt_util.utcnow() + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire( + "my_custom_event", {"foo": "bar", "beer": 2}, context=context + ) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == now.isoformat(timespec="seconds") + data = date_state.attributes.get("data") + assert data is not None + assert data != "" + assert data.get("foo") == "bar" + assert data.get("beer") == 2 + + inverted_foo_template = template.helpers.blueprint_in_template( + hass, "sensor.my_custom_event" + ) + assert inverted_foo_template == blueprint + + inverted_binary_sensor_blueprint_entity_ids = ( + template.helpers.templates_with_blueprint(hass, blueprint) + ) + assert len(inverted_binary_sensor_blueprint_entity_ids) == 1 + + with pytest.raises(BlueprintInUse): + await template.async_get_blueprints(hass).async_remove_blueprint(blueprint) + + +@pytest.mark.parametrize( + ("blueprint", "override"), + [ + # Override a blueprint with modern schema with legacy schema + ( + "test_event_sensor.yaml", + {"trigger": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with modern schema with modern schema + ( + "test_event_sensor.yaml", + {"triggers": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with legacy schema with legacy schema + ( + "test_event_sensor_legacy_schema.yaml", + {"trigger": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with legacy schema with modern schema + ( + "test_event_sensor_legacy_schema.yaml", + {"triggers": {"platform": "event", "event_type": "override"}}, + ), + ], +) +async def test_blueprint_template_override( + hass: HomeAssistant, blueprint: str, override: dict +) -> None: + """Test blueprint template where the template config overrides the blueprint.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "use_blueprint": { + "path": blueprint, + "input": { + "event_type": "my_custom_event", + "event_data": {"foo": "bar"}, + }, + }, + "name": "My Custom Event", + } + | override, + ] + }, + ) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == "unknown" + + context = Context() + now = dt_util.utcnow() + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire( + "my_custom_event", {"foo": "bar", "beer": 2}, context=context + ) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == "unknown" + + context = Context() + now = dt_util.utcnow() + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire("override", {"foo": "bar", "beer": 2}, context=context) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == now.isoformat(timespec="seconds") + data = date_state.attributes.get("data") + assert data is not None + assert data != "" + assert data.get("foo") == "bar" + assert data.get("beer") == 2 + + inverted_foo_template = template.helpers.blueprint_in_template( + hass, "sensor.my_custom_event" + ) + assert inverted_foo_template == blueprint + + inverted_binary_sensor_blueprint_entity_ids = ( + template.helpers.templates_with_blueprint(hass, blueprint) + ) + assert len(inverted_binary_sensor_blueprint_entity_ids) == 1 + + with pytest.raises(BlueprintInUse): + await template.async_get_blueprints(hass).async_remove_blueprint(blueprint) + + async def test_domain_blueprint(hass: HomeAssistant) -> None: """Test DomainBlueprint services.""" reload_handler_calls = async_mock_service(hass, DOMAIN, SERVICE_RELOAD) @@ -262,7 +417,8 @@ async def test_invalid_blueprint( ) assert "more than one platform defined per blueprint" in caplog.text - assert await template.async_get_blueprints(hass).async_get_blueprints() == {} + blueprints = await template.async_get_blueprints(hass).async_get_blueprints() + assert "invalid.yaml" not in blueprints async def test_no_blueprint(hass: HomeAssistant) -> None: diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index b201385240c..31239dbaf92 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -93,6 +93,45 @@ async def test_missing_optional_config(hass: HomeAssistant) -> None: _verify(hass, STATE_UNKNOWN) +async def test_missing_emtpy_press_action_config( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test: missing optional template is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "button": { + "press": [], + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_UNKNOWN) + + now = dt.datetime.now(dt.UTC) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {CONF_ENTITY_ID: _TEST_BUTTON}, + blocking=True, + ) + + _verify( + hass, + now.isoformat(), + ) + + async def test_missing_required_keys(hass: HomeAssistant) -> None: """Test: missing required fields will fail.""" with assert_setup_component(0, "template"): diff --git a/tests/components/template/test_config.py b/tests/components/template/test_config.py new file mode 100644 index 00000000000..b14ff0efa5a --- /dev/null +++ b/tests/components/template/test_config.py @@ -0,0 +1,50 @@ +"""Test Template config.""" + +from __future__ import annotations + +import pytest +import voluptuous as vol + +from homeassistant.components.template.config import CONFIG_SECTION_SCHEMA +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + "config", + [ + { + "trigger": {"trigger": "event", "event_type": "my_event"}, + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + { + "trigger": {"trigger": "event", "event_type": "my_event"}, + "action": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + ], +) +async def test_invalid_schema(hass: HomeAssistant, config: dict) -> None: + """Test invalid config schemas.""" + with pytest.raises(vol.Invalid): + CONFIG_SECTION_SCHEMA(config) diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 2c9b81e7c91..2c4e24ddf71 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -13,9 +13,39 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value from tests.typing import WebSocketGenerator +SWITCH_BEFORE_OPTIONS = { + "name": "test_template_switch", + "template_type": "switch", + "turn_off": [{"event": "test_template_switch", "event_data": {"event": "off"}}], + "turn_on": [{"event": "test_template_switch", "event_data": {"event": "on"}}], + "value_template": "{{ now().minute % 2 == 0 }}", +} + + +SWITCH_AFTER_OPTIONS = { + "name": "test_template_switch", + "template_type": "switch", + "turn_off": [{"event": "test_template_switch", "event_data": {"event": "off"}}], + "turn_on": [{"event": "test_template_switch", "event_data": {"event": "on"}}], + "state": "{{ now().minute % 2 == 0 }}", + "value_template": "{{ now().minute % 2 == 0 }}", +} + +SENSOR_OPTIONS = { + "name": "test_template_sensor", + "template_type": "sensor", + "state": "{{ 'a' if now().minute % 2 == 0 else 'b' }}", +} + +BINARY_SENSOR_OPTIONS = { + "name": "test_template_sensor", + "template_type": "binary_sensor", + "state": "{{ now().minute % 2 == 0 else }}", +} + @pytest.mark.parametrize( ( @@ -377,17 +407,6 @@ async def test_config_flow_device( } -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # If the desired key is missing from the schema, return None - return None - - @pytest.mark.parametrize( ( "template_type", @@ -578,7 +597,7 @@ async def test_options( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type - assert get_suggested( + assert get_schema_suggested_value( result["data_schema"].schema, key_template ) == old_state_template.get(key_template) assert "name" not in result["data_schema"].schema @@ -625,8 +644,10 @@ async def test_options( assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type - assert get_suggested(result["data_schema"].schema, "name") is None - assert get_suggested(result["data_schema"].schema, key_template) is None + assert get_schema_suggested_value(result["data_schema"].schema, "name") is None + assert ( + get_schema_suggested_value(result["data_schema"].schema, key_template) is None + ) @pytest.mark.parametrize( diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index c49db59c2ee..5f28a977867 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -4,11 +4,12 @@ from typing import Any import pytest -from homeassistant import setup +from homeassistant.components import cover, template from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, + CoverEntityFeature, CoverState, ) from homeassistant.const import ( @@ -28,657 +29,776 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import assert_setup_component -ENTITY_COVER = "cover.test_template_cover" +TEST_OBJECT_ID = "test_template_cover" +TEST_ENTITY_ID = f"cover.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "cover.test_state" - -OPEN_CLOSE_COVER_CONFIG = { - "open_cover": { - "service": "test.automation", - "data_template": { - "action": "open_cover", - "caller": "{{ this.entity_id }}", - }, - }, - "close_cover": { - "service": "test.automation", - "data_template": { - "action": "close_cover", - "caller": "{{ this.entity_id }}", - }, +OPEN_COVER = { + "service": "test.automation", + "data_template": { + "action": "open_cover", + "caller": "{{ this.entity_id }}", }, } +CLOSE_COVER = { + "service": "test.automation", + "data_template": { + "action": "close_cover", + "caller": "{{ this.entity_id }}", + }, +} -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - ("config", "states"), - [ - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ states.cover.test_state.state }}", - } - }, - } - }, - [ - ("cover.test_state", CoverState.OPEN, CoverState.OPEN, {}, -1, ""), - ("cover.test_state", CoverState.CLOSED, CoverState.CLOSED, {}, -1, ""), - ( - "cover.test_state", - CoverState.OPENING, - CoverState.OPENING, - {}, - -1, - "", - ), - ( - "cover.test_state", - CoverState.CLOSING, - CoverState.CLOSING, - {}, - -1, - "", - ), - ( - "cover.test_state", - "dog", - STATE_UNKNOWN, - {}, - -1, - "Received invalid cover is_on state: dog", - ), - ("cover.test_state", CoverState.OPEN, CoverState.OPEN, {}, -1, ""), - ( - "cover.test_state", - "cat", - STATE_UNKNOWN, - {}, - -1, - "Received invalid cover is_on state: cat", - ), - ("cover.test_state", CoverState.CLOSED, CoverState.CLOSED, {}, -1, ""), - ( - "cover.test_state", - "bear", - STATE_UNKNOWN, - {}, - -1, - "Received invalid cover is_on state: bear", - ), - ], - ), - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": ( - "{{ states.cover.test.attributes.position }}" - ), - "value_template": "{{ states.cover.test_state.state }}", - } - }, - } - }, - [ - ("cover.test_state", CoverState.OPEN, STATE_UNKNOWN, {}, -1, ""), - ("cover.test_state", CoverState.CLOSED, STATE_UNKNOWN, {}, -1, ""), - ( - "cover.test_state", - CoverState.OPENING, - CoverState.OPENING, - {}, - -1, - "", - ), - ( - "cover.test_state", - CoverState.CLOSING, - CoverState.CLOSING, - {}, - -1, - "", - ), - ( - "cover.test", - CoverState.CLOSED, - CoverState.CLOSING, - {"position": 0}, - 0, - "", - ), - ("cover.test_state", CoverState.OPEN, CoverState.CLOSED, {}, -1, ""), - ( - "cover.test", - CoverState.CLOSED, - CoverState.OPEN, - {"position": 10}, - 10, - "", - ), - ( - "cover.test_state", - "dog", - CoverState.OPEN, - {}, - -1, - "Received invalid cover is_on state: dog", - ), - ], - ), - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_state_text( - hass: HomeAssistant, states, caplog: pytest.LogCaptureFixture +SET_COVER_POSITION = { + "service": "test.automation", + "data_template": { + "action": "set_cover_position", + "caller": "{{ this.entity_id }}", + "position": "{{ position }}", + }, +} + +SET_COVER_TILT_POSITION = { + "service": "test.automation", + "data_template": { + "action": "set_cover_tilt_position", + "caller": "{{ this.entity_id }}", + "tilt_position": "{{ tilt }}", + }, +} + +COVER_ACTIONS = { + "open_cover": OPEN_COVER, + "close_cover": CLOSE_COVER, +} +NAMED_COVER_ACTIONS = { + **COVER_ACTIONS, + "name": TEST_OBJECT_ID, +} +UNIQUE_ID_CONFIG = { + **COVER_ACTIONS, + "unique_id": "not-so-unique-anymore", +} + + +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, cover_config: dict[str, Any] ) -> None: - """Test the state text of a template.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_UNKNOWN + """Do setup of cover integration via legacy format.""" + config = {"cover": {"platform": "template", "covers": cover_config}} - for entity, set_state, test_state, attr, pos, text in states: - hass.states.async_set(entity, set_state, attributes=attr) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == test_state - if pos >= 0: - assert state.attributes.get("current_position") == pos - assert text in caplog.text - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - ("config", "entity", "set_state", "test_state", "attr"), - [ - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": ( - "{{ states.cover.test.attributes.position }}" - ), - "value_template": "{{ states.cover.test_state.state }}", - } - }, - } - }, - "cover.test_state", - "", - STATE_UNKNOWN, - {}, - ), - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": ( - "{{ states.cover.test.attributes.position }}" - ), - "value_template": "{{ states.cover.test_state.state }}", - } - }, - } - }, - "cover.test_state", - None, - STATE_UNKNOWN, - {}, - ), - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_state_text_ignored_if_none_or_empty( - hass: HomeAssistant, - entity: str, - set_state: str, - test_state: str, - attr: dict[str, Any], - caplog: pytest.LogCaptureFixture, -) -> None: - """Test ignoring an empty state text of a template.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_UNKNOWN - - hass.states.async_set(entity, set_state, attributes=attr) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.state == test_state - assert "ERROR" not in caplog.text - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ 1 == 1 }}", - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_state_boolean(hass: HomeAssistant) -> None: - """Test the value_template attribute.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == CoverState.OPEN - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": ( - "{{ states.cover.test.attributes.position }}" - ), - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_position( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test the position_template attribute.""" - hass.states.async_set("cover.test", CoverState.OPEN) - attrs = {} - - for set_state, pos, test_state in ( - (CoverState.CLOSED, 42, CoverState.OPEN), - (CoverState.OPEN, 0.0, CoverState.CLOSED), - (CoverState.CLOSED, None, STATE_UNKNOWN), - ): - attrs["position"] = pos - hass.states.async_set("cover.test", set_state, attributes=attrs) - await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_position") == pos - assert state.state == test_state - assert "ValueError" not in caplog.text - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "optimistic": False, - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_not_optimistic(hass: HomeAssistant) -> None: - """Test the is_closed attribute.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_UNKNOWN - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - ("config", "tilt_position"), - [ - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ 1 == 1 }}", - "tilt_template": "{{ 42 }}", - } - }, - } - }, - 42.0, - ), - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ 1 == 1 }}", - "tilt_template": "{{ None }}", - } - }, - } - }, - None, - ), - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_tilt(hass: HomeAssistant, tilt_position: float | None) -> None: - """Test the tilt_template attribute.""" - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") == tilt_position - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": "{{ -1 }}", - "tilt_template": "{{ 110 }}", - } - }, - } - }, - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": "{{ on }}", - "tilt_template": ( - "{% if states.cover.test_state.state %}" - "on" - "{% else %}" - "off" - "{% endif %}" - ), - }, - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_out_of_bounds(hass: HomeAssistant) -> None: - """Test template out-of-bounds condition.""" - state = hass.states.get("cover.test_template_cover") - assert state.attributes.get("current_tilt_position") is None - assert state.attributes.get("current_position") is None - - -@pytest.mark.parametrize(("count", "domain"), [(0, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": {"test_template_cover": {"value_template": "{{ 1 == 1 }}"}}, - } - }, - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ 1 == 1 }}", - "open_cover": { - "service": "test.automation", - "data_template": { - "action": "open_cover", - "caller": "{{ this.entity_id }}", - }, - }, - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_template_open_or_position( - hass: HomeAssistant, caplog_setup_text -) -> None: - """Test that at least one of open_cover or set_position is used.""" - assert hass.states.async_all("cover") == [] - assert "Invalid config for 'cover' from integration 'template'" in caplog_setup_text - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": "{{ 0 }}", - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test the open_cover command.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == CoverState.CLOSED - - await hass.services.async_call( - COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - - assert len(calls) == 1 - assert calls[0].data["action"] == "open_cover" - assert calls[0].data["caller"] == "cover.test_template_cover" - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "position_template": "{{ 100 }}", - "stop_cover": { - "service": "test.automation", - "data_template": { - "action": "stop_cover", - "caller": "{{ this.entity_id }}", - }, - }, - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_close_stop_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test the close-cover and stop_cover commands.""" - state = hass.states.get("cover.test_template_cover") - assert state.state == CoverState.OPEN - - await hass.services.async_call( - COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - - await hass.services.async_call( - COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True - ) - await hass.async_block_till_done() - - assert len(calls) == 2 - assert calls[0].data["action"] == "close_cover" - assert calls[0].data["caller"] == "cover.test_template_cover" - assert calls[1].data["action"] == "stop_cover" - assert calls[1].data["caller"] == "cover.test_template_cover" - - -@pytest.mark.parametrize(("count", "domain"), [(1, "input_number")]) -@pytest.mark.parametrize( - "config", - [ - {"input_number": {"test": {"min": "0", "max": "100", "initial": "42"}}}, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_set_position(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test the set_position command.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( + with assert_setup_component(count, cover.DOMAIN): + assert await async_setup_component( hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "set_cover_position": { - "service": "test.automation", - "data_template": { - "action": "set_cover_position", - "caller": "{{ this.entity_id }}", - "position": "{{ position }}", - }, - }, - } - }, - } - }, + cover.DOMAIN, + config, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, cover_config: dict[str, Any] +) -> None: + """Do setup of cover integration via modern format.""" + config = {"template": {"cover": cover_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, ) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - state = hass.states.async_set("input_number.test", 42) + +async def async_setup_cover_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + cover_config: dict[str, Any], +) -> None: + """Do setup of cover integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, cover_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, cover_config) + + +@pytest.fixture +async def setup_cover( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + cover_config: dict[str, Any], +) -> None: + """Do setup of cover integration.""" + await async_setup_cover_config(hass, count, style, cover_config) + + +@pytest.fixture +async def setup_state_cover( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of cover integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **COVER_ACTIONS, + "value_template": state_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_COVER_ACTIONS, + "state": state_template, + }, + ) + + +@pytest.fixture +async def setup_position_cover( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + position_template: str, +): + """Do setup of cover integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **COVER_ACTIONS, + "position_template": position_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_COVER_ACTIONS, + "position": position_template, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_state_cover( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of cover integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **COVER_ACTIONS, + "value_template": state_template, + **extra, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_COVER_ACTIONS, + "state": state_template, + **extra, + }, + ) + + +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states.cover.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("set_state", "test_state", "text"), + [ + (CoverState.OPEN, CoverState.OPEN, ""), + (CoverState.CLOSED, CoverState.CLOSED, ""), + (CoverState.OPENING, CoverState.OPENING, ""), + (CoverState.CLOSING, CoverState.CLOSING, ""), + ("dog", STATE_UNKNOWN, "Received invalid cover is_on state: dog"), + ("cat", STATE_UNKNOWN, "Received invalid cover is_on state: cat"), + ("bear", STATE_UNKNOWN, "Received invalid cover is_on state: bear"), + ], +) +async def test_template_state_text( + hass: HomeAssistant, + set_state: str, + test_state: str, + text: str, + caplog: pytest.LogCaptureFixture, + setup_state_cover, +) -> None: + """Test the state text of a template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + hass.states.async_set(TEST_STATE_ENTITY_ID, set_state) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == test_state + assert text in caplog.text + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + "{{ states.cover.test_state.state }}", + "{{ states.cover.test_position.attributes.position }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "position_template"), + (ConfigurationStyle.MODERN, "position"), + ], +) +@pytest.mark.parametrize( + "states", + [ + ( + [ + (TEST_STATE_ENTITY_ID, CoverState.OPEN, STATE_UNKNOWN, "", None), + (TEST_STATE_ENTITY_ID, CoverState.CLOSED, STATE_UNKNOWN, "", None), + ( + TEST_STATE_ENTITY_ID, + CoverState.OPENING, + CoverState.OPENING, + "", + None, + ), + ( + TEST_STATE_ENTITY_ID, + CoverState.CLOSING, + CoverState.CLOSING, + "", + None, + ), + ("cover.test_position", CoverState.CLOSED, CoverState.CLOSING, "", 0), + (TEST_STATE_ENTITY_ID, CoverState.OPEN, CoverState.CLOSED, "", None), + ("cover.test_position", CoverState.CLOSED, CoverState.OPEN, "", 10), + ( + TEST_STATE_ENTITY_ID, + "dog", + CoverState.OPEN, + "Received invalid cover is_on state: dog", + None, + ), + ] + ) + ], +) +async def test_template_state_text_with_position( + hass: HomeAssistant, + states: list[tuple[str, str, str, int | None]], + caplog: pytest.LogCaptureFixture, + setup_single_attribute_state_cover, +) -> None: + """Test the state of a position template in order.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + for test_entity, set_state, test_state, text, position in states: + attrs = {"position": position} if position is not None else {} + + hass.states.async_set(test_entity, set_state, attrs) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == test_state + if position is not None: + assert state.attributes.get("current_position") == position + assert text in caplog.text + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + "{{ states.cover.test_state.state }}", + "{{ states.cover.test_position.attributes.position }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "position_template"), + (ConfigurationStyle.MODERN, "position"), + ], +) +@pytest.mark.parametrize( + "set_state", + [ + "", + None, + ], +) +async def test_template_state_text_ignored_if_none_or_empty( + hass: HomeAssistant, + set_state: str, + caplog: pytest.LogCaptureFixture, + setup_single_attribute_state_cover, +) -> None: + """Test ignoring an empty state text of a template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + hass.states.async_set(TEST_STATE_ENTITY_ID, set_state) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + assert "ERROR" not in caplog.text + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_template_state_boolean(hass: HomeAssistant, setup_state_cover) -> None: + """Test the value_template attribute.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == CoverState.OPEN + + +@pytest.mark.parametrize( + ("count", "position_template"), + [(1, "{{ states.cover.test_state.attributes.position }}")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("test_state", "position", "expected"), + [ + (CoverState.CLOSED, 42, CoverState.OPEN), + (CoverState.OPEN, 0.0, CoverState.CLOSED), + (CoverState.CLOSED, None, STATE_UNKNOWN), + ], +) +async def test_template_position( + hass: HomeAssistant, + test_state: str, + position: int | None, + expected: str, + caplog: pytest.LogCaptureFixture, + setup_position_cover, +) -> None: + """Test the position_template attribute.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, CoverState.OPEN) + await hass.async_block_till_done() + + hass.states.async_set( + TEST_STATE_ENTITY_ID, test_state, attributes={"position": position} + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("current_position") == position + assert state.state == expected + assert "ValueError" not in caplog.text + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "cover_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + **COVER_ACTIONS, + "optimistic": False, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + **NAMED_COVER_ACTIONS, + "optimistic": False, + }, + ), + ], +) +async def test_template_not_optimistic(hass: HomeAssistant, setup_cover) -> None: + """Test the is_closed attribute.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + ( + ConfigurationStyle.LEGACY, + "tilt_template", + ), + ( + ConfigurationStyle.MODERN, + "tilt", + ), + ], +) +@pytest.mark.parametrize( + ("attribute_template", "tilt_position"), + [ + ("{{ 1 }}", 1.0), + ("{{ 42 }}", 42.0), + ("{{ 100 }}", 100.0), + ("{{ None }}", None), + ("{{ 110 }}", None), + ("{{ -1 }}", None), + ("{{ 'on' }}", None), + ], +) +async def test_template_tilt( + hass: HomeAssistant, tilt_position: float | None, setup_single_attribute_state_cover +) -> None: + """Test tilt in and out-of-bound conditions.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("current_tilt_position") == tilt_position + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + ( + ConfigurationStyle.LEGACY, + "position_template", + ), + ( + ConfigurationStyle.MODERN, + "position", + ), + ], +) +@pytest.mark.parametrize( + "attribute_template", + [ + "{{ -1 }}", + "{{ 110 }}", + "{{ 'on' }}", + "{{ 'off' }}", + ], +) +async def test_position_out_of_bounds( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: + """Test position out-of-bounds condition.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("current_position") is None + + +@pytest.mark.parametrize("count", [0]) +@pytest.mark.parametrize( + ("style", "cover_config", "error"), + [ + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + "value_template": "{{ 1 == 1 }}", + } + }, + "Invalid config for 'cover' from integration 'template'", + ), + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + "value_template": "{{ 1 == 1 }}", + "open_cover": OPEN_COVER, + } + }, + "Invalid config for 'cover' from integration 'template'", + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "state": "{{ 1 == 1 }}", + }, + "Invalid config for 'template': must contain at least one of open_cover, set_cover_position.", + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "state": "{{ 1 == 1 }}", + "open_cover": OPEN_COVER, + }, + "Invalid config for 'template': some but not all values in the same group of inclusion 'open_or_close'", + ), + ], +) +async def test_template_open_or_position( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + cover_config: dict[str, Any], + error: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that at least one of open_cover or set_position is used.""" + await async_setup_cover_config(hass, count, style, cover_config) + assert hass.states.async_all("cover") == [] + assert error in caplog.text + + +@pytest.mark.parametrize( + ("count", "position_template"), + [(1, "{{ 0 }}")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_open_action( + hass: HomeAssistant, setup_position_cover, calls: list[ServiceCall] +) -> None: + """Test the open_cover command.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == CoverState.CLOSED + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["action"] == "open_cover" + assert calls[0].data["caller"] == TEST_ENTITY_ID + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "cover_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + **COVER_ACTIONS, + "position_template": "{{ 100 }}", + "stop_cover": { + "service": "test.automation", + "data_template": { + "action": "stop_cover", + "caller": "{{ this.entity_id }}", + }, + }, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + **NAMED_COVER_ACTIONS, + "position": "{{ 100 }}", + "stop_cover": { + "service": "test.automation", + "data_template": { + "action": "stop_cover", + "caller": "{{ this.entity_id }}", + }, + }, + }, + ), + ], +) +async def test_close_stop_action( + hass: HomeAssistant, setup_cover, calls: list[ServiceCall] +) -> None: + """Test the close-cover and stop_cover commands.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == CoverState.OPEN + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(calls) == 2 + assert calls[0].data["action"] == "close_cover" + assert calls[0].data["caller"] == TEST_ENTITY_ID + assert calls[1].data["action"] == "stop_cover" + assert calls[1].data["caller"] == TEST_ENTITY_ID + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "cover_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + "set_cover_position": SET_COVER_POSITION, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "set_cover_position": SET_COVER_POSITION, + }, + ), + ], +) +async def test_set_position( + hass: HomeAssistant, setup_cover, calls: list[ServiceCall] +) -> None: + """Test the set_position command.""" + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN await hass.services.async_call( - COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 100.0 assert len(calls) == 1 assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["position"] == 100 await hass.services.async_call( - COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 0.0 assert len(calls) == 2 assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["position"] == 0 await hass.services.async_call( - COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 100.0 assert len(calls) == 3 assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["position"] == 100 await hass.services.async_call( - COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 0.0 assert len(calls) == 4 assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["position"] == 0 await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 25}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_POSITION: 25}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 25.0 assert len(calls) == 5 assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["position"] == 25 -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("style", "cover_config"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "set_cover_tilt_position": { - "service": "test.automation", - "data_template": { - "action": "set_cover_tilt_position", - "caller": "{{ this.entity_id }}", - "tilt_position": "{{ tilt }}", - }, - }, - } - }, - } - }, + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + **COVER_ACTIONS, + "set_cover_tilt_position": SET_COVER_TILT_POSITION, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + **NAMED_COVER_ACTIONS, + "set_cover_tilt_position": SET_COVER_TILT_POSITION, + }, + ), ], ) @pytest.mark.parametrize( @@ -686,20 +806,20 @@ async def test_set_position(hass: HomeAssistant, calls: list[ServiceCall]) -> No [ ( SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_TILT_POSITION: 42}, 42, ), - (SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, 100), - (SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, 0), + (SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, 100), + (SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, 0), ], ) -@pytest.mark.usefixtures("start_ha") async def test_set_tilt_position( hass: HomeAssistant, service, attr, - calls: list[ServiceCall], tilt_position, + setup_cover, + calls: list[ServiceCall], ) -> None: """Test the set_tilt_position command.""" await hass.services.async_call( @@ -712,42 +832,46 @@ async def test_set_tilt_position( assert len(calls) == 1 assert calls[-1].data["action"] == "set_cover_tilt_position" - assert calls[-1].data["caller"] == "cover.test_template_cover" + assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["tilt_position"] == tilt_position -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("style", "cover_config"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - "set_cover_position": {"service": "test.automation"} - } - }, - } - }, + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + "set_cover_position": SET_COVER_POSITION, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "set_cover_position": SET_COVER_POSITION, + }, + ), ], ) -@pytest.mark.usefixtures("start_ha") async def test_set_position_optimistic( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, setup_cover, calls: list[ServiceCall] ) -> None: """Test optimistic position mode.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") is None await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 42}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_POSITION: 42}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_position") == 42.0 for service, test_state in ( @@ -757,47 +881,53 @@ async def test_set_position_optimistic( (SERVICE_TOGGLE, CoverState.OPEN), ): await hass.services.async_call( - COVER_DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, service, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == test_state -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("style", "cover_config"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - "position_template": "{{ 100 }}", - "set_cover_position": {"service": "test.automation"}, - "set_cover_tilt_position": {"service": "test.automation"}, - } - }, - } - }, + ( + ConfigurationStyle.LEGACY, + { + "test_template_cover": { + "position_template": "{{ 100 }}", + "set_cover_position": SET_COVER_POSITION, + "set_cover_tilt_position": SET_COVER_TILT_POSITION, + } + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "position": "{{ 100 }}", + "set_cover_position": SET_COVER_POSITION, + "set_cover_tilt_position": SET_COVER_TILT_POSITION, + }, + ), ], ) -@pytest.mark.usefixtures("calls", "start_ha") async def test_set_tilt_position_optimistic( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, setup_cover, calls: list[ServiceCall] ) -> None: """Test the optimistic tilt_position mode.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_tilt_position") is None await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_TILT_POSITION: 42}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_tilt_position") == 42.0 for service, pos in ( @@ -807,157 +937,140 @@ async def test_set_tilt_position_optimistic( (SERVICE_TOGGLE_COVER_TILT, 100.0), ): await hass.services.async_call( - COVER_DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, service, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True ) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("current_tilt_position") == pos -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ states.cover.test_state.state }}", - "icon_template": ( - "{% if states.cover.test_state.state %}mdi:check{% endif %}" - ), - } - }, - } - }, + ( + 1, + "{{ states.cover.test_state.state }}", + "{% if states.cover.test_state.state %}mdi:check{% endif %}", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_icon_template(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "icon_template"), + (ConfigurationStyle.MODERN, "icon"), + ], +) +async def test_icon_template( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: """Test icon template.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("icon") == "" state = hass.states.async_set("cover.test_state", CoverState.OPEN) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["icon"] == "mdi:check" -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ states.cover.test_state.state }}", - "entity_picture_template": ( - "{% if states.cover.test_state.state %}" - "/local/cover.png" - "{% endif %}" - ), - } - }, - } - }, + ( + 1, + "{{ states.cover.test_state.state }}", + "{% if states.cover.test_state.state %}/local/cover.png{% endif %}", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_entity_picture_template(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "entity_picture_template"), + (ConfigurationStyle.MODERN, "picture"), + ], +) +async def test_entity_picture_template( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: """Test icon template.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("entity_picture") == "" state = hass.states.async_set("cover.test_state", CoverState.OPEN) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["entity_picture"] == "/local/cover.png" -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "open", - "availability_template": ( - "{{ is_state('availability_state.state','on') }}" - ), - } - }, - } - }, + ( + 1, + "{{ 1 == 1 }}", + "{{ is_state('availability_state.state','on') }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_availability_template(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +async def test_availability_template( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: """Test availability template.""" hass.states.async_set("availability_state.state", STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("cover.test_template_cover").state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE hass.states.async_set("availability_state.state", STATE_ON) await hass.async_block_till_done() - assert hass.states.get("cover.test_template_cover").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("config", "domain"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "open", - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_availability_without_availability_template(hass: HomeAssistant) -> None: - """Test that component is available if there is no.""" - state = hass.states.get("cover.test_template_cover") - assert state.state != STATE_UNAVAILABLE - - -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "availability_template": "{{ x - 12 }}", - "value_template": "open", - } - }, - } - }, + ( + { + COVER_DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + **COVER_ACTIONS, + "availability_template": "{{ x - 12 }}", + "value_template": "open", + } + }, + } + }, + cover.DOMAIN, + ), + ( + { + "template": { + "cover": { + **NAMED_COVER_ACTIONS, + "state": "{{ true }}", + "availability": "{{ x - 12 }}", + }, + } + }, + template.DOMAIN, + ), ], ) @pytest.mark.usefixtures("start_ha") @@ -965,111 +1078,142 @@ async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog_setup_text ) -> None: """Test that an invalid availability keeps the device available.""" - assert hass.states.get("cover.test_template_cover") != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID) != STATE_UNAVAILABLE assert "UndefinedError: 'x' is undefined" in caplog_setup_text -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ states.cover.test_state.state }}", - "device_class": "door", - } - }, - } - }, - ], + ("count", "state_template", "attribute", "attribute_template"), + [(1, "{{ 1 == 1 }}", "device_class", "door")], ) -@pytest.mark.usefixtures("start_ha") -async def test_device_class(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +async def test_device_class( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: """Test device class.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("device_class") == "door" -@pytest.mark.parametrize(("count", "domain"), [(0, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **OPEN_CLOSE_COVER_CONFIG, - "value_template": "{{ states.cover.test_state.state }}", - "device_class": "barnacle_bill", - } - }, - } - }, - ], + ("count", "state_template", "attribute", "attribute_template"), + [(0, "{{ 1 == 1 }}", "device_class", "barnacle_bill")], ) -@pytest.mark.usefixtures("start_ha") -async def test_invalid_device_class(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +async def test_invalid_device_class( + hass: HomeAssistant, setup_single_attribute_state_cover +) -> None: """Test device class.""" - state = hass.states.get("cover.test_template_cover") + state = hass.states.get(TEST_ENTITY_ID) assert not state -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("cover_config", "style"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover_01": { - **OPEN_CLOSE_COVER_CONFIG, - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - }, - "test_template_cover_02": { - **OPEN_CLOSE_COVER_CONFIG, - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - }, + ( + { + "test_template_cover_01": UNIQUE_ID_CONFIG, + "test_template_cover_02": UNIQUE_ID_CONFIG, + }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_cover_01", + **UNIQUE_ID_CONFIG, }, - } - }, + { + "name": "test_template_cover_02", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id(hass: HomeAssistant, setup_cover) -> None: """Test unique_id option only creates one cover per id.""" assert len(hass.states.async_all()) == 1 -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "garage_door": { - **OPEN_CLOSE_COVER_CONFIG, - "friendly_name": "Garage Door", - "value_template": ( - "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}" - ), - }, +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a template unique_id propagates to switch unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "cover": [ + { + **COVER_ACTIONS, + "name": "test_a", + "unique_id": "a", + "state": "{{ true }}", + }, + { + **COVER_ACTIONS, + "name": "test_b", + "unique_id": "b", + "state": "{{ true }}", + }, + ], }, - } - }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("cover")) == 2 + + entry = entity_registry.async_get("cover.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("cover.test_b") + assert entry + assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "cover_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "garage_door": { + **COVER_ACTIONS, + "friendly_name": "Garage Door", + "value_template": "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}", + }, + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": "Garage Door", + **COVER_ACTIONS, + "state": "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}", + }, + ), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_state_gets_lowercased(hass: HomeAssistant) -> None: +async def test_state_gets_lowercased(hass: HomeAssistant, setup_cover) -> None: """Test True/False is lowercased.""" hass.states.async_set("binary_sensor.garage_door_sensor", "off") @@ -1083,43 +1227,76 @@ async def test_state_gets_lowercased(hass: HomeAssistant) -> None: assert hass.states.get("cover.garage_door").state == CoverState.CLOSED -@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "office": { - "icon_template": """{% if is_state('cover.office', 'open') %} - mdi:window-shutter-open - {% else %} - mdi:window-shutter - {% endif %}""", - "open_cover": { - "service": "switch.turn_on", - "entity_id": "switch.office_blinds_up", - }, - "close_cover": { - "service": "switch.turn_on", - "entity_id": "switch.office_blinds_down", - }, - "stop_cover": { - "service": "switch.turn_on", - "entity_id": "switch.office_blinds_up", - }, - }, - }, - } - }, + ( + 1, + "{{ states.cover.test_state.state }}", + "mdi:window-shutter{{ '-open' if is_state('cover.test_template_cover', 'open') else '' }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "icon_template"), + (ConfigurationStyle.MODERN, "icon"), ], ) -@pytest.mark.usefixtures("start_ha") async def test_self_referencing_icon_with_no_template_is_not_a_loop( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + setup_single_attribute_state_cover, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a self referencing icon with no value template is not a loop.""" assert len(hass.states.async_all()) == 1 assert "Template loop detected" not in caplog.text + + +@pytest.mark.parametrize( + ("script", "supported_feature"), + [ + ("stop_cover", CoverEntityFeature.STOP), + ("set_cover_position", CoverEntityFeature.SET_POSITION), + ( + "set_cover_tilt_position", + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, + ), + ], +) +async def test_emtpy_action_config( + hass: HomeAssistant, script: str, supported_feature: CoverEntityFeature +) -> None: + """Test configuration with empty script.""" + with assert_setup_component(1, COVER_DOMAIN): + assert await async_setup_component( + hass, + COVER_DOMAIN, + { + COVER_DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "open_cover": [], + "close_cover": [], + script: [], + } + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("cover.test_template_cover") + assert ( + state.attributes["supported_features"] + == CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | supported_feature + ) diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py new file mode 100644 index 00000000000..67a85839982 --- /dev/null +++ b/tests/components/template/test_entity.py @@ -0,0 +1,17 @@ +"""Test abstract template entity.""" + +import pytest + +from homeassistant.components.template import entity as abstract_entity +from homeassistant.core import HomeAssistant + + +async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: + """Test abstract template entity raises not implemented error.""" + + entity = abstract_entity.AbstractTemplateEntity(None) + with pytest.raises(NotImplementedError): + _ = entity.referenced_blueprint + + with pytest.raises(NotImplementedError): + entity._render_script_variables() diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index e92bc82f5ae..a061ce86256 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -1,9 +1,11 @@ """The tests for the Template fan platform.""" +from typing import Any + import pytest import voluptuous as vol -from homeassistant import setup +from homeassistant.components import fan, template from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, @@ -11,374 +13,817 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODE, DIRECTION_FORWARD, DIRECTION_REVERSE, - DOMAIN as FAN_DOMAIN, FanEntityFeature, NotValidPresetModeError, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import assert_setup_component from tests.components.fan import common -_TEST_FAN = "fan.test_fan" +TEST_OBJECT_ID = "test_fan" +TEST_ENTITY_ID = f"fan.{TEST_OBJECT_ID}" # Represent for fan's state _STATE_INPUT_BOOLEAN = "input_boolean.state" # Represent for fan's state _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" -# Represent for fan's preset mode -_PRESET_MODE_INPUT_SELECT = "input_select.preset_mode" -# Represent for fan's speed percentage -_PERCENTAGE_INPUT_NUMBER = "input_number.percentage" -# Represent for fan's oscillating -_OSC_INPUT = "input_select.osc" -# Represent for fan's direction -_DIRECTION_INPUT_SELECT = "input_select.direction" + +OPTIMISTIC_ON_OFF_ACTIONS = { + "turn_on": { + "service": "test.automation", + "data": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", + }, + }, + "turn_off": { + "service": "test.automation", + "data": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", + }, + }, +} +NAMED_ON_OFF_ACTIONS = { + **OPTIMISTIC_ON_OFF_ACTIONS, + "name": TEST_OBJECT_ID, +} + +PERCENTAGE_ACTION = { + "set_percentage": { + "action": "test.automation", + "data": { + "action": "set_percentage", + "percentage": "{{ percentage }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_PERCENTAGE_CONFIG = { + **OPTIMISTIC_ON_OFF_ACTIONS, + **PERCENTAGE_ACTION, +} + +PRESET_MODE_ACTION = { + "set_preset_mode": { + "action": "test.automation", + "data": { + "action": "set_preset_mode", + "preset_mode": "{{ preset_mode }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_PRESET_MODE_CONFIG = { + **OPTIMISTIC_ON_OFF_ACTIONS, + **PRESET_MODE_ACTION, +} +OPTIMISTIC_PRESET_MODE_CONFIG2 = { + **OPTIMISTIC_PRESET_MODE_CONFIG, + "preset_modes": ["auto", "low", "medium", "high"], +} + +OSCILLATE_ACTION = { + "set_oscillating": { + "action": "test.automation", + "data": { + "action": "set_oscillating", + "oscillating": "{{ oscillating }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_OSCILLATE_CONFIG = { + **OPTIMISTIC_ON_OFF_ACTIONS, + **OSCILLATE_ACTION, +} + +DIRECTION_ACTION = { + "set_direction": { + "action": "test.automation", + "data": { + "action": "set_direction", + "direction": "{{ direction }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_DIRECTION_CONFIG = { + **OPTIMISTIC_ON_OFF_ACTIONS, + **DIRECTION_ACTION, +} +UNIQUE_ID_CONFIG = { + **OPTIMISTIC_ON_OFF_ACTIONS, + "unique_id": "not-so-unique-anymore", +} -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ +def _verify( + hass: HomeAssistant, + expected_state: str, + expected_percentage: int | None = None, + expected_oscillating: bool | None = None, + expected_direction: str | None = None, + expected_preset_mode: str | None = None, +) -> None: + """Verify fan's state, speed and osc.""" + state = hass.states.get(TEST_ENTITY_ID) + attributes = state.attributes + assert state.state == str(expected_state) + assert attributes.get(ATTR_PERCENTAGE) == expected_percentage + assert attributes.get(ATTR_OSCILLATING) == expected_oscillating + assert attributes.get(ATTR_DIRECTION) == expected_direction + assert attributes.get(ATTR_PRESET_MODE) == expected_preset_mode + + +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, fan_config: dict[str, Any] +) -> None: + """Do setup of fan integration via legacy format.""" + config = {"fan": {"platform": "template", "fans": fan_config}} + + with assert_setup_component(count, fan.DOMAIN): + assert await async_setup_component( + hass, + fan.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, fan_config: dict[str, Any] +) -> None: + """Do setup of fan integration via modern format.""" + config = {"template": {"fan": fan_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_legacy_named_fan( + hass: HomeAssistant, count: int, fan_config: dict[str, Any] +): + """Do setup of a named fan via legacy format.""" + await async_setup_legacy_format(hass, count, {TEST_OBJECT_ID: fan_config}) + + +async def async_setup_modern_named_fan( + hass: HomeAssistant, count: int, fan_config: dict[str, Any] +): + """Do setup of a named fan via legacy format.""" + await async_setup_modern_format(hass, count, {"name": TEST_OBJECT_ID, **fan_config}) + + +async def async_setup_legacy_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a legacy fan that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_legacy_format( + hass, + count, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, + TEST_OBJECT_ID: { + **extra_config, + "value_template": "{{ 1 == 1 }}", + **extra, } }, - ], + ) + + +async def async_setup_modern_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a modern fan that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **extra_config, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) + + +@pytest.fixture +async def setup_fan( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + fan_config: dict[str, Any], +) -> None: + """Do setup of fan integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, fan_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, fan_config) + + +@pytest.fixture +async def setup_named_fan( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + fan_config: dict[str, Any], +) -> None: + """Do setup of fan integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_named_fan(hass, count, fan_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_named_fan(hass, count, fan_config) + + +@pytest.fixture +async def setup_state_fan( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of fan integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **OPTIMISTIC_ON_OFF_ACTIONS, + "value_template": state_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_ON_OFF_ACTIONS, + "state": state_template, + }, + ) + + +@pytest.fixture +async def setup_test_fan_with_extra_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + fan_config: dict[str, Any], + extra_config: dict[str, Any], +) -> None: + """Do setup of fan integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, count, {TEST_OBJECT_ID: {**fan_config, **extra_config}} + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": TEST_OBJECT_ID, **fan_config, **extra_config} + ) + + +@pytest.fixture +async def setup_optimistic_fan_attribute( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + extra_config: dict, +) -> None: + """Do setup of a non-optimistic fan with an optimistic attribute.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format_with_attribute( + hass, count, "", "", extra_config + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format_with_attribute( + hass, count, "", "", extra_config + ) + + +@pytest.fixture +async def setup_single_attribute_state_fan( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, + state_template: str, + extra_config: dict, +) -> None: + """Do setup of fan integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **OPTIMISTIC_ON_OFF_ACTIONS, + "value_template": state_template, + **extra, + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_ON_OFF_ACTIONS, + "state": state_template, + **extra, + **extra_config, + }, + ) + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 'on' }}")]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_fan") async def test_missing_optional_config(hass: HomeAssistant) -> None: """Test: missing optional template is ok.""" _verify(hass, STATE_ON, None, None, None, None) -@pytest.mark.parametrize(("count", "domain"), [(0, FAN_DOMAIN)]) +@pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "config", + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + "fan_config", [ { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "platform": "template", - "fans": { - "test_fan": { - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - }, - } + "value_template": "{{ 'on' }}", + "turn_off": {"service": "script.fan_off"}, }, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_off": {"service": "script.fan_off"}, - } - }, - }, - } - }, - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "turn_on": {"service": "script.fan_on"}, - } - }, - }, - } + "value_template": "{{ 'on' }}", + "turn_on": {"service": "script.fan_on"}, }, ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_fan") async def test_wrong_template_config(hass: HomeAssistant) -> None: - """Test: missing 'value_template' will fail.""" + """Test: missing 'turn_on' or 'turn_off' will fail.""" assert hass.states.async_all("fan") == [] -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": """ - {% if is_state('input_boolean.state', 'True') %} - {{ 'on' }} - {% else %} - {{ 'off' }} - {% endif %} - """, - "percentage_template": ( - "{{ states('input_number.percentage') }}" - ), - "preset_mode_template": ( - "{{ states('input_select.preset_mode') }}" - ), - "oscillating_template": "{{ states('input_select.osc') }}", - "direction_template": "{{ states('input_select.direction') }}", - "speed_count": "3", - "set_percentage": { - "service": "script.fans_set_speed", - "data_template": {"percentage": "{{ percentage }}"}, - }, - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ], + ("count", "state_template"), [(1, "{{ is_state('input_boolean.state', 'on') }}")] ) -@pytest.mark.usefixtures("start_ha") -async def test_templates_with_entities(hass: HomeAssistant) -> None: - """Test tempalates with values from other entities.""" - _verify(hass, STATE_OFF, 0, None, None, None) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_fan") +async def test_state_template(hass: HomeAssistant) -> None: + """Test state template.""" + _verify(hass, STATE_OFF, None, None, None, None) - hass.states.async_set(_STATE_INPUT_BOOLEAN, True) - hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 66) - hass.states.async_set(_OSC_INPUT, "True") - - for set_state, set_value, value in ( - (_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD, 66), - (_PERCENTAGE_INPUT_NUMBER, 33, 33), - (_PERCENTAGE_INPUT_NUMBER, 66, 66), - (_PERCENTAGE_INPUT_NUMBER, 100, 100), - (_PERCENTAGE_INPUT_NUMBER, "dog", 0), - ): - hass.states.async_set(set_state, set_value) - await hass.async_block_till_done() - _verify(hass, STATE_ON, value, True, DIRECTION_FORWARD, None) - - hass.states.async_set(_STATE_INPUT_BOOLEAN, False) + hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) await hass.async_block_till_done() - _verify(hass, STATE_OFF, 0, True, DIRECTION_FORWARD, None) + + _verify(hass, STATE_ON, None, None, None, None) + + hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_OFF) + await hass.async_block_till_done() + + _verify(hass, STATE_OFF, None, None, None, None) -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "entity", "tests"), + ("state_template", "expected"), + [ + ("{{ True }}", STATE_ON), + ("{{ False }}", STATE_OFF), + ("{{ x - 1 }}", STATE_UNAVAILABLE), + ("{{ 7.45 }}", STATE_OFF), + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_fan") +async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: + """Test state template.""" + _verify(hass, expected, None, None, None, None) + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), [ ( + 1, + "{{ 1 == 1}}", + "{% if states.input_boolean.state.state %}/local/switch.png{% endif %}", + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "picture"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_picture_template(hass: HomeAssistant) -> None: + """Test picture template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") in ("", None) + + hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["entity_picture"] == "/local/switch.png" + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 1 == 1}}", + "{% if states.input_boolean.state.state %}mdi:eye{% endif %}", + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "icon"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_icon_template(hass: HomeAssistant) -> None: + """Test icon template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") in ("", None) + + hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["icon"] == "mdi:eye" + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 1 == 1 }}", + "{{ states('sensor.percentage') }}", + PERCENTAGE_ACTION, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "percentage_template"), + (ConfigurationStyle.MODERN, "percentage"), + ], +) +@pytest.mark.parametrize( + ("percent", "expected"), + [ + ("0", 0), + ("33", 33), + ("invalid", 0), + ("5000", 0), + ("100", 100), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_percentage_template( + hass: HomeAssistant, percent: str, expected: int, calls: list[ServiceCall] +) -> None: + """Test templates with fan percentages from other entities.""" + hass.states.async_set("sensor.percentage", percent) + await hass.async_block_till_done() + _verify(hass, STATE_ON, expected, None, None, None) + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 1 == 1 }}", + "{{ states('sensor.preset_mode') }}", + {"preset_modes": ["auto", "smart"], **PRESET_MODE_ACTION}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "preset_mode_template"), + (ConfigurationStyle.MODERN, "preset_mode"), + ], +) +@pytest.mark.parametrize( + ("preset_mode", "expected"), + [ + ("0", None), + ("invalid", None), + ("auto", "auto"), + ("smart", "smart"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_preset_mode_template( + hass: HomeAssistant, preset_mode: str, expected: int +) -> None: + """Test preset_mode template.""" + hass.states.async_set("sensor.preset_mode", preset_mode) + await hass.async_block_till_done() + _verify(hass, STATE_ON, None, None, None, expected) + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 1 == 1 }}", + "{{ is_state('binary_sensor.oscillating', 'on') }}", + OSCILLATE_ACTION, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "oscillating_template"), + (ConfigurationStyle.MODERN, "oscillating"), + ], +) +@pytest.mark.parametrize( + ("oscillating", "expected"), + [ + (STATE_ON, True), + (STATE_OFF, False), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_oscillating_template( + hass: HomeAssistant, oscillating: str, expected: bool | None +) -> None: + """Test oscillating template.""" + hass.states.async_set("binary_sensor.oscillating", oscillating) + await hass.async_block_till_done() + _verify(hass, STATE_ON, None, expected, None, None) + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 1 == 1 }}", + "{{ states('sensor.direction') }}", + DIRECTION_ACTION, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "direction_template"), + (ConfigurationStyle.MODERN, "direction"), + ], +) +@pytest.mark.parametrize( + ("direction", "expected"), + [ + (DIRECTION_FORWARD, DIRECTION_FORWARD), + (DIRECTION_REVERSE, DIRECTION_REVERSE), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_fan") +async def test_direction_template( + hass: HomeAssistant, direction: str, expected: bool | None +) -> None: + """Test direction template.""" + hass.states.async_set("sensor.direction", direction) + await hass.async_block_till_done() + _verify(hass, STATE_ON, None, None, expected, None) + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "percentage_template": "{{ states('sensor.percentage') }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - }, - }, - } + "availability_template": ( + "{{ is_state('availability_boolean.state', 'on') }}" + ), + "value_template": "{{ 'on' }}", + "oscillating_template": "{{ 1 == 1 }}", + "direction_template": "{{ 'forward' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, }, - "sensor.percentage", - [ - ("0", 0, None), - ("33", 33, None), - ("invalid", 0, None), - ("5000", 0, None), - ("100", 100, None), - ("0", 0, None), - ], ), ( + ConfigurationStyle.MODERN, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "preset_modes": ["auto", "smart"], - "preset_mode_template": ( - "{{ states('sensor.preset_mode') }}" - ), - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - }, - }, - } + "availability": ("{{ is_state('availability_boolean.state', 'on') }}"), + "state": "{{ 'on' }}", + "oscillating": "{{ 1 == 1 }}", + "direction": "{{ 'forward' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, }, - "sensor.preset_mode", - [ - ("0", None, None), - ("invalid", None, None), - ("auto", None, "auto"), - ("smart", None, "smart"), - ("invalid", None, None), - ], ), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_templates_with_entities2(hass: HomeAssistant, entity, tests) -> None: - """Test templates with values from other entities.""" - for set_percentage, test_percentage, test_type in tests: - hass.states.async_set(entity, set_percentage) - await hass.async_block_till_done() - _verify(hass, STATE_ON, test_percentage, None, None, test_type) - - -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "availability_template": ( - "{{ is_state('availability_boolean.state', 'on') }}" - ), - "value_template": "{{ 'on' }}", - "oscillating_template": "{{ 1 == 1 }}", - "direction_template": "{{ 'forward' }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, - ], -) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_named_fan") async def test_availability_template_with_entities(hass: HomeAssistant) -> None: """Test availability tempalates with values from other entities.""" for state, test_assert in ((STATE_ON, True), (STATE_OFF, False)): hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, state) await hass.async_block_till_done() - assert (hass.states.get(_TEST_FAN).state != STATE_UNAVAILABLE) == test_assert + assert ( + hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + ) == test_assert -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "states"), + ("style", "fan_config", "states"), [ ( + ConfigurationStyle.LEGACY, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'unavailable' }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } + "value_template": "{{ 'unavailable' }}", + **OPTIMISTIC_ON_OFF_ACTIONS, }, [STATE_OFF, None, None, None], ), ( + ConfigurationStyle.MODERN, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "oscillating_template": "{{ 'unavailable' }}", - "direction_template": "{{ 'unavailable' }}", - "percentage_template": "{{ 0 }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } + "state": "{{ 'unavailable' }}", + **OPTIMISTIC_ON_OFF_ACTIONS, + }, + [STATE_OFF, None, None, None], + ), + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + "percentage_template": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 'unavailable' }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'unavailable' }}", + **DIRECTION_ACTION, }, [STATE_ON, 0, None, None], ), ( + ConfigurationStyle.MODERN, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "oscillating_template": "{{ 1 == 1 }}", - "direction_template": "{{ 'forward' }}", - "percentage_template": "{{ 66 }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } + "state": "{{ 'on' }}", + "percentage": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 'unavailable' }}", + **OSCILLATE_ACTION, + "direction": "{{ 'unavailable' }}", + **DIRECTION_ACTION, + }, + [STATE_ON, 0, None, None], + ), + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + "percentage_template": "{{ 66 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 1 == 1 }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'forward' }}", + **DIRECTION_ACTION, }, [STATE_ON, 66, True, DIRECTION_FORWARD], ), ( + ConfigurationStyle.MODERN, { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'abc' }}", - "oscillating_template": "{{ 'xyz' }}", - "direction_template": "{{ 'right' }}", - "percentage_template": "{{ 0 }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } + "state": "{{ 'on' }}", + "percentage": "{{ 66 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 1 == 1 }}", + **OSCILLATE_ACTION, + "direction": "{{ 'forward' }}", + **DIRECTION_ACTION, + }, + [STATE_ON, 66, True, DIRECTION_FORWARD], + ), + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'abc' }}", + "percentage_template": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 'xyz' }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'right' }}", + **DIRECTION_ACTION, + }, + [STATE_OFF, 0, None, None], + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'abc' }}", + "percentage": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 'xyz' }}", + **OSCILLATE_ACTION, + "direction": "{{ 'right' }}", + **DIRECTION_ACTION, }, [STATE_OFF, 0, None, None], ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_named_fan") async def test_template_with_unavailable_entities(hass: HomeAssistant, states) -> None: """Test unavailability with value_template.""" _verify(hass, states[0], states[1], states[2], states[3], None) -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + ("style", "fan_config"), [ - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_fan": { - "value_template": "{{ 'on' }}", - "availability_template": "{{ x - 12 }}", - "preset_mode_template": ( - "{{ states('input_select.preset_mode') }}" - ), - "oscillating_template": "{{ states('input_select.osc') }}", - "direction_template": "{{ states('input_select.direction') }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, - } - }, - } - }, + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + "availability_template": "{{ x - 12 }}", + "preset_mode_template": ("{{ states('input_select.preset_mode') }}"), + "oscillating_template": "{{ states('input_select.osc') }}", + "direction_template": "{{ states('input_select.direction') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + "availability": "{{ x - 12 }}", + "preset_mode": ("{{ states('input_select.preset_mode') }}"), + "oscillating": "{{ states('input_select.osc') }}", + "direction": "{{ states('input_select.direction') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_named_fan") async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog_setup_text ) -> None: @@ -388,147 +833,380 @@ async def test_invalid_availability_template_keeps_component_available( assert "x" in caplog_setup_text +@pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_ON_OFF_ACTIONS)]) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'off' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'off' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_on_off(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test turn on and turn off.""" - await _register_components(hass) - for expected_calls, (func, state, action) in enumerate( + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + for expected_calls, (func, action) in enumerate( [ - (common.async_turn_on, STATE_ON, "turn_on"), - (common.async_turn_off, STATE_OFF, "turn_off"), + (common.async_turn_on, "turn_on"), + (common.async_turn_off, "turn_off"), ] ): - await func(hass, _TEST_FAN) - assert hass.states.get(_STATE_INPUT_BOOLEAN).state == state - _verify(hass, state, 0, None, None, None) + await func(hass, TEST_ENTITY_ID) + assert len(calls) == expected_calls + 1 assert calls[-1].data["action"] == action - assert calls[-1].data["caller"] == _TEST_FAN + assert calls[-1].data["caller"] == TEST_ENTITY_ID -async def test_set_invalid_direction_from_initial_stage( +@pytest.mark.parametrize( + ("count", "extra_config"), + [ + ( + 1, + { + **OPTIMISTIC_ON_OFF_ACTIONS, + **OPTIMISTIC_PRESET_MODE_CONFIG2, + **OPTIMISTIC_PERCENTAGE_CONFIG, + }, + ) + ], +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'off' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'off' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +async def test_on_with_extra_attributes( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: + """Test turn on and turn off.""" + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + await common.async_turn_on(hass, TEST_ENTITY_ID, 100) + + assert len(calls) == 2 + assert calls[-2].data["action"] == "turn_on" + assert calls[-2].data["caller"] == TEST_ENTITY_ID + + assert calls[-1].data["action"] == "set_percentage" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["percentage"] == 100 + + await common.async_turn_off(hass, TEST_ENTITY_ID) + + assert len(calls) == 3 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + await common.async_turn_on(hass, TEST_ENTITY_ID, None, "auto") + + assert len(calls) == 5 + assert calls[-2].data["action"] == "turn_on" + assert calls[-2].data["caller"] == TEST_ENTITY_ID + + assert calls[-1].data["action"] == "set_preset_mode" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["preset_mode"] == "auto" + + await common.async_turn_off(hass, TEST_ENTITY_ID) + + assert len(calls) == 6 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + await common.async_turn_on(hass, TEST_ENTITY_ID, 50, "high") + + assert len(calls) == 9 + assert calls[-3].data["action"] == "turn_on" + assert calls[-3].data["caller"] == TEST_ENTITY_ID + + assert calls[-2].data["action"] == "set_preset_mode" + assert calls[-2].data["caller"] == TEST_ENTITY_ID + assert calls[-2].data["preset_mode"] == "high" + + assert calls[-1].data["action"] == "set_percentage" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["percentage"] == 50 + + await common.async_turn_off(hass, TEST_ENTITY_ID) + + assert len(calls) == 10 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +async def test_set_invalid_direction_from_initial_stage(hass: HomeAssistant) -> None: """Test set invalid direction when fan is in initial state.""" - await _register_components(hass) - - await common.async_turn_on(hass, _TEST_FAN) - - await common.async_set_direction(hass, _TEST_FAN, "invalid") - - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == "" - _verify(hass, STATE_ON, 0, None, None, None) + await common.async_set_direction(hass, TEST_ENTITY_ID, "invalid") + _verify(hass, STATE_ON, None, None, None, None) +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set oscillating.""" - await _register_components(hass) expected_calls = 0 - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) expected_calls += 1 for state in (True, False): - await common.async_oscillate(hass, _TEST_FAN, state) - assert hass.states.get(_OSC_INPUT).state == str(state) - _verify(hass, STATE_ON, 0, state, None, None) + await common.async_oscillate(hass, TEST_ENTITY_ID, state) + _verify(hass, STATE_ON, None, state, None, None) expected_calls += 1 assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_oscillating" - assert calls[-1].data["caller"] == _TEST_FAN - assert calls[-1].data["option"] == state + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["oscillating"] == state +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_direction(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid direction.""" - await _register_components(hass) expected_calls = 0 - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) expected_calls += 1 - for cmd in (DIRECTION_FORWARD, DIRECTION_REVERSE): - await common.async_set_direction(hass, _TEST_FAN, cmd) - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == cmd - _verify(hass, STATE_ON, 0, None, cmd, None) + for direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): + await common.async_set_direction(hass, TEST_ENTITY_ID, direction) + _verify(hass, STATE_ON, None, None, direction, None) expected_calls += 1 assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_direction" - assert calls[-1].data["caller"] == _TEST_FAN - assert calls[-1].data["option"] == cmd + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["direction"] == direction +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_invalid_direction( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set invalid direction when fan has valid direction.""" - await _register_components(hass) - - await common.async_turn_on(hass, _TEST_FAN) - for cmd in (DIRECTION_FORWARD, "invalid"): - await common.async_set_direction(hass, _TEST_FAN, cmd) - assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, 0, None, DIRECTION_FORWARD, None) + expected_calls = 1 + for direction in (DIRECTION_FORWARD, "invalid"): + await common.async_set_direction(hass, TEST_ENTITY_ID, direction) + _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD, None) + assert len(calls) == expected_calls + assert calls[-1].data["action"] == "set_direction" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["direction"] == DIRECTION_FORWARD +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, OPTIMISTIC_PRESET_MODE_CONFIG2)] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_preset_modes(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test preset_modes.""" - await _register_components( - hass, ["off", "low", "medium", "high", "auto", "smart"], ["auto", "smart"] - ) - - await common.async_turn_on(hass, _TEST_FAN) - for extra, state, expected_calls in ( - ("auto", "auto", 2), - ("smart", "smart", 3), - ("invalid", "smart", 3), - ): - if extra != state: + expected_calls = 0 + valid_modes = OPTIMISTIC_PRESET_MODE_CONFIG2["preset_modes"] + for mode in ("auto", "low", "medium", "high", "invalid", "smart"): + if mode not in valid_modes: with pytest.raises(NotValidPresetModeError): - await common.async_set_preset_mode(hass, _TEST_FAN, extra) + await common.async_set_preset_mode(hass, TEST_ENTITY_ID, mode) else: - await common.async_set_preset_mode(hass, _TEST_FAN, extra) - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == state - assert len(calls) == expected_calls - assert calls[-1].data["action"] == "set_preset_mode" - assert calls[-1].data["caller"] == _TEST_FAN - assert calls[-1].data["option"] == state + await common.async_set_preset_mode(hass, TEST_ENTITY_ID, mode) + expected_calls += 1 - await common.async_turn_on(hass, _TEST_FAN, preset_mode="auto") - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" + assert len(calls) == expected_calls + assert calls[-1].data["action"] == "set_preset_mode" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["preset_mode"] == mode +@pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_PERCENTAGE_CONFIG)]) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_percentage(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid speed percentage.""" - await _register_components(hass) expected_calls = 0 - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) expected_calls += 1 for state, value in ( (STATE_ON, 100), (STATE_ON, 66), (STATE_ON, 0), ): - await common.async_set_percentage(hass, _TEST_FAN, value) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + await common.async_set_percentage(hass, TEST_ENTITY_ID, value) _verify(hass, state, value, None, None, None) expected_calls += 1 assert len(calls) == expected_calls - assert calls[-1].data["action"] == "set_value" - assert calls[-1].data["caller"] == _TEST_FAN - assert calls[-1].data["value"] == value + assert calls[-1].data["action"] == "set_percentage" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["percentage"] == value - await common.async_turn_on(hass, _TEST_FAN, percentage=50) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 50 + await common.async_turn_on(hass, TEST_ENTITY_ID, percentage=50) _verify(hass, STATE_ON, 50, None, None, None) +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {"speed_count": 3, **OPTIMISTIC_PERCENTAGE_CONFIG})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_increase_decrease_speed( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set valid increase and decrease speed.""" - await _register_components(hass, speed_count=3) - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) for func, extra, state, value in ( (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 66), @@ -536,129 +1214,183 @@ async def test_increase_decrease_speed( (common.async_decrease_speed, None, STATE_ON, 0), (common.async_increase_speed, None, STATE_ON, 33), ): - await func(hass, _TEST_FAN, extra) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + await func(hass, TEST_ENTITY_ID, extra) _verify(hass, state, value, None, None, None) -async def test_no_value_template(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test a fan without a value_template.""" - await _register_fan_sources(hass) - - with assert_setup_component(1, "fan"): - test_fan_config = { - "preset_mode_template": "{{ states('input_select.preset_mode') }}", - "preset_modes": ["auto"], - "percentage_template": "{{ states('input_number.percentage') }}", - "oscillating_template": "{{ states('input_select.osc') }}", - "direction_template": "{{ states('input_select.direction') }}", - "turn_on": [ - { - "service": "input_boolean.turn_on", - "entity_id": _STATE_INPUT_BOOLEAN, - }, - { - "service": "test.automation", - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "turn_off": [ - { - "service": "input_boolean.turn_off", - "entity_id": _STATE_INPUT_BOOLEAN, - }, - { - "service": "test.automation", - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "set_preset_mode": [ - { - "service": "input_select.select_option", - "data_template": { - "entity_id": _PRESET_MODE_INPUT_SELECT, - "option": "{{ preset_mode }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_preset_mode", - "caller": "{{ this.entity_id }}", - "option": "{{ preset_mode }}", - }, - }, - ], - "set_percentage": [ - { - "service": "input_number.set_value", - "data_template": { - "entity_id": _PERCENTAGE_INPUT_NUMBER, - "value": "{{ percentage }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_value", - "caller": "{{ this.entity_id }}", - "value": "{{ percentage }}", - }, - }, - ], - } - assert await setup.async_setup_component( - hass, - "fan", - {"fan": {"platform": "template", "fans": {"test_fan": test_fan_config}}}, +@pytest.mark.parametrize( + ("count", "fan_config"), + [ + ( + 1, + { + **OPTIMISTIC_ON_OFF_ACTIONS, + "preset_modes": ["auto"], + **PRESET_MODE_ACTION, + **PERCENTAGE_ACTION, + **OSCILLATE_ACTION, + **DIRECTION_ACTION, + }, ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +@pytest.mark.usefixtures("setup_named_fan") +async def test_optimistic_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: + """Test a fan without a value_template.""" - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() + await common.async_turn_on(hass, TEST_ENTITY_ID) + _verify(hass, STATE_ON) - await common.async_turn_on(hass, _TEST_FAN) - _verify(hass, STATE_ON, 0, None, None, "auto") + assert len(calls) == 1 + assert calls[-1].data["action"] == "turn_on" + assert calls[-1].data["caller"] == TEST_ENTITY_ID - await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, 0, None, None, "auto") + await common.async_turn_off(hass, TEST_ENTITY_ID) + _verify(hass, STATE_OFF) + + assert len(calls) == 2 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == TEST_ENTITY_ID percent = 100 - await common.async_set_percentage(hass, _TEST_FAN, percent) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == percent - _verify(hass, STATE_ON, percent, None, None, "auto") + await common.async_set_percentage(hass, TEST_ENTITY_ID, percent) + _verify(hass, STATE_ON, percent) - await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, percent, None, None, "auto") + assert len(calls) == 3 + assert calls[-1].data["action"] == "set_percentage" + assert calls[-1].data["percentage"] == 100 + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + await common.async_turn_off(hass, TEST_ENTITY_ID) + _verify(hass, STATE_OFF, percent) + + assert len(calls) == 4 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == TEST_ENTITY_ID preset = "auto" - await common.async_set_preset_mode(hass, _TEST_FAN, preset) - assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == preset + await common.async_set_preset_mode(hass, TEST_ENTITY_ID, preset) _verify(hass, STATE_ON, percent, None, None, preset) - await common.async_turn_off(hass, _TEST_FAN) + assert len(calls) == 5 + assert calls[-1].data["action"] == "set_preset_mode" + assert calls[-1].data["preset_mode"] == preset + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + await common.async_turn_off(hass, TEST_ENTITY_ID) _verify(hass, STATE_OFF, percent, None, None, preset) - await common.async_set_direction(hass, _TEST_FAN, True) - _verify(hass, STATE_OFF, percent, None, None, preset) + assert len(calls) == 6 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == TEST_ENTITY_ID - await common.async_oscillate(hass, _TEST_FAN, True) - _verify(hass, STATE_OFF, percent, None, None, preset) + await common.async_set_direction(hass, TEST_ENTITY_ID, DIRECTION_FORWARD) + _verify(hass, STATE_OFF, percent, None, DIRECTION_FORWARD, preset) + + assert len(calls) == 7 + assert calls[-1].data["action"] == "set_direction" + assert calls[-1].data["direction"] == DIRECTION_FORWARD + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + await common.async_oscillate(hass, TEST_ENTITY_ID, True) + _verify(hass, STATE_OFF, percent, True, DIRECTION_FORWARD, preset) + + assert len(calls) == 8 + assert calls[-1].data["action"] == "set_oscillating" + assert calls[-1].data["oscillating"] is True + assert calls[-1].data["caller"] == TEST_ENTITY_ID +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("extra_config", "attribute", "action", "verify_attr", "coro", "value"), + [ + ( + OPTIMISTIC_PERCENTAGE_CONFIG, + "percentage", + "set_percentage", + "expected_percentage", + common.async_set_percentage, + 50, + ), + ( + OPTIMISTIC_PRESET_MODE_CONFIG2, + "preset_mode", + "set_preset_mode", + "expected_preset_mode", + common.async_set_preset_mode, + "auto", + ), + ( + OPTIMISTIC_OSCILLATE_CONFIG, + "oscillating", + "set_oscillating", + "expected_oscillating", + common.async_oscillate, + True, + ), + ( + OPTIMISTIC_DIRECTION_CONFIG, + "direction", + "set_direction", + "expected_direction", + common.async_set_direction, + DIRECTION_FORWARD, + ), + ], +) +@pytest.mark.usefixtures("setup_optimistic_fan_attribute") +async def test_optimistic_attributes( + hass: HomeAssistant, + attribute: str, + action: str, + verify_attr: str, + coro, + value: Any, + calls: list[ServiceCall], +) -> None: + """Test setting percentage with optimistic template.""" + + await coro(hass, TEST_ENTITY_ID, value) + _verify(hass, STATE_ON, **{verify_attr: value}) + + assert len(calls) == 1 + assert calls[-1].data["action"] == action + assert calls[-1].data[attribute] == value + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + +@pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_PERCENTAGE_CONFIG)]) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_increase_decrease_speed_default_speed_count( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set valid increase and decrease speed.""" - await _register_components(hass) - - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) for func, extra, state, value in ( (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 99), @@ -666,430 +1398,249 @@ async def test_increase_decrease_speed_default_speed_count( (common.async_decrease_speed, 31, STATE_ON, 67), (common.async_decrease_speed, None, STATE_ON, 66), ): - await func(hass, _TEST_FAN, extra) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value + await func(hass, TEST_ENTITY_ID, extra) _verify(hass, state, value, None, None, None) +@pytest.mark.parametrize( + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), + ], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_invalid_osc_from_initial_state( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set invalid oscillating when fan is in initial state.""" - await _register_components(hass) - - await common.async_turn_on(hass, _TEST_FAN) + await common.async_turn_on(hass, TEST_ENTITY_ID) with pytest.raises(vol.Invalid): - await common.async_oscillate(hass, _TEST_FAN, "invalid") - assert hass.states.get(_OSC_INPUT).state == "" - _verify(hass, STATE_ON, 0, None, None, None) + await common.async_oscillate(hass, TEST_ENTITY_ID, "invalid") + _verify(hass, STATE_ON, None, None, None, None) -async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test set invalid oscillating when fan has valid osc.""" - await _register_components(hass) - - await common.async_turn_on(hass, _TEST_FAN) - await common.async_oscillate(hass, _TEST_FAN, True) - assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, 0, True, None, None) - - with pytest.raises(vol.Invalid): - await common.async_oscillate(hass, _TEST_FAN, None) - assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, 0, True, None, None) - - -def _verify( - hass: HomeAssistant, - expected_state: str, - expected_percentage: int | None, - expected_oscillating: bool | None, - expected_direction: str | None, - expected_preset_mode: str | None, -) -> None: - """Verify fan's state, speed and osc.""" - state = hass.states.get(_TEST_FAN) - attributes = state.attributes - assert state.state == str(expected_state) - assert attributes.get(ATTR_PERCENTAGE) == expected_percentage - assert attributes.get(ATTR_OSCILLATING) == expected_oscillating - assert attributes.get(ATTR_DIRECTION) == expected_direction - assert attributes.get(ATTR_PRESET_MODE) == expected_preset_mode - - -async def _register_fan_sources(hass: HomeAssistant) -> None: - with assert_setup_component(1, "input_boolean"): - assert await setup.async_setup_component( - hass, "input_boolean", {"input_boolean": {"state": None}} - ) - - with assert_setup_component(1, "input_number"): - assert await setup.async_setup_component( - hass, - "input_number", - { - "input_number": { - "percentage": { - "min": 0.0, - "max": 100.0, - "name": "Percentage", - "step": 1.0, - "mode": "slider", - } - } - }, - ) - - with assert_setup_component(3, "input_select"): - assert await setup.async_setup_component( - hass, - "input_select", - { - "input_select": { - "preset_mode": { - "name": "Preset Mode", - "options": ["auto", "smart"], - }, - "osc": {"name": "oscillating", "options": ["", "True", "False"]}, - "direction": { - "name": "Direction", - "options": ["", DIRECTION_FORWARD, DIRECTION_REVERSE], - }, - } - }, - ) - - -async def _register_components( - hass: HomeAssistant, - speed_list: list[str] | None = None, - preset_modes: list[str] | None = None, - speed_count: int | None = None, -) -> None: - """Register basic components for testing.""" - await _register_fan_sources(hass) - - with assert_setup_component(1, "fan"): - value_template = """ - {% if is_state('input_boolean.state', 'on') %} - {{ 'on' }} - {% else %} - {{ 'off' }} - {% endif %} - """ - - test_fan_config = { - "value_template": value_template, - "preset_mode_template": "{{ states('input_select.preset_mode') }}", - "percentage_template": "{{ states('input_number.percentage') }}", - "oscillating_template": "{{ states('input_select.osc') }}", - "direction_template": "{{ states('input_select.direction') }}", - "turn_on": [ - { - "service": "input_boolean.turn_on", - "entity_id": _STATE_INPUT_BOOLEAN, - }, - { - "service": "test.automation", - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "turn_off": [ - { - "service": "input_boolean.turn_off", - "entity_id": _STATE_INPUT_BOOLEAN, - }, - { - "service": "input_number.set_value", - "data_template": { - "entity_id": _PERCENTAGE_INPUT_NUMBER, - "value": 0, - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "set_preset_mode": [ - { - "service": "input_select.select_option", - "data_template": { - "entity_id": _PRESET_MODE_INPUT_SELECT, - "option": "{{ preset_mode }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_preset_mode", - "caller": "{{ this.entity_id }}", - "option": "{{ preset_mode }}", - }, - }, - ], - "set_percentage": [ - { - "service": "input_number.set_value", - "data_template": { - "entity_id": _PERCENTAGE_INPUT_NUMBER, - "value": "{{ percentage }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_value", - "caller": "{{ this.entity_id }}", - "value": "{{ percentage }}", - }, - }, - ], - "set_oscillating": [ - { - "service": "input_select.select_option", - "data_template": { - "entity_id": _OSC_INPUT, - "option": "{{ oscillating }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_oscillating", - "caller": "{{ this.entity_id }}", - "option": "{{ oscillating }}", - }, - }, - ], - "set_direction": [ - { - "service": "input_select.select_option", - "data_template": { - "entity_id": _DIRECTION_INPUT_SELECT, - "option": "{{ direction }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_direction", - "caller": "{{ this.entity_id }}", - "option": "{{ direction }}", - }, - }, - ], - } - - if preset_modes: - test_fan_config["preset_modes"] = preset_modes - - if speed_count: - test_fan_config["speed_count"] = speed_count - - assert await setup.async_setup_component( - hass, - "fan", - {"fan": {"platform": "template", "fans": {"test_fan": test_fan_config}}}, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] +) +@pytest.mark.parametrize( + ("style", "fan_config"), [ - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "test_template_fan_01": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - "turn_on": { - "service": "fan.turn_on", - "entity_id": "fan.test_state", - }, - "turn_off": { - "service": "fan.turn_off", - "entity_id": "fan.test_state", - }, - }, - "test_template_fan_02": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - "turn_on": { - "service": "fan.turn_on", - "entity_id": "fan.test_state", - }, - "turn_off": { - "service": "fan.turn_off", - "entity_id": "fan.test_state", - }, - }, - }, - } - }, + ( + ConfigurationStyle.LEGACY, + { + "value_template": "{{ 'on' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "state": "{{ 'on' }}", + }, + ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: + """Test set invalid oscillating when fan has valid osc.""" + await common.async_turn_on(hass, TEST_ENTITY_ID) + await common.async_oscillate(hass, TEST_ENTITY_ID, True) + _verify(hass, STATE_ON, None, True, None, None) + + await common.async_oscillate(hass, TEST_ENTITY_ID, False) + _verify(hass, STATE_ON, None, False, None, None) + + with pytest.raises(vol.Invalid): + await common.async_oscillate(hass, TEST_ENTITY_ID, None) + _verify(hass, STATE_ON, None, False, None, None) + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("fan_config", "style"), + [ + ( + { + "test_template_cover_01": UNIQUE_ID_CONFIG, + "test_template_cover_02": UNIQUE_ID_CONFIG, + }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_cover_01", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_cover_02", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), + ], +) +@pytest.mark.usefixtures("setup_fan") async def test_unique_id(hass: HomeAssistant) -> None: """Test unique_id option only creates one fan per id.""" assert len(hass.states.async_all()) == 1 @pytest.mark.parametrize( - ("speed_count", "percentage_step"), [(0, 1), (100, 1), (3, 100 / 3)] + ("count", "extra_config"), + [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OPTIMISTIC_PERCENTAGE_CONFIG})], ) -async def test_implemented_percentage( - hass: HomeAssistant, speed_count, percentage_step -) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +@pytest.mark.parametrize( + ("fan_config", "percentage_step"), + [({"speed_count": 0}, 1), ({"speed_count": 100}, 1), ({"speed_count": 3}, 100 / 3)], +) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +async def test_speed_percentage_step(hass: HomeAssistant, percentage_step) -> None: """Test a fan that implements percentage.""" - await setup.async_setup_component( - hass, - "fan", - { - "fan": { - "platform": "template", - "fans": { - "mechanical_ventilation": { - "friendly_name": "Mechanische ventilatie", - "unique_id": "a2fd2e38-674b-4b47-b5ef-cc2362211a72", - "value_template": "{{ states('light.mv_snelheid') }}", - "percentage_template": ( - "{{ (state_attr('light.mv_snelheid','brightness') | int /" - " 255 * 100) | int }}" - ), - "turn_on": [ - { - "service": "switch.turn_off", - "target": { - "entity_id": "switch.mv_automatisch", - }, - }, - { - "service": "light.turn_on", - "target": { - "entity_id": "light.mv_snelheid", - }, - "data": {"brightness_pct": 40}, - }, - ], - "turn_off": [ - { - "service": "light.turn_off", - "target": { - "entity_id": "light.mv_snelheid", - }, - }, - { - "service": "switch.turn_on", - "target": { - "entity_id": "switch.mv_automatisch", - }, - }, - ], - "set_percentage": [ - { - "service": "light.turn_on", - "target": { - "entity_id": "light.mv_snelheid", - }, - "data": {"brightness_pct": "{{ percentage }}"}, - } - ], - "speed_count": speed_count, - }, - }, - }, - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 - state = hass.states.get("fan.mechanical_ventilation") + state = hass.states.get(TEST_ENTITY_ID) attributes = state.attributes assert attributes["percentage_step"] == percentage_step assert attributes.get("supported_features") & FanEntityFeature.SET_SPEED -@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - FAN_DOMAIN: { - "platform": "template", - "fans": { - "mechanical_ventilation": { - "friendly_name": "Mechanische ventilatie", - "unique_id": "a2fd2e38-674b-4b47-b5ef-cc2362211a72", - "value_template": "{{ states('light.mv_snelheid') }}", - "preset_mode_template": "{{ 'any' }}", - "preset_modes": ["any"], - "set_preset_mode": [ - { - "service": "light.turn_on", - "target": { - "entity_id": "light.mv_snelheid", - }, - "data": {"brightness_pct": "{{ percentage }}"}, - } - ], - "turn_on": [ - { - "service": "switch.turn_off", - "target": { - "entity_id": "switch.mv_automatisch", - }, - }, - { - "service": "light.turn_on", - "target": { - "entity_id": "light.mv_snelheid", - }, - "data": {"brightness_pct": 40}, - }, - ], - "turn_off": [ - { - "service": "light.turn_off", - "target": { - "entity_id": "light.mv_snelheid", - }, - }, - { - "service": "switch.turn_on", - "target": { - "entity_id": "switch.mv_automatisch", - }, - }, - ], - }, - }, - } - }, - ], + ("count", "fan_config"), + [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OPTIMISTIC_PRESET_MODE_CONFIG2})], ) -@pytest.mark.usefixtures("start_ha") -async def test_implemented_preset_mode(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +@pytest.mark.usefixtures("setup_named_fan") +async def test_preset_mode_supported_features(hass: HomeAssistant) -> None: """Test a fan that implements preset_mode.""" assert len(hass.states.async_all()) == 1 - state = hass.states.get("fan.mechanical_ventilation") + state = hass.states.get(TEST_ENTITY_ID) attributes = state.attributes - assert attributes.get("percentage") is None assert attributes.get("supported_features") & FanEntityFeature.PRESET_MODE + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "turn_on": [], + "turn_off": [], + }, + ), + ( + ConfigurationStyle.MODERN, + { + "turn_on": [], + "turn_off": [], + }, + ), + ], +) +@pytest.mark.parametrize( + ("extra_config", "supported_features"), + [ + ( + { + "set_percentage": [], + }, + FanEntityFeature.SET_SPEED, + ), + ( + { + "set_preset_mode": [], + }, + FanEntityFeature.PRESET_MODE, + ), + ( + { + "set_oscillating": [], + }, + FanEntityFeature.OSCILLATE, + ), + ( + { + "set_direction": [], + }, + FanEntityFeature.DIRECTION, + ), + ], +) +async def test_empty_action_config( + hass: HomeAssistant, + supported_features: FanEntityFeature, + setup_test_fan_with_extra_config, +) -> None: + """Test configuration with empty script.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["supported_features"] == ( + FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON | supported_features + ) + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a template unique_id propagates to switch unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "fan": [ + { + **OPTIMISTIC_ON_OFF_ACTIONS, + "name": "test_a", + "unique_id": "a", + "state": "{{ true }}", + }, + { + **OPTIMISTIC_ON_OFF_ACTIONS, + "name": "test_b", + "unique_id": "b", + "state": "{{ true }}", + }, + ], + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("fan")) == 2 + + entry = entity_registry.async_get("fan.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("fan.test_b") + assert entry + assert entry.unique_id == "x-b" diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index a94ec233f81..f240c2412e0 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -4,7 +4,7 @@ from typing import Any import pytest -from homeassistant.components import light +from homeassistant.components import light, template from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -17,6 +17,7 @@ from homeassistant.components.light import ( ColorMode, LightEntityFeature, ) +from homeassistant.components.template.light import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -24,10 +25,15 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component +from .conftest import ConfigurationStyle + from tests.common import assert_setup_component # Represent for light's availability @@ -154,10 +160,259 @@ OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG = { } -async def async_setup_light( +TEST_STATE_TRIGGER = { + "trigger": {"trigger": "state", "entity_id": "light.test_state"}, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [{"event": "action_event", "event_data": {"what": "triggering_entity"}}], +} + + +TEST_EVENT_TRIGGER = { + "trigger": {"platform": "event", "event_type": "test_event"}, + "variables": {"type": "{{ trigger.event.data.type }}"}, + "action": [{"event": "action_event", "event_data": {"type": "{{ type }}"}}], +} + + +TEST_MISSING_KEY_CONFIG = { + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, +} + + +TEST_ON_ACTION_WITH_TRANSITION_CONFIG = { + "turn_on": { + "service": "test.automation", + "data_template": { + "transition": "{{transition}}", + }, + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + "transition": "{{transition}}", + }, + }, +} + + +TEST_OFF_ACTION_WITH_TRANSITION_CONFIG = { + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "test.automation", + "data_template": { + "transition": "{{transition}}", + }, + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + "transition": "{{transition}}", + }, + }, +} + + +TEST_ALL_COLORS_NO_TEMPLATE_CONFIG = { + "set_hs": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "h": "{{h}}", + "s": "{{s}}", + }, + }, + "set_temperature": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "color_temp": "{{color_temp}}", + }, + }, + "set_rgb": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + }, + }, + "set_rgbw": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "w": "{{w}}", + }, + }, + "set_rgbww": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "cw": "{{cw}}", + "ww": "{{ww}}", + }, + }, +} + + +TEST_UNIQUE_ID_CONFIG = { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "unique_id": "not-so-unique-anymore", +} + + +@pytest.mark.parametrize( + ("old_attr", "new_attr", "attr_template"), + [ + ( + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + "rgb_template", + "rgb", + "{{ (255,255,255) }}", + ), + ( + "rgbw_template", + "rgbw", + "{{ (255,255,255,255) }}", + ), + ( + "rgbww_template", + "rgbww", + "{{ (255,255,255,255,255) }}", + ), + ( + "effect_list_template", + "effect_list", + "{{ ['a', 'b'] }}", + ), + ( + "effect_template", + "effect", + "{{ 'a' }}", + ), + ( + "level_template", + "level", + "{{ 255 }}", + ), + ( + "max_mireds_template", + "max_mireds", + "{{ 255 }}", + ), + ( + "min_mireds_template", + "min_mireds", + "{{ 255 }}", + ), + ( + "supports_transition_template", + "supports_transition", + "{{ True }}", + ), + ( + "temperature_template", + "temperature", + "{{ 255 }}", + ), + ( + "white_value_template", + "white_value", + "{{ 255 }}", + ), + ( + "hs_template", + "hs", + "{{ (255, 255) }}", + ), + ( + "color_template", + "hs", + "{{ (255, 255) }}", + ), + ], +) +async def test_legacy_to_modern_config( + hass: HomeAssistant, old_attr: str, new_attr: str, attr_template: str +) -> None: + """Test the conversion of legacy template to modern template.""" + config = { + "foo": { + "friendly_name": "foo bar", + "unique_id": "foo-bar-light", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + old_attr: attr_template, + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + } + } + altered_configs = rewrite_legacy_to_modern_conf(hass, config) + + assert len(altered_configs) == 1 + + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "turn_off": { + "data_template": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", + }, + "service": "test.automation", + }, + "turn_on": { + "data_template": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", + }, + "service": "test.automation", + }, + "unique_id": "foo-bar-light", + new_attr: Template(attr_template, hass), + } + ] == altered_configs + + +async def async_setup_legacy_format( hass: HomeAssistant, count: int, light_config: dict[str, Any] ) -> None: - """Do setup of light integration.""" + """Do setup of light integration via legacy format.""" config = {"light": {"platform": "template", "lights": light_config}} with assert_setup_component(count, light.DOMAIN): @@ -172,12 +427,430 @@ async def async_setup_light( await hass.async_block_till_done() -@pytest.fixture -async def setup_light( +async def async_setup_legacy_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a legacy light that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **extra_config, + "value_template": "{{ 1 == 1 }}", + **extra, + } + }, + ) + + +async def async_setup_modern_format( hass: HomeAssistant, count: int, light_config: dict[str, Any] +) -> None: + """Do setup of light integration via new format.""" + config = {"template": {"light": light_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a legacy light that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_modern_format( + hass, + count, + { + "name": "test_template_light", + **extra_config, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) + + +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, light_config: dict[str, Any] +) -> None: + """Do setup of light integration via new format.""" + config = { + "template": { + **TEST_STATE_TRIGGER, + "light": light_config, + } + } + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_trigger_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a legacy light that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_trigger_format( + hass, + count, + { + "name": "test_template_light", + **extra_config, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) + + +@pytest.fixture +async def setup_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + light_config: dict[str, Any], ) -> None: """Do setup of light integration.""" - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, light_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, light_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, light_config) + + +@pytest.fixture +async def setup_state_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": state_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "name": "test_template_light", + "state": state_template, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "name": "test_template_light", + "state": state_template, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format_with_attribute( + hass, count, attribute, attribute_template, extra_config + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format_with_attribute( + hass, count, attribute, attribute_template, extra_config + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format_with_attribute( + hass, count, attribute, attribute_template, extra_config + ) + + +@pytest.fixture +async def setup_single_action_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + extra_config: dict, +) -> None: + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format_with_attribute( + hass, count, "", "", extra_config + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format_with_attribute( + hass, count, "", "", extra_config + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format_with_attribute( + hass, count, "", "", extra_config + ) + + +@pytest.fixture +async def setup_empty_action_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + action: str, + extra_config: dict, +) -> None: + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + "turn_on": [], + "turn_off": [], + action: [], + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": "test_template_light", + "turn_on": [], + "turn_off": [], + action: [], + **extra_config, + }, + ) + + +@pytest.fixture +async def setup_light_with_effects( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + effect_list_template: str, + effect_template: str, +) -> None: + """Do setup of light with effects.""" + common = { + "set_effect": { + "service": "test.automation", + "data_template": { + "action": "set_effect", + "caller": "{{ this.entity_id }}", + "entity_id": "test.test_state", + "effect": "{{effect}}", + }, + }, + } + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": "{{true}}", + **common, + "effect_list_template": effect_list_template, + "effect_template": effect_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "state": "{{true}}", + **common, + "effect_list": effect_list_template, + "effect": effect_template, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "state": "{{true}}", + **common, + "effect_list": effect_list_template, + "effect": effect_template, + }, + ) + + +@pytest.fixture +async def setup_light_with_mireds( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of light that uses mireds.""" + common = { + "set_temperature": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "color_temp": "{{color_temp}}", + }, + }, + attribute: attribute_template, + } + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + **common, + "temperature_template": "{{200}}", + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "state": "{{ 1 == 1 }}", + **common, + "temperature": "{{200}}", + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "state": "{{ 1 == 1 }}", + **common, + "temperature": "{{200}}", + }, + ) + + +@pytest.fixture +async def setup_light_with_transition_template( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + transition_template: str, +) -> None: + """Do setup of light that uses mireds.""" + common = { + "set_effect": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "effect": "{{effect}}", + }, + }, + } + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + **common, + "effect_list_template": "{{ ['Disco', 'Police'] }}", + "effect_template": "{{ None }}", + "supports_transition_template": transition_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, + "state": "{{ 1 == 1 }}", + **common, + "effect_list": "{{ ['Disco', 'Police'] }}", + "effect": "{{ None }}", + "supports_transition": transition_template, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, + "state": "{{ 1 == 1 }}", + **common, + "effect_list": "{{ ['Disco', 'Police'] }}", + "effect": "{{ None }}", + "supports_transition": transition_template, + }, + ) @pytest.mark.parametrize("count", [1]) @@ -186,22 +859,24 @@ async def setup_light( [(0, [ColorMode.BRIGHTNESS])], ) @pytest.mark.parametrize( - "light_config", + ("style", "expected_state"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{states.test['big.fat...']}}", - } - }, + (ConfigurationStyle.LEGACY, STATE_OFF), + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), ], ) +@pytest.mark.parametrize("state_template", ["{{states.test['big.fat...']}}"]) async def test_template_state_invalid( - hass: HomeAssistant, supported_features, supported_color_modes, setup_light + hass: HomeAssistant, + supported_features, + supported_color_modes, + expected_state, + setup_state_light, ) -> None: """Test template state with render error.""" state = hass.states.get("light.test_template_light") - assert state.state == STATE_OFF + assert state.state == expected_state assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == supported_color_modes assert state.attributes["supported_features"] == supported_features @@ -209,17 +884,15 @@ async def test_template_state_invalid( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{ states.light.test_state.state }}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) -async def test_template_state_text(hass: HomeAssistant, setup_light) -> None: +@pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) +async def test_template_state_text(hass: HomeAssistant, setup_state_light) -> None: """Test the state text of a template.""" set_state = STATE_ON hass.states.async_set("light.test_state", set_state) @@ -242,7 +915,15 @@ async def test_template_state_text(hass: HomeAssistant, setup_light) -> None: @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("value_template", "expected_state", "expected_color_mode"), + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, + ], +) +@pytest.mark.parametrize( + ("state_template", "expected_state", "expected_color_mode"), [ ( "{{ 1 == 1 }}", @@ -256,21 +937,18 @@ async def test_template_state_text(hass: HomeAssistant, setup_light) -> None: ), ], ) -async def test_templatex_state_boolean( +async def test_template_state_boolean( hass: HomeAssistant, expected_color_mode, expected_state, - count, - value_template, + style, + setup_state_light, ) -> None: """Test the setting of the state with boolean on.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": value_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", expected_state) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.state == expected_state assert state.attributes.get("color_mode") == expected_color_mode @@ -280,48 +958,69 @@ async def test_templatex_state_boolean( @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": "{%- if false -%}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "bad name here": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": "{{ 1== 1}}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + {"test_template_light": "Invalid"}, + ConfigurationStyle.LEGACY, + ), + ( + { **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{%- if false -%}", - } - }, - { - "bad name here": { + "name": "test_template_light", + "state": "{%- if false -%}", + }, + ConfigurationStyle.MODERN, + ), + ( + { **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{ 1== 1}}", - } - }, - {"test_template_light": "Invalid"}, + "name": "test_template_light", + "state": "{%- if false -%}", + }, + ConfigurationStyle.TRIGGER, + ), ], ) -async def test_template_syntax_error(hass: HomeAssistant, setup_light) -> None: - """Test templating syntax error.""" +async def test_template_config_errors(hass: HomeAssistant, setup_light) -> None: + """Test template light configuration errors.""" assert hass.states.async_all("light") == [] @pytest.mark.parametrize( - ("light_config", "count"), + ("light_config", "style", "count"), [ ( - { - "light_one": { - "value_template": "{{ 1== 1}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, + {"light_one": {"value_template": "{{ 1== 1}}", **TEST_MISSING_KEY_CONFIG}}, + ConfigurationStyle.LEGACY, + 0, + ), + ( + {"name": "light_one", "state": "{{ 1== 1}}", **TEST_MISSING_KEY_CONFIG}, + ConfigurationStyle.MODERN, + 0, + ), + ( + {"name": "light_one", "state": "{{ 1== 1}}", **TEST_MISSING_KEY_CONFIG}, + ConfigurationStyle.TRIGGER, 0, ), ], @@ -336,18 +1035,16 @@ async def test_missing_key(hass: HomeAssistant, count, setup_light) -> None: @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{states.light.test_state.state}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) +@pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) async def test_on_action( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] + hass: HomeAssistant, setup_state_light, calls: list[ServiceCall] ) -> None: """Test on action.""" hass.states.async_set("light.test_state", STATE_OFF) @@ -378,32 +1075,36 @@ async def test_on_action( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { - "value_template": "{{states.light.test_state.state}}", - "turn_on": { - "service": "test.automation", - "data_template": { - "transition": "{{transition}}", - }, - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "supports_transition_template": "{{true}}", - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - "transition": "{{transition}}", - }, - }, - } - }, + ( + { + "test_template_light": { + "value_template": "{{states.light.test_state.state}}", + **TEST_ON_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition_template": "{{true}}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", + "state": "{{states.light.test_state.state}}", + **TEST_ON_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition": "{{true}}", + }, + ConfigurationStyle.MODERN, + ), + ( + { + "name": "test_template_light", + "state": "{{states.light.test_state.state}}", + **TEST_ON_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition": "{{true}}", + }, + ConfigurationStyle.TRIGGER, + ), ], ) async def test_on_action_with_transition( @@ -437,17 +1138,38 @@ async def test_on_action_with_transition( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style", "initial_state"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + } + }, + ConfigurationStyle.LEGACY, + STATE_OFF, + ), + ( + { + "name": "test_template_light", **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - } - }, + }, + ConfigurationStyle.MODERN, + STATE_OFF, + ), + ( + { + "name": "test_template_light", + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + }, + ConfigurationStyle.TRIGGER, + STATE_UNKNOWN, + ), ], ) async def test_on_action_optimistic( hass: HomeAssistant, + initial_state: str, setup_light, calls: list[ServiceCall], ) -> None: @@ -456,7 +1178,7 @@ async def test_on_action_optimistic( await hass.async_block_till_done() state = hass.states.get("light.test_template_light") - assert state.state == STATE_OFF + assert state.state == initial_state assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -497,18 +1219,16 @@ async def test_on_action_optimistic( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{states.light.test_state.state}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) +@pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) async def test_off_action( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] + hass: HomeAssistant, setup_state_light, calls: list[ServiceCall] ) -> None: """Test off action.""" hass.states.async_set("light.test_state", STATE_ON) @@ -538,32 +1258,36 @@ async def test_off_action( @pytest.mark.parametrize("count", [(1)]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { - "value_template": "{{states.light.test_state.state}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "test.automation", - "data_template": { - "transition": "{{transition}}", - }, - }, - "supports_transition_template": "{{true}}", - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - "transition": "{{transition}}", - }, - }, - } - }, + ( + { + "test_template_light": { + "value_template": "{{states.light.test_state.state}}", + **TEST_OFF_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition_template": "{{true}}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", + "state": "{{states.light.test_state.state}}", + **TEST_OFF_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition": "{{true}}", + }, + ConfigurationStyle.MODERN, + ), + ( + { + "name": "test_template_light", + "state": "{{states.light.test_state.state}}", + **TEST_OFF_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition": "{{true}}", + }, + ConfigurationStyle.TRIGGER, + ), ], ) async def test_off_action_with_transition( @@ -596,21 +1320,41 @@ async def test_off_action_with_transition( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style", "initial_state"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + } + }, + ConfigurationStyle.LEGACY, + STATE_OFF, + ), + ( + { + "name": "test_template_light", **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - } - }, + }, + ConfigurationStyle.MODERN, + STATE_OFF, + ), + ( + { + "name": "test_template_light", + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + }, + ConfigurationStyle.TRIGGER, + STATE_UNKNOWN, + ), ], ) async def test_off_action_optimistic( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] + hass: HomeAssistant, initial_state, setup_light, calls: list[ServiceCall] ) -> None: """Test off action with optimistic state.""" state = hass.states.get("light.test_template_light") - assert state.state == STATE_OFF + assert state.state == initial_state assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -632,19 +1376,17 @@ async def test_off_action_optimistic( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) +@pytest.mark.parametrize("state_template", ["{{1 == 1}}"]) async def test_level_action_no_template( hass: HomeAssistant, - setup_light, + setup_state_light, calls: list[ServiceCall], ) -> None: """Test setting brightness with optimistic template.""" @@ -671,9 +1413,19 @@ async def test_level_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_level", "level_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "level_template"), + (ConfigurationStyle.MODERN, "level"), + (ConfigurationStyle.TRIGGER, "level"), + ], +) +@pytest.mark.parametrize( + ("expected_level", "attribute_template", "expected_color_mode"), [ (255, "{{255}}", ColorMode.BRIGHTNESS), (None, "{{256}}", ColorMode.BRIGHTNESS), @@ -690,31 +1442,38 @@ async def test_level_action_no_template( ) async def test_level_template( hass: HomeAssistant, - expected_level, - expected_color_mode, - count, - level_template, + style: ConfigurationStyle, + expected_level: Any, + expected_color_mode: ColorMode, + setup_single_attribute_light, ) -> None: """Test the template for the level.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "level_template": level_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("brightness") == expected_level assert state.state == STATE_ON + assert state.attributes["color_mode"] == expected_color_mode assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_temp", "temperature_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "temperature_template"), + (ConfigurationStyle.MODERN, "temperature"), + (ConfigurationStyle.TRIGGER, "temperature"), + ], +) +@pytest.mark.parametrize( + ("expected_temp", "attribute_template", "expected_color_mode"), [ (500, "{{500}}", ColorMode.COLOR_TEMP), (None, "{{501}}", ColorMode.COLOR_TEMP), @@ -727,43 +1486,38 @@ async def test_level_template( ) async def test_temperature_template( hass: HomeAssistant, - expected_temp, - expected_color_mode, - count, - temperature_template, + style: ConfigurationStyle, + expected_temp: Any, + expected_color_mode: ColorMode, + setup_single_attribute_light, ) -> None: """Test the template for the temperature.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "temperature_template": temperature_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("color_temp") == expected_temp assert state.state == STATE_ON - assert state.attributes["color_mode"] == expected_color_mode + assert state.attributes.get("color_mode") == expected_color_mode assert state.attributes["supported_color_modes"] == [ColorMode.COLOR_TEMP] assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [(1, OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) async def test_temperature_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting temperature with optimistic template.""" @@ -793,46 +1547,66 @@ async def test_temperature_action_no_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style", "entity_id"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "friendly_name": "Template light", + "value_template": "{{ 1 == 1 }}", + } + }, + ConfigurationStyle.LEGACY, + "light.test_template_light", + ), + ( + { **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - } - }, + "name": "Template light", + "state": "{{ 1 == 1 }}", + }, + ConfigurationStyle.MODERN, + "light.template_light", + ), + ( + { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "name": "Template light", + "state": "{{ 1 == 1 }}", + }, + ConfigurationStyle.TRIGGER, + "light.template_light", + ), ], ) -async def test_friendly_name(hass: HomeAssistant, setup_light) -> None: +async def test_friendly_name(hass: HomeAssistant, entity_id: str, setup_light) -> None: """Test the accessibility of the friendly_name attribute.""" - state = hass.states.get("light.test_template_light") + state = hass.states.get(entity_id) assert state is not None assert state.attributes.get("friendly_name") == "Template light" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [(1, OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "icon_template": ( - "{% if states.light.test_state.state %}mdi:check{% endif %}" - ), - } - }, + (ConfigurationStyle.LEGACY, "icon_template"), + (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.TRIGGER, "icon"), ], ) -async def test_icon_template(hass: HomeAssistant, setup_light) -> None: +@pytest.mark.parametrize( + "attribute_template", ["{% if states.light.test_state.state %}mdi:check{% endif %}"] +) +async def test_icon_template(hass: HomeAssistant, setup_single_attribute_light) -> None: """Test icon template.""" state = hass.states.get("light.test_template_light") - assert state.attributes.get("icon") == "" + assert state.attributes.get("icon") in ("", None) state = hass.states.async_set("light.test_state", STATE_ON) await hass.async_block_till_done() @@ -842,26 +1616,27 @@ async def test_icon_template(hass: HomeAssistant, setup_light) -> None: assert state.attributes["icon"] == "mdi:check" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [(1, OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "entity_picture_template": ( - "{% if states.light.test_state.state %}/local/light.png{% endif %}" - ), - } - }, + (ConfigurationStyle.LEGACY, "entity_picture_template"), + (ConfigurationStyle.MODERN, "picture"), + (ConfigurationStyle.TRIGGER, "picture"), ], ) -async def test_entity_picture_template(hass: HomeAssistant, setup_light) -> None: +@pytest.mark.parametrize( + "attribute_template", + ["{% if states.light.test_state.state %}/local/light.png{% endif %}"], +) +async def test_entity_picture_template( + hass: HomeAssistant, setup_single_attribute_light +) -> None: """Test entity_picture template.""" state = hass.states.get("light.test_template_light") - assert state.attributes.get("entity_picture") == "" + assert state.attributes.get("entity_picture") in ("", None) state = hass.states.async_set("light.test_state", STATE_ON) await hass.async_block_till_done() @@ -871,21 +1646,21 @@ async def test_entity_picture_template(hass: HomeAssistant, setup_light) -> None assert state.attributes["entity_picture"] == "/local/light.png" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [ - { - "test_template_light": { - **OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + (1, OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG), + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, ], ) async def test_legacy_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting color with optimistic template.""" @@ -913,24 +1688,26 @@ async def test_legacy_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [ - { - "test_template_light": { - **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + (1, OPTIMISTIC_HS_COLOR_LIGHT_CONFIG), + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) async def test_hs_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: - """Test setting hs color with optimistic template.""" + """Test setting color with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") is None @@ -955,21 +1732,21 @@ async def test_hs_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), + [(1, OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG)], +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) async def test_rgb_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting rgb color with optimistic template.""" @@ -998,21 +1775,21 @@ async def test_rgb_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), + [(1, OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG)], +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) async def test_rgbw_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting rgbw color with optimistic template.""" @@ -1045,21 +1822,21 @@ async def test_rgbw_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), + [(1, OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG)], +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, ], ) async def test_rgbww_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting rgbww color with optimistic template.""" @@ -1123,7 +1900,7 @@ async def test_legacy_color_template( "color_template": color_template, } } - await async_setup_light(hass, count, light_config) + await async_setup_legacy_format(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") == expected_hs assert state.state == STATE_ON @@ -1132,9 +1909,19 @@ async def test_legacy_color_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_hs", "hs_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_HS_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "hs_template"), + (ConfigurationStyle.MODERN, "hs"), + (ConfigurationStyle.TRIGGER, "hs"), + ], +) +@pytest.mark.parametrize( + ("expected_hs", "attribute_template", "expected_color_mode"), [ ((360, 100), "{{(360, 100)}}", ColorMode.HS), ((360, 100), "(360, 100)", ColorMode.HS), @@ -1152,18 +1939,14 @@ async def test_hs_template( hass: HomeAssistant, expected_hs, expected_color_mode, - count, - hs_template, + style: ConfigurationStyle, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "hs_template": hs_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") == expected_hs assert state.state == STATE_ON @@ -1172,9 +1955,19 @@ async def test_hs_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_rgb", "rgb_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "rgb_template"), + (ConfigurationStyle.MODERN, "rgb"), + (ConfigurationStyle.TRIGGER, "rgb"), + ], +) +@pytest.mark.parametrize( + ("expected_rgb", "attribute_template", "expected_color_mode"), [ ((160, 78, 192), "{{(160, 78, 192)}}", ColorMode.RGB), ((160, 78, 192), "{{[160, 78, 192]}}", ColorMode.RGB), @@ -1193,18 +1986,14 @@ async def test_rgb_template( hass: HomeAssistant, expected_rgb, expected_color_mode, - count, - rgb_template, + style: ConfigurationStyle, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "rgb_template": rgb_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("rgb_color") == expected_rgb assert state.state == STATE_ON @@ -1213,9 +2002,19 @@ async def test_rgb_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_rgbw", "rgbw_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "rgbw_template"), + (ConfigurationStyle.MODERN, "rgbw"), + (ConfigurationStyle.TRIGGER, "rgbw"), + ], +) +@pytest.mark.parametrize( + ("expected_rgbw", "attribute_template", "expected_color_mode"), [ ((160, 78, 192, 25), "{{(160, 78, 192, 25)}}", ColorMode.RGBW), ((160, 78, 192, 25), "{{[160, 78, 192, 25]}}", ColorMode.RGBW), @@ -1235,18 +2034,14 @@ async def test_rgbw_template( hass: HomeAssistant, expected_rgbw, expected_color_mode, - count, - rgbw_template, + style: ConfigurationStyle, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "rgbw_template": rgbw_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("rgbw_color") == expected_rgbw assert state.state == STATE_ON @@ -1255,9 +2050,19 @@ async def test_rgbw_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_rgbww", "rgbww_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "rgbww_template"), + (ConfigurationStyle.MODERN, "rgbww"), + (ConfigurationStyle.TRIGGER, "rgbww"), + ], +) +@pytest.mark.parametrize( + ("expected_rgbww", "attribute_template", "expected_color_mode"), [ ((160, 78, 192, 25, 55), "{{(160, 78, 192, 25, 55)}}", ColorMode.RGBWW), ((160, 78, 192, 25, 55), "(160, 78, 192, 25, 55)", ColorMode.RGBWW), @@ -1282,18 +2087,14 @@ async def test_rgbww_template( hass: HomeAssistant, expected_rgbww, expected_color_mode, - count, - rgbww_template, + style: ConfigurationStyle, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "rgbww_template": rgbww_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state.attributes.get("rgbww_color") == expected_rgbww assert state.state == STATE_ON @@ -1304,59 +2105,36 @@ async def test_rgbww_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + **TEST_ALL_COLORS_NO_TEMPLATE_CONFIG, + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - "set_hs": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "h": "{{h}}", - "s": "{{s}}", - }, - }, - "set_temperature": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "set_rgb": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "r": "{{r}}", - "g": "{{g}}", - "b": "{{b}}", - }, - }, - "set_rgbw": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "r": "{{r}}", - "g": "{{g}}", - "b": "{{b}}", - "w": "{{w}}", - }, - }, - "set_rgbww": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "r": "{{r}}", - "g": "{{g}}", - "b": "{{b}}", - "cw": "{{cw}}", - "ww": "{{ww}}", - }, - }, - } - }, + "state": "{{1 == 1}}", + **TEST_ALL_COLORS_NO_TEMPLATE_CONFIG, + }, + ConfigurationStyle.MODERN, + ), + ( + { + "name": "test_template_light", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "state": "{{1 == 1}}", + **TEST_ALL_COLORS_NO_TEMPLATE_CONFIG, + }, + ConfigurationStyle.TRIGGER, + ), ], ) async def test_all_colors_mode_no_template( @@ -1554,95 +2332,56 @@ async def test_all_colors_mode_no_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("effect_list_template", "effect_template", "effect", "expected"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{true}}", - "set_effect": { - "service": "test.automation", - "data_template": { - "action": "set_effect", - "caller": "{{ this.entity_id }}", - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": "{{ ['Disco', 'Police'] }}", - "effect_template": "{{ 'Disco' }}", - } - }, + ("{{ ['Disco', 'Police'] }}", "{{ 'Disco' }}", "Disco", "Disco"), + ("{{ ['Disco', 'Police'] }}", "{{ 'None' }}", "RGB", None), ], ) -async def test_effect_action_valid_effect( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] +async def test_effect_action( + hass: HomeAssistant, + effect: str, + expected: Any, + style: ConfigurationStyle, + setup_light_with_effects, + calls: list[ServiceCall], ) -> None: """Test setting valid effect with template.""" + + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state is not None await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_EFFECT: "Disco"}, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_EFFECT: effect}, blocking=True, ) assert len(calls) == 1 assert calls[-1].data["action"] == "set_effect" assert calls[-1].data["caller"] == "light.test_template_light" - assert calls[-1].data["effect"] == "Disco" + assert calls[-1].data["effect"] == effect state = hass.states.get("light.test_template_light") assert state is not None - assert state.attributes.get("effect") == "Disco" + assert state.attributes.get("effect") == expected -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize(("count", "effect_template"), [(1, "{{ None }}")]) @pytest.mark.parametrize( - "light_config", - [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{true}}", - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": "{{ ['Disco', 'Police'] }}", - "effect_template": "{{ None }}", - } - }, - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_effect_action_invalid_effect( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] -) -> None: - """Test setting invalid effect with template.""" - state = hass.states.get("light.test_template_light") - assert state is not None - - await hass.services.async_call( - light.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_EFFECT: "RGB"}, - blocking=True, - ) - - assert len(calls) == 1 - assert calls[0].data["effect"] == "RGB" - - state = hass.states.get("light.test_template_light") - assert state is not None - assert state.attributes.get("effect") is None - - -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("expected_effect_list", "effect_list_template"), [ @@ -1663,31 +2402,29 @@ async def test_effect_action_invalid_effect( ], ) async def test_effect_list_template( - hass: HomeAssistant, expected_effect_list, count, effect_list_template + hass: HomeAssistant, + expected_effect_list, + style: ConfigurationStyle, + setup_light_with_effects, ) -> None: """Test the template for the effect list.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_template": "{{ None }}", - "effect_list_template": effect_list_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect_list") == expected_effect_list -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("count", "effect_list_template"), + [(1, "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}")], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) @pytest.mark.parametrize( ("expected_effect", "effect_template"), [ @@ -1699,27 +2436,16 @@ async def test_effect_list_template( ], ) async def test_effect_template( - hass: HomeAssistant, expected_effect, count, effect_template + hass: HomeAssistant, + expected_effect, + style: ConfigurationStyle, + setup_light_with_effects, ) -> None: """Test the template for the effect.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": ( - "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}" - ), - "effect_template": effect_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect") == expected_effect @@ -1727,7 +2453,15 @@ async def test_effect_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_min_mireds", "min_mireds_template"), + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "min_mireds_template"), + (ConfigurationStyle.MODERN, "min_mireds"), + (ConfigurationStyle.TRIGGER, "min_mireds"), + ], +) +@pytest.mark.parametrize( + ("expected_min_mireds", "attribute_template"), [ (118, "{{118}}"), (153, "{{x - 12}}"), @@ -1738,25 +2472,16 @@ async def test_effect_template( ], ) async def test_min_mireds_template( - hass: HomeAssistant, expected_min_mireds, count, min_mireds_template + hass: HomeAssistant, + expected_min_mireds, + style: ConfigurationStyle, + setup_light_with_mireds, ) -> None: """Test the template for the min mireds.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "temperature_template": "{{200}}", - "min_mireds_template": min_mireds_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("min_mireds") == expected_min_mireds @@ -1764,7 +2489,15 @@ async def test_min_mireds_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_max_mireds", "max_mireds_template"), + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "max_mireds_template"), + (ConfigurationStyle.MODERN, "max_mireds"), + (ConfigurationStyle.TRIGGER, "max_mireds"), + ], +) +@pytest.mark.parametrize( + ("expected_max_mireds", "attribute_template"), [ (488, "{{488}}"), (500, "{{x - 12}}"), @@ -1775,33 +2508,34 @@ async def test_min_mireds_template( ], ) async def test_max_mireds_template( - hass: HomeAssistant, expected_max_mireds, count, max_mireds_template + hass: HomeAssistant, + expected_max_mireds, + style: ConfigurationStyle, + setup_light_with_mireds, ) -> None: """Test the template for the max mireds.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "temperature_template": "{{200}}", - "max_mireds_template": max_mireds_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("max_mireds") == expected_max_mireds -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_supports_transition", "supports_transition_template"), + ("count", "extra_config"), [(1, OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "supports_transition_template"), + (ConfigurationStyle.MODERN, "supports_transition"), + (ConfigurationStyle.TRIGGER, "supports_transition"), + ], +) +@pytest.mark.parametrize( + ("expected_supports_transition", "attribute_template"), [ (True, "{{true}}"), (True, "{{1 == 1}}"), @@ -1813,27 +2547,16 @@ async def test_max_mireds_template( ) async def test_supports_transition_template( hass: HomeAssistant, + style: ConfigurationStyle, expected_supports_transition, - count, - supports_transition_template, + setup_single_attribute_light, ) -> None: """Test the template for the supports transition.""" - light_config = { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": {"service": "light.turn_on", "entity_id": "light.test_state"}, - "turn_off": {"service": "light.turn_off", "entity_id": "light.test_state"}, - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "supports_transition_template": supports_transition_template, - } - } - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") expected_value = 1 @@ -1847,47 +2570,40 @@ async def test_supports_transition_template( ) != expected_value -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("count", "transition_template"), [(1, "{{ states('sensor.test') }}")] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) async def test_supports_transition_template_updates( - hass: HomeAssistant, count: int + hass: HomeAssistant, style: ConfigurationStyle, setup_light_with_transition_template ) -> None: """Test the template for the supports transition dynamically.""" - light_config = { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": {"service": "light.turn_on", "entity_id": "light.test_state"}, - "turn_off": {"service": "light.turn_off", "entity_id": "light.test_state"}, - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": "{{ ['Disco', 'Police'] }}", - "effect_template": "{{ None }}", - "supports_transition_template": "{{ states('sensor.test') }}", - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state is not None hass.states.async_set("sensor.test", 0) await hass.async_block_till_done() + + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") supported_features = state.attributes.get("supported_features") assert supported_features == LightEntityFeature.EFFECT hass.states.async_set("sensor.test", 1) await hass.async_block_till_done() + + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_OFF) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") supported_features = state.attributes.get("supported_features") assert ( @@ -1896,33 +2612,48 @@ async def test_supports_transition_template_updates( hass.states.async_set("sensor.test", 0) await hass.async_block_till_done() + + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") supported_features = state.attributes.get("supported_features") assert supported_features == LightEntityFeature.EFFECT -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config", "attribute_template"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "availability_template": ( - "{{ is_state('availability_boolean.state', 'on') }}" - ), - } - }, + ( + 1, + OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "{{ is_state('availability_boolean.state', 'on') }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) async def test_available_template_with_entities( - hass: HomeAssistant, setup_light + hass: HomeAssistant, style: ConfigurationStyle, setup_single_attribute_light ) -> None: """Test availability templates with values from other entities.""" # When template returns true.. hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, STATE_ON) await hass.async_block_till_done() + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_ON) + await hass.async_block_till_done() + # Device State should not be unavailable assert hass.states.get("light.test_template_light").state != STATE_UNAVAILABLE @@ -1930,24 +2661,36 @@ async def test_available_template_with_entities( hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, STATE_OFF) await hass.async_block_till_done() + if style == ConfigurationStyle.TRIGGER: + # Ensures the trigger template entity updates + hass.states.async_set("light.test_state", STATE_OFF) + await hass.async_block_till_done() + # device state should be unavailable assert hass.states.get("light.test_template_light").state == STATE_UNAVAILABLE -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config", "attribute_template"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "availability_template": "{{ x - 12 }}", - } - }, + ( + 1, + OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "{{ x - 12 }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), ], ) async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, setup_light, caplog_setup_text + hass: HomeAssistant, + setup_single_attribute_light, + caplog_setup_text, ) -> None: """Test that an invalid availability keeps the device available.""" assert hass.states.get("light.test_template_light").state != STATE_UNAVAILABLE @@ -1956,20 +2699,165 @@ async def test_invalid_availability_template_keeps_component_available( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light_01": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "unique_id": "not-so-unique-anymore", + ( + { + "test_template_light_01": TEST_UNIQUE_ID_CONFIG, + "test_template_light_02": TEST_UNIQUE_ID_CONFIG, }, - "test_template_light_02": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "unique_id": "not-so-unique-anymore", - }, - }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_light_01", + **TEST_UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_light_02", + **TEST_UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), + ( + [ + { + "name": "test_template_light_01", + **TEST_UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_light_02", + **TEST_UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), ], ) async def test_unique_id(hass: HomeAssistant, setup_light) -> None: """Test unique_id option only creates one light per id.""" assert len(hass.states.async_all("light")) == 1 + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test unique_id option creates one light per nested id.""" + + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "light": [ + { + "name": "test_a", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "unique_id": "a", + }, + { + "name": "test_b", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "unique_id": "b", + }, + ], + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("light")) == 2 + + entry = entity_registry.async_get("light.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("light.test_b") + assert entry + assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize(("count", "extra_config"), [(1, {})]) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ], +) +@pytest.mark.parametrize( + ("action", "color_mode"), + [ + ("set_level", ColorMode.BRIGHTNESS), + ("set_temperature", ColorMode.COLOR_TEMP), + ("set_hs", ColorMode.HS), + ("set_rgb", ColorMode.RGB), + ("set_rgbw", ColorMode.RGBW), + ("set_rgbww", ColorMode.RGBWW), + ], +) +async def test_empty_color_mode_action_config( + hass: HomeAssistant, + color_mode: ColorMode, + setup_empty_action_light, +) -> None: + """Test empty actions for color mode actions.""" + state = hass.states.get("light.test_template_light") + assert state.attributes["supported_color_modes"] == [color_mode] + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_template_light"}, + blocking=True, + ) + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_template_light"}, + blocking=True, + ) + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_OFF + + +@pytest.mark.parametrize(("count"), [1]) +@pytest.mark.parametrize( + ("style", "extra_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "effect_list_template": "{{ ['a'] }}", + "effect_template": "{{ 'a' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "effect_list": "{{ ['a'] }}", + "effect": "{{ 'a' }}", + }, + ), + ], +) +@pytest.mark.parametrize("action", ["set_effect"]) +async def test_effect_with_empty_action( + hass: HomeAssistant, + setup_empty_action_light, +) -> None: + """Test empty set_effect action.""" + state = hass.states.get("light.test_template_light") + assert state.attributes["supported_features"] == LightEntityFeature.EFFECT diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index d9cb294c41f..4435e4a2404 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -1,10 +1,12 @@ """The tests for the Template lock platform.""" +from typing import Any + import pytest from homeassistant import setup -from homeassistant.components import lock -from homeassistant.components.lock import LockState +from homeassistant.components import lock, template +from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -14,23 +16,38 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component -OPTIMISTIC_LOCK_CONFIG = { - "platform": "template", +from .conftest import ConfigurationStyle + +from tests.common import assert_setup_component + +TEST_OBJECT_ID = "test_template_lock" +TEST_ENTITY_ID = f"lock.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "switch.test_state" + +LOCK_ACTION = { "lock": { "service": "test.automation", "data_template": { "action": "lock", "caller": "{{ this.entity_id }}", + "code": "{{ code if code is defined else None }}", }, }, +} +UNLOCK_ACTION = { "unlock": { "service": "test.automation", "data_template": { "action": "unlock", "caller": "{{ this.entity_id }}", + "code": "{{ code if code is defined else None }}", }, }, +} +OPEN_ACTION = { "open": { "service": "test.automation", "data_template": { @@ -40,424 +57,565 @@ OPTIMISTIC_LOCK_CONFIG = { }, } -OPTIMISTIC_CODED_LOCK_CONFIG = { - "platform": "template", - "lock": { - "service": "test.automation", - "data_template": { - "action": "lock", - "caller": "{{ this.entity_id }}", - "code": "{{ code }}", - }, - }, - "unlock": { - "service": "test.automation", - "data_template": { - "action": "unlock", - "caller": "{{ this.entity_id }}", - "code": "{{ code }}", - }, - }, + +OPTIMISTIC_LOCK = { + **LOCK_ACTION, + **UNLOCK_ACTION, } -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +OPTIMISTIC_LOCK_CONFIG = { + "platform": "template", + **LOCK_ACTION, + **UNLOCK_ACTION, + **OPEN_ACTION, +} + +OPTIMISTIC_CODED_LOCK_CONFIG = { + "platform": "template", + **LOCK_ACTION, + **UNLOCK_ACTION, +} + + +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, lock_config: dict[str, Any] +) -> None: + """Do setup of lock integration via legacy format.""" + config = {"lock": {"platform": "template", "name": TEST_OBJECT_ID, **lock_config}} + + with assert_setup_component(count, lock.DOMAIN): + assert await async_setup_component( + hass, + lock.DOMAIN, + config, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, lock_config: dict[str, Any] +) -> None: + """Do setup of lock integration via modern format.""" + config = {"template": {"lock": {"name": TEST_OBJECT_ID, **lock_config}}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_lock( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + lock_config: dict[str, Any], +) -> None: + """Do setup of lock integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, lock_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, lock_config) + + +@pytest.fixture +async def setup_base_lock( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + extra_config: dict, +): + """Do setup of cover integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + {"value_template": state_template, **extra_config}, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + {"state": state_template, **extra_config}, + ) + + +@pytest.fixture +async def setup_state_lock( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of cover integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + **OPTIMISTIC_LOCK, + "value_template": state_template, + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **OPTIMISTIC_LOCK, + "state": state_template, + }, + ) + + +@pytest.fixture +async def setup_state_lock_with_extra_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + extra_config: dict, +): + """Do setup of cover integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + {**OPTIMISTIC_LOCK, "value_template": state_template, **extra_config}, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + {**OPTIMISTIC_LOCK, "state": state_template, **extra_config}, + ) + + +@pytest.fixture +async def setup_state_lock_with_attribute( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + attribute: str, + attribute_template: str, +): + """Do setup of cover integration using a state template.""" + extra = {attribute: attribute_template} if attribute else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + **OPTIMISTIC_LOCK, + "value_template": state_template, + **extra, + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + {**OPTIMISTIC_LOCK, "state": state_template, **extra}, + ) + + @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "name": "Test template lock", - "value_template": "{{ states.switch.test_state.state }}", - } - }, - ], + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock") async def test_template_state(hass: HomeAssistant) -> None: """Test template.""" - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() state = hass.states.get("lock.test_template_lock") assert state.state == LockState.LOCKED - hass.states.async_set("switch.test_state", STATE_OFF) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() state = hass.states.get("lock.test_template_lock") assert state.state == LockState.UNLOCKED - hass.states.async_set("switch.test_state", STATE_OPEN) + hass.states.async_set(TEST_STATE_ENTITY_ID, 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 }}", - } - }, - ], + ("count", "state_template", "extra_config"), + [(1, "{{ states.switch.test_state.state }}", {"optimistic": True, **OPEN_ACTION})], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock_with_extra_config") 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) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.test_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_OPEN, - {ATTR_ENTITY_ID: "lock.test_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "open" - assert calls[0].data["caller"] == "lock.test_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID - state = hass.states.get("lock.test_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.OPEN -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 1 }}", - } - }, - ], + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_lock") async def test_template_state_boolean_on(hass: HomeAssistant) -> None: """Test the setting of the state with boolean on.""" - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 2 }}")]) @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 2 }}", - } - }, - ], + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_lock") async def test_template_state_boolean_off(hass: HomeAssistant) -> None: """Test the setting of the state with off.""" - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED -@pytest.mark.parametrize(("count", "domain"), [(0, lock.DOMAIN)]) +@pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "config", + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("state_template", "extra_config"), [ - { - lock.DOMAIN: { - "platform": "template", - "value_template": "{% if rubbish %}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - { - "switch": { - "platform": "lock", - "name": "{{%}", - "value_template": "{{ rubbish }", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - }, - }, - {lock.DOMAIN: {"platform": "template", "value_template": "Invalid"}}, - { - lock.DOMAIN: { - "platform": "template", + ("{% if rubbish %}", OPTIMISTIC_LOCK), + ("{{ rubbish }", OPTIMISTIC_LOCK), + ("Invalid", {}), + ( + "{{ 1==1 }}", + { "not_value_template": "{{ states.switch.test_state.state }}", - "lock": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "unlock": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 1 }}", - "code_format_template": "{{ rubbish }", - } - }, - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 1 }}", - "code_format_template": "{% if rubbish %}", - } - }, + **OPTIMISTIC_LOCK, + }, + ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_base_lock") async def test_template_syntax_error(hass: HomeAssistant) -> None: """Test templating syntax errors don't create entities.""" assert hass.states.async_all("lock") == [] -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize(("count", "state_template"), [(0, "{{ 1==1 }}")]) +@pytest.mark.parametrize("attribute_template", ["{{ rubbish }", "{% if rubbish %}"]) @pytest.mark.parametrize( - "config", + ("style", "attribute"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 + 1 }}", - } - }, + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_lock_with_attribute") +async def test_template_code_template_syntax_error(hass: HomeAssistant) -> None: + """Test templating code_format syntax errors don't create entities.""" + assert hass.states.async_all("lock") == [] + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 + 1 }}")]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock") async def test_template_static(hass: HomeAssistant) -> None: """Test that we allow static templates.""" - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED - hass.states.async_set("lock.template_lock", LockState.LOCKED) + hass.states.async_set(TEST_ENTITY_ID, LockState.LOCKED) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("state_template", "expected"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, + ("{{ True }}", LockState.LOCKED), + ("{{ False }}", LockState.UNLOCKED), + ("{{ x - 12 }}", STATE_UNAVAILABLE), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test lock action.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_OFF) +@pytest.mark.usefixtures("setup_state_lock") +async def test_state_template(hass: HomeAssistant, expected: str) -> None: + """Test state and value_template template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1==1 }}")]) +@pytest.mark.parametrize( + "attribute_template", + ["{% if states.switch.test_state.state %}/local/switch.png{% endif %}"], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "picture"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") +async def test_picture_template(hass: HomeAssistant) -> None: + """Test entity_picture template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") in ("", None) + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["entity_picture"] == "/local/switch.png" + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1==1 }}")]) +@pytest.mark.parametrize( + "attribute_template", + ["{% if states.switch.test_state.state %}mdi:eye{% endif %}"], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "icon"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") +async def test_icon_template(hass: HomeAssistant) -> None: + """Test entity_picture template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") in ("", None) + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["icon"] == "mdi:eye" + + +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock") +async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: + """Test lock action.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "lock" - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - ], + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock") async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test unlock action.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_UNLOCK, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "unlock" - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - ], + ("count", "state_template", "extra_config"), + [(1, "{{ states.switch.test_state.state }}", OPEN_ACTION)], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_state_lock_with_extra_config") 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) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_OPEN, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "open" - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_CODED_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "code_format_template": "{{ '.+' }}", - } - }, + ( + 1, + "{{ states.switch.test_state.state }}", + "{{ '.+' }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_lock_action_with_code( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test lock action with defined code format and supplied lock code.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_OFF) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "LOCK_CODE"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "LOCK_CODE"}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "lock" - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID assert calls[0].data["code"] == "LOCK_CODE" -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_CODED_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "code_format_template": "{{ '.+' }}", - } - }, + ( + 1, + "{{ states.switch.test_state.state }}", + "{{ '.+' }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_unlock_action_with_code( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test unlock action with code format and supplied unlock code.""" await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_UNLOCK, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "UNLOCK_CODE"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "UNLOCK_CODE"}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "unlock" - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID assert calls[0].data["code"] == "UNLOCK_CODE" -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 1 }}", - "code_format_template": "{{ '\\\\d+' }}", - } - }, + ( + 1, + "{{ 1 == 1 }}", + "{{ '\\\\d+' }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), ], ) @pytest.mark.parametrize( @@ -467,7 +625,7 @@ async def test_unlock_action_with_code( lock.SERVICE_UNLOCK, ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_lock_actions_fail_with_invalid_code( hass: HomeAssistant, calls: list[ServiceCall], test_action ) -> None: @@ -475,32 +633,36 @@ async def test_lock_actions_fail_with_invalid_code( await hass.services.async_call( lock.DOMAIN, test_action, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "non-number-value"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "non-number-value"}, ) await hass.services.async_call( lock.DOMAIN, test_action, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 0 -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 == 1 }}", - "code_format_template": "{{ 1/0 }}", - } - }, + ( + 1, + "{{ 1 == 1 }}", + "{{ 1/0 }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_lock_actions_dont_execute_with_code_template_rendering_error( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: @@ -508,142 +670,146 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error( await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.services.async_call( lock.DOMAIN, lock.SERVICE_UNLOCK, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "any-value"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "any-value"}, ) await hass.async_block_till_done() assert len(calls) == 0 -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) -@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_CODED_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "code_format_template": "{{ None }}", - } - }, + ( + 1, + "{{ states.switch.test_state.state }}", + "{{ None }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), + ], +) +@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_actions_with_none_as_codeformat_ignores_code( hass: HomeAssistant, action, calls: list[ServiceCall] ) -> None: """Test lock actions with supplied lock code.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_OFF) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "any code"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "any code"}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == action - assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["caller"] == TEST_ENTITY_ID assert calls[0].data["code"] == "any code" -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) -@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "code_format_template": "[12]{1", - } - }, + ( + 1, + "{{ states.switch.test_state.state }}", + "[12]{1", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "code_format_template"), + (ConfigurationStyle.MODERN, "code_format"), + ], +) +@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_actions_with_invalid_regexp_as_codeformat_never_execute( hass: HomeAssistant, action, calls: list[ServiceCall] ) -> None: """Test lock actions don't execute with invalid regexp.""" - await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set("switch.test_state", STATE_OFF) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "1"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "1"}, ) await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "x"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "x"}, ) await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: "lock.template_lock"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, ) await hass.async_block_till_done() assert len(calls) == 0 -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", - [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states.input_select.test_state.state }}", - } - }, - ], + ("count", "state_template"), [(1, "{{ states.input_select.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) @pytest.mark.parametrize( "test_state", [LockState.UNLOCKING, LockState.LOCKING, LockState.JAMMED] ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_state_lock") async def test_lock_state(hass: HomeAssistant, test_state) -> None: """Test value template.""" hass.states.async_set("input_select.test_state", test_state) await hass.async_block_till_done() - state = hass.states.get("lock.template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == test_state -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ states('switch.test_state') }}", - "availability_template": "{{ is_state('availability_state.state', 'on') }}", - } - }, + ( + 1, + "{{ states('switch.test_state') }}", + "{{ is_state('availability_state.state', 'on') }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_available_template_with_entities(hass: HomeAssistant) -> None: """Test availability templates with values from other entities.""" # When template returns true.. @@ -651,35 +817,39 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Device State should not be unavailable - assert hass.states.get("lock.template_lock").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE # When Availability template returns false hass.states.async_set("availability_state.state", STATE_OFF) await hass.async_block_till_done() # device state should be unavailable - assert hass.states.get("lock.template_lock").state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE -@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_template"), [ - { - lock.DOMAIN: { - **OPTIMISTIC_LOCK_CONFIG, - "value_template": "{{ 1 + 1 }}", - "availability_template": "{{ x - 12 }}", - } - }, + ( + 1, + "{{ 1 + 1 }}", + "{{ x - 12 }}", + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +@pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog_setup_text ) -> None: """Test that an invalid availability keeps the device available.""" - assert hass.states.get("lock.template_lock").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE assert ("UndefinedError: 'x' is undefined") in caplog_setup_text @@ -698,7 +868,7 @@ async def test_invalid_availability_template_keeps_component_available( ], ) @pytest.mark.usefixtures("start_ha") -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_legacy_unique_id(hass: HomeAssistant) -> None: """Test unique_id option only creates one lock per id.""" await setup.async_setup_component( hass, @@ -718,3 +888,129 @@ async def test_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all("lock")) == 1 + + +async def test_modern_unique_id(hass: HomeAssistant) -> None: + """Test unique_id option only creates one cover per id.""" + config = { + "template": { + "lock": [ + { + "name": "test_template_lock_01", + "unique_id": "not-so-unique-anymore", + "state": "{{ false }}", + **OPTIMISTIC_LOCK, + }, + { + "name": "test_template_lock_02", + "unique_id": "not-so-unique-anymore", + "state": "{{ false }}", + **OPTIMISTIC_LOCK, + }, + ] + } + } + + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a template unique_id propagates to lock unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "lock": [ + { + **OPTIMISTIC_LOCK, + "name": "test_a", + "unique_id": "a", + "state": "{{ true }}", + }, + { + **OPTIMISTIC_LOCK, + "name": "test_b", + "unique_id": "b", + "state": "{{ true }}", + }, + ], + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("lock")) == 2 + + entry = entity_registry.async_get("lock.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("lock.test_b") + assert entry + assert entry.unique_id == "x-b" + + +async def test_emtpy_action_config(hass: HomeAssistant) -> None: + """Test configuration with empty script.""" + with assert_setup_component(1, lock.DOMAIN): + assert await setup.async_setup_component( + hass, + lock.DOMAIN, + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ 0 == 1 }}", + "lock": [], + "unlock": [], + "open": [], + "name": "test_template_lock", + "optimistic": True, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.attributes["supported_features"] == LockEntityFeature.OPEN + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.test_template_lock"}, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.state == LockState.UNLOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.test_template_lock"}, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.state == LockState.LOCKED diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index ec96245b4d0..5201541e2e0 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -1,8 +1,12 @@ """The tests for the Template number platform.""" +from typing import Any + +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import setup +from homeassistant.components import number, template from homeassistant.components.input_number import ( ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE, DOMAIN as INPUT_NUMBER_DOMAIN, @@ -18,6 +22,7 @@ from homeassistant.components.number import ( ) from homeassistant.components.template import DOMAIN from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, CONF_UNIT_OF_MEASUREMENT, @@ -25,10 +30,14 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import MockConfigEntry, assert_setup_component, async_capture_events -_TEST_NUMBER = "number.template_number" +_TEST_OBJECT_ID = "template_number" +_TEST_NUMBER = f"number.{_TEST_OBJECT_ID}" # Represent for number's value _VALUE_INPUT_NUMBER = "input_number.value" # Represent for number's minimum @@ -50,6 +59,38 @@ _VALUE_INPUT_NUMBER_CONFIG = { } +async def async_setup_modern_format( + hass: HomeAssistant, count: int, number_config: dict[str, Any] +) -> None: + """Do setup of number integration via new format.""" + config = {"template": {"number": number_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_number( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + number_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": _TEST_OBJECT_ID, **number_config} + ) + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -330,7 +371,10 @@ async def test_trigger_number(hass: HomeAssistant) -> None: "max": "{{ trigger.event.data.max_beers }}", "step": "{{ trigger.event.data.step }}", "unit_of_measurement": "beer", - "set_value": {"event": "test_number_event"}, + "set_value": { + "event": "test_number_event", + "event_data": {"entity_id": "{{ this.entity_id }}"}, + }, "optimistic": True, }, ], @@ -379,6 +423,9 @@ async def test_trigger_number(hass: HomeAssistant) -> None: ) assert len(events) == 1 assert events[0].event_type == "test_number_event" + entity_id = events[0].data.get("entity_id") + assert entity_id is not None + assert entity_id == "number.hello_name" def _verify( @@ -559,3 +606,36 @@ async def test_device_id( template_entity = entity_registry.async_get("number.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("count", "number_config"), + [ + ( + 1, + { + "state": "{{ 1 }}", + "set_value": [], + "step": "{{ 1 }}", + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.MODERN, + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_number) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + number.DOMAIN, + number.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _TEST_NUMBER, "value": 4}, + blocking=True, + ) + + state = hass.states.get(_TEST_NUMBER) + assert float(state.state) == 4 diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 5b4723a3034..b2bc56af44a 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -1,8 +1,12 @@ """The tests for the Template select platform.""" +from typing import Any + +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import setup +from homeassistant.components import select, template from homeassistant.components.input_select import ( ATTR_OPTION as INPUT_SELECT_ATTR_OPTION, ATTR_OPTIONS as INPUT_SELECT_ATTR_OPTIONS, @@ -17,17 +21,53 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, ) from homeassistant.components.template import DOMAIN -from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import MockConfigEntry, assert_setup_component, async_capture_events -_TEST_SELECT = "select.template_select" +_TEST_OBJECT_ID = "template_select" +_TEST_SELECT = f"select.{_TEST_OBJECT_ID}" # Represent for select's current_option _OPTION_INPUT_SELECT = "input_select.option" +async def async_setup_modern_format( + hass: HomeAssistant, count: int, select_config: dict[str, Any] +) -> None: + """Do setup of select integration via new format.""" + config = {"template": {"select": select_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_select( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + select_config: dict[str, Any], +) -> None: + """Do setup of select integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": _TEST_OBJECT_ID, **select_config} + ) + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -264,6 +304,7 @@ async def test_templates_with_entities( async def test_trigger_select(hass: HomeAssistant) -> None: """Test trigger based template select.""" events = async_capture_events(hass, "test_number_event") + action_events = async_capture_events(hass, "action_event") assert await setup.async_setup_component( hass, "template", @@ -274,13 +315,23 @@ async def test_trigger_select(hass: HomeAssistant) -> None: { "unique_id": "listening-test-event", "trigger": {"platform": "event", "event_type": "test_event"}, + "variables": {"beer": "{{ trigger.event.data.beer }}"}, + "action": [ + {"event": "action_event", "event_data": {"beer": "{{ beer }}"}} + ], "select": [ { "name": "Hello Name", "unique_id": "hello_name-id", "state": "{{ trigger.event.data.beer }}", "options": "{{ trigger.event.data.beers }}", - "select_option": {"event": "test_number_event"}, + "select_option": { + "event": "test_number_event", + "event_data": { + "entity_id": "{{ this.entity_id }}", + "beer": "{{ beer }}", + }, + }, "optimistic": True, }, ], @@ -308,6 +359,12 @@ async def test_trigger_select(hass: HomeAssistant) -> None: assert state.state == "duff" assert state.attributes["options"] == ["duff", "alamo"] + assert len(action_events) == 1 + assert action_events[0].event_type == "action_event" + beer = action_events[0].data.get("beer") + assert beer is not None + assert beer == "duff" + await hass.services.async_call( SELECT_DOMAIN, SELECT_SERVICE_SELECT_OPTION, @@ -316,6 +373,13 @@ async def test_trigger_select(hass: HomeAssistant) -> None: ) assert len(events) == 1 assert events[0].event_type == "test_number_event" + entity_id = events[0].data.get("entity_id") + assert entity_id is not None + assert entity_id == "select.hello_name" + + beer = events[0].data.get("beer") + assert beer is not None + assert beer == "duff" def _verify( @@ -503,3 +567,36 @@ async def test_device_id( template_entity = entity_registry.async_get("select.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "state": "{{ 'b' }}", + "select_option": [], + "options": "{{ ['a', 'b'] }}", + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.MODERN, + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "a"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "a" diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 6f0e6be8a2a..56eaa120b20 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1527,6 +1527,217 @@ async def test_trigger_entity_available(hass: HomeAssistant) -> None: assert state.state == "unavailable" +async def test_trigger_entity_available_skips_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity availability works.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Never Available", + "availability": "{{ trigger and trigger.event.data.beer == 2 }}", + "state": "{{ noexist - 1 }}", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.never_available") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 1}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.never_available") + assert state.state == "unavailable" + + assert "'noexist' is undefined" not in caplog.text + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.never_available") + assert state.state == "unavailable" + + assert "'noexist' is undefined" in caplog.text + + +async def test_trigger_state_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity is available when attributes have syntax errors.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Test Sensor", + "availability": "{{ what_the_heck == 2 }}", + "state": "{{ trigger.event.data.beer }}", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert ( + "Error rendering availability template for sensor.test_sensor: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +async def test_trigger_available_with_attribute_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity is available when attributes have syntax errors.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Test Sensor", + "availability": "{{ trigger and trigger.event.data.beer == 2 }}", + "state": "{{ trigger.event.data.beer }}", + "attributes": { + "beer": "{{ trigger.event.data.beer }}", + "no_beer": "{{ sad - 1 }}", + "more_beer": "{{ beer + 1 }}", + }, + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert state.attributes["beer"] == 2 + assert "no_beer" not in state.attributes + assert ( + "Error rendering attributes.no_beer template for sensor.test_sensor: UndefinedError: 'sad' is undefined" + in caplog.text + ) + assert state.attributes["more_beer"] == 3 + + +async def test_trigger_attribute_order( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity attributes order.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Test Sensor", + "availability": "{{ trigger and trigger.event.data.beer == 2 }}", + "state": "{{ trigger.event.data.beer }}", + "attributes": { + "beer": "{{ trigger.event.data.beer }}", + "no_beer": "{{ sad - 1 }}", + "more_beer": "{{ beer + 1 }}", + "all_the_beer": "{{ this.state | int + more_beer }}", + }, + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert state.attributes["beer"] == 2 + assert "no_beer" not in state.attributes + assert ( + "Error rendering attributes.no_beer template for sensor.test_sensor: UndefinedError: 'sad' is undefined" + in caplog.text + ) + assert state.attributes["more_beer"] == 3 + assert ( + "Error rendering attributes.all_the_beer template for sensor.test_sensor: ValueError: Template error: int got invalid input 'unknown' when rendering template '{{ this.state | int + more_beer }}' but no default was specified" + in caplog.text + ) + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert state.attributes["beer"] == 2 + assert state.attributes["more_beer"] == 3 + assert state.attributes["all_the_beer"] == 5 + + assert ( + caplog.text.count( + "Error rendering attributes.no_beer template for sensor.test_sensor: UndefinedError: 'sad' is undefined" + ) + == 2 + ) + + async def test_trigger_entity_device_class_parsing_works(hass: HomeAssistant) -> None: """Test trigger entity device class parsing works.""" assert await async_setup_component( @@ -2092,6 +2303,61 @@ async def test_trigger_conditional_action(hass: HomeAssistant) -> None: assert len(events) == 1 +@pytest.mark.parametrize("trigger_field", ["trigger", "triggers"]) +@pytest.mark.parametrize("condition_field", ["condition", "conditions"]) +@pytest.mark.parametrize("action_field", ["action", "actions"]) +async def test_legacy_and_new_config_schema( + hass: HomeAssistant, trigger_field: str, condition_field: str, action_field: str +) -> None: + """Tests that both old and new config schema (singular -> plural) work.""" + + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "unique_id": "listening-test-event", + f"{trigger_field}": { + "platform": "event", + "event_type": "beer_event", + }, + f"{condition_field}": [ + { + "condition": "template", + "value_template": "{{ trigger.event.data.beer >= 42 }}", + } + ], + f"{action_field}": [ + {"event": "test_event_by_action"}, + ], + "sensor": [ + { + "name": "Unimportant", + "state": "Uninteresting", + } + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + event = "test_event_by_action" + events = async_capture_events(hass, event) + + hass.bus.async_fire("beer_event", {"beer": 1}) + await hass.async_block_till_done() + + assert len(events) == 0 + + hass.bus.async_fire("beer_event", {"beer": 42}) + await hass.async_block_till_done() + + assert len(events) == 1 + + async def test_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index 2fc0f29acaf..de6894c73a8 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -1,11 +1,14 @@ """The tests for the Template switch platform.""" +from typing import Any + import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant import setup -from homeassistant.components import template +from homeassistant.components import switch, template from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.template.switch import rewrite_legacy_to_modern_conf +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -15,36 +18,347 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component +from .conftest import ConfigurationStyle + from tests.common import ( MockConfigEntry, assert_setup_component, mock_component, mock_restore_cache, ) +from tests.typing import WebSocketGenerator -OPTIMISTIC_SWITCH_CONFIG = { - "turn_on": { - "service": "test.automation", - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, +TEST_OBJECT_ID = "test_template_switch" +TEST_ENTITY_ID = f"switch.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "switch.test_state" + +TEST_EVENT_TRIGGER = { + "trigger": {"platform": "event", "event_type": "test_event"}, + "variables": {"type": "{{ trigger.event.data.type }}"}, + "action": [{"event": "action_event", "event_data": {"type": "{{ type }}"}}], +} + +SWITCH_TURN_ON = { + "service": "test.automation", + "data_template": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", }, - "turn_off": { - "service": "test.automation", - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, +} +SWITCH_TURN_OFF = { + "service": "test.automation", + "data_template": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", }, } +SWITCH_ACTIONS = { + "turn_on": SWITCH_TURN_ON, + "turn_off": SWITCH_TURN_OFF, +} +NAMED_SWITCH_ACTIONS = { + **SWITCH_ACTIONS, + "name": TEST_OBJECT_ID, +} +UNIQUE_ID_CONFIG = { + **SWITCH_ACTIONS, + "unique_id": "not-so-unique-anymore", +} +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, switch_config: dict[str, Any] +) -> None: + """Do setup of switch integration via legacy format.""" + config = {"switch": {"platform": "template", "switches": switch_config}} + + with assert_setup_component(count, switch.DOMAIN): + assert await async_setup_component( + hass, + switch.DOMAIN, + config, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, switch_config: dict[str, Any] +) -> None: + """Do setup of switch integration via modern format.""" + config = {"template": {"switch": switch_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, switch_config: dict[str, Any] +) -> None: + """Do setup of switch integration via modern format.""" + config = {"template": {**TEST_EVENT_TRIGGER, "switch": switch_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_ensure_triggered_entity_updates( + hass: HomeAssistant, style: ConfigurationStyle, **kwargs +) -> None: + """Trigger template entities.""" + if style == ConfigurationStyle.TRIGGER: + hass.bus.async_fire("test_event", {"type": "test_event", **kwargs}) + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + switch_config: dict[str, Any], +) -> None: + """Do setup of switch integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, switch_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, switch_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, switch_config) + + +@pytest.fixture +async def setup_state_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of switch integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + "value_template": state_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + "state": state_template, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + "state": state_template, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of switch integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + "value_template": "{{ 1 == 1 }}", + **extra, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) + + +@pytest.fixture +async def setup_optimistic_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, +) -> None: + """Do setup of an optimistic switch.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_optimistic_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of switch integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + **extra, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + **extra, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + **extra, + }, + ) + + +async def test_legacy_to_modern_config(hass: HomeAssistant) -> None: + """Test the conversion of legacy template to modern template.""" + config = { + "foo": { + "friendly_name": "foo bar", + "value_template": "{{ 1 == 1 }}", + "unique_id": "foo-bar-switch", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + **SWITCH_ACTIONS, + } + } + altered_configs = rewrite_legacy_to_modern_conf(hass, config) + + assert len(altered_configs) == 1 + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "turn_off": SWITCH_TURN_OFF, + "turn_on": SWITCH_TURN_ON, + "unique_id": "foo-bar-switch", + "state": Template("{{ 1 == 1 }}", hass), + } + ] == altered_configs + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ True }}")]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +async def test_setup( + hass: HomeAssistant, style: ConfigurationStyle, setup_state_switch +) -> None: + """Test template.""" + await async_ensure_triggered_entity_updates(hass, style) + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.name == TEST_OBJECT_ID + assert state.state == STATE_ON + + +@pytest.mark.parametrize("state_key", ["value_template", "state"]) async def test_setup_config_entry( hass: HomeAssistant, + state_key: str, snapshot: SnapshotAssertion, ) -> None: """Test the config flow.""" @@ -60,7 +374,7 @@ async def test_setup_config_entry( domain=template.DOMAIN, options={ "name": "My template", - "value_template": "{{ states('switch.one') }}", + state_key: "{{ states('switch.one') }}", "template_type": SWITCH_DOMAIN, }, title="My template", @@ -75,200 +389,245 @@ async def test_setup_config_entry( assert state == snapshot -async def test_template_state_text(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("state_key", ["value_template", "state"]) +async def test_flow_preview( + hass: HomeAssistant, + state_key: str, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + result = await hass.config_entries.flow.async_init( + template.DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": SWITCH_DOMAIN}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SWITCH_DOMAIN + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", state_key: "{{ 'on' }}"}, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"]["state"] == "on" + + +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +async def test_template_state_text( + hass: HomeAssistant, style: ConfigurationStyle, setup_state_switch +) -> None: """Test the state text of a template.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("switch.test_state", STATE_ON) - await hass.async_block_till_done() + await async_ensure_triggered_entity_updates(hass, style) - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON - hass.states.async_set("switch.test_state", STATE_OFF) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") + await async_ensure_triggered_entity_updates(hass, style) + + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF -async def test_template_state_boolean_on(hass: HomeAssistant) -> None: - """Test the setting of the state with boolean on.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ 1 == 1 }}", - } - }, - } - }, - ) +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("expected", "state_template"), + [ + (STATE_ON, "{{ 1 == 1 }}"), + (STATE_OFF, "{{ 1 == 2 }}"), + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +async def test_template_state_boolean( + hass: HomeAssistant, expected: str, style: ConfigurationStyle, setup_state_switch +) -> None: + """Test the setting of the state with boolean template.""" + await async_ensure_triggered_entity_updates(hass, style) + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected - await hass.async_block_till_done() - await hass.async_start() + +@pytest.mark.parametrize( + ("count", "attribute_template"), + [(1, "{% if states.switch.test_state.state %}mdi:check{% endif %}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "icon_template"), + (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.TRIGGER, "icon"), + ], +) +async def test_icon_template( + hass: HomeAssistant, style: ConfigurationStyle, setup_single_attribute_switch +) -> None: + """Test the state text of a template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") in ("", None) + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") - assert state.state == STATE_ON + await async_ensure_triggered_entity_updates(hass, style) - -async def test_template_state_boolean_off(hass: HomeAssistant) -> None: - """Test the setting of the state with off.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ 1 == 2 }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") - assert state.state == STATE_OFF - - -async def test_icon_template(hass: HomeAssistant) -> None: - """Test icon template.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "icon_template": ( - "{% if states.switch.test_state.state %}" - "mdi:check" - "{% endif %}" - ), - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") - assert state.attributes.get("icon") == "" - - hass.states.async_set("switch.test_state", STATE_ON) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["icon"] == "mdi:check" -async def test_entity_picture_template(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("config_attr", "attribute", "expected"), + [("icon", "icon", "mdi:icon"), ("picture", "entity_picture", "picture.jpg")], +) +async def test_attributes_with_optimistic_state( + hass: HomeAssistant, + config_attr: str, + attribute: str, + expected: str, + calls: list[ServiceCall], +) -> None: + """Test attributes when trigger entity is optimistic.""" + await async_setup_trigger_format( + hass, + 1, + { + **NAMED_SWITCH_ACTIONS, + config_attr: "{{ trigger.event.data.attr }}", + }, + ) + + hass.states.async_set(TEST_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes.get(attribute) is None + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes.get(attribute) is None + + assert len(calls) == 1 + assert calls[-1].data["action"] == "turn_on" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes.get(attribute) is None + + assert len(calls) == 2 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + await async_ensure_triggered_entity_updates( + hass, ConfigurationStyle.TRIGGER, attr=expected + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes.get(attribute) == expected + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes.get(attribute) == expected + + assert len(calls) == 3 + assert calls[-1].data["action"] == "turn_on" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + +@pytest.mark.parametrize( + ("count", "attribute_template"), + [(1, "{% if states.switch.test_state.state %}/local/switch.png{% endif %}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "entity_picture_template"), + (ConfigurationStyle.MODERN, "picture"), + (ConfigurationStyle.TRIGGER, "picture"), + ], +) +async def test_entity_picture_template( + hass: HomeAssistant, style: ConfigurationStyle, setup_single_attribute_switch +) -> None: """Test entity_picture template.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "entity_picture_template": ( - "{% if states.switch.test_state.state %}" - "/local/switch.png" - "{% endif %}" - ), - } - }, - } - }, - ) + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") in ("", None) - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") - assert state.attributes.get("entity_picture") == "" + await async_ensure_triggered_entity_updates(hass, style) - hass.states.async_set("switch.test_state", STATE_ON) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["entity_picture"] == "/local/switch.png" -async def test_template_syntax_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("count", "state_template"), [(0, "{% if rubbish %}")]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +async def test_template_syntax_error(hass: HomeAssistant, setup_state_switch) -> None: """Test templating syntax error.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{% if rubbish %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.async_all("switch") == [] -async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: - """Test invalid name.""" +async def test_invalid_legacy_slug_does_not_create(hass: HomeAssistant) -> None: + """Test invalid legacy slug.""" with assert_setup_component(0, "switch"): assert await async_setup_component( hass, @@ -278,7 +637,7 @@ async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: "platform": "template", "switches": { "test INVALID switch": { - **OPTIMISTIC_SWITCH_CONFIG, + **SWITCH_ACTIONS, "value_template": "{{ rubbish }", } }, @@ -293,19 +652,32 @@ async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: assert hass.states.async_all("switch") == [] -async def test_invalid_switch_does_not_create(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "template": {"switch": "Invalid"}, + }, + template.DOMAIN, + ), + ( + { + "switch": { + "platform": "template", + "switches": {TEST_OBJECT_ID: "Invalid"}, + } + }, + switch.DOMAIN, + ), + ], +) +async def test_invalid_switch_does_not_create( + hass: HomeAssistant, config: dict, domain: str +) -> None: """Test invalid switch.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": {"test_template_switch": "Invalid"}, - } - }, - ) + with assert_setup_component(0, domain): + assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() @@ -314,12 +686,33 @@ async def test_invalid_switch_does_not_create(hass: HomeAssistant) -> None: assert hass.states.async_all("switch") == [] -async def test_no_switches_does_not_create(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("config", "domain", "count"), + [ + ( + { + "template": {"switch": []}, + }, + template.DOMAIN, + 1, + ), + ( + { + "switch": { + "platform": "template", + } + }, + switch.DOMAIN, + 0, + ), + ], +) +async def test_no_switches_does_not_create( + hass: HomeAssistant, config: dict, domain: str, count: int +) -> None: """Test if there are no switches no creation.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, "switch", {"switch": {"platform": "template"}} - ) + with assert_setup_component(count, domain): + assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() @@ -328,239 +721,286 @@ async def test_no_switches_does_not_create(hass: HomeAssistant) -> None: assert hass.states.async_all("switch") == [] -async def test_missing_on_does_not_create(hass: HomeAssistant) -> None: - """Test missing on.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - "value_template": "{{ states.switch.test_state.state }}", - "not_on": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "turn_off": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all("switch") == [] - - -async def test_missing_off_does_not_create(hass: HomeAssistant) -> None: - """Test missing off.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - "value_template": "{{ states.switch.test_state.state }}", - "turn_on": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "not_off": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all("switch") == [] - - -async def test_on_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test on action.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", + "template": { + "switch": { + "not_on": SWITCH_TURN_ON, + "turn_off": SWITCH_TURN_OFF, + "state": "{{ states.switch.test_state.state }}", } }, - } - }, - ) + }, + template.DOMAIN, + ), + ( + { + "switch": { + "platform": "template", + "switches": { + TEST_OBJECT_ID: { + "not_on": SWITCH_TURN_ON, + "turn_off": SWITCH_TURN_OFF, + "value_template": "{{ states.switch.test_state.state }}", + } + }, + } + }, + switch.DOMAIN, + ), + ], +) +async def test_missing_on_does_not_create( + hass: HomeAssistant, config: dict, domain: str +) -> None: + """Test missing on.""" + with assert_setup_component(0, domain): + assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("switch.test_state", STATE_OFF) + assert hass.states.async_all("switch") == [] + + +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "template": { + "switch": { + "turn_on": SWITCH_TURN_ON, + "not_off": SWITCH_TURN_OFF, + "state": "{{ states.switch.test_state.state }}", + } + }, + }, + template.DOMAIN, + ), + ( + { + "switch": { + "platform": "template", + "switches": { + TEST_OBJECT_ID: { + "turn_on": SWITCH_TURN_ON, + "not_off": SWITCH_TURN_OFF, + "value_template": "{{ states.switch.test_state.state }}", + } + }, + } + }, + switch.DOMAIN, + ), + ], +) +async def test_missing_off_does_not_create( + hass: HomeAssistant, config: dict, domain: str +) -> None: + """Test missing off.""" + with assert_setup_component(0, domain): + assert await async_setup_component(hass, domain, config) + + await hass.async_block_till_done() + await hass.async_start() await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") + assert hass.states.async_all("switch") == [] + + +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states('switch.test_state') }}")] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +async def test_on_action( + hass: HomeAssistant, + style: ConfigurationStyle, + setup_state_switch, + calls: list[ServiceCall], +) -> None: + """Test on action.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + await async_ensure_triggered_entity_updates(hass, style) + + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) assert len(calls) == 1 assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) async def test_on_action_optimistic( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, setup_optimistic_switch, calls: list[ServiceCall] ) -> None: """Test on action in optimistic mode.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - } - }, - } - }, - ) - - await hass.async_start() + hass.states.async_set(TEST_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - hass.states.async_set("switch.test_template_switch", STATE_OFF) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON assert len(calls) == 1 assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID -async def test_off_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +async def test_off_action( + hass: HomeAssistant, + style: ConfigurationStyle, + setup_state_switch, + calls: list[ServiceCall], +) -> None: """Test off action.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("switch.test_state", STATE_ON) - await hass.async_block_till_done() + await async_ensure_triggered_entity_updates(hass, style) - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) assert len(calls) == 1 assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) async def test_off_action_optimistic( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, setup_optimistic_switch, calls: list[ServiceCall] ) -> None: """Test off action in optimistic mode.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - } - }, - } - }, - ) - - await hass.async_start() + hass.states.async_set(TEST_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("switch.test_template_switch", STATE_ON) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF assert len(calls) == 1 assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID -async def test_restore_state(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "switch": { + "platform": "template", + "switches": { + "s1": { + **SWITCH_ACTIONS, + }, + "s2": { + **SWITCH_ACTIONS, + }, + }, + } + }, + switch.DOMAIN, + ), + ( + { + "template": { + "switch": [ + { + "name": "s1", + **SWITCH_ACTIONS, + }, + { + "name": "s2", + **SWITCH_ACTIONS, + }, + ], + } + }, + template.DOMAIN, + ), + ( + { + "template": { + "trigger": {"trigger": "event", "event_type": "test_event"}, + "switch": [ + { + "name": "s1", + **SWITCH_ACTIONS, + }, + { + "name": "s2", + **SWITCH_ACTIONS, + }, + ], + } + }, + template.DOMAIN, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, count: int, domain: str, config: dict[str, Any] +) -> None: """Test state restoration.""" mock_restore_cache( hass, @@ -573,23 +1013,9 @@ async def test_restore_state(hass: HomeAssistant) -> None: hass.set_state(CoreState.starting) mock_component(hass, "recorder") - await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "s1": { - **OPTIMISTIC_SWITCH_CONFIG, - }, - "s2": { - **OPTIMISTIC_SWITCH_CONFIG, - }, - }, - } - }, - ) + with assert_setup_component(count, domain): + await async_setup_component(hass, domain, config) + await hass.async_block_till_done() state = hass.states.get("switch.s1") @@ -601,100 +1027,162 @@ async def test_restore_state(hass: HomeAssistant) -> None: assert state.state == STATE_OFF -async def test_available_template_with_entities(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("count", "attribute_template"), + [(1, "{{ is_state('switch.test_state', 'on') }}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), + ], +) +async def test_available_template_with_entities( + hass: HomeAssistant, style: ConfigurationStyle, setup_single_attribute_switch +) -> None: """Test availability templates with values from other entities.""" - await setup.async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ 1 == 1 }}", - "availability_template": ( - "{{ is_state('availability_state.state', 'on') }}" - ), - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("availability_state.state", STATE_ON) + await async_ensure_triggered_entity_updates(hass, style) + + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("switch.test_template_switch").state != STATE_UNAVAILABLE + await async_ensure_triggered_entity_updates(hass, style) - hass.states.async_set("availability_state.state", STATE_OFF) - await hass.async_block_till_done() - - assert hass.states.get("switch.test_template_switch").state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "switch": { + "platform": "template", + "switches": { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + "value_template": "{{ true }}", + "availability_template": "{{ x - 12 }}", + } + }, + } + }, + switch.DOMAIN, + ), + ( + { + "template": { + "switch": { + **NAMED_SWITCH_ACTIONS, + "state": "{{ true }}", + "availability": "{{ x - 12 }}", + }, + } + }, + template.DOMAIN, + ), + ], +) async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + count: int, + config: dict[str, Any], + domain: str, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that an invalid availability keeps the device available.""" - await setup.async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ true }}", - "availability_template": "{{ x - 12 }}", - } - }, - } - }, - ) + with assert_setup_component(count, domain): + await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - assert hass.states.get("switch.test_template_switch").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE assert "UndefinedError: 'x' is undefined" in caplog.text -async def test_unique_id(hass: HomeAssistant) -> None: - """Test unique_id option only creates one switch per id.""" - await setup.async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch_01": { - **OPTIMISTIC_SWITCH_CONFIG, - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - }, - "test_template_switch_02": { - **OPTIMISTIC_SWITCH_CONFIG, - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - }, +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("switch_config", "style"), + [ + ( + { + "test_template_switch_01": UNIQUE_ID_CONFIG, + "test_template_switch_02": UNIQUE_ID_CONFIG, + }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_switch_01", + **UNIQUE_ID_CONFIG, }, - } - }, - ) + { + "name": "test_template_switch_02", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), + ], +) +async def test_unique_id(hass: HomeAssistant, setup_switch) -> None: + """Test unique_id option only creates one switch per id.""" + assert len(hass.states.async_all("switch")) == 1 + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a template unique_id propagates to switch unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "switch": [ + { + **SWITCH_ACTIONS, + "name": "test_a", + "unique_id": "a", + "state": "{{ true }}", + }, + { + **SWITCH_ACTIONS, + "name": "test_b", + "unique_id": "b", + "state": "{{ true }}", + }, + ], + }, + }, + ) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - assert len(hass.states.async_all("switch")) == 1 + assert len(hass.states.async_all("switch")) == 2 + + entry = entity_registry.async_get("switch.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("switch.test_b") + assert entry + assert entry.unique_id == "x-b" async def test_device_id( @@ -720,7 +1208,7 @@ async def test_device_id( domain=template.DOMAIN, options={ "name": "My template", - "value_template": "{{ true }}", + "state": "{{ true }}", "template_type": "switch", "device_id": device_entry.id, }, @@ -734,3 +1222,49 @@ async def test_device_id( template_entity = entity_registry.async_get("switch.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "switch_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + TEST_OBJECT_ID: { + "turn_on": [], + "turn_off": [], + }, + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "turn_on": [], + "turn_off": [], + }, + ), + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_switch) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index c09a09750fe..d66fc2710c9 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers import template async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity(hass) + entity = template_entity.TemplateEntity(None) with pytest.raises(ValueError, match="^hass cannot be None"): entity.add_template_attribute("_hello", template.Template("Hello")) diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py new file mode 100644 index 00000000000..65db69fa2b9 --- /dev/null +++ b/tests/components/template/test_trigger_entity.py @@ -0,0 +1,136 @@ +"""Test trigger template entity.""" + +import pytest + +from homeassistant.components.template import trigger_entity +from homeassistant.components.template.coordinator import TriggerUpdateCoordinator +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_STATE, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import template +from homeassistant.helpers.trigger_template_entity import CONF_PICTURE + +_ICON_TEMPLATE = 'mdi:o{{ "n" if value=="on" else "ff" }}' +_PICTURE_TEMPLATE = '/local/picture_o{{ "n" if value=="on" else "ff" }}' + + +class TestEntity(trigger_entity.TriggerEntity): + """Test entity class.""" + + __test__ = False + extra_template_keys = (CONF_STATE,) + + @property + def state(self) -> bool | None: + """Return extra attributes.""" + return self._rendered.get(CONF_STATE) + + +async def test_reference_blueprints_is_none(hass: HomeAssistant) -> None: + """Test template entity requires hass to be set before accepting templates.""" + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = trigger_entity.TriggerEntity(hass, coordinator, {}) + + assert entity.referenced_blueprint is None + + +async def test_template_state(hass: HomeAssistant) -> None: + """Test manual trigger template entity with a state.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ value == 'on' }}", hass), + } + + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, config) + entity.entity_id = "test.entity" + + coordinator._execute_update({"value": STATE_ON}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.state == "True" + assert entity.icon == "mdi:on" + assert entity.entity_picture == "/local/picture_on" + + coordinator._execute_update({"value": STATE_OFF}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.state == "False" + assert entity.icon == "mdi:off" + assert entity.entity_picture == "/local/picture_off" + + +async def test_bad_template_state(hass: HomeAssistant) -> None: + """Test manual trigger template entity with a state.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ x - 1 }}", hass), + } + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, config) + entity.entity_id = "test.entity" + + coordinator._execute_update({"x": 1}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.available is True + assert entity.state == "0" + assert entity.icon == "mdi:off" + assert entity.entity_picture == "/local/picture_off" + + coordinator._execute_update({"value": STATE_OFF}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.available is False + assert entity.state is None + assert entity.icon is None + assert entity.entity_picture is None + + +async def test_template_state_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when state render fails.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ incorrect ", hass), + } + + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, config) + entity.entity_id = "test.entity" + + coordinator._execute_update({"value": STATE_ON}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert f"Error rendering {CONF_STATE} template for test.entity" in caplog.text + + assert entity.state is None + assert entity.icon is None + assert entity.entity_picture is None + + +async def test_script_variables_from_coordinator(hass: HomeAssistant) -> None: + """Test script variables.""" + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, {}) + + assert entity._render_script_variables() == {} + + coordinator.data = {"run_variables": None} + + assert entity._render_script_variables() == {} + + coordinator._execute_update({"value": STATE_ON}) + + assert entity._render_script_variables() == {"value": STATE_ON} diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 6053a2bd9ec..90ca0b56afb 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -1,174 +1,568 @@ """The tests for the Template vacuum platform.""" +from typing import Any + import pytest -from homeassistant import setup -from homeassistant.components.vacuum import ATTR_BATTERY_LEVEL, VacuumActivity +from homeassistant.components import template, vacuum +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, + VacuumActivity, + VacuumEntityFeature, +) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import assert_setup_component from tests.components.vacuum import common -_TEST_VACUUM = "vacuum.test_vacuum" -_STATE_INPUT_SELECT = "input_select.state" -_SPOT_CLEANING_INPUT_BOOLEAN = "input_boolean.spot_cleaning" -_LOCATING_INPUT_BOOLEAN = "input_boolean.locating" -_FAN_SPEED_INPUT_SELECT = "input_select.fan_speed" -_BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" +TEST_OBJECT_ID = "test_vacuum" +TEST_ENTITY_ID = f"vacuum.{TEST_OBJECT_ID}" + +STATE_INPUT_SELECT = "input_select.state" +BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" + +START_ACTION = { + "start": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "start", + }, + }, +} -@pytest.mark.parametrize(("count", "domain"), [(1, "vacuum")]) +TEMPLATE_VACUUM_ACTIONS = { + **START_ACTION, + "pause": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "pause", + }, + }, + "stop": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "stop", + }, + }, + "return_to_base": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "return_to_base", + }, + }, + "clean_spot": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "clean_spot", + }, + }, + "locate": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "locate", + }, + }, + "set_fan_speed": { + "service": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "set_fan_speed", + "fan_speed": "{{ fan_speed }}", + }, + }, +} + +UNIQUE_ID_CONFIG = {"unique_id": "not-so-unique-anymore", **TEMPLATE_VACUUM_ACTIONS} + + +def _verify( + hass: HomeAssistant, + expected_state: str, + expected_battery_level: int | None = None, + expected_fan_speed: int | None = None, +) -> None: + """Verify vacuum's state and speed.""" + state = hass.states.get(TEST_ENTITY_ID) + attributes = state.attributes + assert state.state == expected_state + assert attributes.get(ATTR_BATTERY_LEVEL) == expected_battery_level + assert attributes.get(ATTR_FAN_SPEED) == expected_fan_speed + + +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] +) -> None: + """Do setup of vacuum integration via new format.""" + config = {"vacuum": {"platform": "template", "vacuums": vacuum_config}} + + with assert_setup_component(count, vacuum.DOMAIN): + assert await async_setup_component( + hass, + vacuum.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] +) -> None: + """Do setup of vacuum integration via modern format.""" + config = {"template": {"vacuum": vacuum_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + vacuum_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, vacuum_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, vacuum_config) + + +@pytest.fixture +async def setup_test_vacuum_with_extra_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + vacuum_config: dict[str, Any], + extra_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, count, {TEST_OBJECT_ID: {**vacuum_config, **extra_config}} + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": TEST_OBJECT_ID, **vacuum_config, **extra_config} + ) + + +@pytest.fixture +async def setup_state_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of vacuum integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + "value_template": state_template, + **TEMPLATE_VACUUM_ACTIONS, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **TEMPLATE_VACUUM_ACTIONS, + }, + ) + + +@pytest.fixture +async def setup_base_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str | None, + extra_config: dict, +): + """Do setup of vacuum integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + state_config = {"value_template": state_template} if state_template else {} + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **state_config, + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + state_config = {"state": state_template} if state_template else {} + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **state_config, + **extra_config, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_state_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str | None, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of vacuum integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + if style == ConfigurationStyle.LEGACY: + state_config = {"value_template": state_template} if state_template else {} + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + **extra, + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + state_config = {"state": state_template} if state_template else {} + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + **extra, + **extra_config, + }, + ) + + +@pytest.fixture +async def setup_attributes_state_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str | None, + attributes: dict, +) -> None: + """Do setup of vacuum integration testing a single attribute.""" + if style == ConfigurationStyle.LEGACY: + state_config = {"value_template": state_template} if state_template else {} + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + "attribute_templates": attributes, + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + state_config = {"state": state_template} if state_template else {} + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "attributes": attributes, + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + }, + ) + + +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("parm1", "parm2", "config"), + ("style", "state_template", "extra_config", "parm1", "parm2"), [ ( + ConfigurationStyle.LEGACY, + None, + {"start": {"service": "script.vacuum_start"}}, STATE_UNKNOWN, None, - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": {"start": {"service": "script.vacuum_start"}} - }, - } - }, ), ( + ConfigurationStyle.MODERN, + None, + {"start": {"service": "script.vacuum_start"}}, + STATE_UNKNOWN, + None, + ), + ( + ConfigurationStyle.LEGACY, + "{{ 'cleaning' }}", + { + "battery_level_template": "{{ 100 }}", + "start": {"service": "script.vacuum_start"}, + }, VacuumActivity.CLEANING, 100, - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ 'cleaning' }}", - "battery_level_template": "{{ 100 }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, ), ( - STATE_UNKNOWN, - None, + ConfigurationStyle.MODERN, + "{{ 'cleaning' }}", { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ 'abc' }}", - "battery_level_template": "{{ 101 }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } + "battery_level": "{{ 100 }}", + "start": {"service": "script.vacuum_start"}, }, + VacuumActivity.CLEANING, + 100, ), ( + ConfigurationStyle.LEGACY, + "{{ 'abc' }}", + { + "battery_level_template": "{{ 101 }}", + "start": {"service": "script.vacuum_start"}, + }, STATE_UNKNOWN, None, + ), + ( + ConfigurationStyle.MODERN, + "{{ 'abc' }}", { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ this_function_does_not_exist() }}", - "battery_level_template": "{{ this_function_does_not_exist() }}", - "fan_speed_template": "{{ this_function_does_not_exist() }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } + "battery_level": "{{ 101 }}", + "start": {"service": "script.vacuum_start"}, }, + STATE_UNKNOWN, + None, + ), + ( + ConfigurationStyle.LEGACY, + "{{ this_function_does_not_exist() }}", + { + "battery_level_template": "{{ this_function_does_not_exist() }}", + "fan_speed_template": "{{ this_function_does_not_exist() }}", + "start": {"service": "script.vacuum_start"}, + }, + STATE_UNKNOWN, + None, + ), + ( + ConfigurationStyle.MODERN, + "{{ this_function_does_not_exist() }}", + { + "battery_level": "{{ this_function_does_not_exist() }}", + "fan_speed": "{{ this_function_does_not_exist() }}", + "start": {"service": "script.vacuum_start"}, + }, + STATE_UNKNOWN, + None, ), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_valid_configs(hass: HomeAssistant, count, parm1, parm2) -> None: +@pytest.mark.usefixtures("setup_base_vacuum") +async def test_valid_legacy_configs(hass: HomeAssistant, count, parm1, parm2) -> None: """Test: configs.""" assert len(hass.states.async_all("vacuum")) == count _verify(hass, parm1, parm2) -@pytest.mark.parametrize(("count", "domain"), [(0, "vacuum")]) +@pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "config", + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("state_template", "extra_config"), [ - { - "vacuum": { - "platform": "template", - "vacuums": {"test_vacuum": {"value_template": "{{ 'on' }}"}}, - } - }, - { - "platform": "template", - "vacuums": {"test_vacuum": {"start": {"service": "script.vacuum_start"}}}, - }, + ("{{ 'on' }}", {}), + (None, {"nothingburger": {"service": "script.vacuum_start"}}), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_base_vacuum") async def test_invalid_configs(hass: HomeAssistant, count) -> None: """Test: configs.""" assert len(hass.states.async_all("vacuum")) == count @pytest.mark.parametrize( - ("count", "domain", "config"), + ("count", "state_template", "extra_config"), + [(1, "{{ states('input_select.state') }}", {})], +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - ( - 1, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "value_template": "{{ states('input_select.state') }}", - "battery_level_template": "{{ states('input_number.battery_level') }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, - ) + (ConfigurationStyle.LEGACY, "battery_level_template"), + (ConfigurationStyle.MODERN, "battery_level"), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_templates_with_entities(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ '0' }}", 0), + ("{{ 100 }}", 100), + ("{{ 101 }}", None), + ("{{ -1 }}", None), + ("{{ 'foo' }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_battery_level_template( + hass: HomeAssistant, expected: int | None +) -> None: """Test templates with values from other entities.""" - _verify(hass, STATE_UNKNOWN, None) - - 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, VacuumActivity.CLEANING, 100) + _verify(hass, STATE_UNKNOWN, expected) @pytest.mark.parametrize( - ("count", "domain", "config"), + ("count", "state_template", "extra_config"), [ ( 1, - "vacuum", + "{{ states('input_select.state') }}", { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "availability_template": "{{ is_state('availability_state.state', 'on') }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } + "fan_speeds": ["low", "medium", "high"], }, ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "fan_speed_template"), + (ConfigurationStyle.MODERN, "fan_speed"), + ], +) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ 'low' }}", "low"), + ("{{ 'medium' }}", "medium"), + ("{{ 'high' }}", "high"), + ("{{ 'invalid' }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_fan_speed_template(hass: HomeAssistant, expected: str | None) -> None: + """Test templates with values from other entities.""" + _verify(hass, STATE_UNKNOWN, None, expected) + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 'on' }}", + "{% if states.switch.test_state.state %}mdi:check{% endif %}", + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "icon"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_icon_template(hass: HomeAssistant) -> None: + """Test icon template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") in ("", None) + + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["icon"] == "mdi:check" + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ 'on' }}", + "{% if states.switch.test_state.state %}local/vacuum.png{% endif %}", + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.MODERN, "picture"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_picture_template(hass: HomeAssistant) -> None: + """Test picture template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") in ("", None) + + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["entity_picture"] == "local/vacuum.png" + + +@pytest.mark.parametrize("extra_config", [{}]) +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + None, + "{{ is_state('availability_state.state', 'on') }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_available_template_with_entities(hass: HomeAssistant) -> None: """Test availability templates with values from other entities.""" @@ -177,105 +571,83 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Device State should not be unavailable - assert hass.states.get("vacuum.test_template_vacuum").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE # When Availability template returns false hass.states.async_set("availability_state.state", STATE_OFF) await hass.async_block_till_done() # device state should be unavailable - assert hass.states.get("vacuum.test_template_vacuum").state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE +@pytest.mark.parametrize("extra_config", [{}]) @pytest.mark.parametrize( - ("count", "domain", "config"), + ("count", "state_template", "attribute_template"), [ ( 1, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "availability_template": "{{ x - 12 }}", - "start": {"service": "script.vacuum_start"}, - } - }, - } - }, + None, + "{{ x - 12 }}", ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog_setup_text ) -> None: """Test that an invalid availability keeps the device available.""" - assert hass.states.get("vacuum.test_template_vacuum") != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID) != STATE_UNAVAILABLE assert "UndefinedError: 'x' is undefined" in caplog_setup_text @pytest.mark.parametrize( - ("count", "domain", "config"), + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("count", "state_template", "attributes"), [ ( 1, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum": { - "value_template": "{{ 'cleaning' }}", - "start": {"service": "script.vacuum_start"}, - "attribute_templates": { - "test_attribute": "It {{ states.sensor.test_state.state }}." - }, - } - }, - } - }, + "{{ 'cleaning' }}", + {"test_attribute": "It {{ states.sensor.test_state.state }}."}, ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_attributes_state_vacuum") async def test_attribute_templates(hass: HomeAssistant) -> None: """Test attribute_templates template.""" - state = hass.states.get("vacuum.test_template_vacuum") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["test_attribute"] == "It ." hass.states.async_set("sensor.test_state", "Works") await hass.async_block_till_done() - await async_update_entity(hass, "vacuum.test_template_vacuum") - state = hass.states.get("vacuum.test_template_vacuum") + await async_update_entity(hass, TEST_ENTITY_ID) + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["test_attribute"] == "It Works." @pytest.mark.parametrize( - ("count", "domain", "config"), + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("count", "state_template", "attributes"), [ ( 1, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "invalid_template": { - "value_template": "{{ states('input_select.state') }}", - "start": {"service": "script.vacuum_start"}, - "attribute_templates": { - "test_attribute": "{{ this_function_does_not_exist() }}" - }, - } - }, - } - }, + "{{ states('input_select.state') }}", + {"test_attribute": "{{ this_function_does_not_exist() }}"}, ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_attributes_state_vacuum") async def test_invalid_attribute_template( hass: HomeAssistant, caplog_setup_text ) -> None: @@ -285,246 +657,247 @@ async def test_invalid_attribute_template( assert "TemplateError" in caplog_setup_text +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("count", "domain", "config"), + ("style", "vacuum_config"), [ ( - 1, - "vacuum", + ConfigurationStyle.LEGACY, { - "vacuum": { - "platform": "template", - "vacuums": { - "test_template_vacuum_01": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - "start": {"service": "script.vacuum_start"}, - }, - "test_template_vacuum_02": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - "start": {"service": "script.vacuum_start"}, - }, - }, - } + "test_template_vacuum_01": { + "value_template": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + "test_template_vacuum_02": { + "value_template": "{{ false }}", + **UNIQUE_ID_CONFIG, + }, }, ), + ( + ConfigurationStyle.MODERN, + [ + { + "name": "test_template_vacuum_01", + "state": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_vacuum_02", + "state": "{{ false }}", + **UNIQUE_ID_CONFIG, + }, + ], + ), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_vacuum") async def test_unique_id(hass: HomeAssistant) -> None: """Test unique_id option only creates one vacuum per id.""" assert len(hass.states.async_all("vacuum")) == 1 +@pytest.mark.parametrize( + ("count", "state_template", "extra_config"), [(1, None, START_ACTION)] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.usefixtures("setup_base_vacuum") async def test_unused_services(hass: HomeAssistant) -> None: """Test calling unused services raises.""" - await _register_basic_vacuum(hass) - # Pause vacuum with pytest.raises(HomeAssistantError): - await common.async_pause(hass, _TEST_VACUUM) + await common.async_pause(hass, TEST_ENTITY_ID) await hass.async_block_till_done() # Stop vacuum with pytest.raises(HomeAssistantError): - await common.async_stop(hass, _TEST_VACUUM) + await common.async_stop(hass, TEST_ENTITY_ID) await hass.async_block_till_done() # Return vacuum to base with pytest.raises(HomeAssistantError): - await common.async_return_to_base(hass, _TEST_VACUUM) + await common.async_return_to_base(hass, TEST_ENTITY_ID) await hass.async_block_till_done() # Spot cleaning with pytest.raises(HomeAssistantError): - await common.async_clean_spot(hass, _TEST_VACUUM) + await common.async_clean_spot(hass, TEST_ENTITY_ID) await hass.async_block_till_done() # Locate vacuum with pytest.raises(HomeAssistantError): - await common.async_locate(hass, _TEST_VACUUM) + await common.async_locate(hass, TEST_ENTITY_ID) await hass.async_block_till_done() # Set fan's speed with pytest.raises(HomeAssistantError): - await common.async_set_fan_speed(hass, "medium", _TEST_VACUUM) + await common.async_set_fan_speed(hass, "medium", TEST_ENTITY_ID) await hass.async_block_till_done() _verify(hass, STATE_UNKNOWN, None) -async def test_state_services(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test state services.""" - await _register_components(hass) - - # Start vacuum - await common.async_start(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - 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 - - # Pause vacuum - await common.async_pause(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - 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 - - # Stop vacuum - await common.async_stop(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - 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 - - # Return vacuum to base - await common.async_return_to_base(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - 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 - - -async def test_clean_spot_service( - hass: HomeAssistant, calls: list[ServiceCall] +@pytest.mark.parametrize( + ("count", "state_template"), + [(1, "{{ states('input_select.state') }}")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + "action", + [ + "start", + "pause", + "stop", + "clean_spot", + "return_to_base", + "locate", + ], +) +@pytest.mark.usefixtures("setup_state_vacuum") +async def test_state_services( + hass: HomeAssistant, action: str, calls: list[ServiceCall] ) -> None: - """Test clean spot service.""" - await _register_components(hass) - - # Clean spot - await common.async_clean_spot(hass, _TEST_VACUUM) - await hass.async_block_till_done() - - # verify - assert hass.states.get(_SPOT_CLEANING_INPUT_BOOLEAN).state == STATE_ON - assert len(calls) == 1 - assert calls[-1].data["action"] == "clean_spot" - assert calls[-1].data["caller"] == _TEST_VACUUM - - -async def test_locate_service(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test locate service.""" - await _register_components(hass) - # Locate vacuum - await common.async_locate(hass, _TEST_VACUUM) + await hass.services.async_call( + "vacuum", + action, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) await hass.async_block_till_done() # verify - assert hass.states.get(_LOCATING_INPUT_BOOLEAN).state == STATE_ON assert len(calls) == 1 - assert calls[-1].data["action"] == "locate" - assert calls[-1].data["caller"] == _TEST_VACUUM + assert calls[-1].data["action"] == action + assert calls[-1].data["caller"] == TEST_ENTITY_ID +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template", "extra_config"), + [ + ( + 1, + "{{ states('input_select.state') }}", + "{{ states('input_select.fan_speed') }}", + { + "fan_speeds": ["low", "medium", "high"], + }, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "fan_speed_template"), + (ConfigurationStyle.MODERN, "fan_speed"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_set_fan_speed(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid fan speed.""" - await _register_components(hass) # Set vacuum's fan speed to high - await common.async_set_fan_speed(hass, "high", _TEST_VACUUM) + await common.async_set_fan_speed(hass, "high", TEST_ENTITY_ID) await hass.async_block_till_done() # verify - assert hass.states.get(_FAN_SPEED_INPUT_SELECT).state == "high" assert len(calls) == 1 assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == _TEST_VACUUM - assert calls[-1].data["option"] == "high" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["fan_speed"] == "high" # Set fan's speed to medium - await common.async_set_fan_speed(hass, "medium", _TEST_VACUUM) + await common.async_set_fan_speed(hass, "medium", TEST_ENTITY_ID) await hass.async_block_till_done() # verify - assert hass.states.get(_FAN_SPEED_INPUT_SELECT).state == "medium" assert len(calls) == 2 assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == _TEST_VACUUM - assert calls[-1].data["option"] == "medium" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["fan_speed"] == "medium" +@pytest.mark.parametrize( + "extra_config", + [ + { + "fan_speeds": ["low", "medium", "high"], + } + ], +) +@pytest.mark.parametrize( + ("count", "state_template", "attribute_template"), + [ + ( + 1, + "{{ states('input_select.state') }}", + "{{ states('input_select.fan_speed') }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "fan_speed_template"), + (ConfigurationStyle.MODERN, "fan_speed"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_set_invalid_fan_speed( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set invalid fan speed when fan has valid speed.""" - await _register_components(hass) # Set vacuum's fan speed to high - await common.async_set_fan_speed(hass, "high", _TEST_VACUUM) + await common.async_set_fan_speed(hass, "high", TEST_ENTITY_ID) await hass.async_block_till_done() # verify - assert hass.states.get(_FAN_SPEED_INPUT_SELECT).state == "high" + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_fan_speed" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["fan_speed"] == "high" # Set vacuum's fan speed to 'invalid' - await common.async_set_fan_speed(hass, "invalid", _TEST_VACUUM) + await common.async_set_fan_speed(hass, "invalid", TEST_ENTITY_ID) await hass.async_block_till_done() # verify fan speed is unchanged - assert hass.states.get(_FAN_SPEED_INPUT_SELECT).state == "high" + assert len(calls) == 1 + assert calls[-1].data["action"] == "set_fan_speed" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert calls[-1].data["fan_speed"] == "high" -def _verify( - hass: HomeAssistant, expected_state: str, expected_battery_level: int +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Verify vacuum's state and speed.""" - state = hass.states.get(_TEST_VACUUM) - attributes = state.attributes - assert state.state == expected_state - assert attributes.get(ATTR_BATTERY_LEVEL) == expected_battery_level - - -async def _register_basic_vacuum(hass: HomeAssistant) -> None: - """Register basic vacuum with only required options for testing.""" - with assert_setup_component(1, "input_select"): - assert await setup.async_setup_component( + """Test a template unique_id propagates to switch unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( hass, - "input_select", + template.DOMAIN, { - "input_select": { - "state": {"name": "State", "options": [VacuumActivity.CLEANING]} - } - }, - ) - - with assert_setup_component(1, "vacuum"): - assert await setup.async_setup_component( - hass, - "vacuum", - { - "vacuum": { - "platform": "template", - "vacuums": { - "test_vacuum": { - "start": { - "service": "input_select.select_option", - "data": { - "entity_id": _STATE_INPUT_SELECT, - "option": VacuumActivity.CLEANING, - }, - } - } - }, - } + "template": { + "unique_id": "x", + "vacuum": [ + { + **TEMPLATE_VACUUM_ACTIONS, + "name": "test_a", + "unique_id": "a", + }, + { + **TEMPLATE_VACUUM_ACTIONS, + "name": "test_b", + "unique_id": "b", + }, + ], + }, }, ) @@ -532,168 +905,72 @@ async def _register_basic_vacuum(hass: HomeAssistant) -> None: await hass.async_start() await hass.async_block_till_done() + assert len(hass.states.async_all("vacuum")) == 2 -async def _register_components(hass: HomeAssistant) -> None: - """Register basic components for testing.""" - with assert_setup_component(2, "input_boolean"): - assert await setup.async_setup_component( - hass, - "input_boolean", - {"input_boolean": {"spot_cleaning": None, "locating": None}}, - ) + entry = entity_registry.async_get("vacuum.test_a") + assert entry + assert entry.unique_id == "x-a" - with assert_setup_component(2, "input_select"): - assert await setup.async_setup_component( - hass, - "input_select", + entry = entity_registry.async_get("vacuum.test_b") + assert entry + assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize(("count", "vacuum_config"), [(1, {"start": []})]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("extra_config", "supported_features"), + [ + ( { - "input_select": { - "state": { - "name": "State", - "options": [ - VacuumActivity.CLEANING, - VacuumActivity.DOCKED, - VacuumActivity.IDLE, - VacuumActivity.PAUSED, - VacuumActivity.RETURNING, - ], - }, - "fan_speed": { - "name": "Fan speed", - "options": ["", "low", "medium", "high"], - }, - } + "pause": [], }, - ) - - with assert_setup_component(1, "vacuum"): - test_vacuum_config = { - "value_template": "{{ states('input_select.state') }}", - "fan_speed_template": "{{ states('input_select.fan_speed') }}", - "start": [ - { - "service": "input_select.select_option", - "data": { - "entity_id": _STATE_INPUT_SELECT, - "option": VacuumActivity.CLEANING, - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "start", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "pause": [ - { - "service": "input_select.select_option", - "data": { - "entity_id": _STATE_INPUT_SELECT, - "option": VacuumActivity.PAUSED, - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "pause", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "stop": [ - { - "service": "input_select.select_option", - "data": { - "entity_id": _STATE_INPUT_SELECT, - "option": VacuumActivity.IDLE, - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "stop", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "return_to_base": [ - { - "service": "input_select.select_option", - "data": { - "entity_id": _STATE_INPUT_SELECT, - "option": VacuumActivity.RETURNING, - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "return_to_base", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "clean_spot": [ - { - "service": "input_boolean.turn_on", - "entity_id": _SPOT_CLEANING_INPUT_BOOLEAN, - }, - { - "service": "test.automation", - "data_template": { - "action": "clean_spot", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "locate": [ - { - "service": "input_boolean.turn_on", - "entity_id": _LOCATING_INPUT_BOOLEAN, - }, - { - "service": "test.automation", - "data_template": { - "action": "locate", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "set_fan_speed": [ - { - "service": "input_select.select_option", - "data_template": { - "entity_id": _FAN_SPEED_INPUT_SELECT, - "option": "{{ fan_speed }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_fan_speed", - "caller": "{{ this.entity_id }}", - "option": "{{ fan_speed }}", - }, - }, - ], - "fan_speeds": ["low", "medium", "high"], - "attribute_templates": { - "test_attribute": "It {{ states.sensor.test_state.state }}." - }, - } - - assert await setup.async_setup_component( - hass, - "vacuum", + VacuumEntityFeature.PAUSE, + ), + ( { - "vacuum": { - "platform": "template", - "vacuums": {"test_vacuum": test_vacuum_config}, - } + "stop": [], }, - ) + VacuumEntityFeature.STOP, + ), + ( + { + "return_to_base": [], + }, + VacuumEntityFeature.RETURN_HOME, + ), + ( + { + "clean_spot": [], + }, + VacuumEntityFeature.CLEAN_SPOT, + ), + ( + { + "locate": [], + }, + VacuumEntityFeature.LOCATE, + ), + ( + { + "set_fan_speed": [], + }, + VacuumEntityFeature.FAN_SPEED, + ), + ], +) +async def test_empty_action_config( + hass: HomeAssistant, + supported_features: VacuumEntityFeature, + setup_test_vacuum_with_extra_config, +) -> None: + """Test configuration with empty script.""" + await common.async_start(hass, TEST_ENTITY_ID) + await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["supported_features"] == ( + VacuumEntityFeature.STATE | VacuumEntityFeature.START | supported_features + ) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 081028b6f5b..5db6a000ccc 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -928,3 +928,65 @@ async def test_trigger_entity_restore_state_fail( state = hass.states.get("weather.test") assert state.state == STATE_UNKNOWN assert state.attributes.get("temperature") is None + + +async def test_new_style_template_state_text(hass: HomeAssistant) -> None: + """Test the state text of a template.""" + assert await async_setup_component( + hass, + "weather", + { + "weather": [ + {"weather": {"platform": "demo"}}, + ] + }, + ) + assert await async_setup_component( + hass, + "template", + { + "template": { + "weather": { + "name": "test", + "attribution_template": "{{ states('sensor.attribution') }}", + "condition_template": "sunny", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + "pressure_template": "{{ states('sensor.pressure') }}", + "wind_speed_template": "{{ states('sensor.windspeed') }}", + "wind_bearing_template": "{{ states('sensor.windbearing') }}", + "ozone_template": "{{ states('sensor.ozone') }}", + "visibility_template": "{{ states('sensor.visibility') }}", + "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", + "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", + "dew_point_template": "{{ states('sensor.dew_point') }}", + "apparent_temperature_template": "{{ states('sensor.apparent_temperature') }}", + }, + }, + }, + ) + + for attr, v_attr, value in ( + ( + "sensor.attribution", + ATTR_ATTRIBUTION, + "The custom attribution", + ), + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ("sensor.pressure", ATTR_WEATHER_PRESSURE, 1000), + ("sensor.windspeed", ATTR_WEATHER_WIND_SPEED, 20), + ("sensor.windbearing", ATTR_WEATHER_WIND_BEARING, 180), + ("sensor.ozone", ATTR_WEATHER_OZONE, 25), + ("sensor.visibility", ATTR_WEATHER_VISIBILITY, 4.6), + ("sensor.wind_gust_speed", ATTR_WEATHER_WIND_GUST_SPEED, 30), + ("sensor.cloud_coverage", ATTR_WEATHER_CLOUD_COVERAGE, 75), + ("sensor.dew_point", ATTR_WEATHER_DEW_POINT, 2.2), + ("sensor.apparent_temperature", ATTR_WEATHER_APPARENT_TEMPERATURE, 25), + ): + hass.states.async_set(attr, value) + await hass.async_block_till_done() + state = hass.states.get("weather.test") + assert state is not None + assert state.state == "sunny" + assert state.attributes.get(v_attr) == value diff --git a/tests/components/tesla_fleet/__init__.py b/tests/components/tesla_fleet/__init__.py index 78159402bff..c51cd83ee66 100644 --- a/tests/components/tesla_fleet/__init__.py +++ b/tests/components/tesla_fleet/__init__.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.application_credentials import ( ClientCredential, diff --git a/tests/components/tesla_fleet/conftest.py b/tests/components/tesla_fleet/conftest.py index 06d2b54c936..10b01caca96 100644 --- a/tests/components/tesla_fleet/conftest.py +++ b/tests/components/tesla_fleet/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures for Tessie.""" +"""Fixtures for Tesla Fleet.""" from __future__ import annotations @@ -113,7 +113,7 @@ def mock_products() -> Generator[AsyncMock]: def mock_vehicle_state() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.vehicle", + "tesla_fleet_api.tesla.VehicleFleet.vehicle", return_value=VEHICLE_ONLINE, ) as mock_vehicle: yield mock_vehicle @@ -123,7 +123,7 @@ def mock_vehicle_state() -> Generator[AsyncMock]: def mock_vehicle_data() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle_data method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.vehicle_data", + "tesla_fleet_api.tesla.VehicleFleet.vehicle_data", return_value=VEHICLE_DATA, ) as mock_vehicle_data: yield mock_vehicle_data @@ -133,7 +133,7 @@ def mock_vehicle_data() -> Generator[AsyncMock]: def mock_wake_up() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific wake_up method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.wake_up", + "tesla_fleet_api.tesla.VehicleFleet.wake_up", return_value=VEHICLE_ONLINE, ) as mock_wake_up: yield mock_wake_up @@ -143,7 +143,7 @@ def mock_wake_up() -> Generator[AsyncMock]: def mock_live_status() -> Generator[AsyncMock]: """Mock Tesla Fleet API Energy Specific live_status method.""" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.live_status", + "tesla_fleet_api.tesla.EnergySite.live_status", side_effect=lambda: deepcopy(LIVE_STATUS), ) as mock_live_status: yield mock_live_status @@ -153,7 +153,7 @@ def mock_live_status() -> Generator[AsyncMock]: def mock_site_info() -> Generator[AsyncMock]: """Mock Tesla Fleet API Energy Specific site_info method.""" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.site_info", + "tesla_fleet_api.tesla.EnergySite.site_info", side_effect=lambda: deepcopy(SITE_INFO), ) as mock_live_status: yield mock_live_status @@ -182,7 +182,7 @@ def mock_request(): def mock_energy_history(): """Mock Teslemetry Energy Specific site_info method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.energy_history", + "tesla_fleet_api.tesla.EnergySite.energy_history", return_value=ENERGY_HISTORY, ) as mock_live_status: yield mock_live_status @@ -192,7 +192,7 @@ def mock_energy_history(): def mock_signed_command() -> Generator[AsyncMock]: """Mock Tesla Fleet Api signed_command method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSigned.signed_command", + "tesla_fleet_api.tesla.VehicleSigned.signed_command", return_value=COMMAND_OK, ) as mock_signed_command: yield mock_signed_command diff --git a/tests/components/tesla_fleet/snapshots/test_number.ambr b/tests/components/tesla_fleet/snapshots/test_number.ambr index 1981544a024..a3fccf3a45a 100644 --- a/tests/components/tesla_fleet/snapshots/test_number.ambr +++ b/tests/components/tesla_fleet/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, diff --git a/tests/components/tesla_fleet/test_button.py b/tests/components/tesla_fleet/test_button.py index ef1cfd90357..9eb12961dfa 100644 --- a/tests/components/tesla_fleet/test_button.py +++ b/tests/components/tesla_fleet/test_button.py @@ -4,7 +4,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import NotOnWhitelistFault from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS @@ -56,7 +56,7 @@ async def test_press( await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) with patch( - f"homeassistant.components.tesla_fleet.VehicleSpecific.{func}", + f"tesla_fleet_api.tesla.VehicleFleet.{func}", return_value=COMMAND_OK, ) as command: await hass.services.async_call( @@ -85,7 +85,7 @@ async def test_press_signing_error( with ( patch("homeassistant.components.tesla_fleet.TeslaFleetApi.get_private_key"), patch( - "homeassistant.components.tesla_fleet.VehicleSigned.flash_lights", + "tesla_fleet_api.tesla.VehicleSigned.flash_lights", side_effect=NotOnWhitelistFault, ), pytest.raises(HomeAssistantError) as error, diff --git a/tests/components/tesla_fleet/test_climate.py b/tests/components/tesla_fleet/test_climate.py index b45e5259a5c..fae79c795c2 100644 --- a/tests/components/tesla_fleet/test_climate.py +++ b/tests/components/tesla_fleet/test_climate.py @@ -257,7 +257,7 @@ async def test_invalid_error( with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", side_effect=InvalidCommand, ) as mock_on, pytest.raises( @@ -285,7 +285,7 @@ async def test_errors( with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=response, ) as mock_on, pytest.raises(HomeAssistantError), @@ -308,7 +308,7 @@ async def test_ignored_error( await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) entity_id = "climate.test_climate" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=COMMAND_IGNORED_REASON, ) as mock_on: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_cover.py b/tests/components/tesla_fleet/test_cover.py index ac5307b2fdd..045e5cfabb9 100644 --- a/tests/components/tesla_fleet/test_cover.py +++ b/tests/components/tesla_fleet/test_cover.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.cover import ( @@ -89,7 +89,7 @@ async def test_cover_services( # Vent Windows entity_id = "cover.test_windows" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.window_control", + "tesla_fleet_api.tesla.VehicleFleet.window_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -118,7 +118,7 @@ async def test_cover_services( # Charge Port Door entity_id = "cover.test_charge_port_door" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.tesla.VehicleFleet.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -133,7 +133,7 @@ async def test_cover_services( assert state.state == CoverState.OPEN with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_close", + "tesla_fleet_api.tesla.VehicleFleet.charge_port_door_close", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -150,7 +150,7 @@ async def test_cover_services( # Frunk entity_id = "cover.test_frunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.tesla.VehicleFleet.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -167,7 +167,7 @@ async def test_cover_services( # Trunk entity_id = "cover.test_trunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.tesla.VehicleFleet.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -196,7 +196,7 @@ async def test_cover_services( # Sunroof entity_id = "cover.test_sunroof" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.sun_roof_control", + "tesla_fleet_api.tesla.VehicleFleet.sun_roof_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index ff103ce03c2..7bd90a3568c 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -1,6 +1,7 @@ """Test the Tesla Fleet init.""" from copy import deepcopy +from datetime import timedelta from unittest.mock import AsyncMock, patch from aiohttp import RequestInfo @@ -231,57 +232,58 @@ async def test_vehicle_sleep( freezer: FrozenDateTimeFactory, ) -> None: """Test coordinator refresh with an error.""" - await setup_platform(hass, normal_config_entry) - 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 + TEST_INTERVAL = timedelta(seconds=120) - 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 + with patch( + "homeassistant.components.tesla_fleet.coordinator.VEHICLE_INTERVAL", + TEST_INTERVAL, + ): + await setup_platform(hass, normal_config_entry) + assert mock_vehicle_data.call_count == 1 - 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 + TEST_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_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(TEST_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) - # Regular polling - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 4 + 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 - 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(TEST_INTERVAL) + async_fire_time_changed(hass) + # Regular polling + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 4 - 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 + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + freezer.tick(TEST_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 == 7 + freezer.tick(TEST_INTERVAL) + 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(TEST_INTERVAL) + 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 diff --git a/tests/components/tesla_fleet/test_lock.py b/tests/components/tesla_fleet/test_lock.py index 00b77aefcaf..a8aec27100c 100644 --- a/tests/components/tesla_fleet/test_lock.py +++ b/tests/components/tesla_fleet/test_lock.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.lock import ( @@ -59,7 +59,7 @@ async def test_lock_services( entity_id = "lock.test_lock" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.door_lock", + "tesla_fleet_api.tesla.VehicleFleet.door_lock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -73,7 +73,7 @@ async def test_lock_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.door_unlock", + "tesla_fleet_api.tesla.VehicleFleet.door_unlock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -97,7 +97,7 @@ async def test_lock_services( ) with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.tesla.VehicleFleet.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_media_player.py b/tests/components/tesla_fleet/test_media_player.py index 4c833e7499f..3233246b8b5 100644 --- a/tests/components/tesla_fleet/test_media_player.py +++ b/tests/components/tesla_fleet/test_media_player.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.media_player import ( @@ -88,7 +88,7 @@ async def test_media_player_services( entity_id = "media_player.test_media_player" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.adjust_volume", + "tesla_fleet_api.tesla.VehicleFleet.adjust_volume", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -102,7 +102,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.tesla.VehicleFleet.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -117,7 +117,7 @@ async def test_media_player_services( # This test will fail without the previous call to pause playback with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.tesla.VehicleFleet.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -131,7 +131,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_next_track", + "tesla_fleet_api.tesla.VehicleFleet.media_next_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -144,7 +144,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_prev_track", + "tesla_fleet_api.tesla.VehicleFleet.media_prev_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_number.py b/tests/components/tesla_fleet/test_number.py index 8551a99ee29..66734c27f6f 100644 --- a/tests/components/tesla_fleet/test_number.py +++ b/tests/components/tesla_fleet/test_number.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.number import ( @@ -57,7 +57,7 @@ async def test_number_services( entity_id = "number.test_charge_current" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.set_charging_amps", + "tesla_fleet_api.tesla.VehicleFleet.set_charging_amps", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -72,7 +72,7 @@ async def test_number_services( entity_id = "number.test_charge_limit" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.set_charge_limit", + "tesla_fleet_api.tesla.VehicleFleet.set_charge_limit", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -87,7 +87,7 @@ async def test_number_services( entity_id = "number.energy_site_backup_reserve" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.backup", + "tesla_fleet_api.tesla.EnergySite.backup", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -105,7 +105,7 @@ async def test_number_services( entity_id = "number.energy_site_off_grid_reserve" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.off_grid_vehicle_charging_reserve", + "tesla_fleet_api.tesla.EnergySite.off_grid_vehicle_charging_reserve", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_select.py b/tests/components/tesla_fleet/test_select.py index 902b28ddb7a..5aa05ab7976 100644 --- a/tests/components/tesla_fleet/test_select.py +++ b/tests/components/tesla_fleet/test_select.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode from tesla_fleet_api.exceptions import VehicleOffline @@ -61,11 +61,11 @@ async def test_select_services( entity_id = "select.test_seat_heater_front_left" with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.remote_seat_heater_request", + "tesla_fleet_api.tesla.VehicleFleet.remote_seat_heater_request", return_value=COMMAND_OK, ) as remote_seat_heater_request, patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=COMMAND_OK, ) as auto_conditioning_start, ): @@ -83,11 +83,11 @@ async def test_select_services( entity_id = "select.test_steering_wheel_heater" with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.remote_steering_wheel_heat_level_request", + "tesla_fleet_api.tesla.VehicleFleet.remote_steering_wheel_heat_level_request", return_value=COMMAND_OK, ) as remote_steering_wheel_heat_level_request, patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=COMMAND_OK, ) as auto_conditioning_start, ): @@ -104,7 +104,7 @@ async def test_select_services( entity_id = "select.energy_site_operation_mode" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.operation", + "tesla_fleet_api.tesla.EnergySite.operation", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -122,7 +122,7 @@ async def test_select_services( entity_id = "select.energy_site_allow_export" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.grid_import_export", + "tesla_fleet_api.tesla.EnergySite.grid_import_export", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_switch.py b/tests/components/tesla_fleet/test_switch.py index fba4fc05cc4..dcdf66b7cc1 100644 --- a/tests/components/tesla_fleet/test_switch.py +++ b/tests/components/tesla_fleet/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.switch import ( @@ -71,41 +71,41 @@ async def test_switch_offline( @pytest.mark.parametrize( ("name", "on", "off"), [ - ("test_charge", "VehicleSpecific.charge_start", "VehicleSpecific.charge_stop"), + ("test_charge", "VehicleFleet.charge_start", "VehicleFleet.charge_stop"), ( "test_auto_seat_climate_left", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", ), ( "test_auto_seat_climate_right", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", ), ( "test_auto_steering_wheel_heater", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", + "VehicleFleet.remote_auto_steering_wheel_heat_climate_request", + "VehicleFleet.remote_auto_steering_wheel_heat_climate_request", ), ( "test_defrost", - "VehicleSpecific.set_preconditioning_max", - "VehicleSpecific.set_preconditioning_max", + "VehicleFleet.set_preconditioning_max", + "VehicleFleet.set_preconditioning_max", ), ( "energy_site_storm_watch", - "EnergySpecific.storm_mode", - "EnergySpecific.storm_mode", + "EnergySite.storm_mode", + "EnergySite.storm_mode", ), ( "energy_site_allow_charging_from_grid", - "EnergySpecific.grid_import_export", - "EnergySpecific.grid_import_export", + "EnergySite.grid_import_export", + "EnergySite.grid_import_export", ), ( "test_sentry_mode", - "VehicleSpecific.set_sentry_mode", - "VehicleSpecific.set_sentry_mode", + "VehicleFleet.set_sentry_mode", + "VehicleFleet.set_sentry_mode", ), ], ) @@ -122,7 +122,7 @@ async def test_switch_services( entity_id = f"switch.{name}" with patch( - f"homeassistant.components.tesla_fleet.{on}", + f"tesla_fleet_api.tesla.{on}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -136,7 +136,7 @@ async def test_switch_services( call.assert_called_once() with patch( - f"homeassistant.components.tesla_fleet.{off}", + f"tesla_fleet_api.tesla.{off}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index e89bab9eff1..0152543e512 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -25,7 +25,7 @@ from .const import ( def mock_metadata(): """Mock Tesla Fleet Api metadata method.""" with patch( - "homeassistant.components.teslemetry.Teslemetry.metadata", return_value=METADATA + "tesla_fleet_api.teslemetry.Teslemetry.metadata", return_value=METADATA ) as mock_products: yield mock_products @@ -34,7 +34,7 @@ def mock_metadata(): def mock_products(): """Mock Tesla Fleet Api products method.""" with patch( - "homeassistant.components.teslemetry.Teslemetry.products", return_value=PRODUCTS + "tesla_fleet_api.teslemetry.Teslemetry.products", return_value=PRODUCTS ) as mock_products: yield mock_products @@ -43,7 +43,7 @@ def mock_products(): def mock_vehicle_data() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle_data method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.vehicle_data", + "tesla_fleet_api.teslemetry.Vehicle.vehicle_data", return_value=VEHICLE_DATA, ) as mock_vehicle_data: yield mock_vehicle_data @@ -53,7 +53,7 @@ def mock_vehicle_data() -> Generator[AsyncMock]: def mock_legacy(): """Mock Tesla Fleet Api products method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True + "tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True ) as mock_pre2021: yield mock_pre2021 @@ -62,7 +62,7 @@ def mock_legacy(): def mock_wake_up(): """Mock Tesla Fleet API Vehicle Specific wake_up method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.wake_up", + "tesla_fleet_api.teslemetry.Vehicle.wake_up", return_value=WAKE_UP_ONLINE, ) as mock_wake_up: yield mock_wake_up @@ -72,7 +72,7 @@ def mock_wake_up(): def mock_vehicle() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.vehicle", + "tesla_fleet_api.teslemetry.Vehicle.vehicle", return_value=WAKE_UP_ONLINE, ) as mock_vehicle: yield mock_vehicle @@ -82,7 +82,7 @@ def mock_vehicle() -> Generator[AsyncMock]: def mock_request(): """Mock Tesla Fleet API Vehicle Specific class.""" with patch( - "homeassistant.components.teslemetry.Teslemetry._request", + "tesla_fleet_api.teslemetry.Teslemetry._request", return_value=COMMAND_OK, ) as mock_request: yield mock_request @@ -92,7 +92,7 @@ def mock_request(): def mock_live_status(): """Mock Teslemetry Energy Specific live_status method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.live_status", + "tesla_fleet_api.tesla.energysite.EnergySite.live_status", side_effect=lambda: deepcopy(LIVE_STATUS), ) as mock_live_status: yield mock_live_status @@ -102,7 +102,7 @@ def mock_live_status(): def mock_site_info(): """Mock Teslemetry Energy Specific site_info method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.site_info", + "tesla_fleet_api.tesla.energysite.EnergySite.site_info", side_effect=lambda: deepcopy(SITE_INFO), ) as mock_live_status: yield mock_live_status @@ -112,7 +112,7 @@ def mock_site_info(): def mock_energy_history(): """Mock Teslemetry Energy Specific site_info method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.energy_history", + "tesla_fleet_api.tesla.energysite.EnergySite.energy_history", return_value=ENERGY_HISTORY, ) as mock_live_status: yield mock_live_status @@ -122,7 +122,7 @@ def mock_energy_history(): def mock_add_listener(): """Mock Teslemetry Stream listen method.""" with patch( - "homeassistant.components.teslemetry.TeslemetryStream.async_add_listener", + "teslemetry_stream.TeslemetryStream.async_add_listener", ) as mock_add_listener: mock_add_listener.listeners = [] @@ -165,7 +165,7 @@ def mock_stream_update_config(): def mock_stream_connected(): """Mock Teslemetry Stream listen method.""" with patch( - "homeassistant.components.teslemetry.TeslemetryStream.connected", + "teslemetry_stream.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 40d55dab71f..b658c1e2271 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -11,6 +11,8 @@ WAKE_UP_ONLINE = {"response": {"state": TeslemetryState.ONLINE}, "error": None} WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None} PRODUCTS = load_json_object_fixture("products.json", DOMAIN) +PRODUCTS_MODERN = load_json_object_fixture("products.json", DOMAIN) +PRODUCTS_MODERN["response"][0]["command_signing"] = "required" 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 @@ -18,7 +20,6 @@ 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,6 +45,7 @@ METADATA = { "vehicle_device_data", "vehicle_cmds", "vehicle_charging_cmds", + "vehicle_location", "energy_device_data", "energy_cmds", ], @@ -52,7 +54,7 @@ METADATA = { "proxy": False, "access": True, "polling": True, - "firmware": "2024.44.25", + "firmware": "2026.0.0", } }, } diff --git a/tests/components/teslemetry/fixtures/metadata.json b/tests/components/teslemetry/fixtures/metadata.json deleted file mode 100644 index 60282afc934..00000000000 --- a/tests/components/teslemetry/fixtures/metadata.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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 56497a6d936..f324aa96366 100644 --- a/tests/components/teslemetry/fixtures/products.json +++ b/tests/components/teslemetry/fixtures/products.json @@ -67,7 +67,7 @@ "webcam_supported": true, "wheel_type": "Pinwheel18CapKit" }, - "command_signing": "allowed", + "command_signing": "off", "release_notes_supported": true }, { diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index 0cd238c4e52..051c7199d00 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -192,7 +192,7 @@ "api_version": 71, "autopark_state_v2": "unavailable", "calendar_supported": true, - "car_version": "2024.44.25 06f534d46010", + "car_version": "2026.0.0 06f534d46010", "center_display_state": 0, "dashcam_clip_save_available": true, "dashcam_state": "Recording", diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 6a6e9826dc2..0af85a6846d 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -11,7 +11,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.energy_site_backup_capable', 'has_entity_name': True, 'hidden_by': None, @@ -58,7 +58,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.energy_site_grid_services_active', '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.energy_site_grid_services_enabled', 'has_entity_name': True, 'hidden_by': None, @@ -140,6 +140,54 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.energy_site_grid_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.energy_site_grid_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': 'Grid status', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_status', + 'unique_id': '123456-grid_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.energy_site_grid_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Grid status', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_grid_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensor[binary_sensor.energy_site_storm_watch_active-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -518,6 +566,54 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_cellular-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_cellular', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cellular', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cellular', + 'unique_id': 'LRW3F7EK4NC700000-cellular', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_cellular-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Cellular', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cellular', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_charge_cable-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -566,6 +662,53 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_charge_enable_request-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charge_enable_request', + 'has_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 enable request', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_enable_request', + 'unique_id': 'LRW3F7EK4NC700000-charge_enable_request', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_charge_enable_request-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge enable request', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_enable_request', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -755,6 +898,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Defrost for preconditioning', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'defrost_for_preconditioning', + 'unique_id': 'LRW3F7EK4NC700000-defrost_for_preconditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Defrost for preconditioning', + }), + 'context': , + 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_drive_rail-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1324,6 +1514,148 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_hazard_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_hazard_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hazard lights', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lights_hazards_active', + 'unique_id': 'LRW3F7EK4NC700000-lights_hazards_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_hazard_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Hazard lights', + }), + 'context': , + 'entity_id': 'binary_sensor.test_hazard_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_high_beams-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_high_beams', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'High beams', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lights_high_beams', + 'unique_id': 'LRW3F7EK4NC700000-lights_high_beams', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_high_beams-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test High beams', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_beams', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'High voltage interlock loop fault', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvil', + 'unique_id': 'LRW3F7EK4NC700000-hvil', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test High voltage interlock loop fault', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_homelink_nearby-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1371,6 +1703,194 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_hvac_auto_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': 'HVAC auto mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_auto_mode', + 'unique_id': 'LRW3F7EK4NC700000-hvac_auto_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test HVAC auto mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_hvac_auto_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_favorite-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_located_at_favorite', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Located at favorite', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'located_at_favorite', + 'unique_id': 'LRW3F7EK4NC700000-located_at_favorite', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_favorite-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at favorite', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_favorite', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_located_at_home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Located at home', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'located_at_home', + 'unique_id': 'LRW3F7EK4NC700000-located_at_home', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at home', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_work-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_located_at_work', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Located at work', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'located_at_work', + 'unique_id': 'LRW3F7EK4NC700000-located_at_work', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_work-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at work', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_work', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_offroad_lightbar-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1490,7 +2010,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Pin to drive enabled', + 'original_name': 'PIN to Drive enabled', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, @@ -1502,7 +2022,7 @@ # name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Pin to drive enabled', + 'friendly_name': 'Test PIN to Drive enabled', }), 'context': , 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', @@ -1845,6 +2365,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_remote_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_remote_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': 'Remote start', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_start_enabled', + 'unique_id': 'LRW3F7EK4NC700000-remote_start_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_remote_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Remote start', + }), + 'context': , + 'entity_id': 'binary_sensor.test_remote_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_right_hand_drive-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1939,6 +2506,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_seat_vent_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': 'Seat vent enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'seat_vent_enabled', + 'unique_id': 'LRW3F7EK4NC700000-seat_vent_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat vent enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_seat_vent_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_service_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1986,6 +2600,53 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_speed_limited-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_speed_limited', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Speed limited', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'speed_limit_mode', + 'unique_id': 'LRW3F7EK4NC700000-speed_limit_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_speed_limited-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Speed limited', + }), + 'context': , + 'entity_id': 'binary_sensor.test_speed_limited', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2031,7 +2692,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-entry] @@ -2368,6 +3029,54 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_wi_fi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_wi_fi', + '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', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi', + 'unique_id': 'LRW3F7EK4NC700000-wifi', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_wi_fi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Wi-Fi', + }), + 'context': , + 'entity_id': 'binary_sensor.test_wi_fi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_wiper_heat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2454,6 +3163,20 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.energy_site_grid_status-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Grid status', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_grid_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.energy_site_storm_watch_active-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2560,6 +3283,20 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_cellular-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Cellular', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cellular', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_charge_cable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2574,6 +3311,19 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_charge_enable_request-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge enable request', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_enable_request', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_charge_port_cold_weather_mode-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2627,6 +3377,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_defrost_for_preconditioning-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Defrost for preconditioning', + }), + 'context': , + 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_drive_rail-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2788,6 +3551,46 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_hazard_lights-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Hazard lights', + }), + 'context': , + 'entity_id': 'binary_sensor.test_hazard_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_high_beams-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test High beams', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_beams', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_high_voltage_interlock_loop_fault-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test High voltage interlock loop fault', + }), + 'context': , + 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_homelink_nearby-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2801,6 +3604,58 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_hvac_auto_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test HVAC auto mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_hvac_auto_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_located_at_favorite-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at favorite', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_favorite', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_located_at_home-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at home', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_located_at_work-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at work', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_work', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_offroad_lightbar-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2830,7 +3685,7 @@ # name: test_binary_sensor_refresh[binary_sensor.test_pin_to_drive_enabled-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Pin to drive enabled', + 'friendly_name': 'Test PIN to Drive enabled', }), 'context': , 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', @@ -2935,6 +3790,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_remote_start-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Remote start', + }), + 'context': , + 'entity_id': 'binary_sensor.test_remote_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_right_hand_drive-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2961,6 +3829,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_seat_vent_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat vent enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_seat_vent_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_service_mode-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2974,6 +3855,19 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_speed_limited-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Speed limited', + }), + 'context': , + 'entity_id': 'binary_sensor.test_speed_limited', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -2985,7 +3879,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_supercharger_session_trip_planner-statealt] @@ -3084,6 +3978,20 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_wi_fi-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Wi-Fi', + }), + 'context': , + 'entity_id': 'binary_sensor.test_wi_fi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_wiper_heat-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3097,6 +4005,12 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensors_connectivity[binary_sensor.test_cellular-state] + 'on' +# --- +# name: test_binary_sensors_connectivity[binary_sensor.test_wi_fi-state] + 'off' +# --- # name: test_binary_sensors_streaming[binary_sensor.test_driver_seat_belt-state] 'off' # --- @@ -3110,5 +4024,11 @@ 'off' # --- # name: test_binary_sensors_streaming[binary_sensor.test_front_passenger_window-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_rear_driver_window-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_rear_passenger_window-state] 'on' # --- diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 4c265c00cb8..e0e68f23c79 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -1,10 +1,4 @@ # serializer version: 1 -# name: test_asleep_or_offline[HomeAssistantError] - 'Timed out trying to wake up vehicle' -# --- -# name: test_asleep_or_offline[InvalidCommand] - 'Failed to wake up vehicle: The data request or command is unknown.' -# --- # name: test_climate[climate.test_cabin_overheat_protection-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -78,6 +72,10 @@ }), 'area_id': None, 'capabilities': dict({ + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'hvac_modes': list([ , , @@ -113,7 +111,7 @@ 'original_name': 'Climate', 'platform': 'teslemetry', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, @@ -123,6 +121,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 30.0, + 'fan_mode': 'off', + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'friendly_name': 'Test Climate', 'hvac_modes': list([ , @@ -137,7 +140,7 @@ 'dog', 'camp', ]), - 'supported_features': , + 'supported_features': , 'temperature': 22.0, }), 'context': , @@ -220,6 +223,10 @@ }), 'area_id': None, 'capabilities': dict({ + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'hvac_modes': list([ , , @@ -255,7 +262,7 @@ 'original_name': 'Climate', 'platform': 'teslemetry', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, @@ -265,6 +272,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 30.0, + 'fan_mode': 'off', + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'friendly_name': 'Test Climate', 'hvac_modes': list([ , @@ -279,7 +291,7 @@ 'dog', 'camp', ]), - 'supported_features': , + 'supported_features': , 'temperature': 22.0, }), 'context': , @@ -297,7 +309,9 @@ 'area_id': None, 'capabilities': dict({ 'hvac_modes': list([ + , , + , ]), 'max_temp': 40, 'min_temp': 30, @@ -339,6 +353,7 @@ 'capabilities': dict({ 'hvac_modes': list([ , + , ]), 'max_temp': 28.0, 'min_temp': 15.0, @@ -374,3 +389,85 @@ # name: test_invalid_error[error] 'Command returned exception: The data request or command is unknown.' # --- +# name: test_select_streaming[climate.test_cabin_overheat_protection] + 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, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_select_streaming[climate.test_climate LHD] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26.0, + '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': 21.0, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_select_streaming[climate.test_climate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26.0, + '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': 21.0, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 56a8f759a21..6b02b2f6d83 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -191,6 +191,7 @@ 'vehicle_device_data', 'vehicle_cmds', 'vehicle_charging_cmds', + 'vehicle_location', 'energy_device_data', 'energy_cmds', ]), @@ -375,7 +376,7 @@ 'vehicle_state_api_version': 71, 'vehicle_state_autopark_state_v2': 'unavailable', 'vehicle_state_calendar_supported': True, - 'vehicle_state_car_version': '2024.44.25 06f534d46010', + 'vehicle_state_car_version': '2026.0.0 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_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr index 663e91a502c..7f721b95289 100644 --- a/tests/components/teslemetry/snapshots/test_media_player.ambr +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -47,7 +47,7 @@ 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', 'source': 'Audible', 'supported_features': , - 'volume_level': 0.16129355359011466, + 'volume_level': 0.16129354838709678, }), 'context': , 'entity_id': 'media_player.test_media_player', @@ -64,10 +64,12 @@ 'friendly_name': 'Test Media player', 'media_album_name': '', 'media_artist': '', + 'media_duration': 0.0, 'media_playlist': '', 'media_title': '', 'source': 'Spotify', 'supported_features': , + 'volume_level': 0.0, }), 'context': , 'entity_id': 'media_player.test_media_player', @@ -125,7 +127,43 @@ 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', 'source': 'Audible', 'supported_features': , - 'volume_level': 0.16129355359011466, + 'volume_level': 0.16129354838709678, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_update_streaming[off] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update_streaming[on] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': 'Test Album', + 'media_artist': 'Test Artist', + 'media_duration': 60, + 'media_position': 5, + 'source': 'Spotify', + 'supported_features': , + 'volume_level': 0.1935483870967742, }), 'context': , 'entity_id': 'media_player.test_media_player', diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index 5ca9feb22f2..2c6705074f3 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index c5d98abc95c..13d87dbe88b 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -2312,7 +2312,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.energy_site_version', 'has_entity_name': True, 'hidden_by': None, @@ -2325,7 +2325,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'version', + 'original_name': 'Version', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, @@ -2337,7 +2337,7 @@ # name: test_sensors[sensor.energy_site_version-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Energy Site version', + 'friendly_name': 'Energy Site Version', }), 'context': , 'entity_id': 'sensor.energy_site_version', @@ -2350,7 +2350,7 @@ # name: test_sensors[sensor.energy_site_version-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Energy Site version', + 'friendly_name': 'Energy Site Version', }), 'context': , 'entity_id': 'sensor.energy_site_version', @@ -2424,6 +2424,75 @@ 'state': '0', }) # --- +# name: test_sensors[sensor.teslemetry_credits-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.teslemetry_credits', + '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': 'Teslemetry credits', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'credit_balance', + 'unique_id': 'abc-123_credit_balance', + 'unit_of_measurement': 'credits', + }) +# --- +# name: test_sensors[sensor.teslemetry_credits-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Teslemetry credits', + 'state_class': , + 'unit_of_measurement': 'credits', + }), + 'context': , + 'entity_id': 'sensor.teslemetry_credits', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.teslemetry_credits-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Teslemetry credits', + 'state_class': , + 'unit_of_measurement': 'credits', + }), + 'context': , + 'entity_id': 'sensor.teslemetry_credits', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.test_battery_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4499,6 +4568,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -4975,7 +5047,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'disconnected', }) # --- # name: test_sensors[sensor.wall_connector_vehicle-statealt] @@ -4988,7 +5060,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'disconnected', }) # --- # name: test_sensors[sensor.wall_connector_vehicle_2-entry] @@ -5035,7 +5107,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'disconnected', }) # --- # name: test_sensors[sensor.wall_connector_vehicle_2-statealt] @@ -5048,9 +5120,12 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'disconnected', }) # --- +# name: test_sensors_streaming[sensor.teslemetry_credits-state] + '1980' +# --- # name: test_sensors_streaming[sensor.test_battery_level-state] '90' # --- diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr index f9997133044..ffbfc06026e 100644 --- a/tests/components/teslemetry/snapshots/test_switch.ambr +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -383,6 +383,54 @@ 'state': 'off', }) # --- +# name: test_switch[switch.test_valet_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_valet_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': 'Valet mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_valet_mode', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_valet_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_valet_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Valet mode', + }), + 'context': , + 'entity_id': 'switch.test_valet_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switch_alt[switch.energy_site_allow_charging_from_grid-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -495,3 +543,35 @@ 'state': 'off', }) # --- +# name: test_switch_alt[switch.test_valet_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Valet mode', + }), + 'context': , + 'entity_id': 'switch.test_valet_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_streaming[switch.test_auto_seat_climate_left] + 'on' +# --- +# name: test_switch_streaming[switch.test_auto_seat_climate_right] + 'off' +# --- +# name: test_switch_streaming[switch.test_auto_steering_wheel_heater] + 'on' +# --- +# name: test_switch_streaming[switch.test_charge] + 'on' +# --- +# name: test_switch_streaming[switch.test_defrost] + 'off' +# --- +# name: test_switch_streaming[switch.test_sentry_mode] + 'on' +# --- diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index 1c7d525af86..391d81c086e 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -41,7 +41,7 @@ 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', 'friendly_name': 'Test Update', 'in_progress': False, - 'installed_version': '2024.44.25', + 'installed_version': '2026.0.0', 'latest_version': '2024.12.0.0', 'release_summary': None, 'release_url': None, @@ -117,3 +117,128 @@ 'state': 'off', }) # --- +# name: test_update_streaming[downloading] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.1.1', + 'latest_version': '2025.2.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_streaming[installing] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.1.1', + 'latest_version': '2025.2.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_streaming[ready] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.1.1', + 'latest_version': '2025.2.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_streaming[restored] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.2.1', + 'latest_version': '2025.1.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update_streaming[updated] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.2.1', + 'latest_version': '2025.1.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index 5a7126afe1b..0f5588fe323 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -73,6 +73,8 @@ async def test_binary_sensors_streaming( "data": { Signal.FD_WINDOW: "WindowStateOpened", Signal.FP_WINDOW: "INVALID_VALUE", + Signal.RD_WINDOW: "WindowStateClosed", + Signal.RP_WINDOW: "WindowStatePartiallyOpen", Signal.DOOR_STATE: { "DoorState": { "DriverFront": True, @@ -98,9 +100,53 @@ async def test_binary_sensors_streaming( for entity_id in ( "binary_sensor.test_front_driver_window", "binary_sensor.test_front_passenger_window", + "binary_sensor.test_rear_driver_window", + "binary_sensor.test_rear_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") + + +async def test_binary_sensors_connectivity( + 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") + + await setup_platform(hass, [Platform.BINARY_SENSOR]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "status": "CONNECTED", + "networkInterface": "cellular", + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "status": "DISCONNECTED", + "networkInterface": "wifi", + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "binary_sensor.test_cellular", + "binary_sensor.test_wi_fi", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/teslemetry/test_button.py b/tests/components/teslemetry/test_button.py index 75f94342f1e..46db33ce913 100644 --- a/tests/components/teslemetry/test_button.py +++ b/tests/components/teslemetry/test_button.py @@ -42,7 +42,7 @@ async def test_press(hass: HomeAssistant, name: str, func: str) -> None: await setup_platform(hass, [Platform.BUTTON]) with patch( - f"homeassistant.components.teslemetry.VehicleSpecific.{func}", + f"tesla_fleet_api.teslemetry.Vehicle.{func}", return_value=COMMAND_OK, ) as command: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 33f2e134806..27bed45c51f 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -2,10 +2,10 @@ 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 +from teslemetry_stream import Signal from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -24,15 +24,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import ( COMMAND_ERRORS, COMMAND_IGNORED_REASON, METADATA_NOSCOPE, VEHICLE_DATA_ALT, - VEHICLE_DATA_ASLEEP, - WAKE_UP_ASLEEP, - WAKE_UP_ONLINE, ) @@ -41,6 +38,7 @@ async def test_climate( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" @@ -195,6 +193,7 @@ async def test_climate_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" @@ -211,7 +210,7 @@ async def test_invalid_error(hass: HomeAssistant, snapshot: SnapshotAssertion) - with ( patch( - "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.teslemetry.Vehicle.auto_conditioning_start", side_effect=InvalidCommand, ) as mock_on, pytest.raises(HomeAssistantError) as error, @@ -235,7 +234,7 @@ async def test_errors(hass: HomeAssistant, response: str) -> None: with ( patch( - "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.teslemetry.Vehicle.auto_conditioning_start", return_value=response, ) as mock_on, pytest.raises(HomeAssistantError), @@ -257,7 +256,7 @@ async def test_ignored_error( await setup_platform(hass, [Platform.CLIMATE]) entity_id = "climate.test_climate" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.teslemetry.Vehicle.auto_conditioning_start", return_value=COMMAND_IGNORED_REASON, ) as mock_on: await hass.services.async_call( @@ -269,71 +268,12 @@ async def test_ignored_error( mock_on.assert_called_once() -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_asleep_or_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, - mock_wake_up: AsyncMock, - mock_vehicle: AsyncMock, - freezer: FrozenDateTimeFactory, - snapshot: SnapshotAssertion, -) -> None: - """Tests asleep is handled.""" - - mock_vehicle_data.return_value = VEHICLE_DATA_ASLEEP - await setup_platform(hass, [Platform.CLIMATE]) - entity_id = "climate.test_climate" - - # Run a command but fail trying to wake up the vehicle - mock_wake_up.side_effect = InvalidCommand - with pytest.raises(HomeAssistantError) as error: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - assert str(error.value) == snapshot(name="InvalidCommand") - mock_wake_up.assert_called_once() - - mock_wake_up.side_effect = None - mock_wake_up.reset_mock() - - # Run a command but timeout trying to wake up the vehicle - mock_wake_up.return_value = WAKE_UP_ASLEEP - mock_vehicle.return_value = WAKE_UP_ASLEEP - with ( - patch("homeassistant.components.teslemetry.helpers.asyncio.sleep"), - pytest.raises(HomeAssistantError) as error, - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - assert str(error.value) == snapshot(name="HomeAssistantError") - mock_wake_up.assert_called_once() - mock_vehicle.assert_called() - - mock_wake_up.reset_mock() - mock_vehicle.reset_mock() - mock_wake_up.return_value = WAKE_UP_ONLINE - mock_vehicle.return_value = WAKE_UP_ONLINE - - # Run a command and wake up the vehicle immediately - await hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [entity_id]}, blocking=True - ) - await hass.async_block_till_done() - mock_wake_up.assert_called_once() - - async def test_climate_noscope( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" mock_metadata.return_value = METADATA_NOSCOPE @@ -363,3 +303,47 @@ async def test_climate_noscope( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, blocking=True, ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the select entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.CLIMATE]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.INSIDE_TEMP: 26, + Signal.HVAC_AC_ENABLED: True, + Signal.CLIMATE_KEEPER_MODE: "ClimateKeeperModeOn", + Signal.RIGHT_HAND_DRIVE: True, + Signal.HVAC_LEFT_TEMPERATURE_REQUEST: 22, + Signal.HVAC_RIGHT_TEMPERATURE_REQUEST: 21, + Signal.CABIN_OVERHEAT_PROTECTION_MODE: "CabinOverheatProtectionModeStateOn", + Signal.CABIN_OVERHEAT_PROTECTION_TEMPERATURE_LIMIT: 35, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + assert hass.states.get("climate.test_climate") == snapshot( + name="climate.test_climate LHD" + ) + + await reload_platform(hass, entry, [Platform.CLIMATE]) + + # Assert the entities restored their values + for entity_id in ( + "climate.test_climate", + "climate.test_cabin_overheat_protection", + ): + assert hass.states.get(entity_id) == snapshot(name=entity_id) diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 14af1e732fe..e3933931c9f 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -75,7 +75,7 @@ async def test_cover_services( # Vent Windows entity_id = "cover.test_windows" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.window_control", + "tesla_fleet_api.teslemetry.Vehicle.window_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -104,7 +104,7 @@ async def test_cover_services( # Charge Port Door entity_id = "cover.test_charge_port_door" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.teslemetry.Vehicle.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -119,7 +119,7 @@ async def test_cover_services( assert state.state == CoverState.OPEN with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_close", + "tesla_fleet_api.teslemetry.Vehicle.charge_port_door_close", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -136,7 +136,7 @@ async def test_cover_services( # Frunk entity_id = "cover.test_frunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.teslemetry.Vehicle.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -153,7 +153,7 @@ async def test_cover_services( # Trunk entity_id = "cover.test_trunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.teslemetry.Vehicle.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -182,7 +182,7 @@ async def test_cover_services( # Sunroof entity_id = "cover.test_sunroof" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.sun_roof_control", + "tesla_fleet_api.teslemetry.Vehicle.sun_roof_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py index 38a28092d33..ea0ee08e64f 100644 --- a/tests/components/teslemetry/test_device_tracker.py +++ b/tests/components/teslemetry/test_device_tracker.py @@ -11,7 +11,7 @@ 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 .const import METADATA_NOSCOPE, VEHICLE_DATA_ALT @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -42,6 +42,23 @@ async def test_device_tracker_alt( assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_device_tracker_noscope( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_metadata: AsyncMock, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, +) -> None: + """Tests that the device tracker entities are correct.""" + + mock_metadata.return_value = METADATA_NOSCOPE + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert len(entity_entries) == 0 + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_tracker_streaming( hass: HomeAssistant, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 5481e6cc034..d2ef5c38893 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -9,20 +9,17 @@ from tesla_fleet_api.exceptions import ( InvalidToken, SubscriptionRequired, TeslaFleetError, - VehicleOffline, ) 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 +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_platform -from .const import VEHICLE_DATA_ALT - -from tests.common import async_fire_time_changed +from .const import PRODUCTS_MODERN, VEHICLE_DATA_ALT ERRORS = [ (InvalidToken, ConfigEntryState.SETUP_ERROR), @@ -69,22 +66,6 @@ async def test_devices( assert device == snapshot(name=f"{device.identifiers}") -async def test_vehicle_refresh_offline( - hass: HomeAssistant, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory -) -> None: - """Test coordinator refresh with an error.""" - entry = await setup_platform(hass, [Platform.CLIMATE]) - assert entry.state is ConfigEntryState.LOADED - mock_vehicle_data.assert_called_once() - 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() - - @pytest.mark.parametrize(("side_effect", "state"), ERRORS) async def test_vehicle_refresh_error( hass: HomeAssistant, @@ -151,7 +132,7 @@ async def test_vehicle_stream( mock_add_listener.assert_called() state = hass.states.get("binary_sensor.test_status") - assert state.state == STATE_ON + assert state.state == STATE_UNKNOWN state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_OFF @@ -160,11 +141,15 @@ async def test_vehicle_stream( { "vin": VEHICLE_DATA_ALT["response"]["vin"], "vehicle_data": VEHICLE_DATA_ALT["response"], + "state": "online", "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_ON + state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_ON @@ -190,3 +175,21 @@ async def test_no_live_status( await setup_platform(hass) assert hass.states.get("sensor.energy_site_grid_power") is None + + +async def test_modern_no_poll( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + mock_products: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that modern vehicles do not poll vehicle_data.""" + + mock_products.return_value = PRODUCTS_MODERN + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert mock_vehicle_data.called is False + freezer.tick(VEHICLE_INTERVAL) + assert mock_vehicle_data.called is False + freezer.tick(VEHICLE_INTERVAL) + assert mock_vehicle_data.called is False diff --git a/tests/components/teslemetry/test_lock.py b/tests/components/teslemetry/test_lock.py index 848eee82c39..a74d613859f 100644 --- a/tests/components/teslemetry/test_lock.py +++ b/tests/components/teslemetry/test_lock.py @@ -57,7 +57,7 @@ async def test_lock_services( entity_id = "lock.test_lock" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.door_lock", + "tesla_fleet_api.teslemetry.Vehicle.door_lock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -71,7 +71,7 @@ async def test_lock_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.door_unlock", + "tesla_fleet_api.teslemetry.Vehicle.door_unlock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -95,7 +95,7 @@ async def test_lock_services( ) with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.teslemetry.Vehicle.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py index ae462bfd026..ab8f21ceda4 100644 --- a/tests/components/teslemetry/test_media_player.py +++ b/tests/components/teslemetry/test_media_player.py @@ -2,7 +2,9 @@ from unittest.mock import AsyncMock, patch +import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_LEVEL, @@ -18,7 +20,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, assert_entities_alt, setup_platform +from . import assert_entities, assert_entities_alt, reload_platform, setup_platform from .const import COMMAND_OK, METADATA_NOSCOPE, VEHICLE_DATA_ALT @@ -26,6 +28,7 @@ async def test_media_player( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct.""" @@ -38,6 +41,7 @@ async def test_media_player_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct.""" @@ -51,6 +55,7 @@ async def test_media_player_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct without required scope.""" @@ -62,6 +67,7 @@ async def test_media_player_noscope( async def test_media_player_services( hass: HomeAssistant, snapshot: SnapshotAssertion, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player services work.""" @@ -70,7 +76,7 @@ async def test_media_player_services( entity_id = "media_player.test_media_player" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.adjust_volume", + "tesla_fleet_api.teslemetry.Vehicle.adjust_volume", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -84,7 +90,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.teslemetry.Vehicle.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -99,7 +105,7 @@ async def test_media_player_services( # This test will fail without the previous call to pause playback with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.teslemetry.Vehicle.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -113,7 +119,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_next_track", + "tesla_fleet_api.teslemetry.Vehicle.media_next_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -126,7 +132,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_prev_track", + "tesla_fleet_api.teslemetry.Vehicle.media_prev_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -137,3 +143,62 @@ async def test_media_player_services( ) state = hass.states.get(entity_id) call.assert_called_once() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the media player entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.CENTER_DISPLAY: "Off", + Signal.MEDIA_PLAYBACK_STATUS: None, + Signal.MEDIA_PLAYBACK_SOURCE: None, + Signal.MEDIA_AUDIO_VOLUME: None, + Signal.MEDIA_NOW_PLAYING_DURATION: None, + Signal.MEDIA_NOW_PLAYING_ELAPSED: None, + Signal.MEDIA_NOW_PLAYING_ARTIST: None, + Signal.MEDIA_NOW_PLAYING_ALBUM: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("media_player.test_media_player") + assert state == snapshot(name="off") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.CENTER_DISPLAY: "Driving", + Signal.MEDIA_PLAYBACK_STATUS: "Playing", + Signal.MEDIA_PLAYBACK_SOURCE: "Spotify", + Signal.MEDIA_AUDIO_VOLUME: 2, + Signal.MEDIA_NOW_PLAYING_DURATION: 60000, + Signal.MEDIA_NOW_PLAYING_ELAPSED: 5000, + Signal.MEDIA_NOW_PLAYING_ARTIST: "Test Artist", + Signal.MEDIA_NOW_PLAYING_ALBUM: "Test Album", + }, + "createdAt": "2024-10-04T10:55:17.000Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("media_player.test_media_player") + assert state == snapshot(name="on") + + await reload_platform(hass, entry, [Platform.MEDIA_PLAYER]) + + # Ensure the restored state is the same as the previous state + state = hass.states.get("media_player.test_media_player") + assert state == snapshot(name="on") diff --git a/tests/components/teslemetry/test_number.py b/tests/components/teslemetry/test_number.py index 95eed5a3f1e..2c45631a060 100644 --- a/tests/components/teslemetry/test_number.py +++ b/tests/components/teslemetry/test_number.py @@ -42,7 +42,7 @@ async def test_number_services( entity_id = "number.test_charge_current" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_charging_amps", + "tesla_fleet_api.teslemetry.Vehicle.set_charging_amps", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -57,7 +57,7 @@ async def test_number_services( entity_id = "number.test_charge_limit" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_charge_limit", + "tesla_fleet_api.teslemetry.Vehicle.set_charge_limit", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -72,7 +72,7 @@ async def test_number_services( entity_id = "number.energy_site_backup_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.backup", + "tesla_fleet_api.teslemetry.EnergySite.backup", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -90,7 +90,7 @@ async def test_number_services( entity_id = "number.energy_site_off_grid_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.off_grid_vehicle_charging_reserve", + "tesla_fleet_api.teslemetry.EnergySite.off_grid_vehicle_charging_reserve", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index c49e83803cd..b17b52903fa 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -41,7 +41,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.test_seat_heater_front_left" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.remote_seat_heater_request", + "tesla_fleet_api.teslemetry.Vehicle.remote_seat_heater_request", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -56,7 +56,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.test_steering_wheel_heater" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.remote_steering_wheel_heat_level_request", + "tesla_fleet_api.teslemetry.Vehicle.remote_steering_wheel_heat_level_request", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -71,7 +71,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.energy_site_operation_mode" with patch( - "homeassistant.components.teslemetry.EnergySpecific.operation", + "tesla_fleet_api.teslemetry.EnergySite.operation", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -89,7 +89,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.energy_site_allow_export" with patch( - "homeassistant.components.teslemetry.EnergySpecific.grid_import_export", + "tesla_fleet_api.teslemetry.EnergySite.grid_import_export", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index a488ebc8a06..f50dc93bde4 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -31,9 +31,7 @@ async def test_sensors( freezer.move_to("2024-01-01 00:00:00+00:00") # Force the vehicle to use polling - with patch( - "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True - ): + with patch("tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True): entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) @@ -72,9 +70,15 @@ async def test_sensors_streaming( Signal.AC_CHARGING_ENERGY_IN: 10, Signal.AC_CHARGING_POWER: 2, Signal.CHARGING_CABLE_TYPE: None, - Signal.TIME_TO_FULL_CHARGE: 10, + Signal.TIME_TO_FULL_CHARGE: 0.166666667, Signal.MINUTES_TO_ARRIVAL: None, }, + "credits": { + "type": "wake_up", + "cost": 20, + "name": "wake_up", + "balance": 1980, + }, "createdAt": "2024-10-04T10:45:17.537Z", } ) @@ -93,6 +97,7 @@ async def test_sensors_streaming( "sensor.test_charge_cable", "sensor.test_time_to_full_charge", "sensor.test_time_to_arrival", + "sensor.teslemetry_credits", ): state = hass.states.get(entity_id) assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/teslemetry/test_services.py b/tests/components/teslemetry/test_services.py index a5b55f5dcc5..bcf5407999f 100644 --- a/tests/components/teslemetry/test_services.py +++ b/tests/components/teslemetry/test_services.py @@ -51,7 +51,7 @@ async def test_services( ).device_id with patch( - "homeassistant.components.teslemetry.VehicleSpecific.navigation_gps_request", + "tesla_fleet_api.teslemetry.Vehicle.navigation_gps_request", return_value=COMMAND_OK, ) as navigation_gps_request: await hass.services.async_call( @@ -66,7 +66,7 @@ async def test_services( navigation_gps_request.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_scheduled_charging", + "tesla_fleet_api.teslemetry.Vehicle.set_scheduled_charging", return_value=COMMAND_OK, ) as set_scheduled_charging: await hass.services.async_call( @@ -93,7 +93,7 @@ async def test_services( ) with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_scheduled_departure", + "tesla_fleet_api.teslemetry.Vehicle.set_scheduled_departure", return_value=COMMAND_OK, ) as set_scheduled_departure: await hass.services.async_call( @@ -138,7 +138,7 @@ async def test_services( ) with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_valet_mode", + "tesla_fleet_api.teslemetry.Vehicle.set_valet_mode", return_value=COMMAND_OK, ) as set_valet_mode: await hass.services.async_call( @@ -154,7 +154,7 @@ async def test_services( set_valet_mode.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.speed_limit_activate", + "tesla_fleet_api.teslemetry.Vehicle.speed_limit_activate", return_value=COMMAND_OK, ) as speed_limit_activate: await hass.services.async_call( @@ -170,7 +170,7 @@ async def test_services( speed_limit_activate.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.speed_limit_deactivate", + "tesla_fleet_api.teslemetry.Vehicle.speed_limit_deactivate", return_value=COMMAND_OK, ) as speed_limit_deactivate: await hass.services.async_call( @@ -186,7 +186,7 @@ async def test_services( speed_limit_deactivate.assert_called_once() with patch( - "homeassistant.components.teslemetry.EnergySpecific.time_of_use_settings", + "tesla_fleet_api.teslemetry.EnergySite.time_of_use_settings", return_value=COMMAND_OK, ) as set_time_of_use: await hass.services.async_call( @@ -202,7 +202,7 @@ async def test_services( with ( patch( - "homeassistant.components.teslemetry.EnergySpecific.time_of_use_settings", + "tesla_fleet_api.teslemetry.EnergySite.time_of_use_settings", return_value=COMMAND_ERROR, ) as set_time_of_use, pytest.raises(HomeAssistantError), diff --git a/tests/components/teslemetry/test_switch.py b/tests/components/teslemetry/test_switch.py index 6a1ddb430ce..6b31a28db59 100644 --- a/tests/components/teslemetry/test_switch.py +++ b/tests/components/teslemetry/test_switch.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, @@ -14,7 +15,7 @@ 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 -from . import assert_entities, assert_entities_alt, setup_platform +from . import assert_entities, assert_entities_alt, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA_ALT @@ -22,6 +23,7 @@ async def test_switch( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the switch entities are correct.""" @@ -34,6 +36,7 @@ async def test_switch_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the switch entities are correct.""" @@ -46,41 +49,41 @@ async def test_switch_alt( @pytest.mark.parametrize( ("name", "on", "off"), [ - ("test_charge", "VehicleSpecific.charge_start", "VehicleSpecific.charge_stop"), + ("test_charge", "Vehicle.charge_start", "Vehicle.charge_stop"), ( "test_auto_seat_climate_left", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", ), ( "test_auto_seat_climate_right", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", ), ( "test_auto_steering_wheel_heater", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", + "Vehicle.remote_auto_steering_wheel_heat_climate_request", + "Vehicle.remote_auto_steering_wheel_heat_climate_request", ), ( "test_defrost", - "VehicleSpecific.set_preconditioning_max", - "VehicleSpecific.set_preconditioning_max", + "Vehicle.set_preconditioning_max", + "Vehicle.set_preconditioning_max", ), ( "energy_site_storm_watch", - "EnergySpecific.storm_mode", - "EnergySpecific.storm_mode", + "EnergySite.storm_mode", + "EnergySite.storm_mode", ), ( "energy_site_allow_charging_from_grid", - "EnergySpecific.grid_import_export", - "EnergySpecific.grid_import_export", + "EnergySite.grid_import_export", + "EnergySite.grid_import_export", ), ( "test_sentry_mode", - "VehicleSpecific.set_sentry_mode", - "VehicleSpecific.set_sentry_mode", + "Vehicle.set_sentry_mode", + "Vehicle.set_sentry_mode", ), ], ) @@ -93,7 +96,7 @@ async def test_switch_services( entity_id = f"switch.{name}" with patch( - f"homeassistant.components.teslemetry.{on}", + f"tesla_fleet_api.teslemetry.{on}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -107,7 +110,7 @@ async def test_switch_services( call.assert_called_once() with patch( - f"homeassistant.components.teslemetry.{off}", + f"tesla_fleet_api.teslemetry.{off}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -119,3 +122,47 @@ async def test_switch_services( state = hass.states.get(entity_id) assert state.state == STATE_OFF call.assert_called_once() + + +async def test_switch_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the switch entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.SWITCH]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SENTRY_MODE: "SentryModeStateIdle", + Signal.AUTO_SEAT_CLIMATE_LEFT: True, + Signal.AUTO_SEAT_CLIMATE_RIGHT: False, + Signal.HVAC_STEERING_WHEEL_HEAT_AUTO: True, + Signal.DEFROST_MODE: "DefrostModeStateOff", + Signal.DETAILED_CHARGE_STATE: "DetailedChargeStateCharging", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await reload_platform(hass, entry, [Platform.SWITCH]) + + # Assert the entities restored their values + for entity_id in ( + "switch.test_sentry_mode", + "switch.test_auto_seat_climate_left", + "switch.test_auto_seat_climate_right", + "switch.test_auto_steering_wheel_heater", + "switch.test_defrost", + "switch.test_charge", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=entity_id) diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py index 448f31afd67..af6c9d847f1 100644 --- a/tests/components/teslemetry/test_update.py +++ b/tests/components/teslemetry/test_update.py @@ -4,7 +4,9 @@ import copy 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.components.teslemetry.update import INSTALLING @@ -13,7 +15,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA, VEHICLE_DATA_ALT from tests.common import async_fire_time_changed @@ -23,6 +25,7 @@ async def test_update( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the update entities are correct.""" @@ -35,6 +38,7 @@ async def test_update_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the update entities are correct.""" @@ -48,6 +52,7 @@ async def test_update_services( mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, + mock_legacy: AsyncMock, ) -> None: """Tests that the update services work.""" @@ -56,7 +61,7 @@ async def test_update_services( entity_id = "update.test_update" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.schedule_software_update", + "tesla_fleet_api.teslemetry.Vehicle.schedule_software_update", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -78,3 +83,90 @@ async def test_update_services( state = hass.states.get(entity_id) assert state.attributes["in_progress"] == 1 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the select entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.UPDATE]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 50, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state == snapshot(name="downloading") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 1, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state == snapshot(name="ready") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 50, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state == snapshot(name="installing") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "", + Signal.VERSION: "2025.2.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state == snapshot(name="updated") + + await reload_platform(hass, entry, [Platform.UPDATE]) + + state = hass.states.get("update.test_update") + assert state == snapshot(name="restored") diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index 37a38fffaa4..a78d91e3f48 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -5,7 +5,7 @@ from unittest.mock import patch from aiohttp import ClientConnectionError, ClientResponseError from aiohttp.client import RequestInfo -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tessie import PLATFORMS from homeassistant.components.tessie.const import DOMAIN, TessieStatus diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py index e0aba73af17..5fb844ff6b4 100644 --- a/tests/components/tessie/conftest.py +++ b/tests/components/tessie/conftest.py @@ -85,7 +85,7 @@ def mock_request(): def mock_live_status(): """Mock Tesla Fleet API EnergySpecific live_status method.""" with patch( - "homeassistant.components.tessie.EnergySpecific.live_status", + "tesla_fleet_api.tessie.EnergySite.live_status", side_effect=lambda: deepcopy(LIVE_STATUS), ) as mock_live_status: yield mock_live_status @@ -95,7 +95,7 @@ def mock_live_status(): def mock_site_info(): """Mock Tesla Fleet API EnergySpecific site_info method.""" with patch( - "homeassistant.components.tessie.EnergySpecific.site_info", + "tesla_fleet_api.tessie.EnergySite.site_info", side_effect=lambda: deepcopy(SITE_INFO), ) as mock_live_status: yield mock_live_status diff --git a/tests/components/tessie/snapshots/test_number.ambr b/tests/components/tessie/snapshots/test_number.ambr index 0e43695ca78..e865058c4a2 100644 --- a/tests/components/tessie/snapshots/test_number.ambr +++ b/tests/components/tessie/snapshots/test_number.ambr @@ -88,7 +88,7 @@ }), 'original_device_class': , 'original_icon': 'mdi:battery-unknown', - 'original_name': 'Off grid reserve', + 'original_name': 'Off-grid reserve', 'platform': 'tessie', 'previous_unique_id': None, 'supported_features': 0, @@ -101,7 +101,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Energy Site Off grid reserve', + 'friendly_name': 'Energy Site Off-grid reserve', 'icon': 'mdi:battery-unknown', 'max': 100, 'min': 0, diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr index 5465f89d808..b40cf204bca 100644 --- a/tests/components/tessie/snapshots/test_sensor.ambr +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -1614,7 +1614,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'p', }) # --- # name: test_sensors[sensor.test_speed-entry] diff --git a/tests/components/tessie/test_binary_sensor.py b/tests/components/tessie/test_binary_sensor.py index 0ced8a6d8aa..26d343181fa 100644 --- a/tests/components/tessie/test_binary_sensor.py +++ b/tests/components/tessie/test_binary_sensor.py @@ -1,7 +1,7 @@ """Test the Tessie binary sensor platform.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py index c9cfca3288a..da5942c0fdd 100644 --- a/tests/components/tessie/test_button.py +++ b/tests/components/tessie/test_button.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform diff --git a/tests/components/tessie/test_climate.py b/tests/components/tessie/test_climate.py index bc688e1ca70..4a0134c1b58 100644 --- a/tests/components/tessie/test_climate.py +++ b/tests/components/tessie/test_climate.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_HVAC_MODE, diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index 02a8f22b6ea..b71b1f44377 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, diff --git a/tests/components/tessie/test_device_tracker.py b/tests/components/tessie/test_device_tracker.py index 08d96b7303e..01defd8844c 100644 --- a/tests/components/tessie/test_device_tracker.py +++ b/tests/components/tessie/test_device_tracker.py @@ -1,6 +1,6 @@ """Test the Tessie device tracker platform.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tessie/test_lock.py b/tests/components/tessie/test_lock.py index 1208bb17d55..f94614bd2bf 100644 --- a/tests/components/tessie/test_lock.py +++ b/tests/components/tessie/test_lock.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, diff --git a/tests/components/tessie/test_media_player.py b/tests/components/tessie/test_media_player.py index 008607b8018..27a4828b6bb 100644 --- a/tests/components/tessie/test_media_player.py +++ b/tests/components/tessie/test_media_player.py @@ -3,7 +3,7 @@ from datetime import timedelta from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL from homeassistant.const import Platform diff --git a/tests/components/tessie/test_number.py b/tests/components/tessie/test_number.py index 0fb13779183..8f1d0820ea9 100644 --- a/tests/components/tessie/test_number.py +++ b/tests/components/tessie/test_number.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, @@ -67,7 +67,7 @@ async def test_numbers( entity_id = "number.energy_site_backup_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.backup", + "tesla_fleet_api.tessie.EnergySite.backup", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( @@ -85,7 +85,7 @@ async def test_numbers( entity_id = "number.energy_site_off_grid_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.off_grid_vehicle_charging_reserve", + "tesla_fleet_api.tessie.EnergySite.off_grid_vehicle_charging_reserve", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py index c78923fbf5b..44a5e99b5c1 100644 --- a/tests/components/tessie/test_select.py +++ b/tests/components/tessie/test_select.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode from tesla_fleet_api.exceptions import UnsupportedVehicle @@ -52,7 +52,7 @@ async def test_select( # Test site operation mode entity_id = "select.energy_site_operation_mode" with patch( - "homeassistant.components.teslemetry.EnergySpecific.operation", + "tesla_fleet_api.tessie.EnergySite.operation", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( @@ -71,7 +71,7 @@ async def test_select( # Test site export mode entity_id = "select.energy_site_allow_export" with patch( - "homeassistant.components.teslemetry.EnergySpecific.grid_import_export", + "tesla_fleet_api.tessie.EnergySite.grid_import_export", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( @@ -129,7 +129,7 @@ async def test_errors(hass: HomeAssistant) -> None: # Test changing energy select with unknown error with ( patch( - "homeassistant.components.tessie.EnergySpecific.operation", + "tesla_fleet_api.tessie.EnergySite.operation", side_effect=UnsupportedVehicle, ) as mock_set, pytest.raises(HomeAssistantError) as error, diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py index 92256d25eb1..144ec06723d 100644 --- a/tests/components/tessie/test_sensor.py +++ b/tests/components/tessie/test_sensor.py @@ -2,7 +2,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index 690ad7d1ab4..aaa9c769ff8 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, @@ -61,13 +61,13 @@ async def test_switches( [ ( "energy_site_storm_watch", - "EnergySpecific.storm_mode", - "EnergySpecific.storm_mode", + "storm_mode", + "storm_mode", ), ( "energy_site_allow_charging_from_grid", - "EnergySpecific.grid_import_export", - "EnergySpecific.grid_import_export", + "grid_import_export", + "grid_import_export", ), ], ) @@ -80,7 +80,7 @@ async def test_switch_services( entity_id = f"switch.{name}" with patch( - f"homeassistant.components.teslemetry.{on}", + f"tesla_fleet_api.tessie.EnergySite.{on}", return_value=RESPONSE_OK, ) as call: await hass.services.async_call( @@ -94,7 +94,7 @@ async def test_switch_services( call.assert_called_once() with patch( - f"homeassistant.components.teslemetry.{off}", + f"tesla_fleet_api.tessie.EnergySite.{off}", return_value=RESPONSE_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tessie/test_update.py b/tests/components/tessie/test_update.py index 8d098e9a966..3510632b62c 100644 --- a/tests/components/tessie/test_update.py +++ b/tests/components/tessie/test_update.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.update import ( ATTR_IN_PROGRESS, diff --git a/tests/components/thermobeacon/__init__.py b/tests/components/thermobeacon/__init__.py index 2f7e220ebaa..32b6d823ec2 100644 --- a/tests/components/thermobeacon/__init__.py +++ b/tests/components/thermobeacon/__init__.py @@ -1,8 +1,48 @@ """Tests for the ThermoBeacon integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_THERMOBEACON_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +NOT_THERMOBEACON_SERVICE_INFO = make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -12,7 +52,7 @@ NOT_THERMOBEACON_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -THERMOBEACON_SERVICE_INFO = BluetoothServiceInfo( +THERMOBEACON_SERVICE_INFO = make_bluetooth_service_info( name="ThermoBeacon", address="aa:bb:cc:dd:ee:ff", rssi=-60, diff --git a/tests/components/thermopro/__init__.py b/tests/components/thermopro/__init__.py index d3cba26858f..7ac593e6336 100644 --- a/tests/components/thermopro/__init__.py +++ b/tests/components/thermopro/__init__.py @@ -1,8 +1,48 @@ """Tests for the ThermoPro integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_THERMOPRO_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice +from bluetooth_data_tools import monotonic_time_coarse + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + + +def make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, + raw: bytes | None = None, +) -> BluetoothServiceInfoBleak: + """Create a BluetoothServiceInfoBleak object for testing.""" + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=monotonic_time_coarse(), + advertisement=None, + connectable=True, + tx_power=tx_power, + raw=raw, + ) + + +NOT_THERMOPRO_SERVICE_INFO = make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -13,7 +53,7 @@ NOT_THERMOPRO_SERVICE_INFO = BluetoothServiceInfo( ) -TP357_SERVICE_INFO = BluetoothServiceInfo( +TP357_SERVICE_INFO = make_bluetooth_service_info( name="TP357 (2142)", manufacturer_data={61890: b"\x00\x1d\x02,"}, service_uuids=[], @@ -23,7 +63,7 @@ TP357_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -TP358_SERVICE_INFO = BluetoothServiceInfo( +TP358_SERVICE_INFO = make_bluetooth_service_info( name="TP358 (4221)", manufacturer_data={61890: b"\x00\x1d\x02,"}, service_uuids=[], @@ -33,7 +73,7 @@ TP358_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -TP962R_SERVICE_INFO = BluetoothServiceInfo( +TP962R_SERVICE_INFO = make_bluetooth_service_info( name="TP962R (0000)", manufacturer_data={14081: b"\x00;\x0b7\x00"}, service_uuids=["72fbb631-6f6b-d1ba-db55-2ee6fdd942bd"], @@ -43,7 +83,7 @@ TP962R_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -TP962R_SERVICE_INFO_2 = BluetoothServiceInfo( +TP962R_SERVICE_INFO_2 = make_bluetooth_service_info( name="TP962R (0000)", manufacturer_data={17152: b"\x00\x17\nC\x00", 14081: b"\x00;\x0b7\x00"}, service_uuids=["72fbb631-6f6b-d1ba-db55-2ee6fdd942bd"], diff --git a/tests/components/threshold/test_config_flow.py b/tests/components/threshold/test_config_flow.py index c13717800bf..3c27f09d396 100644 --- a/tests/components/threshold/test_config_flow.py +++ b/tests/components/threshold/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.threshold.const import DOMAIN @@ -11,7 +11,7 @@ from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value from tests.typing import WebSocketGenerator @@ -88,17 +88,6 @@ async def test_fail(hass: HomeAssistant, extra_input_data, error) -> None: assert result["errors"] == {"base": error} -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" input_sensor = "sensor.input" @@ -125,9 +114,9 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "hysteresis") == 0.0 - assert get_suggested(schema, "lower") == -2.0 - assert get_suggested(schema, "upper") is None + assert get_schema_suggested_value(schema, "hysteresis") == 0.0 + assert get_schema_suggested_value(schema, "lower") == -2.0 + assert get_schema_suggested_value(schema, "upper") is None result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/tile/conftest.py b/tests/components/tile/conftest.py index 4391853c878..21ca2c90fa1 100644 --- a/tests/components/tile/conftest.py +++ b/tests/components/tile/conftest.py @@ -26,6 +26,7 @@ def tile() -> AsyncMock: mock.latitude = 1 mock.longitude = 1 mock.altitude = 0 + mock.accuracy = 13.496111 mock.lost = False mock.last_timestamp = datetime(2020, 8, 12, 17, 55, 26) mock.lost_timestamp = datetime(1969, 12, 31, 19, 0, 0) @@ -42,8 +43,8 @@ def tile() -> AsyncMock: "hardware_version": "02.09", "kind": "TILE", "last_timestamp": datetime(2020, 8, 12, 17, 55, 26), - "latitude": 0, - "longitude": 0, + "latitude": 1, + "longitude": 1, "lost": False, "lost_timestamp": datetime(1969, 12, 31, 19, 0, 0), "name": "Wallet", diff --git a/tests/components/tile/snapshots/test_device_tracker.ambr b/tests/components/tile/snapshots/test_device_tracker.ambr index f5de1511c99..3f94f679f10 100644 --- a/tests/components/tile/snapshots/test_device_tracker.ambr +++ b/tests/components/tile/snapshots/test_device_tracker.ambr @@ -38,7 +38,7 @@ 'attributes': ReadOnlyDict({ 'altitude': 0, 'friendly_name': 'Wallet', - 'gps_accuracy': 1, + 'gps_accuracy': 13.496111, '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), diff --git a/tests/components/tile/test_binary_sensor.py b/tests/components/tile/test_binary_sensor.py index c8b4b9b8376..e5606baf5c7 100644 --- a/tests/components/tile/test_binary_sensor.py +++ b/tests/components/tile/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tile/test_device_tracker.py b/tests/components/tile/test_device_tracker.py index 105cae1a7d7..50718114aa6 100644 --- a/tests/components/tile/test_device_tracker.py +++ b/tests/components/tile/test_device_tracker.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tile/test_diagnostics.py b/tests/components/tile/test_diagnostics.py index 87bc670d604..0c7e0001ff3 100644 --- a/tests/components/tile/test_diagnostics.py +++ b/tests/components/tile/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/tile/test_init.py b/tests/components/tile/test_init.py index fba354ade17..28daac6ff5d 100644 --- a/tests/components/tile/test_init.py +++ b/tests/components/tile/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tile.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/tod/test_config_flow.py b/tests/components/tod/test_config_flow.py index 81f10061774..125a969c09d 100644 --- a/tests/components/tod/test_config_flow.py +++ b/tests/components/tod/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.tod.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -55,17 +55,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: assert config_entry.title == "My tod" -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - @pytest.mark.freeze_time("2022-03-16 17:37:00", tz_offset=-7) async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" @@ -88,8 +77,8 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "after_time") == "10:00" - assert get_suggested(schema, "before_time") == "18:05" + assert get_schema_suggested_value(schema, "after_time") == "10:00" + assert get_schema_suggested_value(schema, "before_time") == "18:05" result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/todo/__init__.py b/tests/components/todo/__init__.py index 53772ab144e..239b586d366 100644 --- a/tests/components/todo/__init__.py +++ b/tests/components/todo/__init__.py @@ -34,6 +34,13 @@ class MockTodoListEntity(TodoListEntity): """Delete an item in the To-do list.""" self._attr_todo_items = [item for item in self.items if item.uid not in uids] + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item in the To-do list.""" + for idx, existing_item in enumerate(self.items): + if existing_item.uid == item.uid: + self._attr_todo_items[idx] = item + break + async def create_mock_platform( hass: HomeAssistant, diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 8e8c010f758..adada97a9e4 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -7,8 +7,6 @@ import zoneinfo import pytest import voluptuous as vol -from homeassistant.components import conversation -from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.todo import ( ATTR_DESCRIPTION, ATTR_DUE_DATE, @@ -22,7 +20,6 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, TodoServices, - intent as todo_intent, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES @@ -32,10 +29,9 @@ from homeassistant.exceptions import ( ServiceNotSupported, ServiceValidationError, ) -from homeassistant.helpers import intent from homeassistant.setup import async_setup_component -from . import MockTodoListEntity, create_mock_platform +from . import create_mock_platform from tests.typing import WebSocketGenerator @@ -164,9 +160,18 @@ async def test_unsupported_websocket( assert resp.get("error", {}).get("code") == "not_found" +@pytest.mark.parametrize( + ("new_item_name"), + [ + ("New item"), + ("New item "), + (" New item"), + ], +) async def test_add_item_service( hass: HomeAssistant, test_entity: TodoListEntity, + new_item_name: str, ) -> None: """Test adding an item in a To-do list.""" @@ -175,7 +180,7 @@ async def test_add_item_service( await hass.services.async_call( DOMAIN, TodoServices.ADD_ITEM, - {ATTR_ITEM: "New item"}, + {ATTR_ITEM: new_item_name}, target={ATTR_ENTITY_ID: "todo.entity1"}, blocking=True, ) @@ -213,6 +218,7 @@ async def test_add_item_service_raises( [ ({}, vol.Invalid, "required key not provided"), ({ATTR_ITEM: ""}, vol.Invalid, "length of value must be at least 1"), + ({ATTR_ITEM: " "}, vol.Invalid, "length of value must be at least 1"), ( {ATTR_ITEM: "Submit forms", ATTR_DESCRIPTION: "Submit tax forms"}, ServiceValidationError, @@ -335,9 +341,18 @@ async def test_add_item_service_extended_fields( assert item == expected_item +@pytest.mark.parametrize( + ("new_item_name"), + [ + ("Updated item"), + ("Updated item "), + (" Updated item "), + ], +) async def test_update_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, + new_item_name: str, ) -> None: """Test updating an item in a To-do list.""" @@ -346,7 +361,7 @@ async def test_update_todo_item_service_by_id( await hass.services.async_call( DOMAIN, TodoServices.UPDATE_ITEM, - {ATTR_ITEM: "1", ATTR_RENAME: "Updated item", ATTR_STATUS: "completed"}, + {ATTR_ITEM: "1", ATTR_RENAME: new_item_name, ATTR_STATUS: "completed"}, target={ATTR_ENTITY_ID: "todo.entity1"}, blocking=True, ) @@ -989,116 +1004,6 @@ async def test_move_item_unsupported( assert resp.get("error", {}).get("code") == "not_supported" -async def test_add_item_intent( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test adding items to lists using an intent.""" - assert await async_setup_component(hass, "homeassistant", {}) - await todo_intent.async_setup_intents(hass) - - entity1 = MockTodoListEntity() - entity1._attr_name = "List 1" - entity1.entity_id = "todo.list_1" - - entity2 = MockTodoListEntity() - entity2._attr_name = "List 2" - entity2.entity_id = "todo.list_2" - - await create_mock_platform(hass, [entity1, entity2]) - - # Add to first list - response = await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {ATTR_ITEM: {"value": " beer "}, "name": {"value": "list 1"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.success_results[0].name == "list 1" - assert response.success_results[0].type == intent.IntentResponseTargetType.ENTITY - assert response.success_results[0].id == entity1.entity_id - - assert len(entity1.items) == 1 - assert len(entity2.items) == 0 - assert entity1.items[0].summary == "beer" # summary is trimmed - assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION - entity1.items.clear() - - # Add to second list - response = await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {ATTR_ITEM: {"value": "cheese"}, "name": {"value": "List 2"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.ACTION_DONE - - assert len(entity1.items) == 0 - assert len(entity2.items) == 1 - assert entity2.items[0].summary == "cheese" - assert entity2.items[0].status == TodoItemStatus.NEEDS_ACTION - - # List name is case insensitive - response = await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {ATTR_ITEM: {"value": "wine"}, "name": {"value": "lIST 2"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.ACTION_DONE - - assert len(entity1.items) == 0 - assert len(entity2.items) == 2 - assert entity2.items[1].summary == "wine" - assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION - - # Should fail if lists are not exposed - async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": "cookies"}, "name": {"value": "list 1"}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Missing list - with pytest.raises(intent.MatchFailedError): - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, - assistant=conversation.DOMAIN, - ) - - # Fail with empty name/item - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": "wine"}, "name": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": ""}, "name": {"value": "list 1"}}, - assistant=conversation.DOMAIN, - ) - - async def test_remove_completed_items_service( hass: HomeAssistant, test_entity: TodoListEntity, diff --git a/tests/components/todo/test_intent.py b/tests/components/todo/test_intent.py new file mode 100644 index 00000000000..3f86347d1b7 --- /dev/null +++ b/tests/components/todo/test_intent.py @@ -0,0 +1,292 @@ +"""Tests for the todo intents.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.todo import ( + ATTR_ITEM, + DOMAIN, + TodoItem, + TodoItemStatus, + TodoListEntity, + intent as todo_intent, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component + +from . import MockTodoListEntity, create_mock_platform + +from tests.common import async_mock_service +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_intents(hass: HomeAssistant) -> None: + """Set up the intents.""" + assert await async_setup_component(hass, "homeassistant", {}) + await todo_intent.async_setup_intents(hass) + + +async def test_add_item_intent( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test adding items to lists using an intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + await todo_intent.async_setup_intents(hass) + + entity1 = MockTodoListEntity() + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + entity2 = MockTodoListEntity() + entity2._attr_name = "List 2" + entity2.entity_id = "todo.list_2" + + await create_mock_platform(hass, [entity1, entity2]) + + # Add to first list + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {ATTR_ITEM: {"value": " beer "}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.success_results[0].name == "list 1" + assert response.success_results[0].type == intent.IntentResponseTargetType.ENTITY + assert response.success_results[0].id == entity1.entity_id + + assert len(entity1.items) == 1 + assert len(entity2.items) == 0 + assert entity1.items[0].summary == "beer" # summary is trimmed + assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION + entity1.items.clear() + + # Add to second list + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {ATTR_ITEM: {"value": "cheese"}, "name": {"value": "List 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 1 + assert entity2.items[0].summary == "cheese" + assert entity2.items[0].status == TodoItemStatus.NEEDS_ACTION + + # List name is case insensitive + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {ATTR_ITEM: {"value": "wine"}, "name": {"value": "lIST 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 2 + assert entity2.items[1].summary == "wine" + assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION + + # Should fail if lists are not exposed + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "cookies"}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Missing list + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, + assistant=conversation.DOMAIN, + ) + + # Fail with empty name/item + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": ""}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + + +async def test_add_item_intent_errors( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test errors with the add item intent.""" + test_entity._attr_name = "List 1" + await create_mock_platform(hass, [test_entity]) + + # Try to add item in list that does not exist + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + { + ATTR_ITEM: {"value": "wine"}, + ATTR_NAME: {"value": "This list does not exist"}, + }, + assistant=conversation.DOMAIN, + ) + + # Mock the get_entity method to return None + hass.data[DOMAIN].get_entity = lambda entity_id: None + + # Try to add item in a list that exists but get_entity returns None + with pytest.raises(intent.IntentHandleError, match="No to-do list: List 1"): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + { + ATTR_ITEM: {"value": "wine"}, + ATTR_NAME: {"value": "List 1"}, + }, + assistant=conversation.DOMAIN, + ) + + +async def test_complete_item_intent( + hass: HomeAssistant, +) -> None: + """Test the complete item intent.""" + entity1 = MockTodoListEntity( + [ + TodoItem(summary="beer", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="wine", uid="2", status=TodoItemStatus.NEEDS_ACTION), + ] + ) + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + # Add entities to hass + config_entry = await create_mock_platform(hass, [entity1]) + assert config_entry.state is ConfigEntryState.LOADED + + assert len(entity1.items) == 2 + assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION + + # Complete item + async_mock_service(hass, DOMAIN, todo_intent.INTENT_LIST_COMPLETE_ITEM) + response = await intent.async_handle( + hass, + DOMAIN, + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "beer"}, ATTR_NAME: {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 2 + assert entity1.items[0].status == TodoItemStatus.COMPLETED + + +async def test_complete_item_intent_errors( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test errors with the complete item intent.""" + entity1 = MockTodoListEntity( + [ + TodoItem(summary="beer", uid="1", status=TodoItemStatus.COMPLETED), + ] + ) + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + # Add entities to hass + await create_mock_platform(hass, [entity1]) + + # Try to complete item in list that does not exist + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_COMPLETE_ITEM, + { + ATTR_ITEM: {"value": "wine"}, + ATTR_NAME: {"value": "This list does not exist"}, + }, + assistant=conversation.DOMAIN, + ) + + # Try to complete item that does not exist + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "bread"}, ATTR_NAME: {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + + # Item is already completed + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "beer"}, ATTR_NAME: {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + + +async def test_complete_item_intent_ha_errors( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test error handling of HA errors with the complete item intent.""" + test_entity._attr_name = "List 1" + test_entity.entity_id = "todo.list_1" + await create_mock_platform(hass, [test_entity]) + + # Mock the get_entity method to return None + with ( + patch( + "homeassistant.helpers.entity_component.EntityComponent.get_entity", + return_value=None, + ), + pytest.raises(intent.IntentHandleError), + ): + await intent.async_handle( + hass, + DOMAIN, + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "wine"}, ATTR_NAME: {"value": "List 1"}}, + assistant=conversation.DOMAIN, + ) diff --git a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr index a63319a6c76..ac32b50762f 100644 --- a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr @@ -36,19 +36,11 @@ # name: test_attributes[alarm_control_panel.test-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'ac_loss': False, 'changed_by': None, 'code_arm_required': False, 'code_format': None, - 'cover_tampered': False, 'friendly_name': 'test', - 'location_id': 123456, - 'location_name': 'test', - 'low_battery': False, - 'partition': 1, 'supported_features': , - 'triggered_source': None, - 'triggered_zone': None, }), 'context': , 'entity_id': 'alarm_control_panel.test', @@ -95,19 +87,11 @@ # name: test_attributes[alarm_control_panel.test_partition_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'ac_loss': False, 'changed_by': None, 'code_arm_required': False, 'code_format': None, - 'cover_tampered': False, 'friendly_name': 'test Partition 2', - 'location_id': 123456, - 'location_name': 'test partition 2', - 'low_battery': False, - 'partition': 2, 'supported_features': , - 'triggered_source': None, - 'triggered_zone': None, }), 'context': , 'entity_id': 'alarm_control_panel.test_partition_2', diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index bc76f7243ca..6f7d8163362 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -5,7 +5,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from total_connect_client.exceptions import ( AuthenticationError, ServiceUnavailable, @@ -50,9 +50,6 @@ from .common import ( RESPONSE_DISARMED, RESPONSE_DISARMING, RESPONSE_SUCCESS, - RESPONSE_TRIGGERED_CARBON_MONOXIDE, - RESPONSE_TRIGGERED_FIRE, - RESPONSE_TRIGGERED_POLICE, RESPONSE_UNKNOWN, RESPONSE_USER_CODE_INVALID, TOTALCONNECT_REQUEST, @@ -195,7 +192,7 @@ async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not arm home instant" + assert str(err.value) == "Usercode is invalid, did not arm home instant" assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED # should have started a re-auth flow assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 @@ -513,45 +510,6 @@ async def test_disarming(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMING -async def test_triggered_fire(hass: HomeAssistant) -> None: - """Test triggered by fire.""" - responses = [RESPONSE_TRIGGERED_FIRE] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) - assert state.state == AlarmControlPanelState.TRIGGERED - assert state.attributes.get("triggered_source") == "Fire/Smoke" - assert mock_request.call_count == 1 - - -async def test_triggered_police(hass: HomeAssistant) -> None: - """Test triggered by police.""" - responses = [RESPONSE_TRIGGERED_POLICE] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) - assert state.state == AlarmControlPanelState.TRIGGERED - assert state.attributes.get("triggered_source") == "Police/Medical" - assert mock_request.call_count == 1 - - -async def test_triggered_carbon_monoxide(hass: HomeAssistant) -> None: - """Test triggered by carbon monoxide.""" - responses = [RESPONSE_TRIGGERED_CARBON_MONOXIDE] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) - assert state.state == AlarmControlPanelState.TRIGGERED - assert state.attributes.get("triggered_source") == "Carbon Monoxide" - assert mock_request.call_count == 1 - - async def test_armed_custom(hass: HomeAssistant) -> None: """Test armed custom.""" responses = [RESPONSE_ARMED_CUSTOM] diff --git a/tests/components/totalconnect/test_binary_sensor.py b/tests/components/totalconnect/test_binary_sensor.py index dc433129ac8..8910487ea58 100644 --- a/tests/components/totalconnect/test_binary_sensor.py +++ b/tests/components/totalconnect/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR, diff --git a/tests/components/totalconnect/test_button.py b/tests/components/totalconnect/test_button.py index 80de004be1d..092b058e693 100644 --- a/tests/components/totalconnect/test_button.py +++ b/tests/components/totalconnect/test_button.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from total_connect_client.exceptions import FailedToBypassZone from homeassistant.components.button import DOMAIN as BUTTON, SERVICE_PRESS @@ -11,12 +11,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import ( - RESPONSE_ZONE_BYPASS_FAILURE, - RESPONSE_ZONE_BYPASS_SUCCESS, - TOTALCONNECT_REQUEST, - setup_platform, -) +from .common import setup_platform from tests.common import snapshot_platform @@ -34,12 +29,23 @@ async def test_entity_registry( await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -@pytest.mark.parametrize("entity_id", [ZONE_BYPASS_ID, PANEL_BYPASS_ID]) -async def test_bypass_button(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + ("entity_id", "tcc_request"), + [ + (ZONE_BYPASS_ID, "total_connect_client.zone.TotalConnectZone.bypass"), + ( + PANEL_BYPASS_ID, + "total_connect_client.location.TotalConnectLocation.zone_bypass_all", + ), + ], +) +async def test_bypass_button( + hass: HomeAssistant, entity_id: str, tcc_request: str +) -> None: """Test pushing a bypass button.""" - responses = [RESPONSE_ZONE_BYPASS_FAILURE, RESPONSE_ZONE_BYPASS_SUCCESS] + responses = [FailedToBypassZone, None] await setup_platform(hass, BUTTON) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: + with patch(tcc_request, side_effect=responses) as mock_request: # try to bypass, but fails with pytest.raises(FailedToBypassZone): await hass.services.async_call( diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index f5020394bce..b7ac42c84b5 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -28,6 +28,7 @@ from .common import ( TOTALCONNECT_REQUEST, TOTALCONNECT_REQUEST_TOKEN, USERNAME, + init_integration, ) from tests.common import MockConfigEntry @@ -219,42 +220,19 @@ async def test_no_locations(hass: HomeAssistant) -> None: async def test_options_flow(hass: HomeAssistant) -> None: """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG_DATA, - unique_id=USERNAME, + config_entry = await init_integration(hass) + result = await hass.config_entries.options.async_init(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={AUTO_BYPASS: True, CODE_REQUIRED: False} ) - config_entry.add_to_hass(hass) - responses = [ - RESPONSE_SESSION_DETAILS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_DISARMED, - RESPONSE_DISARMED, - RESPONSE_DISARMED, - ] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == {AUTO_BYPASS: True, CODE_REQUIRED: False} + await hass.async_block_till_done() - with ( - patch(TOTALCONNECT_REQUEST, side_effect=responses), - patch(TOTALCONNECT_GET_CONFIG, side_effect=None), - patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), - ): - assert 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.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={AUTO_BYPASS: True, CODE_REQUIRED: False} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {AUTO_BYPASS: True, CODE_REQUIRED: False} - await hass.async_block_till_done() - - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index ac5bb347765..c67f1495986 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -20,7 +20,7 @@ from kasa.smart.modules import Speaker from kasa.smart.modules.alarm import Alarm from kasa.smart.modules.clean import AreaUnit, Clean, ErrorCode, Status from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.tplink.const import DOMAIN diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr index e037c2c9e40..67749b30d1a 100644 --- a/tests/components/tplink/snapshots/test_camera.ambr +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -39,7 +39,6 @@ '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': , diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 72198e579a1..73fcdc8565d 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -95,7 +95,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Auto off at', + 'original_name': 'Auto-off at', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, @@ -108,7 +108,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'my_device Auto off at', + 'friendly_name': 'my_device Auto-off at', }), 'context': , 'entity_id': 'sensor.my_device_auto_off_at', diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index bd89da8e841..fd398434a07 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -108,7 +108,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Auto off enabled', + 'original_name': 'Auto-off enabled', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, @@ -120,7 +120,7 @@ # name: test_states[switch.my_device_auto_off_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my_device Auto off enabled', + 'friendly_name': 'my_device Auto-off enabled', }), 'context': , 'entity_id': 'switch.my_device_auto_off_enabled', @@ -155,7 +155,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Auto update enabled', + 'original_name': 'Auto-update enabled', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, @@ -167,7 +167,7 @@ # name: test_states[switch.my_device_auto_update_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my_device Auto update enabled', + 'friendly_name': 'my_device Auto-update enabled', }), 'context': , 'entity_id': 'switch.my_device_auto_update_enabled', diff --git a/tests/components/tplink_omada/snapshots/test_switch.ambr b/tests/components/tplink_omada/snapshots/test_switch.ambr index dde196deaaf..eae97f2aae1 100644 --- a/tests/components/tplink_omada/snapshots/test_switch.ambr +++ b/tests/components/tplink_omada/snapshots/test_switch.ambr @@ -2,7 +2,7 @@ # name: test_gateway_api_fail_disables_switch_entities StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', @@ -15,7 +15,7 @@ # name: test_gateway_connect_ipv4_switch StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', @@ -28,7 +28,7 @@ # name: test_gateway_port_change_disables_switch_entities StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Router Port 4 Internet Connected', + 'friendly_name': 'Test Router Port 4 Internet connected', }), 'context': , 'entity_id': 'switch.test_router_port_4_internet_connected', diff --git a/tests/components/traccar_server/test_diagnostics.py b/tests/components/traccar_server/test_diagnostics.py index 738fea1a45d..711c812e6a3 100644 --- a/tests/components/traccar_server/test_diagnostics.py +++ b/tests/components/traccar_server/test_diagnostics.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er diff --git a/tests/components/tractive/conftest.py b/tests/components/tractive/conftest.py index 88c68a4b62f..f32aaa84349 100644 --- a/tests/components/tractive/conftest.py +++ b/tests/components/tractive/conftest.py @@ -29,6 +29,7 @@ def mock_tractive_client() -> Generator[AsyncMock]: "tracker_id": "device_id_123", "hardware": {"battery_level": 88}, "tracker_state": "operational", + "tracker_state_reason": "POWER_SAVING", "charging_state": "CHARGING", } entry.runtime_data.client._send_hardware_update(event) diff --git a/tests/components/tractive/snapshots/test_binary_sensor.ambr b/tests/components/tractive/snapshots/test_binary_sensor.ambr index 761626347a7..c7252da7a3b 100644 --- a/tests/components/tractive/snapshots/test_binary_sensor.ambr +++ b/tests/components/tractive/snapshots/test_binary_sensor.ambr @@ -47,3 +47,50 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_pet_tracker_power_saving-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_pet_tracker_power_saving', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tracker power saving', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker_power_saving', + 'unique_id': 'pet_id_123_power_saving', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pet_tracker_power_saving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Tracker power saving', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pet_tracker_power_saving', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tractive/test_binary_sensor.py b/tests/components/tractive/test_binary_sensor.py index cd7ffbc3da3..283543d761d 100644 --- a/tests/components/tractive/test_binary_sensor.py +++ b/tests/components/tractive/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tractive/test_device_tracker.py b/tests/components/tractive/test_device_tracker.py index ff78173ef7b..6fdbc245662 100644 --- a/tests/components/tractive/test_device_tracker.py +++ b/tests/components/tractive/test_device_tracker.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.device_tracker import SourceType from homeassistant.const import Platform @@ -59,3 +59,31 @@ async def test_source_type_phone( hass.states.get("device_tracker.test_pet_tracker").attributes["source_type"] is SourceType.BLUETOOTH ) + + +async def test_source_type_gps( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if the source type is GPS when the location sensor is KNOWN WIFI.""" + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_position_event( + mock_config_entry, + { + "tracker_id": "device_id_123", + "position": { + "latlong": [22.333, 44.555], + "accuracy": 99, + "sensor_used": "KNOWN_WIFI", + }, + }, + ) + mock_tractive_client.send_hardware_event(mock_config_entry) + await hass.async_block_till_done() + + assert ( + hass.states.get("device_tracker.test_pet_tracker").attributes["source_type"] + is SourceType.GPS + ) diff --git a/tests/components/tractive/test_diagnostics.py b/tests/components/tractive/test_diagnostics.py index ce07b4d6e2a..1dcba8e12dd 100644 --- a/tests/components/tractive/test_diagnostics.py +++ b/tests/components/tractive/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/tractive/test_sensor.py b/tests/components/tractive/test_sensor.py index b53cc3c4d64..30463cd0bd9 100644 --- a/tests/components/tractive/test_sensor.py +++ b/tests/components/tractive/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/tractive/test_switch.py b/tests/components/tractive/test_switch.py index cc7ce6cf81f..92e4676aef1 100644 --- a/tests/components/tractive/test_switch.py +++ b/tests/components/tractive/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from aiotractive.exceptions import TractiveError -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( diff --git a/tests/components/tradfri/__init__.py b/tests/components/tradfri/__init__.py index 37792ae7e32..f73d887d16c 100644 --- a/tests/components/tradfri/__init__.py +++ b/tests/components/tradfri/__init__.py @@ -1,4 +1,6 @@ """Tests for the tradfri component.""" GATEWAY_ID = "mock-gateway-id" +GATEWAY_ID1 = "mockgatewayid1" +GATEWAY_ID2 = "mockgatewayid2" TRADFRI_PATH = "homeassistant.components.tradfri" diff --git a/tests/components/tradfri/test_init.py b/tests/components/tradfri/test_init.py index 54ce469f3c5..a1a4b8d9627 100644 --- a/tests/components/tradfri/test_init.py +++ b/tests/components/tradfri/test_init.py @@ -2,13 +2,19 @@ from unittest.mock import MagicMock +from pytradfri.const import ATTR_FIRMWARE_VERSION, ATTR_GATEWAY_ID +from pytradfri.gateway import Gateway + from homeassistant.components import tradfri +from homeassistant.components.tradfri.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component -from . import GATEWAY_ID +from . import GATEWAY_ID, GATEWAY_ID1, GATEWAY_ID2 +from .common import CommandStore -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def test_entry_setup_unload( @@ -66,6 +72,7 @@ async def test_remove_stale_devices( device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(tradfri.DOMAIN, "stale_device_id")}, + name="stale-device", ) device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id @@ -91,3 +98,178 @@ async def test_remove_stale_devices( assert device_entry.manufacturer == "IKEA of Sweden" assert device_entry.name == "Gateway" assert device_entry.model == "E1526" + + +async def test_migrate_config_entry_and_identifiers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + command_store: CommandStore, +) -> None: + """Test correction of device registry entries.""" + config_entry1 = MockConfigEntry( + domain=tradfri.DOMAIN, + data={ + tradfri.CONF_HOST: "mock-host1", + tradfri.CONF_IDENTITY: "mock-identity1", + tradfri.CONF_KEY: "mock-key1", + tradfri.CONF_GATEWAY_ID: GATEWAY_ID1, + }, + ) + + gateway1 = mock_gateway_fixture(command_store, GATEWAY_ID1) + command_store.register_device( + gateway1, load_json_object_fixture("bulb_w.json", DOMAIN) + ) + config_entry1.add_to_hass(hass) + + config_entry2 = MockConfigEntry( + domain=tradfri.DOMAIN, + data={ + tradfri.CONF_HOST: "mock-host2", + tradfri.CONF_IDENTITY: "mock-identity2", + tradfri.CONF_KEY: "mock-key2", + tradfri.CONF_GATEWAY_ID: GATEWAY_ID2, + }, + ) + + config_entry2.add_to_hass(hass) + + # Add non-tradfri config entry for use in testing negation logic + config_entry3 = MockConfigEntry( + domain="test_domain", + ) + + config_entry3.add_to_hass(hass) + + # Create gateway device for config entry 1 + gateway1_device = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(config_entry1.domain, config_entry1.data["gateway_id"])}, + name="Gateway", + ) + + # Create bulb 1 on gateway 1 in Device Registry - this has the old identifiers format + gateway1_bulb1 = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(tradfri.DOMAIN, 65537)}, + name="bulb1", + ) + + # Update bulb 1 device to have both config entry IDs + # This is to simulate existing data scenario with older version of tradfri component + device_registry.async_update_device( + gateway1_bulb1.id, + add_config_entry_id=config_entry2.entry_id, + ) + + # Create bulb 2 on gateway 1 in Device Registry - this has the new identifiers format + gateway1_bulb2 = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65538")}, + name="bulb2", + ) + + # Update bulb 2 device to have an additional config entry from config_entry3 + # This is to simulate scenario whereby a device entry + # is shared by multiple config entries + # and where at least one of those config entries is not the 'tradfri' domain + device_registry.async_update_device( + gateway1_bulb2.id, + add_config_entry_id=config_entry3.entry_id, + merge_identifiers={("test_domain", "config_entry_3-device2")}, + ) + + # Create a device on config entry 3 in Device Registry + config_entry3_device = device_registry.async_get_or_create( + config_entry_id=config_entry3.entry_id, + identifiers={("test_domain", "config_entry_3-device1")}, + name="device", + ) + + # Set up all tradfri config entries. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Validate that gateway 1 bulb 1 is still the same device entry + # This inherently also validates that the device's identifiers + # have been updated to the new unique format + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry1.entry_id + ) + assert ( + device_registry.async_get_device( + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65537")} + ).id + == gateway1_bulb1.id + ) + + # Validate that gateway 1 bulb 1 only has gateway 1's config ID associated to it + # (Device at index 0 is the gateway) + assert device_entries[1].config_entries == {config_entry1.entry_id} + + # Validate that the gateway 1 device is unchanged + assert device_entries[0].id == gateway1_device.id + assert device_entries[0].identifiers == gateway1_device.identifiers + assert device_entries[0].config_entries == gateway1_device.config_entries + + # Validate that gateway 1 bulb 2 now only exists associated to config entry 3. + # The device will have had its identifiers updated to the new format (for the tradfri + # domain) per migrate_config_entry_and_identifiers(). + # The device will have then been removed from config entry 1 (gateway1) + # due to it not matching a device in the command store. + device_entry = device_registry.async_get_device( + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65538")} + ) + + assert device_entry.id == gateway1_bulb2.id + # Assert that the only config entry associated to this device is config entry 3 + assert device_entry.config_entries == {config_entry3.entry_id} + # Assert that that device's other identifiers remain untouched + assert device_entry.identifiers == { + (tradfri.DOMAIN, f"{GATEWAY_ID1}-65538"), + ("test_domain", "config_entry_3-device2"), + } + + # Validate that gateway 2 bulb 1 has been added to device registry and with correct unique identifiers + # (This bulb device exists on gateway 2 because the command_store created above will be executed + # for each gateway being set up.) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry2.entry_id + ) + assert len(device_entries) == 2 + assert device_entries[1].identifiers == {(tradfri.DOMAIN, f"{GATEWAY_ID2}-65537")} + + # Validate that gateway 2 bulb 1 only has gateway 2's config ID associated to it + assert device_entries[1].config_entries == {config_entry2.entry_id} + + # Validate that config entry 3 device 1 is still present, + # and has not had its config entries or identifiers changed + # N.B. The gateway1_bulb2 device will qualify in this set + # because the config entry 3 was added to it above + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry3.entry_id + ) + assert len(device_entries) == 2 + assert device_entries[0].id == config_entry3_device.id + assert device_entries[0].identifiers == {("test_domain", "config_entry_3-device1")} + assert device_entries[0].config_entries == {config_entry3.entry_id} + + # Assert that the tradfri config entries have been migrated to v2 and + # the non-tradfri config entry remains at v1 + assert config_entry1.version == 2 + assert config_entry2.version == 2 + assert config_entry3.version == 1 + + +def mock_gateway_fixture(command_store: CommandStore, gateway_id: str) -> Gateway: + """Mock a Tradfri gateway.""" + gateway = Gateway() + command_store.register_response( + gateway.get_gateway_info(), + {ATTR_GATEWAY_ID: gateway_id, ATTR_FIRMWARE_VERSION: "1.2.1234"}, + ) + command_store.register_response( + gateway.get_devices(), + [], + ) + return gateway diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 921cab4cba2..171334c136a 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -14,9 +14,11 @@ import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.tts import ( CONF_LANG, + DATA_TTS_MANAGER, DOMAIN as TTS_DOMAIN, PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, + ResultStream, TextToSpeechEntity, TtsAudioType, Voice, @@ -40,6 +42,7 @@ from tests.typing import ClientSessionGenerator DEFAULT_LANG = "en_US" SUPPORT_LANGUAGES = ["de_CH", "de_DE", "en_GB", "en_US"] TEST_DOMAIN = "test" +MOCK_DATA = b"123" def mock_tts_get_cache_files_fixture_helper() -> Generator[MagicMock]: @@ -162,7 +165,7 @@ class BaseProvider: self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS dat.""" - return ("mp3", b"") + return ("mp3", MOCK_DATA) class MockTTSProvider(BaseProvider, Provider): @@ -263,3 +266,34 @@ async def mock_config_entry_setup( await hass.async_block_till_done() return config_entry + + +class MockResultStream(ResultStream): + """Mock result stream.""" + + test_set_message: str | None = None + + def __init__(self, hass: HomeAssistant, extension: str, data: bytes) -> None: + """Initialize the result stream.""" + super().__init__( + token="test-token", + extension=extension, + content_type=f"audio/mock-{extension}", + engine="test-engine", + use_file_cache=True, + supports_streaming_input=True, + language="en", + options={}, + _manager=hass.data[DATA_TTS_MANAGER], + ) + hass.data[DATA_TTS_MANAGER].token_to_stream[self.token] = self + self._mock_data = data + + @callback + def async_set_message(self, message: str) -> None: + """Set message to be generated.""" + self.test_set_message = message + + async def async_stream_result(self): + """Stream the result.""" + yield self._mock_data diff --git a/tests/components/tts/test_entity.py b/tests/components/tts/test_entity.py new file mode 100644 index 00000000000..8648ca95e93 --- /dev/null +++ b/tests/components/tts/test_entity.py @@ -0,0 +1,177 @@ +"""Tests for the TTS entity.""" + +from typing import Any + +import pytest + +from homeassistant.components import tts +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, State + +from .common import ( + DEFAULT_LANG, + SUPPORT_LANGUAGES, + TEST_DOMAIN, + MockTTSEntity, + mock_config_entry_setup, +) + +from tests.common import mock_restore_cache + + +class DefaultEntity(tts.TextToSpeechEntity): + """Test entity.""" + + _attr_supported_languages = SUPPORT_LANGUAGES + _attr_default_language = DEFAULT_LANG + + +async def test_default_entity_attributes() -> None: + """Test default entity attributes.""" + entity = DefaultEntity() + + assert entity.hass is None + assert entity.default_language == DEFAULT_LANG + assert entity.supported_languages == SUPPORT_LANGUAGES + assert entity.supported_options is None + assert entity.default_options is None + assert entity.async_get_supported_voices("test") is None + + +async def test_restore_state( + hass: HomeAssistant, + mock_tts_entity: MockTTSEntity, +) -> None: + """Test we restore state in the integration.""" + entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" + timestamp = "2023-01-01T23:59:59+00:00" + mock_restore_cache(hass, (State(entity_id, timestamp),)) + + config_entry = await mock_config_entry_setup(hass, mock_tts_entity) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + state = hass.states.get(entity_id) + assert state + assert state.state == timestamp + + +async def test_tts_entity_subclass_properties( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test for errors when subclasses of the TextToSpeechEntity are missing required properties.""" + + class TestClass1(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass1()) + + class TestClass2(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass2()) + + assert all(record.exc_info is None for record in caplog.records) + + caplog.clear() + + class TestClass3(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass3()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass4(tts.TextToSpeechEntity): + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass4()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass5(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass5()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass6(tts.TextToSpeechEntity): + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass6()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + + +def test_streaming_supported() -> None: + """Test streaming support.""" + base_entity = tts.TextToSpeechEntity() + assert base_entity.async_supports_streaming_input() is False + + class StreamingEntity(tts.TextToSpeechEntity): + async def async_stream_tts_audio(self) -> None: + pass + + streaming_entity = StreamingEntity() + assert streaming_entity.async_supports_streaming_input() is True + + class NonStreamingEntity(tts.TextToSpeechEntity): + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> tts.TtsAudioType: + pass + + non_streaming_entity = NonStreamingEntity() + assert non_streaming_entity.async_supports_streaming_input() is False + + class SyncNonStreamingEntity(tts.TextToSpeechEntity): + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> tts.TtsAudioType: + pass + + sync_non_streaming_entity = SyncNonStreamingEntity() + assert sync_non_streaming_entity.async_supports_streaming_input() is False diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 4d0767cddf3..ccb62959eba 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -4,7 +4,7 @@ import asyncio from http import HTTPStatus from pathlib import Path from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -20,15 +20,16 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import ( DEFAULT_LANG, - SUPPORT_LANGUAGES, + MOCK_DATA, TEST_DOMAIN, + MockResultStream, MockTTS, MockTTSEntity, MockTTSProvider, @@ -38,37 +39,12 @@ from .common import ( retrieve_media, ) -from tests.common import ( - MockModule, - async_mock_service, - mock_integration, - mock_platform, - mock_restore_cache, -) +from tests.common import MockModule, async_mock_service, mock_integration, mock_platform from tests.typing import ClientSessionGenerator, WebSocketGenerator ORIG_WRITE_TAGS = tts.SpeechManager.write_tags -class DefaultEntity(tts.TextToSpeechEntity): - """Test entity.""" - - _attr_supported_languages = SUPPORT_LANGUAGES - _attr_default_language = DEFAULT_LANG - - -async def test_default_entity_attributes() -> None: - """Test default entity attributes.""" - entity = DefaultEntity() - - assert entity.hass is None - assert entity.default_language == DEFAULT_LANG - assert entity.supported_languages == SUPPORT_LANGUAGES - assert entity.supported_options is None - assert entity.default_options is None - assert entity.async_get_supported_voices("test") is None - - async def test_config_entry_unload( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -120,24 +96,6 @@ async def test_config_entry_unload( assert state is None -async def test_restore_state( - hass: HomeAssistant, - mock_tts_entity: MockTTSEntity, -) -> None: - """Test we restore state in the integration.""" - entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" - timestamp = "2023-01-01T23:59:59+00:00" - mock_restore_cache(hass, (State(entity_id, timestamp),)) - - config_entry = await mock_config_entry_setup(hass, mock_tts_entity) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - state = hass.states.get(entity_id) - assert state - assert state.state == timestamp - - @pytest.mark.parametrize( "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True ) @@ -211,7 +169,7 @@ async def test_service( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" @@ -273,7 +231,7 @@ async def test_service_default_language( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -337,7 +295,7 @@ async def test_service_default_special_language( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" @@ -397,7 +355,7 @@ async def test_service_language( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" @@ -513,7 +471,7 @@ async def test_service_options( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -597,7 +555,7 @@ async def test_service_default_options( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -671,7 +629,7 @@ async def test_merge_default_service_options( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -786,7 +744,7 @@ async def test_service_clear_cache( # To make sure the file is persisted assert len(calls) == 1 await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" @@ -851,7 +809,7 @@ async def test_service_receive_voice( await hass.async_block_till_done() client = await hass_client() req = await client.get(url) - tts_data = b"" + tts_data = MOCK_DATA tts_data = tts.SpeechManager.write_tags( f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3", tts_data, @@ -922,7 +880,7 @@ async def test_service_receive_voice_german( await hass.async_block_till_done() client = await hass_client() req = await client.get(url) - tts_data = b"" + tts_data = MOCK_DATA tts_data = tts.SpeechManager.write_tags( "42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3", tts_data, @@ -1064,7 +1022,7 @@ async def test_setup_legacy_cache_dir( """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - tts_data = b"" + tts_data = MOCK_DATA cache_file = ( mock_tts_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) @@ -1102,7 +1060,7 @@ async def test_setup_cache_dir( """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - tts_data = b"" + tts_data = MOCK_DATA cache_file = mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" ) @@ -1197,7 +1155,7 @@ async def test_service_get_tts_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) @@ -1208,7 +1166,7 @@ async def test_legacy_cannot_retrieve_without_token( hass_client: ClientSessionGenerator, ) -> None: """Verify that a TTS cannot be retrieved by filename directly.""" - tts_data = b"" + tts_data = MOCK_DATA cache_file = ( mock_tts_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) @@ -1231,7 +1189,7 @@ async def test_cannot_retrieve_without_token( hass_client: ClientSessionGenerator, ) -> None: """Verify that a TTS cannot be retrieved by filename directly.""" - tts_data = b"" + tts_data = MOCK_DATA cache_file = mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" ) @@ -1420,29 +1378,6 @@ def test_resolve_engine(hass: HomeAssistant, setup: str, engine_id: str) -> None assert tts.async_resolve_engine(hass, None) is None -@pytest.mark.parametrize( - ("setup", "engine_id"), - [ - ("mock_setup", "test"), - ("mock_config_entry_setup", "tts.test"), - ], - indirect=["setup"], -) -async def test_support_options(hass: HomeAssistant, setup: str, engine_id: str) -> None: - """Test supporting options.""" - assert await tts.async_support_options(hass, engine_id, "en_US") is True - assert await tts.async_support_options(hass, engine_id, "nl") is False - assert ( - await tts.async_support_options( - hass, engine_id, "en_US", {"invalid_option": "yo"} - ) - is False - ) - - with pytest.raises(HomeAssistantError): - await tts.async_support_options(hass, "non-existing") - - async def test_legacy_fetching_in_async( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: @@ -1587,6 +1522,45 @@ async def test_fetching_in_async( ) +@pytest.mark.parametrize( + ("setup", "engine_id"), + [ + ("mock_setup", "test"), + ], + indirect=["setup"], +) +async def test_ws_list_engines_filter_deprecated( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup: str, + engine_id: str, +) -> None: + """Test listing tts engines and supported languages.""" + client = await hass_ws_client() + + await client.send_json_auto_id({"type": "tts/engine/list"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "providers": [ + { + "name": "Test", + "engine_id": engine_id, + "supported_languages": ["de_CH", "de_DE", "en_GB", "en_US"], + } + ] + } + + hass.data[tts.DATA_TTS_MANAGER].providers[engine_id].has_entity = True + + await client.send_json_auto_id({"type": "tts/engine/list"}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"providers": []} + + @pytest.mark.parametrize( ("setup", "engine_id", "extra_data"), [ @@ -1835,99 +1809,15 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: """Test that ffmpeg failing during audio conversion will raise an error.""" assert await async_setup_component(hass, ffmpeg.DOMAIN, {}) + async def bad_data_gen(): + yield bytes(0) + with pytest.raises(RuntimeError): # Simulate a bad WAV file - await tts.async_convert_audio(hass, "wav", bytes(0), "mp3") - - -async def test_ttsentity_subclass_properties( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test for errors when subclasses of the TextToSpeechEntity are missing required properties.""" - - class TestClass1(tts.TextToSpeechEntity): - _attr_default_language = DEFAULT_LANG - _attr_supported_languages = SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass1()) - - class TestClass2(tts.TextToSpeechEntity): - @property - def default_language(self) -> str: - return DEFAULT_LANG - - @property - def supported_languages(self) -> list[str]: - return SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass2()) - - assert all(record.exc_info is None for record in caplog.records) - - caplog.clear() - - class TestClass3(tts.TextToSpeechEntity): - _attr_default_language = DEFAULT_LANG - - await mock_config_entry_setup(hass, TestClass3()) - - assert ( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass4(tts.TextToSpeechEntity): - _attr_supported_languages = SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass4()) - - assert ( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass5(tts.TextToSpeechEntity): - @property - def default_language(self) -> str: - return DEFAULT_LANG - - await mock_config_entry_setup(hass, TestClass5()) - - assert ( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass6(tts.TextToSpeechEntity): - @property - def supported_languages(self) -> list[str]: - return SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass6()) - - assert ( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) + async for _chunk in tts._async_convert_audio( + hass, "wav", bad_data_gen(), "mp3" + ): + pass async def test_default_engine_prefer_entity( @@ -1986,3 +1876,136 @@ 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" + + +async def test_stream(hass: HomeAssistant, mock_tts_entity: MockTTSEntity) -> None: + """Test creating streams.""" + await mock_config_entry_setup(hass, mock_tts_entity) + + stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) + assert stream.language == mock_tts_entity.default_language + assert stream.options == (mock_tts_entity.default_options or {}) + assert stream.supports_streaming_input is False + assert tts.async_get_stream(hass, stream.token) is stream + stream.async_set_message("beer") + result_data = b"".join([chunk async for chunk in stream.async_stream_result()]) + assert result_data == MOCK_DATA + + async def async_stream_tts_audio( + request: tts.TTSAudioRequest, + ) -> tts.TTSAudioResponse: + """Mock stream TTS audio.""" + + async def gen_data(): + async for msg in request.message_gen: + yield msg.encode() + + return tts.TTSAudioResponse( + extension="mp3", + data_gen=gen_data(), + ) + + mock_tts_entity.async_stream_tts_audio = async_stream_tts_audio + mock_tts_entity.async_supports_streaming_input = Mock(return_value=True) + + async def stream_message(): + """Mock stream message.""" + yield "he" + yield "ll" + yield "o" + + stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) + assert stream.supports_streaming_input is True + stream.async_set_message_stream(stream_message()) + result_data = b"".join([chunk async for chunk in stream.async_stream_result()]) + assert result_data == b"hello" + + data = b"beer" + stream2 = MockResultStream(hass, "wav", data) + assert tts.async_get_stream(hass, stream2.token) is stream2 + assert stream2.extension == "wav" + result_data = b"".join([chunk async for chunk in stream2.async_stream_result()]) + assert result_data == data + + +async def test_tts_cache() -> None: + """Test TTSCache.""" + + async def data_gen(queue: asyncio.Queue[bytes | None | Exception]): + while chunk := await queue.get(): + if isinstance(chunk, Exception): + raise chunk + yield chunk + + queue = asyncio.Queue() + cache = tts.TTSCache("test-key", "mp3", data_gen(queue)) + assert cache.cache_key == "test-key" + assert cache.extension == "mp3" + + for i in range(10): + queue.put_nowait(f"{i}".encode()) + queue.put_nowait(None) + + assert await cache.async_load_data() == b"0123456789" + + with pytest.raises(RuntimeError): + await cache.async_load_data() + + # When data is loaded, we get it all in 1 chunk + cur = 0 + async for chunk in cache.async_stream_data(): + assert chunk == b"0123456789" + cur += 1 + assert cur == 1 + + # Show we can stream the data while it's still being generated + async def consume_cache(cache: tts.TTSCache): + return b"".join([chunk async for chunk in cache.async_stream_data()]) + + queue = asyncio.Queue() + cache = tts.TTSCache("test-key", "mp3", data_gen(queue)) + + load_data_task = asyncio.create_task(cache.async_load_data()) + consume_pre_data_loaded_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(b"0") + await asyncio.sleep(0) + queue.put_nowait(b"1") + await asyncio.sleep(0) + consume_mid_data_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(b"2") + await asyncio.sleep(0) + queue.put_nowait(None) + consume_post_data_loaded_task = asyncio.create_task(consume_cache(cache)) + await asyncio.sleep(0) + assert await load_data_task == b"012" + assert await consume_post_data_loaded_task == b"012" + assert await consume_mid_data_task == b"012" + assert await consume_pre_data_loaded_task == b"012" + + # Now with errors + async def consume_cache(cache: tts.TTSCache): + return b"".join([chunk async for chunk in cache.async_stream_data()]) + + queue = asyncio.Queue() + cache = tts.TTSCache("test-key", "mp3", data_gen(queue)) + + load_data_task = asyncio.create_task(cache.async_load_data()) + consume_pre_data_loaded_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(b"0") + await asyncio.sleep(0) + queue.put_nowait(b"1") + await asyncio.sleep(0) + consume_mid_data_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(ValueError("Boom!")) + await asyncio.sleep(0) + queue.put_nowait(None) + consume_post_data_loaded_task = asyncio.create_task(consume_cache(cache)) + await asyncio.sleep(0) + with pytest.raises(ValueError): + assert await load_data_task == b"012" + with pytest.raises(ValueError): + assert await consume_post_data_loaded_task == b"012" + with pytest.raises(ValueError): + assert await consume_mid_data_task == b"012" + with pytest.raises(ValueError): + assert await consume_pre_data_loaded_task == b"012" diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index d90923b02ab..eb4b09cab5b 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -9,9 +9,8 @@ import pytest from homeassistant.components import media_source from homeassistant.components.media_player import BrowseError from homeassistant.components.tts.media_source import ( - MediaSourceOptions, generate_media_source_id, - media_source_id_to_kwargs, + parse_media_source_id, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -79,6 +78,7 @@ async def test_browsing(hass: HomeAssistant, setup: str) -> None: assert item_child.children is None assert item_child.can_play is False assert item_child.can_expand is True + assert item_child.thumbnail == "https://brands.home-assistant.io/_/test/logo.png" item_child = await media_source.async_browse_media( hass, item.children[0].media_content_id + "?message=bla" @@ -115,6 +115,13 @@ async def test_legacy_resolving( await mock_setup(hass, mock_provider) mock_get_tts_audio = mock_provider.get_tts_audio + mock_provider.has_entity = True + root = await media_source.async_browse_media(hass, "media-source://tts") + assert len(root.children) == 0 + mock_provider.has_entity = False + root = await media_source.async_browse_media(hass, "media-source://tts") + assert len(root.children) == 1 + mock_get_tts_audio.reset_mock() media_id = "media-source://tts/test?message=Hello%20World" media = await media_source.async_resolve_media(hass, media_id, None) @@ -249,13 +256,13 @@ async def test_resolving_errors(hass: HomeAssistant, setup: str, engine: str) -> ], indirect=["setup"], ) -async def test_generate_media_source_id_and_media_source_id_to_kwargs( +async def test_generate_media_source_id_and_parse_media_source_id( hass: HomeAssistant, setup: str, result_engine: str, ) -> None: - """Test media_source_id and media_source_id_to_kwargs.""" - kwargs: MediaSourceOptions = { + """Test media_source_id and parse_media_source_id.""" + kwargs = { "engine": None, "message": "hello", "language": "en_US", @@ -263,12 +270,14 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "cache": True, } media_source_id = generate_media_source_id(hass, **kwargs) - assert media_source_id_to_kwargs(media_source_id) == { - "engine": result_engine, + assert parse_media_source_id(media_source_id) == { "message": "hello", - "language": "en_US", - "options": {"age": 5}, - "cache": True, + "options": { + "engine": result_engine, + "language": "en_US", + "options": {"age": 5}, + "use_file_cache": True, + }, } kwargs = { @@ -279,12 +288,14 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "cache": True, } media_source_id = generate_media_source_id(hass, **kwargs) - assert media_source_id_to_kwargs(media_source_id) == { - "engine": result_engine, + assert parse_media_source_id(media_source_id) == { "message": "hello", - "language": "en_US", - "options": {"age": [5, 6]}, - "cache": True, + "options": { + "engine": result_engine, + "language": "en_US", + "options": {"age": [5, 6]}, + "use_file_cache": True, + }, } kwargs = { @@ -295,10 +306,12 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "cache": True, } media_source_id = generate_media_source_id(hass, **kwargs) - assert media_source_id_to_kwargs(media_source_id) == { - "engine": result_engine, + assert parse_media_source_id(media_source_id) == { "message": "hello", - "language": "en_US", - "options": {"age": {"k1": [5, 6], "k2": "v2"}}, - "cache": True, + "options": { + "engine": result_engine, + "language": "en_US", + "options": {"age": {"k1": [5, 6], "k2": "v2"}}, + "use_file_cache": True, + }, } diff --git a/tests/components/twinkly/test_diagnostics.py b/tests/components/twinkly/test_diagnostics.py index d7ef4dd9b11..b1f75d005b9 100644 --- a/tests/components/twinkly/test_diagnostics.py +++ b/tests/components/twinkly/test_diagnostics.py @@ -1,7 +1,7 @@ """Tests for the diagnostics of the twinkly component.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index f8289cb95e3..670f9c4a381 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from ttls.client import TwinklyError from homeassistant.components.light import ( diff --git a/tests/components/twinkly/test_select.py b/tests/components/twinkly/test_select.py index 103fbe0f634..515ce3c2cb5 100644 --- a/tests/components/twinkly/test_select.py +++ b/tests/components/twinkly/test_select.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNAVAILABLE, Platform diff --git a/tests/components/uk_transport/test_sensor.py b/tests/components/uk_transport/test_sensor.py index a4a9aea18c8..ba547c5eecc 100644 --- a/tests/components/uk_transport/test_sensor.py +++ b/tests/components/uk_transport/test_sensor.py @@ -8,6 +8,7 @@ import requests_mock from homeassistant.components.uk_transport.sensor import ( ATTR_ATCOCODE, ATTR_CALLING_AT, + ATTR_LAST_UPDATED, ATTR_LOCALITY, ATTR_NEXT_BUSES, ATTR_NEXT_TRAINS, @@ -90,3 +91,4 @@ async def test_train(hass: HomeAssistant) -> None: == "London Waterloo" ) assert train_state.attributes[ATTR_NEXT_TRAINS][0]["estimated"] == "06:13" + assert train_state.attributes[ATTR_LAST_UPDATED] == "2017-07-10T06:10:05+01:00" diff --git a/tests/components/unifi/snapshots/test_diagnostics.ambr b/tests/components/unifi/snapshots/test_diagnostics.ambr index aa7337be0ba..04aec0541b9 100644 --- a/tests/components/unifi/snapshots/test_diagnostics.ambr +++ b/tests/components/unifi/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entry_diagnostics[dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0] +# name: test_entry_diagnostics[wlan_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0] dict({ 'clients': dict({ '00:00:00:00:00:00': dict({ @@ -128,6 +128,82 @@ }), 'role_is_admin': True, 'wlans': dict({ + '67f2eaec026b2c2893c41b2a': dict({ + '_id': '67f2eaec026b2c2893c41b2a', + 'ap_group_ids': list([ + '67f2e03f7c572754fa1a249e', + ]), + 'ap_group_mode': 'all', + 'bc_filter_list': '**REDACTED**', + 'bss_transition': True, + 'dtim_6e': 3, + 'dtim_mode': 'default', + 'dtim_na': 3, + 'dtim_ng': 1, + 'enabled': True, + 'enhanced_iot': False, + 'fast_roaming_enabled': False, + 'group_rekey': 3600, + 'hide_ssid': False, + 'hotspot2conf_enabled': False, + 'iapp_enabled': True, + 'is_guest': False, + 'l2_isolation': False, + 'mac_filter_enabled': False, + 'mac_filter_list': list([ + ]), + 'mac_filter_policy': 'allow', + 'mcastenhance_enabled': False, + 'minrate_na_advertising_rates': False, + 'minrate_na_data_rate_kbps': 6000, + 'minrate_na_enabled': False, + 'minrate_ng_advertising_rates': False, + 'minrate_ng_data_rate_kbps': 1000, + 'minrate_ng_enabled': True, + 'minrate_setting_preference': 'auto', + 'mlo_enabled': False, + 'name': 'devices', + 'networkconf_id': '67f2e03f7c572754fa1a2498', + 'no2ghz_oui': True, + 'passphrase_autogenerated': True, + 'pmf_mode': 'disabled', + 'private_preshared_keys': list([ + dict({ + 'networkconf_id': '67f2e03f7c572754fa1a2498', + 'password': '**REDACTED**', + }), + ]), + 'private_preshared_keys_enabled': True, + 'proxy_arp': False, + 'radius_das_enabled': False, + 'radius_mac_auth_enabled': False, + 'radius_macacl_format': 'none_lower', + 'sae_anti_clogging': 5, + 'sae_groups': list([ + ]), + 'sae_psk': list([ + ]), + 'sae_sync': 5, + 'schedule': list([ + ]), + 'schedule_with_duration': list([ + ]), + 'security': 'wpapsk', + 'setting_preference': 'manual', + 'site_id': '67f2e00e7c572754fa1a247e', + 'uapsd_enabled': False, + 'usergroup_id': '67f2e03f7c572754fa1a2499', + 'wlan_band': '2g', + 'wlan_bands': list([ + '2g', + ]), + 'wpa3_fast_roaming': False, + 'wpa3_support': False, + 'wpa3_transition': False, + 'wpa_enc': 'ccmp', + 'wpa_mode': 'wpa2', + 'x_passphrase': '**REDACTED**', + }), }), }) # --- diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 94343d12ba2..61bb9718be7 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -7,7 +7,7 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.unifi.const import CONF_SITE_ID diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 39b70344db7..73b986aed87 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -9,7 +9,7 @@ from aiounifi.models.event import EventKey from aiounifi.models.message import MessageKey from freezegun.api import FrozenDateTimeFactory, freeze_time import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN from homeassistant.components.unifi.const import ( diff --git a/tests/components/unifi/test_diagnostics.py b/tests/components/unifi/test_diagnostics.py index 80359a9c75c..e9fd86f0f8b 100644 --- a/tests/components/unifi/test_diagnostics.py +++ b/tests/components/unifi/test_diagnostics.py @@ -103,6 +103,75 @@ DPI_GROUP_DATA = [ "dpiapp_ids": ["5f976f62e3c58f018ec7e17d"], } ] +WLAN_DATA = [ + { + "setting_preference": "manual", + "wpa3_support": False, + "dtim_6e": 3, + "minrate_na_advertising_rates": False, + "wpa_mode": "wpa2", + "minrate_setting_preference": "auto", + "minrate_ng_advertising_rates": False, + "hotspot2conf_enabled": False, + "radius_das_enabled": False, + "mlo_enabled": False, + "group_rekey": 3600, + "radius_macacl_format": "none_lower", + "pmf_mode": "disabled", + "wpa3_transition": False, + "passphrase_autogenerated": True, + "private_preshared_keys": [ + { + "password": "should be redacted", + "networkconf_id": "67f2e03f7c572754fa1a2498", + } + ], + "mcastenhance_enabled": False, + "usergroup_id": "67f2e03f7c572754fa1a2499", + "proxy_arp": False, + "sae_sync": 5, + "iapp_enabled": True, + "uapsd_enabled": False, + "enhanced_iot": False, + "name": "devices", + "site_id": "67f2e00e7c572754fa1a247e", + "hide_ssid": False, + "wlan_band": "2g", + "_id": "67f2eaec026b2c2893c41b2a", + "private_preshared_keys_enabled": True, + "no2ghz_oui": True, + "networkconf_id": "67f2e03f7c572754fa1a2498", + "is_guest": False, + "dtim_na": 3, + "minrate_na_enabled": False, + "sae_groups": [], + "enabled": True, + "sae_psk": [], + "wlan_bands": ["2g"], + "mac_filter_policy": "allow", + "security": "wpapsk", + "ap_group_ids": ["67f2e03f7c572754fa1a249e"], + "l2_isolation": False, + "minrate_ng_enabled": True, + "bss_transition": True, + "minrate_ng_data_rate_kbps": 1000, + "radius_mac_auth_enabled": False, + "schedule_with_duration": [], + "wpa3_fast_roaming": False, + "ap_group_mode": "all", + "fast_roaming_enabled": False, + "wpa_enc": "ccmp", + "mac_filter_list": [], + "dtim_mode": "default", + "schedule": [], + "bc_filter_list": "should be redacted", + "minrate_na_data_rate_kbps": 6000, + "mac_filter_enabled": False, + "sae_anti_clogging": 5, + "dtim_ng": 1, + "x_passphrase": "should be redacted", + } +] @pytest.mark.parametrize( @@ -119,6 +188,7 @@ DPI_GROUP_DATA = [ @pytest.mark.parametrize("device_payload", [DEVICE_DATA]) @pytest.mark.parametrize("dpi_app_payload", [DPI_APP_DATA]) @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUP_DATA]) +@pytest.mark.parametrize("wlan_payload", [WLAN_DATA]) async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/unifi/test_image.py b/tests/components/unifi/test_image.py index dc37d7cb8b7..4f0c815ca0c 100644 --- a/tests/components/unifi/test_image.py +++ b/tests/components/unifi/test_image.py @@ -8,7 +8,7 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index ee8b102edaa..6b58f49f072 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -10,7 +10,7 @@ from aiounifi.models.device import DeviceState from aiounifi.models.message import MessageKey from freezegun.api import FrozenDateTimeFactory, freeze_time import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index c8ee786895c..c336c4ef6db 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -7,7 +7,7 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index 7bf4b9aec9d..3b54aa9ebe4 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -5,7 +5,7 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from yarl import URL from homeassistant.components.unifi.const import CONF_SITE_ID diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 3a8d5d952ce..3aa441659b0 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -407,7 +407,7 @@ async def test_binary_sensor_update_mount_type_garage( ) -> None: """Test binary_sensor motion entity.""" - await init_entry(hass, ufp, [sensor_all], debug=True) + await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) _, entity_id = ids_from_device_description( diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 975e93edf09..34a1d064547 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -12,6 +12,7 @@ from uiprotect.websocket import WebsocketState from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( + CameraCapabilities, CameraEntityFeature, CameraState, CameraWebRTCProvider, @@ -21,6 +22,7 @@ from homeassistant.components.camera import ( async_get_stream_source, async_register_webrtc_provider, ) +from homeassistant.components.camera.helper import get_camera_from_entity_id from homeassistant.components.unifiprotect.const import ( ATTR_BITRATE, ATTR_CHANNEL_ID, @@ -345,9 +347,11 @@ async def test_webrtc_support( camera_high_only.channels[2].is_rtsp_enabled = False await init_entry(hass, ufp, [camera_high_only]) entity_id = validate_default_camera_entity(hass, camera_high_only, 0) - state = hass.states.get(entity_id) - assert state - assert StreamType.WEB_RTC in state.attributes["frontend_stream_type"] + assert hass.states.get(entity_id) + camera_obj = get_camera_from_entity_id(hass, entity_id) + assert camera_obj.camera_capabilities == CameraCapabilities( + {StreamType.HLS, StreamType.WEB_RTC} + ) async def test_adopt( diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 7dd0362f17c..06ffe16ab87 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -25,7 +25,6 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityDescription -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -167,7 +166,6 @@ async def init_entry( ufp: MockUFPFixture, devices: Sequence[ProtectAdoptableDeviceModel], regenerate_ids: bool = True, - debug: bool = False, ) -> None: """Initialize Protect entry with given devices.""" @@ -175,14 +173,6 @@ async def init_entry( for device in devices: add_device(ufp.api.bootstrap, device, regenerate_ids) - if debug: - assert await async_setup_component(hass, "logger", {"logger": {}}) - await hass.services.async_call( - "logger", - "set_level", - {"homeassistant.components.unifiprotect": "DEBUG"}, - blocking=True, - ) await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index 4b27ab5ff05..3de9b9ec399 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -34,8 +34,8 @@ async def test_presentation(hass: HomeAssistant) -> None: assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] -async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: - """Test entity unaviable on update failure.""" +async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: + """Test entity unavailable on update failure.""" await setup_uptimerobot_integration(hass) entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index 3ba5ad696a6..c7ae6a5d772 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -24,8 +24,8 @@ from .common import ( from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_user(hass: HomeAssistant) -> None: + """Test user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -56,8 +56,8 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_read_only(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_user_key_read_only(hass: HomeAssistant) -> None: + """Test user flow with read only key.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -87,8 +87,8 @@ async def test_form_read_only(hass: HomeAssistant) -> None: (UptimeRobotAuthenticationException, "invalid_api_key"), ], ) -async def test_form_exception_thrown(hass: HomeAssistant, exception, error_key) -> None: - """Test that we handle exceptions.""" +async def test_exception_thrown(hass: HomeAssistant, exception, error_key) -> None: + """Test user flow throwing exceptions.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -106,10 +106,8 @@ async def test_form_exception_thrown(hass: HomeAssistant, exception, error_key) assert result2["errors"]["base"] == error_key -async def test_form_api_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test we handle unexpected error.""" +async def test_api_error(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test expected API error is catch.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index 187178de78d..435b0737c6d 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -239,7 +239,6 @@ async def test_device_management( freezer.tick(COORDINATOR_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - await hass.async_block_till_done() devices = dr.async_entries_for_config_entry(device_registry, mock_entry.entry_id) assert len(devices) == 1 diff --git a/tests/components/usb/__init__.py b/tests/components/usb/__init__.py index 96d671d0958..6db0cea1ffe 100644 --- a/tests/components/usb/__init__.py +++ b/tests/components/usb/__init__.py @@ -1,44 +1,29 @@ """Tests for the USB Discovery integration.""" -from homeassistant.components.usb.models import USBDevice +from unittest.mock import patch -conbee_device = USBDevice( - device="/dev/cu.usbmodemDE24338801", - vid="1CF1", - pid="0030", - serial_number="DE2433880", - manufacturer="dresden elektronik ingenieurtechnik GmbH", - description="ConBee II", -) -slae_sh_device = USBDevice( - device="/dev/cu.usbserial-110", - vid="10C4", - pid="EA60", - serial_number="00_12_4B_00_22_98_88_7F", - manufacturer="Silicon Labs", - description="slae.sh cc2652rb stick - slaesh's iot stuff", -) -electro_lama_device = USBDevice( - device="/dev/cu.usbserial-110", - vid="1A86", - pid="7523", - serial_number=None, - manufacturer=None, - description="USB2.0-Serial", -) -skyconnect_macos_correct = USBDevice( - device="/dev/cu.SLAB_USBtoUART", - vid="10C4", - pid="EA60", - serial_number="9ab1da1ea4b3ed11956f4eaca7669f5d", - manufacturer="Nabu Casa", - description="SkyConnect v1.0", -) -skyconnect_macos_incorrect = USBDevice( - device="/dev/cu.usbserial-2110", - vid="10C4", - pid="EA60", - serial_number="9ab1da1ea4b3ed11956f4eaca7669f5d", - manufacturer="Nabu Casa", - description="SkyConnect v1.0", -) +from aiousbwatcher import InotifyNotAvailableError +import pytest + +from homeassistant.components.usb import async_request_scan as usb_async_request_scan +from homeassistant.core import HomeAssistant + + +@pytest.fixture(name="force_usb_polling_watcher") +def force_usb_polling_watcher(): + """Patch the USB integration to not use inotify and fall back to polling.""" + with patch( + "homeassistant.components.usb.AIOUSBWatcher.async_start", + side_effect=InotifyNotAvailableError, + ): + yield + + +def patch_scanned_serial_ports(**kwargs) -> None: + """Patch the USB integration's list of scanned serial ports.""" + return patch("homeassistant.components.usb.scan_serial_ports", **kwargs) + + +async def async_request_scan(hass: HomeAssistant) -> None: + """Request a USB scan.""" + return await usb_async_request_scan(hass) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 9730dba53d7..3a56e929b22 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -7,31 +7,40 @@ import os from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel -from aiousbwatcher import InotifyNotAvailableError import pytest from homeassistant.components import usb -from homeassistant.components.usb.utils import usb_device_from_port +from homeassistant.components.usb.models import USBDevice 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 homeassistant.util import dt as dt_util -from . import conbee_device, slae_sh_device +from . import ( + force_usb_polling_watcher, # noqa: F401 + patch_scanned_serial_ports, +) from tests.common import async_fire_time_changed, import_and_test_deprecated_constant from tests.typing import WebSocketGenerator - -@pytest.fixture(name="aiousbwatcher_no_inotify") -def aiousbwatcher_no_inotify(): - """Patch AIOUSBWatcher to not use inotify.""" - with patch( - "homeassistant.components.usb.AIOUSBWatcher.async_start", - side_effect=InotifyNotAvailableError, - ): - yield +conbee_device = USBDevice( + device="/dev/cu.usbmodemDE24338801", + vid="1CF1", + pid="0030", + serial_number="DE2433880", + manufacturer="dresden elektronik ingenieurtechnik GmbH", + description="ConBee II", +) +slae_sh_device = USBDevice( + device="/dev/cu.usbserial-110", + vid="10C4", + pid="EA60", + serial_number="00_12_4B_00_22_98_88_7F", + manufacturer="Silicon Labs", + description="slae.sh cc2652rb stick - slaesh's iot stuff", +) async def test_aiousbwatcher_discovery( @@ -40,11 +49,11 @@ async def test_aiousbwatcher_discovery( """Test that aiousbwatcher can discover a device without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}, {"domain": "test2", "vid": "0FA0"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -63,7 +72,7 @@ async def test_aiousbwatcher_discovery( with ( patch("sys.platform", "linux"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch( "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher ), @@ -81,11 +90,11 @@ async def test_aiousbwatcher_discovery( await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 - mock_comports.append( - MagicMock( + mock_ports.append( + USBDevice( device=slae_sh_device.device, - vid=4000, - pid=4000, + vid="0FA0", + pid="0FA0", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -107,7 +116,7 @@ async def test_aiousbwatcher_discovery( await hass.async_block_till_done() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_polling_discovery( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -115,19 +124,19 @@ async def test_polling_discovery( new_usb = [{"domain": "test1", "vid": "3039"}] mock_comports_found_device = asyncio.Event() - def get_comports() -> list: - nonlocal mock_comports + def scan_serial_ports() -> list: + nonlocal mock_ports # Only "find" a device after a few invocations - if len(mock_comports.mock_calls) < 5: + if len(mock_ports.mock_calls) < 5: return [] mock_comports_found_device.set() return [ - MagicMock( + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -141,9 +150,7 @@ async def test_polling_discovery( 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_scanned_serial_ports(side_effect=scan_serial_ports) as mock_ports, patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -163,16 +170,16 @@ async def test_polling_discovery( await hass.async_block_till_done() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> None: """Test a device is removed by the aiousbwatcher before started.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -181,13 +188,13 @@ async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> N with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), 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() - with patch("homeassistant.components.usb.comports", return_value=[]): + with patch_scanned_serial_ports(return_value=[]): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -197,18 +204,18 @@ async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> N await hass.async_block_till_done() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -217,7 +224,7 @@ async def test_discovered_by_websocket_scan( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -234,7 +241,7 @@ async def test_discovered_by_websocket_scan( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_limited_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -243,11 +250,11 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*2652*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -256,7 +263,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -273,7 +280,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_most_targeted_matcher_wins( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -283,11 +290,11 @@ async def test_most_targeted_matcher_wins( {"domain": "more", "vid": "3039", "pid": "3039", "description": "*2652*"}, ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -296,7 +303,7 @@ async def test_most_targeted_matcher_wins( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -313,7 +320,7 @@ async def test_most_targeted_matcher_wins( assert mock_config_flow.mock_calls[0][1][0] == "more" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_rejected_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -322,11 +329,11 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*not_it*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -335,7 +342,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -351,7 +358,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -365,11 +372,11 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( } ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -378,7 +385,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -395,7 +402,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -404,11 +411,11 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -417,7 +424,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -433,7 +440,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -447,11 +454,11 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( } ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=conbee_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, @@ -460,7 +467,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -477,7 +484,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -491,11 +498,11 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( } ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=conbee_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, @@ -504,7 +511,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -520,7 +527,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -529,11 +536,11 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( {"domain": "test1", "vid": "3039", "pid": "3039", "serial_number": "123*"} ] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=conbee_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=None, manufacturer=None, description=None, @@ -542,7 +549,7 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -558,18 +565,18 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_match_vid_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan only matching vid.""" new_usb = [{"domain": "test1", "vid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -578,7 +585,7 @@ async def test_discovered_by_websocket_scan_match_vid_only( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -595,18 +602,18 @@ async def test_discovered_by_websocket_scan_match_vid_only( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_scan_match_vid_wrong_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan only matching vid but wrong pid.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -615,7 +622,7 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -631,15 +638,15 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_discovered_by_websocket_no_vid_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a device is discovered from websocket scan with no vid or pid.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, vid=None, pid=None, @@ -651,7 +658,7 @@ async def test_discovered_by_websocket_no_vid_pid( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -667,18 +674,18 @@ async def test_discovered_by_websocket_no_vid_pid( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_non_matching_discovered_by_scanner_after_started( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a websocket scan that does not match.""" new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -687,7 +694,7 @@ async def test_non_matching_discovered_by_scanner_after_started( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -709,11 +716,11 @@ async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( """Test that aiousbwatcher on WSL failure results in fallback to scanning without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -722,7 +729,7 @@ async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -743,17 +750,17 @@ async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) - """Test a device is discovered since aiousbwatcher is now running.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ) ] - initial_mock_comports = [] + initial_ports = [] aiousbwatcher_callback = None def async_register_callback(callback): @@ -766,9 +773,7 @@ async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) - with ( patch("sys.platform", "linux"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch( - "homeassistant.components.usb.comports", return_value=initial_mock_comports - ), + patch_scanned_serial_ports(return_value=initial_ports), patch( "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher ), @@ -782,7 +787,7 @@ async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) - assert len(mock_config_flow.mock_calls) == 0 - initial_mock_comports.extend(mock_comports) + initial_ports.extend(mock_ports) aiousbwatcher_callback() await hass.async_block_till_done() @@ -874,18 +879,18 @@ def test_human_readable_device_name() -> None: assert "8A2A" in name -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_async_is_plugged_in( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test async_is_plugged_in.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -899,7 +904,7 @@ async def test_async_is_plugged_in( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -909,7 +914,7 @@ async def test_async_is_plugged_in( assert not usb.async_is_plugged_in(hass, matcher) with ( - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch.object(hass.config_entries.flow, "async_init"), ): ws_client = await hass_ws_client(hass) @@ -920,7 +925,7 @@ async def test_async_is_plugged_in( assert usb.async_is_plugged_in(hass, matcher) -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @pytest.mark.parametrize( "matcher", [ @@ -940,7 +945,7 @@ async def test_async_is_plugged_in_case_enforcement( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -952,7 +957,7 @@ async def test_async_is_plugged_in_case_enforcement( usb.async_is_plugged_in(hass, matcher) -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_web_socket_triggers_discovery_request_callbacks( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -961,7 +966,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( with ( patch("homeassistant.components.usb.async_get_usb", return_value=[]), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -987,7 +992,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( assert len(mock_callback.mock_calls) == 1 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -997,7 +1002,7 @@ async def test_initial_scan_callback( with ( patch("homeassistant.components.usb.async_get_usb", return_value=[]), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1023,7 +1028,7 @@ async def test_initial_scan_callback( cancel_2() -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_cancel_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1032,7 +1037,7 @@ async def test_cancel_initial_scan_callback( with ( patch("homeassistant.components.usb.async_get_usb", return_value=[]), - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), patch.object(hass.config_entries.flow, "async_init"), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1049,18 +1054,18 @@ async def test_cancel_initial_scan_callback( assert len(mock_callback.mock_calls) == 0 -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") async def test_resolve_serial_by_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test the discovery data resolves to serial/by-id.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] - mock_comports = [ - MagicMock( + mock_ports = [ + USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, @@ -1069,7 +1074,7 @@ async def test_resolve_serial_by_id( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch_scanned_serial_ports(return_value=mock_ports), patch( "homeassistant.components.usb.get_serial_by_id", return_value="/dev/serial/by-id/bla", @@ -1091,73 +1096,73 @@ async def test_resolve_serial_by_id( assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/serial/by-id/bla" -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @pytest.mark.parametrize( "ports", [ [ - MagicMock( + USBDevice( device="/dev/cu.usbserial-2120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.usbserial-1120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART2", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), ], [ - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART2", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.SLAB_USBtoUART", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.usbserial-1120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ), - MagicMock( + USBDevice( device="/dev/cu.usbserial-2120", - vid=0x3039, - pid=0x3039, + vid="3039", + pid="3039", serial_number=conbee_device.serial_number, manufacturer=conbee_device.manufacturer, description=conbee_device.description, @@ -1177,7 +1182,7 @@ async def test_cp2102n_ordering_on_macos( with ( patch("sys.platform", "darwin"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=ports), + patch_scanned_serial_ports(return_value=ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1224,34 +1229,31 @@ def test_deprecated_constants( ) -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @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( + port1 = USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ) - port2 = Mock( + port2 = USBDevice( device=conbee_device.device, - vid=12346, - pid=12346, + vid="303A", + pid="303A", 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() @@ -1259,7 +1261,7 @@ async def test_register_port_event_callback( # Start off with no ports with ( - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1270,13 +1272,13 @@ async def test_register_port_event_callback( assert mock_callback2.mock_calls == [] # Add two new ports - with patch("homeassistant.components.usb.comports", return_value=[port1, port2]): + with patch_scanned_serial_ports(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())] + assert mock_callback1.mock_calls == [call({port1, port2}, set())] + assert mock_callback2.mock_calls == [call({port1, port2}, set())] # Cancel the second callback cancel2() @@ -1286,20 +1288,20 @@ async def test_register_port_event_callback( mock_callback2.reset_mock() # Remove port 2 - with patch("homeassistant.components.usb.comports", return_value=[port1]): + with patch_scanned_serial_ports(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_callback1.mock_calls == [call(set(), {port2})] 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]): + with patch_scanned_serial_ports(return_value=[port1]): await ws_client.send_json({"id": 3, "type": "usb/scan"}) response = await ws_client.receive_json() assert response["success"] @@ -1310,17 +1312,17 @@ async def test_register_port_event_callback( assert mock_callback2.mock_calls == [] # Unplug one and plug in the other - with patch("homeassistant.components.usb.comports", return_value=[port2]): + with patch_scanned_serial_ports(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_callback1.mock_calls == [call({port2}, {port1})] assert mock_callback2.mock_calls == [] -@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@pytest.mark.usefixtures("force_usb_polling_watcher") @patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) async def test_register_port_event_callback_failure( hass: HomeAssistant, @@ -1329,27 +1331,24 @@ async def test_register_port_event_callback_failure( ) -> None: """Test port event callback failure handling.""" - port1 = Mock( + port1 = USBDevice( device=slae_sh_device.device, - vid=12345, - pid=12345, + vid="3039", + pid="3039", serial_number=slae_sh_device.serial_number, manufacturer=slae_sh_device.manufacturer, description=slae_sh_device.description, ) - port2 = Mock( + port2 = USBDevice( device=conbee_device.device, - vid=12346, - pid=12346, + vid="303A", + pid="303A", 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")) @@ -1357,7 +1356,7 @@ async def test_register_port_event_callback_failure( # Start off with no ports with ( - patch("homeassistant.components.usb.comports", return_value=[]), + patch_scanned_serial_ports(return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1369,7 +1368,7 @@ async def test_register_port_event_callback_failure( # Add two new ports with ( - patch("homeassistant.components.usb.comports", return_value=[port1, port2]), + patch_scanned_serial_ports(return_value=[port1, port2]), caplog.at_level(logging.ERROR, logger="homeassistant.components.usb"), ): await ws_client.send_json({"id": 1, "type": "usb/scan"}) @@ -1378,8 +1377,8 @@ async def test_register_port_event_callback_failure( 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 mock_callback1.mock_calls == [call({port1, port2}, set())] + assert mock_callback2.mock_calls == [call({port1, port2}, set())] assert caplog.text.count("Error in USB port event callback") == 2 assert "Failure 1" in caplog.text diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 4901e069aee..01fd80acc0e 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -10,7 +10,7 @@ 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 tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.mark.parametrize("platform", ["sensor"]) @@ -253,17 +253,6 @@ async def test_always_available(hass: HomeAssistant) -> None: } -def get_suggested(schema, key): - """Get suggested value for key in voluptuous schema.""" - for k in schema: - if k == key: - if k.description is None or "suggested_value" not in k.description: - return None - return k.description["suggested_value"] - # Wanted key absent from schema - raise KeyError("Wanted key absent from schema") - - async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" input_sensor1_entity_id = "sensor.input1" @@ -293,8 +282,8 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema - assert get_suggested(schema, "source") == input_sensor1_entity_id - assert get_suggested(schema, "periodically_resetting") is True + assert get_schema_suggested_value(schema, "source") == input_sensor1_entity_id + assert get_schema_suggested_value(schema, "periodically_resetting") is True result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/utility_meter/test_diagnostics.py b/tests/components/utility_meter/test_diagnostics.py index 8be5f949940..88521a91b7f 100644 --- a/tests/components/utility_meter/test_diagnostics.py +++ b/tests/components/utility_meter/test_diagnostics.py @@ -3,7 +3,7 @@ from aiohttp.test_utils import TestClient from freezegun import freeze_time import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.auth.models import Credentials diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index c671969c5ac..2de2ee553b3 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -43,6 +43,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -1637,8 +1638,21 @@ async def _test_self_reset( now += timedelta(seconds=30) with freeze_time(now): + # Listen for events and check that state in the first event after reset is actually 0, issue #142053 + events = [] + + async def handle_energy_bill_event(event): + events.append(event) + + unsub = async_track_state_change_event( + hass, + "sensor.energy_bill", + handle_energy_bill_event, + ) + async_fire_time_changed(hass, now) await hass.async_block_till_done() + unsub() hass.states.async_set( entity_id, 6, @@ -1654,6 +1668,10 @@ async def _test_self_reset( state.attributes.get("last_reset") == dt_util.as_utc(now).isoformat() ) # last_reset is kept in UTC assert state.state == "3" + # In first event state should be 0 + assert len(events) == 2 + assert events[0].data.get("new_state").state == "0" + assert events[1].data.get("new_state").state == "0" else: assert state.attributes.get("last_period") == "0" assert state.state == "5" diff --git a/tests/components/v2c/test_diagnostics.py b/tests/components/v2c/test_diagnostics.py index eafbd68e6fc..6371b2480e8 100644 --- a/tests/components/v2c/test_diagnostics.py +++ b/tests/components/v2c/test_diagnostics.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.config_entries import ConfigEntry diff --git a/tests/components/v2c/test_sensor.py b/tests/components/v2c/test_sensor.py index 430f91647dd..11dcfe5e4a5 100644 --- a/tests/components/v2c/test_sensor.py +++ b/tests/components/v2c/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.v2c.sensor import _METER_ERROR_OPTIONS from homeassistant.const import Platform diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 8ae054b5646..967b9672805 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -5,7 +5,6 @@ from __future__ import annotations from enum import Enum from types import ModuleType from typing import Any -from unittest.mock import patch import pytest @@ -25,7 +24,6 @@ from homeassistant.components.vacuum import ( 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 @@ -326,7 +324,6 @@ async def test_vacuum_not_log_deprecated_state_warning( @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, @@ -356,6 +353,7 @@ async def test_vacuum_log_deprecated_state_warning_using_state_prop( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -370,7 +368,6 @@ async def test_vacuum_log_deprecated_state_warning_using_state_prop( @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, @@ -399,6 +396,7 @@ async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -427,8 +425,7 @@ async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( @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( +async def test_vacuum_deprecated_state_does_not_break_state( hass: HomeAssistant, config_flow_fixture: None, caplog: pytest.LogCaptureFixture, @@ -463,6 +460,7 @@ async def test_alarm_control_panel_deprecated_state_does_not_break_state( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 65418790280..f7cbeb7a052 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -85,6 +85,7 @@ def mock_module_no_subdevices( module.get_type_name.return_value = "VMB4RYLD" module.get_addresses.return_value = [1, 2, 3, 4] module.get_name.return_value = "BedRoom" + module.get_serial.return_value = "a1b2c3d4e5f6" module.get_sw_version.return_value = "1.0.0" module.is_loaded.return_value = True module.get_channels.return_value = {} @@ -98,6 +99,7 @@ def mock_module_subdevices() -> AsyncMock: module.get_type_name.return_value = "VMB2BLE" module.get_addresses.return_value = [88] module.get_name.return_value = "Kitchen" + module.get_serial.return_value = "a1b2c3d4e5f6" module.get_sw_version.return_value = "2.0.0" module.is_loaded.return_value = True module.get_channels.return_value = {} diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index ee714624b45..36d658f9633 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -59,43 +59,30 @@ def mock_controller_connection_failed(): @pytest.mark.usefixtures("controller") -async def test_user_network_succes(hass: HomeAssistant) -> None: - """Test user network config.""" - # inttial menu show - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result - assert result.get("flow_id") - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "user" - assert result.get("menu_options") == ["network", "usbselect"] - # select the network option - result = await hass.config_entries.flow.async_configure( - result.get("flow_id"), - {"next_step_id": "network"}, - ) - assert result.get("type") is FlowResultType.FORM - # fill in the network form - result = await hass.config_entries.flow.async_configure( - result.get("flow_id"), - { - CONF_TLS: False, - CONF_HOST: "velbus", - CONF_PORT: 6000, - CONF_PASSWORD: "", - }, - ) - assert result - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "Velbus Network" - data = result.get("data") - assert data - assert data[CONF_PORT] == "velbus:6000" - - -@pytest.mark.usefixtures("controller") -async def test_user_network_succes_tls(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("inputParams", "expected"), + [ + ( + { + CONF_TLS: True, + CONF_PASSWORD: "password", + }, + "tls://password@velbus:6000", + ), + ( + { + CONF_TLS: True, + CONF_PASSWORD: "", + }, + "tls://velbus:6000", + ), + ({CONF_TLS: True}, "tls://velbus:6000"), + ({CONF_TLS: False}, "velbus:6000"), + ], +) +async def test_user_network_succes( + hass: HomeAssistant, inputParams: str, expected: str +) -> None: """Test user network config.""" # inttial menu show result = await hass.config_entries.flow.async_init( @@ -116,10 +103,9 @@ async def test_user_network_succes_tls(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result.get("flow_id"), { - CONF_TLS: True, CONF_HOST: "velbus", CONF_PORT: 6000, - CONF_PASSWORD: "password", + **inputParams, }, ) assert result @@ -127,7 +113,7 @@ async def test_user_network_succes_tls(hass: HomeAssistant) -> None: assert result.get("title") == "Velbus Network" data = result.get("data") assert data - assert data[CONF_PORT] == "tls://password@velbus:6000" + assert data[CONF_PORT] == expected @pytest.mark.usefixtures("controller") diff --git a/tests/components/velbus/test_diagnostics.py b/tests/components/velbus/test_diagnostics.py index af84115ff14..74a0b4911de 100644 --- a/tests/components/velbus/test_diagnostics.py +++ b/tests/components/velbus/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Velbus diagnostics.""" import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 39a92778727..cf2f49ff28f 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -15,6 +15,10 @@ ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT = "select.humidifier_300s_night_light_level" +ENTITY_FAN = "fan.SmartTowerFan" + +ENTITY_SWITCH_DISPLAY = "switch.humidifier_200s_display" + ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ dev["deviceName"] for dev in ALL_DEVICES["result"]["list"] @@ -27,7 +31,11 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") ], "Air Purifier 131s": [ - ("post", "/131airPurifier/v1/device/deviceDetail", "purifier-detail.json") + ( + "post", + "/131airPurifier/v1/device/deviceDetail", + "air-purifier-131s-detail.json", + ) ], "Air Purifier 200s": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index df6ebbdf6e7..32f23101755 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -198,6 +198,26 @@ async def install_humidifier_device( await hass.async_block_till_done() +@pytest.fixture(name="fan_config_entry") +async def fan_config_entry( + hass: HomeAssistant, requests_mock: requests_mock.Mocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `SmartTowerFan`.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + ) + entry.add_to_hass(hass) + + device_name = "SmartTowerFan" + 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 + + @pytest.fixture(name="switch_old_id_config_entry") async def switch_old_id_config_entry( hass: HomeAssistant, requests_mock: requests_mock.Mocker, config diff --git a/tests/components/vesync/fixtures/air-purifier-131s-detail.json b/tests/components/vesync/fixtures/air-purifier-131s-detail.json new file mode 100644 index 00000000000..a7598c621d3 --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-131s-detail.json @@ -0,0 +1,25 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1744558015", + "screenStatus": "on", + "filterLife": { + "change": false, + "useHour": 3034, + "percent": 25 + }, + "activeTime": 0, + "timer": null, + "scheduleCount": 0, + "schedule": null, + "levelNew": 0, + "airQuality": "excellent", + "level": null, + "mode": "sleep", + "deviceName": "Levoit 131S Air Purifier", + "currentFirmVersion": "2.0.58", + "childLock": "off", + "deviceStatus": "on", + "deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/airpurifier131_240.png", + "connectionStatus": "online" +} diff --git a/tests/components/vesync/fixtures/purifier-detail.json b/tests/components/vesync/fixtures/purifier-detail.json deleted file mode 100644 index de0843975c3..00000000000 --- a/tests/components/vesync/fixtures/purifier-detail.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "code": 0, - "deviceStatus": "on", - "activeTime": 50, - "filterLife": 90, - "screenStatus": "on", - "mode": "auto", - "level": 2, - "airQuality": 95 -} diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 407e18d65b6..aa55a9be3cb 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -290,6 +290,30 @@ }), 'unit_of_measurement': '%', }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fan_display', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan Display', + }), + 'entity_id': 'switch.fan_display', + 'last_changed': str, + 'last_reported': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), ]), 'name': 'Fan', 'name_by_user': None, diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 0b56a08eeff..412bd8a1b2e 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -78,11 +78,17 @@ # name: test_fan_state[Air Purifier 131s][fan.air_purifier_131s] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'active_time': 0, 'friendly_name': 'Air Purifier 131s', + 'mode': 'sleep', + 'percentage': None, + 'percentage_step': 33.333333333333336, + 'preset_mode': 'sleep', 'preset_modes': list([ 'auto', 'sleep', ]), + 'screen_status': 'on', 'supported_features': , }), 'context': , @@ -90,7 +96,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'on', }) # --- # name: test_fan_state[Air Purifier 200s][devices] @@ -634,8 +640,8 @@ 'preset_modes': list([ 'advancedSleep', 'auto', - 'turbo', 'normal', + 'turbo', ]), }), 'config_entry_id': , @@ -676,12 +682,12 @@ 'night_light': 'off', 'percentage': None, 'percentage_step': 7.6923076923076925, - 'preset_mode': None, + 'preset_mode': 'normal', 'preset_modes': list([ 'advancedSleep', 'auto', - 'turbo', 'normal', + 'turbo', ]), 'screen_status': False, 'supported_features': , diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index c701fa8a324..ecae8fa7674 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -114,7 +114,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'excellent', }) # --- # name: test_sensor_state[Air Purifier 131s][sensor.air_purifier_131s_filter_lifetime] @@ -129,7 +129,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '25', }) # --- # name: test_sensor_state[Air Purifier 200s][devices] diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 1faed941338..f25aaf3d51b 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -36,8 +36,53 @@ # --- # name: test_switch_state[Air Purifier 131s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.air_purifier_131s_display', + 'has_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', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'air-purifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 131s][switch.air_purifier_131s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 131s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_131s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Air Purifier 200s][devices] list([ DeviceRegistryEntrySnapshot({ @@ -75,8 +120,53 @@ # --- # name: test_switch_state[Air Purifier 200s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.air_purifier_200s_display', + 'has_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', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 200s][switch.air_purifier_200s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 200s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_200s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Air Purifier 400s][devices] list([ DeviceRegistryEntrySnapshot({ @@ -114,8 +204,53 @@ # --- # name: test_switch_state[Air Purifier 400s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.air_purifier_400s_display', + 'has_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', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '400s-purifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 400s][switch.air_purifier_400s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 400s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_400s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Air Purifier 600s][devices] list([ DeviceRegistryEntrySnapshot({ @@ -153,8 +288,53 @@ # --- # name: test_switch_state[Air Purifier 600s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.air_purifier_600s_display', + 'has_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', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '600s-purifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 600s][switch.air_purifier_600s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 600s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_600s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Dimmable Light][devices] list([ DeviceRegistryEntrySnapshot({ @@ -270,8 +450,53 @@ # --- # name: test_switch_state[Humidifier 200s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.humidifier_200s_display', + 'has_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', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '200s-humidifier4321-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Humidifier 200s][switch.humidifier_200s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Humidifier 200s Display', + }), + 'context': , + 'entity_id': 'switch.humidifier_200s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Humidifier 600S][devices] list([ DeviceRegistryEntrySnapshot({ @@ -309,8 +534,53 @@ # --- # name: test_switch_state[Humidifier 600S][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.humidifier_600s_display', + 'has_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', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '600s-humidifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Humidifier 600S][switch.humidifier_600s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Humidifier 600S Display', + }), + 'context': , + 'entity_id': 'switch.humidifier_600s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Outlet][devices] list([ DeviceRegistryEntrySnapshot({ @@ -433,8 +703,53 @@ # --- # name: test_switch_state[SmartTowerFan][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.smarttowerfan_display', + 'has_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', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'smarttowerfan-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[SmartTowerFan][switch.smarttowerfan_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SmartTowerFan Display', + }), + 'context': , + 'entity_id': 'switch.smarttowerfan_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switch_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index 25aa5337281..c2b789a932e 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import patch from pyvesync.helpers import Helpers -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.matchers import path_type from homeassistant.components.vesync.const import DOMAIN diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index 4d444036a60..cf572e5b981 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -1,17 +1,24 @@ """Tests for the fan module.""" +from contextlib import nullcontext +from unittest.mock import patch + import pytest import requests_mock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.components.fan import ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError 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_FAN, mock_devices_response from tests.common import MockConfigEntry +NoException = nullcontext() + @pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) async def test_fan_state( @@ -49,3 +56,105 @@ async def test_fan_state( # Check states for entity in entities: assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncTowerFan.turn_on"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncTowerFan.turn_off"), + ], +) +async def test_turn_on_off_success( + hass: HomeAssistant, + fan_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test turn_on and turn_off method.""" + + with ( + patch(command, return_value=True) as method_mock, + ): + with patch( + "homeassistant.components.vesync.fan.VeSyncFanHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + FAN_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_FAN}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncTowerFan.turn_on"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncTowerFan.turn_off"), + ], +) +async def test_turn_on_off_raises_error( + hass: HomeAssistant, + fan_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test turn_on and turn_off raises errors when fails.""" + + # returns False indicating failure in which case raises HomeAssistantError. + with ( + patch( + command, + return_value=False, + ) as method_mock, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + FAN_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_FAN}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_set_preset_mode( + hass: HomeAssistant, + fan_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test handling of value in set_preset_mode method. Does this via turn on as it increases test coverage.""" + + # If VeSyncTowerFan.normal_mode fails (returns False), then HomeAssistantError is raised + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncTowerFan.normal_mode", + return_value=api_response, + ) as method_mock, + ): + with patch( + "homeassistant.components.vesync.fan.VeSyncFanHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_FAN, ATTR_PRESET_MODE: "normal"}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 31df2418b3d..d1e76174ea0 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -163,11 +163,11 @@ async def test_migrate_config_entry( assert migrated_humidifer is not None assert migrated_humidifer.unique_id == "humidifer" - # Assert that only one entity exists in the switch domain + # Assert that entity exists in the switch domain switch_entities = [ e for e in entity_registry.entities.values() if e.domain == "switch" ] - assert len(switch_entities) == 1 + assert len(switch_entities) == 2 humidifer_entities = [ e for e in entity_registry.entities.values() if e.domain == "humidifer" diff --git a/tests/components/vesync/test_light.py b/tests/components/vesync/test_light.py index 866e6b295bf..7300e28e406 100644 --- a/tests/components/vesync/test_light.py +++ b/tests/components/vesync/test_light.py @@ -2,7 +2,7 @@ import pytest import requests_mock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/vesync/test_select.py b/tests/components/vesync/test_select.py index 30c83c89e0e..c96d687dfd2 100644 --- a/tests/components/vesync/test_select.py +++ b/tests/components/vesync/test_select.py @@ -7,8 +7,10 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.vesync.const import NIGHT_LIGHT_LEVEL_DIM -from homeassistant.components.vesync.select import HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP +from homeassistant.components.vesync.const import HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM +from homeassistant.components.vesync.select import ( + HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP, +) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -18,24 +20,24 @@ from .common import ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT @pytest.mark.parametrize( "install_humidifier_device", ["humidifier_300s"], indirect=True ) -async def test_set_nightlight_level( +async def test_humidifier_set_nightlight_level( hass: HomeAssistant, manager, humidifier_300s, install_humidifier_device ) -> None: - """Test set of night light level.""" + """Test set of humidifier night light level.""" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT, - ATTR_OPTION: NIGHT_LIGHT_LEVEL_DIM, + ATTR_OPTION: HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM, }, blocking=True, ) # Assert that setter API was invoked with the expected translated value humidifier_300s.set_night_light_brightness.assert_called_once_with( - HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP[NIGHT_LIGHT_LEVEL_DIM] + HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP[HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM] ) # Assert that devices were refreshed manager.update_all_devices.assert_called_once() @@ -44,11 +46,13 @@ async def test_set_nightlight_level( @pytest.mark.parametrize( "install_humidifier_device", ["humidifier_300s"], indirect=True ) -async def test_nightlight_level(hass: HomeAssistant, install_humidifier_device) -> None: - """Test the state of night light level select entity.""" +async def test_humidifier_nightlight_level( + hass: HomeAssistant, install_humidifier_device +) -> None: + """Test the state of humidifier night light level select entity.""" # The mocked device has night_light_brightness=50 which is "dim" assert ( hass.states.get(ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT).state - == NIGHT_LIGHT_LEVEL_DIM + == HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM ) diff --git a/tests/components/vesync/test_sensor.py b/tests/components/vesync/test_sensor.py index 04d759de584..d4e6abcdbab 100644 --- a/tests/components/vesync/test_sensor.py +++ b/tests/components/vesync/test_sensor.py @@ -2,7 +2,7 @@ import pytest import requests_mock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/vesync/test_switch.py b/tests/components/vesync/test_switch.py index 111f2b80960..b0af5afc5d2 100644 --- a/tests/components/vesync/test_switch.py +++ b/tests/components/vesync/test_switch.py @@ -1,17 +1,24 @@ """Tests for the switch module.""" +from contextlib import nullcontext +from unittest.mock import patch + import pytest import requests_mock -from syrupy import SnapshotAssertion +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 from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError 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_SWITCH_DISPLAY, mock_devices_response from tests.common import MockConfigEntry +NoException = nullcontext() + @pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) async def test_switch_state( @@ -49,3 +56,72 @@ async def test_switch_state( # Check states for entity in entities: assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on_display"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off_display"), + ], +) +async def test_turn_on_off_display_success( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test switch turn on and off command with success response.""" + + with ( + patch( + command, + return_value=True, + ) as method_mock, + patch( + "homeassistant.components.vesync.switch.VeSyncSwitchEntity.schedule_update_ha_state" + ) as update_mock, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_SWITCH_DISPLAY}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on_display"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off_display"), + ], +) +async def test_turn_on_off_display_raises_error( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test switch turn on and off command raises HomeAssistantError.""" + + with ( + patch( + command, + return_value=False, + ) as method_mock, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_SWITCH_DISPLAY}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() diff --git a/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json new file mode 100644 index 00000000000..a733d33a12a --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json @@ -0,0 +1,3019 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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": "2025-02-10T14:01:48.216Z", + "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": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.bufferCylinder.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.0.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T20:58:18.395Z", + "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.1.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -15, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 0 + }, + "slope": { + "type": "number", + "unit": "", + "value": 0.4 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "standby", + "maxEntries": 8, + "modes": ["reduced", "normal", "fixed"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["dhw", "dhwAndHeating", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "dhwAndHeating" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.0.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.0.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "normal" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.2.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.3 + } + }, + "timestamp": "2025-02-11T20:49:01.456Z", + "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.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.0.temperature", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 33.2 + } + }, + "timestamp": "2025-02-11T19:48:05.380Z", + "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.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.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.0.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.0.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.0.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 44 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 15 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.1.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "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.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "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.0/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 4332.4 + }, + "starts": { + "type": "number", + "unit": "", + "value": 21314 + } + }, + "timestamp": "2025-02-11T20:34:55.482Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1.statistics", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "phase": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-02-11T20:45:56.068Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.controller.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.controller.serial" + }, + { + "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": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.charging", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.charging" + }, + { + "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": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 8, + "modes": ["5/25-cycles", "5/10-cycles", "on"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.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": "06:50" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ] + } + } + }, + "timestamp": "2025-02-11T17:50:12.565Z", + "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.primary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.primary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "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": 8, + "modes": ["top", "normal", "temp-2"], + "overlapAllowed": true, + "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": "normal", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "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": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "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.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.bottom" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "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": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "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.bottom", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.bottom" + }, + { + "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": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "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.sensors.temperature.outlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.outlet" + }, + { + "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": false, + "name": "setHysteresisSwitchOffValue", + "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/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": 5 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 10, + "efficientUpperBorder": 60, + "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": 50 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "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.temp2/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.temp2", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 60 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.temp2" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "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": false + }, + "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": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 6.9 + } + }, + "timestamp": "2025-02-11T20:58:31.054Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.return" + }, + { + "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": 5.2 + } + }, + "timestamp": "2025-02-11T20:48:38.307Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "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": 26.9 + } + }, + "timestamp": "2025-02-11T20:46:37.502Z", + "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.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 1.9 + } + }, + "timestamp": "2025-02-11T21:00:13.154Z", + "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": 26.5 + } + }, + "timestamp": "2025-02-11T20:48:00.474Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.cumulativeProduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.cumulativeProduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.production", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.production" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.pumps.circuit", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.pumps.circuit" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.collector", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.collector" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelFour", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelFour" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelOne", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelThree", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelThree" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelTwo", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelTwo" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["standby", "standard", "ventilation"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setMode" + }, + "setModeContinuousSensorOverride": { + "isExecutable": false, + "name": "setModeContinuousSensorOverride", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setModeContinuousSensorOverride" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "ventilation" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.standard", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.standard" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "levelThree" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "demand": { + "type": "string", + "value": "ventilation" + }, + "level": { + "type": "string", + "value": "levelThree" + }, + "reason": { + "type": "string", + "value": "schedule" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.state" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "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/ventilation.quickmodes.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "levelOne", + "maxEntries": 8, + "modes": ["levelTwo", "levelThree", "levelFour"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "ventilation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.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.0.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.0.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-01-12T22:36:28.706Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.1.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.2.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.name" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 0bac421e2c7..2a44fb87b65 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -1,4 +1,74 @@ # serializer version: 1 +# name: test_all_entities[fan.model0_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model0_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model0_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Ventilation', + 'icon': 'mdi:fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + , + ]), + 'supported_features': , + 'vicare_quickmodes': list([ + 'comfort', + 'eco', + 'holiday', + ]), + }), + 'context': , + 'entity_id': 'fan.model0_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[fan.model1_ventilation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -29,7 +99,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:fan-off', + 'original_icon': 'mdi:fan', 'original_name': 'Ventilation', 'platform': 'vicare', 'previous_unique_id': None, @@ -43,7 +113,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model1 Ventilation', - 'icon': 'mdi:fan-off', + 'icon': 'mdi:fan', 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, @@ -53,6 +123,11 @@ , ]), 'supported_features': , + 'vicare_quickmodes': list([ + 'comfort', + 'eco', + 'holiday', + ]), }), 'context': , 'entity_id': 'fan.model1_ventilation', @@ -62,3 +137,69 @@ 'state': 'off', }) # --- +# name: test_all_entities[fan.model2_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model2_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway2_################-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model2_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model2 Ventilation', + 'icon': 'mdi:fan', + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + ]), + 'supported_features': , + 'vicare_quickmodes': list([ + 'comfort', + 'eco', + 'holiday', + ]), + }), + 'context': , + 'entity_id': 'fan.model2_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vicare/test_climate.py b/tests/components/vicare/test_climate.py index f48a8988cf0..9299f6567b1 100644 --- a/tests/components/vicare/test_climate.py +++ b/tests/components/vicare/test_climate.py @@ -23,7 +23,9 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + fixtures: list[Fixture] = [ + Fixture({"type:boiler"}, "vicare/Vitodens300W.json"), + ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), diff --git a/tests/components/vicare/test_fan.py b/tests/components/vicare/test_fan.py index 5683f48f01f..8c42c92fb50 100644 --- a/tests/components/vicare/test_fan.py +++ b/tests/components/vicare/test_fan.py @@ -26,6 +26,7 @@ async def test_all_entities( fixtures: list[Fixture] = [ Fixture({"type:ventilation"}, "vicare/ViAir300F.json"), Fixture({"type:ventilation"}, "vicare/VitoPure.json"), + Fixture({"type:heatpump"}, "vicare/Vitocal222G_Vitovent300W.json"), ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), diff --git a/tests/components/vodafone_station/conftest.py b/tests/components/vodafone_station/conftest.py index a065a1e8065..778d8fdaa41 100644 --- a/tests/components/vodafone_station/conftest.py +++ b/tests/components/vodafone_station/conftest.py @@ -5,7 +5,7 @@ from datetime import UTC, datetime from aiovodafone import VodafoneStationDevice import pytest -from homeassistant.components.vodafone_station import DOMAIN +from homeassistant.components.vodafone_station.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from .const import DEVICE_1_HOST, DEVICE_1_MAC, DEVICE_2_MAC diff --git a/tests/components/vodafone_station/test_button.py b/tests/components/vodafone_station/test_button.py index d5f377d3f6f..84df839cae0 100644 --- a/tests/components/vodafone_station/test_button.py +++ b/tests/components/vodafone_station/test_button.py @@ -2,11 +2,20 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from aiovodafone.exceptions import ( + AlreadyLogged, + CannotAuthenticate, + CannotConnect, + GenericLoginError, +) +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.vodafone_station.const import DOMAIN 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 @@ -46,3 +55,39 @@ async def test_pressing_button( blocking=True, ) mock_vodafone_station_router.restart_router.assert_called_once() + + +@pytest.mark.parametrize( + ("side_effect", "key", "error"), + [ + (CannotConnect, "cannot_execute_action", "CannotConnect()"), + (AlreadyLogged, "cannot_execute_action", "AlreadyLogged()"), + (GenericLoginError, "cannot_execute_action", "GenericLoginError()"), + (CannotAuthenticate, "cannot_authenticate", "CannotAuthenticate()"), + ], +) +async def test_button_fails( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + key: str, + error: str, +) -> None: + """Test button action fails.""" + + await setup_integration(hass, mock_config_entry) + + mock_vodafone_station_router.restart_router.side_effect = side_effect + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.vodafone_station_m123456789_restart"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == key + assert exc_info.value.translation_placeholders == {"error": error} diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 68f8247bdf9..7ab56f2e967 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -228,3 +228,77 @@ async def test_options_flow( assert result["data"] == { CONF_CONSIDER_HOME: 37, } + + +async def test_reconfigure_successful( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the host can be reconfigured.""" + 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" + + # original entry + assert mock_config_entry.data["host"] == "fake_host" + + new_host = "192.168.100.60" + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: new_host, + CONF_PASSWORD: "fake_password", + CONF_USERNAME: "fake_username", + }, + ) + + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data["host"] == new_host + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (AlreadyLogged, "already_logged"), + (ConnectionResetError, "unknown"), + ], +) +async def test_reconfigure_fails( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test that the host can be reconfigured.""" + 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" + + mock_vodafone_station_router.login.side_effect = side_effect + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.100.60", + CONF_PASSWORD: "fake_password", + CONF_USERNAME: "fake_username", + }, + ) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + assert reconfigure_result["errors"] == {"base": error} diff --git a/tests/components/vodafone_station/test_coordinator.py b/tests/components/vodafone_station/test_coordinator.py index 1a9470245c7..5f75b538803 100644 --- a/tests/components/vodafone_station/test_coordinator.py +++ b/tests/components/vodafone_station/test_coordinator.py @@ -40,8 +40,7 @@ async def test_coordinator_device_cleanup( device_tracker = f"device_tracker.{DEVICE_1_HOST}" - state = hass.states.get(device_tracker) - assert state is not None + assert hass.states.get(device_tracker) mock_vodafone_station_router.get_devices_data.return_value = { DEVICE_2_MAC: VodafoneStationDevice( @@ -59,10 +58,10 @@ async def test_coordinator_device_cleanup( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(device_tracker) - assert state is None + assert hass.states.get(device_tracker) is None assert f"Skipping entity {DEVICE_2_HOST}" in caplog.text - device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_1_MAC)}) - assert device is None + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_1_MAC)}) is None + ) assert f"Removing device: {DEVICE_1_HOST}" in caplog.text diff --git a/tests/components/vodafone_station/test_device_tracker.py b/tests/components/vodafone_station/test_device_tracker.py index e172fa76de5..2c8c2065510 100644 --- a/tests/components/vodafone_station/test_device_tracker.py +++ b/tests/components/vodafone_station/test_device_tracker.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.vodafone_station.const import SCAN_INTERVAL from homeassistant.components.vodafone_station.coordinator import CONSIDER_HOME_SECONDS @@ -47,8 +47,7 @@ async def test_consider_home( device_tracker = f"device_tracker.{DEVICE_1_HOST}" - state = hass.states.get(device_tracker) - assert state + assert (state := hass.states.get(device_tracker)) assert state.state == STATE_HOME mock_vodafone_station_router.get_devices_data.return_value[ @@ -59,6 +58,5 @@ async def test_consider_home( 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 := hass.states.get(device_tracker)) 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 5a4a46ce693..fa74292bcbc 100644 --- a/tests/components/vodafone_station/test_diagnostics.py +++ b/tests/components/vodafone_station/test_diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/vodafone_station/test_init.py b/tests/components/vodafone_station/test_init.py new file mode 100644 index 00000000000..053f0a95fe4 --- /dev/null +++ b/tests/components/vodafone_station/test_init.py @@ -0,0 +1,53 @@ +"""Tests for Vodafone Station init.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.device_tracker import CONF_CONSIDER_HOME +from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_reload_config_entry_with_options( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the the config entry is reloaded with options.""" + await setup_integration(hass, mock_config_entry) + + 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, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_CONSIDER_HOME: 37, + } + + +async def test_unload_entry( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading the config entry.""" + await setup_integration(hass, mock_config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert 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 + assert not hass.data.get(DOMAIN) diff --git a/tests/components/vodafone_station/test_sensor.py b/tests/components/vodafone_station/test_sensor.py index ddf97824c75..35c486a359f 100644 --- a/tests/components/vodafone_station/test_sensor.py +++ b/tests/components/vodafone_station/test_sensor.py @@ -6,7 +6,7 @@ from aiovodafone import CannotAuthenticate from aiovodafone.exceptions import AlreadyLogged, CannotConnect from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.vodafone_station.const import LINE_TYPES, SCAN_INTERVAL from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform @@ -55,8 +55,7 @@ async def test_active_connection_type( active_connection_entity = "sensor.vodafone_station_m123456789_active_connection" - state = hass.states.get(active_connection_entity) - assert state + assert (state := hass.states.get(active_connection_entity)) assert state.state == STATE_UNKNOWN mock_vodafone_station_router.get_sensor_data.return_value[connection_type] = ( @@ -67,8 +66,7 @@ async def test_active_connection_type( 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 := hass.states.get(active_connection_entity)) assert state.state == LINE_TYPES[index] @@ -85,8 +83,7 @@ async def test_uptime( 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 := hass.states.get(uptime_entity)) assert state.state == uptime mock_vodafone_station_router.get_sensor_data.return_value["sys_uptime"] = "12:17:23" @@ -95,8 +92,7 @@ async def test_uptime( 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 := hass.states.get(uptime_entity)) assert state.state == uptime @@ -124,6 +120,5 @@ async def test_coordinator_client_connector_error( 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 := hass.states.get("sensor.vodafone_station_m123456789_uptime")) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 776c0ac153a..e6a30d7fac2 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -200,7 +200,7 @@ async def test_service_say_error( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA @@ -234,7 +234,7 @@ async def test_service_say_timeout( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA @@ -273,7 +273,7 @@ async def test_service_say_error_msg( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA diff --git a/tests/components/voip/test_assist_satellite.py b/tests/components/voip/test_assist_satellite.py new file mode 100644 index 00000000000..f3e2611631e --- /dev/null +++ b/tests/components/voip/test_assist_satellite.py @@ -0,0 +1,62 @@ +"""Test the Assist Satellite platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.voip.devices import VoIPDevice +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent as intent_helper + + +@pytest.mark.parametrize( + ("intent_args", "message"), + [ + ( + {}, + "0:02:00 timer finished", + ), + ( + {"name": {"value": "pizza"}}, + "pizza finished", + ), + ], +) +async def test_timer_events( + hass: HomeAssistant, voip_device: VoIPDevice, intent_args: dict, message: str +) -> None: + """Test for timer events.""" + + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_START_TIMER, + { + "minutes": {"value": 2}, + } + | intent_args, + device_id=voip_device.device_id, + ) + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._resolve_announcement_media_id", + ) as mock_resolve, + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite.async_announce", + ) as mock_announce, + ): + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_DECREASE_TIMER, + { + "minutes": {"value": 2}, + }, + device_id=voip_device.device_id, + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_resolve.mock_calls) == 1 + assert len(mock_announce.mock_calls) == 1 + assert mock_resolve.mock_calls[0][1][0] == message diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 3e3e5337417..364c4d3dd5a 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -22,10 +22,13 @@ from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.components.voip.voip import PreRecordMessageProtocol, make_protocol from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import EntityComponent from homeassistant.setup import async_setup_component +from tests.components.tts.common import MockResultStream + _ONE_SECOND = 16000 * 2 # 16Khz 16-bit _MEDIA_ID = "12345" @@ -35,12 +38,12 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> None: """Mock the TTS cache dir with empty dir.""" -def _empty_wav() -> bytes: +def _empty_wav(framerate=16000) -> bytes: """Return bytes of an empty WAV file.""" with io.BytesIO() as wav_io: wav_file: wave.Wave_write = wave.open(wav_io, "wb") with wav_file: - wav_file.setframerate(16000) + wav_file.setframerate(framerate) wav_file.setsampwidth(2) wav_file.setnchannels(1) @@ -123,7 +126,7 @@ async def test_calls_not_allowed( await done.wait() assert sum(played_audio_bytes) > 0 - assert played_audio_bytes == snapshot() + assert played_audio_bytes == snapshot async def test_pipeline_not_found( @@ -304,10 +307,11 @@ async def test_pipeline( assert satellite.state == AssistSatelliteState.RESPONDING # Proceed with media output + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) @@ -323,28 +327,16 @@ async def test_pipeline( original_tts_response_finished() done.set() - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - assert media_source_id == _MEDIA_ID - return ("wav", _empty_wav()) - with ( patch( "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), patch.object(satellite, "tts_response_finished", tts_response_finished), ): satellite._tones = Tones(0) - satellite.transport = Mock() + satellite.connection_made(Mock()) - satellite.connection_made(satellite.transport) assert satellite.state == AssistSatelliteState.IDLE # Ensure audio queue is cleared before pipeline starts @@ -454,10 +446,11 @@ async def test_tts_timeout( ) # Proceed with media output + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) @@ -471,28 +464,15 @@ async def test_tts_timeout( # Block here to force a timeout in _send_tts await asyncio.sleep(2) - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - # Should time out immediately - return ("wav", _empty_wav()) - - with ( - patch( - "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, ): satellite._tts_extra_timeout = 0.001 for tone in Tones: satellite._tone_bytes[tone] = tone_bytes - satellite.transport = Mock() + satellite.connection_made(Mock()) satellite.send_audio = Mock() original_send_tts = satellite._send_tts @@ -530,6 +510,7 @@ async def test_tts_wrong_extension( assert await async_setup_component(hass, "voip", {}) satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + satellite.addr = ("192.168.1.1", 12345) assert isinstance(satellite, VoipAssistSatellite) done = asyncio.Event() @@ -565,32 +546,19 @@ async def test_tts_wrong_extension( ) # Proceed with media output + # Should fail because it's not "wav" + mock_tts_result_stream = MockResultStream(hass, "mp3", b"") event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - # Should fail because it's not "wav" - return ("mp3", b"") - - with ( - patch( - "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, ): - satellite.transport = Mock() - original_send_tts = satellite._send_tts async def send_tts(*args, **kwargs): @@ -602,6 +570,8 @@ async def test_tts_wrong_extension( satellite._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + satellite.connection_made(Mock()) + # silence satellite.on_chunk(bytes(_ONE_SECOND)) @@ -609,10 +579,18 @@ async def test_tts_wrong_extension( satellite.on_chunk(bytes([255] * _ONE_SECOND * 2)) # silence (assumes relaxed VAD sensitivity) - satellite.on_chunk(bytes(_ONE_SECOND * 4)) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream - async with asyncio.timeout(1): + async with asyncio.timeout(3): await done.wait() @@ -625,6 +603,7 @@ async def test_tts_wrong_wav_format( assert await async_setup_component(hass, "voip", {}) satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + satellite.addr = ("192.168.1.1", 12345) assert isinstance(satellite, VoipAssistSatellite) done = asyncio.Event() @@ -660,39 +639,19 @@ async def test_tts_wrong_wav_format( ) # Proceed with media output + # Should fail because it's not 16Khz + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav(22050)) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) - async def async_get_media_source_audio( - hass: HomeAssistant, - media_source_id: str, - ) -> tuple[str, bytes]: - # Should fail because it's not 16Khz, 16-bit mono - with io.BytesIO() as wav_io: - wav_file: wave.Wave_write = wave.open(wav_io, "wb") - with wav_file: - wav_file.setframerate(22050) - wav_file.setsampwidth(2) - wav_file.setnchannels(2) - - return ("wav", wav_io.getvalue()) - - with ( - patch( - "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", - new=async_get_media_source_audio, - ), + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, ): - satellite.transport = Mock() - original_send_tts = satellite._send_tts async def send_tts(*args, **kwargs): @@ -704,6 +663,8 @@ async def test_tts_wrong_wav_format( satellite._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + satellite.connection_made(Mock()) + # silence satellite.on_chunk(bytes(_ONE_SECOND)) @@ -711,10 +672,18 @@ async def test_tts_wrong_wav_format( satellite.on_chunk(bytes([255] * _ONE_SECOND * 2)) # silence (assumes relaxed VAD sensitivity) - satellite.on_chunk(bytes(_ONE_SECOND * 4)) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream - async with asyncio.timeout(1): + async with asyncio.timeout(3): await done.wait() @@ -727,6 +696,7 @@ async def test_empty_tts_output( assert await async_setup_component(hass, "voip", {}) satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + satellite.addr = ("192.168.1.1", 12345) assert isinstance(satellite, VoipAssistSatellite) async def async_pipeline_from_audio_stream(*args, **kwargs): @@ -776,7 +746,7 @@ async def test_empty_tts_output( "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", ) as mock_send_tts, ): - satellite.transport = Mock() + satellite.connection_made(Mock()) # silence satellite.on_chunk(bytes(_ONE_SECOND)) @@ -785,10 +755,18 @@ async def test_empty_tts_output( satellite.on_chunk(bytes([255] * _ONE_SECOND * 2)) # silence (assumes relaxed VAD sensitivity) - satellite.on_chunk(bytes(_ONE_SECOND * 4)) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to finish - async with asyncio.timeout(1): + async with asyncio.timeout(2): await satellite._tts_done.wait() mock_send_tts.assert_not_called() @@ -833,7 +811,7 @@ async def test_pipeline_error( ), ): satellite._tones = Tones.ERROR - satellite.transport = Mock() + satellite.connection_made(Mock()) satellite._async_send_audio = AsyncMock(side_effect=async_send_audio) # type: ignore[method-assign] satellite.on_chunk(bytes(_ONE_SECOND)) @@ -843,7 +821,7 @@ async def test_pipeline_error( await done.wait() assert sum(played_audio_bytes) > 0 - assert played_audio_bytes == snapshot() + assert played_audio_bytes == snapshot @pytest.mark.usefixtures("socket_enabled") @@ -862,9 +840,24 @@ async def test_announce( & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE ) + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + "assist_satellite", + "announce", + service_data={"media_id": "http://example.com"}, + blocking=True, + target={ + "entity_id": satellite.entity_id, + }, + ) + assert err.value.translation_domain == "voip" + assert err.value.translation_key == "non_tts_announcement" + + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -878,19 +871,25 @@ async def test_announce( "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", ) as mock_send_tts, ): - satellite.transport = Mock() announce_task = hass.async_create_background_task( satellite.async_announce(announcement), "voip_announce" ) await asyncio.sleep(0) + satellite.connection_made(Mock()) mock_protocol.outgoing_call.assert_called_once() # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - async with asyncio.timeout(1): + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(2): await announce_task - mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + mock_send_tts.assert_called_once_with( + mock_tts_result_stream, wait_for_tone=False + ) @pytest.mark.usefixtures("socket_enabled") @@ -909,9 +908,11 @@ async def test_voip_id_is_ip_address( & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE ) + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -926,11 +927,11 @@ async def test_voip_id_is_ip_address( "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", ) as mock_send_tts, ): - satellite.transport = Mock() announce_task = hass.async_create_background_task( satellite.async_announce(announcement), "voip_announce" ) await asyncio.sleep(0) + satellite.connection_made(Mock()) mock_protocol.outgoing_call.assert_called_once() assert ( mock_protocol.outgoing_call.call_args.kwargs["destination"].host @@ -939,10 +940,16 @@ async def test_voip_id_is_ip_address( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - async with asyncio.timeout(1): + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(2): await announce_task - mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + mock_send_tts.assert_called_once_with( + mock_tts_result_stream, wait_for_tone=False + ) @pytest.mark.usefixtures("socket_enabled") @@ -961,9 +968,11 @@ async def test_announce_timeout( & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE ) + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -980,7 +989,7 @@ async def test_announce_timeout( 0.01, ), ): - satellite.transport = Mock() + satellite.connection_made(Mock()) with pytest.raises(TimeoutError): await satellite.async_announce(announcement) @@ -1001,9 +1010,11 @@ async def test_start_conversation( & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION ) + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token=mock_tts_result_stream.token, original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -1041,10 +1052,11 @@ async def test_start_conversation( ) # Proceed with media output + mock_tts_result_stream = MockResultStream(hass, "wav", _empty_wav()) event_callback( assist_pipeline.PipelineEvent( type=assist_pipeline.PipelineEventType.TTS_END, - data={"tts_output": {"media_id": _MEDIA_ID}}, + data={"tts_output": {"token": mock_tts_result_stream.token}}, ) ) @@ -1064,7 +1076,7 @@ async def test_start_conversation( new=async_pipeline_from_audio_stream, ), ): - satellite.transport = Mock() + satellite.connection_made(Mock()) conversation_task = hass.async_create_background_task( satellite.async_start_conversation(announcement), "voip_start_conversation" ) @@ -1073,16 +1085,20 @@ async def test_start_conversation( # Trigger announcement and wait for it to finish satellite.on_chunk(bytes(_ONE_SECOND)) - async with asyncio.timeout(1): + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(2): await tts_sent.wait() - tts_sent.clear() - # Trigger pipeline satellite.on_chunk(bytes(_ONE_SECOND)) - async with asyncio.timeout(1): - # Wait for TTS - await tts_sent.wait() + await asyncio.sleep(0.2) + satellite.on_chunk(bytes(_ONE_SECOND)) + await asyncio.sleep(3) + async with asyncio.timeout(3): + # Wait for Conversation end await conversation_task @@ -1095,79 +1111,35 @@ async def test_start_conversation_user_doesnt_pick_up( """Test start conversation when the user doesn't pick up.""" assert await async_setup_component(hass, "voip", {}) - pipeline = assist_pipeline.Pipeline( - conversation_engine="test engine", - conversation_language="en", - language="en", - name="test pipeline", - stt_engine="test stt", - stt_language="en", - tts_engine="test tts", - tts_language="en", - tts_voice=None, - wake_word_entity=None, - wake_word_id=None, - ) - satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + satellite.addr = ("192.168.1.1", 12345) assert isinstance(satellite, VoipAssistSatellite) assert ( satellite.supported_features & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION ) - # Protocol has already been mocked, but "outgoing_call" is not async + # Protocol has already been mocked, but "outgoing_call" and "cancel_call" are not async mock_protocol: AsyncMock = hass.data[DOMAIN].protocol mock_protocol.outgoing_call = Mock() + mock_protocol.cancel_call = Mock() - pipeline_started = asyncio.Event() - - async def async_pipeline_from_audio_stream( - hass: HomeAssistant, - context: Context, - *args, - conversation_extra_system_prompt: str | None = None, - **kwargs, - ): - # System prompt should be not be set due to timeout (user not picking up) - assert conversation_extra_system_prompt is None - - pipeline_started.set() + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + tts_token="test-token", + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + # Very short timeout which will trigger because we don't send any audio in with ( patch( - "homeassistant.components.assist_satellite.entity.async_get_pipeline", - return_value=pipeline, - ), - patch( - "homeassistant.components.voip.assist_satellite.VoipAssistSatellite.async_start_conversation", - side_effect=TimeoutError, - ), - patch( - "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", - return_value="test media id", + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_RING_TIMEOUT", + 0.1, ), ): - satellite.transport = Mock() + satellite.connection_made(Mock()) - # Error should clear system prompt with pytest.raises(TimeoutError): - await hass.services.async_call( - assist_satellite.DOMAIN, - "start_conversation", - { - "entity_id": satellite.entity_id, - "start_message": "test announcement", - "extra_system_prompt": "test prompt", - }, - blocking=True, - ) - - # Trigger a pipeline so we can check if the system prompt was cleared - satellite.on_chunk(bytes(_ONE_SECOND)) - async with asyncio.timeout(1): - await pipeline_started.wait() + await satellite.async_start_conversation(announcement) diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 0cd2aa67233..7fd8e214240 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import patch from aiowaqi import WAQIAirQuality, WAQIError import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.waqi.const import DOMAIN diff --git a/tests/components/watergate/snapshots/test_event.ambr b/tests/components/watergate/snapshots/test_event.ambr new file mode 100644 index 00000000000..97f453697ca --- /dev/null +++ b/tests/components/watergate/snapshots/test_event.ambr @@ -0,0 +1,111 @@ +# serializer version: 1 +# name: test_event[event.sonic_duration_auto_shut_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'duration_threshold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sonic_duration_auto_shut_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': 'Duration auto shut-off', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_shut_off_duration', + 'unique_id': 'a63182948ce2896a.auto_shut_off_duration', + 'unit_of_measurement': None, + }) +# --- +# name: test_event[event.sonic_duration_auto_shut_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'duration_threshold', + ]), + 'friendly_name': 'Sonic Duration auto shut-off', + }), + 'context': , + 'entity_id': 'event.sonic_duration_auto_shut_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_event[event.sonic_volume_auto_shut_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'volume_threshold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sonic_volume_auto_shut_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': 'Volume auto shut-off', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_shut_off_volume', + 'unique_id': 'a63182948ce2896a.auto_shut_off_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_event[event.sonic_volume_auto_shut_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'volume_threshold', + ]), + 'friendly_name': 'Sonic Volume auto shut-off', + }), + 'context': , + 'entity_id': 'event.sonic_volume_auto_shut_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/watergate/test_event.py b/tests/components/watergate/test_event.py new file mode 100644 index 00000000000..6997c3f1fdf --- /dev/null +++ b/tests/components/watergate/test_event.py @@ -0,0 +1,84 @@ +"""Tests for the Watergate event entity platform.""" + +from collections.abc import Generator + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.typing import StateType + +from . import init_integration +from .const import 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_event( + 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.EVENT]): + await init_integration(hass, mock_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "event_type"), + [ + ("sonic_volume_auto_shut_off", "volume_threshold"), + ("sonic_duration_auto_shut_off", "duration_threshold"), + ], +) +async def test_auto_shut_off_webhook( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], + entity_id: str, + event_type: str, +) -> 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(f"event.{entity_id}") + assert state.state == str(expected_state) + + assert_state(entity_id, "unknown") + + telemetry_change_data = { + "type": "auto-shut-off-report", + "data": { + "type": event_type, + "volume": 1500, + "duration": 30, + "timestamp": 1730148016, + }, + } + 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() + + def assert_extra_state( + entity_id: str, attribute: str, expected_attribute: StateType + ): + attributes = hass.states.get(f"event.{entity_id}").attributes + assert attributes.get(attribute) == expected_attribute + + assert_extra_state(entity_id, "event_type", event_type) + assert_extra_state(entity_id, "volume", 1500) + assert_extra_state(entity_id, "duration", 30) diff --git a/tests/components/watergate/test_sensor.py b/tests/components/watergate/test_sensor.py index 78e375857ed..0bf883a1955 100644 --- a/tests/components/watergate/test_sensor.py +++ b/tests/components/watergate/test_sensor.py @@ -1,4 +1,4 @@ -"""Tests for the Watergate valve platform.""" +"""Tests for the Watergate sensor platform.""" from collections.abc import Generator diff --git a/tests/components/watttime/test_diagnostics.py b/tests/components/watttime/test_diagnostics.py index f4465a44d26..ff697d5119e 100644 --- a/tests/components/watttime/test_diagnostics.py +++ b/tests/components/watttime/test_diagnostics.py @@ -1,6 +1,6 @@ """Test WattTime diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant diff --git a/tests/components/weatherflow_cloud/test_sensor.py b/tests/components/weatherflow_cloud/test_sensor.py index 4d6ff0c8c9f..13ac3910571 100644 --- a/tests/components/weatherflow_cloud/test_sensor.py +++ b/tests/components/weatherflow_cloud/test_sensor.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from weatherflow4py.models.rest.observation import ObservationStationREST from homeassistant.components.weatherflow_cloud import DOMAIN diff --git a/tests/components/weatherflow_cloud/test_weather.py b/tests/components/weatherflow_cloud/test_weather.py index 04da96df423..8da67b27060 100644 --- a/tests/components/weatherflow_cloud/test_weather.py +++ b/tests/components/weatherflow_cloud/test_weather.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/webdav/__init__.py b/tests/components/webdav/__init__.py index 33e0222fb34..3b901bdd308 100644 --- a/tests/components/webdav/__init__.py +++ b/tests/components/webdav/__init__.py @@ -1 +1,14 @@ """Tests for the WebDAV 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 WebDAV 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/webdav/conftest.py b/tests/components/webdav/conftest.py index 4fdd6fb7870..5fa972e5fae 100644 --- a/tests/components/webdav/conftest.py +++ b/tests/components/webdav/conftest.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.webdav.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME -from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES +from .const import BACKUP_METADATA, MOCK_LIST_FILES from tests.common import MockConfigEntry @@ -58,8 +58,9 @@ def mock_webdav_client() -> Generator[AsyncMock]: mock = mock_webdav_client.return_value mock.check.return_value = True mock.mkdir.return_value = True - mock.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES + mock.list_files.return_value = MOCK_LIST_FILES mock.download_iter.side_effect = _download_mock mock.upload_iter.return_value = None mock.clean.return_value = None + mock.move.return_value = None yield mock diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py index 52cad9a163b..0147826a777 100644 --- a/tests/components/webdav/const.py +++ b/tests/components/webdav/const.py @@ -1,7 +1,5 @@ """Constants for WebDAV tests.""" -from aiowebdav2 import Property - BACKUP_METADATA = { "addons": [], "backup_id": "23e64aec", @@ -16,18 +14,7 @@ BACKUP_METADATA = { "size": 34519040, } -MOCK_LIST_WITH_PROPERTIES = { - "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], - "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ - Property( - namespace="homeassistant", - name="backup_id", - value="23e64aec", - ), - Property( - namespace="homeassistant", - name="metadata_version", - value="1", - ), - ], -} +MOCK_LIST_FILES = [ + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar", + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json", +] diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index c20e73cc786..ca20467484f 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -6,7 +6,6 @@ from collections.abc import AsyncGenerator from io import StringIO from unittest.mock import Mock, patch -from aiowebdav2 import Property from aiowebdav2.exceptions import UnauthorizedError, WebDavError import pytest @@ -17,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component -from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES +from .const import BACKUP_METADATA from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -184,7 +183,6 @@ async def test_agents_upload( assert resp.status == 201 assert webdav_client.upload_iter.call_count == 2 - assert webdav_client.set_property_batch.call_count == 1 async def test_agents_download( @@ -211,7 +209,7 @@ async def test_error_on_agents_download( """Test we get not found on a not existing backup on download.""" client = await hass_client() backup_id = BACKUP_METADATA["backup_id"] - webdav_client.list_with_properties.side_effect = [MOCK_LIST_WITH_PROPERTIES, {}] + webdav_client.list_files.return_value = [] resp = await client.get( f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" @@ -262,7 +260,7 @@ async def test_agents_delete_not_found_does_not_throw( webdav_client: AsyncMock, ) -> None: """Test agent delete backup.""" - webdav_client.list_with_properties.return_value = {} + webdav_client.list_files.return_value = {} client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -283,7 +281,7 @@ async def test_agents_backup_not_found( webdav_client: AsyncMock, ) -> None: """Test backup not found.""" - webdav_client.list_with_properties.return_value = [] + webdav_client.list_files.return_value = [] backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -300,7 +298,7 @@ async def test_raises_on_403( mock_config_entry: MockConfigEntry, ) -> None: """Test we raise on 403.""" - webdav_client.list_with_properties.side_effect = UnauthorizedError( + webdav_client.list_files.side_effect = UnauthorizedError( "https://webdav.example.com" ) backup_id = BACKUP_METADATA["backup_id"] @@ -324,30 +322,3 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: remove_listener() assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None - - -async def test_metadata_misses_backup_id( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - webdav_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test getting a backup when metadata has backup id property.""" - MOCK_LIST_WITH_PROPERTIES[ - "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json" - ] = [ - Property( - namespace="homeassistant", - name="metadata_version", - value="1", - ) - ] - webdav_client.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES - - backup_id = BACKUP_METADATA["backup_id"] - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["backup"] is None diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py index eb887edb1a1..9204e6eadab 100644 --- a/tests/components/webdav/test_config_flow.py +++ b/tests/components/webdav/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import UnauthorizedError +from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError import pytest from homeassistant import config_entries @@ -86,6 +86,7 @@ async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: ("exception", "expected_error"), [ (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (MethodNotSupportedError("check", "https://webdav.demo"), "invalid_method"), (Exception("Unexpected error"), "unknown"), ], ) diff --git a/tests/components/webdav/test_init.py b/tests/components/webdav/test_init.py new file mode 100644 index 00000000000..124a644fa93 --- /dev/null +++ b/tests/components/webdav/test_init.py @@ -0,0 +1,112 @@ +"""Test WebDAV component setup.""" + +from unittest.mock import AsyncMock + +from aiowebdav2.exceptions import WebDavError +import pytest + +from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_migrate_wrong_path( + hass: HomeAssistant, webdav_client: AsyncMock +) -> None: + """Test migration of wrong encoded folder path.""" + webdav_client.list_with_properties.return_value = [ + {"/wrong%20path": []}, + ] + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/wrong path", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + webdav_client.move.assert_called_once_with("/wrong%20path", "/wrong path") + + +@pytest.mark.parametrize( + ("expected_path", "remote_path_check"), + [ + ( + "/correct path", + False, + ), # remote_path_check is False as /correct%20path is not there + ("/", True), + ("/folder_with_underscores", True), + ], +) +async def test_migrate_non_wrong_path( + hass: HomeAssistant, + webdav_client: AsyncMock, + expected_path: str, + remote_path_check: bool, +) -> None: + """Test no migration of correct folder path.""" + webdav_client.list_with_properties.return_value = [ + {expected_path: []}, + ] + # first return is used to check the connectivity + # second is used in the migration to determine if wrong quoted path is there + webdav_client.check.side_effect = [True, remote_path_check] + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: expected_path, + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + + await setup_integration(hass, config_entry) + + webdav_client.move.assert_not_called() + + +async def test_migrate_error( + hass: HomeAssistant, + webdav_client: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test migration of wrong encoded folder path with error.""" + webdav_client.list_with_properties.return_value = [ + {"/wrong%20path": []}, + ] + webdav_client.move.side_effect = WebDavError("Failed to move") + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/wrong path", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + 'Failed to migrate wrong encoded folder "/wrong%20path" to "/wrong path"' + in caplog.text + ) diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index 15ec1b15ee5..20fe5024962 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -17,10 +17,12 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture -def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: +async def mock_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Create http client for webhooks.""" - hass.loop.run_until_complete(async_setup_component(hass, "webhook", {})) - return hass.loop.run_until_complete(hass_client()) + await async_setup_component(hass, "webhook", {}) + return await hass_client() async def test_unregistering_webhook(hass: HomeAssistant, mock_client) -> None: diff --git a/tests/components/webmin/snapshots/test_sensor.ambr b/tests/components/webmin/snapshots/test_sensor.ambr index a2068f662ba..1af5fe46b5c 100644 --- a/tests/components/webmin/snapshots/test_sensor.ambr +++ b/tests/components/webmin/snapshots/test_sensor.ambr @@ -1451,7 +1451,7 @@ 'state': '8794.3125', }) # --- -# name: test_sensor[sensor.192_168_1_1_load_15m-entry] +# name: test_sensor[sensor.192_168_1_1_load_15_min-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1466,7 +1466,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.192_168_1_1_load_15m', + 'entity_id': 'sensor.192_168_1_1_load_15_min', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1478,7 +1478,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Load (15m)', + 'original_name': 'Load (15 min)', 'platform': 'webmin', 'previous_unique_id': None, 'supported_features': 0, @@ -1487,21 +1487,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.192_168_1_1_load_15m-state] +# name: test_sensor[sensor.192_168_1_1_load_15_min-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '192.168.1.1 Load (15m)', + 'friendly_name': '192.168.1.1 Load (15 min)', 'state_class': , }), 'context': , - 'entity_id': 'sensor.192_168_1_1_load_15m', + 'entity_id': 'sensor.192_168_1_1_load_15_min', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1.37', }) # --- -# name: test_sensor[sensor.192_168_1_1_load_1m-entry] +# name: test_sensor[sensor.192_168_1_1_load_1_min-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1516,7 +1516,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.192_168_1_1_load_1m', + 'entity_id': 'sensor.192_168_1_1_load_1_min', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1528,7 +1528,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Load (1m)', + 'original_name': 'Load (1 min)', 'platform': 'webmin', 'previous_unique_id': None, 'supported_features': 0, @@ -1537,21 +1537,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.192_168_1_1_load_1m-state] +# name: test_sensor[sensor.192_168_1_1_load_1_min-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '192.168.1.1 Load (1m)', + 'friendly_name': '192.168.1.1 Load (1 min)', 'state_class': , }), 'context': , - 'entity_id': 'sensor.192_168_1_1_load_1m', + 'entity_id': 'sensor.192_168_1_1_load_1_min', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1.29', }) # --- -# name: test_sensor[sensor.192_168_1_1_load_5m-entry] +# name: test_sensor[sensor.192_168_1_1_load_5_min-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1566,7 +1566,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.192_168_1_1_load_5m', + 'entity_id': 'sensor.192_168_1_1_load_5_min', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1578,7 +1578,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Load (5m)', + 'original_name': 'Load (5 min)', 'platform': 'webmin', 'previous_unique_id': None, 'supported_features': 0, @@ -1587,14 +1587,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.192_168_1_1_load_5m-state] +# name: test_sensor[sensor.192_168_1_1_load_5_min-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '192.168.1.1 Load (5m)', + 'friendly_name': '192.168.1.1 Load (5 min)', 'state_class': , }), 'context': , - 'entity_id': 'sensor.192_168_1_1_load_5m', + 'entity_id': 'sensor.192_168_1_1_load_5_min', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c0114cde42b..4ca2098550b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -26,15 +26,17 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_state_change_event from homeassistant.loader import async_get_integration -from homeassistant.setup import async_setup_component +from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from homeassistant.util.json import json_loads from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockModule, MockUser, async_mock_service, + mock_integration, mock_platform, ) from tests.typing import ( @@ -106,9 +108,8 @@ async def test_fire_event( hass.bus.async_listen_once("event_type_test", event_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "fire_event", "event_type": "event_type_test", "event_data": {"hello": "world"}, @@ -116,7 +117,6 @@ async def test_fire_event( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -137,16 +137,14 @@ async def test_fire_event_without_data( hass.bus.async_listen_once("event_type_test", event_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "fire_event", "event_type": "event_type_test", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -162,9 +160,8 @@ async def test_call_service( """Test call service command.""" calls = async_mock_service(hass, "domain_test", "test_service") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -173,7 +170,6 @@ async def test_call_service( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -191,9 +187,8 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N hass.services.async_register( "domain_test", "test_service_with_no_response", lambda x: None ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 8, "type": "call_service", "domain": "domain_test", "service": "test_service_with_no_response", @@ -203,7 +198,6 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N ) msg = await websocket_client.receive_json() - assert msg["id"] == 8 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "service_validation_error" @@ -225,9 +219,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = {"foo": "bar"} - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 4, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -237,7 +230,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 4 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["response"] == {"foo": "bar"} @@ -256,9 +248,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -267,7 +258,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -286,9 +276,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "homeassistant", "service": "test_service", @@ -296,7 +285,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -315,9 +303,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "homeassistant", "service": "restart", @@ -325,7 +312,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -346,9 +332,8 @@ async def test_call_service_target( """Test call service command with target.""" calls = async_mock_service(hass, "domain_test", "test_service") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -361,7 +346,6 @@ async def test_call_service_target( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -382,9 +366,8 @@ async def test_call_service_target_template( hass: HomeAssistant, websocket_client ) -> None: """Test call service command with target does not allow template.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -396,7 +379,6 @@ async def test_call_service_target_template( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT @@ -406,9 +388,8 @@ async def test_call_service_not_found( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test call service command.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -417,7 +398,6 @@ async def test_call_service_not_found( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_NOT_FOUND @@ -440,9 +420,8 @@ async def test_call_service_child_not_found( hass.services.async_register("domain_test", "test_service", serv_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -451,7 +430,6 @@ async def test_call_service_child_not_found( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_HOME_ASSISTANT_ERROR @@ -492,9 +470,8 @@ async def test_call_service_schema_validation_error( schema=service_schema, ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -502,14 +479,12 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -517,14 +492,12 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -532,7 +505,6 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT @@ -573,9 +545,8 @@ async def test_call_service_error( hass.services.async_register("domain_test", "unknown_error", unknown_error_call) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "ha_error", @@ -583,7 +554,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "home_assistant_error" @@ -592,9 +562,8 @@ async def test_call_service_error( assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "domain_test", "service": "service_error", @@ -602,7 +571,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "service_validation_error" @@ -611,9 +579,8 @@ async def test_call_service_error( assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "domain_test", "service": "unknown_error", @@ -621,7 +588,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "unknown_error" @@ -634,12 +600,12 @@ async def test_subscribe_unsubscribe_events( """Test subscribe/unsubscribe events command.""" init_count = sum(hass.bus.async_listeners().values()) - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "test_event"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "test_event"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -653,7 +619,7 @@ async def test_subscribe_unsubscribe_events( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] @@ -661,12 +627,11 @@ async def test_subscribe_unsubscribe_events( assert event["data"] == {"hello": "world"} assert event["origin"] == "LOCAL" - await websocket_client.send_json( - {"id": 6, "type": "unsubscribe_events", "subscription": 5} + await websocket_client.send_json_auto_id( + {"type": "unsubscribe_events", "subscription": subscription} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -681,10 +646,9 @@ async def test_get_states( hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bye", "universe") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -711,10 +675,9 @@ async def test_get_config( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test get_config command.""" - await websocket_client.send_json({"id": 5, "type": "get_config"}) + await websocket_client.send_json_auto_id({"type": "get_config"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -737,10 +700,9 @@ async def test_get_config( async def test_ping(websocket_client: MockHAClientWebSocket) -> None: """Test get_panels command.""" - await websocket_client.send_json({"id": 5, "type": "ping"}) + await websocket_client.send_json_auto_id({"type": "ping"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == "pong" @@ -792,8 +754,8 @@ async def test_subscribe_requires_admin( ) -> None: """Test subscribing events without being admin.""" hass_admin_user.groups = [] - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "test_event"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "test_event"} ) msg = await websocket_client.receive_json() @@ -809,10 +771,9 @@ async def test_states_filters_visible( hass_admin_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) hass.states.async_set("test.entity", "hello") hass.states.async_set("test.not_visible_entity", "invisible") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -828,13 +789,12 @@ async def test_get_states_not_allows_nan( hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) hass.states.async_set("greeting.bye", "universe") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) bad = dict(hass.states.get("greeting.bad").as_dict()) bad["attributes"] = dict(bad["attributes"]) bad["attributes"]["hello"] = None msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [ @@ -852,22 +812,21 @@ async def test_subscribe_unsubscribe_events_whitelist( """Test subscribe/unsubscribe events on whitelist.""" hass_admin_user.groups = [] - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "not-in-whitelist"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "not-in-whitelist"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "unauthorized" - await websocket_client.send_json( - {"id": 6, "type": "subscribe_events", "event_type": "themes_updated"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "themes_updated"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 + themes_updated_subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -876,7 +835,7 @@ async def test_subscribe_unsubscribe_events_whitelist( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 6 + assert msg["id"] == themes_updated_subscription assert msg["type"] == "event" event = msg["event"] assert event["event_type"] == "themes_updated" @@ -892,12 +851,12 @@ async def test_subscribe_unsubscribe_events_state_changed( hass_admin_user.groups = [] hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) - await websocket_client.send_json( - {"id": 7, "type": "subscribe_events", "event_type": "state_changed"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "state_changed"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -905,7 +864,7 @@ async def test_subscribe_unsubscribe_events_state_changed( hass.states.async_set("light.permitted", "on") msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"]["event_type"] == "state_changed" assert msg["event"]["data"]["entity_id"] == "light.permitted" @@ -949,15 +908,15 @@ async def test_subscribe_entities_with_unserializable_state( } ) - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -971,7 +930,7 @@ async def test_subscribe_entities_with_unserializable_state( } hass.states.async_set("light.permitted", "on", {"effect": "help"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -988,7 +947,7 @@ async def test_subscribe_entities_with_unserializable_state( } hass.states.async_set("light.cannot_serialize", "on", {"effect": "help"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" # Order does not matter msg["event"]["c"]["light.cannot_serialize"]["-"]["a"] = set( @@ -1022,7 +981,7 @@ async def test_subscribe_entities_with_unserializable_state( {"color": "red", "cannot_serialize": CannotSerializeMe()}, ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "result" assert msg["error"] == { "code": "unknown_error", @@ -1052,15 +1011,15 @@ async def test_subscribe_unsubscribe_entities( hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) assert not hass_admin_user.is_admin - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert isinstance(msg["event"]["a"]["light.permitted"]["c"], str) assert msg["event"] == { @@ -1083,7 +1042,7 @@ async def test_subscribe_unsubscribe_entities( hass.states.async_set("light.permitted", "on", {"effect": "help", "color": "blue"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1115,7 +1074,7 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1148,7 +1107,7 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1180,12 +1139,12 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == {"r": ["light.permitted"]} msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -1219,17 +1178,17 @@ async def test_subscribe_unsubscribe_entities_specific_entities( } ) - await websocket_client.send_json( - {"id": 7, "type": "subscribe_entities", "entity_ids": ["light.permitted"]} + await websocket_client.send_json_auto_id( + {"type": "subscribe_entities", "entity_ids": ["light.permitted"]} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert isinstance(msg["event"]["a"]["light.permitted"]["c"], str) assert msg["event"] == { @@ -1247,7 +1206,7 @@ async def test_subscribe_unsubscribe_entities_specific_entities( hass.states.async_set("light.permitted", "on", {"color": "blue"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1271,17 +1230,17 @@ async def test_subscribe_unsubscribe_entities_with_filter( """Test subscribe/unsubscribe entities with an entity filter.""" hass.states.async_set("switch.not_included", "off") hass.states.async_set("light.include", "off") - await websocket_client.send_json( - {"id": 7, "type": "subscribe_entities", "include": {"domains": ["light"]}} + await websocket_client.send_json_auto_id( + {"type": "subscribe_entities", "include": {"domains": ["light"]}} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -1296,7 +1255,7 @@ async def test_subscribe_unsubscribe_entities_with_filter( hass.states.async_set("switch.not_included", "on") hass.states.async_set("light.include", "on") msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1317,21 +1276,20 @@ async def test_render_template_renders_template( """Test simple template is rendered and updated.""" hass.states.async_set("light.test", "on") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": "State is: {{ states('light.test') }}", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1346,7 +1304,7 @@ async def test_render_template_renders_template( hass.states.async_set("light.test", "off") msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1364,9 +1322,8 @@ async def test_render_template_with_timeout_and_variables( hass: HomeAssistant, websocket_client ) -> None: """Test a template with a timeout and variables renders without error.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "timeout": 10, "variables": {"test": {"value": "hello"}}, @@ -1375,12 +1332,12 @@ async def test_render_template_with_timeout_and_variables( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1400,21 +1357,20 @@ async def test_render_template_manual_entity_ids_no_longer_needed( """Test that updates to specified entity ids cause a template rerender.""" hass.states.async_set("light.test", "on") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": "State is: {{ states('light.test') }}", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1429,7 +1385,7 @@ async def test_render_template_manual_entity_ids_no_longer_needed( hass.states.async_set("light.test", "off") msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1523,9 +1479,8 @@ async def test_render_template_with_error( ) -> None: """Test a template with an error.""" caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "report_errors": True, @@ -1534,7 +1489,6 @@ async def test_render_template_with_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1596,9 +1550,8 @@ async def test_render_template_with_timeout_and_error( ) -> None: """Test a template with an error with a timeout.""" caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1608,7 +1561,6 @@ async def test_render_template_with_timeout_and_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1666,9 +1618,8 @@ async def test_render_template_strict_with_timeout_and_error( In this test report_errors is enabled. """ caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1679,7 +1630,6 @@ async def test_render_template_strict_with_timeout_and_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1729,9 +1679,8 @@ async def test_render_template_strict_with_timeout_and_error_2( In this test report_errors is disabled. """ caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1741,7 +1690,6 @@ async def test_render_template_strict_with_timeout_and_error_2( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1815,9 +1763,8 @@ async def test_render_template_error_in_template_code( In this test report_errors is enabled. """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "report_errors": True, @@ -1826,7 +1773,6 @@ async def test_render_template_error_in_template_code( for expected_event in expected_events_1: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1834,7 +1780,6 @@ async def test_render_template_error_in_template_code( for expected_event in expected_events_2: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1882,13 +1827,12 @@ async def test_render_template_error_in_template_code_2( In this test report_errors is disabled. """ - await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template} + await websocket_client.send_json_auto_id( + {"type": "render_template", "template": template} ) for expected_event in expected_events_1: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1896,7 +1840,6 @@ async def test_render_template_error_in_template_code_2( for expected_event in expected_events_2: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1924,9 +1867,8 @@ async def test_render_template_with_delayed_error( {% endif %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template_str, "report_errors": True, @@ -1935,7 +1877,7 @@ async def test_render_template_with_delayed_error( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -1943,7 +1885,7 @@ async def test_render_template_with_delayed_error( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1957,13 +1899,13 @@ async def test_render_template_with_delayed_error( } msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event["error"] == "'None' has no attribute 'state'" msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1994,9 +1936,8 @@ async def test_render_template_with_delayed_error_2( {% endif %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template_str, "report_errors": False, @@ -2005,7 +1946,7 @@ async def test_render_template_with_delayed_error_2( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2013,7 +1954,7 @@ async def test_render_template_with_delayed_error_2( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -2044,9 +1985,8 @@ async def test_render_template_with_timeout( {%- endfor %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "timeout": 0.000001, "template": slow_template_str, @@ -2054,7 +1994,6 @@ async def test_render_template_with_timeout( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR @@ -2066,12 +2005,11 @@ async def test_render_template_returns_with_match_all( hass: HomeAssistant, websocket_client ) -> None: """Test that a template that would match with all entities still return success.""" - await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": "State is: {{ 42 }}"} + await websocket_client.send_json_auto_id( + {"type": "render_template", "template": "State is: {{ 42 }}"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2083,10 +2021,9 @@ async def test_manifest_list( http = await async_get_integration(hass, "http") websocket_api = await async_get_integration(hass, "websocket_api") - await websocket_client.send_json({"id": 5, "type": "manifest/list"}) + await websocket_client.send_json_auto_id({"type": "manifest/list"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert sorted(msg["result"], key=lambda manifest: manifest["domain"]) == [ @@ -2101,13 +2038,12 @@ async def test_manifest_list_specific_integrations( """Test loading manifests for specific integrations.""" websocket_api = await async_get_integration(hass, "websocket_api") - await websocket_client.send_json( - {"id": 5, "type": "manifest/list", "integrations": ["hue", "websocket_api"]} + await websocket_client.send_json_auto_id( + {"type": "manifest/list", "integrations": ["hue", "websocket_api"]} ) hue = await async_get_integration(hass, "hue") msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert sorted(msg["result"], key=lambda manifest: manifest["domain"]) == [ @@ -2122,23 +2058,21 @@ async def test_manifest_get( """Test getting a manifest.""" hue = await async_get_integration(hass, "hue") - await websocket_client.send_json( - {"id": 6, "type": "manifest/get", "integration": "hue"} + await websocket_client.send_json_auto_id( + {"type": "manifest/get", "integration": "hue"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == hue.manifest # Non existing - await websocket_client.send_json( - {"id": 7, "type": "manifest/get", "integration": "non_existing"} + await websocket_client.send_json_auto_id( + {"type": "manifest/get", "integration": "non_existing"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "not_found" @@ -2157,10 +2091,9 @@ async def test_entity_source_admin( ) # Fetch all - await websocket_client.send_json({"id": 6, "type": "entity/source"}) + await websocket_client.send_json_auto_id({"type": "entity/source"}) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { @@ -2175,10 +2108,9 @@ async def test_entity_source_admin( ) # Fetch all - await websocket_client.send_json({"id": 10, "type": "entity/source"}) + await websocket_client.send_json_auto_id({"type": "entity/source"}) msg = await websocket_client.receive_json() - assert msg["id"] == 10 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { @@ -2192,9 +2124,8 @@ async def test_subscribe_trigger( """Test subscribing to a trigger.""" init_count = sum(hass.bus.async_listeners().values()) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "subscribe_trigger", "trigger": {"platform": "event", "event_type": "test_event"}, "variables": {"hello": "world"}, @@ -2202,7 +2133,6 @@ async def test_subscribe_trigger( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2218,7 +2148,6 @@ async def test_subscribe_trigger( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == "event" assert msg["event"]["context"]["id"] == context.id assert msg["event"]["variables"]["trigger"]["platform"] == "event" @@ -2229,12 +2158,11 @@ async def test_subscribe_trigger( assert event["data"] == {"hello": "world"} assert event["origin"] == "LOCAL" - await websocket_client.send_json( - {"id": 6, "type": "unsubscribe_events", "subscription": 5} + await websocket_client.send_json_auto_id( + {"type": "unsubscribe_events", "subscription": msg["id"]} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2248,9 +2176,8 @@ async def test_test_condition( """Test testing a condition.""" hass.states.async_set("hello.world", "paulus") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "test_condition", "condition": { "condition": "state", @@ -2262,14 +2189,12 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is True - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "test_condition", "condition": { "condition": "template", @@ -2280,14 +2205,12 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is True - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "test_condition", "condition": { "condition": "template", @@ -2298,7 +2221,6 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is False @@ -2312,9 +2234,8 @@ async def test_execute_script( hass, "domain_test", "test_service", response={"hello": "world"} ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "execute_script", "sequence": [ { @@ -2328,14 +2249,12 @@ async def test_execute_script( ) msg_no_var = await websocket_client.receive_json() - assert msg_no_var["id"] == 5 assert msg_no_var["type"] == const.TYPE_RESULT assert msg_no_var["success"] assert msg_no_var["result"]["response"] == {"hello": "world"} - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "execute_script", "sequence": { "service": "domain_test.test_service", @@ -2346,7 +2265,6 @@ async def test_execute_script( ) msg_var = await websocket_client.receive_json() - assert msg_var["id"] == 6 assert msg_var["type"] == const.TYPE_RESULT assert msg_var["success"] @@ -2403,9 +2321,8 @@ async def test_execute_script_err_localization( hass, "domain_test", "test_service", raise_exception=raise_exception ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "execute_script", "sequence": [ { @@ -2418,7 +2335,6 @@ async def test_execute_script_err_localization( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == err_code @@ -2522,12 +2438,12 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe bootstrap_integrations.""" - await websocket_client.send_json( - {"id": 7, "type": "subscribe_bootstrap_integrations"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_bootstrap_integrations"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2535,7 +2451,7 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, message) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == message @@ -2553,10 +2469,9 @@ async def test_integration_setup_info( "isy994": 12.8, }, ): - await websocket_client.send_json({"id": 7, "type": "integration/setup_info"}) + await websocket_client.send_json_auto_id({"type": "integration/setup_info"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [ @@ -2614,9 +2529,8 @@ async def test_validate_config_works( "state": "paulus", }, ( - "Unexpected value for condition: 'non_existing'. Expected and, device," - " not, numeric_state, or, state, sun, template, time, trigger, zone " - "@ data[0]" + "Invalid condition \"non_existing\" specified {'condition': " + "'non_existing', 'entity_id': 'hello.world', 'state': 'paulus'}" ), ), # Raises HomeAssistantError @@ -2855,12 +2769,7 @@ async def test_integration_descriptions( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - await ws_client.send_json( - { - "id": 1, - "type": "integration/descriptions", - } - ) + await ws_client.send_json_auto_id({"type": "integration/descriptions"}) response = await ws_client.receive_json() assert response["success"] @@ -2884,31 +2793,31 @@ async def test_subscribe_entities_chained_state_change( async_track_state_change_event(hass, ["light.permitted"], auto_off_listener) - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == {"a": {}} hass.states.async_set("light.permitted", "on") data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": {"light.permitted": {"a": {}, "c": ANY, "lc": ANY, "s": "on"}} } data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": {"light.permitted": {"+": {"c": ANY, "lc": ANY, "s": "off"}}} @@ -2916,3 +2825,83 @@ async def test_subscribe_entities_chained_state_change( await websocket_client.close() await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("domain", "result"), + [ + ("config", {"integration_loaded": True}), + ("non_existing_domain", {"integration_loaded": False}), + ], +) +async def test_wait_integration( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + result: dict[str, Any], +) -> None: + """Test we can get wait for an integration to load.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "integration/wait", "domain": domain}) + response = await ws_client.receive_json() + assert response == { + "id": ANY, + "result": result, + "success": True, + "type": "result", + } + + +async def test_wait_integration_startup( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test we can get wait for an integration to load during startup.""" + ws_client = await hass_ws_client(hass) + + setup_stall = asyncio.Event() + setup_started = asyncio.Event() + + async def mock_setup(hass: HomeAssistant, _) -> bool: + setup_started.set() + await setup_stall.wait() + return True + + mock_integration(hass, MockModule("test", async_setup=mock_setup)) + + # The integration is not loaded, and is also not scheduled to load + await ws_client.send_json_auto_id({"type": "integration/wait", "domain": "test"}) + response = await ws_client.receive_json() + assert response == { + "id": ANY, + "result": {"integration_loaded": False}, + "success": True, + "type": "result", + } + + # Mark the component as scheduled to be loaded + async_set_domains_to_be_loaded(hass, {"test"}) + + # Start loading the component, including its config entries + hass.async_create_task(async_setup_component(hass, "test", {})) + await setup_started.wait() + + # The component is not yet loaded + assert "test" not in hass.config.components + + # Allow setup to proceed + setup_stall.set() + + # The component is scheduled to load, this will block until the config entry is loaded + await ws_client.send_json_auto_id({"type": "integration/wait", "domain": "test"}) + response = await ws_client.receive_json() + assert response == { + "id": ANY, + "result": {"integration_loaded": True}, + "success": True, + "type": "result", + } + + # The component has been loaded + assert "test" in hass.config.components diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 03e30c11ee9..b4b11d9cf02 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -16,9 +16,10 @@ from homeassistant.components.websocket_api import ( ) from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback +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 async_call_logger_set_level, async_fire_time_changed from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -241,7 +242,7 @@ async def test_pending_msg_peak( instance: http.WebSocketHandler = cast(http.WebSocketHandler, setup_instance) # Fill the queue past the allowed peak - for _ in range(10): + for _ in range(20): instance._send_message({"overload": "message"}) async_fire_time_changed( @@ -251,7 +252,7 @@ async def test_pending_msg_peak( msg = await websocket_client.receive() assert msg.type is WSMsgType.CLOSE assert "Client unable to keep up with pending messages" in caplog.text - assert "Stayed over 5 for 5 seconds" in caplog.text + assert "Stayed over 5 for 10 seconds" in caplog.text assert "overload" in caplog.text @@ -523,3 +524,28 @@ async def test_binary_message( assert "Received binary message for non-existing handler 0" in caplog.text assert "Received binary message for non-existing handler 3" in caplog.text assert "Received binary message for non-existing handler 10" in caplog.text + + +async def test_enable_disable_debug_logging( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enabling and disabling debug logging.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) + async with async_call_logger_set_level( + "homeassistant.components.websocket_api", "DEBUG", hass=hass, caplog=caplog + ): + await websocket_client.send_json({"id": 1, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":1,"type":"pong"}\'' in caplog.text + async with async_call_logger_set_level( + "homeassistant.components.websocket_api", "WARNING", hass=hass, caplog=caplog + ): + await websocket_client.send_json({"id": 2, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 2 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":2,"type":"pong"}\'' not in caplog.text diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py index dbdeb0726dd..692792955fc 100644 --- a/tests/components/weheat/conftest.py +++ b/tests/components/weheat/conftest.py @@ -124,6 +124,8 @@ def mock_weheat_heat_pump_instance() -> MagicMock: 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.dhw_flow_volume = 1.12 + mock_heat_pump_instance.central_heating_flow_volume = 1.23 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 diff --git a/tests/components/weheat/snapshots/test_sensor.ambr b/tests/components/weheat/snapshots/test_sensor.ambr index 77f85224913..b968d925675 100644 --- a/tests/components/weheat/snapshots/test_sensor.ambr +++ b/tests/components/weheat/snapshots/test_sensor.ambr @@ -125,6 +125,61 @@ 'state': '33', }) # --- +# name: test_all_entities[sensor.test_model_central_heating_pump_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_central_heating_pump_flow', + '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': 'Central heating pump flow', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'central_heating_flow_volume', + 'unique_id': '0000-1111-2222-3333_central_heating_flow_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_central_heating_pump_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Test Model Central heating pump flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_central_heating_pump_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- # name: test_all_entities[sensor.test_model_compressor_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -390,6 +445,61 @@ 'state': '88', }) # --- +# name: test_all_entities[sensor.test_model_dhw_pump_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_dhw_pump_flow', + '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': 'DHW pump flow', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_flow_volume', + 'unique_id': '0000-1111-2222-3333_dhw_flow_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_dhw_pump_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Test Model DHW pump flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_dhw_pump_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.12', + }) +# --- # name: test_all_entities[sensor.test_model_dhw_top_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/weheat/test_binary_sensor.py b/tests/components/weheat/test_binary_sensor.py index 5769fc9a1a8..69122a35ea9 100644 --- a/tests/components/weheat/test_binary_sensor.py +++ b/tests/components/weheat/test_binary_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery from homeassistant.const import Platform diff --git a/tests/components/weheat/test_sensor.py b/tests/components/weheat/test_sensor.py index f3eec282704..b4d436cdaf1 100644 --- a/tests/components/weheat/test_sensor.py +++ b/tests/components/weheat/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery from homeassistant.const import Platform @@ -33,7 +33,7 @@ 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, 15), (True, 17)]) +@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 16), (True, 19)]) async def test_create_entities( hass: HomeAssistant, mock_weheat_discover: AsyncMock, diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py index ca00975941a..ca96ff1f2a9 100644 --- a/tests/components/whirlpool/__init__.py +++ b/tests/components/whirlpool/__init__.py @@ -1,8 +1,13 @@ """Tests for the Whirlpool Sixth Sense integration.""" +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry from tests.common import MockConfigEntry @@ -31,5 +36,29 @@ async def init_integration_with_entry( entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - return entry + + +def snapshot_whirlpool_entities( + hass: HomeAssistant, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + platform: Platform, +) -> None: + """Snapshot Whirlpool entities.""" + entities = hass.states.async_all(platform) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") + + +async def trigger_attr_callback( + hass: HomeAssistant, mock_api_instance: MagicMock +) -> None: + """Simulate an update trigger from the API.""" + + for call in mock_api_instance.register_attr_callback.call_args_list: + update_ha_state_cb = call[0][0] + update_ha_state_cb() + await hass.async_block_till_done() diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index c302922fe25..7447c1edd5a 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -1,17 +1,13 @@ """Fixtures for the Whirlpool Sixth Sense integration tests.""" from unittest import mock -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import Mock import pytest -import whirlpool -import whirlpool.aircon +from whirlpool import aircon, appliancesmanager, auth, washerdryer from whirlpool.backendselector import Brand, Region -MOCK_SAID1 = "said1" -MOCK_SAID2 = "said2" -MOCK_SAID3 = "said3" -MOCK_SAID4 = "said4" +from .const import MOCK_SAID1, MOCK_SAID2 @pytest.fixture( @@ -36,55 +32,43 @@ def fixture_brand(request: pytest.FixtureRequest) -> tuple[str, Brand]: return request.param -@pytest.fixture(name="mock_auth_api") +@pytest.fixture(name="mock_auth_api", autouse=True) def fixture_mock_auth_api(): """Set up Auth fixture.""" with ( - mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth, + mock.patch( + "homeassistant.components.whirlpool.Auth", spec=auth.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 -@pytest.fixture(name="mock_appliances_manager_api") -def fixture_mock_appliances_manager_api(): +@pytest.fixture(name="mock_appliances_manager_api", autouse=True) +def fixture_mock_appliances_manager_api( + mock_aircon1_api, mock_aircon2_api, mock_washer_api, mock_dryer_api +): """Set up AppliancesManager fixture.""" with ( mock.patch( - "homeassistant.components.whirlpool.AppliancesManager" + "homeassistant.components.whirlpool.AppliancesManager", + spec=appliancesmanager.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"}, - {"SAID": MOCK_SAID2, "NAME": "TestZone"}, + mock_aircon1_api, + mock_aircon2_api, ] mock_appliances_manager.return_value.washer_dryers = [ - {"SAID": MOCK_SAID3, "NAME": "washer"}, - {"SAID": MOCK_SAID4, "NAME": "dryer"}, - ] - yield mock_appliances_manager - - -@pytest.fixture(name="mock_appliances_manager_laundry_api") -def fixture_mock_appliances_manager_laundry_api(): - """Set up AppliancesManager fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.AppliancesManager" - ) as mock_appliances_manager: - mock_appliances_manager.return_value.fetch_appliances = AsyncMock() - mock_appliances_manager.return_value.aircons = None - mock_appliances_manager.return_value.washer_dryers = [ - {"SAID": MOCK_SAID3, "NAME": "washer"}, - {"SAID": MOCK_SAID4, "NAME": "dryer"}, + mock_washer_api, + mock_dryer_api, ] yield mock_appliances_manager @@ -106,108 +90,79 @@ def fixture_mock_backend_selector_api(): def get_aircon_mock(said): """Get a mock of an air conditioner.""" - mock_aircon = mock.Mock(said=said) - mock_aircon.connect = AsyncMock() - mock_aircon.disconnect = AsyncMock() - mock_aircon.register_attr_callback = MagicMock() + mock_aircon = Mock(spec=aircon.Aircon, said=said) + mock_aircon.name = f"Aircon {said}" + mock_aircon.appliance_info = Mock( + data_model="aircon_model", category="aircon", model_number="12345" + ) mock_aircon.get_online.return_value = True mock_aircon.get_power_on.return_value = True - mock_aircon.get_mode.return_value = whirlpool.aircon.Mode.Cool - mock_aircon.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Auto + mock_aircon.get_mode.return_value = aircon.Mode.Cool + mock_aircon.get_fanspeed.return_value = aircon.FanSpeed.Auto mock_aircon.get_current_temp.return_value = 15 mock_aircon.get_temp.return_value = 20 mock_aircon.get_current_humidity.return_value = 80 mock_aircon.get_humidity.return_value = 50 mock_aircon.get_h_louver_swing.return_value = True - mock_aircon.set_power_on = AsyncMock() - mock_aircon.set_mode = AsyncMock() - mock_aircon.set_temp = AsyncMock() - mock_aircon.set_humidity = AsyncMock() - mock_aircon.set_mode = AsyncMock() - mock_aircon.set_fanspeed = AsyncMock() - mock_aircon.set_h_louver_swing = AsyncMock() - return mock_aircon @pytest.fixture(name="mock_aircon1_api", autouse=False) -def fixture_mock_aircon1_api(mock_auth_api, mock_appliances_manager_api): +def fixture_mock_aircon1_api(): """Set up air conditioner API fixture.""" return get_aircon_mock(MOCK_SAID1) @pytest.fixture(name="mock_aircon2_api", autouse=False) -def fixture_mock_aircon2_api(mock_auth_api, mock_appliances_manager_api): +def fixture_mock_aircon2_api(): """Set up air conditioner API fixture.""" return get_aircon_mock(MOCK_SAID2) -@pytest.fixture(name="mock_aircon_api_instances", autouse=False) -def fixture_mock_aircon_api_instances(mock_aircon1_api, mock_aircon2_api): - """Set up air conditioner API fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.climate.Aircon" - ) as mock_aircon_api: - mock_aircon_api.side_effect = [mock_aircon1_api, mock_aircon2_api] - yield mock_aircon_api - - -def side_effect_function(*args, **kwargs): - """Return correct value for attribute.""" - if args[0] == "Cavity_TimeStatusEstTimeRemaining": - return 3540 - if args[0] == "Cavity_OpStatusDoorOpen": - return "0" - if args[0] == "WashCavity_OpStatusBulkDispense1Level": - return "3" - - return None - - -def get_sensor_mock(said): - """Get a mock of a sensor.""" - mock_sensor = mock.Mock(said=said) - mock_sensor.connect = AsyncMock() - mock_sensor.disconnect = AsyncMock() - mock_sensor.register_attr_callback = MagicMock() - mock_sensor.get_online.return_value = True - mock_sensor.get_machine_state.return_value = ( - whirlpool.washerdryer.MachineState.Standby +@pytest.fixture +def mock_washer_api(): + """Get a mock of a washer.""" + mock_washer = Mock(spec=washerdryer.WasherDryer, said="said_washer") + mock_washer.name = "Washer" + mock_washer.appliance_info = Mock( + data_model="washer", category="washer_dryer", model_number="12345" ) - mock_sensor.get_attribute.side_effect = side_effect_function - mock_sensor.get_cycle_status_filling.return_value = False - mock_sensor.get_cycle_status_rinsing.return_value = False - mock_sensor.get_cycle_status_sensing.return_value = False - mock_sensor.get_cycle_status_soaking.return_value = False - mock_sensor.get_cycle_status_spinning.return_value = False - mock_sensor.get_cycle_status_washing.return_value = False + mock_washer.get_online.return_value = True + mock_washer.get_machine_state.return_value = ( + washerdryer.MachineState.RunningMainCycle + ) + mock_washer.get_door_open.return_value = False + mock_washer.get_dispense_1_level.return_value = 3 + mock_washer.get_time_remaining.return_value = 3540 + mock_washer.get_cycle_status_filling.return_value = False + mock_washer.get_cycle_status_rinsing.return_value = False + mock_washer.get_cycle_status_sensing.return_value = False + mock_washer.get_cycle_status_soaking.return_value = False + mock_washer.get_cycle_status_spinning.return_value = False + mock_washer.get_cycle_status_washing.return_value = False - return mock_sensor + return mock_washer -@pytest.fixture(name="mock_sensor1_api", autouse=False) -def fixture_mock_sensor1_api(mock_auth_api, mock_appliances_manager_laundry_api): - """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID3) - - -@pytest.fixture(name="mock_sensor2_api", autouse=False) -def fixture_mock_sensor2_api(mock_auth_api, mock_appliances_manager_laundry_api): - """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID4) - - -@pytest.fixture(name="mock_sensor_api_instances", autouse=False) -def fixture_mock_sensor_api_instances(mock_sensor1_api, mock_sensor2_api): - """Set up sensor API fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.sensor.WasherDryer" - ) as mock_sensor_api: - mock_sensor_api.side_effect = [ - mock_sensor1_api, - mock_sensor2_api, - mock_sensor1_api, - mock_sensor2_api, - ] - yield mock_sensor_api +@pytest.fixture +def mock_dryer_api(): + """Get a mock of a dryer.""" + mock_dryer = mock.Mock(spec=washerdryer.WasherDryer, said="said_dryer") + mock_dryer.name = "Dryer" + mock_dryer.appliance_info = Mock( + data_model="dryer", category="washer_dryer", model_number="12345" + ) + mock_dryer.get_online.return_value = True + mock_dryer.get_machine_state.return_value = ( + washerdryer.MachineState.RunningMainCycle + ) + mock_dryer.get_door_open.return_value = False + mock_dryer.get_time_remaining.return_value = 3540 + mock_dryer.get_cycle_status_filling.return_value = False + mock_dryer.get_cycle_status_rinsing.return_value = False + mock_dryer.get_cycle_status_sensing.return_value = False + mock_dryer.get_cycle_status_soaking.return_value = False + mock_dryer.get_cycle_status_spinning.return_value = False + mock_dryer.get_cycle_status_washing.return_value = False + return mock_dryer diff --git a/tests/components/whirlpool/const.py b/tests/components/whirlpool/const.py new file mode 100644 index 00000000000..f7348ba4641 --- /dev/null +++ b/tests/components/whirlpool/const.py @@ -0,0 +1,4 @@ +"""Constants for the Whirlpool Sixth Sense integration tests.""" + +MOCK_SAID1 = "said1" +MOCK_SAID2 = "said2" diff --git a/tests/components/whirlpool/snapshots/test_binary_sensor.ambr b/tests/components/whirlpool/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..1a902f806cf --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.dryer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dryer_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': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'said_dryer-door', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.dryer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Dryer Door', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.washer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_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': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'said_washer-door', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.washer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Washer Door', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_climate.ambr b/tests/components/whirlpool/snapshots/test_climate.ambr new file mode 100644 index 00000000000..2957a609fa2 --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_climate.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_all_entities[climate.aircon_said1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + 'off', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 16, + 'swing_modes': list([ + 'horizontal', + 'off', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aircon_said1', + 'has_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': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'said1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[climate.aircon_said1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 80, + 'current_temperature': 15, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + 'off', + ]), + 'friendly_name': 'Aircon said1', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 16, + 'supported_features': , + 'swing_mode': 'horizontal', + 'swing_modes': list([ + 'horizontal', + 'off', + ]), + 'target_temp_step': 1, + 'temperature': 20, + }), + 'context': , + 'entity_id': 'climate.aircon_said1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_all_entities[climate.aircon_said2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + 'off', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 16, + 'swing_modes': list([ + 'horizontal', + 'off', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aircon_said2', + 'has_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': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'said2', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[climate.aircon_said2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 80, + 'current_temperature': 15, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + 'off', + ]), + 'friendly_name': 'Aircon said2', + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 16, + 'supported_features': , + 'swing_mode': 'horizontal', + 'swing_modes': list([ + 'horizontal', + 'off', + ]), + 'target_temp_step': 1, + 'temperature': 20, + }), + 'context': , + 'entity_id': 'climate.aircon_said2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index ee8abe04bf1..f1eef6f7dfc 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -2,24 +2,32 @@ # name: test_entry_diagnostics dict({ 'appliances': dict({ - 'Washer_dryers': dict({ - 'dryer': dict({ - 'NAME': 'dryer', - 'SAID': '**REDACTED**', - }), - 'washer': dict({ - 'NAME': 'washer', - 'SAID': '**REDACTED**', - }), - }), 'aircons': dict({ - 'TestZone': dict({ - 'NAME': 'TestZone', - 'SAID': '**REDACTED**', + 'Aircon said1': dict({ + 'category': 'aircon', + 'data_model': 'aircon_model', + 'model_number': '12345', + }), + 'Aircon said2': dict({ + 'category': 'aircon', + 'data_model': 'aircon_model', + 'model_number': '12345', }), }), 'ovens': dict({ }), + 'washer_dryers': dict({ + 'Dryer': dict({ + 'category': 'washer_dryer', + 'data_model': 'dryer', + 'model_number': '12345', + }), + 'Washer': dict({ + 'category': 'washer_dryer', + 'data_model': 'washer', + 'model_number': '12345', + }), + }), }), 'config_entry': dict({ 'data': dict({ diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6a0465ba8b9 --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -0,0 +1,372 @@ +# serializer version: 1 +# name: test_all_entities[sensor.dryer_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:progress-clock', + 'original_name': 'End time', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'end_time', + 'unique_id': 'said_dryer-timeremaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.dryer_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Dryer End time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.dryer_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-04T12:59:00+00:00', + }) +# --- +# name: test_all_entities[sensor.dryer_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_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': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_state', + 'unique_id': 'said_dryer-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.dryer_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dryer State', + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'context': , + 'entity_id': 'sensor.dryer_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running_maincycle', + }) +# --- +# name: test_all_entities[sensor.washer_detergent_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'empty', + '25', + '50', + '100', + 'active', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_detergent_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': 'Detergent level', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'whirlpool_tank', + 'unique_id': 'said_washer-DispenseLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.washer_detergent_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer Detergent level', + 'options': list([ + 'empty', + '25', + '50', + '100', + 'active', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_detergent_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.washer_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:progress-clock', + 'original_name': 'End time', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'end_time', + 'unique_id': 'said_washer-timeremaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.washer_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washer End time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.washer_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-04T12:59:00+00:00', + }) +# --- +# name: test_all_entities[sensor.washer_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_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': 'whirlpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_state', + 'unique_id': 'said_washer-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.washer_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer State', + 'options': list([ + 'standby', + 'setting', + 'delay_countdown', + 'delay_paused', + 'smart_delay', + 'smart_grid_pause', + 'pause', + 'running_maincycle', + 'running_postcycle', + 'exception', + 'complete', + 'power_failure', + 'service_diagnostic_mode', + 'factory_diagnostic_mode', + 'life_test', + 'customer_focus_mode', + 'demo_mode', + 'hard_stop_or_error', + 'system_initialize', + 'cycle_filling', + 'cycle_rinsing', + 'cycle_sensing', + 'cycle_soaking', + 'cycle_spinning', + 'cycle_washing', + 'door_open', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running_maincycle', + }) +# --- diff --git a/tests/components/whirlpool/test_binary_sensor.py b/tests/components/whirlpool/test_binary_sensor.py new file mode 100644 index 00000000000..e4539fa5d13 --- /dev/null +++ b/tests/components/whirlpool/test_binary_sensor.py @@ -0,0 +1,55 @@ +"""Test the Whirlpool Binary Sensor domain.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await init_integration(hass) + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.BINARY_SENSOR) + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture", "mock_method"), + [ + ("binary_sensor.washer_door", "mock_washer_api", "get_door_open"), + ("binary_sensor.dryer_door", "mock_dryer_api", "get_door_open"), + ], +) +async def test_simple_binary_sensors( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + mock_method: str, + request: pytest.FixtureRequest, +) -> None: + """Test simple binary sensors states.""" + mock_instance = request.getfixturevalue(mock_fixture) + mock_method = getattr(mock_instance, mock_method) + await init_integration(hass) + + mock_method.return_value = False + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + mock_method.return_value = True + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + mock_method.return_value = None + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state is STATE_UNKNOWN diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index cdae28f4432..2c36c713546 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -2,22 +2,16 @@ from unittest.mock import MagicMock -from attr import dataclass import pytest +from syrupy.assertion import SnapshotAssertion import whirlpool from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, - ATTR_FAN_MODES, ATTR_HVAC_MODE, - ATTR_HVAC_MODES, - ATTR_MAX_TEMP, - ATTR_MIN_TEMP, ATTR_SWING_MODE, - ATTR_SWING_MODES, - ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, FAN_HIGH, @@ -31,23 +25,33 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, SWING_HORIZONTAL, SWING_OFF, - ClimateEntityFeature, HVACMode, ) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback + + +@pytest.fixture( + params=[ + ("climate.aircon_said1", "mock_aircon1_api"), + ("climate.aircon_said2", "mock_aircon2_api"), + ] +) +def multiple_climate_entities(request: pytest.FixtureRequest) -> tuple[str, str]: + """Fixture for multiple climate entities.""" + entity_id, mock_fixture = request.param + return entity_id, mock_fixture async def update_ac_state( @@ -56,326 +60,270 @@ async def update_ac_state( mock_aircon_api_instance: MagicMock, ): """Simulate an update trigger from the API.""" - for call in mock_aircon_api_instance.register_attr_callback.call_args_list: - update_ha_state_cb = call[0][0] - update_ha_state_cb() - await hass.async_block_till_done() + await trigger_attr_callback(hass, mock_aircon_api_instance) return hass.states.get(entity_id) -async def test_no_appliances( - hass: HomeAssistant, mock_appliances_manager_api: MagicMock -) -> None: - """Test the setup of the climate entities when there are no appliances available.""" - mock_appliances_manager_api.return_value.aircons = [] - await init_integration(hass) - assert len(hass.states.async_all()) == 0 - - -async def test_static_attributes( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( hass: HomeAssistant, + snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, - mock_aircon1_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: - """Test static climate attributes.""" + """Test all entities.""" await init_integration(hass) - - for entity_id in ("climate.said1", "climate.said2"): - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == entity_id.split(".")[1] - - state = hass.states.get(entity_id) - assert state is not None - assert state.state != STATE_UNAVAILABLE - assert state.state == HVACMode.COOL - - attributes = state.attributes - assert attributes[ATTR_FRIENDLY_NAME] == "TestZone" - - assert ( - attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.SWING_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert attributes[ATTR_HVAC_MODES] == [ - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.OFF, - ] - assert attributes[ATTR_FAN_MODES] == [ - FAN_AUTO, - FAN_HIGH, - FAN_MEDIUM, - FAN_LOW, - FAN_OFF, - ] - assert attributes[ATTR_SWING_MODES] == [SWING_HORIZONTAL, SWING_OFF] - assert attributes[ATTR_TARGET_TEMP_STEP] == 1 - assert attributes[ATTR_MIN_TEMP] == 16 - assert attributes[ATTR_MAX_TEMP] == 30 + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.CLIMATE) async def test_dynamic_attributes( hass: HomeAssistant, - mock_aircon_api_instances: MagicMock, - mock_aircon1_api: MagicMock, - mock_aircon2_api: MagicMock, + multiple_climate_entities: tuple[str, str], + request: pytest.FixtureRequest, ) -> None: """Test dynamic attributes.""" + entity_id, mock_fixture = multiple_climate_entities + mock_instance = request.getfixturevalue(mock_fixture) await init_integration(hass) - @dataclass - class ClimateTestInstance: - """Helper class for multiple climate and mock instances.""" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == HVACMode.COOL - entity_id: str - mock_instance: MagicMock - mock_instance_idx: int + mock_instance.get_power_on.return_value = False + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == HVACMode.OFF - for clim_test_instance in ( - ClimateTestInstance("climate.said1", mock_aircon1_api, 0), - ClimateTestInstance("climate.said2", mock_aircon2_api, 1), - ): - entity_id = clim_test_instance.entity_id - mock_instance = clim_test_instance.mock_instance - state = hass.states.get(entity_id) - assert state is not None - assert state.state == HVACMode.COOL + mock_instance.get_online.return_value = False + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == STATE_UNAVAILABLE - mock_instance.get_power_on.return_value = False - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.state == HVACMode.OFF + mock_instance.get_power_on.return_value = True + mock_instance.get_online.return_value = True + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == HVACMode.COOL - mock_instance.get_online.return_value = False - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.state == STATE_UNAVAILABLE + mock_instance.get_mode.return_value = whirlpool.aircon.Mode.Heat + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == HVACMode.HEAT - mock_instance.get_power_on.return_value = True - mock_instance.get_online.return_value = True - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.state == HVACMode.COOL + mock_instance.get_mode.return_value = whirlpool.aircon.Mode.Fan + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.state == HVACMode.FAN_ONLY - mock_instance.get_mode.return_value = whirlpool.aircon.Mode.Heat - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.state == HVACMode.HEAT + mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Auto + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.attributes[ATTR_FAN_MODE] == HVACMode.AUTO - mock_instance.get_mode.return_value = whirlpool.aircon.Mode.Fan - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.state == HVACMode.FAN_ONLY + mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Low + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.attributes[ATTR_FAN_MODE] == FAN_LOW - mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Auto - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.attributes[ATTR_FAN_MODE] == HVACMode.AUTO + mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Medium + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.attributes[ATTR_FAN_MODE] == FAN_MEDIUM - mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Low - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.attributes[ATTR_FAN_MODE] == FAN_LOW + mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.High + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH - mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Medium - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.attributes[ATTR_FAN_MODE] == FAN_MEDIUM + mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Off + state = await update_ac_state(hass, entity_id, mock_instance) + assert state.attributes[ATTR_FAN_MODE] == FAN_OFF - mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.High - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH + mock_instance.get_current_temp.return_value = 15 + mock_instance.get_temp.return_value = 20 + mock_instance.get_current_humidity.return_value = 80 + mock_instance.get_h_louver_swing.return_value = True + attributes = (await update_ac_state(hass, entity_id, mock_instance)).attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] == 15 + assert attributes[ATTR_TEMPERATURE] == 20 + assert attributes[ATTR_CURRENT_HUMIDITY] == 80 + assert attributes[ATTR_SWING_MODE] == SWING_HORIZONTAL - mock_instance.get_fanspeed.return_value = whirlpool.aircon.FanSpeed.Off - state = await update_ac_state(hass, entity_id, mock_instance) - assert state.attributes[ATTR_FAN_MODE] == FAN_OFF - - mock_instance.get_current_temp.return_value = 15 - mock_instance.get_temp.return_value = 20 - mock_instance.get_current_humidity.return_value = 80 - mock_instance.get_h_louver_swing.return_value = True - attributes = (await update_ac_state(hass, entity_id, mock_instance)).attributes - assert attributes[ATTR_CURRENT_TEMPERATURE] == 15 - assert attributes[ATTR_TEMPERATURE] == 20 - assert attributes[ATTR_CURRENT_HUMIDITY] == 80 - assert attributes[ATTR_SWING_MODE] == SWING_HORIZONTAL - - mock_instance.get_current_temp.return_value = 16 - mock_instance.get_temp.return_value = 21 - mock_instance.get_current_humidity.return_value = 70 - mock_instance.get_h_louver_swing.return_value = False - attributes = (await update_ac_state(hass, entity_id, mock_instance)).attributes - assert attributes[ATTR_CURRENT_TEMPERATURE] == 16 - assert attributes[ATTR_TEMPERATURE] == 21 - assert attributes[ATTR_CURRENT_HUMIDITY] == 70 - assert attributes[ATTR_SWING_MODE] == SWING_OFF + mock_instance.get_current_temp.return_value = 16 + mock_instance.get_temp.return_value = 21 + mock_instance.get_current_humidity.return_value = 70 + mock_instance.get_h_louver_swing.return_value = False + attributes = (await update_ac_state(hass, entity_id, mock_instance)).attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] == 16 + assert attributes[ATTR_TEMPERATURE] == 21 + assert attributes[ATTR_CURRENT_HUMIDITY] == 70 + assert attributes[ATTR_SWING_MODE] == SWING_OFF +@pytest.mark.parametrize( + ("service", "service_data", "expected_call", "expected_args"), + [ + (SERVICE_TURN_OFF, {}, "set_power_on", [False]), + (SERVICE_TURN_ON, {}, "set_power_on", [True]), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.COOL}, + "set_mode", + [whirlpool.aircon.Mode.Cool], + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + "set_mode", + [whirlpool.aircon.Mode.Heat], + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, + "set_mode", + [whirlpool.aircon.Mode.Fan], + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + "set_power_on", + [False], + ), + (SERVICE_SET_TEMPERATURE, {ATTR_TEMPERATURE: 20}, "set_temp", [20]), + ( + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: FAN_AUTO}, + "set_fanspeed", + [whirlpool.aircon.FanSpeed.Auto], + ), + ( + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: FAN_LOW}, + "set_fanspeed", + [whirlpool.aircon.FanSpeed.Low], + ), + ( + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: FAN_MEDIUM}, + "set_fanspeed", + [whirlpool.aircon.FanSpeed.Medium], + ), + ( + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: FAN_HIGH}, + "set_fanspeed", + [whirlpool.aircon.FanSpeed.High], + ), + ( + SERVICE_SET_SWING_MODE, + {ATTR_SWING_MODE: SWING_HORIZONTAL}, + "set_h_louver_swing", + [True], + ), + ( + SERVICE_SET_SWING_MODE, + {ATTR_SWING_MODE: SWING_OFF}, + "set_h_louver_swing", + [False], + ), + ], +) async def test_service_calls( hass: HomeAssistant, - mock_aircon_api_instances: MagicMock, - mock_aircon1_api: MagicMock, - mock_aircon2_api: MagicMock, + service: str, + service_data: dict, + expected_call: str, + expected_args: list, + multiple_climate_entities: tuple[str, str], + request: pytest.FixtureRequest, ) -> None: """Test controlling the entity through service calls.""" await init_integration(hass) + entity_id, mock_fixture = multiple_climate_entities + mock_instance = request.getfixturevalue(mock_fixture) - @dataclass - class ClimateInstancesData: - """Helper class for multiple climate and mock instances.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + assert getattr(mock_instance, expected_call).call_count == 1 + getattr(mock_instance, expected_call).assert_called_once_with(*expected_args) - entity_id: str - mock_instance: MagicMock - for clim_test_instance in ( - ClimateInstancesData("climate.said1", mock_aircon1_api), - ClimateInstancesData("climate.said2", mock_aircon2_api), - ): - mock_instance = clim_test_instance.mock_instance - entity_id = clim_test_instance.entity_id - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_instance.set_power_on.assert_called_once_with(False) - - mock_instance.set_power_on.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_instance.set_power_on.assert_called_once_with(True) - - mock_instance.set_power_on.reset_mock() - mock_instance.get_power_on.return_value = False - await hass.services.async_call( - CLIMATE_DOMAIN, +@pytest.mark.parametrize( + ("service", "service_data"), + [ + ( SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, - blocking=True, - ) - mock_instance.set_power_on.assert_called_once_with(True) - - mock_instance.set_temp.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 16}, - blocking=True, - ) - mock_instance.set_temp.assert_called_once_with(16) - - mock_instance.set_mode.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, + {ATTR_HVAC_MODE: HVACMode.COOL}, + ), + ( SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, - blocking=True, - ) - mock_instance.set_mode.assert_called_once_with(whirlpool.aircon.Mode.Cool) - - mock_instance.set_mode.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + ), + ( SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, - blocking=True, - ) - mock_instance.set_mode.assert_called_once_with(whirlpool.aircon.Mode.Heat) + {ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, + ), + ], +) +async def test_service_hvac_mode_turn_on( + hass: HomeAssistant, + service: str, + service_data: dict, + multiple_climate_entities: tuple[str, str], + request: pytest.FixtureRequest, +) -> None: + """Test that the HVAC mode service call turns on the entity, if it is off.""" + await init_integration(hass) + entity_id, mock_fixture = multiple_climate_entities + mock_instance = request.getfixturevalue(mock_fixture) - mock_instance.set_mode.reset_mock() - # HVACMode.DRY is not supported - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.DRY}, - blocking=True, - ) - mock_instance.set_mode.assert_not_called() + mock_instance.get_power_on.return_value = False + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + mock_instance.set_power_on.assert_called_once_with(True) - mock_instance.set_mode.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, + # Test that set_power_on is not called if the device is already on + mock_instance.set_power_on.reset_mock() + mock_instance.get_power_on.return_value = True + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + mock_instance.set_power_on.assert_not_called() + + +@pytest.mark.parametrize( + ("service", "service_data", "exception"), + [ + ( SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, - blocking=True, - ) - mock_instance.set_mode.assert_called_once_with(whirlpool.aircon.Mode.Fan) - - mock_instance.set_fanspeed.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, + {ATTR_HVAC_MODE: HVACMode.DRY}, + ValueError, + ), + ( SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_AUTO}, - blocking=True, - ) - mock_instance.set_fanspeed.assert_called_once_with( - whirlpool.aircon.FanSpeed.Auto - ) + {ATTR_FAN_MODE: FAN_MIDDLE}, + ServiceValidationError, + ), + ], +) +async def test_service_unsupported( + hass: HomeAssistant, + service: str, + service_data: dict, + exception: type[Exception], + multiple_climate_entities: tuple[str, str], +) -> None: + """Test that unsupported service calls are handled properly.""" + await init_integration(hass) + entity_id, _ = multiple_climate_entities - mock_instance.set_fanspeed.reset_mock() + with pytest.raises(exception): await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, blocking=True, ) - mock_instance.set_fanspeed.assert_called_once_with( - whirlpool.aircon.FanSpeed.Low - ) - - mock_instance.set_fanspeed.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_MEDIUM}, - blocking=True, - ) - mock_instance.set_fanspeed.assert_called_once_with( - whirlpool.aircon.FanSpeed.Medium - ) - - mock_instance.set_fanspeed.reset_mock() - # FAN_MIDDLE is not supported - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_MIDDLE}, - blocking=True, - ) - mock_instance.set_fanspeed.assert_not_called() - - mock_instance.set_fanspeed.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_HIGH}, - blocking=True, - ) - mock_instance.set_fanspeed.assert_called_once_with( - whirlpool.aircon.FanSpeed.High - ) - - mock_instance.set_h_louver_swing.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_SWING_MODE: SWING_HORIZONTAL}, - blocking=True, - ) - mock_instance.set_h_louver_swing.assert_called_with(True) - - mock_instance.set_h_louver_swing.reset_mock() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_SWING_MODE: SWING_OFF}, - blocking=True, - ) - mock_instance.set_h_louver_swing.assert_called_with(False) diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index e01fbc07b51..6563f88515f 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -5,10 +5,12 @@ from unittest.mock import MagicMock, patch import aiohttp import pytest from whirlpool.auth import AccountLockedError +from whirlpool.backendselector import Brand, Region from homeassistant import config_entries from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -20,6 +22,42 @@ CONFIG_INPUT = { } +def assert_successful_user_flow( + mock_whirlpool_setup_entry: MagicMock, + result: ConfigFlowResult, + region: str, + brand: str, +) -> None: + """Assert that the flow was successful.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_USERNAME: CONFIG_INPUT[CONF_USERNAME], + CONF_PASSWORD: CONFIG_INPUT[CONF_PASSWORD], + CONF_REGION: region, + CONF_BRAND: brand, + } + assert result["result"].unique_id == CONFIG_INPUT[CONF_USERNAME] + assert len(mock_whirlpool_setup_entry.mock_calls) == 1 + + +def assert_successful_reauth_flow( + mock_entry: MockConfigEntry, + result: ConfigFlowResult, + region: str, + brand: str, +) -> None: + """Assert that the reauth flow was successful.""" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_entry.data == { + CONF_USERNAME: CONFIG_INPUT[CONF_USERNAME], + CONF_PASSWORD: "new-password", + CONF_REGION: region[0], + CONF_BRAND: brand[0], + } + + @pytest.fixture(name="mock_whirlpool_setup_entry") def fixture_mock_whirlpool_setup_entry(): """Set up async_setup_entry fixture.""" @@ -30,14 +68,14 @@ def fixture_mock_whirlpool_setup_entry(): @pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") -async def test_form( +async def test_user_flow( hass: HomeAssistant, - region, - brand, + region: tuple[str, Region], + brand: tuple[str, Brand], mock_backend_selector_api: MagicMock, mock_whirlpool_setup_entry: MagicMock, ) -> None: - """Test we get the form.""" + """Test successful flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -45,38 +83,39 @@ async def test_form( assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - "region": region[0], - "brand": brand[0], - } - assert len(mock_whirlpool_setup_entry.mock_calls) == 1 + assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0]) mock_backend_selector_api.assert_called_once_with(brand[1], region[1]) -async def test_form_invalid_auth( - hass: HomeAssistant, region, brand, mock_auth_api: MagicMock +async def test_user_flow_invalid_auth( + hass: HomeAssistant, + region: tuple[str, Region], + brand: tuple[str, Brand], + mock_auth_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, ) -> None: - """Test we handle invalid auth.""" + """Test invalid authentication in the flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) 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]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + # Test that it succeeds if the authentication is valid + mock_auth_api.return_value.is_access_token_valid.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} + ) + assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0]) @pytest.mark.usefixtures("mock_appliances_manager_api") @@ -89,16 +128,16 @@ async def test_form_invalid_auth( (Exception, "unknown"), ], ) -async def test_form_auth_error( +async def test_user_flow_auth_error( hass: HomeAssistant, exception: Exception, expected_error: str, - region, - brand, + region: tuple[str, Region], + brand: tuple[str, Brand], mock_auth_api: MagicMock, mock_whirlpool_setup_entry: MagicMock, ) -> None: - """Test we handle cannot connect error.""" + """Test authentication exceptions in the flow initialized by the user.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -108,8 +147,8 @@ async def test_form_auth_error( result["flow_id"], CONFIG_INPUT | { - "region": region[0], - "brand": brand[0], + CONF_REGION: region[0], + CONF_BRAND: brand[0], }, ) assert result["type"] is FlowResultType.FORM @@ -118,27 +157,20 @@ async def test_form_auth_error( # Test that it succeeds after the error is cleared mock_auth_api.return_value.do_auth.side_effect = None result = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - "region": region[0], - "brand": brand[0], - } - assert len(mock_whirlpool_setup_entry.mock_calls) == 1 + assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0]) @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.""" +async def test_already_configured( + hass: HomeAssistant, region: tuple[str, Region], brand: tuple[str, Brand] +) -> None: + """Test that configuring the integration twice with the same data fails.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}, unique_id="test-username", ) mock_entry.add_to_hass(hass) @@ -150,22 +182,21 @@ 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 - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.usefixtures("mock_auth_api") async def test_no_appliances_flow( - hass: HomeAssistant, region, brand, mock_appliances_manager_api: MagicMock + hass: HomeAssistant, + region: tuple[str, Region], + brand: tuple[str, Brand], + mock_appliances_manager_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, ) -> None: """Test we get an error with no appliances.""" result = await hass.config_entries.flow.async_init( @@ -175,25 +206,35 @@ async def test_no_appliances_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER + original_aircons = mock_appliances_manager_api.return_value.aircons 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]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "no_appliances"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_appliances"} + + # Test that it succeeds if appliances are found + mock_appliances_manager_api.return_value.aircons = original_aircons + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} + ) + + assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0]) @pytest.mark.usefixtures( "mock_auth_api", "mock_appliances_manager_api", "mock_whirlpool_setup_entry" ) -async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: +async def test_reauth_flow( + hass: HomeAssistant, region: tuple[str, Region], brand: tuple[str, Brand] +) -> None: """Test a successful reauth flow.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}, unique_id="test-username", ) mock_entry.add_to_hass(hass) @@ -204,30 +245,25 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert mock_entry.data == { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "new-password", - "region": region[0], - "brand": brand[0], - } + assert_successful_reauth_flow(mock_entry, result, region, brand) @pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") async def test_reauth_flow_invalid_auth( - hass: HomeAssistant, region, brand, mock_auth_api: MagicMock + hass: HomeAssistant, + region: tuple[str, Region], + brand: tuple[str, Brand], + mock_auth_api: MagicMock, ) -> None: """Test an authorization error reauth flow.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}, unique_id="test-username", ) mock_entry.add_to_hass(hass) @@ -238,13 +274,21 @@ async def test_reauth_flow_invalid_auth( assert result["errors"] == {} mock_auth_api.return_value.is_access_token_valid.return_value = False - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + # Test that it succeeds if the credentials are valid + mock_auth_api.return_value.is_access_token_valid.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]} + ) + + assert_successful_reauth_flow(mock_entry, result, region, brand) @pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") @@ -261,15 +305,15 @@ async def test_reauth_flow_auth_error( hass: HomeAssistant, exception: Exception, expected_error: str, - region, - brand, + region: tuple[str, Region], + brand: tuple[str, Brand], mock_auth_api: MagicMock, ) -> None: """Test a connection error reauth flow.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}, unique_id="test-username", ) mock_entry.add_to_hass(hass) @@ -281,9 +325,16 @@ async def test_reauth_flow_auth_error( assert result["errors"] == {} mock_auth_api.return_value.do_auth.side_effect = exception - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]} ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": expected_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # Test that it succeeds if the exception is cleared + mock_auth_api.return_value.do_auth.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]} + ) + + assert_successful_reauth_flow(mock_entry, result, region, brand) diff --git a/tests/components/whirlpool/test_diagnostics.py b/tests/components/whirlpool/test_diagnostics.py index 2a0b2e6fd18..6ffdc82289f 100644 --- a/tests/components/whirlpool/test_diagnostics.py +++ b/tests/components/whirlpool/test_diagnostics.py @@ -1,8 +1,6 @@ """Test Blink diagnostics.""" -from unittest.mock import MagicMock - -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant @@ -19,9 +17,6 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, - mock_appliances_manager_api: MagicMock, - mock_aircon1_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test config entry diagnostics.""" diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index 8f082ff6294..d33bd8be0e1 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -21,7 +21,6 @@ async def test_setup( mock_backend_selector_api: MagicMock, region, brand, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup.""" entry = await init_integration(hass, region[0], brand[0]) @@ -33,7 +32,6 @@ async def test_setup( async def test_setup_region_fallback( hass: HomeAssistant, mock_backend_selector_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup when no region is available on the ConfigEntry. @@ -57,7 +55,6 @@ async def test_setup_brand_fallback( hass: HomeAssistant, region, mock_backend_selector_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup when no brand is available on the ConfigEntry. @@ -78,10 +75,19 @@ async def test_setup_brand_fallback( mock_backend_selector_api.assert_called_once_with(Brand.Whirlpool, region[1]) +async def test_setup_no_appliances( + hass: HomeAssistant, mock_appliances_manager_api: MagicMock +) -> None: + """Test setup when there are no appliances available.""" + mock_appliances_manager_api.return_value.aircons = [] + mock_appliances_manager_api.return_value.washer_dryers = [] + await init_integration(hass) + assert len(hass.states.async_all()) == 0 + + async def test_setup_http_exception( hass: HomeAssistant, mock_auth_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup with an http exception.""" mock_auth_api.return_value.do_auth = AsyncMock( @@ -95,7 +101,6 @@ async def test_setup_http_exception( async def test_setup_auth_failed( hass: HomeAssistant, mock_auth_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup with failed auth.""" mock_auth_api.return_value.do_auth = AsyncMock() @@ -108,7 +113,6 @@ async def test_setup_auth_failed( async def test_setup_auth_account_locked( hass: HomeAssistant, mock_auth_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup with failed auth due to account being locked.""" mock_auth_api.return_value.do_auth.side_effect = AccountLockedError @@ -120,20 +124,15 @@ async def test_setup_auth_account_locked( async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup with failed fetch_appliances.""" mock_appliances_manager_api.return_value.fetch_appliances.return_value = False entry = await init_integration(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.SETUP_ERROR + assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry( - hass: HomeAssistant, - mock_aircon_api_instances: MagicMock, - mock_sensor_api_instances: MagicMock, -) -> None: +async def test_unload_entry(hass: HomeAssistant) -> None: """Test successful unload of entry.""" entry = await init_integration(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 548025e29bd..6e28539d661 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -1,389 +1,330 @@ """Test the Whirlpool Sensor domain.""" -from datetime import UTC, datetime -from unittest.mock import MagicMock +from datetime import UTC, datetime, timedelta +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from whirlpool.washerdryer import MachineState from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL -from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import as_timestamp, utc_from_timestamp, utcnow -from . import init_integration +from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback from tests.common import async_fire_time_changed, mock_restore_cache_with_extra_data +WASHER_ENTITY_ID_BASE = "sensor.washer" +DRYER_ENTITY_ID_BASE = "sensor.dryer" -async def update_sensor_state( + +# Freeze time for WasherDryerTimeSensor +@pytest.mark.freeze_time("2025-05-04 12:00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await init_integration(hass) + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.SENSOR) + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_end_time", "mock_washer_api"), + ("sensor.dryer_end_time", "mock_dryer_api"), + ], +) +@pytest.mark.freeze_time("2022-11-30 00:00:00") +async def test_washer_dryer_time_sensor( hass: HomeAssistant, entity_id: str, - mock_sensor_api_instance: MagicMock, -) -> State: - """Simulate an update trigger from the API.""" - - for call in mock_sensor_api_instance.register_attr_callback.call_args_list: - update_ha_state_cb = call[0][0] - update_ha_state_cb() - await hass.async_block_till_done() - - return hass.states.get(entity_id) - - -def side_effect_function_open_door(*args, **kwargs): - """Return correct value for attribute.""" - if args[0] == "Cavity_TimeStatusEstTimeRemaining": - return 3540 - - if args[0] == "Cavity_OpStatusDoorOpen": - return "1" - - if args[0] == "WashCavity_OpStatusBulkDispense1Level": - return "3" - - return None - - -async def test_dryer_sensor_values( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor2_api: MagicMock, - entity_registry: er.EntityRegistry, + mock_fixture: str, + request: pytest.FixtureRequest, + freezer: FrozenDateTimeFactory, ) -> None: - """Test the sensor value callbacks.""" - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) + """Test Washer/Dryer end time sensors.""" + now = utcnow() + restored_datetime: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, - ( + [ ( - State( - "sensor.washer_end_time", - "1", - ), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State("sensor.dryer_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), + State(entity_id, "1"), + {"native_value": restored_datetime, "native_unit_of_measurement": None}, + ) + ], ) + mock_instance = request.getfixturevalue(mock_fixture) + mock_instance.get_machine_state.return_value = MachineState.Pause await init_integration(hass) - entity_id = "sensor.dryer_state" - mock_instance = mock_sensor2_api - entry = entity_registry.async_get(entity_id) - assert entry + # Test restored state. state = hass.states.get(entity_id) - assert state is not None - assert state.state == "standby" + assert state.state == restored_datetime.isoformat() - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - state_id = f"{entity_id.split('_', maxsplit=1)[0]}_end_time" - state = hass.states.get(state_id) - assert state.state == thetimestamp.isoformat() + # Test no time change because the machine is not running. + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state == restored_datetime.isoformat() + + # Test new time when machine starts a cycle. mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle - mock_instance.get_cycle_status_filling.return_value = False - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - False, - False, - False, - ] + mock_instance.get_time_remaining.return_value = 60 + await trigger_attr_callback(hass, mock_instance) - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "running_maincycle" - - mock_instance.get_machine_state.return_value = MachineState.Complete - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "complete" - - -async def test_washer_sensor_values( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor1_api: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test the sensor value callbacks.""" - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) - mock_restore_cache_with_extra_data( - hass, - ( - ( - State( - "sensor.washer_end_time", - "1", - ), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State("sensor.dryer_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), - ) - - await init_integration(hass) - - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - - entity_id = "sensor.washer_state" - mock_instance = mock_sensor1_api - entry = entity_registry.async_get(entity_id) - assert entry state = hass.states.get(entity_id) - assert state is not None - assert state.state == "standby" + expected_time = (now + timedelta(seconds=60)).isoformat() + assert state.state == expected_time - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - state_id = f"{entity_id.split('_', maxsplit=1)[0]}_end_time" - state = hass.states.get(state_id) - assert state.state == thetimestamp.isoformat() + # Test no state change for < 60 seconds elapsed time. + mock_instance.get_time_remaining.return_value = 65 + await trigger_attr_callback(hass, mock_instance) - state_id = f"{entity_id.split('_', maxsplit=1)[0]}_detergent_level" - entry = entity_registry.async_get(state_id) - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - update_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - await hass.async_block_till_done() - - assert update_entry != entry - assert update_entry.disabled is False - state = hass.states.get(state_id) - assert state is None - - await hass.config_entries.async_reload(entry.config_entry_id) - state = hass.states.get(state_id) - assert state is not None - assert state.state == "50" - - # Test the washer cycle states - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle - mock_instance.get_cycle_status_filling.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - True, - False, - False, - False, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_filling" - - mock_instance.get_cycle_status_filling.return_value = False - mock_instance.get_cycle_status_rinsing.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - True, - False, - False, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_rinsing" - - mock_instance.get_cycle_status_rinsing.return_value = False - mock_instance.get_cycle_status_sensing.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - True, - False, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_sensing" - - mock_instance.get_cycle_status_sensing.return_value = False - mock_instance.get_cycle_status_soaking.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - True, - False, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_soaking" - - mock_instance.get_cycle_status_soaking.return_value = False - mock_instance.get_cycle_status_spinning.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - False, - True, - False, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_spinning" - - mock_instance.get_cycle_status_spinning.return_value = False - mock_instance.get_cycle_status_washing.return_value = True - mock_instance.attr_value_to_bool.side_effect = [ - False, - False, - False, - False, - False, - True, - ] - - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "cycle_washing" - - mock_instance.get_machine_state.return_value = MachineState.Complete - mock_instance.attr_value_to_bool.side_effect = None - mock_instance.get_attribute.side_effect = side_effect_function_open_door - state = await update_sensor_state(hass, entity_id, mock_instance) - assert state is not None - assert state.state == "door_open" - - -async def test_restore_state( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, -) -> None: - """Test sensor restore state.""" - # Home assistant is not running yet - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) - mock_restore_cache_with_extra_data( - hass, - ( - ( - State( - "sensor.washer_end_time", - "1", - ), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State("sensor.dryer_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), - ) - - # create and add entry - await init_integration(hass) - # restore from cache - state = hass.states.get("sensor.washer_end_time") - assert state.state == thetimestamp.isoformat() - state = hass.states.get("sensor.dryer_end_time") - assert state.state == thetimestamp.isoformat() - - -async def test_no_restore_state( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor1_api: MagicMock, -) -> None: - """Test sensor restore state with no restore.""" - # create and add entry - entity_id = "sensor.washer_end_time" - await init_integration(hass) - # restore from cache state = hass.states.get(entity_id) - assert state.state == "unknown" - - mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle - state = await update_sensor_state(hass, entity_id, mock_sensor1_api) - assert state.state != "unknown" - - -@pytest.mark.freeze_time("2022-11-30 00:00:00") -async def test_callback( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor1_api: MagicMock, -) -> None: - """Test callback timestamp callback function.""" - hass.set_state(CoreState.not_running) - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) - mock_restore_cache_with_extra_data( - hass, - ( - ( - State( - "sensor.washer_end_time", - "1", - ), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ( - State("sensor.dryer_end_time", "1"), - {"native_value": thetimestamp, "native_unit_of_measurement": None}, - ), - ), - ) - - # create and add entry - await init_integration(hass) - # restore from cache - state = hass.states.get("sensor.washer_end_time") - assert state.state == thetimestamp.isoformat() - callback = mock_sensor1_api.register_attr_callback.call_args_list[1][0][0] - callback() - - state = hass.states.get("sensor.washer_end_time") - assert state.state == thetimestamp.isoformat() - mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle - mock_sensor1_api.get_attribute.side_effect = None - mock_sensor1_api.get_attribute.return_value = "60" - callback() - - # Test new timestamp when machine starts a cycle. - state = hass.states.get("sensor.washer_end_time") - time = state.state - assert state.state != thetimestamp.isoformat() - - # Test no timestamp change for < 60 seconds time change. - mock_sensor1_api.get_attribute.return_value = "65" - callback() - state = hass.states.get("sensor.washer_end_time") - assert state.state == time + assert state.state == expected_time # Test timestamp change for > 60 seconds. - mock_sensor1_api.get_attribute.return_value = "125" - callback() - state = hass.states.get("sensor.washer_end_time") - newtime = utc_from_timestamp(as_timestamp(time) + 65) - assert state.state == newtime.isoformat() + mock_instance.get_time_remaining.return_value = 125 + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + assert ( + state.state == utc_from_timestamp(as_timestamp(expected_time) + 65).isoformat() + ) + + # Test that periodic updates call the API to fetch data + mock_instance.fetch_data.reset_mock() + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_instance.fetch_data.assert_called_once() + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_end_time", "mock_washer_api"), + ("sensor.dryer_end_time", "mock_dryer_api"), + ], +) +@pytest.mark.freeze_time("2022-11-30 00:00:00") +async def test_washer_dryer_time_sensor_no_restore( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer end time sensors without state restore.""" + now = utcnow() + + mock_instance = request.getfixturevalue(mock_fixture) + mock_instance.get_machine_state.return_value = MachineState.Pause + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + # Test no change because the machine is paused. + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + # Test new time when machine starts a cycle. + mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + mock_instance.get_time_remaining.return_value = 60 + await trigger_attr_callback(hass, mock_instance) + + state = hass.states.get(entity_id) + expected_time = (now + timedelta(seconds=60)).isoformat() + assert state.state == expected_time + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_state", "mock_washer_api"), + ("sensor.dryer_state", "mock_dryer_api"), + ], +) +@pytest.mark.parametrize( + ("machine_state", "expected_state"), + [ + (MachineState.Standby, "standby"), + (MachineState.Setting, "setting"), + (MachineState.DelayCountdownMode, "delay_countdown"), + (MachineState.DelayPause, "delay_paused"), + (MachineState.SmartDelay, "smart_delay"), + (MachineState.SmartGridPause, "smart_grid_pause"), + (MachineState.Pause, "pause"), + (MachineState.RunningMainCycle, "running_maincycle"), + (MachineState.RunningPostCycle, "running_postcycle"), + (MachineState.Exceptions, "exception"), + (MachineState.Complete, "complete"), + (MachineState.PowerFailure, "power_failure"), + (MachineState.ServiceDiagnostic, "service_diagnostic_mode"), + (MachineState.FactoryDiagnostic, "factory_diagnostic_mode"), + (MachineState.LifeTest, "life_test"), + (MachineState.CustomerFocusMode, "customer_focus_mode"), + (MachineState.DemoMode, "demo_mode"), + (MachineState.HardStopOrError, "hard_stop_or_error"), + (MachineState.SystemInit, "system_initialize"), + ], +) +async def test_washer_dryer_machine_states( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + machine_state: MachineState, + expected_state: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer machine states.""" + mock_instance = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + mock_instance.get_machine_state.return_value = machine_state + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_state", "mock_washer_api"), + ("sensor.dryer_state", "mock_dryer_api"), + ], +) +@pytest.mark.parametrize( + ( + "filling", + "rinsing", + "sensing", + "soaking", + "spinning", + "washing", + "expected_state", + ), + [ + (True, False, False, False, False, False, "cycle_filling"), + (False, True, False, False, False, False, "cycle_rinsing"), + (False, False, True, False, False, False, "cycle_sensing"), + (False, False, False, True, False, False, "cycle_soaking"), + (False, False, False, False, True, False, "cycle_spinning"), + (False, False, False, False, False, True, "cycle_washing"), + ], +) +async def test_washer_dryer_running_states( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + filling: bool, + rinsing: bool, + sensing: bool, + soaking: bool, + spinning: bool, + washing: bool, + expected_state: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer machine states for RunningMainCycle.""" + mock_instance = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + mock_instance.get_cycle_status_filling.return_value = filling + mock_instance.get_cycle_status_rinsing.return_value = rinsing + mock_instance.get_cycle_status_sensing.return_value = sensing + mock_instance.get_cycle_status_soaking.return_value = soaking + mock_instance.get_cycle_status_spinning.return_value = spinning + mock_instance.get_cycle_status_washing.return_value = washing + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture"), + [ + ("sensor.washer_state", "mock_washer_api"), + ("sensor.dryer_state", "mock_dryer_api"), + ], +) +async def test_washer_dryer_door_open_state( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + request: pytest.FixtureRequest, +) -> None: + """Test Washer/Dryer machine state when door is open.""" + mock_instance = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state.state == "running_maincycle" + + mock_instance.get_door_open.return_value = True + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state == "door_open" + + mock_instance.get_door_open.return_value = False + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state.state == "running_maincycle" + + +@pytest.mark.parametrize( + ("entity_id", "mock_fixture", "mock_method_name", "values"), + [ + ( + "sensor.washer_detergent_level", + "mock_washer_api", + "get_dispense_1_level", + [ + (0, STATE_UNKNOWN), + (1, "empty"), + (2, "25"), + (3, "50"), + (4, "100"), + (5, "active"), + ], + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_simple_enum_sensors( + hass: HomeAssistant, + entity_id: str, + mock_fixture: str, + mock_method_name: str, + values: list[tuple[int, str]], + request: pytest.FixtureRequest, +) -> None: + """Test simple enum sensors where state maps directly from a single API value.""" + await init_integration(hass) + + mock_instance = request.getfixturevalue(mock_fixture) + mock_method = getattr(mock_instance, mock_method_name) + for raw_value, expected_state in values: + mock_method.return_value = raw_value + + await trigger_attr_callback(hass, mock_instance) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state diff --git a/tests/components/whois/conftest.py b/tests/components/whois/conftest.py index 4bb18581c1a..c4138a5d1d2 100644 --- a/tests/components/whois/conftest.py +++ b/tests/components/whois/conftest.py @@ -63,7 +63,7 @@ def mock_whois() -> Generator[MagicMock]: domain.registrant = "registrant@example.com" domain.registrar = "My Registrar" domain.reseller = "Top Domains, Low Prices" - domain.status = "OK" + domain.status = "ok" domain.statuses = ["OK"] yield whois_mock @@ -86,7 +86,7 @@ def mock_whois_missing_some_attrs() -> Generator[Mock]: self.name = "home-assistant.io" self.name_servers = ["ns1.example.com", "ns2.example.com"] self.registrar = "My Registrar" - self.status = "OK" + self.status = "ok" self.statuses = ["OK"] with patch( diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index 0d99b0596e3..97d6fde6376 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -175,6 +175,94 @@ 'version': 1, }) # --- +# name: test_full_flow_with_error[WhoisPrivateRegistry-private_registry] + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'example.com', + }), + 'data': dict({ + 'domain': 'example.com', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'whois', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'domain': 'example.com', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'whois', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Example.com', + 'unique_id': 'example.com', + 'version': 1, + }), + 'subentries': tuple( + ), + 'title': 'Example.com', + 'type': , + 'version': 1, + }) +# --- +# name: test_full_flow_with_error[WhoisQuotaExceeded-quota_exceeded] + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'example.com', + }), + 'data': dict({ + 'domain': 'example.com', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'whois', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'domain': 'example.com', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'whois', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Example.com', + 'unique_id': 'example.com', + 'version': 1, + }), + 'subentries': tuple( + ), + 'title': 'Example.com', + 'type': , + 'version': 1, + }) +# --- # name: test_full_user_flow FlowResultSnapshot({ 'context': dict({ diff --git a/tests/components/whois/snapshots/test_diagnostics.ambr b/tests/components/whois/snapshots/test_diagnostics.ambr index f373a20700e..a498d0f88e9 100644 --- a/tests/components/whois/snapshots/test_diagnostics.ambr +++ b/tests/components/whois/snapshots/test_diagnostics.ambr @@ -5,7 +5,7 @@ 'dnssec': True, 'expiration_date': '2023-01-01T00:00:00', 'last_updated': '2022-01-01T00:00:00+01:00', - 'status': 'OK', + 'status': 'ok', 'statuses': list([ 'OK', ]), diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index b5b1dde1c3d..61499ba0f9d 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -727,6 +727,138 @@ 'via_device_id': None, }) # --- +# name: test_whois_sensors[sensor.home_assistant_io_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'home-assistant.io Status', + 'options': list([ + 'add_period', + 'auto_renew_period', + 'inactive', + 'active', + 'pending_create', + 'pending_renew', + 'pending_restore', + 'pending_transfer', + 'pending_update', + 'redemption_period', + 'renew_period', + 'server_delete_prohibited', + 'server_hold', + 'server_renew_prohibited', + 'server_transfer_prohibited', + 'server_update_prohibited', + 'transfer_period', + 'client_delete_prohibited', + 'client_hold', + 'client_renew_prohibited', + 'client_transfer_prohibited', + 'client_update_prohibited', + 'ok', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_status].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'add_period', + 'auto_renew_period', + 'inactive', + 'active', + 'pending_create', + 'pending_renew', + 'pending_restore', + 'pending_transfer', + 'pending_update', + 'redemption_period', + 'renew_period', + 'server_delete_prohibited', + 'server_hold', + 'server_renew_prohibited', + 'server_transfer_prohibited', + 'server_update_prohibited', + 'transfer_period', + 'client_delete_prohibited', + 'client_hold', + 'client_renew_prohibited', + 'client_transfer_prohibited', + 'client_update_prohibited', + 'ok', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_assistant_io_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': 'whois', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'home-assistant.io_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_status].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'whois', + 'home-assistant.io', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'home-assistant.io', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_whois_sensors_missing_some_attrs StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/whois/test_config_flow.py b/tests/components/whois/test_config_flow.py index 35e40c4e809..6ab02887be2 100644 --- a/tests/components/whois/test_config_flow.py +++ b/tests/components/whois/test_config_flow.py @@ -9,6 +9,8 @@ from whois.exceptions import ( UnknownDateFormat, UnknownTld, WhoisCommandFailed, + WhoisPrivateRegistry, + WhoisQuotaExceeded, ) from homeassistant.components.whois.const import DOMAIN @@ -52,6 +54,8 @@ async def test_full_user_flow( (FailedParsingWhoisOutput, "unexpected_response"), (UnknownDateFormat, "unknown_date_format"), (WhoisCommandFailed, "whois_command_failed"), + (WhoisPrivateRegistry, "private_registry"), + (WhoisQuotaExceeded, "quota_exceeded"), ], ) async def test_full_flow_with_error( diff --git a/tests/components/whois/test_sensor.py b/tests/components/whois/test_sensor.py index d290bc347a9..69e32d923c4 100644 --- a/tests/components/whois/test_sensor.py +++ b/tests/components/whois/test_sensor.py @@ -32,6 +32,7 @@ pytestmark = [ "sensor.home_assistant_io_registrant", "sensor.home_assistant_io_registrar", "sensor.home_assistant_io_reseller", + "sensor.home_assistant_io_status", ], ) async def test_whois_sensors( @@ -73,6 +74,7 @@ async def test_whois_sensors_missing_some_attrs( "sensor.home_assistant_io_registrant", "sensor.home_assistant_io_registrar", "sensor.home_assistant_io_reseller", + "sensor.home_assistant_io_status", ], ) async def test_disabled_by_default_sensors( @@ -98,6 +100,7 @@ async def test_disabled_by_default_sensors( "sensor.home_assistant_io_registrant", "sensor.home_assistant_io_registrar", "sensor.home_assistant_io_reseller", + "sensor.home_assistant_io_status", ], ) async def test_no_data( diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index ec9fc1ed3fc..f735c506f65 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -503,6 +503,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -512,7 +515,7 @@ 'supported_features': 0, 'translation_key': 'deep_sleep', 'unique_id': 'withings_12345_sleep_deep_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_deep_sleep-state] @@ -521,14 +524,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Deep sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_deep_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5820', + 'state': '1.617', }) # --- # name: test_all_entities[sensor.henk_diastolic_blood_pressure-entry] @@ -1778,7 +1781,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 1, + 'suggested_display_precision': 2, }), }), 'original_device_class': , @@ -2242,6 +2245,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2251,7 +2257,7 @@ 'supported_features': 0, 'translation_key': 'light_sleep', 'unique_id': 'withings_12345_sleep_light_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_light_sleep-state] @@ -2260,14 +2266,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Light sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_light_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '10440', + 'state': '2.900', }) # --- # name: test_all_entities[sensor.henk_maximum_heart_rate-entry] @@ -2988,6 +2994,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2997,7 +3006,7 @@ 'supported_features': 0, 'translation_key': 'rem_sleep', 'unique_id': 'withings_12345_sleep_rem_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_rem_sleep-state] @@ -3006,14 +3015,14 @@ 'device_class': 'duration', 'friendly_name': 'henk REM sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_rem_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2400', + 'state': '0.667', }) # --- # name: test_all_entities[sensor.henk_skin_temperature-entry] @@ -3616,6 +3625,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -3625,7 +3637,7 @@ 'supported_features': 0, 'translation_key': 'time_to_sleep', 'unique_id': 'withings_12345_sleep_tosleep_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_time_to_sleep-state] @@ -3634,14 +3646,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Time to sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_time_to_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '540', + 'state': '0.150', }) # --- # name: test_all_entities[sensor.henk_time_to_wakeup-entry] @@ -3668,6 +3680,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -3677,7 +3692,7 @@ 'supported_features': 0, 'translation_key': 'time_to_wakeup', 'unique_id': 'withings_12345_sleep_towakeup_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_time_to_wakeup-state] @@ -3686,14 +3701,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Time to wakeup', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_time_to_wakeup', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1140', + 'state': '0.317', }) # --- # name: test_all_entities[sensor.henk_total_calories_burnt_today-entry] @@ -3971,6 +3986,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -3980,7 +3998,7 @@ 'supported_features': 0, 'translation_key': 'wakeup_time', 'unique_id': 'withings_12345_sleep_wakeup_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_wakeup_time-state] @@ -3989,14 +4007,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Wakeup time', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_wakeup_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3060', + 'state': '0.850', }) # --- # name: test_all_entities[sensor.henk_weight-entry] diff --git a/tests/components/withings/test_diagnostics.py b/tests/components/withings/test_diagnostics.py index 51f54b2ab17..2b58d6d22cf 100644 --- a/tests/components/withings/test_diagnostics.py +++ b/tests/components/withings/test_diagnostics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index d88af39488b..e71402b8a98 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -15,7 +15,7 @@ from aiowithings import ( ) from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components import cloud diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 20927c197a4..0b863721f85 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from aiowithings import Goals from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.withings import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform diff --git a/tests/components/wiz/test_diagnostics.py b/tests/components/wiz/test_diagnostics.py index 07178d5e93b..14fbdbf916a 100644 --- a/tests/components/wiz/test_diagnostics.py +++ b/tests/components/wiz/test_diagnostics.py @@ -1,6 +1,6 @@ """Test WiZ diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index ca3b0a5dc6e..d3f8fbcc21d 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -99,77 +99,77 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Segment 1 color palette', 'options': list([ - 'Default', - '* Random Cycle', '* Color 1', - '* Colors 1&2', '* Color Gradient', + '* Colors 1&2', '* Colors Only', - 'Party', - 'Cloud', - 'Lava', - 'Ocean', - 'Forest', - 'Rainbow', - 'Rainbow Bands', - 'Sunset', - 'Rivendell', - 'Breeze', - 'Red & Blue', - 'Yellowout', + '* Random Cycle', 'Analogous', - 'Splash', - 'Pastel', - 'Sunset 2', - 'Beach', - 'Vintage', - 'Departure', - 'Landscape', - 'Beech', - 'Sherbet', - 'Hult', - 'Hult 64', - 'Drywet', - 'Jul', - 'Grintage', - 'Rewhi', - 'Tertiary', - 'Fire', - 'Icefire', - 'Cyane', - 'Light Pink', - 'Autumn', - 'Magenta', - 'Magred', - 'Yelmag', - 'Yelblu', - 'Orange & Teal', - 'Tiamat', 'April Night', - 'Orangery', - 'C9', - 'Sakura', - 'Aurora', + 'Aqua Flash', 'Atlantica', + 'Aurora', + 'Aurora 2', + 'Autumn', + 'Beach', + 'Beech', + 'Blink Red', + 'Breeze', + 'C9', 'C9 2', 'C9 New', - 'Temperature', - 'Aurora 2', - 'Retro Clown', 'Candy', - 'Toxy Reaf', + 'Candy2', + 'Cloud', + 'Cyane', + 'Default', + 'Departure', + 'Drywet', 'Fairy Reaf', - 'Semi Blue', - 'Pink Candy', - 'Red Reaf', - 'Aqua Flash', - 'Yelblu Hot', + 'Fire', + 'Forest', + 'Grintage', + 'Hult', + 'Hult 64', + 'Icefire', + 'Jul', + 'Landscape', + 'Lava', + 'Light Pink', 'Lite Light', + 'Magenta', + 'Magred', + 'Ocean', + 'Orange & Teal', + 'Orangery', + 'Party', + 'Pastel', + 'Pink Candy', + 'Rainbow', + 'Rainbow Bands', + 'Red & Blue', 'Red Flash', - 'Blink Red', + 'Red Reaf', 'Red Shift', 'Red Tide', - 'Candy2', + 'Retro Clown', + 'Rewhi', + 'Rivendell', + 'Sakura', + 'Semi Blue', + 'Sherbet', + 'Splash', + 'Sunset', + 'Sunset 2', + 'Temperature', + 'Tertiary', + 'Tiamat', + 'Toxy Reaf', + 'Vintage', + 'Yelblu', + 'Yelblu Hot', + 'Yellowout', + 'Yelmag', ]), }), 'context': , @@ -187,77 +187,77 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'Default', - '* Random Cycle', '* Color 1', - '* Colors 1&2', '* Color Gradient', + '* Colors 1&2', '* Colors Only', - 'Party', - 'Cloud', - 'Lava', - 'Ocean', - 'Forest', - 'Rainbow', - 'Rainbow Bands', - 'Sunset', - 'Rivendell', - 'Breeze', - 'Red & Blue', - 'Yellowout', + '* Random Cycle', 'Analogous', - 'Splash', - 'Pastel', - 'Sunset 2', - 'Beach', - 'Vintage', - 'Departure', - 'Landscape', - 'Beech', - 'Sherbet', - 'Hult', - 'Hult 64', - 'Drywet', - 'Jul', - 'Grintage', - 'Rewhi', - 'Tertiary', - 'Fire', - 'Icefire', - 'Cyane', - 'Light Pink', - 'Autumn', - 'Magenta', - 'Magred', - 'Yelmag', - 'Yelblu', - 'Orange & Teal', - 'Tiamat', 'April Night', - 'Orangery', - 'C9', - 'Sakura', - 'Aurora', + 'Aqua Flash', 'Atlantica', + 'Aurora', + 'Aurora 2', + 'Autumn', + 'Beach', + 'Beech', + 'Blink Red', + 'Breeze', + 'C9', 'C9 2', 'C9 New', - 'Temperature', - 'Aurora 2', - 'Retro Clown', 'Candy', - 'Toxy Reaf', + 'Candy2', + 'Cloud', + 'Cyane', + 'Default', + 'Departure', + 'Drywet', 'Fairy Reaf', - 'Semi Blue', - 'Pink Candy', - 'Red Reaf', - 'Aqua Flash', - 'Yelblu Hot', + 'Fire', + 'Forest', + 'Grintage', + 'Hult', + 'Hult 64', + 'Icefire', + 'Jul', + 'Landscape', + 'Lava', + 'Light Pink', 'Lite Light', + 'Magenta', + 'Magred', + 'Ocean', + 'Orange & Teal', + 'Orangery', + 'Party', + 'Pastel', + 'Pink Candy', + 'Rainbow', + 'Rainbow Bands', + 'Red & Blue', 'Red Flash', - 'Blink Red', + 'Red Reaf', 'Red Shift', 'Red Tide', - 'Candy2', + 'Retro Clown', + 'Rewhi', + 'Rivendell', + 'Sakura', + 'Semi Blue', + 'Sherbet', + 'Splash', + 'Sunset', + 'Sunset 2', + 'Temperature', + 'Tertiary', + 'Tiamat', + 'Toxy Reaf', + 'Vintage', + 'Yelblu', + 'Yelblu Hot', + 'Yellowout', + 'Yelmag', ]), }), 'config_entry_id': , diff --git a/tests/components/wmspro/conftest.py b/tests/components/wmspro/conftest.py index 4b0e7eb4fef..dc648dafcc2 100644 --- a/tests/components/wmspro/conftest.py +++ b/tests/components/wmspro/conftest.py @@ -55,17 +55,29 @@ def mock_hub_configuration_test() -> Generator[AsyncMock]: """Override WebControlPro.configuration.""" with patch( "wmspro.webcontrol.WebControlPro._getConfiguration", - return_value=load_json_object_fixture("example_config_test.json", DOMAIN), + return_value=load_json_object_fixture("config_test.json", DOMAIN), ) as mock_hub_configuration: yield mock_hub_configuration @pytest.fixture -def mock_hub_configuration_prod() -> Generator[AsyncMock]: +def mock_hub_configuration_prod_awning_dimmer() -> Generator[AsyncMock]: """Override WebControlPro._getConfiguration.""" with patch( "wmspro.webcontrol.WebControlPro._getConfiguration", - return_value=load_json_object_fixture("example_config_prod.json", DOMAIN), + return_value=load_json_object_fixture("config_prod_awning_dimmer.json", DOMAIN), + ) as mock_hub_configuration: + yield mock_hub_configuration + + +@pytest.fixture +def mock_hub_configuration_prod_roller_shutter() -> Generator[AsyncMock]: + """Override WebControlPro._getConfiguration.""" + with patch( + "wmspro.webcontrol.WebControlPro._getConfiguration", + return_value=load_json_object_fixture( + "config_prod_roller_shutter.json", DOMAIN + ), ) as mock_hub_configuration: yield mock_hub_configuration @@ -75,23 +87,31 @@ def mock_hub_status_prod_awning() -> Generator[AsyncMock]: """Override WebControlPro._getStatus.""" with patch( "wmspro.webcontrol.WebControlPro._getStatus", - return_value=load_json_object_fixture( - "example_status_prod_awning.json", DOMAIN - ), - ) as mock_dest_refresh: - yield mock_dest_refresh + return_value=load_json_object_fixture("status_prod_awning.json", DOMAIN), + ) as mock_hub_status: + yield mock_hub_status @pytest.fixture def mock_hub_status_prod_dimmer() -> Generator[AsyncMock]: + """Override WebControlPro._getStatus.""" + with patch( + "wmspro.webcontrol.WebControlPro._getStatus", + return_value=load_json_object_fixture("status_prod_dimmer.json", DOMAIN), + ) as mock_hub_status: + yield mock_hub_status + + +@pytest.fixture +def mock_hub_status_prod_roller_shutter() -> Generator[AsyncMock]: """Override WebControlPro._getStatus.""" with patch( "wmspro.webcontrol.WebControlPro._getStatus", return_value=load_json_object_fixture( - "example_status_prod_dimmer.json", DOMAIN + "status_prod_roller_shutter.json", DOMAIN ), - ) as mock_dest_refresh: - yield mock_dest_refresh + ) as mock_hub_status: + yield mock_hub_status @pytest.fixture @@ -100,8 +120,8 @@ def mock_dest_refresh() -> Generator[AsyncMock]: with patch( "wmspro.destination.Destination.refresh", return_value=True, - ) as mock_dest_refresh: - yield mock_dest_refresh + ) as mock_hub_status: + yield mock_hub_status @pytest.fixture diff --git a/tests/components/wmspro/fixtures/example_config_prod.json b/tests/components/wmspro/fixtures/config_prod_awning_dimmer.json similarity index 100% rename from tests/components/wmspro/fixtures/example_config_prod.json rename to tests/components/wmspro/fixtures/config_prod_awning_dimmer.json diff --git a/tests/components/wmspro/fixtures/config_prod_roller_shutter.json b/tests/components/wmspro/fixtures/config_prod_roller_shutter.json new file mode 100644 index 00000000000..b865c32f18a --- /dev/null +++ b/tests/components/wmspro/fixtures/config_prod_roller_shutter.json @@ -0,0 +1,171 @@ +{ + "command": "getConfiguration", + "protocolVersion": "1.0.0", + "destinations": [ + { + "id": 18894, + "animationType": 2, + "names": ["Wohnebene alle", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 116682, + "animationType": 2, + "names": ["Wohnzimmer", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 172555, + "animationType": 2, + "names": ["Badezimmer", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 230952, + "animationType": 2, + "names": ["Sportzimmer", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 284942, + "animationType": 2, + "names": ["Terrasse", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 328518, + "animationType": 2, + "names": ["alle Rolll\u00e4den", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 4, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + } + ], + "rooms": [ + { + "id": 15175, + "name": "Wohnbereich", + "destinations": [18894, 116682, 172555, 230952], + "scenes": [] + }, + { + "id": 92218, + "name": "Terrasse", + "destinations": [284942], + "scenes": [] + }, + { + "id": 193582, + "name": "Alle", + "destinations": [328518], + "scenes": [] + } + ], + "scenes": [] +} diff --git a/tests/components/wmspro/fixtures/example_config_test.json b/tests/components/wmspro/fixtures/config_test.json similarity index 100% rename from tests/components/wmspro/fixtures/example_config_test.json rename to tests/components/wmspro/fixtures/config_test.json diff --git a/tests/components/wmspro/fixtures/example_status_prod_awning.json b/tests/components/wmspro/fixtures/status_prod_awning.json similarity index 100% rename from tests/components/wmspro/fixtures/example_status_prod_awning.json rename to tests/components/wmspro/fixtures/status_prod_awning.json diff --git a/tests/components/wmspro/fixtures/example_status_prod_dimmer.json b/tests/components/wmspro/fixtures/status_prod_dimmer.json similarity index 100% rename from tests/components/wmspro/fixtures/example_status_prod_dimmer.json rename to tests/components/wmspro/fixtures/status_prod_dimmer.json diff --git a/tests/components/wmspro/fixtures/status_prod_roller_shutter.json b/tests/components/wmspro/fixtures/status_prod_roller_shutter.json new file mode 100644 index 00000000000..a409c61b1b3 --- /dev/null +++ b/tests/components/wmspro/fixtures/status_prod_roller_shutter.json @@ -0,0 +1,22 @@ +{ + "command": "getStatus", + "protocolVersion": "1.0.0", + "details": [ + { + "destinationId": 18894, + "data": { + "drivingCause": 0, + "heartbeatError": false, + "blocking": false, + "productData": [ + { + "actionId": 0, + "value": { + "percentage": 100 + } + } + ] + } + } + ] +} diff --git a/tests/components/wmspro/snapshots/test_button.ambr b/tests/components/wmspro/snapshots/test_button.ambr new file mode 100644 index 00000000000..431a92c26d6 --- /dev/null +++ b/tests/components/wmspro/snapshots/test_button.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_button_update + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by WMS WebControl pro API', + 'device_class': 'identify', + 'friendly_name': 'Markise Identify', + }), + 'context': , + 'entity_id': 'button.markise_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/wmspro/snapshots/test_diagnostics.ambr b/tests/components/wmspro/snapshots/test_diagnostics.ambr index 00cb62e18c4..0c5edd91315 100644 --- a/tests/components/wmspro/snapshots/test_diagnostics.ambr +++ b/tests/components/wmspro/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_diagnostics +# name: test_diagnostics[mock_hub_configuration_prod_awning_dimmer] dict({ 'config': dict({ 'command': 'getConfiguration', @@ -242,3 +242,540 @@ }), }) # --- +# name: test_diagnostics[mock_hub_configuration_prod_roller_shutter] + dict({ + 'config': dict({ + 'command': 'getConfiguration', + 'destinations': list([ + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 18894, + 'names': list([ + 'Wohnebene alle', + '', + '', + '', + ]), + }), + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 116682, + 'names': list([ + 'Wohnzimmer', + '', + '', + '', + ]), + }), + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 172555, + 'names': list([ + 'Badezimmer', + '', + '', + '', + ]), + }), + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 230952, + 'names': list([ + 'Sportzimmer', + '', + '', + '', + ]), + }), + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 284942, + 'names': list([ + 'Terrasse', + '', + '', + '', + ]), + }), + dict({ + 'actions': list([ + dict({ + 'actionDescription': 4, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 2, + 'id': 328518, + 'names': list([ + 'alle Rollläden', + '', + '', + '', + ]), + }), + ]), + 'protocolVersion': '1.0.0', + 'rooms': list([ + dict({ + 'destinations': list([ + 18894, + 116682, + 172555, + 230952, + ]), + 'id': 15175, + 'name': 'Wohnbereich', + 'scenes': list([ + ]), + }), + dict({ + 'destinations': list([ + 284942, + ]), + 'id': 92218, + 'name': 'Terrasse', + 'scenes': list([ + ]), + }), + dict({ + 'destinations': list([ + 328518, + ]), + 'id': 193582, + 'name': 'Alle', + 'scenes': list([ + ]), + }), + ]), + 'scenes': list([ + ]), + }), + 'dests': dict({ + '116682': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 116682, + 'name': 'Wohnzimmer', + 'room': dict({ + '15175': 'Wohnbereich', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + '172555': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 172555, + 'name': 'Badezimmer', + 'room': dict({ + '15175': 'Wohnbereich', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + '18894': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 18894, + 'name': 'Wohnebene alle', + 'room': dict({ + '15175': 'Wohnbereich', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + '230952': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 230952, + 'name': 'Sportzimmer', + 'room': dict({ + '15175': 'Wohnbereich', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + '284942': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 284942, + 'name': 'Terrasse', + 'room': dict({ + '92218': 'Terrasse', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + '328518': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'RollerShutterBlindDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'RollerShutterBlind', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 328518, + 'name': 'alle Rollläden', + 'room': dict({ + '193582': 'Alle', + }), + 'status': dict({ + }), + 'unknownProducts': dict({ + }), + }), + }), + 'host': 'webcontrol', + 'rooms': dict({ + '15175': dict({ + 'destinations': dict({ + '116682': 'Wohnzimmer', + '172555': 'Badezimmer', + '18894': 'Wohnebene alle', + '230952': 'Sportzimmer', + }), + 'id': 15175, + 'name': 'Wohnbereich', + 'scenes': dict({ + }), + }), + '193582': dict({ + 'destinations': dict({ + '328518': 'alle Rollläden', + }), + 'id': 193582, + 'name': 'Alle', + 'scenes': dict({ + }), + }), + '92218': dict({ + 'destinations': dict({ + '284942': 'Terrasse', + }), + 'id': 92218, + 'name': 'Terrasse', + 'scenes': dict({ + }), + }), + }), + 'scenes': dict({ + }), + }) +# --- diff --git a/tests/components/wmspro/snapshots/test_init.ambr b/tests/components/wmspro/snapshots/test_init.ambr new file mode 100644 index 00000000000..147d66f2b69 --- /dev/null +++ b/tests/components/wmspro/snapshots/test_init.ambr @@ -0,0 +1,397 @@ +# serializer version: 1 +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_awning][device-19239] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '19239', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Room', + 'model_id': None, + 'name': 'Terrasse', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '19239', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_awning][device-58717] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '58717', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Awning', + 'model_id': None, + 'name': 'Markise', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '58717', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_awning][device-97358] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '97358', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Dimmer', + 'model_id': None, + 'name': 'Licht', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '97358', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_dimmer][device-19239] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '19239', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Room', + 'model_id': None, + 'name': 'Terrasse', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '19239', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_dimmer][device-58717] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '58717', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Awning', + 'model_id': None, + 'name': 'Markise', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '58717', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_awning_dimmer-mock_hub_status_prod_dimmer][device-97358] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '97358', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Dimmer', + 'model_id': None, + 'name': 'Licht', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '97358', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-116682] + DeviceRegistryEntrySnapshot({ + 'area_id': 'wohnbereich', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '116682', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'Wohnzimmer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '116682', + 'suggested_area': 'Wohnbereich', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-172555] + DeviceRegistryEntrySnapshot({ + 'area_id': 'wohnbereich', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '172555', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'Badezimmer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '172555', + 'suggested_area': 'Wohnbereich', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-18894] + DeviceRegistryEntrySnapshot({ + 'area_id': 'wohnbereich', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '18894', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'Wohnebene alle', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '18894', + 'suggested_area': 'Wohnbereich', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-230952] + DeviceRegistryEntrySnapshot({ + 'area_id': 'wohnbereich', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '230952', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'Sportzimmer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '230952', + 'suggested_area': 'Wohnbereich', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-284942] + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '284942', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'Terrasse', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '284942', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_device[mock_hub_configuration_prod_roller_shutter-mock_hub_status_prod_roller_shutter][device-328518] + DeviceRegistryEntrySnapshot({ + 'area_id': 'alle', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '328518', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'RollerShutterBlind', + 'model_id': None, + 'name': 'alle Rollläden', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '328518', + 'suggested_area': 'Alle', + 'sw_version': None, + 'via_device_id': , + }) +# --- diff --git a/tests/components/wmspro/test_button.py b/tests/components/wmspro/test_button.py new file mode 100644 index 00000000000..980b347ea2b --- /dev/null +++ b/tests/components/wmspro/test_button.py @@ -0,0 +1,66 @@ +"""Test the wmspro button support.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import setup_config_entry + +from tests.common import MockConfigEntry + + +async def test_button_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + mock_action_call: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that a button entity is created and updated correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) == 2 + + entity = hass.states.get("button.markise_identify") + assert entity is not None + assert entity == snapshot + + +async def test_button_press( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + mock_action_call: AsyncMock, +) -> None: + """Test that a button entity is pressed correctly.""" + + assert await setup_config_entry(hass, mock_config_entry) + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + entity = hass.states.get("button.markise_identify") + before_state = entity.state + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + entity = hass.states.get("button.markise_identify") + assert entity is not None + assert entity.state != before_state + assert len(mock_hub_status_prod_awning.mock_calls) == before diff --git a/tests/components/wmspro/test_config_flow.py b/tests/components/wmspro/test_config_flow.py index 2c628bbc296..dc56d2bf988 100644 --- a/tests/components/wmspro/test_config_flow.py +++ b/tests/components/wmspro/test_config_flow.py @@ -367,13 +367,15 @@ async def test_config_flow_multiple_entries( mock_hub_ping: AsyncMock, mock_dest_refresh: AsyncMock, mock_hub_configuration_test: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, ) -> None: """Test we allow creation of different config entries.""" await setup_config_entry(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED - mock_hub_configuration_prod.return_value = mock_hub_configuration_test.return_value + mock_hub_configuration_prod_awning_dimmer.return_value = ( + mock_hub_configuration_test.return_value + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/tests/components/wmspro/test_cover.py b/tests/components/wmspro/test_cover.py index 2c20ef51b64..f28d7f849ef 100644 --- a/tests/components/wmspro/test_cover.py +++ b/tests/components/wmspro/test_cover.py @@ -3,7 +3,8 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.wmspro.const import DOMAIN from homeassistant.components.wmspro.cover import SCAN_INTERVAL @@ -29,7 +30,7 @@ async def test_cover_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_awning: AsyncMock, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, @@ -37,7 +38,7 @@ async def test_cover_device( """Test that a cover device is created correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_awning.mock_calls) == 2 device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "58717")}) @@ -49,7 +50,7 @@ async def test_cover_update( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_awning: AsyncMock, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, @@ -57,7 +58,7 @@ async def test_cover_update( """Test that a cover entity is created and updated correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_awning.mock_calls) == 2 entity = hass.states.get("cover.markise") @@ -72,21 +73,41 @@ async def test_cover_update( assert len(mock_hub_status_prod_awning.mock_calls) >= 3 +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status", "entity_name"), + [ + ( + "mock_hub_configuration_prod_awning_dimmer", + "mock_hub_status_prod_awning", + "cover.markise", + ), + ( + "mock_hub_configuration_prod_roller_shutter", + "mock_hub_status_prod_roller_shutter", + "cover.wohnebene_alle", + ), + ], +) async def test_cover_open_and_close( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, - mock_hub_status_prod_awning: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, mock_action_call: AsyncMock, + request: pytest.FixtureRequest, + entity_name: str, ) -> None: """Test that a cover entity is opened and closed correctly.""" + mock_hub_configuration = request.getfixturevalue(mock_hub_configuration) + mock_hub_status = request.getfixturevalue(mock_hub_status) + assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 - assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) >= 1 - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_CLOSED assert entity.attributes["current_position"] == 0 @@ -95,7 +116,7 @@ async def test_cover_open_and_close( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status_prod_awning.mock_calls) + before = len(mock_hub_status.mock_calls) await hass.services.async_call( Platform.COVER, @@ -104,17 +125,17 @@ async def test_cover_open_and_close( blocking=True, ) - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes["current_position"] == 100 - assert len(mock_hub_status_prod_awning.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status_prod_awning.mock_calls) + before = len(mock_hub_status.mock_calls) await hass.services.async_call( Platform.COVER, @@ -123,28 +144,48 @@ async def test_cover_open_and_close( blocking=True, ) - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_CLOSED assert entity.attributes["current_position"] == 0 - assert len(mock_hub_status_prod_awning.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status", "entity_name"), + [ + ( + "mock_hub_configuration_prod_awning_dimmer", + "mock_hub_status_prod_awning", + "cover.markise", + ), + ( + "mock_hub_configuration_prod_roller_shutter", + "mock_hub_status_prod_roller_shutter", + "cover.wohnebene_alle", + ), + ], +) async def test_cover_open_to_pos( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, - mock_hub_status_prod_awning: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, mock_action_call: AsyncMock, + request: pytest.FixtureRequest, + entity_name: str, ) -> None: """Test that a cover entity is opened to correct position.""" + mock_hub_configuration = request.getfixturevalue(mock_hub_configuration) + mock_hub_status = request.getfixturevalue(mock_hub_status) + assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 - assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) >= 1 - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_CLOSED assert entity.attributes["current_position"] == 0 @@ -153,7 +194,7 @@ async def test_cover_open_to_pos( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status_prod_awning.mock_calls) + before = len(mock_hub_status.mock_calls) await hass.services.async_call( Platform.COVER, @@ -162,28 +203,48 @@ async def test_cover_open_to_pos( blocking=True, ) - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes["current_position"] == 50 - assert len(mock_hub_status_prod_awning.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status", "entity_name"), + [ + ( + "mock_hub_configuration_prod_awning_dimmer", + "mock_hub_status_prod_awning", + "cover.markise", + ), + ( + "mock_hub_configuration_prod_roller_shutter", + "mock_hub_status_prod_roller_shutter", + "cover.wohnebene_alle", + ), + ], +) async def test_cover_open_and_stop( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, - mock_hub_status_prod_awning: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, mock_action_call: AsyncMock, + request: pytest.FixtureRequest, + entity_name: str, ) -> None: """Test that a cover entity is opened and stopped correctly.""" + mock_hub_configuration = request.getfixturevalue(mock_hub_configuration) + mock_hub_status = request.getfixturevalue(mock_hub_status) + assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 - assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) >= 1 - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_CLOSED assert entity.attributes["current_position"] == 0 @@ -192,7 +253,7 @@ async def test_cover_open_and_stop( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status_prod_awning.mock_calls) + before = len(mock_hub_status.mock_calls) await hass.services.async_call( Platform.COVER, @@ -201,17 +262,17 @@ async def test_cover_open_and_stop( blocking=True, ) - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes["current_position"] == 80 - assert len(mock_hub_status_prod_awning.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status_prod_awning.mock_calls) + before = len(mock_hub_status.mock_calls) await hass.services.async_call( Platform.COVER, @@ -220,8 +281,8 @@ async def test_cover_open_and_stop( blocking=True, ) - entity = hass.states.get("cover.markise") + entity = hass.states.get(entity_name) assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes["current_position"] == 80 - assert len(mock_hub_status_prod_awning.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before diff --git a/tests/components/wmspro/test_diagnostics.py b/tests/components/wmspro/test_diagnostics.py index 930c3f2898e..43313402f78 100644 --- a/tests/components/wmspro/test_diagnostics.py +++ b/tests/components/wmspro/test_diagnostics.py @@ -2,7 +2,8 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -13,20 +14,30 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.parametrize( + ("mock_hub_configuration"), + [ + ("mock_hub_configuration_prod_awning_dimmer"), + ("mock_hub_configuration_prod_roller_shutter"), + ], +) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration: AsyncMock, mock_dest_refresh: AsyncMock, snapshot: SnapshotAssertion, + request: pytest.FixtureRequest, ) -> None: """Test that a config entry can be loaded with DeviceConfig.""" + mock_hub_configuration = request.getfixturevalue(mock_hub_configuration) + assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 - assert len(mock_dest_refresh.mock_calls) == 2 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_dest_refresh.mock_calls) > 0 result = await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry diff --git a/tests/components/wmspro/test_init.py b/tests/components/wmspro/test_init.py index aeb5f3db152..c0fab8e2c81 100644 --- a/tests/components/wmspro/test_init.py +++ b/tests/components/wmspro/test_init.py @@ -3,9 +3,13 @@ from unittest.mock import AsyncMock import aiohttp +import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.wmspro.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_config_entry @@ -36,3 +40,49 @@ async def test_config_entry_device_config_refresh_failed( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_refresh.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status"), + [ + ("mock_hub_configuration_prod_awning_dimmer", "mock_hub_status_prod_awning"), + ("mock_hub_configuration_prod_awning_dimmer", "mock_hub_status_prod_dimmer"), + ( + "mock_hub_configuration_prod_roller_shutter", + "mock_hub_status_prod_roller_shutter", + ), + ], +) +async def test_cover_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + request: pytest.FixtureRequest, +) -> None: + """Test that the device is created correctly.""" + mock_hub_configuration = request.getfixturevalue(mock_hub_configuration) + mock_hub_status = request.getfixturevalue(mock_hub_status) + + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) > 0 + + device_entries = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(device_entries) > 1 + + device_entries = list( + filter( + lambda e: e.identifiers != {(DOMAIN, mock_config_entry.entry_id)}, + device_entries, + ) + ) + assert len(device_entries) > 0 + for device_entry in device_entries: + assert device_entry == snapshot(name=f"device-{device_entry.serial_number}") diff --git a/tests/components/wmspro/test_light.py b/tests/components/wmspro/test_light.py index db53b54a2f6..749c1d9104b 100644 --- a/tests/components/wmspro/test_light.py +++ b/tests/components/wmspro/test_light.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.wmspro.const import DOMAIN @@ -28,7 +28,7 @@ async def test_light_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_dimmer: AsyncMock, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, @@ -36,7 +36,7 @@ async def test_light_device( """Test that a light device is created correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_dimmer.mock_calls) == 2 device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "97358")}) @@ -48,7 +48,7 @@ async def test_light_update( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_dimmer: AsyncMock, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, @@ -56,7 +56,7 @@ async def test_light_update( """Test that a light entity is created and updated correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_dimmer.mock_calls) == 2 entity = hass.states.get("light.licht") @@ -75,14 +75,14 @@ async def test_light_turn_on_and_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_dimmer: AsyncMock, mock_action_call: AsyncMock, ) -> None: """Test that a light entity is turned on and off correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_dimmer.mock_calls) >= 1 entity = hass.states.get("light.licht") @@ -133,14 +133,14 @@ async def test_light_dimm_on_and_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hub_ping: AsyncMock, - mock_hub_configuration_prod: AsyncMock, + mock_hub_configuration_prod_awning_dimmer: AsyncMock, mock_hub_status_prod_dimmer: AsyncMock, mock_action_call: AsyncMock, ) -> None: """Test that a light entity is dimmed on and off correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 - assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_configuration_prod_awning_dimmer.mock_calls) == 1 assert len(mock_hub_status_prod_dimmer.mock_calls) >= 1 entity = hass.states.get("light.licht") diff --git a/tests/components/wmspro/test_scene.py b/tests/components/wmspro/test_scene.py index a6b16e5bbc9..9a24d54fa76 100644 --- a/tests/components/wmspro/test_scene.py +++ b/tests/components/wmspro/test_scene.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.wmspro.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON diff --git a/tests/components/wolflink/__init__.py b/tests/components/wolflink/__init__.py index dea7c5195ad..11c82ad9f61 100644 --- a/tests/components/wolflink/__init__.py +++ b/tests/components/wolflink/__init__.py @@ -1 +1,14 @@ """Tests for the Wolf SmartSet Service 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 wolflink 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/wolflink/conftest.py b/tests/components/wolflink/conftest.py new file mode 100644 index 00000000000..5142762b5e4 --- /dev/null +++ b/tests/components/wolflink/conftest.py @@ -0,0 +1,125 @@ +"""Fixtures for Wolflink integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest +from wolf_comm import ( + EnergyParameter, + FlowParameter, + FrequencyParameter, + HoursParameter, + ListItem, + ListItemParameter, + PercentageParameter, + PowerParameter, + Pressure, + RPMParameter, + SimpleParameter, + Temperature, + Value, +) + +from homeassistant.components.wolflink.const import ( + DEVICE_GATEWAY, + DEVICE_ID, + DEVICE_NAME, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Wolf SmartSet", + domain=DOMAIN, + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + DEVICE_NAME: "test-device", + DEVICE_GATEWAY: "5678", + DEVICE_ID: "1234", + }, + unique_id="1234", + version=1, + minor_version=2, + ) + + +@pytest.fixture +def mock_wolflink() -> Generator[MagicMock]: + """Return a mocked wolflink client.""" + with ( + patch( + "homeassistant.components.wolflink.WolfClient", autospec=True + ) as wolflink_mock, + patch( + "homeassistant.components.wolflink.config_flow.WolfClient", + new=wolflink_mock, + ), + ): + wolflink = wolflink_mock.return_value + + wolflink.fetch_parameters.return_value = [ + EnergyParameter( + 6002800000, "Energy Parameter", "Heating", 6005200000, 2000 + ), + ListItemParameter( + 8002800000, + "List Item Parameter", + "Heating", + [ListItem("0", "Aus"), ListItem("1", "Ein")], + 8005200000, + 3001, + ), + PowerParameter(5002800000, "Power Parameter", "Heating", 5005200000, 1000), + Pressure(4002800000, "Pressure Parameter", "Heating", 4005200000, 1000), + Temperature(3002800000, "Temperature Parameter", "Solar", 3005200000, 1000), + PercentageParameter( + 2002800000, "Percentage Parameter", "Solar", 2005200000, 1000 + ), + HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000, 1000), + SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000, 1000), + FrequencyParameter( + 9002800000, "Frequency Parameter", "Heating", 9005200000, 1000 + ), + RPMParameter(1000280001, "RPM Parameter", "Heating", 10005200000, 7000), + FlowParameter(1100280001, "Flow Parameter", "Heating", 11005200000, 8000), + HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000, 1000), + SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000, 1000), + ] + + wolflink.fetch_value.return_value = [ + Value(6002800000, "183", 1), + Value(8002800000, "1", 1), + Value(5002800000, "50", 1), + Value(4002800000, "3", 1), + Value(3002800000, "65", 1), + Value(2002800000, "20", 1), + Value(7002800000, "10", 1), + Value(1002800000, "12", 1), + Value(9002800000, "50", 1), + Value(1000280001, "1500", 1), + Value(1100280001, "5", 1), + ] + + yield wolflink + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wolflink: MagicMock +) -> MockConfigEntry: + """Set up the Wolflink integration for testing.""" + await setup_integration(hass, mock_config_entry) + + return mock_config_entry diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c1ff80c9630 --- /dev/null +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -0,0 +1,603 @@ +# serializer version: 1 +# name: test_device_entry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://www.wolf-smartset.com/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wolflink', + '1234', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WOLF GmbH', + 'model': None, + 'model_id': None, + 'name': 'Wolf SmartSet', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.energy_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:6005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Parameter', + 'parameter_id': 6005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 6002800000, + }), + 'context': , + 'entity_id': 'sensor.energy_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '183', + }) +# --- +# name: test_sensors[sensor.flow_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flow_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flow Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:11005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flow_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flow Parameter', + 'parameter_id': 11005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 1100280001, + }), + 'context': , + 'entity_id': 'sensor.flow_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensors[sensor.frequency_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frequency_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:9005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.frequency_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Frequency Parameter', + 'parameter_id': 9005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 9002800000, + }), + 'context': , + 'entity_id': 'sensor.frequency_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[sensor.hours_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hours_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:clock', + 'original_name': 'Hours Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:7005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.hours_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hours Parameter', + 'icon': 'mdi:clock', + 'parameter_id': 7005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 7002800000, + }), + 'context': , + 'entity_id': 'sensor.hours_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.list_item_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.list_item_parameter', + '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': 'List Item Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': '1234:8005200000', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.list_item_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'List Item Parameter', + 'parameter_id': 8005200000, + 'parent': 'Heating', + 'value_id': 8002800000, + }), + 'context': , + 'entity_id': 'sensor.list_item_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ein', + }) +# --- +# name: test_sensors[sensor.percentage_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.percentage_parameter', + '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': 'Percentage Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:2005200000', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.percentage_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Percentage Parameter', + 'parameter_id': 2005200000, + 'parent': 'Solar', + 'unit_of_measurement': '%', + 'value_id': 2002800000, + }), + 'context': , + 'entity_id': 'sensor.percentage_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_sensors[sensor.power_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:5005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power Parameter', + 'parameter_id': 5005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 5002800000, + }), + 'context': , + 'entity_id': 'sensor.power_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[sensor.pressure_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pressure_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:4005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.pressure_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Pressure Parameter', + 'parameter_id': 4005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 4002800000, + }), + 'context': , + 'entity_id': 'sensor.pressure_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[sensor.rpm_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rpm_parameter', + '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': 'RPM Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:10005200000', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensors[sensor.rpm_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RPM Parameter', + 'parameter_id': 10005200000, + 'parent': 'Heating', + 'state_class': , + 'unit_of_measurement': 'rpm', + 'value_id': 1000280001, + }), + 'context': , + 'entity_id': 'sensor.rpm_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500', + }) +# --- +# name: test_sensors[sensor.simple_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.simple_parameter', + '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': 'Simple Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:1005200000', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.simple_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Simple Parameter', + 'parameter_id': 1005200000, + 'parent': 'DHW', + 'value_id': 1002800000, + }), + 'context': , + 'entity_id': 'sensor.simple_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_sensors[sensor.temperature_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.temperature_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:3005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.temperature_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Temperature Parameter', + 'parameter_id': 3005200000, + 'parent': 'Solar', + 'unit_of_measurement': , + 'value_id': 3002800000, + }), + 'context': , + 'entity_id': 'sensor.temperature_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- diff --git a/tests/components/wolflink/test_sensor.py b/tests/components/wolflink/test_sensor.py new file mode 100644 index 00000000000..ad0325ec06e --- /dev/null +++ b/tests/components/wolflink/test_sensor.py @@ -0,0 +1,45 @@ +"""Test the Wolf SmartSet Service Sensor platform.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, patch, snapshot_platform + + +async def test_device_entry( + hass: HomeAssistant, + mock_wolflink: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device entry creation.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device({(mock_config_entry.domain, "1234")}) + assert device == snapshot + + +async def test_sensors( + hass: HomeAssistant, + mock_wolflink: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test wolflink sensors.""" + + with patch("homeassistant.components.wolflink.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/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 51d4b899d25..c05da654f96 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -55,7 +55,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: [], CONF_REMOVE_HOLIDAYS: [], - CONF_LANGUAGE: "de", + CONF_LANGUAGE: "en_US", }, ) await hass.async_block_till_done() @@ -70,7 +70,48 @@ async def test_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "language": "de", + "language": "en_US", + } + + +async def test_form_province_no_alias(hass: HomeAssistant) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "US", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "US", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], } diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py index 1e0c9cbebc6..2735175b49b 100644 --- a/tests/components/workday/test_init.py +++ b/tests/components/workday/test_init.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime from freezegun.api import FrozenDateTimeFactory +from holidays.utils import country_holidays from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -50,3 +51,18 @@ async def test_update_options( assert entry_check.state is ConfigEntryState.LOADED state = hass.states.get("binary_sensor.workday_sensor") assert state.state == "off" + + +async def test_workday_subdiv_aliases() -> None: + """Test subdiv aliases in holidays library.""" + + country = country_holidays( + country="FR", + years=2025, + ) + subdiv_aliases = country.get_subdivision_aliases() + assert subdiv_aliases["GES"] == [ # codespell:ignore + "Alsace", + "Champagne-Ardenne", + "Lorraine", + ] diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 018fff33821..125edc547c6 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -121,7 +121,9 @@ def handle_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture -async def init_wyoming_stt(hass: HomeAssistant, stt_config_entry: ConfigEntry): +async def init_wyoming_stt( + hass: HomeAssistant, stt_config_entry: ConfigEntry +) -> ConfigEntry: """Initialize Wyoming STT.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -129,9 +131,13 @@ async def init_wyoming_stt(hass: HomeAssistant, stt_config_entry: ConfigEntry): ): await hass.config_entries.async_setup(stt_config_entry.entry_id) + return stt_config_entry + @pytest.fixture -async def init_wyoming_tts(hass: HomeAssistant, tts_config_entry: ConfigEntry): +async def init_wyoming_tts( + hass: HomeAssistant, tts_config_entry: ConfigEntry +) -> ConfigEntry: """Initialize Wyoming TTS.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -139,11 +145,13 @@ async def init_wyoming_tts(hass: HomeAssistant, tts_config_entry: ConfigEntry): ): await hass.config_entries.async_setup(tts_config_entry.entry_id) + return tts_config_entry + @pytest.fixture async def init_wyoming_wake_word( hass: HomeAssistant, wake_word_config_entry: ConfigEntry -): +) -> ConfigEntry: """Initialize Wyoming Wake Word.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -151,6 +159,8 @@ async def init_wyoming_wake_word( ): await hass.config_entries.async_setup(wake_word_config_entry.entry_id) + return wake_word_config_entry + @pytest.fixture async def init_wyoming_intent( diff --git a/tests/components/wyoming/test_conversation.py b/tests/components/wyoming/test_conversation.py index 02b04503962..d3c60f9d0c6 100644 --- a/tests/components/wyoming/test_conversation.py +++ b/tests/components/wyoming/test_conversation.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from wyoming.asr import Transcript from wyoming.handle import Handled, NotHandled from wyoming.intent import Entity, Intent, NotRecognized @@ -192,7 +192,7 @@ async def test_connection_lost( assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN assert result.response.speech, "No speech" - assert result.response.speech.get("plain", {}).get("speech") == snapshot() + assert result.response.speech.get("plain", {}).get("speech") == snapshot async def test_oserror( @@ -221,4 +221,4 @@ async def test_oserror( assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN assert result.response.speech, "No speech" - assert result.response.speech.get("plain", {}).get("speech") == snapshot() + assert result.response.speech.get("plain", {}).get("speech") == snapshot diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 0e4bb3da78c..800870f4604 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -35,6 +35,7 @@ from homeassistant.setup import async_setup_component from . import SATELLITE_INFO, WAKE_WORD_INFO, MockAsyncTcpClient from tests.common import MockConfigEntry +from tests.components.tts.common import MockResultStream async def setup_config_entry(hass: HomeAssistant) -> MockConfigEntry: @@ -259,10 +260,6 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.wyoming.assist_satellite.tts.async_get_media_source_audio", - return_value=("wav", get_test_wav()), - ), patch("homeassistant.components.wyoming.assist_satellite._PING_SEND_DELAY", 0), ): entry = await setup_config_entry(hass) @@ -411,10 +408,11 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.synthesize.voice.name == "test voice" # Text-to-speech media + mock_tts_result_stream = MockResultStream(hass, "wav", get_test_wav()) pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.TTS_END, - {"tts_output": {"media_id": "test media id"}}, + {"tts_output": {"token": mock_tts_result_stream.token}}, ) ) async with asyncio.timeout(1): @@ -435,12 +433,6 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: ) assert not device.is_active - # The client should have received another ping by now - async with asyncio.timeout(1): - await mock_client.ping_event.wait() - - assert mock_client.ping is not None - # Pipeline should automatically restart async with asyncio.timeout(1): await run_pipeline_called.wait() @@ -746,10 +738,6 @@ async def test_tts_not_wav(hass: HomeAssistant) -> None: "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", wraps=_async_pipeline_from_audio_stream, ) as mock_run_pipeline, - patch( - "homeassistant.components.wyoming.assist_satellite.tts.async_get_media_source_audio", - return_value=("mp3", bytes(1)), - ), patch( "homeassistant.components.wyoming.assist_satellite.WyomingAssistSatellite._stream_tts", _stream_tts, @@ -779,10 +767,11 @@ async def test_tts_not_wav(hass: HomeAssistant) -> None: await mock_client.synthesize_event.wait() # Text-to-speech media + mock_tts_result_stream = MockResultStream(hass, "mp3", bytes(1)) event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.TTS_END, - {"tts_output": {"media_id": "test media id"}}, + {"tts_output": {"token": mock_tts_result_stream.token}}, ) ) diff --git a/tests/components/wyoming/test_stt.py b/tests/components/wyoming/test_stt.py index bd83c31c561..cfbcf24d405 100644 --- a/tests/components/wyoming/test_stt.py +++ b/tests/components/wyoming/test_stt.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from wyoming.asr import Transcript from homeassistant.components import stt diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 263804787b1..c658bff1d0c 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -7,7 +7,7 @@ from unittest.mock import patch import wave import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from wyoming.audio import AudioChunk, AudioStop from homeassistant.components import tts, wyoming @@ -117,7 +117,6 @@ async def test_get_tts_audio_different_formats( assert wav_file.getframerate() == 48000 assert wav_file.getsampwidth() == 2 assert wav_file.getnchannels() == 2 - assert wav_file.getnframes() == wav_file.getframerate() # one second assert mock_client.written == snapshot @@ -150,17 +149,15 @@ async def test_get_tts_audio_connection_lost( hass: HomeAssistant, init_wyoming_tts ) -> None: """Test streaming audio and losing connection.""" - with ( - patch( - "homeassistant.components.wyoming.tts.AsyncTcpClient", - MockAsyncTcpClient([None]), - ), - pytest.raises(HomeAssistantError), + stream = tts.async_create_stream(hass, "tts.test_tts", "en-US") + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient([None]), ): - await tts.async_get_media_source_audio( - hass, - tts.generate_media_source_id(hass, "Hello world", "tts.test_tts", "en-US"), - ) + stream.async_set_message("Hello world") + with pytest.raises(HomeAssistantError): + async for _chunk in stream.async_stream_result(): + pass async def test_get_tts_audio_audio_oserror( diff --git a/tests/components/wyoming/test_websocket.py b/tests/components/wyoming/test_websocket.py new file mode 100644 index 00000000000..18b43321354 --- /dev/null +++ b/tests/components/wyoming/test_websocket.py @@ -0,0 +1,58 @@ +"""Websocket tests for Wyoming integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + + +async def test_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + init_wyoming_stt: ConfigEntry, + init_wyoming_tts: ConfigEntry, + init_wyoming_wake_word: ConfigEntry, + init_wyoming_intent: ConfigEntry, + init_wyoming_handle: ConfigEntry, +) -> None: + """Test info websocket command.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "wyoming/info"}) + + # result + msg = await client.receive_json() + assert msg["success"] + + info = msg.get("result", {}).get("info", {}) + + # stt (speech-to-text) = asr (automated speech recognition) + assert init_wyoming_stt.entry_id in info + asr_info = info[init_wyoming_stt.entry_id].get("asr", []) + assert len(asr_info) == 1 + assert asr_info[0].get("name") == "Test ASR" + + # tts (text-to-speech) + assert init_wyoming_tts.entry_id in info + tts_info = info[init_wyoming_tts.entry_id].get("tts", []) + assert len(tts_info) == 1 + assert tts_info[0].get("name") == "Test TTS" + + # wake word detection + assert init_wyoming_wake_word.entry_id in info + wake_info = info[init_wyoming_wake_word.entry_id].get("wake", []) + assert len(wake_info) == 1 + assert wake_info[0].get("name") == "Test Wake Word" + + # intent recognition + assert init_wyoming_intent.entry_id in info + intent_info = info[init_wyoming_intent.entry_id].get("intent", []) + assert len(intent_info) == 1 + assert intent_info[0].get("name") == "Test Intent" + + # intent handling + assert init_wyoming_handle.entry_id in info + handle_info = info[init_wyoming_handle.entry_id].get("handle", []) + assert len(handle_info) == 1 + assert handle_info[0].get("name") == "Test Handle" diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index 11a20a62d02..f5625d4e74d 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -694,21 +694,21 @@ async def test_miscale_v1_uuid(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes assert mass_non_stabilized_sensor.state == "86.55" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Smart Scale (B5DC) Mass Non Stabilized" + == "Mi Smart Scale (B5DC) Weight non stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" - mass_sensor = hass.states.get("sensor.mi_smart_scale_b5dc_mass") + mass_sensor = hass.states.get("sensor.mi_smart_scale_b5dc_weight") mass_sensor_attr = mass_sensor.attributes assert mass_sensor.state == "86.55" - assert mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Smart Scale (B5DC) Mass" + assert mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Smart Scale (B5DC) Weight" assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -736,22 +736,23 @@ async def test_miscale_v2_uuid(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 3 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_body_composition_scale_b5dc_mass_non_stabilized" + "sensor.mi_body_composition_scale_b5dc_weight_non_stabilized" ) mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes assert mass_non_stabilized_sensor.state == "85.15" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Body Composition Scale (B5DC) Mass Non Stabilized" + == "Mi Body Composition Scale (B5DC) Weight non stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" - mass_sensor = hass.states.get("sensor.mi_body_composition_scale_b5dc_mass") + mass_sensor = hass.states.get("sensor.mi_body_composition_scale_b5dc_weight") mass_sensor_attr = mass_sensor.attributes assert mass_sensor.state == "85.15" assert ( - mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Body Composition Scale (B5DC) Mass" + mass_sensor_attr[ATTR_FRIENDLY_NAME] + == "Mi Body Composition Scale (B5DC) Weight" ) assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -845,7 +846,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) assert mass_non_stabilized_sensor.state == "86.55" @@ -866,7 +867,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time @@ -896,7 +897,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) assert mass_non_stabilized_sensor.state == "86.55" @@ -917,7 +918,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time @@ -930,7 +931,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time and restore it diff --git a/tests/components/yale/test_binary_sensor.py b/tests/components/yale/test_binary_sensor.py index 16ec0ffbeb4..95434b1b2d2 100644 --- a/tests/components/yale/test_binary_sensor.py +++ b/tests/components/yale/test_binary_sensor.py @@ -3,7 +3,7 @@ import datetime from freezegun.api import FrozenDateTimeFactory -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.const import ( diff --git a/tests/components/yale/test_diagnostics.py b/tests/components/yale/test_diagnostics.py index e5fd6b1c1a7..8a18f9ee791 100644 --- a/tests/components/yale/test_diagnostics.py +++ b/tests/components/yale/test_diagnostics.py @@ -1,6 +1,6 @@ """Test yale diagnostics.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant diff --git a/tests/components/yale/test_lock.py b/tests/components/yale/test_lock.py index 1a99cf967ba..50051913d5f 100644 --- a/tests/components/yale/test_lock.py +++ b/tests/components/yale/test_lock.py @@ -5,7 +5,7 @@ import datetime from aiohttp import ClientResponseError from freezegun.api import FrozenDateTimeFactory import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from yalexs.manager.activity import INITIAL_LOCK_RESYNC_TIME from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState diff --git a/tests/components/yale/test_sensor.py b/tests/components/yale/test_sensor.py index 5d724b4bb9d..1ee04bf1ee1 100644 --- a/tests/components/yale/test_sensor.py +++ b/tests/components/yale/test_sensor.py @@ -2,7 +2,7 @@ from typing import Any -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant import core as ha from homeassistant.const import ( diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index 77878c2be51..098fc025bf3 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -223,7 +223,7 @@ async def test_service_say_timeout( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 @@ -269,7 +269,7 @@ async def test_service_say_http_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) diff --git a/tests/components/youless/test_sensor.py b/tests/components/youless/test_sensor.py index 67dff314df7..e18ae678e42 100644 --- a/tests/components/youless/test_sensor.py +++ b/tests/components/youless/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index 73652d9b239..2cfb970928d 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -131,7 +131,51 @@ async def test_flow_abort_without_subscriptions( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, ) -> None: - """Check abort flow if user has no subscriptions.""" + """Check abort flow if user has no subscriptions and no own channel.""" + result = await hass.config_entries.flow.async_init( + "youtube", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + 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" + + service = MockYouTube( + channel_fixture="youtube/get_no_channel.json", + subscriptions_fixture="youtube/get_no_subscriptions.json", + ) + with ( + patch("homeassistant.components.youtube.async_setup_entry", return_value=True), + patch( + "homeassistant.components.youtube.config_flow.YouTube", return_value=service + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_channel" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_without_subscriptions( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Check flow continues even without subscriptions since user has their own channel.""" result = await hass.config_entries.flow.async_init( "youtube", context={"source": config_entries.SOURCE_USER} ) @@ -163,8 +207,30 @@ async def test_flow_abort_without_subscriptions( ), ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_subscriptions" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "channels" + + # Verify the form schema contains only the user's own channel + schema = result["data_schema"] + channels = schema.schema[CONF_CHANNELS].config["options"] + assert len(channels) == 1 + assert channels[0]["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert "(Your Channel)" in channels[0]["label"] + + # Test selecting the own channel + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert "result" in result + assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["options"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} @pytest.mark.usefixtures("current_request_with_host") @@ -373,3 +439,112 @@ async def test_options_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_own_channel_included( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test that the user's own channel is included in the list of selectable channels.""" + result = await hass.config_entries.flow.async_init( + "youtube", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + 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" + + with ( + patch( + "homeassistant.components.youtube.async_setup_entry", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.youtube.config_flow.YouTube", + return_value=MockYouTube(), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "channels" + + # Verify the form schema contains the user's own channel + schema = result["data_schema"] + channels = schema.schema[CONF_CHANNELS].config["options"] + assert any( + channel["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + and "(Your Channel)" in channel["label"] + for channel in channels + ) + + # Test selecting both own channel and a subscribed channel + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw", "UC_x5XG1OV2P6uZZ5FSM9Ttw"] + }, + ) + + 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"] == TITLE + assert "result" in result + assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["options"] == { + CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw", "UC_x5XG1OV2P6uZZ5FSM9Ttw"] + } + + +async def test_options_flow_own_channel( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test the options flow includes the user's own channel.""" + await setup_integration() + with patch( + "homeassistant.components.youtube.config_flow.YouTube", + return_value=MockYouTube(), + ): + entry = hass.config_entries.async_entries(DOMAIN)[0] + 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["step_id"] == "init" + + # Verify the form schema contains the user's own channel + schema = result["data_schema"] + channels = schema.schema[CONF_CHANNELS].config["options"] + assert any( + channel["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + and "(Your Channel)" in channel["label"] + for channel in channels + ) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} diff --git a/tests/components/youtube/test_diagnostics.py b/tests/components/youtube/test_diagnostics.py index 3a5765b5890..99d8b9d5185 100644 --- a/tests/components/youtube/test_diagnostics.py +++ b/tests/components/youtube/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the YouTube integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.youtube.const import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index e883347c8db..1090b8c391a 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import patch -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from youtubeaio.types import UnauthorizedError, YouTubeBackendError from homeassistant import config_entries diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 56262600511..847727796bb 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -14,6 +14,7 @@ from zeroconf.asyncio import AsyncServiceInfo from homeassistant import config_entries from homeassistant.components import zeroconf +from homeassistant.components.zeroconf import discovery from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_CLOSE, @@ -181,10 +182,10 @@ async def test_setup(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> Non ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock + discovery, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -214,7 +215,7 @@ async def test_setup_with_overly_long_url_and_name( """Test we still setup with long urls and names.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.get_url", return_value=( @@ -240,7 +241,7 @@ async def test_setup_with_overly_long_url_and_name( ), ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo.async_request", ), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) @@ -258,9 +259,9 @@ async def test_setup_with_defaults( """Test default interface config.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -302,10 +303,10 @@ async def test_zeroconf_match_macaddress(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -351,10 +352,10 @@ async def test_zeroconf_match_manufacturer(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Samsung Electronics"), ), ): @@ -392,10 +393,10 @@ async def test_zeroconf_match_model(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_model("appletv"), ), ): @@ -433,10 +434,10 @@ async def test_zeroconf_match_manufacturer_not_present(hass: HomeAssistant) -> N ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("aabbccddeeff"), ), ): @@ -469,10 +470,10 @@ async def test_zeroconf_no_match(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -509,10 +510,10 @@ async def test_zeroconf_no_match_manufacturer(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Not Samsung Electronics"), ), ): @@ -540,14 +541,14 @@ async def test_homekit_match_partial_space(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("LIFX bulb", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -588,14 +589,14 @@ async def test_device_with_invalid_name( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=BadTypeInNameException, ), ): @@ -624,14 +625,14 @@ async def test_homekit_match_partial_dash(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock( "Smart Bridge-001", HOMEKIT_STATUS_UNPAIRED ), @@ -662,14 +663,14 @@ async def test_homekit_match_partial_fnmatch(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("YLDP13YL", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -698,14 +699,14 @@ async def test_homekit_match_full(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("BSB002", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -737,14 +738,14 @@ async def test_homekit_already_paired(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("tado", HOMEKIT_STATUS_PAIRED), ), ): @@ -774,14 +775,14 @@ async def test_homekit_invalid_paring_status(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("Smart Bridge", b"invalid"), ), ): @@ -805,10 +806,10 @@ async def test_homekit_not_paired(hass: HomeAssistant) -> None: ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock + discovery, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock( "this_will_not_match_any_integration", HOMEKIT_STATUS_UNPAIRED ), @@ -847,14 +848,14 @@ async def test_homekit_controller_still_discovered_unpaired_for_cloud( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("Rachio-xyz", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -892,14 +893,14 @@ async def test_homekit_controller_still_discovered_unpaired_for_polling( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, + discovery, "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_homekit_info_mock("iSmartGate", HOMEKIT_STATUS_UNPAIRED), ), ): @@ -1053,9 +1054,9 @@ async def test_removed_ignored(hass: HomeAssistant) -> None: ) with ( - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ) as mock_service_info, ): @@ -1088,13 +1089,13 @@ async def test_async_detect_interfaces_setting_non_loopback_route( with ( patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1176,13 +1177,13 @@ async def test_async_detect_interfaces_setting_empty_route_linux( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1210,13 +1211,13 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1261,13 +1262,13 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1290,13 +1291,13 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1319,13 +1320,13 @@ async def test_async_detect_interfaces_explicitly_before_setup( patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch( "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_service_info_mock, ), ): @@ -1359,14 +1360,14 @@ async def test_setup_with_disallowed_characters_in_local_name( """Test we still setup with disallowed characters in the location name.""" with ( patch.object(hass.config_entries.flow, "async_init"), - patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), patch.object( hass.config, "location_name", "My.House", ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo.async_request", ), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) @@ -1422,10 +1423,10 @@ async def test_zeroconf_removed(hass: HomeAssistant) -> None: ) as mock_async_progress_by_init_data_type, patch.object(hass.config_entries.flow, "async_abort") as mock_async_abort, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=_device_removed_mock + discovery, "AsyncServiceBrowser", side_effect=_device_removed_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -1545,10 +1546,10 @@ async def test_zeroconf_rediscover( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): @@ -1665,10 +1666,10 @@ async def test_zeroconf_rediscover_no_match( ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + discovery, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), ), ): diff --git a/tests/components/zeroconf/test_usage.py b/tests/components/zeroconf/test_usage.py index e79f2319915..2e186bc39d0 100644 --- a/tests/components/zeroconf/test_usage.py +++ b/tests/components/zeroconf/test_usage.py @@ -3,7 +3,6 @@ from unittest.mock import Mock, patch import pytest -import zeroconf from homeassistant.components.zeroconf import async_get_instance from homeassistant.components.zeroconf.usage import install_multiple_zeroconf_catcher @@ -15,6 +14,16 @@ from tests.common import extract_stack_to_frame DOMAIN = "zeroconf" +class MockZeroconf: + """Mock Zeroconf class.""" + + def __init__(self, *args, **kwargs) -> None: + """Initialize the mock.""" + + def __new__(cls, *args, **kwargs) -> "MockZeroconf": + """Return the shared instance.""" + + @pytest.mark.usefixtures("mock_async_zeroconf", "mock_zeroconf") async def test_multiple_zeroconf_instances( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -24,12 +33,13 @@ async def test_multiple_zeroconf_instances( zeroconf_instance = await async_get_instance(hass) - install_multiple_zeroconf_catcher(zeroconf_instance) + with patch("zeroconf.Zeroconf", MockZeroconf): + install_multiple_zeroconf_catcher(zeroconf_instance) - new_zeroconf_instance = zeroconf.Zeroconf() - assert new_zeroconf_instance == zeroconf_instance + new_zeroconf_instance = MockZeroconf() + assert new_zeroconf_instance == zeroconf_instance - assert "Zeroconf" in caplog.text + assert "Zeroconf" in caplog.text @pytest.mark.usefixtures("mock_async_zeroconf", "mock_zeroconf") @@ -41,44 +51,45 @@ async def test_multiple_zeroconf_instances_gives_shared( zeroconf_instance = await async_get_instance(hass) - install_multiple_zeroconf_catcher(zeroconf_instance) + with patch("zeroconf.Zeroconf", MockZeroconf): + install_multiple_zeroconf_catcher(zeroconf_instance) - correct_frame = Mock( - filename="/config/custom_components/burncpu/light.py", - lineno="23", - line="self.light.is_on", - ) - with ( - patch( - "homeassistant.helpers.frame.linecache.getline", - return_value=correct_frame.line, - ), - patch( - "homeassistant.helpers.frame.get_current_frame", - return_value=extract_stack_to_frame( - [ - Mock( - filename="/home/dev/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - correct_frame, - Mock( - filename="/home/dev/homeassistant/components/zeroconf/usage.py", - lineno="23", - line="self.light.is_on", - ), - Mock( - filename="/home/dev/mdns/lights.py", - lineno="2", - line="something()", - ), - ] + correct_frame = Mock( + filename="/config/custom_components/burncpu/light.py", + lineno="23", + line="self.light.is_on", + ) + with ( + patch( + "homeassistant.helpers.frame.linecache.getline", + return_value=correct_frame.line, ), - ), - ): - assert zeroconf.Zeroconf() == zeroconf_instance + patch( + "homeassistant.helpers.frame.get_current_frame", + return_value=extract_stack_to_frame( + [ + Mock( + filename="/home/dev/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/dev/homeassistant/components/zeroconf/usage.py", + lineno="23", + line="self.light.is_on", + ), + Mock( + filename="/home/dev/mdns/lights.py", + lineno="2", + line="something()", + ), + ] + ), + ), + ): + assert MockZeroconf() == zeroconf_instance - assert "custom_components/burncpu/light.py" in caplog.text - assert "23" in caplog.text - assert "self.light.is_on" in caplog.text + assert "custom_components/burncpu/light.py" in caplog.text + assert "23" in caplog.text + assert "self.light.is_on" in caplog.text diff --git a/tests/components/zeroconf/test_websocket_api.py b/tests/components/zeroconf/test_websocket_api.py new file mode 100644 index 00000000000..9677b3e34fd --- /dev/null +++ b/tests/components/zeroconf/test_websocket_api.py @@ -0,0 +1,194 @@ +"""The tests for the zeroconf WebSocket API.""" + +import asyncio +import socket +from unittest.mock import patch + +from zeroconf import ( + DNSAddress, + DNSPointer, + DNSService, + DNSText, + RecordUpdate, + const, + current_time_millis, +) + +from homeassistant.components.zeroconf import DOMAIN, async_get_async_instance +from homeassistant.core import HomeAssistant +from homeassistant.generated import zeroconf as zc_gen +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +async def test_subscribe_discovery( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test zeroconf subscribe_discovery.""" + instance = await async_get_async_instance(hass) + instance.zeroconf.cache.async_add_records( + [ + DNSPointer( + "_fakeservice._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "wrong._wrongservice._tcp.local.", + ), + DNSPointer( + "_fakeservice._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "foo2._fakeservice._tcp.local.", + ), + DNSService( + "foo2._fakeservice._tcp.local.", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_OTHER_TTL, + 0, + 0, + 1234, + "foo2.local.", + ), + DNSAddress( + "foo2.local.", + const._TYPE_A, + const._CLASS_IN, + const._DNS_HOST_TTL, + socket.inet_aton("127.0.0.1"), + ), + DNSText( + "foo2.local.", + const._TYPE_TXT, + const._CLASS_IN, + const._DNS_HOST_TTL, + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5" + b"\x05c#=12\x04s#=1", + ), + DNSPointer( + "_fakeservice._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "foo3._fakeservice._tcp.local.", + ), + DNSService( + "foo3._fakeservice._tcp.local.", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_OTHER_TTL, + 0, + 0, + 1234, + "foo3.local.", + ), + DNSText( + "foo3.local.", + const._TYPE_TXT, + const._CLASS_IN, + const._DNS_HOST_TTL, + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5" + b"\x05c#=12\x04s#=1", + ), + ] + ) + with patch.dict( + zc_gen.ZEROCONF, + {"_fakeservice._tcp.local.": []}, + clear=True, + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "zeroconf/subscribe_discovery", + } + ) + 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": [ + { + "ip_addresses": ["127.0.0.1"], + "name": "foo2._fakeservice._tcp.local.", + "port": 1234, + "properties": {}, + "type": "_fakeservice._tcp.local.", + } + ] + } + + # now late inject the address record + records = [ + DNSAddress( + "foo3.local.", + const._TYPE_A, + const._CLASS_IN, + const._DNS_HOST_TTL, + socket.inet_aton("127.0.0.1"), + ), + ] + instance.zeroconf.cache.async_add_records(records) + instance.zeroconf.record_manager.async_updates( + current_time_millis(), + [RecordUpdate(record, None) for record in records], + ) + # Now for the add + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "ip_addresses": ["127.0.0.1"], + "name": "foo3._fakeservice._tcp.local.", + "port": 1234, + "properties": {}, + "type": "_fakeservice._tcp.local.", + } + ] + } + # Now for the update + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "ip_addresses": ["127.0.0.1"], + "name": "foo3._fakeservice._tcp.local.", + "port": 1234, + "properties": {}, + "type": "_fakeservice._tcp.local.", + } + ] + } + + # now move time forward and remove the record + future = current_time_millis() + (4500 * 1000) + records = instance.zeroconf.cache.async_expire(future) + record_updates = [RecordUpdate(record, record) for record in records] + instance.zeroconf.record_manager.async_updates(future, record_updates) + instance.zeroconf.record_manager.async_updates_complete(True) + + removes: set[str] = set() + for _ in range(3): + async with asyncio.timeout(1): + response = await client.receive_json() + assert "remove" in response["event"] + removes.add(next(iter(response["event"]["remove"]))["name"]) + + assert len(removes) == 3 + assert removes == { + "foo2._fakeservice._tcp.local.", + "foo3._fakeservice._tcp.local.", + "wrong._wrongservice._tcp.local.", + } diff --git a/tests/components/zeversolar/test_diagnostics.py b/tests/components/zeversolar/test_diagnostics.py index 0d7a919b023..b5a59b588fb 100644 --- a/tests/components/zeversolar/test_diagnostics.py +++ b/tests/components/zeversolar/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Zeversolar integration.""" -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.zeversolar import DOMAIN from homeassistant.core import HomeAssistant diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 96a61a6628b..df61fb499d2 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -17,6 +17,7 @@ from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE import zigpy.device import zigpy.group import zigpy.profiles +from zigpy.profiles import zha import zigpy.quirks import zigpy.state import zigpy.types @@ -173,6 +174,7 @@ async def zigpy_app_controller(): dev.model = "Coordinator Model" ep = dev.add_endpoint(1) + ep.profile_id = zha.PROFILE_ID ep.add_input_cluster(Basic.cluster_id) ep.add_input_cluster(Groups.cluster_id) diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index ba8aa9ea245..44fb913489d 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -154,87 +154,107 @@ # name: test_diagnostics_for_device dict({ 'active_coordinator': False, - 'area_id': None, 'available': True, - 'cluster_details': dict({ + 'device_type': 'EndDevice', + 'endpoints': dict({ '1': dict({ 'device_type': dict({ 'id': 1025, 'name': 'IAS_ANCILLARY_CONTROL', }), - 'in_clusters': dict({ - '0x0500': dict({ - 'attributes': dict({ - '0x0000': dict({ - 'attribute': "ZCLAttributeDef(id=0x0000, name='zone_state', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': None, + 'in_clusters': list([ + dict({ + 'attributes': list([ + dict({ + 'id': '0x0010', + 'name': 'cie_addr', + 'unsupported': False, + 'value': list([ + 50, + 79, + 50, + 2, + 0, + 141, + 21, + 0, + ]), + 'zcl_type': 'EUI64', }), - '0x0001': dict({ - 'attribute': "ZCLAttributeDef(id=0x0001, name='zone_type', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", + dict({ + 'id': '0x0013', + 'name': 'current_zone_sensitivity_level', + 'unsupported': False, 'value': None, + 'zcl_type': 'uint8', }), - '0x0002': dict({ - 'attribute': "ZCLAttributeDef(id=0x0002, name='zone_status', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", + dict({ + 'id': '0x0012', + 'name': 'num_zone_sensitivity_levels_supported', + 'unsupported': True, 'value': None, + 'zcl_type': 'uint8', }), - '0x0010': dict({ - 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", + dict({ + 'id': '0x0011', + 'name': 'zone_id', + 'unsupported': False, 'value': None, + 'zcl_type': 'uint8', }), - '0x0011': dict({ - 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", + dict({ + 'id': '0x0000', + 'name': 'zone_state', + 'unsupported': False, 'value': None, + 'zcl_type': 'enum8', }), - '0x0012': dict({ - 'attribute': "ZCLAttributeDef(id=0x0012, name='num_zone_sensitivity_levels_supported', type=, zcl_type=, access=, mandatory=False, is_manufacturer_specific=False)", + dict({ + 'id': '0x0002', + 'name': 'zone_status', + 'unsupported': False, 'value': None, + 'zcl_type': 'map16', }), - '0x0013': dict({ - 'attribute': "ZCLAttributeDef(id=0x0013, name='current_zone_sensitivity_level', type=, zcl_type=, access=, mandatory=False, is_manufacturer_specific=False)", + dict({ + 'id': '0x0001', + 'name': 'zone_type', + 'unsupported': False, 'value': None, + 'zcl_type': 'uint16', }), - }), + ]), + 'cluster_id': '0x0500', 'endpoint_attribute': 'ias_zone', - 'unsupported_attributes': list([ - 18, - 'current_zone_sensitivity_level', - ]), }), - '0x0501': dict({ - 'attributes': dict({ - '0xfffd': dict({ - 'attribute': "ZCLAttributeDef(id=0xFFFD, name='cluster_revision', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", + dict({ + 'attributes': list([ + dict({ + 'id': '0xfffd', + 'name': 'cluster_revision', + 'unsupported': False, 'value': None, + 'zcl_type': 'uint16', }), - '0xfffe': dict({ - 'attribute': "ZCLAttributeDef(id=0xFFFE, name='reporting_status', type=, zcl_type=, access=, mandatory=False, is_manufacturer_specific=False)", + dict({ + 'id': '0xfffe', + 'name': 'reporting_status', + 'unsupported': False, 'value': None, + 'zcl_type': 'enum8', }), - }), + ]), + 'cluster_id': '0x0501', 'endpoint_attribute': 'ias_ace', - 'unsupported_attributes': list([ - 4096, - 'unknown_attribute_name', - ]), }), - }), - 'out_clusters': dict({ - }), + ]), + 'out_clusters': list([ + ]), 'profile_id': 260, }), }), - 'device_type': 'EndDevice', - 'endpoint_names': list([ - dict({ - 'name': 'IAS_ANCILLARY_CONTROL', - }), - ]), - 'entities': list([ - dict({ - 'entity_id': 'alarm_control_panel.fakemanufacturer_fakemodel_alarm_control_panel', - 'name': 'FakeManufacturer FakeModel', - }), - ]), + 'friendly_manufacturer': 'FakeManufacturer', + 'friendly_model': 'FakeModel', 'ieee': '**REDACTED**', 'lqi': None, 'manufacturer': 'FakeManufacturer', @@ -243,7 +263,22 @@ 'name': 'FakeManufacturer FakeModel', 'neighbors': list([ ]), - 'nwk': 47004, + 'node_descriptor': dict({ + 'aps_flags': 0, + 'complex_descriptor_available': False, + 'descriptor_capability_field': 0, + 'frequency_band': 8, + 'logical_type': 'EndDevice', + 'mac_capability_flags': 140, + 'manufacturer_code': 4098, + 'maximum_buffer_size': 82, + 'maximum_incoming_transfer_size': 82, + 'maximum_outgoing_transfer_size': 82, + 'reserved': 0, + 'server_mask': 0, + 'user_descriptor_available': False, + }), + 'nwk': '0xB79C', 'power_source': 'Mains', 'quirk_applied': False, 'quirk_class': 'zigpy.device.Device', @@ -251,37 +286,100 @@ 'routes': list([ ]), 'rssi': None, - 'signature': dict({ - 'endpoints': dict({ - '1': dict({ - 'device_type': '0x0401', - 'input_clusters': list([ - '0x0500', - '0x0501', - ]), - 'output_clusters': list([ - ]), - 'profile_id': '0x0104', + 'version': 1, + 'zha_lib_entities': dict({ + 'alarm_control_panel': list([ + dict({ + 'info_object': dict({ + 'available': True, + 'class_name': 'AlarmControlPanel', + 'cluster_handlers': list([ + dict({ + 'class_name': 'IasAceClusterHandler', + 'cluster': dict({ + 'id': 1281, + 'name': 'IAS Ancillary Control Equipment', + 'type': 'server', + }), + 'endpoint_id': 1, + 'generic_id': 'cluster_handler_0x0501', + 'id': '1:0x0501', + 'status': 'INITIALIZED', + 'unique_id': '**REDACTED**', + 'value_attribute': None, + }), + ]), + 'code_arm_required': False, + 'code_format': 'number', + 'device_class': None, + 'device_ieee': '**REDACTED**', + 'enabled': True, + 'endpoint_id': 1, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'fallback_name': None, + 'group_id': None, + 'migrate_unique_ids': list([ + ]), + 'platform': 'alarm_control_panel', + 'primary': False, + 'state_class': None, + 'supported_features': 15, + 'translation_key': 'alarm_control_panel', + 'unique_id': '**REDACTED**', + }), + 'state': dict({ + 'available': True, + 'class_name': 'AlarmControlPanel', + 'state': 'disarmed', + }), }), - }), - 'manufacturer': 'FakeManufacturer', - 'model': 'FakeModel', - 'node_descriptor': dict({ - 'aps_flags': 0, - 'complex_descriptor_available': 0, - 'descriptor_capability_field': 0, - 'frequency_band': 8, - 'logical_type': 2, - 'mac_capability_flags': 140, - 'manufacturer_code': 4098, - 'maximum_buffer_size': 82, - 'maximum_incoming_transfer_size': 82, - 'maximum_outgoing_transfer_size': 82, - 'reserved': 0, - 'server_mask': 0, - 'user_descriptor_available': 0, - }), + ]), + 'binary_sensor': list([ + dict({ + 'info_object': dict({ + 'attribute_name': 'zone_status', + 'available': True, + 'class_name': 'IASZone', + 'cluster_handlers': list([ + dict({ + 'class_name': 'IASZoneClusterHandler', + 'cluster': dict({ + 'id': 1280, + 'name': 'IAS Zone', + 'type': 'server', + }), + 'endpoint_id': 1, + 'generic_id': 'cluster_handler_0x0500', + 'id': '1:0x0500', + 'status': 'INITIALIZED', + 'unique_id': '**REDACTED**', + 'value_attribute': None, + }), + ]), + 'device_class': None, + 'device_ieee': '**REDACTED**', + 'enabled': True, + 'endpoint_id': 1, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'fallback_name': None, + 'group_id': None, + 'migrate_unique_ids': list([ + ]), + 'platform': 'binary_sensor', + 'primary': True, + 'state_class': None, + 'translation_key': 'ias_zone', + 'unique_id': '**REDACTED**', + }), + 'state': dict({ + 'available': True, + 'class_name': 'IASZone', + 'state': False, + }), + }), + ]), }), - 'user_given_name': None, }) # --- diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index e5d588aa1bf..70fdac2c313 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -80,8 +80,8 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: # load up cover domain cluster = zigpy_device.endpoints[1].window_covering cluster.PLUGGED_ATTR_READS = { - WCAttrs.current_position_lift_percentage.name: 0, - WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.current_position_lift_percentage.name: 0, # Zigbee open % + WCAttrs.current_position_tilt_percentage.name: 100, # Zigbee closed % WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), } @@ -114,34 +114,34 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: state = hass.states.get(entity_id) assert state assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 - assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 58 + assert state.attributes[ATTR_CURRENT_POSITION] == 100 # HA open % + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 # HA closed % - # test that the state has changed from unavailable to off + # test that the state has changed from open to closed await send_attributes_report( hass, cluster, {WCAttrs.current_position_lift_percentage.id: 100} ) assert hass.states.get(entity_id).state == CoverState.CLOSED - # test to see if it opens + # test that it opens await send_attributes_report( hass, cluster, {WCAttrs.current_position_lift_percentage.id: 0} ) assert hass.states.get(entity_id).state == CoverState.OPEN - # test that the state remains after tilting to 100% - await send_attributes_report( - hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} - ) - assert hass.states.get(entity_id).state == CoverState.OPEN - - # test to see the state remains after tilting to 0% + # test that the state remains after tilting to 0% (open) await send_attributes_report( hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} ) assert hass.states.get(entity_id).state == CoverState.OPEN - # close from UI + # test that the state remains after tilting to 100% (closed) + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} + ) + assert hass.states.get(entity_id).state == CoverState.OPEN + + # close lift from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True @@ -160,6 +160,13 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.CLOSED + # close tilt from UI, needs re-opening first + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) + assert ( + hass.states.get(entity_id).state == CoverState.CLOSED + ) # CLOSED lift state currently takes precedence over OPEN tilt with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -185,7 +192,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.CLOSED - # open from UI + # open lift from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True @@ -204,6 +211,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.OPEN + # open tilt from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -229,7 +237,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.OPEN - # set position UI + # set lift position from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -261,6 +269,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.OPEN + # set tilt position from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -281,13 +290,13 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.CLOSING await send_attributes_report( - hass, cluster, {WCAttrs.current_position_lift_percentage.id: 35} + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 35} ) assert hass.states.get(entity_id).state == CoverState.CLOSING await send_attributes_report( - hass, cluster, {WCAttrs.current_position_lift_percentage.id: 53} + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 53} ) assert hass.states.get(entity_id).state == CoverState.OPEN @@ -338,7 +347,7 @@ async def test_cover_failures( # load up cover domain cluster = zigpy_device.endpoints[1].window_covering cluster.PLUGGED_ATTR_READS = { - WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.current_position_tilt_percentage.name: 100, WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, } update_attribute_cache(cluster) @@ -355,7 +364,7 @@ async def test_cover_failures( await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) assert hass.states.get(entity_id).state == CoverState.CLOSED - # test to see if it opens + # test that it opens await send_attributes_report(hass, cluster, {0: 1, 8: 0, 1: 100}) assert hass.states.get(entity_id).state == CoverState.OPEN diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 8bee821654d..becf9d81557 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -209,7 +209,7 @@ async def test_action( cluster_handler = ( gateway.get_device(zigpy_device.ieee) .endpoints[1] - .client_cluster_handlers["1:0x0006"] + .client_cluster_handlers["1:0x0006_client"] ) cluster_handler.zha_send_event(COMMAND_SINGLE, []) await hass.async_block_till_done() @@ -252,9 +252,73 @@ async def test_invalid_zha_event_type( cluster_handler = ( gateway.get_device(zigpy_device.ieee) .endpoints[1] - .client_cluster_handlers["1:0x0006"] + .client_cluster_handlers["1:0x0006_client"] ) # `zha_send_event` accepts only zigpy responses, lists, and dicts with pytest.raises(TypeError): cluster_handler.zha_send_event(COMMAND_SINGLE, 123) + + +async def test_client_unique_id_suffix_stripped( + hass: HomeAssistant, setup_zha, zigpy_device_mock +) -> None: + """Test that the `_CLIENT_` unique ID suffix is stripped.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "event", + "event_type": "zha_event", + "event_data": { + "unique_id": "38:5b:44:ff:fe:a7:cc:69:1:0x0006", # no `_CLIENT` suffix + "endpoint_id": 1, + "cluster_id": 6, + "command": "on", + "args": [], + "params": {}, + }, + }, + "action": {"service": "zha.test"}, + } + }, + ) + + service_calls = async_mock_service(hass, DOMAIN, "test") + + await setup_zha() + gateway = get_zha_gateway(hass) + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + security.IasZone.cluster_id, + security.IasWd.cluster_id, + ], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + 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(zha_device.device) + + zha_device.emit_zha_event( + { + "unique_id": "38:5b:44:ff:fe:a7:cc:69:1:0x0006_CLIENT", + "endpoint_id": 1, + "cluster_id": 6, + "command": "on", + "args": [], + "params": {}, + } + ) + + await hass.async_block_till_done(wait_background_tasks=True) + assert len(service_calls) == 1 diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 09b2d155547..ace3029dac9 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -199,6 +199,7 @@ async def test_if_fires_on_event( ) ep = zigpy_device.add_endpoint(1) ep.add_output_cluster(0x0006) + ep.profile_id = zigpy.profiles.zha.PROFILE_ID zigpy_device.device_automation_triggers = { (SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE}, diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index af81ac0d586..0ff863f0c45 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -49,7 +49,8 @@ def test_detect_radio_hardware(hass: HomeAssistant) -> None: "product": "SkyConnect v1.0", "firmware": "ezsp", }, - version=2, + version=1, + minor_version=4, domain=SKYCONNECT_DOMAIN, options={}, title="Home Assistant SkyConnect", @@ -66,7 +67,8 @@ def test_detect_radio_hardware(hass: HomeAssistant) -> None: "product": "Home Assistant Connect ZBT-1", "firmware": "ezsp", }, - version=2, + version=1, + minor_version=4, domain=SKYCONNECT_DOMAIN, options={}, title="Home Assistant Connect ZBT-1", diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 88fb9974c1b..863ea3964ab 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -62,10 +62,10 @@ async def async_test_temperature(hass: HomeAssistant, cluster: Cluster, entity_i async def async_test_pressure(hass: HomeAssistant, cluster: Cluster, entity_id: str): """Test pressure sensor.""" await send_attributes_report(hass, cluster, {1: 1, 0: 1000, 2: 10000}) - assert_state(hass, entity_id, "1000", UnitOfPressure.HPA) + assert_state(hass, entity_id, "1000.0", UnitOfPressure.HPA) await send_attributes_report(hass, cluster, {0: 1000, 20: -1, 16: 10000}) - assert_state(hass, entity_id, "1000", UnitOfPressure.HPA) + assert_state(hass, entity_id, "1000.0", UnitOfPressure.HPA) async def async_test_illuminance(hass: HomeAssistant, cluster: Cluster, entity_id: str): @@ -167,14 +167,14 @@ async def async_test_electrical_measurement( # update divisor cached value await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) await send_attributes_report(hass, cluster, {0: 1, 1291: 100, 10: 1000}) - assert_state(hass, entity_id, "100", UnitOfPower.WATT) + assert_state(hass, entity_id, "100.0", UnitOfPower.WATT) await send_attributes_report(hass, cluster, {0: 1, 1291: 99, 10: 1000}) - assert_state(hass, entity_id, "99", UnitOfPower.WATT) + assert_state(hass, entity_id, "99.0", UnitOfPower.WATT) await send_attributes_report(hass, cluster, {"ac_power_divisor": 10}) await send_attributes_report(hass, cluster, {0: 1, 1291: 1000, 10: 5000}) - assert_state(hass, entity_id, "100", UnitOfPower.WATT) + assert_state(hass, entity_id, "100.0", UnitOfPower.WATT) await send_attributes_report(hass, cluster, {0: 1, 1291: 99, 10: 5000}) assert_state(hass, entity_id, "9.9", UnitOfPower.WATT) @@ -191,14 +191,14 @@ async def async_test_em_apparent_power( # update divisor cached value await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) await send_attributes_report(hass, cluster, {0: 1, 0x050F: 100, 10: 1000}) - assert_state(hass, entity_id, "100", UnitOfApparentPower.VOLT_AMPERE) + assert_state(hass, entity_id, "100.0", UnitOfApparentPower.VOLT_AMPERE) await send_attributes_report(hass, cluster, {0: 1, 0x050F: 99, 10: 1000}) - assert_state(hass, entity_id, "99", UnitOfApparentPower.VOLT_AMPERE) + assert_state(hass, entity_id, "99.0", UnitOfApparentPower.VOLT_AMPERE) await send_attributes_report(hass, cluster, {"ac_power_divisor": 10}) await send_attributes_report(hass, cluster, {0: 1, 0x050F: 1000, 10: 5000}) - assert_state(hass, entity_id, "100", UnitOfApparentPower.VOLT_AMPERE) + assert_state(hass, entity_id, "100.0", UnitOfApparentPower.VOLT_AMPERE) await send_attributes_report(hass, cluster, {0: 1, 0x050F: 99, 10: 5000}) assert_state(hass, entity_id, "9.9", UnitOfApparentPower.VOLT_AMPERE) @@ -211,17 +211,17 @@ async def async_test_em_power_factor( # update divisor cached value await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) await send_attributes_report(hass, cluster, {0: 1, 0x0510: 100, 10: 1000}) - assert_state(hass, entity_id, "100", PERCENTAGE) + assert_state(hass, entity_id, "100.0", PERCENTAGE) await send_attributes_report(hass, cluster, {0: 1, 0x0510: 99, 10: 1000}) - assert_state(hass, entity_id, "99", PERCENTAGE) + assert_state(hass, entity_id, "99.0", PERCENTAGE) await send_attributes_report(hass, cluster, {"ac_power_divisor": 10}) await send_attributes_report(hass, cluster, {0: 1, 0x0510: 100, 10: 5000}) - assert_state(hass, entity_id, "100", PERCENTAGE) + assert_state(hass, entity_id, "100.0", PERCENTAGE) await send_attributes_report(hass, cluster, {0: 1, 0x0510: 99, 10: 5000}) - assert_state(hass, entity_id, "99", PERCENTAGE) + assert_state(hass, entity_id, "99.0", PERCENTAGE) async def async_test_em_rms_current( @@ -230,14 +230,14 @@ async def async_test_em_rms_current( """Test electrical measurement RMS Current sensor.""" await send_attributes_report(hass, cluster, {0: 1, 0x0508: 1234, 10: 1000}) - assert_state(hass, entity_id, "1.2", UnitOfElectricCurrent.AMPERE) + assert_state(hass, entity_id, "1.234", UnitOfElectricCurrent.AMPERE) await send_attributes_report(hass, cluster, {"ac_current_divisor": 10}) await send_attributes_report(hass, cluster, {0: 1, 0x0508: 236, 10: 1000}) assert_state(hass, entity_id, "23.6", UnitOfElectricCurrent.AMPERE) await send_attributes_report(hass, cluster, {0: 1, 0x0508: 1236, 10: 1000}) - assert_state(hass, entity_id, "124", UnitOfElectricCurrent.AMPERE) + assert_state(hass, entity_id, "123.6", UnitOfElectricCurrent.AMPERE) assert "rms_current_max" not in hass.states.get(entity_id).attributes await send_attributes_report(hass, cluster, {0: 1, 0x050A: 88, 10: 5000}) @@ -250,18 +250,18 @@ async def async_test_em_rms_voltage( """Test electrical measurement RMS Voltage sensor.""" await send_attributes_report(hass, cluster, {0: 1, 0x0505: 1234, 10: 1000}) - assert_state(hass, entity_id, "123", UnitOfElectricPotential.VOLT) + assert_state(hass, entity_id, "123.4", UnitOfElectricPotential.VOLT) await send_attributes_report(hass, cluster, {0: 1, 0x0505: 234, 10: 1000}) assert_state(hass, entity_id, "23.4", UnitOfElectricPotential.VOLT) await send_attributes_report(hass, cluster, {"ac_voltage_divisor": 100}) await send_attributes_report(hass, cluster, {0: 1, 0x0505: 2236, 10: 1000}) - assert_state(hass, entity_id, "22.4", UnitOfElectricPotential.VOLT) + assert_state(hass, entity_id, "22.36", UnitOfElectricPotential.VOLT) assert "rms_voltage_max" not in hass.states.get(entity_id).attributes await send_attributes_report(hass, cluster, {0: 1, 0x0507: 888, 10: 5000}) - assert hass.states.get(entity_id).attributes["rms_voltage_max"] == 8.9 + assert hass.states.get(entity_id).attributes["rms_voltage_max"] == 8.88 async def async_test_powerconfiguration( @@ -269,7 +269,7 @@ async def async_test_powerconfiguration( ): """Test powerconfiguration/battery sensor.""" await send_attributes_report(hass, cluster, {33: 98}) - assert_state(hass, entity_id, "49", "%") + assert_state(hass, entity_id, "49.0", "%") assert hass.states.get(entity_id).attributes["battery_voltage"] == 2.9 assert hass.states.get(entity_id).attributes["battery_quantity"] == 3 assert hass.states.get(entity_id).attributes["battery_size"] == "AAA" @@ -288,7 +288,7 @@ async def async_test_powerconfiguration2( assert_state(hass, entity_id, STATE_UNKNOWN, "%") await send_attributes_report(hass, cluster, {33: 98}) - assert_state(hass, entity_id, "49", "%") + assert_state(hass, entity_id, "49.0", "%") async def async_test_device_temperature( @@ -317,7 +317,7 @@ async def async_test_pi_heating_demand( await send_attributes_report( hass, cluster, {Thermostat.AttributeDefs.pi_heating_demand.id: 1} ) - assert_state(hass, entity_id, "1", "%") + assert_state(hass, entity_id, "1.0", "%") @pytest.mark.parametrize( diff --git a/tests/components/zimi/__init__.py b/tests/components/zimi/__init__.py new file mode 100644 index 00000000000..0e95ffc9c33 --- /dev/null +++ b/tests/components/zimi/__init__.py @@ -0,0 +1 @@ +"""Tests for the zimi component.""" diff --git a/tests/components/zimi/test_config_flow.py b/tests/components/zimi/test_config_flow.py new file mode 100644 index 00000000000..9ec0c624b6f --- /dev/null +++ b/tests/components/zimi/test_config_flow.py @@ -0,0 +1,371 @@ +"""Tests for the zimi config flow.""" + +from unittest.mock import MagicMock, patch + +import pytest +from zcc import ( + ControlPointCannotConnectError, + ControlPointConnectionRefusedError, + ControlPointDescription, + ControlPointError, + ControlPointInvalidHostError, + ControlPointTimeoutError, +) + +from homeassistant import config_entries +from homeassistant.components.zimi.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + +INPUT_MAC = "aa:bb:cc:dd:ee:ff" +INPUT_MAC_EXTRA = "aa:bb:cc:dd:ee:ee" +INPUT_HOST = "192.168.1.100" +INPUT_HOST_EXTRA = "192.168.1.101" +INPUT_PORT = 5003 +INPUT_PORT_EXTRA = 5004 + +INVALID_INPUT_MAC = "xyz" +MISMATCHED_INPUT_MAC = "aa:bb:cc:dd:ee:ee" +SELECTED_HOST_AND_PORT = "selected_host_and_port" + + +@pytest.fixture +def discovery_mock(): + """Mock the ControlPointDiscoveryService.""" + with patch( + "homeassistant.components.zimi.config_flow.ControlPointDiscoveryService", + autospec=True, + ) as mock: + mock.return_value = mock + yield mock + + +async def test_user_discovery_success( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test user form transitions to creation if zcc discovery succeeds.""" + + discovery_mock.discovers.return_value = [ + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT) + ] + + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +async def test_user_discovery_success_selection( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test user form transitions via selection to creation if zcc discovery succeeds has multiple hosts.""" + + discovery_mock.discovers.return_value = [ + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT), + ControlPointDescription(host=INPUT_HOST_EXTRA, port=INPUT_PORT_EXTRA), + ] + + 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"] == "selection" + assert result["errors"] == {} + + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription( + host=INPUT_HOST_EXTRA, port=INPUT_PORT_EXTRA, mac=INPUT_MAC_EXTRA + ) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + SELECTED_HOST_AND_PORT: f"{INPUT_HOST_EXTRA}:{INPUT_PORT_EXTRA!s}", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "host": INPUT_HOST_EXTRA, + "port": INPUT_PORT_EXTRA, + "mac": format_mac(INPUT_MAC_EXTRA), + } + + +async def test_user_discovery_duplicates( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test that flow is aborted if duplicates are added.""" + + MockConfigEntry( + domain=DOMAIN, + unique_id=INPUT_MAC, + data={ + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + "mac": format_mac(INPUT_MAC), + }, + ).add_to_hass(hass) + + discovery_mock.discovers.return_value = [ + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT) + ] + + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_finish_manual_success( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test manual form transitions to creation with valid data.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + 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"] == "manual" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +async def test_manual_cannot_connect( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test manual form transitions via cannot_connect to creation.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + + 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"] == "manual" + assert result["errors"] == {} + + # First attempt fails with CANNOT_CONNECT when attempting to connect + discovery_mock.return_value.validate_connection.side_effect = ( + ControlPointCannotConnectError + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == {"base": "cannot_connect"} + + # Second attempt succeeds + discovery_mock.return_value.validate_connection.side_effect = None + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +async def test_manual_gethostbyname_error( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test manual form transitions via gethostbyname failure to creation.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + + 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"] == "manual" + assert result["errors"] == {} + + # First attempt fails with name lookup failure when attempting to connect + discovery_mock.return_value.validate_connection.side_effect = ( + ControlPointInvalidHostError + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] + assert result["errors"] == {"base": "invalid_host"} + + # Second attempt succeeds + discovery_mock.return_value.validate_connection.side_effect = None + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +@pytest.mark.parametrize( + ("side_effect", "error_expected"), + [ + ( + ControlPointInvalidHostError, + {"base": "invalid_host"}, + ), + ( + ControlPointConnectionRefusedError, + {"base": "connection_refused"}, + ), + ( + ControlPointCannotConnectError, + {"base": "cannot_connect"}, + ), + ( + ControlPointTimeoutError, + {"base": "timeout"}, + ), + ( + Exception, + {"base": "unknown"}, + ), + ], +) +async def test_manual_connection_errors( + hass: HomeAssistant, + discovery_mock: MagicMock, + side_effect: Exception, + error_expected: dict, +) -> None: + """Test manual form connection errors.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + + 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"] == "manual" + assert result["errors"] == {} + + # First attempt fails with connection errors + discovery_mock.return_value.validate_connection.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == error_expected + + # Second attempt succeeds + discovery_mock.return_value.validate_connection.side_effect = None + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index a28b3c0592a..27276c6905f 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -17,22 +17,20 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": 32.880837, - "longitude": -117.237561, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + } + }, ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index bcdc0c3ce16..e0485ced091 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -1,6 +1,7 @@ """Provide common Z-Wave JS fixtures.""" import asyncio +from collections.abc import Generator import copy import io from typing import Any, cast @@ -13,7 +14,10 @@ from zwave_js_server.model.node import Node from zwave_js_server.model.node.data_model import NodeDataType from zwave_js_server.version import VersionInfo +from homeassistant.components.zwave_js import PLATFORMS from homeassistant.components.zwave_js.const import DOMAIN +from homeassistant.components.zwave_js.helpers import SERVER_VERSION_TIMEOUT +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonArrayType @@ -507,22 +511,38 @@ def aeotec_smart_switch_7_state_fixture() -> NodeDataType: ) +@pytest.fixture(name="zcombo_smoke_co_alarm_state") +def zcombo_smoke_co_alarm_state_fixture() -> NodeDataType: + """Load node with fixture data for ZCombo-G Smoke/CO Alarm.""" + return cast( + NodeDataType, + load_json_object_fixture("zcombo_smoke_co_alarm_state.json", DOMAIN), + ) + + # model fixtures @pytest.fixture(name="listen_block") -def mock_listen_block_fixture(): +def mock_listen_block_fixture() -> asyncio.Event: """Mock a listen block.""" return asyncio.Event() +@pytest.fixture(name="listen_result") +def listen_result_fixture() -> asyncio.Future[None]: + """Mock a listen result.""" + return asyncio.Future() + + @pytest.fixture(name="client") def mock_client_fixture( - controller_state, - controller_node_state, - version_state, - log_config_state, - listen_block, + controller_state: dict[str, Any], + controller_node_state: dict[str, Any], + version_state: dict[str, Any], + log_config_state: dict[str, Any], + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], ): """Mock a client.""" with patch( @@ -537,6 +557,7 @@ def mock_client_fixture( async def listen(driver_ready: asyncio.Event) -> None: driver_ready.set() await listen_block.wait() + await listen_result async def disconnect(): client.connected = False @@ -544,6 +565,7 @@ def mock_client_fixture( client.connect = AsyncMock(side_effect=connect) client.listen = AsyncMock(side_effect=listen) client.disconnect = AsyncMock(side_effect=disconnect) + client.disable_server_logging = MagicMock() client.driver = Driver( client, copy.deepcopy(controller_state), copy.deepcopy(log_config_state) ) @@ -567,6 +589,44 @@ def mock_client_fixture( yield client +@pytest.fixture(name="server_version_side_effect") +def server_version_side_effect_fixture() -> Any | None: + """Return the server version side effect.""" + return None + + +@pytest.fixture(name="get_server_version", autouse=True) +def mock_get_server_version( + server_version_side_effect: Any | None, server_version_timeout: int +) -> Generator[AsyncMock]: + """Mock server version.""" + version_info = VersionInfo( + driver_version="mock-driver-version", + server_version="mock-server-version", + home_id=1234, + min_schema_version=0, + max_schema_version=1, + ) + with ( + patch( + "homeassistant.components.zwave_js.helpers.get_server_version", + side_effect=server_version_side_effect, + return_value=version_info, + ) as mock_version, + patch( + "homeassistant.components.zwave_js.helpers.SERVER_VERSION_TIMEOUT", + new=server_version_timeout, + ), + ): + yield mock_version + + +@pytest.fixture(name="server_version_timeout") +def mock_server_version_timeout() -> int: + """Patch the timeout for getting server version.""" + return SERVER_VERSION_TIMEOUT + + @pytest.fixture(name="multisensor_6") def multisensor_6_fixture(client, multisensor_6_state) -> Node: """Mock a multisensor 6 node.""" @@ -817,18 +877,33 @@ def nortek_thermostat_removed_event_fixture(client) -> Node: @pytest.fixture(name="integration") -async def integration_fixture(hass: HomeAssistant, client) -> MockConfigEntry: +async def integration_fixture( + hass: HomeAssistant, + client: MagicMock, + platforms: list[Platform], +) -> MockConfigEntry: """Set up the zwave_js integration.""" - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org"}, + unique_id=str(client.driver.controller.home_id), + ) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.zwave_js.PLATFORMS", platforms): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() client.async_send_command.reset_mock() return entry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return PLATFORMS + + @pytest.fixture(name="chain_actuator_zws12") def window_cover_fixture(client, chain_actuator_zws12_state) -> Node: """Mock a window cover node.""" @@ -1231,3 +1306,13 @@ def aeotec_smart_switch_7_fixture( node = Node(client, aeotec_smart_switch_7_state) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="zcombo_smoke_co_alarm") +def zcombo_smoke_co_alarm_fixture( + client: MagicMock, zcombo_smoke_co_alarm_state: NodeDataType +) -> Node: + """Load node for ZCombo-G Smoke/CO Alarm.""" + node = Node(client, zcombo_smoke_co_alarm_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/controller_state.json b/tests/components/zwave_js/fixtures/controller_state.json index d6d9dcacd9e..c3b9de4bdec 100644 --- a/tests/components/zwave_js/fixtures/controller_state.json +++ b/tests/components/zwave_js/fixtures/controller_state.json @@ -23,6 +23,7 @@ ], "sucNodeId": 1, "supportsTimers": false, + "supportsLongRange": true, "isHealNetworkActive": false, "inclusionState": 0, "status": 0 diff --git a/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json b/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json new file mode 100644 index 00000000000..c7417859f1c --- /dev/null +++ b/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json @@ -0,0 +1,854 @@ +{ + "nodeId": 3, + "index": 0, + "installerIcon": 3073, + "userIcon": 3073, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 312, + "productId": 3, + "productType": 1, + "firmwareVersion": "11.0.0", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/data/db/devices/0x0138/zcombo-g.json", + "isEmbedded": true, + "manufacturer": "First Alert (BRK Brands Inc)", + "manufacturerId": 312, + "label": "ZCOMBO", + "description": "ZCombo-G Smoke/CO Alarm", + "devices": [ + { + "productType": 1, + "productId": 3 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "wakeup": "WAKEUP\n1. Slide battery door open and then closed with the batteries inserted.", + "inclusion": "ADD\n1. Slide battery door open.\n2. Insert batteries checking the correct orientation.\n3. Press and hold the test button. Keep it held down as you slide the battery drawer closed. You may then release the button.\nNOTE: Use only your finger or thumb on the test button. The use of any other instrument is strictly prohibited", + "exclusion": "REMOVE\n1. Slide battery door open.\n2. Remove and re-insert batteries checking the correct orientation.\n3. Press and hold the test button. Keep it held down as you slide the battery drawer closed. You may then release the button.\nNOTE: Use only your finger or thumb on the test button. The use of any other instrument is strictly prohibited", + "reset": "RESET DEVICE\nIf the device is powered up with the test button held down for 10+ seconds, the device will reset all Z-Wave settings and leave the network.\nUpon completion of the Reset operation, the LED will glow and the horn will sound for ~1 second.\nPlease use this procedure only when the network primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/3886/User_Manual_M08-0456-173833_D2.pdf" + } + }, + "label": "ZCOMBO", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0138:0x0001:0x0003:11.0.0", + "statistics": { + "commandsTX": 1, + "commandsRX": 4, + "commandsDroppedRX": 1, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -79, + "repeaterRSSI": [] + }, + "lastSeen": "2024-11-11T21:36:45.802Z", + "rtt": 28.9, + "rssi": -79 + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-11-11T19:17:39.916Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Supervision Report Timeout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "ZCOMBO will send the message over Supervision Command Class and it will wait for the Supervision report from the Controller for the Supervision report timeout time.", + "label": "Supervision Report Timeout", + "default": 1500, + "min": 500, + "max": 5000, + "unit": "ms", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1500 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Supervision Retry Count", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "If the Supervision report is not received within the Supervision report timeout time, the ZCOMBO will retry sending the message again. Upon exceeding the max retry, the ZCOMBO device will send the next message available in the queue.", + "label": "Supervision Retry Count", + "default": 1, + "min": 0, + "max": 5, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Supervision Wait Time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Before retrying the message, ZCOMBO will wait for the Supervision wait time. Actual wait time is calculated using the formula: Wait Time = Supervision wait time base-value + random-value + (attempt-count x 5 seconds). The random value will be between 100 and 1100 milliseconds.", + "label": "Supervision Wait Time", + "default": 5, + "min": 1, + "max": 60, + "unit": "seconds", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Smoke Alarm", + "propertyKey": "Sensor status", + "propertyName": "Smoke Alarm", + "propertyKeyName": "Sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Sensor status", + "ccSpecific": { + "notificationType": 1 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Smoke detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Smoke Alarm", + "propertyKey": "Alarm status", + "propertyName": "Smoke Alarm", + "propertyKeyName": "Alarm status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm status", + "ccSpecific": { + "notificationType": 1 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "Smoke alarm test", + "6": "Alarm silenced" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Sensor status", + "propertyName": "CO Alarm", + "propertyKeyName": "Sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Sensor status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Carbon monoxide detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Maintenance status", + "propertyName": "CO Alarm", + "propertyKeyName": "Maintenance status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Maintenance status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "5": "Replacement required, End-of-life" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Alarm status", + "propertyName": "CO Alarm", + "propertyKeyName": "Alarm status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Alarm silenced" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "System hardware failure" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 312 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 92 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 2, + "metadata": { + "type": "number", + "default": 4200, + "readable": false, + "writeable": true, + "min": 4200, + "max": 4200, + "steps": 0, + "stateful": true, + "secret": false + }, + "value": 4200 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.7" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["11.0", "7.0"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "6.81.6" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "4.3.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 52445 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.7.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 97 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "11.0.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 0 + } + ], + "endpoints": [ + { + "nodeId": 3, + "index": 0, + "installerIcon": 3073, + "userIcon": 3073, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 2, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 42c5d59d7ad..d2f0f205e8f 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5,8 +5,9 @@ from http import HTTPStatus from io import BytesIO import json from typing import Any -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch +from aiohttp import ClientError import pytest from zwave_js_server.const import ( ExclusionStrategy, @@ -39,10 +40,12 @@ from zwave_js_server.model.value import ConfigurationValue, get_value_id_str from homeassistant.components.websocket_api import ERR_INVALID_FORMAT, ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( APPLICATION_VERSION, + AREA_ID, CLIENT_SIDE_AUTH, COMMAND_CLASS_ID, CONFIG, DEVICE_ID, + DEVICE_NAME, DSK, ENABLED, ENDPOINT, @@ -67,6 +70,7 @@ from homeassistant.components.zwave_js.api import ( PRODUCT_TYPE, PROPERTY, PROPERTY_KEY, + PROTOCOL, QR_CODE_STRING, QR_PROVISIONING_INFORMATION, REQUESTED_SECURITY_CLASSES, @@ -103,6 +107,12 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [] + + def get_device(hass: HomeAssistant, node): """Get device ID for a node.""" dev_reg = dr.async_get(hass) @@ -168,6 +178,7 @@ async def test_network_status( assert result["client"]["server_version"] == "1.0.0" assert not result["client"]["server_logging_enabled"] assert result["controller"]["inclusion_state"] == InclusionState.IDLE + assert result["controller"]["supports_long_range"] # Try API call with device ID device = device_registry.async_get_device( @@ -478,14 +489,14 @@ async def test_node_alerts( hass_ws_client: WebSocketGenerator, ) -> None: """Test the node comments websocket command.""" + entry = integration ws_client = await hass_ws_client(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, "3245146787-35")}) assert device - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 3, TYPE: "zwave_js/node_alerts", DEVICE_ID: device.id, } @@ -495,6 +506,99 @@ async def test_node_alerts( assert result["comments"] == [{"level": "info", "text": "test"}] assert result["is_embedded"] + # Test with node in interview + with patch("zwave_js_server.model.node.Node.in_interview", return_value=True): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/node_alerts", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["comments"]) == 2 + assert msg["result"]["comments"][1] == { + "level": "warning", + "text": "This device is currently being interviewed and may not be fully operational.", + } + + # Test with provisioned device + valid_qr_info = { + VERSION: 1, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + } + + # Test QR provisioning information + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/provision_smart_start_node", + ENTRY_ID: entry.entry_id, + QR_PROVISIONING_INFORMATION: valid_qr_info, + DEVICE_NAME: "test", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries", + return_value=[ + ProvisioningEntry.from_dict({**valid_qr_info, "device_id": msg["result"]}) + ], + ): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/node_alerts", + DEVICE_ID: msg["result"], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"]["comments"] == [ + { + "level": "info", + "text": "This device has been provisioned but is not yet included in the network.", + } + ] + + # Test missing node with no provisioning entry + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "3245146787-12")}, + ) + assert device + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/node_alerts", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + # Test integration not loaded error - need to unload the integration + 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/node_alerts", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + async def test_add_node( hass: HomeAssistant, @@ -1086,7 +1190,11 @@ async def test_validate_dsk_and_enter_pin( async def test_provision_smart_start_node( - hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + integration, + client, + hass_ws_client: WebSocketGenerator, ) -> None: """Test provision_smart_start_node websocket command.""" entry = integration @@ -1094,16 +1202,27 @@ async def test_provision_smart_start_node( client.async_send_command.return_value = {"success": True} - # Test provisioning entry + valid_qr_info = { + VERSION: 1, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + "name": "test", + } + + # Test QR provisioning information await ws_client.send_json( { - ID: 2, + ID: 3, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - PLANNED_PROVISIONING_ENTRY: { - DSK: "test", - SECURITY_CLASSES: [0], - }, + QR_PROVISIONING_INFORMATION: valid_qr_info, } ) @@ -1114,55 +1233,8 @@ async def test_provision_smart_start_node( assert client.async_send_command.call_args[0][0] == { "command": "controller.provision_smart_start_node", "entry": ProvisioningEntry( - "test", [SecurityClass.S2_UNAUTHENTICATED] - ).to_dict(), - } - - client.async_send_command.reset_mock() - client.async_send_command.return_value = {"success": True} - - # Test QR provisioning information - await ws_client.send_json( - { - ID: 3, - TYPE: "zwave_js/provision_smart_start_node", - ENTRY_ID: entry.entry_id, - QR_PROVISIONING_INFORMATION: { - VERSION: 1, - SECURITY_CLASSES: [0], - DSK: "test", - GENERIC_DEVICE_CLASS: 1, - SPECIFIC_DEVICE_CLASS: 1, - INSTALLER_ICON_TYPE: 1, - MANUFACTURER_ID: 1, - PRODUCT_TYPE: 1, - PRODUCT_ID: 1, - APPLICATION_VERSION: "test", - "name": "test", - }, - } - ) - - msg = await ws_client.receive_json() - assert msg["success"] - - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.provision_smart_start_node", - "entry": QRProvisioningInformation( - version=QRCodeVersion.SMART_START, - security_classes=[SecurityClass.S2_UNAUTHENTICATED], dsk="test", - generic_device_class=1, - specific_device_class=1, - installer_icon_type=1, - manufacturer_id=1, - product_type=1, - product_id=1, - application_version="test", - max_inclusion_request_interval=None, - uuid=None, - supported_protocols=None, + security_classes=[SecurityClass.S2_UNAUTHENTICATED], additional_properties={"name": "test"}, ).to_dict(), } @@ -1170,27 +1242,50 @@ async def test_provision_smart_start_node( client.async_send_command.reset_mock() client.async_send_command.return_value = {"success": True} - # Test QR code string + # Test QR provisioning information with device name and area await ws_client.send_json( { ID: 4, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + QR_PROVISIONING_INFORMATION: { + **valid_qr_info, + }, + PROTOCOL: Protocols.ZWAVE_LONG_RANGE, + DEVICE_NAME: "test_name", + AREA_ID: "test_area", } ) - msg = await ws_client.receive_json() assert msg["success"] - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.provision_smart_start_node", - "entry": "90testtesttesttesttesttesttesttesttesttesttesttesttest", - } + # verify a device was created + device = device_registry.async_get_device( + identifiers={(DOMAIN, "provision_test")}, + ) + assert device is not None + assert device.name == "test_name" + assert device.area_id == "test_area" - client.async_send_command.reset_mock() - client.async_send_command.return_value = {"success": True} + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "config_manager.lookup_device", + "manufacturerId": 1, + "productType": 1, + "productId": 1, + } + assert client.async_send_command.call_args_list[1][0][0] == { + "command": "controller.provision_smart_start_node", + "entry": ProvisioningEntry( + dsk="test", + security_classes=[SecurityClass.S2_UNAUTHENTICATED], + protocol=Protocols.ZWAVE_LONG_RANGE, + additional_properties={ + "name": "test", + "device_id": device.id, + }, + ).to_dict(), + } # Test QR provisioning information with S2 version throws error await ws_client.send_json( @@ -1242,9 +1337,7 @@ async def test_provision_smart_start_node( ID: 7, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: ( - "90testtesttesttesttesttesttesttesttesttesttesttesttest" - ), + QR_PROVISIONING_INFORMATION: valid_qr_info, } ) msg = await ws_client.receive_json() @@ -1262,7 +1355,7 @@ async def test_provision_smart_start_node( ID: 8, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + QR_PROVISIONING_INFORMATION: valid_qr_info, } ) msg = await ws_client.receive_json() @@ -1272,7 +1365,11 @@ async def test_provision_smart_start_node( async def test_unprovision_smart_start_node( - hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + integration, + client, + hass_ws_client: WebSocketGenerator, ) -> None: """Test unprovision_smart_start_node websocket command.""" entry = integration @@ -1281,9 +1378,8 @@ async def test_unprovision_smart_start_node( client.async_send_command.return_value = {} # Test node ID as input - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 1, TYPE: "zwave_js/unprovision_smart_start_node", ENTRY_ID: entry.entry_id, NODE_ID: 1, @@ -1293,8 +1389,12 @@ async def test_unprovision_smart_start_node( msg = await ws_client.receive_json() assert msg["success"] - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "controller.get_provisioning_entry", + "dskOrNodeId": 1, + } + assert client.async_send_command.call_args_list[1][0][0] == { "command": "controller.unprovision_smart_start_node", "dskOrNodeId": 1, } @@ -1303,9 +1403,8 @@ async def test_unprovision_smart_start_node( client.async_send_command.return_value = {} # Test DSK as input - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 2, TYPE: "zwave_js/unprovision_smart_start_node", ENTRY_ID: entry.entry_id, DSK: "test", @@ -1315,8 +1414,12 @@ async def test_unprovision_smart_start_node( msg = await ws_client.receive_json() assert msg["success"] - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "controller.get_provisioning_entry", + "dskOrNodeId": "test", + } + assert client.async_send_command.call_args_list[1][0][0] == { "command": "controller.unprovision_smart_start_node", "dskOrNodeId": "test", } @@ -1325,9 +1428,8 @@ async def test_unprovision_smart_start_node( client.async_send_command.return_value = {} # Test not including DSK or node ID as input fails - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 3, TYPE: "zwave_js/unprovision_smart_start_node", ENTRY_ID: entry.entry_id, } @@ -1338,14 +1440,78 @@ async def test_unprovision_smart_start_node( assert len(client.async_send_command.call_args_list) == 0 + # Test with pre provisioned device + # Create device registry entry for mock node + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "provision_test"), ("other_domain", "test")}, + name="Node 67", + ) + provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": "test", + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": device.id, + } + ) + with patch.object( + client.driver.controller, + "async_get_provisioning_entry", + return_value=provisioning_entry, + ): + # Don't remove the device if it has additional identifiers + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/unprovision_smart_start_node", + ENTRY_ID: entry.entry_id, + DSK: "test", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.unprovision_smart_start_node", + "dskOrNodeId": "test", + } + + device = device_registry.async_get(device.id) + assert device is not None + + client.async_send_command.reset_mock() + + # Remove the device if it doesn't have additional identifiers + device_registry.async_update_device( + device.id, new_identifiers={(DOMAIN, "provision_test")} + ) + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/unprovision_smart_start_node", + ENTRY_ID: entry.entry_id, + DSK: "test", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.unprovision_smart_start_node", + "dskOrNodeId": "test", + } + + # Verify device was removed from device registry + device = device_registry.async_get(device.id) + assert device is None + # Test FailedZWaveCommand is caught with patch( f"{CONTROLLER_PATCH_PREFIX}.async_unprovision_smart_start_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 6, TYPE: "zwave_js/unprovision_smart_start_node", ENTRY_ID: entry.entry_id, DSK: "test", @@ -1361,9 +1527,8 @@ async def test_unprovision_smart_start_node( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 7, TYPE: "zwave_js/unprovision_smart_start_node", ENTRY_ID: entry.entry_id, DSK: "test", @@ -4930,53 +5095,136 @@ async def test_subscribe_node_statistics( assert msg["error"]["code"] == ERR_NOT_LOADED -@pytest.mark.skip( - reason="The test needs to be updated to reflect what happens when resetting the controller" -) async def test_hard_reset_controller( hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, device_registry: dr.DeviceRegistry, - client, - integration, - listen_block, + client: MagicMock, + get_server_version: AsyncMock, + integration: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: """Test that the hard_reset_controller WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) + assert entry.unique_id == "3245146787" + + async def async_send_command_driver_ready( + message: dict[str, Any], + require_schema: int | None = None, + ) -> dict: + """Send a command and get a response.""" + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + return {} + + client.async_send_command.side_effect = async_send_command_driver_ready + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() device = device_registry.async_get_device( identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} ) + assert device is not None + assert msg["result"] == device.id + assert msg["success"] - client.async_send_command.return_value = {} - await ws_client.send_json( + assert client.async_send_command.call_count == 3 + # The first call is the relevant hard reset command. + # 25 is the require_schema parameter. + assert client.async_send_command.call_args_list[0] == call( + {"command": "driver.hard_reset"}, 25 + ) + assert entry.unique_id == "1234" + + client.async_send_command.reset_mock() + + # Test client connect error when getting the server version. + + get_server_version.side_effect = ClientError("Boom!") + + await ws_client.send_json_auto_id( { - ID: 1, TYPE: "zwave_js/hard_reset_controller", ENTRY_ID: entry.entry_id, } ) - listen_block.set() - listen_block.clear() - await hass.async_block_till_done() - msg = await ws_client.receive_json() + + device = device_registry.async_get_device( + identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} + ) + assert device is not None assert msg["result"] == device.id assert msg["success"] - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == {"command": "driver.hard_reset"} + assert client.async_send_command.call_count == 3 + # The first call is the relevant hard reset command. + # 25 is the require_schema parameter. + assert client.async_send_command.call_args_list[0] == call( + {"command": "driver.hard_reset"}, 25 + ) + assert ( + "Failed to get server version, cannot update config entry" + "unique id with new home id, after controller reset" + ) in caplog.text + + client.async_send_command.reset_mock() + + # Test sending command with driver not ready and timeout. + + async def async_send_command_no_driver_ready( + message: dict[str, Any], + require_schema: int | None = None, + ) -> dict: + """Send a command and get a response.""" + return {} + + client.async_send_command.side_effect = async_send_command_no_driver_ready + + with patch( + "homeassistant.components.zwave_js.api.DRIVER_READY_TIMEOUT", + new=0, + ): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + device = device_registry.async_get_device( + identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} + ) + assert device is not None + assert msg["result"] == device.id + assert msg["success"] + + assert client.async_send_command.call_count == 3 + # The first call is the relevant hard reset command. + # 25 is the require_schema parameter. + assert client.async_send_command.call_args_list[0] == call( + {"command": "driver.hard_reset"}, 25 + ) + + client.async_send_command.reset_mock() # Test FailedZWaveCommand is caught with patch( "zwave_js_server.model.driver.Driver.async_hard_reset", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 2, TYPE: "zwave_js/hard_reset_controller", ENTRY_ID: entry.entry_id, } @@ -4991,9 +5239,8 @@ async def test_hard_reset_controller( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 3, TYPE: "zwave_js/hard_reset_controller", ENTRY_ID: entry.entry_id, } @@ -5003,9 +5250,8 @@ async def test_hard_reset_controller( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_LOADED - await ws_client.send_json( + await ws_client.send_json_auto_id( { - ID: 4, TYPE: "zwave_js/hard_reset_controller", ENTRY_ID: "INVALID", } @@ -5200,6 +5446,308 @@ async def test_get_integration_settings( } +async def test_backup_nvm( + hass: HomeAssistant, + integration, + client, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the backup NVM websocket command.""" + ws_client = await hass_ws_client(hass) + + # Set up mocks for the controller events + controller = client.driver.controller + + # Test subscription and events + with patch.object( + controller, "async_backup_nvm_raw_base64", return_value="test" + ) as mock_backup: + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": integration.entry_id, + } + ) + + # Verify the finished event with data first + msg = await ws_client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["event"] == "finished" + assert msg["event"]["data"] == "test" + + # Verify subscription success + msg = await ws_client.receive_json() + assert msg["type"] == "result" + assert msg["success"] is True + + # Simulate progress events + event = Event( + "nvm backup progress", + { + "source": "controller", + "event": "nvm backup progress", + "bytesRead": 25, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm backup progress" + assert msg["event"]["bytesRead"] == 25 + assert msg["event"]["total"] == 100 + + event = Event( + "nvm backup progress", + { + "source": "controller", + "event": "nvm backup progress", + "bytesRead": 50, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm backup progress" + assert msg["event"]["bytesRead"] == 50 + assert msg["event"]["total"] == 100 + + # Wait for the backup to complete + await hass.async_block_till_done() + + # Verify the backup was called + assert mock_backup.called + + # Test backup failure + with patch.object( + controller, + "async_backup_nvm_raw_base64", + side_effect=FailedCommand("failed_command", "Backup failed"), + ): + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": integration.entry_id, + } + ) + + # Verify error response + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "Backup failed" + + # Test config entry not found + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": "invalid_entry_id", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + + # Test config entry not loaded + await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": integration.entry_id, + } + ) + msg = await ws_client.receive_json() + assert msg["error"]["code"] == "not_loaded" + + +async def test_restore_nvm( + hass: HomeAssistant, + integration, + client, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the restore NVM websocket command.""" + ws_client = await hass_ws_client(hass) + + # Set up mocks for the controller events + controller = client.driver.controller + + async def async_send_command_driver_ready( + message: dict[str, Any], + require_schema: int | None = None, + ) -> dict: + """Send a command and get a response.""" + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + return {} + + client.async_send_command.side_effect = async_send_command_driver_ready + + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": integration.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + + # Verify the finished event first + msg = await ws_client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["event"] == "finished" + + # Verify subscription success + msg = await ws_client.receive_json() + assert msg["type"] == "result" + assert msg["success"] is True + + # Simulate progress events + event = Event( + "nvm restore progress", + { + "source": "controller", + "event": "nvm restore progress", + "bytesWritten": 25, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 25 + assert msg["event"]["total"] == 100 + + event = Event( + "nvm restore progress", + { + "source": "controller", + "event": "nvm restore progress", + "bytesWritten": 50, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 50 + assert msg["event"]["total"] == 100 + + await hass.async_block_till_done() + + # Verify the restore was called + # The first call is the relevant one for nvm restore. + assert client.async_send_command.call_count == 3 + assert client.async_send_command.call_args_list[0] == call( + { + "command": "controller.restore_nvm", + "nvmData": "dGVzdA==", + }, + require_schema=14, + ) + + client.async_send_command.reset_mock() + + # Test sending command with driver not ready and timeout. + + async def async_send_command_no_driver_ready( + message: dict[str, Any], + require_schema: int | None = None, + ) -> dict: + """Send a command and get a response.""" + return {} + + client.async_send_command.side_effect = async_send_command_no_driver_ready + + with patch( + "homeassistant.components.zwave_js.api.DRIVER_READY_TIMEOUT", + new=0, + ): + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": integration.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + + # Verify the finished event first + msg = await ws_client.receive_json() + + assert msg["type"] == "event" + assert msg["event"]["event"] == "finished" + + # Verify subscription success + msg = await ws_client.receive_json() + assert msg["type"] == "result" + assert msg["success"] is True + + await hass.async_block_till_done() + + # Verify the restore was called + # The first call is the relevant one for nvm restore. + assert client.async_send_command.call_count == 3 + assert client.async_send_command.call_args_list[0] == call( + { + "command": "controller.restore_nvm", + "nvmData": "dGVzdA==", + }, + require_schema=14, + ) + + client.async_send_command.reset_mock() + + # Test restore failure + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_restore_nvm_base64", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": integration.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + + # Verify error response + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + + # Test entry_id not found + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": "invalid_entry_id", + "data": "dGVzdA==", # base64 encoded "test" + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + + # Test config entry not loaded + await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": integration.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_loaded" + + async def test_cancel_secure_bootstrap_s2( hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator ) -> None: @@ -5340,3 +5888,163 @@ async def test_subscribe_s2_inclusion( msg = await ws_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + + +async def test_lookup_device( + hass: HomeAssistant, + integration: MockConfigEntry, + client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test lookup_device websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + # Create mock device response + mock_device = MagicMock() + mock_device.to_dict.return_value = { + "manufacturer": "Test Manufacturer", + "label": "Test Device", + "description": "Test Device Description", + "devices": [{"productType": 1, "productId": 2}], + "firmwareVersion": {"min": "1.0", "max": "2.0"}, + } + + # Test successful lookup + client.driver.config_manager.lookup_device = AsyncMock(return_value=mock_device) + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 2, + PRODUCT_ID: 3, + APPLICATION_VERSION: "1.5", + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["result"] == mock_device.to_dict.return_value + + client.driver.config_manager.lookup_device.assert_called_once_with(1, 2, 3, "1.5") + + # Reset mock + client.driver.config_manager.lookup_device.reset_mock() + + # Test lookup without optional application_version + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 4, + PRODUCT_TYPE: 5, + PRODUCT_ID: 6, + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["result"] == mock_device.to_dict.return_value + + client.driver.config_manager.lookup_device.assert_called_once_with(4, 5, 6, None) + + # Test device not found + with patch.object( + client.driver.config_manager, + "lookup_device", + return_value=None, + ): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 99, + PRODUCT_TYPE: 99, + PRODUCT_ID: 99, + APPLICATION_VERSION: "9.9", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + assert msg["error"]["message"] == "Device not found" + + # Test sending command with improper entry ID fails + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: "invalid_entry_id", + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "1.0", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + assert msg["error"]["message"] == "Config entry invalid_entry_id not found" + + # Test FailedCommand exception + error_message = "Failed to execute lookup_device command" + with patch.object( + client.driver.config_manager, + "lookup_device", + side_effect=FailedCommand("lookup_device", error_message), + ): + # Send the subscription request + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 2, + PRODUCT_ID: 3, + APPLICATION_VERSION: "1.0", + } + ) + + # Verify error response + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == error_message + assert msg["error"]["message"] == f"Command failed: {error_message}" + + +async def test_subscribe_new_devices( + hass: HomeAssistant, + integration, + client, + hass_ws_client: WebSocketGenerator, + multisensor_6_state, +) -> None: + """Test the subscribe_new_devices websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/subscribe_new_devices", + ENTRY_ID: entry.entry_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] is None + + # Simulate a device being registered + node = Node(client, deepcopy(multisensor_6_state)) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + # Verify we receive the expected message + msg = await ws_client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["event"] == "device registered" + assert msg["event"]["device"]["name"] == node.device_config.description + assert msg["event"]["device"]["manufacturer"] == node.device_config.manufacturer + assert msg["event"]["device"]["model"] == node.device_config.label diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 0054439ef1d..93ac52f9041 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test the Z-Wave JS binary sensor platform.""" +import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node @@ -10,6 +11,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNKNOWN, EntityCategory, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -26,6 +28,12 @@ from .common import ( from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BINARY_SENSOR] + + async def test_low_battery_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration ) -> None: @@ -285,3 +293,141 @@ async def test_config_parameter_binary_sensor( state = hass.states.get(binary_sensor_entity_id) assert state assert state.state == STATE_OFF + + +async def test_smoke_co_notification_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zcombo_smoke_co_alarm: Node, + integration: MockConfigEntry, +) -> None: + """Test smoke and CO notification sensors with diagnostic states.""" + # Test smoke alarm sensor + smoke_sensor = "binary_sensor.zcombo_g_smoke_co_alarm_smoke_detected" + state = hass.states.get(smoke_sensor) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.SMOKE + entity_entry = entity_registry.async_get(smoke_sensor) + assert entity_entry + assert entity_entry.entity_category != EntityCategory.DIAGNOSTIC + + # Test smoke alarm diagnostic sensor + smoke_diagnostic = "binary_sensor.zcombo_g_smoke_co_alarm_smoke_alarm_test" + state = hass.states.get(smoke_diagnostic) + assert state + assert state.state == STATE_OFF + entity_entry = entity_registry.async_get(smoke_diagnostic) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + # Test CO alarm sensor + co_sensor = "binary_sensor.zcombo_g_smoke_co_alarm_carbon_monoxide_detected" + state = hass.states.get(co_sensor) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CO + entity_entry = entity_registry.async_get(co_sensor) + assert entity_entry + assert entity_entry.entity_category != EntityCategory.DIAGNOSTIC + + # Test diagnostic entities + entity_ids = [ + "binary_sensor.zcombo_g_smoke_co_alarm_smoke_alarm_test", + "binary_sensor.zcombo_g_smoke_co_alarm_alarm_silenced", + "binary_sensor.zcombo_g_smoke_co_alarm_replacement_required_end_of_life", + "binary_sensor.zcombo_g_smoke_co_alarm_alarm_silenced_2", + "binary_sensor.zcombo_g_smoke_co_alarm_system_hardware_failure", + "binary_sensor.zcombo_g_smoke_co_alarm_low_battery_level", + ] + for entity_id in entity_ids: + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + # Test state updates for smoke alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Smoke Alarm", + "propertyKey": "Sensor status", + "newValue": 2, + "prevValue": 0, + "propertyName": "Smoke Alarm", + "propertyKeyName": "Sensor status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(smoke_sensor) + assert state is not None, "Smoke sensor state should not be None" + assert state.state == STATE_ON, ( + f"Expected smoke sensor state to be 'on', got '{state.state}'" + ) + + # Test state updates for CO alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "CO Alarm", + "propertyKey": "Sensor status", + "newValue": 2, + "prevValue": 0, + "propertyName": "CO Alarm", + "propertyKeyName": "Sensor status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(co_sensor) + assert state is not None, "CO sensor state should not be None" + assert state.state == STATE_ON, ( + f"Expected CO sensor state to be 'on', got '{state.state}'" + ) + + # Test diagnostic state updates for smoke alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Smoke Alarm", + "propertyKey": "Alarm status", + "newValue": 3, + "prevValue": 0, + "propertyName": "Smoke Alarm", + "propertyKeyName": "Alarm status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(smoke_diagnostic) + assert state is not None, "Smoke diagnostic state should not be None" + assert state.state == STATE_ON, ( + f"Expected smoke diagnostic state to be 'on', got '{state.state}'" + ) diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index b0c06668926..0282a268b54 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -5,11 +5,17 @@ import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id -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 +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BUTTON] + + async def test_ping_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 5d711528a28..f312284d897 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -42,6 +42,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -56,6 +57,12 @@ from .common import ( ) +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + async def test_thermostat_v2( hass: HomeAssistant, client, diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index e7239c23de6..7a2788a7b75 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -13,18 +13,20 @@ from aiohasupervisor.models import AddonsOptions, Discovery import aiohttp import pytest from serial.tools.list_ports_common import ListPortInfo +from zwave_js_server.exceptions import FailedCommand from zwave_js_server.version import VersionInfo -from homeassistant import config_entries -from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE -from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.zwave_js.config_flow import TITLE +from homeassistant.components.zwave_js.const import ADDON_SLUG, CONF_USB_PATH, DOMAIN +from homeassistant.components.zwave_js.helpers import SERVER_VERSION_TIMEOUT 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 +from tests.common import MockConfigEntry, async_capture_events ADDON_DISCOVERY_INFO = { "addon": "Z-Wave JS", @@ -61,6 +63,12 @@ CP2652_ZIGBEE_DISCOVERY_INFO = UsbServiceInfo( ) +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture(name="setup_entry") def setup_entry_fixture() -> Generator[AsyncMock]: """Mock entry setup.""" @@ -70,6 +78,15 @@ def setup_entry_fixture() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture(name="unload_entry") +def unload_entry_fixture() -> Generator[AsyncMock]: + """Mock entry unload.""" + with patch( + "homeassistant.components.zwave_js.async_unload_entry", return_value=True + ) as mock_unload_entry: + yield mock_unload_entry + + @pytest.fixture(name="supervisor") def mock_supervisor_fixture() -> Generator[None]: """Mock Supervisor.""" @@ -79,44 +96,6 @@ def mock_supervisor_fixture() -> Generator[None]: yield -@pytest.fixture(name="server_version_side_effect") -def server_version_side_effect_fixture() -> Any | None: - """Return the server version side effect.""" - return None - - -@pytest.fixture(name="get_server_version", autouse=True) -def mock_get_server_version( - server_version_side_effect: Any | None, server_version_timeout: int -) -> Generator[AsyncMock]: - """Mock server version.""" - version_info = VersionInfo( - driver_version="mock-driver-version", - server_version="mock-server-version", - home_id=1234, - min_schema_version=0, - max_schema_version=1, - ) - with ( - patch( - "homeassistant.components.zwave_js.config_flow.get_server_version", - side_effect=server_version_side_effect, - return_value=version_info, - ) as mock_version, - patch( - "homeassistant.components.zwave_js.config_flow.SERVER_VERSION_TIMEOUT", - new=server_version_timeout, - ), - ): - yield mock_version - - -@pytest.fixture(name="server_version_timeout") -def mock_server_version_timeout() -> int: - """Patch the timeout for getting server version.""" - return SERVER_VERSION_TIMEOUT - - @pytest.fixture(name="addon_setup_time", autouse=True) def mock_addon_setup_time() -> Generator[None]: """Mock add-on setup sleep time.""" @@ -170,6 +149,16 @@ def mock_usb_serial_by_id_fixture() -> Generator[MagicMock]: yield mock_usb_serial_by_id +@pytest.fixture +def mock_sdk_version(client: MagicMock) -> Generator[None]: + """Mock the SDK version of the controller.""" + original_sdk_version = client.driver.controller.data.get("sdkVersion") + client.driver.controller.data["sdkVersion"] = "6.60" + yield + if original_sdk_version is not None: + client.driver.controller.data["sdkVersion"] = original_sdk_version + + async def test_manual(hass: HomeAssistant) -> None: """Test we create an entry with manual step.""" @@ -220,18 +209,48 @@ async def slow_server_version(*args): @pytest.mark.parametrize( - ("flow", "flow_params"), + ("url", "server_version_side_effect", "server_version_timeout", "error"), [ ( - "flow", - lambda entry: { - "handler": DOMAIN, - "context": {"source": config_entries.SOURCE_USER}, - }, + "not-ws-url", + None, + SERVER_VERSION_TIMEOUT, + "invalid_ws_url", + ), + ( + "ws://localhost:3000", + slow_server_version, + 0, + "cannot_connect", + ), + ( + "ws://localhost:3000", + Exception("Boom"), + SERVER_VERSION_TIMEOUT, + "unknown", ), - ("options", lambda entry: {"handler": entry.entry_id}), ], ) +async def test_manual_errors(hass: HomeAssistant, integration, url, error) -> None: + """Test all errors with a manual set up.""" + 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"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": url, + }, + ) + + assert result["step_id"] == "manual" + assert result["errors"] == {"base": error} + + @pytest.mark.parametrize( ("url", "server_version_side_effect", "server_version_timeout", "error"), [ @@ -255,25 +274,30 @@ async def slow_server_version(*args): ), ], ) -async def test_manual_errors( - hass: HomeAssistant, integration, url, error, flow, flow_params +async def test_reconfigure_manual_errors( + hass: HomeAssistant, integration, url, error ) -> None: - """Test all errors with a manual set up.""" + """Test all errors with a manual set up in a reconfigure flow.""" entry = integration - result = await getattr(hass.config_entries, flow).async_init(**flow_params(entry)) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" + assert result["step_id"] == "manual_reconfigure" - result = await getattr(hass.config_entries, flow).async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "url": url, }, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" + assert result["step_id"] == "manual_reconfigure" assert result["errors"] == {"base": error} @@ -556,6 +580,28 @@ async def test_abort_hassio_discovery_for_other_addon( assert result2["reason"] == "not_zwave_js_addon" +@pytest.mark.parametrize( + ("usb_discovery_info", "device", "discovery_name"), + [ + ( + USB_DISCOVERY_INFO, + USB_DISCOVERY_INFO.device, + "zwave radio", + ), + ( + UsbServiceInfo( + device="/dev/zwa2", + pid="303A", + vid="4001", + serial_number="1234", + description="ZWA-2 - Nabu Casa ZWA-2", + manufacturer="Nabu Casa", + ), + "/dev/zwa2", + "Home Assistant Connect ZWA-2", + ), + ], +) @pytest.mark.parametrize( "discovery_info", [ @@ -576,17 +622,23 @@ async def test_usb_discovery( install_addon, addon_options, get_addon_discovery_info, + mock_usb_serial_by_id: MagicMock, set_addon_options, start_addon, + usb_discovery_info: UsbServiceInfo, + device: str, + discovery_name: str, ) -> None: """Test usb discovery success path.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USB}, - data=USB_DISCOVERY_INFO, + data=usb_discovery_info, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "usb_confirm" + assert result["description_placeholders"] == {"name": discovery_name} + assert mock_usb_serial_by_id.call_count == 1 result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -601,7 +653,7 @@ async def test_usb_discovery( assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -619,7 +671,7 @@ async def test_usb_discovery( "core_zwave_js", AddonsOptions( config={ - "device": USB_DISCOVERY_INFO.device, + "device": device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -652,7 +704,7 @@ async def test_usb_discovery( assert result["title"] == TITLE assert result["data"] == { "url": "ws://host1:3001", - "usb_path": USB_DISCOVERY_INFO.device, + "usb_path": device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -684,6 +736,7 @@ async def test_usb_discovery_addon_not_running( supervisor, addon_installed, addon_options, + mock_usb_serial_by_id: MagicMock, set_addon_options, start_addon, get_addon_discovery_info, @@ -698,11 +751,12 @@ async def test_usb_discovery_addon_not_running( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "usb_confirm" + assert mock_usb_serial_by_id.call_count == 2 result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" # Make sure the discovered usb device is preferred. data_schema = result["data_schema"] @@ -778,6 +832,300 @@ async def test_usb_discovery_addon_not_running( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("supervisor", "addon_running", "get_addon_discovery_info") +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_usb_discovery_migration( + hass: HomeAssistant, + addon_options: dict[str, Any], + mock_usb_serial_by_id: MagicMock, + set_addon_options: AsyncMock, + restart_addon: AsyncMock, + client: MagicMock, + integration: MockConfigEntry, + get_server_version: AsyncMock, +) -> None: + """Test usb discovery migration.""" + version_info = get_server_version.return_value + version_info.home_id = 4321 + addon_options["device"] = "/dev/ttyUSB0" + entry = integration + assert client.connect.call_count == 1 + hass.config_entries.async_update_entry( + entry, + unique_id="1234", + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/dev/ttyUSB0", + }, + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm backup progress", {"bytesRead": 100, "total": 200} + ) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_reset_controller(): + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) + + async def mock_restore_nvm(data: bytes): + client.driver.controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, + ) + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + assert mock_usb_serial_by_id.call_count == 2 + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes") as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + assert len(events) == 1 + assert events[0].data["progress"] == 0.5 + events.clear() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.unique_id == "4321" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert set_addon_options.call_args == call( + "core_zwave_js", AddonsOptions(config={"device": USB_DISCOVERY_INFO.device}) + ) + + await hass.async_block_till_done() + + assert restart_addon.call_args == call("core_zwave_js") + + version_info.home_id = 5678 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 + + await hass.async_block_till_done() + assert client.connect.call_count == 3 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert client.driver.controller.async_restore_nvm.call_count == 1 + assert len(events) == 2 + assert events[0].data["progress"] == 0.25 + assert events[1].data["progress"] == 0.75 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device + assert entry.data["use_addon"] is True + assert entry.unique_id == "5678" + + +@pytest.mark.usefixtures("supervisor", "addon_running", "get_addon_discovery_info") +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_usb_discovery_migration_restore_driver_ready_timeout( + hass: HomeAssistant, + addon_options: dict[str, Any], + mock_usb_serial_by_id: MagicMock, + set_addon_options: AsyncMock, + restart_addon: AsyncMock, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test driver ready timeout after nvm restore during usb discovery migration.""" + addon_options["device"] = "/dev/ttyUSB0" + entry = integration + assert client.connect.call_count == 1 + hass.config_entries.async_update_entry( + entry, + unique_id="1234", + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/dev/ttyUSB0", + }, + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm backup progress", {"bytesRead": 100, "total": 200} + ) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_reset_controller(): + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) + + async def mock_restore_nvm(data: bytes): + client.driver.controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, + ) + + client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + assert mock_usb_serial_by_id.call_count == 2 + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes") as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + assert len(events) == 1 + assert events[0].data["progress"] == 0.5 + events.clear() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert set_addon_options.call_args == call( + "core_zwave_js", AddonsOptions(config={"device": USB_DISCOVERY_INFO.device}) + ) + + await hass.async_block_till_done() + + assert restart_addon.call_args == call("core_zwave_js") + + with patch( + ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), + new=0, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 + + await hass.async_block_till_done() + assert client.connect.call_count == 3 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert client.driver.controller.async_restore_nvm.call_count == 1 + assert len(events) == 2 + assert events[0].data["progress"] == 0.25 + assert events[1].data["progress"] == 0.75 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert integration.data["url"] == "ws://host1:3001" + assert integration.data["usb_path"] == USB_DISCOVERY_INFO.device + assert integration.data["use_addon"] is True + + async def test_discovery_addon_not_running( hass: HomeAssistant, supervisor, @@ -806,7 +1154,7 @@ async def test_discovery_addon_not_running( result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -908,7 +1256,7 @@ async def test_discovery_addon_not_installed( assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1001,10 +1349,50 @@ async def test_abort_usb_discovery_with_existing_flow( assert result2["reason"] == "already_in_progress" -async def test_abort_usb_discovery_already_configured( +@pytest.mark.usefixtures("supervisor", "addon_installed") +async def test_usb_discovery_with_existing_usb_flow(hass: HomeAssistant) -> None: + """Test usb discovery allows more than one USB flow in progress.""" + first_usb_info = UsbServiceInfo( + device="/dev/other_device", + pid="AAAA", + vid="AAAA", + serial_number="5678", + description="zwave radio", + manufacturer="test", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=first_usb_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "usb_confirm" + + usb_flows_in_progress = hass.config_entries.flow.async_progress_by_handler( + DOMAIN, match_context={"source": config_entries.SOURCE_USB} + ) + + assert len(usb_flows_in_progress) == 2 + + for flow in (result, result2): + hass.config_entries.flow.async_abort(flow["flow_id"]) + + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_abort_usb_discovery_addon_required( hass: HomeAssistant, supervisor, addon_options ) -> None: - """Test usb discovery flow is aborted when there is an existing entry.""" + """Test usb discovery aborted when existing entry not using add-on.""" entry = MockConfigEntry( domain=DOMAIN, data={"url": "ws://localhost:3000"}, @@ -1019,7 +1407,54 @@ async def test_abort_usb_discovery_already_configured( data=USB_DISCOVERY_INFO, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "addon_required" + + +@pytest.mark.usefixtures( + "supervisor", + "addon_running", +) +async def test_abort_usb_discovery_confirm_addon_required( + hass: HomeAssistant, + addon_options: dict[str, Any], + mock_usb_serial_by_id: MagicMock, +) -> None: + """Test usb discovery confirm aborted when existing entry not using add-on.""" + addon_options["device"] = "/dev/another_device" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "url": "ws://localhost:3000", + "usb_path": "/dev/another_device", + "use_addon": True, + }, + title=TITLE, + unique_id="1234", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + assert mock_usb_serial_by_id.call_count == 2 + + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + "use_addon": False, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_required" async def test_usb_discovery_requires_supervisor(hass: HomeAssistant) -> None: @@ -1033,10 +1468,14 @@ async def test_usb_discovery_requires_supervisor(hass: HomeAssistant) -> None: assert result["reason"] == "discovery_requires_supervisor" -async def test_usb_discovery_already_running( - hass: HomeAssistant, supervisor, addon_running +@pytest.mark.usefixtures("supervisor", "addon_running") +async def test_usb_discovery_same_device( + hass: HomeAssistant, + addon_options: dict[str, Any], + mock_usb_serial_by_id: MagicMock, ) -> None: - """Test usb discovery flow is aborted when the addon is running.""" + """Test usb discovery flow is aborted when the add-on device is discovered.""" + addon_options["device"] = USB_DISCOVERY_INFO.device result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USB}, @@ -1044,6 +1483,7 @@ async def test_usb_discovery_already_running( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + assert mock_usb_serial_by_id.call_count == 2 @pytest.mark.parametrize( @@ -1377,7 +1817,7 @@ async def test_addon_installed( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1480,7 +1920,7 @@ async def test_addon_installed_start_failure( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1565,7 +2005,7 @@ async def test_addon_installed_failures( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1646,7 +2086,7 @@ async def test_addon_installed_set_options_failure( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1682,6 +2122,32 @@ async def test_addon_installed_set_options_failure( assert start_addon.call_count == 0 +async def test_addon_installed_usb_ports_failure( + hass: HomeAssistant, + supervisor, + addon_installed, +) -> None: + """Test usb ports failure when add-on is installed.""" + + 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"] == "on_supervisor" + + with patch( + "homeassistant.components.zwave_js.config_flow.async_get_usb_ports", + side_effect=OSError("test_error"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "usb_ports_failed" + + @pytest.mark.parametrize( "discovery_info", [ @@ -1735,7 +2201,7 @@ async def test_addon_installed_already_configured( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1831,7 +2297,7 @@ async def test_addon_not_installed( assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_user" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1927,25 +2393,33 @@ async def test_install_addon_failure( assert result["reason"] == "addon_install_failed" -async def test_options_manual(hass: HomeAssistant, client, integration) -> None: - """Test manual settings in options flow.""" +async def test_reconfigure_manual(hass: HomeAssistant, client, integration) -> None: + """Test manual settings in reconfigure flow.""" entry = integration hass.config_entries.async_update_entry(entry, unique_id="1234") assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" + assert result["step_id"] == "manual_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"url": "ws://1.1.1.1:3001"} ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://1.1.1.1:3001" assert entry.data["use_addon"] is False assert entry.data["integration_created_addon"] is False @@ -1953,19 +2427,26 @@ async def test_options_manual(hass: HomeAssistant, client, integration) -> None: assert client.disconnect.call_count == 1 -async def test_options_manual_different_device( +async def test_reconfigure_manual_different_device( hass: HomeAssistant, integration ) -> None: - """Test options flow manual step connecting to different device.""" + """Test reconfigure flow manual step connecting to different device.""" entry = integration hass.config_entries.async_update_entry(entry, unique_id="5678") - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" + assert result["step_id"] == "manual_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"url": "ws://1.1.1.1:3001"} ) await hass.async_block_till_done() @@ -1974,29 +2455,36 @@ async def test_options_manual_different_device( assert result["reason"] == "different_device" -async def test_options_not_addon( +async def test_reconfigure_not_addon( hass: HomeAssistant, client, supervisor, integration ) -> None: - """Test options flow and opting out of add-on on Supervisor.""" + """Test reconfigure flow and opting out of add-on on Supervisor.""" entry = integration hass.config_entries.async_update_entry(entry, unique_id="1234") assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": False} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" + assert result["step_id"] == "manual_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "url": "ws://localhost:3000", @@ -2004,7 +2492,8 @@ async def test_options_not_addon( ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://localhost:3000" assert entry.data["use_addon"] is False assert entry.data["integration_created_addon"] is False @@ -2012,6 +2501,127 @@ async def test_options_not_addon( assert client.disconnect.call_count == 1 +@pytest.mark.usefixtures("supervisor") +async def test_reconfigure_not_addon_with_addon( + hass: HomeAssistant, + setup_entry: AsyncMock, + unload_entry: AsyncMock, + integration: MockConfigEntry, + stop_addon: AsyncMock, +) -> None: + """Test reconfigure flow opting out of add-on on Supervisor with add-on.""" + entry = integration + hass.config_entries.async_update_entry( + entry, + data={**entry.data, "url": "ws://host1:3001", "use_addon": True}, + unique_id="1234", + ) + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert unload_entry.call_count == 0 + setup_entry.reset_mock() + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "on_supervisor_reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": False} + ) + + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert setup_entry.call_count == 0 + assert unload_entry.call_count == 1 + assert stop_addon.call_count == 1 + assert stop_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "ws://localhost:3000", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data["url"] == "ws://localhost:3000" + assert entry.data["use_addon"] is False + assert entry.data["integration_created_addon"] is False + assert entry.state is config_entries.ConfigEntryState.LOADED + assert setup_entry.call_count == 1 + assert unload_entry.call_count == 1 + + # avoid unload entry in teardown + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("supervisor") +async def test_reconfigure_not_addon_with_addon_stop_fail( + hass: HomeAssistant, + setup_entry: AsyncMock, + unload_entry: AsyncMock, + integration: MockConfigEntry, + stop_addon: AsyncMock, +) -> None: + """Test reconfigure flow opting out of add-on and add-on stop error.""" + stop_addon.side_effect = SupervisorError("Boom!") + entry = integration + hass.config_entries.async_update_entry( + entry, + data={**entry.data, "url": "ws://host1:3001", "use_addon": True}, + unique_id="1234", + ) + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert unload_entry.call_count == 0 + setup_entry.reset_mock() + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "on_supervisor_reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": False} + ) + await hass.async_block_till_done() + + assert stop_addon.call_count == 1 + assert stop_addon.call_args == call("core_zwave_js") + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_stop_failed" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["use_addon"] is True + assert entry.state is config_entries.ConfigEntryState.LOADED + assert setup_entry.call_count == 1 + assert unload_entry.call_count == 1 + + # avoid unload entry in teardown + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + @pytest.mark.parametrize( ( "discovery_info", @@ -2089,7 +2699,7 @@ async def test_options_not_addon( ), ], ) -async def test_options_addon_running( +async def test_reconfigure_addon_running( hass: HomeAssistant, client, supervisor, @@ -2105,7 +2715,7 @@ async def test_options_addon_running( new_addon_options, disconnect_calls, ) -> None: - """Test options flow and add-on already running on Supervisor.""" + """Test reconfigure flow and add-on already running on Supervisor.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2116,19 +2726,26 @@ async def test_options_addon_running( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2144,12 +2761,13 @@ async def test_options_addon_running( assert result["step_id"] == "start_addon" await hass.async_block_till_done() - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert restart_addon.call_args == call("core_zwave_js") - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] @@ -2217,7 +2835,7 @@ async def test_options_addon_running( ), ], ) -async def test_options_addon_running_no_changes( +async def test_reconfigure_addon_running_no_changes( hass: HomeAssistant, client, supervisor, @@ -2232,7 +2850,7 @@ async def test_options_addon_running_no_changes( old_addon_options, new_addon_options, ) -> None: - """Test options flow without changes, and add-on already running on Supervisor.""" + """Test reconfigure flow without changes, and add-on already running on Supervisor.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2243,19 +2861,26 @@ async def test_options_addon_running_no_changes( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2265,7 +2890,8 @@ async def test_options_addon_running_no_changes( assert set_addon_options.call_count == 0 assert restart_addon.call_count == 0 - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] @@ -2388,7 +3014,7 @@ async def different_device_server_version(*args): ), ], ) -async def test_options_different_device( +async def test_reconfigure_different_device( hass: HomeAssistant, client, supervisor, @@ -2405,7 +3031,7 @@ async def test_options_different_device( disconnect_calls, server_version_side_effect, ) -> None: - """Test options flow and configuring a different device.""" + """Test reconfigure flow and configuring a different device.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2416,19 +3042,26 @@ async def test_options_different_device( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2447,7 +3080,7 @@ async def test_options_different_device( assert restart_addon.call_count == 1 assert restart_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() # Default emulate_hardware is False. @@ -2467,7 +3100,7 @@ async def test_options_different_device( assert restart_addon.call_count == 2 assert restart_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT @@ -2564,7 +3197,7 @@ async def test_options_different_device( ), ], ) -async def test_options_addon_restart_failed( +async def test_reconfigure_addon_restart_failed( hass: HomeAssistant, client, supervisor, @@ -2581,7 +3214,7 @@ async def test_options_addon_restart_failed( disconnect_calls, restart_addon_side_effect, ) -> None: - """Test options flow and add-on restart failure.""" + """Test reconfigure flow and add-on restart failure.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2592,19 +3225,26 @@ async def test_options_addon_restart_failed( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2623,7 +3263,7 @@ async def test_options_addon_restart_failed( assert restart_addon.call_count == 1 assert restart_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() # The legacy network key should not be reset. @@ -2640,7 +3280,7 @@ async def test_options_addon_restart_failed( assert restart_addon.call_count == 2 assert restart_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT @@ -2698,7 +3338,7 @@ async def test_options_addon_restart_failed( ), ], ) -async def test_options_addon_running_server_info_failure( +async def test_reconfigure_addon_running_server_info_failure( hass: HomeAssistant, client, supervisor, @@ -2715,7 +3355,7 @@ async def test_options_addon_running_server_info_failure( disconnect_calls, server_version_side_effect, ) -> None: - """Test options flow and add-on already running with server info failure.""" + """Test reconfigure flow and add-on already running with server info failure.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2726,19 +3366,26 @@ async def test_options_addon_running_server_info_failure( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2828,7 +3475,7 @@ async def test_options_addon_running_server_info_failure( ), ], ) -async def test_options_addon_not_installed( +async def test_reconfigure_addon_not_installed( hass: HomeAssistant, client, supervisor, @@ -2845,7 +3492,7 @@ async def test_options_addon_not_installed( new_addon_options, disconnect_calls, ) -> None: - """Test options flow and add-on not installed on Supervisor.""" + """Test reconfigure flow and add-on not installed on Supervisor.""" addon_options.update(old_addon_options) entry = integration data = {**entry.data, **entry_data} @@ -2856,12 +3503,19 @@ async def test_options_addon_not_installed( assert client.connect.call_count == 1 assert client.disconnect.call_count == 0 - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "on_supervisor" + assert result["step_id"] == "on_supervisor_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"use_addon": True} ) @@ -2871,14 +3525,14 @@ async def test_options_addon_not_installed( # Make sure the flow continues when the progress task is done. await hass.async_block_till_done() - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon" + assert result["step_id"] == "configure_addon_reconfigure" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], new_addon_options, ) @@ -2897,11 +3551,12 @@ async def test_options_addon_not_installed( assert start_addon.call_count == 1 assert start_addon.call_args == call("core_zwave_js") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == new_addon_options["device"] assert entry.data["s0_legacy_key"] == new_addon_options["s0_legacy_key"] @@ -2959,3 +3614,1050 @@ async def test_zeroconf(hass: HomeAssistant) -> None: } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_migrate_no_addon(hass: HomeAssistant, integration) -> None: + """Test migration flow fails when not using add-on.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": False} + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_required" + + +@pytest.mark.usefixtures("mock_sdk_version") +async def test_reconfigure_migrate_low_sdk_version( + hass: HomeAssistant, + integration: MockConfigEntry, +) -> None: + """Test migration flow fails with too low controller SDK version.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_low_sdk_version" + + +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +@pytest.mark.parametrize( + ( + "reset_server_version_side_effect", + "reset_unique_id", + "restore_server_version_side_effect", + "final_unique_id", + ), + [ + (None, "4321", None, "8765"), + (aiohttp.ClientError("Boom"), "1234", None, "8765"), + (None, "4321", aiohttp.ClientError("Boom"), "5678"), + (aiohttp.ClientError("Boom"), "1234", aiohttp.ClientError("Boom"), "5678"), + ], +) +async def test_reconfigure_migrate_with_addon( + hass: HomeAssistant, + client, + supervisor, + integration, + addon_running, + restart_addon, + set_addon_options, + get_addon_discovery_info, + get_server_version: AsyncMock, + reset_server_version_side_effect: Exception | None, + reset_unique_id: str, + restore_server_version_side_effect: Exception | None, + final_unique_id: str, +) -> None: + """Test migration flow with add-on.""" + get_server_version.side_effect = reset_server_version_side_effect + version_info = get_server_version.return_value + version_info.home_id = 4321 + entry = integration + assert client.connect.call_count == 1 + hass.config_entries.async_update_entry( + entry, + unique_id="1234", + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/dev/ttyUSB0", + }, + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm backup progress", {"bytesRead": 100, "total": 200} + ) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_reset_controller(): + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) + + async def mock_restore_nvm(data: bytes): + client.driver.controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, + ) + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes") as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + assert len(events) == 1 + assert events[0].data["progress"] == 0.5 + events.clear() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert entry.unique_id == reset_unique_id + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + assert result["data_schema"].schema[CONF_USB_PATH] + + # Reset side effect before starting the add-on. + get_server_version.side_effect = None + version_info.home_id = 5678 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert set_addon_options.call_args == call( + "core_zwave_js", AddonsOptions(config={"device": "/test"}) + ) + + await hass.async_block_till_done() + + assert restart_addon.call_args == call("core_zwave_js") + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert entry.unique_id == "5678" + get_server_version.side_effect = restore_server_version_side_effect + version_info.home_id = 8765 + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 + + await hass.async_block_till_done() + assert client.connect.call_count == 3 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert client.driver.controller.async_restore_nvm.call_count == 1 + assert len(events) == 2 + assert events[0].data["progress"] == 0.25 + assert events[1].data["progress"] == 0.75 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["usb_path"] == "/test" + assert entry.data["use_addon"] is True + assert entry.unique_id == final_unique_id + + +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_reconfigure_migrate_reset_driver_ready_timeout( + hass: HomeAssistant, + client, + supervisor, + integration, + addon_running, + restart_addon, + set_addon_options, + get_addon_discovery_info, + get_server_version: AsyncMock, +) -> None: + """Test migration flow with driver ready timeout after controller reset.""" + version_info = get_server_version.return_value + version_info.home_id = 4321 + entry = integration + assert client.connect.call_count == 1 + hass.config_entries.async_update_entry( + entry, + unique_id="1234", + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/dev/ttyUSB0", + }, + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm backup progress", {"bytesRead": 100, "total": 200} + ) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_reset_controller(): + await asyncio.sleep(0) + + client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) + + async def mock_restore_nvm(data: bytes): + client.driver.controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, + ) + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + with ( + patch( + ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), + new=0, + ), + patch("pathlib.Path.write_bytes") as mock_file, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + assert len(events) == 1 + assert events[0].data["progress"] == 0.5 + events.clear() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert entry.unique_id == "4321" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + assert result["data_schema"].schema[CONF_USB_PATH] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert set_addon_options.call_args == call( + "core_zwave_js", AddonsOptions(config={"device": "/test"}) + ) + + await hass.async_block_till_done() + + assert restart_addon.call_args == call("core_zwave_js") + + version_info.home_id = 5678 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 + + await hass.async_block_till_done() + assert client.connect.call_count == 3 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert client.driver.controller.async_restore_nvm.call_count == 1 + assert len(events) == 2 + assert events[0].data["progress"] == 0.25 + assert events[1].data["progress"] == 0.75 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["usb_path"] == "/test" + assert entry.data["use_addon"] is True + assert entry.unique_id == "5678" + + +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_reconfigure_migrate_restore_driver_ready_timeout( + hass: HomeAssistant, + client, + supervisor, + integration, + addon_running, + restart_addon, + set_addon_options, + get_addon_discovery_info, +) -> None: + """Test migration flow with driver ready timeout after nvm restore.""" + entry = integration + assert client.connect.call_count == 1 + hass.config_entries.async_update_entry( + entry, + unique_id="1234", + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "usb_path": "/dev/ttyUSB0", + }, + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm backup progress", {"bytesRead": 100, "total": 200} + ) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_reset_controller(): + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) + + async def mock_restore_nvm(data: bytes): + client.driver.controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, + ) + + client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes") as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + assert len(events) == 1 + assert events[0].data["progress"] == 0.5 + events.clear() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + assert result["data_schema"].schema[CONF_USB_PATH] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + assert set_addon_options.call_args == call( + "core_zwave_js", AddonsOptions(config={"device": "/test"}) + ) + + await hass.async_block_till_done() + + assert restart_addon.call_args == call("core_zwave_js") + + with patch( + ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), + new=0, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + assert client.connect.call_count == 2 + + await hass.async_block_till_done() + assert client.connect.call_count == 3 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert client.driver.controller.async_restore_nvm.call_count == 1 + assert len(events) == 2 + assert events[0].data["progress"] == 0.25 + assert events[1].data["progress"] == 0.75 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "migration_successful" + assert integration.data["url"] == "ws://host1:3001" + assert integration.data["usb_path"] == "/test" + assert integration.data["use_addon"] is True + + +async def test_reconfigure_migrate_backup_failure( + hass: HomeAssistant, integration, client +) -> None: + """Test backup failure.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=FailedCommand("test_error", "unknown_error") + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "backup_failed" + + +async def test_reconfigure_migrate_backup_file_failure( + hass: HomeAssistant, integration, client +) -> None: + """Test backup file failure.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes", side_effect=OSError("test_error")): + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "backup_failed" + + +@pytest.mark.usefixtures("supervisor", "addon_running", "get_addon_discovery_info") +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_reconfigure_migrate_start_addon_failure( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, + restart_addon: AsyncMock, + set_addon_options: AsyncMock, +) -> None: + """Test add-on start failure during migration.""" + restart_addon.side_effect = SupervisorError("Boom!") + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_reset_controller(): + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes") as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert set_addon_options.call_count == 1 + assert set_addon_options.call_args == call( + "core_zwave_js", AddonsOptions(config={"device": "/test"}) + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_start_failed" + + +@pytest.mark.parametrize( + "discovery_info", + [ + [ + Discovery( + addon="core_zwave_js", + service="zwave_js", + uuid=uuid4(), + config=ADDON_DISCOVERY_INFO, + ) + ] + ], +) +async def test_reconfigure_migrate_restore_failure( + hass: HomeAssistant, + client, + supervisor, + integration, + addon_running, + restart_addon, + set_addon_options, + get_addon_discovery_info, +) -> None: + """Test restore failure.""" + entry = integration + hass.config_entries.async_update_entry( + entry, unique_id="1234", data={**entry.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_reset_controller(): + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) + client.driver.controller.async_restore_nvm = AsyncMock( + side_effect=FailedCommand("test_error", "unknown_error") + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes") as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "choose_serial_port" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USB_PATH: "/test", + }, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "start_addon" + + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + + await hass.async_block_till_done() + + assert client.driver.controller.async_restore_nvm.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "restore_failed" + assert result["description_placeholders"]["file_path"] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "restore_nvm" + + await hass.async_block_till_done() + + assert client.driver.controller.async_restore_nvm.call_count == 2 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "restore_failed" + + hass.config_entries.flow.async_abort(result["flow_id"]) + + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_get_driver_failure_intent_migrate( + hass: HomeAssistant, + integration: MockConfigEntry, +) -> None: + """Test get driver failure in intent migrate step.""" + entry = integration + hass.config_entries.async_update_entry( + integration, unique_id="1234", data={**integration.data, "use_addon": True} + ) + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + await hass.config_entries.async_unload(integration.entry_id) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "config_entry_not_loaded" + + +async def test_get_driver_failure_instruct_unplug( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test get driver failure in instruct unplug step.""" + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + client.driver.controller.emit( + "nvm backup progress", {"bytesRead": 100, "total": 200} + ) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + entry = integration + hass.config_entries.async_update_entry( + integration, unique_id="1234", data={**integration.data, "use_addon": True} + ) + result = await entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes") as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + + await hass.config_entries.async_unload(integration.entry_id) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "config_entry_not_loaded" + + +async def test_hard_reset_failure(hass: HomeAssistant, integration, client) -> None: + """Test hard reset failure.""" + entry = integration + hass.config_entries.async_update_entry( + integration, unique_id="1234", data={**integration.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + client.driver.async_hard_reset = AsyncMock( + side_effect=FailedCommand("test_error", "unknown_error") + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes") as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reset_failed" + + +async def test_choose_serial_port_usb_ports_failure( + hass: HomeAssistant, integration, client +) -> None: + """Test choose serial port usb ports failure.""" + entry = integration + hass.config_entries.async_update_entry( + integration, unique_id="1234", data={**integration.data, "use_addon": True} + ) + + async def mock_backup_nvm_raw(): + await asyncio.sleep(0) + return b"test_nvm_data" + + client.driver.controller.async_backup_nvm_raw = AsyncMock( + side_effect=mock_backup_nvm_raw + ) + + async def mock_reset_controller(): + client.driver.emit( + "driver ready", {"event": "driver ready", "source": "driver"} + ) + + client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_migrate"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "intent_migrate" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backup_nvm" + + with patch("pathlib.Path.write_bytes") as mock_file: + await hass.async_block_till_done() + assert client.driver.controller.async_backup_nvm_raw.call_count == 1 + assert mock_file.call_count == 1 + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "instruct_unplug" + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + with patch( + "homeassistant.components.zwave_js.config_flow.async_get_usb_ports", + side_effect=OSError("test_error"), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "usb_ports_failed" + + +async def test_configure_addon_usb_ports_failure( + hass: HomeAssistant, integration, addon_installed, supervisor +) -> None: + """Test configure addon usb ports failure.""" + entry = integration + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "intent_reconfigure"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "on_supervisor_reconfigure" + + with patch( + "homeassistant.components.zwave_js.config_flow.async_get_usb_ports", + side_effect=OSError("test_error"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "usb_ports_failed" diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index b13d4f9787f..13f519725fd 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -2,6 +2,7 @@ import logging +import pytest from zwave_js_server.const import ( CURRENT_STATE_PROPERTY, CURRENT_VALUE_PROPERTY, @@ -35,6 +36,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant @@ -50,6 +52,12 @@ FIBARO_FGR_223_SHUTTER_COVER_ENTITY = "cover.fgr_223_test_cover" LOGGER.setLevel(logging.DEBUG) +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.COVER] + + async def test_window_cover( hass: HomeAssistant, client, chain_actuator_zws12, integration ) -> None: diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 0be0cca78c8..7ef5f0e480f 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -431,10 +431,11 @@ async def test_rediscovery( async def test_aeotec_smart_switch_7( hass: HomeAssistant, + entity_registry: er.EntityRegistry, aeotec_smart_switch_7: Node, integration: MockConfigEntry, ) -> None: - """Test that Smart Switch 7 has a light and a switch entity.""" + """Test Smart Switch 7 discovery.""" state = hass.states.get("light.smart_switch_7") assert state assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ @@ -443,3 +444,9 @@ async def test_aeotec_smart_switch_7( state = hass.states.get("switch.smart_switch_7") assert state + + state = hass.states.get("button.smart_switch_7_reset_accumulated_values") + assert state + entity_entry = entity_registry.async_get(state.entity_id) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.CONFIG diff --git a/tests/components/zwave_js/test_event.py b/tests/components/zwave_js/test_event.py index 1db02662f4e..84b1ade2632 100644 --- a/tests/components/zwave_js/test_event.py +++ b/tests/components/zwave_js/test_event.py @@ -3,11 +3,12 @@ from datetime import timedelta from freezegun import freeze_time +import pytest from zwave_js_server.event import Event from homeassistant.components.event import ATTR_EVENT_TYPE from homeassistant.components.zwave_js.const import ATTR_VALUE -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -15,6 +16,12 @@ BASIC_EVENT_VALUE_ENTITY = "event.honeywell_in_wall_smart_fan_control_event_valu CENTRAL_SCENE_ENTITY = "event.node_51_scene_002" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.EVENT] + + async def test_basic( hass: HomeAssistant, client, fan_honeywell_39358, integration ) -> None: diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index 0bb6376a02b..8cdaef3e63d 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -6,11 +6,18 @@ import pytest from zwave_js_server.const import CommandClass from zwave_js_server.event import Event +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import async_capture_events +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [] + + async def test_scenes( hass: HomeAssistant, hank_binary_switch, integration, client ) -> None: @@ -244,6 +251,7 @@ async def test_notifications( assert events[2].data["command_class_name"] == "Multilevel Switch" +@pytest.mark.parametrize("platforms", [[Platform.SWITCH]]) async def test_value_updated( hass: HomeAssistant, vision_security_zl7432, integration, client ) -> None: diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 2551fc7b34a..25ab6a87200 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -29,12 +29,19 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.FAN] + + async def test_generic_fan( hass: HomeAssistant, client, fan_generic, integration ) -> None: diff --git a/tests/components/zwave_js/test_helpers.py b/tests/components/zwave_js/test_helpers.py index 2df2e134f49..c163b8e8c75 100644 --- a/tests/components/zwave_js/test_helpers.py +++ b/tests/components/zwave_js/test_helpers.py @@ -1,17 +1,33 @@ """Test the Z-Wave JS helpers module.""" -import voluptuous as vol +from unittest.mock import patch +import pytest +import voluptuous as vol +from zwave_js_server.const import SecurityClass +from zwave_js_server.model.controller import ProvisioningEntry + +from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.components.zwave_js.helpers import ( async_get_node_status_sensor_entity_id, async_get_nodes_from_area_id, + async_get_provisioning_entry_from_device_id, get_value_state_schema, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar, device_registry as dr from tests.common import MockConfigEntry +CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [] + async def test_async_get_node_status_sensor_entity_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry @@ -43,3 +59,82 @@ async def test_get_value_state_schema_boolean_config_value( ) assert isinstance(schema_validator, vol.Coerce) assert schema_validator.type is bool + + +async def test_async_get_provisioning_entry_from_device_id( + hass: HomeAssistant, client, device_registry: dr.DeviceRegistry, integration +) -> None: + """Test async_get_provisioning_entry_from_device_id function.""" + device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, "test-device")}, + ) + + provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": "test", + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": device.id, + } + ) + + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries", + return_value=[provisioning_entry], + ): + result = await async_get_provisioning_entry_from_device_id(hass, device.id) + assert result == provisioning_entry + + # Test invalid device + with pytest.raises(ValueError, match="Device ID not-a-real-device is not valid"): + await async_get_provisioning_entry_from_device_id(hass, "not-a-real-device") + + # Test device exists but is not from a zwave_js config entry + non_zwave_config_entry = MockConfigEntry(domain="not_zwave_js") + non_zwave_config_entry.add_to_hass(hass) + non_zwave_device = device_registry.async_get_or_create( + config_entry_id=non_zwave_config_entry.entry_id, + identifiers={("not_zwave_js", "test-device")}, + ) + with pytest.raises( + ValueError, + match=f"Device {non_zwave_device.id} is not from an existing zwave_js config entry", + ): + await async_get_provisioning_entry_from_device_id(hass, non_zwave_device.id) + + # Test device exists but config entry is not loaded + not_loaded_config_entry = MockConfigEntry( + domain=DOMAIN, state=ConfigEntryState.NOT_LOADED + ) + not_loaded_config_entry.add_to_hass(hass) + not_loaded_device = device_registry.async_get_or_create( + config_entry_id=not_loaded_config_entry.entry_id, + identifiers={(DOMAIN, "not-loaded-device")}, + ) + with pytest.raises( + ValueError, match=f"Device {not_loaded_device.id} config entry is not loaded" + ): + await async_get_provisioning_entry_from_device_id(hass, not_loaded_device.id) + + # Test no matching provisioning entry + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries", + return_value=[], + ): + result = await async_get_provisioning_entry_from_device_id(hass, device.id) + assert result is None + + # Test multiple provisioning entries but only one matches + other_provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": "other", + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": "other-id", + } + ) + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries", + return_value=[other_provisioning_entry, provisioning_entry], + ): + result = await async_get_provisioning_entry_from_device_id(hass, device.id) + assert result == provisioning_entry diff --git a/tests/components/zwave_js/test_humidifier.py b/tests/components/zwave_js/test_humidifier.py index 261e09babee..78ea7899287 100644 --- a/tests/components/zwave_js/test_humidifier.py +++ b/tests/components/zwave_js/test_humidifier.py @@ -1,5 +1,6 @@ """Test the Z-Wave JS humidifier platform.""" +import pytest from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.humidity_control import HumidityControlMode from zwave_js_server.event import Event @@ -22,12 +23,19 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from .common import DEHUMIDIFIER_ADC_T3000_ENTITY, HUMIDIFIER_ADC_T3000_ENTITY +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.HUMIDIFIER] + + async def test_humidifier( hass: HomeAssistant, client, climate_adc_t3000, integration ) -> None: diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index c575066b57c..a0423efdf52 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1,27 +1,34 @@ """Test the Z-Wave JS init module.""" import asyncio +from collections.abc import Generator from copy import deepcopy import logging -from unittest.mock import AsyncMock, call, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, call, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import AddonsOptions import pytest from zwave_js_server.client import Client +from zwave_js_server.const import SecurityClass from zwave_js_server.event import Event -from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion -from zwave_js_server.model.node import Node +from zwave_js_server.exceptions import ( + BaseZwaveJSServerError, + InvalidServerVersion, + NotConnected, +) +from zwave_js_server.model.controller import ProvisioningEntry +from zwave_js_server.model.node import Node, NodeDataType from zwave_js_server.model.version import VersionInfo from homeassistant.components.hassio import HassioAPIError -from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN -from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.components.zwave_js.helpers import get_device_id, get_device_id_ext from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -32,18 +39,29 @@ from homeassistant.setup import async_setup_component from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY -from tests.common import MockConfigEntry, async_get_persistent_notifications +from tests.common import ( + MockConfigEntry, + async_call_logger_set_level, + async_fire_time_changed, + async_get_persistent_notifications, +) from tests.typing import WebSocketGenerator +CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" + @pytest.fixture(name="connect_timeout") -def connect_timeout_fixture(): +def connect_timeout_fixture() -> Generator[int]: """Mock the connect timeout.""" with patch("homeassistant.components.zwave_js.CONNECT_TIMEOUT", new=0) as timeout: yield timeout -async def test_entry_setup_unload(hass: HomeAssistant, client, integration) -> None: +async def test_entry_setup_unload( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, +) -> None: """Test the integration set up and unload.""" entry = integration @@ -56,16 +74,19 @@ async def test_entry_setup_unload(hass: HomeAssistant, client, integration) -> N assert entry.state is ConfigEntryState.NOT_LOADED -async def test_home_assistant_stop(hass: HomeAssistant, client, integration) -> None: +@pytest.mark.usefixtures("integration") +async def test_home_assistant_stop( + hass: HomeAssistant, + client: MagicMock, +) -> None: """Test we clean up on home assistant stop.""" await hass.async_stop() assert client.disconnect.call_count == 1 -async def test_initialized_timeout( - hass: HomeAssistant, client, connect_timeout -) -> None: +@pytest.mark.usefixtures("client", "connect_timeout") +async def test_initialized_timeout(hass: HomeAssistant) -> None: """Test we handle a timeout during client initialization.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -76,7 +97,8 @@ async def test_initialized_timeout( assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_enabled_statistics(hass: HomeAssistant, client) -> None: +@pytest.mark.usefixtures("client") +async def test_enabled_statistics(hass: HomeAssistant) -> None: """Test that we enabled statistics if the entry is opted in.""" entry = MockConfigEntry( domain="zwave_js", @@ -92,8 +114,9 @@ async def test_enabled_statistics(hass: HomeAssistant, client) -> None: assert mock_cmd.called -async def test_disabled_statistics(hass: HomeAssistant, client) -> None: - """Test that we diisabled statistics if the entry is opted out.""" +@pytest.mark.usefixtures("client") +async def test_disabled_statistics(hass: HomeAssistant) -> None: + """Test that we disabled statistics if the entry is opted out.""" entry = MockConfigEntry( domain="zwave_js", data={"url": "ws://test.org", "data_collection_opted_in": False}, @@ -108,7 +131,8 @@ async def test_disabled_statistics(hass: HomeAssistant, client) -> None: assert mock_cmd.called -async def test_noop_statistics(hass: HomeAssistant, client) -> None: +@pytest.mark.usefixtures("client") +async def test_noop_statistics(hass: HomeAssistant) -> None: """Test that we don't make statistics calls if user hasn't set preference.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -127,28 +151,225 @@ async def test_noop_statistics(hass: HomeAssistant, client) -> None: assert not mock_cmd2.called -@pytest.mark.parametrize("error", [BaseZwaveJSServerError("Boom"), Exception("Boom")]) -async def test_listen_failure(hass: HomeAssistant, client, error) -> None: - """Test we handle errors during client listen.""" +async def test_driver_ready_timeout_during_setup( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, +) -> None: + """Test we handle driver ready timeout during setup.""" - async def listen(driver_ready): - """Mock the client listen method.""" - # Set the connect side effect to stop an endless loop on reload. - client.connect.side_effect = BaseZwaveJSServerError("Boom") - raise error + async def listen(driver_ready: asyncio.Event) -> None: + """Mock listen.""" + await listen_block.wait() client.listen.side_effect = listen + + entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org", "data_collection_opted_in": True}, + ) + entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 + + with patch("homeassistant.components.zwave_js.DRIVER_READY_TIMEOUT", new=0): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize("core_state", [CoreState.running, CoreState.stopping]) +@pytest.mark.parametrize( + ("listen_future_result_method", "listen_future_result"), + [ + ("set_exception", BaseZwaveJSServerError("Boom")), + ("set_exception", Exception("Boom")), + ("set_result", None), + ], +) +async def test_listen_done_during_setup_before_forward_entry( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], + core_state: CoreState, + listen_future_result_method: str, + listen_future_result: Exception | None, +) -> None: + """Test listen task finishing during setup before forward entry.""" + assert hass.state is CoreState.running + + async def listen(driver_ready: asyncio.Event) -> None: + await listen_block.wait() + await listen_result + async_fire_time_changed(hass, fire_all=True) + + client.listen.side_effect = listen + hass.set_state(core_state) + listen_block.set() + getattr(listen_result, listen_future_result_method)(listen_future_result) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 +async def test_not_connected_during_setup_after_forward_entry( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], +) -> None: + """Test we handle not connected client during setup after forward entry.""" + + async def send_command_side_effect(*args: Any, **kwargs: Any) -> None: + """Mock send command.""" + listen_block.set() + listen_result.set_result(None) + # Yield to allow the listen task to run + await asyncio.sleep(0) + raise NotConnected("Boom") + + async def listen(driver_ready: asyncio.Event) -> None: + """Mock listen.""" + driver_ready.set() + client.async_send_command.side_effect = send_command_side_effect + await listen_block.wait() + await listen_result + + client.listen.side_effect = listen + + entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org", "data_collection_opted_in": True}, + ) + entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize("core_state", [CoreState.running, CoreState.stopping]) +@pytest.mark.parametrize( + ("listen_future_result_method", "listen_future_result"), + [ + ("set_exception", BaseZwaveJSServerError("Boom")), + ("set_exception", Exception("Boom")), + ("set_result", None), + ], +) +async def test_listen_done_during_setup_after_forward_entry( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], + core_state: CoreState, + listen_future_result_method: str, + listen_future_result: Exception | None, +) -> None: + """Test listen task finishing during setup after forward entry.""" + assert hass.state is CoreState.running + + original_send_command_side_effect = client.async_send_command.side_effect + + async def send_command_side_effect(*args: Any, **kwargs: Any) -> None: + """Mock send command.""" + listen_block.set() + getattr(listen_result, listen_future_result_method)(listen_future_result) + client.async_send_command.side_effect = original_send_command_side_effect + # Yield to allow the listen task to run + await asyncio.sleep(0) + + async def listen(driver_ready: asyncio.Event) -> None: + """Mock listen.""" + driver_ready.set() + client.async_send_command.side_effect = send_command_side_effect + await listen_block.wait() + await listen_result + + client.listen.side_effect = listen + hass.set_state(core_state) + + entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org", "data_collection_opted_in": True}, + ) + entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize( + ("core_state", "final_config_entry_state", "disconnect_call_count"), + [ + ( + CoreState.running, + ConfigEntryState.SETUP_RETRY, + 2, + ), # the reload will cause a disconnect call too + ( + CoreState.stopping, + ConfigEntryState.LOADED, + 0, + ), # the home assistant stop event will handle the disconnect + ], +) +@pytest.mark.parametrize( + ("listen_future_result_method", "listen_future_result"), + [ + ("set_exception", BaseZwaveJSServerError("Boom")), + ("set_exception", Exception("Boom")), + ("set_result", None), + ], +) +async def test_listen_done_after_setup( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], + core_state: CoreState, + listen_future_result_method: str, + listen_future_result: Exception | None, + final_config_entry_state: ConfigEntryState, + disconnect_call_count: int, +) -> None: + """Test listen task finishing after setup.""" + config_entry = integration + assert config_entry.state is ConfigEntryState.LOADED + assert hass.state is CoreState.running + assert client.disconnect.call_count == 0 + + hass.set_state(core_state) + listen_block.set() + getattr(listen_result, listen_future_result_method)(listen_future_result) + await hass.async_block_till_done() + + assert config_entry.state is final_config_entry_state + assert client.disconnect.call_count == disconnect_call_count + + +@pytest.mark.usefixtures("client") async def test_new_entity_on_value_added( - hass: HomeAssistant, multisensor_6, client, integration + hass: HomeAssistant, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test we create a new entity if a value is added after the fact.""" node: Node = multisensor_6 @@ -182,12 +403,12 @@ async def test_new_entity_on_value_added( assert hass.states.get("sensor.multisensor_6_ultraviolet_10") is not None +@pytest.mark.usefixtures("integration") async def test_on_node_added_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6_state, - client, - integration, + multisensor_6_state: NodeDataType, + client: MagicMock, ) -> None: """Test we handle a node added event with a ready node.""" node = Node(client, deepcopy(multisensor_6_state)) @@ -213,13 +434,53 @@ async def test_on_node_added_ready( ) +async def test_on_node_added_preprovisioned( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + multisensor_6_state, + client, + integration, +) -> None: + """Test node added event with a preprovisioned device.""" + dsk = "test" + node = Node(client, deepcopy(multisensor_6_state)) + device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, f"provision_{dsk}")}, + ) + provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": dsk, + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": device.id, + } + ) + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entry", + side_effect=lambda id: provisioning_entry if id == node.node_id else None, + ): + event = {"node": node} + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + device = device_registry.async_get(device.id) + assert device + assert device.identifiers == { + get_device_id(client.driver, node), + get_device_id_ext(client.driver, node), + } + assert device.sw_version == node.firmware_version + # There should only be the controller and the preprovisioned device + assert len(device_registry.devices) == 2 + + +@pytest.mark.usefixtures("integration") async def test_on_node_added_not_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111_not_ready_state, - client, - integration, + zp3111_not_ready_state: NodeDataType, + client: MagicMock, ) -> None: """Test we handle a node added event with a non-ready node.""" device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}" @@ -255,9 +516,9 @@ async def test_on_node_added_not_ready( async def test_existing_node_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - multisensor_6, - integration, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test we handle a ready node that exists during integration setup.""" node = multisensor_6 @@ -285,7 +546,7 @@ async def test_existing_node_reinterview( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client: Client, - multisensor_6_state: dict, + multisensor_6_state: NodeDataType, multisensor_6: Node, integration: MockConfigEntry, ) -> None: @@ -344,15 +605,16 @@ async def test_existing_node_not_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111_not_ready, - client, - integration, + client: MagicMock, + zp3111_not_ready: Node, + integration: MockConfigEntry, ) -> None: """Test we handle a non-ready node that exists during integration setup.""" node = zp3111_not_ready device_id = f"{client.driver.controller.home_id}-{node.node_id}" device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device assert device.name == f"Node {node.node_id}" assert not device.manufacturer assert not device.model @@ -373,11 +635,11 @@ async def test_existing_node_not_replaced_when_not_ready( area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111, - zp3111_not_ready_state, - zp3111_state, - client, - integration, + client: MagicMock, + zp3111: Node, + zp3111_not_ready_state: NodeDataType, + zp3111_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test when a node added event with a non-ready node is received. @@ -499,21 +761,23 @@ async def test_existing_node_not_replaced_when_not_ready( assert state.name == "Custom Entity Name" +@pytest.mark.usefixtures("client") async def test_null_name( - hass: HomeAssistant, client, null_name_check, integration + hass: HomeAssistant, + null_name_check: Node, + integration: MockConfigEntry, ) -> None: """Test that node without a name gets a generic node name.""" node = null_name_check assert hass.states.get(f"switch.node_{node.node_id}") +@pytest.mark.usefixtures("addon_installed", "addon_info") async def test_start_addon( hass: HomeAssistant, - addon_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test start the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -561,13 +825,12 @@ async def test_start_addon( assert start_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_not_installed", "addon_info") async def test_install_addon( hass: HomeAssistant, - addon_not_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test install and start the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -610,14 +873,12 @@ async def test_install_addon( assert start_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_installed", "addon_info", "set_addon_options") @pytest.mark.parametrize("addon_info_side_effect", [SupervisorError("Boom")]) async def test_addon_info_failure( hass: HomeAssistant, - addon_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test failure to get add-on info for Z-Wave JS add-on during entry setup.""" device = "/test" @@ -637,6 +898,7 @@ async def test_addon_info_failure( assert start_addon.call_count == 0 +@pytest.mark.usefixtures("addon_running", "addon_info", "client") @pytest.mark.parametrize( ( "old_device", @@ -675,26 +937,23 @@ async def test_addon_info_failure( ) async def test_addon_options_changed( hass: HomeAssistant, - client, - addon_installed, - addon_running, - install_addon, - addon_options, - start_addon, - old_device, - new_device, - old_s0_legacy_key, - new_s0_legacy_key, - old_s2_access_control_key, - new_s2_access_control_key, - old_s2_authenticated_key, - new_s2_authenticated_key, - old_s2_unauthenticated_key, - new_s2_unauthenticated_key, - old_lr_s2_access_control_key, - new_lr_s2_access_control_key, - old_lr_s2_authenticated_key, - new_lr_s2_authenticated_key, + install_addon: AsyncMock, + addon_options: dict[str, Any], + start_addon: AsyncMock, + old_device: str, + new_device: str, + old_s0_legacy_key: str, + new_s0_legacy_key: str, + old_s2_access_control_key: str, + new_s2_access_control_key: str, + old_s2_authenticated_key: str, + new_s2_authenticated_key: str, + old_s2_unauthenticated_key: str, + new_s2_unauthenticated_key: str, + old_lr_s2_access_control_key: str, + new_lr_s2_access_control_key: str, + old_lr_s2_authenticated_key: str, + new_lr_s2_authenticated_key: str, ) -> None: """Test update config entry data on entry setup if add-on options changed.""" addon_options["device"] = new_device @@ -736,6 +995,7 @@ async def test_addon_options_changed( assert start_addon.call_count == 0 +@pytest.mark.usefixtures("addon_running") @pytest.mark.parametrize( ( "addon_version", @@ -754,20 +1014,17 @@ async def test_addon_options_changed( ) async def test_update_addon( hass: HomeAssistant, - client, - addon_info, - addon_installed, - addon_running, - create_backup, - update_addon, - addon_options, - addon_version, - update_available, - update_calls, - backup_calls, - update_addon_side_effect, - create_backup_side_effect, - version_state, + client: MagicMock, + addon_info: AsyncMock, + create_backup: AsyncMock, + update_addon: AsyncMock, + addon_options: dict[str, Any], + addon_version: str, + update_available: bool, + update_calls: int, + backup_calls: int, + update_addon_side_effect: Exception | None, + create_backup_side_effect: Exception | None, ) -> None: """Test update the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -802,7 +1059,9 @@ async def test_update_addon( async def test_issue_registry( - hass: HomeAssistant, client, version_state, issue_registry: ir.IssueRegistry + hass: HomeAssistant, + client: MagicMock, + issue_registry: ir.IssueRegistry, ) -> None: """Test issue registry.""" device = "/test" @@ -843,6 +1102,7 @@ async def test_issue_registry( assert not issue_registry.async_get_issue(DOMAIN, "invalid_server_version") +@pytest.mark.usefixtures("addon_running", "client") @pytest.mark.parametrize( ("stop_addon_side_effect", "entry_state"), [ @@ -852,13 +1112,10 @@ async def test_issue_registry( ) async def test_stop_addon( hass: HomeAssistant, - client, - addon_installed, - addon_running, - addon_options, - stop_addon, - stop_addon_side_effect, - entry_state, + addon_options: dict[str, Any], + stop_addon: AsyncMock, + stop_addon_side_effect: Exception | None, + entry_state: ConfigEntryState, ) -> None: """Test stop the Z-Wave JS add-on on entry unload if entry is disabled.""" stop_addon.side_effect = stop_addon_side_effect @@ -893,12 +1150,12 @@ async def test_stop_addon( assert stop_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_installed") async def test_remove_entry( hass: HomeAssistant, - addon_installed, - stop_addon, - create_backup, - uninstall_addon, + stop_addon: AsyncMock, + create_backup: AsyncMock, + uninstall_addon: AsyncMock, caplog: pytest.LogCaptureFixture, ) -> None: """Test remove the config entry.""" @@ -1009,13 +1266,12 @@ async def test_remove_entry( assert "Failed to uninstall the Z-Wave JS add-on" in caplog.text +@pytest.mark.usefixtures("climate_radio_thermostat_ct100_plus", "lock_schlage_be469") async def test_removed_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - climate_radio_thermostat_ct100_plus, - lock_schlage_be469, - integration, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that the device registry gets updated when a device gets removed.""" driver = client.driver @@ -1045,12 +1301,11 @@ async def test_removed_device( ) +@pytest.mark.usefixtures("client", "eaton_rf9640_dimmer") async def test_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - client, - eaton_rf9640_dimmer, ) -> None: """Test that suggested area works.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) @@ -1058,16 +1313,20 @@ async def test_suggested_area( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity = entity_registry.async_get(EATON_RF9640_ENTITY) - assert device_registry.async_get(entity.device_id).area_id is not None + entity_entry = entity_registry.async_get(EATON_RF9640_ENTITY) + assert entity_entry + assert entity_entry.device_id is not None + device = device_registry.async_get(entity_entry.device_id) + assert device + assert device.area_id is not None async def test_node_removed( hass: HomeAssistant, device_registry: dr.DeviceRegistry, multisensor_6_state, - client, - integration, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that device gets removed when node gets removed.""" node = Node(client, deepcopy(multisensor_6_state)) @@ -1096,10 +1355,10 @@ async def test_node_removed( async def test_replace_same_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6, - multisensor_6_state, - client, - integration, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test when a node is replaced with itself that the device remains.""" node_id = multisensor_6.node_id @@ -1206,11 +1465,11 @@ async def test_replace_same_node( async def test_replace_different_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6, - multisensor_6_state, - hank_binary_switch_state, - client, - integration, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + hank_binary_switch_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: """Test when a node is replaced with a different node.""" @@ -1459,9 +1718,9 @@ async def test_node_model_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111, - client, - integration, + zp3111: Node, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test when a node's model is changed due to an updated device config file. @@ -1545,8 +1804,11 @@ async def test_node_model_change( assert state.name == "Custom Entity Name" +@pytest.mark.usefixtures("zp3111", "integration") async def test_disabled_node_status_entity_on_node_replaced( - hass: HomeAssistant, zp3111_state, zp3111, client, integration + hass: HomeAssistant, + zp3111_state: NodeDataType, + client: MagicMock, ) -> None: """Test when node replacement event is received, node status sensor is removed.""" node_status_entity = "sensor.4_in_1_sensor_node_status" @@ -1572,7 +1834,10 @@ async def test_disabled_node_status_entity_on_node_replaced( async def test_disabled_entity_on_value_removed( - hass: HomeAssistant, entity_registry: er.EntityRegistry, zp3111, client, integration + hass: HomeAssistant, + zp3111: Node, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that when entity primary values are removed the entity is removed.""" idle_cover_status_button_entity = ( @@ -1703,7 +1968,10 @@ async def test_disabled_entity_on_value_removed( async def test_identify_event( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test controller identify event.""" # One config entry scenario @@ -1750,7 +2018,9 @@ async def test_identify_event( assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] -async def test_server_logging(hass: HomeAssistant, client) -> None: +async def test_server_logging( + hass: HomeAssistant, client: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: """Test automatic server logging functionality.""" def _reset_mocks(): @@ -1769,85 +2039,91 @@ async def test_server_logging(hass: HomeAssistant, client) -> None: # Setup logger and set log level to debug to trigger event listener assert await async_setup_component(hass, "logger", {"logger": {}}) - assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.INFO - client.async_send_command.reset_mock() - await hass.services.async_call( - LOGGER_DOMAIN, SERVICE_SET_LEVEL, {"zwave_js_server": "debug"}, blocking=True - ) - await hass.async_block_till_done() assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG + client.async_send_command.reset_mock() + async with async_call_logger_set_level( + "zwave_js_server", "DEBUG", hass=hass, caplog=caplog + ): + assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG - # Validate that the server logging was enabled - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "driver.update_log_config", - "config": {"level": "debug"}, - } - assert client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging was enabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "debug"}, + } + assert client.enable_server_logging.called + assert not client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # Emulate server by setting log level to debug - event = Event( - type="log config updated", - data={ - "source": "driver", - "event": "log config updated", - "config": { - "enabled": False, - "level": "debug", - "logToFile": True, - "filename": "test", - "forceConsole": True, + # Emulate server by setting log level to debug + event = Event( + type="log config updated", + data={ + "source": "driver", + "event": "log config updated", + "config": { + "enabled": False, + "level": "debug", + "logToFile": True, + "filename": "test", + "forceConsole": True, + }, }, - }, - ) - client.driver.receive_event(event) + ) + client.driver.receive_event(event) - # "Enable" server logging and unload the entry - client.server_logging_enabled = True - await hass.config_entries.async_unload(entry.entry_id) + # "Enable" server logging and unload the entry + client.server_logging_enabled = True + await hass.config_entries.async_unload(entry.entry_id) - # Validate that the server logging was disabled - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "driver.update_log_config", - "config": {"level": "info"}, - } - assert not client.enable_server_logging.called - assert client.disable_server_logging.called + # Validate that the server logging was disabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "info"}, + } + assert not client.enable_server_logging.called + assert client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # Validate that the server logging doesn't get enabled because HA thinks it already - # is enabled - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 - assert not client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging doesn't get enabled because HA thinks it already + # is enabled + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "controller.get_provisioning_entries", + } + assert client.async_send_command.call_args_list[1][0][0] == { + "command": "controller.get_provisioning_entry", + "dskOrNodeId": 1, + } + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called - _reset_mocks() + _reset_mocks() - # "Disable" server logging and unload the entry - client.server_logging_enabled = False - await hass.config_entries.async_unload(entry.entry_id) + # "Disable" server logging and unload the entry + client.server_logging_enabled = False + await hass.config_entries.async_unload(entry.entry_id) - # Validate that the server logging was not disabled because HA thinks it is already - # is disabled - assert len(client.async_send_command.call_args_list) == 0 - assert not client.enable_server_logging.called - assert not client.disable_server_logging.called + # Validate that the server logging was not disabled because HA thinks it is already + # is disabled + assert len(client.async_send_command.call_args_list) == 0 + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called async def test_factory_reset_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - multisensor_6, - multisensor_6_state, - integration, + client: MagicMock, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test when a node is removed because it was reset.""" # One config entry scenario diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 21a6c0a8fae..954d6422399 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -2,6 +2,7 @@ from copy import deepcopy +import pytest from zwave_js_server.event import Event from homeassistant.components.light import ( @@ -26,6 +27,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -42,6 +44,12 @@ ZDB5100_ENTITY = "light.matrix_office" HSM200_V1_ENTITY = "light.hsm200" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.LIGHT] + + async def test_light( hass: HomeAssistant, client, bulb_6_multi_color, integration ) -> None: diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index f5d7bf28169..e2c182d81d9 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -123,7 +123,7 @@ async def test_number_writeable( blocking=True, ) - assert len(client.async_send_command.call_args_list) == 2 + assert len(client.async_send_command.call_args_list) == 5 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 4 diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index 1d0f74c7269..d8c3de92b3b 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -12,6 +12,7 @@ from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir +from tests.common import MockConfigEntry from tests.components.repairs import ( async_process_repairs_platforms, process_repair_fix_flow, @@ -268,3 +269,118 @@ async def test_abort_confirm( assert data["type"] == "abort" assert data["reason"] == "cannot_connect" assert data["description_placeholders"] == {"device_name": device.name} + + +@pytest.mark.usefixtures("client") +async def test_migrate_unique_id( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the migrate unique id flow.""" + old_unique_id = "123456789" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + data={ + "url": "ws://test.org", + }, + unique_id=old_unique_id, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + http_client = await hass_client() + + # Assert the issue is present + await ws_client.send_json_auto_id({"type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + issue_id = issue["issue_id"] + assert issue_id == f"migrate_unique_id.{config_entry.entry_id}" + + data = await start_repair_fix_flow(http_client, DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + assert data["description_placeholders"] == { + "config_entry_title": "Z-Wave JS", + "controller_model": "ZW090", + "new_unique_id": "3245146787", + "old_unique_id": old_unique_id, + } + + # Apply fix + data = await process_repair_fix_flow(http_client, flow_id) + + assert data["type"] == "create_entry" + assert config_entry.unique_id == "3245146787" + + await ws_client.send_json_auto_id({"type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 + + +@pytest.mark.usefixtures("client") +async def test_migrate_unique_id_missing_config_entry( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the migrate unique id flow with missing config entry.""" + old_unique_id = "123456789" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + data={ + "url": "ws://test.org", + }, + unique_id=old_unique_id, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + http_client = await hass_client() + + # Assert the issue is present + await ws_client.send_json_auto_id({"type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + issue_id = issue["issue_id"] + assert issue_id == f"migrate_unique_id.{config_entry.entry_id}" + + await hass.config_entries.async_remove(config_entry.entry_id) + + assert not hass.config_entries.async_get_entry(config_entry.entry_id) + + data = await start_repair_fix_flow(http_client, DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + assert data["description_placeholders"] == { + "config_entry_title": "Z-Wave JS", + "controller_model": "ZW090", + "new_unique_id": "3245146787", + "old_unique_id": old_unique_id, + } + + # Apply fix + data = await process_repair_fix_flow(http_client, flow_id) + + assert data["type"] == "create_entry" + + await ws_client.send_json_auto_id({"type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 4eb872954d1..d932338f9dc 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -1,5 +1,6 @@ """Test the Z-Wave JS siren platform.""" +import pytest from zwave_js_server.event import Event from homeassistant.components.siren import ( @@ -7,7 +8,7 @@ from homeassistant.components.siren import ( ATTR_TONE, ATTR_VOLUME_LEVEL, ) -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant SIREN_ENTITY = "siren.indoor_siren_6_play_tone_2" @@ -64,6 +65,12 @@ TONE_ID_VALUE_ID = { } +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SIREN] + + async def test_siren( hass: HomeAssistant, client, aeotec_zw164_siren, integration ) -> None: diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index d6683fa24cb..fc225d529a6 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -324,12 +324,12 @@ async def test_update_entity_ha_not_running( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 1 + assert len(client.async_send_command.call_args_list) == 4 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 1 + assert len(client.async_send_command.call_args_list) == 4 # Update should be delayed by a day because HA is not running hass.set_state(CoreState.starting) @@ -337,15 +337,15 @@ async def test_update_entity_ha_not_running( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 1 + assert len(client.async_send_command.call_args_list) == 4 hass.set_state(CoreState.running) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[1][0][0] + assert len(client.async_send_command.call_args_list) == 5 + args = client.async_send_command.call_args_list[4][0][0] assert args["command"] == "controller.get_available_firmware_updates" assert args["nodeId"] == zen_31.node_id @@ -651,28 +651,31 @@ async def test_update_entity_delay( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 2 + assert len(client.async_send_command.call_args_list) == 6 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 2 + assert len(client.async_send_command.call_args_list) == 6 - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done(wait_background_tasks=True) + update_interval = timedelta(minutes=5) + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() nodes: set[int] = set() - assert len(client.async_send_command.call_args_list) == 3 - args = client.async_send_command.call_args_list[2][0][0] + assert len(client.async_send_command.call_args_list) == 7 + args = client.async_send_command.call_args_list[6][0][0] assert args["command"] == "controller.get_available_firmware_updates" nodes.add(args["nodeId"]) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done(wait_background_tasks=True) + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 4 - args = client.async_send_command.call_args_list[3][0][0] + assert len(client.async_send_command.call_args_list) == 8 + args = client.async_send_command.call_args_list[7][0][0] assert args["command"] == "controller.get_available_firmware_updates" nodes.add(args["nodeId"]) @@ -843,8 +846,8 @@ async def test_update_entity_full_restore_data_update_available( assert attrs[ATTR_IN_PROGRESS] is True assert attrs[ATTR_UPDATE_PERCENTAGE] is None - assert len(client.async_send_command.call_args_list) == 2 - assert client.async_send_command.call_args_list[1][0][0] == { + assert len(client.async_send_command.call_args_list) == 5 + assert client.async_send_command.call_args_list[4][0][0] == { "command": "controller.firmware_update_ota", "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, "updateInfo": { diff --git a/tests/conftest.py b/tests/conftest.py index 2f7330ebf22..2c23270daee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ import reprlib from shutil import rmtree import sqlite3 import ssl +import sys import threading from typing import TYPE_CHECKING, Any, cast from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch @@ -41,11 +42,14 @@ import respx from syrupy.assertion import SnapshotAssertion from syrupy.session import SnapshotSession +# Setup patching of JSON functions before any other Home Assistant imports +from . import patch_json # isort:skip + 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 +from . import patch_recorder # isort:skip # Setup patching of dt_util time functions before any other Home Assistant imports from . import patch_time # noqa: F401, isort:skip @@ -66,7 +70,12 @@ from homeassistant.components.websocket_api.auth import ( # pylint: disable-next=hass-component-root-import from homeassistant.components.websocket_api.http import URL from homeassistant.config import YAML_CONFIG_FILE -from homeassistant.config_entries import ConfigEntries, ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ( + ConfigEntries, + ConfigEntry, + ConfigEntryState, + ConfigSubentryData, +) from homeassistant.const import BASE_PLATFORMS, HASSIO_USER_NAME from homeassistant.core import ( Context, @@ -83,6 +92,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, floor_registry as fr, + frame, issue_registry as ir, label_registry as lr, recorder as recorder_helper, @@ -112,8 +122,10 @@ from .typing import ( if TYPE_CHECKING: # Local import to avoid processing recorder and SQLite modules when running a # testcase which does not use the recorder. + from homeassistant.auth.models import RefreshToken from homeassistant.components import recorder + pytest.register_assert_rewrite("tests.common") from .common import ( # noqa: E402, isort:skip @@ -277,6 +289,7 @@ def garbage_collection() -> None: to run per test case if needed. """ gc.collect() + gc.freeze() @pytest.fixture(autouse=True) @@ -428,11 +441,23 @@ def verify_cleanup( @pytest.fixture(autouse=True) -def reset_hass_threading_local_object() -> Generator[None]: - """Reset the _Hass threading.local object for every test case.""" +def reset_globals() -> Generator[None]: + """Reset global objects for every test case.""" yield + + # Reset the _Hass threading.local object ha._hass.__dict__.clear() + # Reset the frame helper globals + frame.async_setup(None) + frame._REPORTED_INTEGRATIONS.clear() + + # Reset patch_json + if patch_json.mock_objects: + obj = patch_json.mock_objects.pop() + patch_json.mock_objects.clear() + pytest.fail(f"Test attempted to serialize mock object {obj}") + @pytest.fixture(autouse=True, scope="session") def bcrypt_cost() -> Generator[None]: @@ -598,6 +623,7 @@ async def hass( async with async_test_home_assistant(loop, load_registries) as hass: orig_exception_handler = loop.get_exception_handler() loop.set_exception_handler(exc_handle) + frame.async_setup(hass) yield hass @@ -838,7 +864,7 @@ def hass_client_no_auth( @pytest.fixture def current_request() -> Generator[MagicMock]: """Mock current request.""" - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mocked_request = make_mocked_request( "GET", "/some/request", @@ -937,6 +963,12 @@ def mqtt_config_entry_data() -> dict[str, Any] | None: return None +@pytest.fixture +def mqtt_config_subentries_data() -> tuple[ConfigSubentryData] | None: + """Fixture to allow overriding MQTT subentries data.""" + return None + + @pytest.fixture def mqtt_config_entry_options() -> dict[str, Any] | None: """Fixture to allow overriding MQTT entry options.""" @@ -1023,6 +1055,7 @@ async def mqtt_mock( mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_config_entry_options: dict[str, Any] | None, + mqtt_config_subentries_data: tuple[ConfigSubentryData] | None, mqtt_mock_entry: MqttMockHAClientGenerator, ) -> AsyncGenerator[MqttMockHAClient]: """Fixture to mock MQTT component.""" @@ -1035,6 +1068,7 @@ async def _mqtt_mock_entry( mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_config_entry_options: dict[str, Any] | None, + mqtt_config_subentries_data: tuple[ConfigSubentryData] | 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 @@ -1051,6 +1085,7 @@ async def _mqtt_mock_entry( entry = MockConfigEntry( data=mqtt_config_entry_data, options=mqtt_config_entry_options, + subentries_data=mqtt_config_subentries_data, domain=mqtt.DOMAIN, title="MQTT", version=1, @@ -1165,6 +1200,7 @@ async def mqtt_mock_entry( mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_config_entry_options: dict[str, Any] | None, + mqtt_config_subentries_data: tuple[ConfigSubentryData] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Set up an MQTT config entry.""" @@ -1181,7 +1217,11 @@ 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, mqtt_config_entry_options + hass, + mqtt_client_mock, + mqtt_config_entry_data, + mqtt_config_entry_options, + mqtt_config_subentries_data, ) as mqtt_mock_entry: yield _setup_mqtt_entry @@ -1288,9 +1328,11 @@ def disable_translations_once( @pytest_asyncio.fixture(autouse=True, scope="session", loop_scope="session") async def mock_zeroconf_resolver() -> AsyncGenerator[_patch]: """Mock out the zeroconf resolver.""" + resolver = AsyncResolver() + resolver.real_close = resolver.close patcher = patch( "homeassistant.helpers.aiohttp_client._async_make_resolver", - return_value=AsyncResolver(), + return_value=resolver, ) patcher.start() try: @@ -1315,9 +1357,13 @@ def mock_zeroconf() -> Generator[MagicMock]: from zeroconf import DNSCache # pylint: disable=import-outside-toplevel with ( - patch("homeassistant.components.zeroconf.HaZeroconf", autospec=True) as mock_zc, - patch("homeassistant.components.zeroconf.AsyncServiceBrowser", autospec=True), + patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, + patch( + "homeassistant.components.zeroconf.discovery.AsyncServiceBrowser", + ) as mock_browser, ): + asb = mock_browser.return_value + asb.async_cancel = AsyncMock() zc = mock_zc.return_value # DNSCache has strong Cython type checks, and MagicMock does not work # so we must mock the class directly @@ -1866,6 +1912,67 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]: yield mock_bleak_scanner_start +@pytest.fixture +def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]: + """Fixture to inject hassio env.""" + from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel + HassioAPIError, + ) + + from .components.hassio import ( # pylint: disable=import-outside-toplevel + SUPERVISOR_TOKEN, + ) + + with ( + patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), + patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}), + patch( + "homeassistant.components.hassio.HassIO.get_info", + Mock(side_effect=HassioAPIError()), + ), + ): + yield + + +@pytest.fixture +async def hassio_stubs( + hassio_env: None, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, +) -> RefreshToken: + """Create mock hassio http client.""" + from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel + HassioAPIError, + ) + + with ( + patch( + "homeassistant.components.hassio.HassIO.update_hass_api", + return_value={"result": "ok"}, + ) as hass_api, + patch( + "homeassistant.components.hassio.HassIO.update_hass_config", + return_value={"result": "ok"}, + ), + patch( + "homeassistant.components.hassio.HassIO.get_info", + side_effect=HassioAPIError(), + ), + patch( + "homeassistant.components.hassio.HassIO.get_ingress_panels", + return_value={"panels": []}, + ), + patch( + "homeassistant.components.hassio.issues.SupervisorIssues.setup", + ), + ): + await async_setup_component(hass, "hassio", {}) + + return hass_api.call_args[0][1] + + @pytest.fixture def integration_frame_path() -> str: """Return the path to the integration frame. @@ -1889,12 +1996,15 @@ def mock_integration_frame(integration_frame_path: str) -> Generator[Mock]: Defaults to calling from `hue` core integration, and can be parametrized with `integration_frame_path`. """ + correct_filename = f"/home/paulus/{integration_frame_path}/light.py" + correct_module_name = f"{integration_frame_path.replace('/', '.')}.light" correct_frame = Mock( filename=f"/home/paulus/{integration_frame_path}/light.py", lineno="23", line="self.light.is_on", ) with ( + patch.dict(sys.modules, {correct_module_name: Mock(__file__=correct_filename)}), patch( "homeassistant.helpers.frame.linecache.getline", return_value=correct_frame.line, diff --git a/tests/hassfest/test_dependencies.py b/tests/hassfest/test_dependencies.py index 84e02b2d9d5..26ed8a01ba8 100644 --- a/tests/hassfest/test_dependencies.py +++ b/tests/hassfest/test_dependencies.py @@ -68,33 +68,6 @@ import homeassistant.components.renamed_absolute as hue assert mock_collector.unfiltered_referenced == {"renamed_absolute"} -def test_hass_components_var(mock_collector) -> None: - """Test detecting a hass_components_var reference.""" - mock_collector.visit( - ast.parse( - """ -def bla(hass): - hass.components.hass_components_var.async_do_something() -""" - ) - ) - assert mock_collector.unfiltered_referenced == {"hass_components_var"} - - -def test_hass_components_class(mock_collector) -> None: - """Test detecting a hass_components_class reference.""" - mock_collector.visit( - ast.parse( - """ -class Hello: - def something(self): - self.hass.components.hass_components_class.async_yo() -""" - ) - ) - assert mock_collector.unfiltered_referenced == {"hass_components_class"} - - def test_all_imports(mock_collector) -> None: """Test all imports together.""" mock_collector.visit( @@ -108,13 +81,6 @@ from homeassistant.components.subimport.smart_home import EVENT_ALEXA_SMART_HOME from homeassistant.components.child_import_field import bla import homeassistant.components.renamed_absolute as hue - -def bla(hass): - hass.components.hass_components_var.async_do_something() - -class Hello: - def something(self): - self.hass.components.hass_components_class.async_yo() """ ) ) @@ -123,6 +89,4 @@ class Hello: "subimport", "child_import_field", "renamed_absolute", - "hass_components_var", - "hass_components_class", } diff --git a/tests/helpers/snapshots/test_frame.ambr b/tests/helpers/snapshots/test_frame.ambr new file mode 100644 index 00000000000..e74a4b2947a --- /dev/null +++ b/tests/helpers/snapshots/test_frame.ambr @@ -0,0 +1,82 @@ +# serializer version: 1 +# name: test_report_usage[core default] + list([ + ]) +# --- +# name: test_report_usage[core integration default] + list([ + "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", + ]) +# --- +# name: test_report_usage[core_behavior ignore] + list([ + ]) +# --- +# name: test_report_usage[core_behavior log] + list([ + 'Detected code that test_report_string. Please report this issue', + ]) +# --- +# name: test_report_usage[core_integration_behavior error] + list([ + "Detected that integration 'test_integration_frame' test_report_string at homeassistant/components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + ]) +# --- +# name: test_report_usage[core_integration_behavior ignore] + list([ + ]) +# --- +# name: test_report_usage[custom integration default] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", + ]) +# --- +# name: test_report_usage[custom integration error] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", + ]) +# --- +# name: test_report_usage[custom integration ignore] + list([ + ]) +# --- +# name: test_report_usage_find_issue_tracker[core integration] + list([ + "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", + ]) +# --- +# name: test_report_usage_find_issue_tracker[core] + list([ + 'Detected code that test_report_string. Please report this issue', + ]) +# --- +# name: test_report_usage_find_issue_tracker[custom integration] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://blablabla.com", + ]) +# --- +# name: test_report_usage_find_issue_tracker[unknown custom integration] + list([ + "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'unknown_custom_integration' custom integration", + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[core integration] + list([ + "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[core] + list([ + 'Detected code that test_report_string. Please report this issue', + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[custom integration] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://blablabla.com", + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[unknown custom integration] + list([ + "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'unknown_custom_integration' custom integration", + ]) +# --- diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 13cb25bc516..e44111634d1 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -249,7 +249,6 @@ async def test_get_clientsession_patched_close(hass: HomeAssistant) -> None: assert mock_close.call_count == 0 -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -292,7 +291,6 @@ async def test_warning_close_session_integration( ) in caplog.text -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_custom( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -403,3 +401,15 @@ async def test_async_mdnsresolver( resp = await session.post("http://localhost/xyz", json={"x": 1}) assert resp.status == 200 assert await resp.json() == {"x": 1} + + +async def test_resolver_is_singleton(hass: HomeAssistant) -> None: + """Test that the resolver is a singleton.""" + session = client.async_get_clientsession(hass) + session2 = client.async_get_clientsession(hass) + session3 = client.async_create_clientsession(hass) + assert isinstance(session._connector, aiohttp.TCPConnector) + assert isinstance(session2._connector, aiohttp.TCPConnector) + assert isinstance(session3._connector, aiohttp.TCPConnector) + assert session._connector._resolver is session2._connector._resolver + assert session._connector._resolver is session3._connector._resolver diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index c69f039027e..3496c41ecf4 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -494,6 +494,29 @@ async def test_async_get_area_by_name(area_registry: ar.AreaRegistry) -> None: assert area_registry.async_get_area_by_name("M o c k 1").normalized_name == "mock1" +async def test_async_get_areas_by_alias( + area_registry: ar.AreaRegistry, +) -> None: + """Make sure we can get the areas by alias.""" + area1 = area_registry.async_create("Mock1", aliases=("alias_1", "alias_2")) + area2 = area_registry.async_create("Mock2", aliases=("alias_1", "alias_3")) + + assert len(area_registry.areas) == 2 + + alias1_list = area_registry.async_get_areas_by_alias("A l i a s_1") + alias2_list = area_registry.async_get_areas_by_alias("A l i a s_2") + alias3_list = area_registry.async_get_areas_by_alias("A l i a s_3") + + assert len(alias1_list) == 2 + assert len(alias2_list) == 1 + assert len(alias3_list) == 1 + + assert area1 in alias1_list + assert area1 in alias2_list + assert area2 in alias1_list + assert area2 in alias3_list + + async def test_async_get_area_by_name_not_found(area_registry: ar.AreaRegistry) -> None: """Make sure we return None for non-existent areas.""" area_registry.async_create("Mock1") diff --git a/tests/helpers/test_backup.py b/tests/helpers/test_backup.py index 10ff5cb855f..f6a4f28622e 100644 --- a/tests/helpers/test_backup.py +++ b/tests/helpers/test_backup.py @@ -17,6 +17,7 @@ async def test_async_get_manager(hass: HomeAssistant) -> None: backup_helper.async_initialize_backup(hass) task = asyncio.create_task(backup_helper.async_get_manager(hass)) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + await hass.async_block_till_done() manager = await task assert manager is hass.data[backup_helper.DATA_MANAGER] @@ -36,7 +37,5 @@ async def test_async_get_manager_backup_failed_setup(hass: HomeAssistant) -> Non side_effect=Exception("Boom!"), ): assert not await async_setup_component(hass, BACKUP_DOMAIN, {}) - with ( - pytest.raises(Exception, match="Boom!"), - ): + with pytest.raises(Exception, match="Boom!"): await backup_helper.async_get_manager(hass) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index b8c8c8a18c8..7285301f12b 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,6 +1,6 @@ """Test the condition helper.""" -from datetime import datetime, timedelta +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, patch @@ -8,7 +8,6 @@ from freezegun import freeze_time import pytest import voluptuous as vol -from homeassistant.components import automation from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -17,10 +16,8 @@ from homeassistant.const import ( CONF_DOMAIN, STATE_UNAVAILABLE, STATE_UNKNOWN, - SUN_EVENT_SUNRISE, - SUN_EVENT_SUNSET, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConditionError, HomeAssistantError from homeassistant.helpers import ( condition, @@ -32,8 +29,6 @@ from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.typing import WebSocketGenerator - def assert_element(trace_element, expected_element, path): """Assert a trace element is as expected. @@ -2080,6 +2075,7 @@ async def test_multiple_zones(hass: HomeAssistant) -> None: assert not test(hass) +@pytest.mark.usefixtures("hass") async def test_extract_entities() -> None: """Test extracting entities.""" assert condition.async_extract_entities( @@ -2153,6 +2149,7 @@ async def test_extract_entities() -> None: } +@pytest.mark.usefixtures("hass") async def test_extract_devices() -> None: """Test extracting devices.""" assert condition.async_extract_devices( @@ -2240,1220 +2237,6 @@ async def test_condition_template_invalid_results(hass: HomeAssistant) -> None: assert not test(hass) -def _find_run_id(traces, trace_type, item_id): - """Find newest run_id for a script or automation.""" - for _trace in reversed(traces): - if _trace["domain"] == trace_type and _trace["item_id"] == item_id: - return _trace["run_id"] - - return None - - -async def assert_automation_condition_trace(hass_ws_client, automation_id, expected): - """Test the result of automation condition.""" - msg_id = 1 - - def next_id(): - nonlocal msg_id - msg_id += 1 - return msg_id - - client = await hass_ws_client() - - # List traces - await client.send_json( - {"id": next_id(), "type": "trace/list", "domain": "automation"} - ) - response = await client.receive_json() - assert response["success"] - run_id = _find_run_id(response["result"], "automation", automation_id) - - # Get trace - await client.send_json( - { - "id": next_id(), - "type": "trace/get", - "domain": "automation", - "item_id": "sun", - "run_id": run_id, - } - ) - response = await client.receive_json() - assert response["success"] - trace = response["result"] - assert len(trace["trace"]["condition/0"]) == 1 - condition_trace = trace["trace"]["condition/0"][0]["result"] - assert condition_trace == expected - - -async def test_if_action_before_sunrise_no_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunrise. - - Before sunrise is true from midnight until sunset, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise + 1s -> 'before sunrise' not true - now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = sunrise -> 'before sunrise' true - now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = local midnight -> 'before sunrise' true - now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = local midnight - 1s -> 'before sunrise' not true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T13:33:18.342542+00:00"}, - ) - - -async def test_if_action_after_sunrise_no_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunrise. - - After sunrise is true from sunrise until midnight, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise - 1s -> 'after sunrise' not true - now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = sunrise + 1s -> 'after sunrise' true - now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = local midnight -> 'after sunrise' not true - now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, - ) - - # now = local midnight - 1s -> 'after sunrise' true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T13:33:18.342542+00:00"}, - ) - - -async def test_if_action_before_sunrise_with_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunrise with offset. - - Before sunrise is true from midnight until sunset, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "before": SUN_EVENT_SUNRISE, - "before_offset": "+1:00:00", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise + 1s + 1h -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 16, 14, 33, 19, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunrise + 1h -> 'before sunrise' with offset +1h true - now = datetime(2015, 9, 16, 14, 33, 18, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = UTC midnight -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = UTC midnight - 1s -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local midnight -> 'before sunrise' with offset +1h true - now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local midnight - 1s -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunset -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunset -1s -> 'before sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-16T14:33:18.342542+00:00"}, - ) - - -async def test_if_action_before_sunset_with_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunset with offset. - - Before sunset is true from midnight until sunset, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "before": "sunset", - "before_offset": "+1:00:00", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = local midnight -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = sunset + 1s + 1h -> 'before sunset' with offset +1h not true - now = datetime(2015, 9, 17, 2, 53, 46, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = sunset + 1h -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = UTC midnight -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 3 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = UTC midnight - 1s -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 4 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = sunrise -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 5 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = sunrise -1s -> 'before sunset' with offset +1h true - now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 6 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = local midnight-1s -> 'after sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 6 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-17T02:53:44.723614+00:00"}, - ) - - -async def test_if_action_after_sunrise_with_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunrise with offset. - - After sunrise is true from sunrise until midnight, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "after": SUN_EVENT_SUNRISE, - "after_offset": "+1:00:00", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise - 1s + 1h -> 'after sunrise' with offset +1h not true - now = datetime(2015, 9, 16, 14, 33, 17, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunrise + 1h -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 16, 14, 33, 58, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = UTC noon -> 'after sunrise' with offset +1h not true - now = datetime(2015, 9, 16, 12, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = UTC noon - 1s -> 'after sunrise' with offset +1h not true - now = datetime(2015, 9, 16, 11, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local noon -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 16, 19, 1, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local noon - 1s -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 16, 18, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 3 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunset -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 4 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = sunset + 1s -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 5 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local midnight-1s -> 'after sunrise' with offset +1h true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 6 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T14:33:18.342542+00:00"}, - ) - - # now = local midnight -> 'after sunrise' with offset +1h not true - now = datetime(2015, 9, 17, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 6 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-17T14:33:57.053037+00:00"}, - ) - - -async def test_if_action_after_sunset_with_offset( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunset with offset. - - After sunset is true from sunset until midnight, local time. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "after": "sunset", - "after_offset": "+1:00:00", - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunset - 1s + 1h -> 'after sunset' with offset +1h not true - now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = sunset + 1h -> 'after sunset' with offset +1h true - now = datetime(2015, 9, 17, 2, 53, 45, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, - ) - - # now = midnight-1s -> 'after sunset' with offset +1h true - now = datetime(2015, 9, 16, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-09-16T02:55:06.099767+00:00"}, - ) - - # now = midnight -> 'after sunset' with offset +1h not true - now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-09-17T02:53:44.723614+00:00"}, - ) - - -async def test_if_action_after_and_before_during( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunrise and before sunset. - - This is true from sunrise until sunset. - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "after": SUN_EVENT_SUNRISE, - "before": SUN_EVENT_SUNSET, - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise - 1s -> 'after sunrise' + 'before sunset' not true - now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": False, - "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = sunset + 1s -> 'after sunrise' + 'before sunset' not true - now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-09-17T01:53:44.723614+00:00"}, - ) - - # now = sunrise + 1s -> 'after sunrise' + 'before sunset' true - now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = sunset - 1s -> 'after sunrise' + 'before sunset' true - now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = 9AM local -> 'after sunrise' + 'before sunset' true - now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 3 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_before": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_after": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - -async def test_if_action_before_or_after_during( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunrise or after sunset. - - This is true from midnight until sunrise and from sunset until midnight - """ - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { - "condition": "sun", - "before": SUN_EVENT_SUNRISE, - "after": SUN_EVENT_SUNSET, - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local - # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC - # now = sunrise - 1s -> 'before sunrise' | 'after sunset' true - now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = sunset + 1s -> 'before sunrise' | 'after sunset' true - now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = sunrise + 1s -> 'before sunrise' | 'after sunset' false - now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": False, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = sunset - 1s -> 'before sunrise' | 'after sunset' false - now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": False, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = midnight + 1s local -> 'before sunrise' | 'after sunset' true - now = datetime(2015, 9, 16, 7, 0, 1, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 3 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - # now = midnight - 1s local -> 'before sunrise' | 'after sunset' true - now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 4 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - { - "result": True, - "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", - "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", - }, - ) - - -async def test_if_action_before_sunrise_no_offset_kotzebue( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunrise. - - Local timezone: Alaska time - Location: Kotzebue, which has a very skewed local timezone with sunrise - at 7 AM and sunset at 3AM during summer - After sunrise is true from sunrise until midnight, local time. - """ - await hass.config.async_set_time_zone("America/Anchorage") - hass.config.latitude = 66.5 - hass.config.longitude = 162.4 - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNRISE}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local - # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC - # now = sunrise + 1s -> 'before sunrise' not true - now = datetime(2015, 7, 24, 15, 21, 13, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = sunrise - 1h -> 'before sunrise' true - now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = local midnight -> 'before sunrise' true - now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = local midnight - 1s -> 'before sunrise' not true - now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-07-23T15:12:19.155123+00:00"}, - ) - - -async def test_if_action_after_sunrise_no_offset_kotzebue( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunrise. - - Local timezone: Alaska time - Location: Kotzebue, which has a very skewed local timezone with sunrise - at 7 AM and sunset at 3AM during summer - Before sunrise is true from midnight until sunrise, local time. - """ - await hass.config.async_set_time_zone("America/Anchorage") - hass.config.latitude = 66.5 - hass.config.longitude = 162.4 - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNRISE}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local - # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC - # now = sunrise -> 'after sunrise' true - now = datetime(2015, 7, 24, 15, 21, 12, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = sunrise - 1h -> 'after sunrise' not true - now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = local midnight -> 'after sunrise' not true - now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-07-24T15:16:46.975735+00:00"}, - ) - - # now = local midnight - 1s -> 'after sunrise' true - now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-07-23T15:12:19.155123+00:00"}, - ) - - -async def test_if_action_before_sunset_no_offset_kotzebue( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was before sunrise. - - Local timezone: Alaska time - Location: Kotzebue, which has a very skewed local timezone with sunrise - at 7 AM and sunset at 3AM during summer - Before sunset is true from midnight until sunset, local time. - """ - await hass.config.async_set_time_zone("America/Anchorage") - hass.config.latitude = 66.5 - hass.config.longitude = 162.4 - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "before": SUN_EVENT_SUNSET}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local - # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC - # now = sunset + 1s -> 'before sunset' not true - now = datetime(2015, 7, 25, 11, 13, 34, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 0 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-07-25T11:13:32.501837+00:00"}, - ) - - # now = sunset - 1h-> 'before sunset' true - now = datetime(2015, 7, 25, 10, 13, 33, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-07-25T11:13:32.501837+00:00"}, - ) - - # now = local midnight -> 'before sunrise' true - now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_before": "2015-07-24T11:17:54.446913+00:00"}, - ) - - # now = local midnight - 1s -> 'before sunrise' not true - now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_before": "2015-07-23T11:22:18.467277+00:00"}, - ) - - -async def test_if_action_after_sunset_no_offset_kotzebue( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - service_calls: list[ServiceCall], -) -> None: - """Test if action was after sunrise. - - Local timezone: Alaska time - Location: Kotzebue, which has a very skewed local timezone with sunrise - at 7 AM and sunset at 3AM during summer - After sunset is true from sunset until midnight, local time. - """ - await hass.config.async_set_time_zone("America/Anchorage") - hass.config.latitude = 66.5 - hass.config.longitude = 162.4 - await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": {"condition": "sun", "after": SUN_EVENT_SUNSET}, - "action": {"service": "test.automation"}, - } - }, - ) - - # sunrise: 2015-07-24 07:21:12 local, sunset: 2015-07-25 03:13:33 local - # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC - # now = sunset -> 'after sunset' true - now = datetime(2015, 7, 25, 11, 13, 33, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-07-25T11:13:32.501837+00:00"}, - ) - - # now = sunset - 1s -> 'after sunset' not true - now = datetime(2015, 7, 25, 11, 13, 32, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-07-25T11:13:32.501837+00:00"}, - ) - - # now = local midnight -> 'after sunset' not true - now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 1 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": False, "wanted_time_after": "2015-07-24T11:17:54.446913+00:00"}, - ) - - # now = local midnight - 1s -> 'after sunset' true - now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with freeze_time(now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(service_calls) == 2 - await assert_automation_condition_trace( - hass_ws_client, - "sun", - {"result": True, "wanted_time_after": "2015-07-23T11:22:18.467277+00:00"}, - ) - - async def test_trigger(hass: HomeAssistant) -> None: """Test trigger condition.""" config = {"alias": "Trigger Cond", "condition": "trigger", "id": "123456"} diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 0fc6b582bb5..5d16a9a62fd 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -1,11 +1,11 @@ """Tests for the Somfy config flow.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from http import HTTPStatus import logging import time from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import aiohttp import pytest @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.network import NoURLAvailableError -from tests.common import MockConfigEntry, mock_platform +from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -27,6 +27,11 @@ ACCESS_TOKEN_1 = "mock-access-token-1" ACCESS_TOKEN_2 = "mock-access-token-2" AUTHORIZE_URL = "https://example.como/auth/authorize" TOKEN_URL = "https://example.como/auth/token" +MOCK_SECRET_TOKEN_URLSAFE = ( + "token-" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) @pytest.fixture @@ -40,6 +45,22 @@ async def local_impl( ) +@pytest.fixture +async def local_impl_pkce( + hass: HomeAssistant, +) -> AsyncGenerator[config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce]: + """Local implementation.""" + assert await setup.async_setup_component(hass, "auth", {}) + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.secrets.token_urlsafe", + return_value=MOCK_SECRET_TOKEN_URLSAFE + + "bbbbbb", # Add some characters that should be removed by the logic. + ): + yield config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce( + hass, TEST_DOMAIN, CLIENT_ID, AUTHORIZE_URL, TOKEN_URL + ) + + @pytest.fixture def flow_handler( hass: HomeAssistant, @@ -963,3 +984,143 @@ async def test_oauth2_without_secret_init( client = await hass_client_no_auth() resp = await client.get("/auth/external/callback?code=abcd&state=qwer") assert resp.status == 400 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_abort_oauth_with_pkce_rejected( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl_pkce: config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Check bad oauth token.""" + flow_handler.async_register_implementation(hass, local_impl_pkce) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + code_challenge = local_impl_pkce.compute_code_challenge(MOCK_SECRET_TOKEN_URLSAFE) + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + + assert result["url"].startswith(f"{AUTHORIZE_URL}?") + assert f"client_id={CLIENT_ID}" in result["url"] + assert "redirect_uri=https://example.com/auth/external/callback" in result["url"] + assert f"state={state}" in result["url"] + assert "scope=read+write" in result["url"] + assert "response_type=code" in result["url"] + assert f"code_challenge={code_challenge}" in result["url"] + assert "code_challenge_method=S256" in result["url"] + + client = await hass_client_no_auth() + resp = await client.get( + f"/auth/external/callback?error=access_denied&state={state}" + ) + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "user_rejected_authorize" + assert result["description_placeholders"] == {"error": "access_denied"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_oauth_with_pkce_adds_code_verifier_to_token_resolve( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl_pkce: config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check pkce flow.""" + + mock_integration( + hass, + MockModule( + domain=TEST_DOMAIN, + async_setup_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, f"{TEST_DOMAIN}.config_flow", None) + flow_handler.async_register_implementation(hass, local_impl_pkce) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + code_challenge = local_impl_pkce.compute_code_challenge(MOCK_SECRET_TOKEN_URLSAFE) + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + + assert result["url"].startswith(f"{AUTHORIZE_URL}?") + assert f"client_id={CLIENT_ID}" in result["url"] + assert "redirect_uri=https://example.com/auth/external/callback" in result["url"] + assert f"state={state}" in result["url"] + assert "scope=read+write" in result["url"] + assert "response_type=code" in result["url"] + assert f"code_challenge={code_challenge}" in result["url"] + assert "code_challenge_method=S256" in result["url"] + + # Setup the response when HA tries to fetch a token with the code + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN_1, + "type": "bearer", + "expires_in": 60, + }, + ) + + client = await hass_client_no_auth() + # trigger the callback + 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" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Verify the token resolve request occurred + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "client_id": CLIENT_ID, + "grant_type": "authorization_code", + "code": "abcd", + "redirect_uri": "https://example.com/auth/external/callback", + "code_verifier": MOCK_SECRET_TOKEN_URLSAFE, + } + + +@pytest.mark.parametrize("code_verifier_length", [40, 129]) +def test_generate_code_verifier_invalid_length(code_verifier_length: int) -> None: + """Test generate_code_verifier with an invalid length.""" + with pytest.raises(ValueError): + config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce.generate_code_verifier( + code_verifier_length + ) + + +@pytest.mark.parametrize("code_verifier", ["", "yyy", "a" * 129]) +def test_compute_code_challenge_invalid_code_verifier(code_verifier: str) -> None: + """Test compute_code_challenge with an invalid code_verifier.""" + with pytest.raises(ValueError): + config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce.compute_code_challenge( + code_verifier + ) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 7202cef6f5f..aec687be40a 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -773,6 +773,7 @@ async def test_dynamic_template_no_hass(hass: HomeAssistant) -> None: await hass.async_add_executor_job(schema, value) +@pytest.mark.usefixtures("hass") def test_template_complex() -> None: """Test template_complex validator.""" schema = vol.Schema(cv.template_complex) @@ -1414,6 +1415,7 @@ def test_key_value_schemas() -> None: schema({"mode": mode, "data": data}) +@pytest.mark.usefixtures("hass") def test_key_value_schemas_with_default() -> None: """Test key value schemas.""" schema = vol.Schema( @@ -1458,11 +1460,6 @@ def test_key_value_schemas_with_default() -> None: [ ({"delay": "{{ invalid"}, "should be format 'HH:MM'"), ({"wait_template": "{{ invalid"}, "invalid template"), - ({"condition": "invalid"}, "Unexpected value for condition: 'invalid'"), - ( - {"condition": "not", "conditions": {"condition": "invalid"}}, - "Unexpected value for condition: 'invalid'", - ), # The validation error message could be improved to explain that this is not # a valid shorthand template ( @@ -1492,8 +1489,9 @@ def test_key_value_schemas_with_default() -> None: ), ], ) +@pytest.mark.usefixtures("hass") def test_script(caplog: pytest.LogCaptureFixture, config: dict, error: str) -> None: - """Test script validation is user friendly.""" + """Test script action validation is user friendly.""" with pytest.raises(vol.Invalid, match=error): cv.script_action(config) @@ -1570,6 +1568,7 @@ def test_language() -> None: assert schema(value) +@pytest.mark.usefixtures("hass") def test_positive_time_period_template() -> None: """Test positive time period template validation.""" schema = vol.Schema(cv.positive_time_period_template) @@ -1949,3 +1948,30 @@ async def test_is_entity_service_schema( vol.All(vol.Schema(cv.make_entity_service_schema({"some": str}))), ): assert cv.is_entity_service_schema(schema) is True + + +def test_renamed(caplog: pytest.LogCaptureFixture, schema) -> None: + """Test renamed.""" + renamed_schema = vol.All(cv.renamed("mors", "mars"), schema) + + test_data = {"mars": True} + output = renamed_schema(test_data.copy()) + assert len(caplog.records) == 0 + assert output == test_data + + test_data = {"mors": True} + output = renamed_schema(test_data.copy()) + assert len(caplog.records) == 0 + assert output == {"mars": True} + + test_data = {"mars": True, "mors": True} + with pytest.raises( + vol.Invalid, + match="Cannot specify both 'mors' and 'mars'. Please use 'mars' only.", + ): + renamed_schema(test_data.copy()) + assert len(caplog.records) == 0 + + # Check error handling if data is not a dict + with pytest.raises(vol.Invalid, match="expected a dictionary"): + renamed_schema([]) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 29edfb3fea7..45144627028 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -6,7 +6,7 @@ from datetime import datetime from functools import partial import time from typing import Any -from unittest.mock import patch +from unittest.mock import ANY, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -34,6 +34,32 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: return entry +@pytest.fixture +def mock_config_entry_with_subentries(hass: HomeAssistant) -> MockConfigEntry: + """Create a mock config entry and add it to hass.""" + entry = MockConfigEntry( + title=None, + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ), + ) + entry.add_to_hass(hass) + return entry + + async def test_get_or_create_returns_same_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -3173,19 +3199,41 @@ async def test_cleanup_entity_registry_change( assert len(mock_call.mock_calls) == 2 +@pytest.mark.usefixtures("freezer") async def test_restore_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, + mock_config_entry_with_subentries: MockConfigEntry, ) -> None: """Make sure device id is stable.""" + entry_id = mock_config_entry_with_subentries.entry_id + subentry_id = "mock-subentry-id-1-1" update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) entry = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, + config_entry_id=entry_id, + config_subentry_id=subentry_id, + configuration_url="http://config_url_orig.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_orig", identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_orig", + model="model_orig", + model_id="model_id_orig", + name="name_orig", + serial_number="serial_no_orig", + suggested_area="suggested_area_orig", + sw_version="version_orig", + via_device="via_device_id_orig", + ) + + # Apply user customizations + device_registry.async_update_device( + entry.id, + area_id="12345A", + disabled_by=dr.DeviceEntryDisabler.USER, + labels={"label1", "label2"}, + name_by_user="Test Friendly Name", ) assert len(device_registry.devices) == 1 @@ -3196,19 +3244,79 @@ async def test_restore_device( assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 1 + # This will create a new device entry2 = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, + config_entry_id=entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, manufacturer="manufacturer", model="model", ) - entry3 = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, + assert entry2 == dr.DeviceEntry( + area_id=None, + config_entries={entry_id}, + config_entries_subentries={entry_id: {None}}, + configuration_url=None, + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:cd:ef:12")}, + created_at=utcnow(), + disabled_by=None, + entry_type=None, + hw_version=None, + id=ANY, + identifiers={("bridgeid", "4567")}, + labels={}, manufacturer="manufacturer", model="model", + model_id=None, + modified_at=utcnow(), + name_by_user=None, + name=None, + primary_config_entry=entry_id, + serial_number=None, + suggested_area=None, + sw_version=None, + ) + # This will restore the original device + entry3 = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=subentry_id, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=None, + hw_version="hw_version_new", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + name="name_new", + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + via_device="via_device_id_new", + ) + assert entry3 == dr.DeviceEntry( + area_id="suggested_area_new", + config_entries={entry_id}, + config_entries_subentries={entry_id: {subentry_id}}, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=None, + entry_type=None, + hw_version="hw_version_new", + id=entry.id, + identifiers={("bridgeid", "0123")}, + labels={}, + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + modified_at=utcnow(), + name_by_user=None, + name="name_new", + primary_config_entry=entry_id, + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", ) assert entry.id == entry3.id @@ -3222,129 +3330,186 @@ async def test_restore_device( await hass.async_block_till_done() - assert len(update_events) == 4 + assert len(update_events) == 5 assert update_events[0].data == { "action": "create", "device_id": entry.id, } assert update_events[1].data == { - "action": "remove", + "action": "update", + "changes": { + "area_id": "suggested_area_orig", + "disabled_by": None, + "labels": set(), + "name_by_user": None, + }, "device_id": entry.id, } assert update_events[2].data == { + "action": "remove", + "device_id": entry.id, + } + assert update_events[3].data == { "action": "create", "device_id": entry2.id, } - assert update_events[3].data == { - "action": "create", - "device_id": entry3.id, - } - - -async def test_restore_simple_device( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, -) -> None: - """Make sure device id is stable.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - entry = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - device_registry.async_remove_device(entry.id) - - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - - entry2 = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, - identifiers={("bridgeid", "4567")}, - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - ) - - assert entry.id == entry3.id - assert entry.id != entry2.id - assert len(device_registry.devices) == 2 - assert len(device_registry.deleted_devices) == 0 - - await hass.async_block_till_done() - - assert len(update_events) == 4 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "remove", - "device_id": entry.id, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry2.id, - } - assert update_events[3].data == { + assert update_events[4].data == { "action": "create", "device_id": entry3.id, } +@pytest.mark.usefixtures("freezer") async def test_restore_shared_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Make sure device id is stable for shared devices.""" update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry() + config_entry_1 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ), + ) config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() config_entry_2.add_to_hass(hass) entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + configuration_url="http://config_url_orig_1.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_orig_1", identifiers={("entry_123", "0123")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_orig_1", + model="model_orig_1", + model_id="model_id_orig_1", + name="name_orig_1", + serial_number="serial_no_orig_1", + suggested_area="suggested_area_orig_1", + sw_version="version_orig_1", + via_device="via_device_id_orig_1", ) assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 + # Add another config entry to the same device device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, + configuration_url="http://config_url_orig_2.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=None, + hw_version="hw_version_orig_2", identifiers={("entry_234", "2345")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_orig_2", + model="model_orig_2", + model_id="model_id_orig_2", + name="name_orig_2", + serial_number="serial_no_orig_2", + suggested_area="suggested_area_orig_2", + sw_version="version_orig_2", + via_device="via_device_id_orig_2", ) assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 + # Apply user customizations + updated_device = device_registry.async_update_device( + entry.id, + area_id="12345A", + disabled_by=dr.DeviceEntryDisabler.USER, + labels={"label1", "label2"}, + name_by_user="Test Friendly Name", + ) + + # Check device entry before we remove it + assert updated_device == dr.DeviceEntry( + area_id="12345A", + config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, + config_entries_subentries={ + config_entry_1.entry_id: {"mock-subentry-id-1-1"}, + config_entry_2.entry_id: {None}, + }, + configuration_url="http://config_url_orig_2.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=dr.DeviceEntryDisabler.USER, + entry_type=None, + hw_version="hw_version_orig_2", + id=entry.id, + identifiers={("entry_123", "0123"), ("entry_234", "2345")}, + labels={"label1", "label2"}, + manufacturer="manufacturer_orig_2", + model="model_orig_2", + model_id="model_id_orig_2", + modified_at=utcnow(), + name_by_user="Test Friendly Name", + name="name_orig_2", + primary_config_entry=config_entry_1.entry_id, + serial_number="serial_no_orig_2", + suggested_area="suggested_area_orig_2", + sw_version="version_orig_2", + ) + device_registry.async_remove_device(entry.id) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 1 + # config_entry_1 restores the original device, only the supplied config entry, + # config subentry, connections, and identifiers will be restored entry2 = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + configuration_url="http://config_url_new_1.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_new_1", identifiers={("entry_123", "0123")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_new_1", + model="model_new_1", + model_id="model_id_new_1", + name="name_new_1", + serial_number="serial_no_new_1", + suggested_area="suggested_area_new_1", + sw_version="version_new_1", + via_device="via_device_id_new_1", + ) + + assert entry2 == dr.DeviceEntry( + area_id="suggested_area_new_1", + config_entries={config_entry_1.entry_id}, + config_entries_subentries={config_entry_1.entry_id: {"mock-subentry-id-1-1"}}, + configuration_url="http://config_url_new_1.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=None, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_new_1", + id=entry.id, + identifiers={("entry_123", "0123")}, + labels={}, + manufacturer="manufacturer_new_1", + model="model_new_1", + model_id="model_id_new_1", + modified_at=utcnow(), + name_by_user=None, + name="name_new_1", + primary_config_entry=config_entry_1.entry_id, + serial_number="serial_no_new_1", + suggested_area="suggested_area_new_1", + sw_version="version_new_1", ) - assert entry.id == entry2.id assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 @@ -3352,17 +3517,55 @@ async def test_restore_shared_device( assert isinstance(entry2.connections, set) assert isinstance(entry2.identifiers, set) + # Remove the device again device_registry.async_remove_device(entry.id) + # config_entry_2 restores the original device, only the supplied config entry, + # config subentry, connections, and identifiers will be restored entry3 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, + configuration_url="http://config_url_new_2.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=None, + hw_version="hw_version_new_2", identifiers={("entry_234", "2345")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_new_2", + model="model_new_2", + model_id="model_id_new_2", + name="name_new_2", + serial_number="serial_no_new_2", + suggested_area="suggested_area_new_2", + sw_version="version_new_2", + via_device="via_device_id_new_2", + ) + + assert entry3 == dr.DeviceEntry( + area_id="suggested_area_new_2", + config_entries={config_entry_2.entry_id}, + config_entries_subentries={ + config_entry_2.entry_id: {None}, + }, + configuration_url="http://config_url_new_2.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=None, + entry_type=None, + hw_version="hw_version_new_2", + id=entry.id, + identifiers={("entry_234", "2345")}, + labels={}, + manufacturer="manufacturer_new_2", + model="model_new_2", + model_id="model_id_new_2", + modified_at=utcnow(), + name_by_user=None, + name="name_new_2", + primary_config_entry=config_entry_2.entry_id, + serial_number="serial_no_new_2", + suggested_area="suggested_area_new_2", + sw_version="version_new_2", ) - assert entry.id == entry3.id assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 @@ -3370,15 +3573,53 @@ async def test_restore_shared_device( assert isinstance(entry3.connections, set) assert isinstance(entry3.identifiers, set) + # Add config_entry_1 back to the restored device entry4 = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + configuration_url="http://config_url_new_1.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_new_1", identifiers={("entry_123", "0123")}, - manufacturer="manufacturer", - model="model", + manufacturer="manufacturer_new_1", + model="model_new_1", + model_id="model_id_new_1", + name="name_new_1", + serial_number="serial_no_new_1", + suggested_area="suggested_area_new_1", + sw_version="version_new_1", + via_device="via_device_id_new_1", + ) + + assert entry4 == dr.DeviceEntry( + area_id="suggested_area_new_2", + config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, + config_entries_subentries={ + config_entry_1.entry_id: {"mock-subentry-id-1-1"}, + config_entry_2.entry_id: {None}, + }, + configuration_url="http://config_url_new_1.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=None, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_new_1", + id=entry.id, + identifiers={("entry_123", "0123"), ("entry_234", "2345")}, + labels={}, + manufacturer="manufacturer_new_1", + model="model_new_1", + model_id="model_id_new_1", + modified_at=utcnow(), + name_by_user=None, + name="name_new_1", + primary_config_entry=config_entry_2.entry_id, + serial_number="serial_no_new_1", + suggested_area="suggested_area_new_1", + sw_version="version_new_1", ) - assert entry.id == entry4.id assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 @@ -3388,7 +3629,7 @@ async def test_restore_shared_device( await hass.async_block_till_done() - assert len(update_events) == 7 + assert len(update_events) == 8 assert update_events[0].data == { "action": "create", "device_id": entry.id, @@ -3398,33 +3639,65 @@ async def test_restore_shared_device( "device_id": entry.id, "changes": { "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, + "config_entries_subentries": { + config_entry_1.entry_id: {"mock-subentry-id-1-1"} + }, + "configuration_url": "http://config_url_orig_1.bla", + "entry_type": dr.DeviceEntryType.SERVICE, + "hw_version": "hw_version_orig_1", "identifiers": {("entry_123", "0123")}, + "manufacturer": "manufacturer_orig_1", + "model": "model_orig_1", + "model_id": "model_id_orig_1", + "name": "name_orig_1", + "serial_number": "serial_no_orig_1", + "suggested_area": "suggested_area_orig_1", + "sw_version": "version_orig_1", }, } assert update_events[2].data == { - "action": "remove", + "action": "update", "device_id": entry.id, + "changes": { + "area_id": "suggested_area_orig_1", + "disabled_by": None, + "labels": set(), + "name_by_user": None, + }, } assert update_events[3].data == { - "action": "create", + "action": "remove", "device_id": entry.id, } assert update_events[4].data == { - "action": "remove", - "device_id": entry.id, - } - assert update_events[5].data == { "action": "create", "device_id": entry.id, } + assert update_events[5].data == { + "action": "remove", + "device_id": entry.id, + } assert update_events[6].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[7].data == { "action": "update", "device_id": entry.id, "changes": { "config_entries": {config_entry_2.entry_id}, "config_entries_subentries": {config_entry_2.entry_id: {None}}, + "configuration_url": "http://config_url_new_2.bla", + "entry_type": None, + "hw_version": "hw_version_new_2", "identifiers": {("entry_234", "2345")}, + "manufacturer": "manufacturer_new_2", + "model": "model_new_2", + "model_id": "model_id_new_2", + "name": "name_new_2", + "serial_number": "serial_no_new_2", + "suggested_area": "suggested_area_new_2", + "sw_version": "version_new_2", }, } diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 6cf0e7c54d2..61396d97359 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -13,6 +13,7 @@ from unittest.mock import MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory from propcache.api import cached_property import pytest +from pytest_unordered import unordered from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -44,6 +45,7 @@ from tests.common import ( MockEntityPlatform, MockModule, MockPlatform, + RegistryEntryWithDefaults, mock_integration, mock_registry, ) @@ -392,7 +394,7 @@ async def test_async_parallel_updates_with_zero_on_sync_update( await asyncio.sleep(0) assert len(updates) == 2 - assert updates == [1, 2] + assert updates == unordered([1, 2]) finally: test_lock.set() await asyncio.sleep(0) @@ -683,7 +685,7 @@ async def test_warn_disabled( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we warn once if we write to a disabled entity.""" - entry = er.RegistryEntry( + entry = RegistryEntryWithDefaults( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", @@ -710,7 +712,7 @@ async def test_warn_disabled( async def test_disabled_in_entity_registry(hass: HomeAssistant) -> None: """Test entity is removed if we disable entity registry entry.""" - entry = er.RegistryEntry( + entry = RegistryEntryWithDefaults( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", @@ -1705,13 +1707,15 @@ async def test_invalid_state( assert hass.states.get("test.test").state == "x" * 255 caplog.clear() - ent._attr_state = "x" * 256 + long_state = "x" * 256 + ent._attr_state = long_state ent.async_write_ha_state() assert hass.states.get("test.test").state == STATE_UNKNOWN assert ( - "homeassistant.helpers.entity", + "homeassistant.core", logging.ERROR, - f"Failed to set state for test.test, fall back to {STATE_UNKNOWN}", + f"State {long_state} for test.test is longer than 255, " + f"falling back to {STATE_UNKNOWN}", ) in caplog.record_tuples ent._attr_state = "x" * 255 diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 41b7271150a..77ac85ed4ed 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -48,6 +48,7 @@ from tests.common import ( MockEntity, MockEntityPlatform, MockPlatform, + RegistryEntryWithDefaults, async_fire_time_changed, mock_platform, mock_registry, @@ -55,7 +56,6 @@ from tests.common import ( _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" -PLATFORM = "test_platform" async def test_polling_only_updates_entities_it_should_poll( @@ -752,7 +752,7 @@ async def test_overriding_name_from_registry(hass: HomeAssistant) -> None: mock_registry( hass, { - "test_domain.world": er.RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -785,7 +785,7 @@ async def test_registry_respect_entity_disabled(hass: HomeAssistant) -> None: mock_registry( hass, { - "test_domain.world": er.RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -832,7 +832,7 @@ async def test_entity_registry_updates_name(hass: HomeAssistant) -> None: registry = mock_registry( hass, { - "test_domain.world": er.RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1065,7 +1065,7 @@ async def test_entity_registry_updates_entity_id(hass: HomeAssistant) -> None: registry = mock_registry( hass, { - "test_domain.world": er.RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" @@ -1097,14 +1097,14 @@ async def test_entity_registry_updates_invalid_entity_id(hass: HomeAssistant) -> registry = mock_registry( hass, { - "test_domain.world": er.RegistryEntry( + "test_domain.world": RegistryEntryWithDefaults( entity_id="test_domain.world", unique_id="1234", # Using component.async_add_entities is equal to platform "domain" platform="test_platform", name="Some name", ), - "test_domain.existing": er.RegistryEntry( + "test_domain.existing": RegistryEntryWithDefaults( entity_id="test_domain.existing", unique_id="5678", platform="test_platform", @@ -1529,14 +1529,19 @@ async def test_entity_info_added_to_entity_registry( entry_default = entity_registry.async_get_or_create(DOMAIN, DOMAIN, "default") assert entry_default == er.RegistryEntry( - "test_domain.best_name", - "default", - "test_domain", + entity_id="test_domain.best_name", + unique_id="default", + platform="test_domain", capabilities={"max": 100}, + config_entry_id=None, + config_subentry_id=None, created_at=dt_util.utcnow(), device_class=None, + device_id=None, + disabled_by=None, entity_category=EntityCategory.CONFIG, has_entity_name=True, + hidden_by=None, icon=None, id=ANY, modified_at=dt_util.utcnow(), @@ -1544,6 +1549,7 @@ async def test_entity_info_added_to_entity_registry( original_device_class="mock-device-class", original_icon="nice:icon", original_name="best name", + options=None, supported_features=5, translation_key="my_translation_key", unit_of_measurement=PERCENTAGE, diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 416f2d5121d..7df7bb398e8 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -24,6 +24,7 @@ from homeassistant.util.dt import utc_from_timestamp from tests.common import ( ANY, MockConfigEntry, + RegistryEntryWithDefaults, async_capture_events, async_fire_time_changed, flush_store, @@ -122,9 +123,9 @@ def test_get_or_create_updates_data( assert set(entity_registry.async_device_ids()) == {orig_device_entry.id} assert orig_entry == er.RegistryEntry( - "light.hue_5678", - "5678", - "hue", + entity_id="light.hue_5678", + unique_id="5678", + platform="hue", capabilities={"max": 100}, config_entry_id=orig_config_entry.entry_id, config_subentry_id=config_subentry_id, @@ -139,6 +140,7 @@ def test_get_or_create_updates_data( id=orig_entry.id, modified_at=created, name=None, + options=None, original_device_class="mock-device-class", original_icon="initial-original_icon", original_name="initial-original_name", @@ -177,9 +179,9 @@ def test_get_or_create_updates_data( ) assert new_entry == er.RegistryEntry( - "light.hue_5678", - "5678", - "hue", + entity_id="light.hue_5678", + unique_id="5678", + platform="hue", aliases=set(), area_id=None, capabilities={"new-max": 150}, @@ -196,6 +198,7 @@ def test_get_or_create_updates_data( id=orig_entry.id, modified_at=modified, name=None, + options=None, original_device_class="new-mock-device-class", original_icon="updated-original_icon", original_name="updated-original_name", @@ -228,13 +231,14 @@ def test_get_or_create_updates_data( ) assert new_entry == er.RegistryEntry( - "light.hue_5678", - "5678", - "hue", + entity_id="light.hue_5678", + unique_id="5678", + platform="hue", aliases=set(), area_id=None, capabilities=None, config_entry_id=None, + config_subentry_id=None, created_at=created, device_class=None, device_id=None, @@ -246,6 +250,7 @@ def test_get_or_create_updates_data( id=orig_entry.id, modified_at=modified, name=None, + options=None, original_device_class=None, original_icon=None, original_name=None, @@ -2012,7 +2017,9 @@ async def test_disabled_entities_excluded_from_entity_list( ) == [entry1, entry2] -async def test_entity_max_length_exceeded(entity_registry: er.EntityRegistry) -> None: +async def test_entity_max_length_exceeded( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that an exception is raised when the max character length is exceeded.""" long_domain_name = ( @@ -2037,20 +2044,13 @@ async def test_entity_max_length_exceeded(entity_registry: er.EntityRegistry) -> "1234567890123456789012345678901234567" ) - known = [] - new_id = entity_registry.async_generate_entity_id( - "sensor", long_entity_id_name, known - ) + new_id = entity_registry.async_generate_entity_id("sensor", long_entity_id_name) assert new_id == "sensor." + long_entity_id_name[: 255 - 7] - known.append(new_id) - new_id = entity_registry.async_generate_entity_id( - "sensor", long_entity_id_name, known - ) + hass.states.async_reserve(new_id) + new_id = entity_registry.async_generate_entity_id("sensor", long_entity_id_name) assert new_id == "sensor." + long_entity_id_name[: 255 - 7 - 2] + "_2" - known.append(new_id) - new_id = entity_registry.async_generate_entity_id( - "sensor", long_entity_id_name, known - ) + hass.states.async_reserve(new_id) + new_id = entity_registry.async_generate_entity_id("sensor", long_entity_id_name) assert new_id == "sensor." + long_entity_id_name[: 255 - 7 - 2] + "_3" @@ -2095,8 +2095,12 @@ def test_entity_registry_items() -> None: assert entities.get_entity_id(("a", "b", "c")) is None assert entities.get_entry("abc") is None - entry1 = er.RegistryEntry("test.entity1", "1234", "hue") - entry2 = er.RegistryEntry("test.entity2", "2345", "hue") + entry1 = RegistryEntryWithDefaults( + entity_id="test.entity1", unique_id="1234", platform="hue" + ) + entry2 = RegistryEntryWithDefaults( + entity_id="test.entity2", unique_id="2345", platform="hue" + ) entities["test.entity1"] = entry1 entities["test.entity2"] = entry2 diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a8691771580..b8bc89e29d7 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -30,6 +30,7 @@ from homeassistant.helpers.event import ( TrackTemplate, TrackTemplateResult, async_call_later, + async_has_entity_registry_updated_listeners, async_track_device_registry_updated_event, async_track_entity_registry_updated_event, async_track_point_in_time, @@ -4682,12 +4683,17 @@ async def test_async_track_entity_registry_updated_event(hass: HomeAssistant) -> def run_callback(event): event_data.append(event.data) + assert async_has_entity_registry_updated_listeners(hass) is False + unsub1 = async_track_entity_registry_updated_event( hass, entity_id, run_callback, job_type=ha.HassJobType.Callback ) unsub2 = async_track_entity_registry_updated_event( hass, new_entity_id, run_callback ) + + assert async_has_entity_registry_updated_listeners(hass) is True + hass.bus.async_fire( EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entity_id} ) diff --git a/tests/helpers/test_floor_registry.py b/tests/helpers/test_floor_registry.py index 6a672399522..5ebd63ae302 100644 --- a/tests/helpers/test_floor_registry.py +++ b/tests/helpers/test_floor_registry.py @@ -327,7 +327,7 @@ async def test_loading_floors_from_storage( assert len(registry.floors) == 1 -async def test_getting_floor(floor_registry: fr.FloorRegistry) -> None: +async def test_getting_floor_by_name(floor_registry: fr.FloorRegistry) -> None: """Make sure we can get the floors by name.""" floor = floor_registry.async_create("First floor") floor2 = floor_registry.async_get_floor_by_name("first floor") @@ -341,6 +341,27 @@ async def test_getting_floor(floor_registry: fr.FloorRegistry) -> None: assert get_floor == floor +async def test_async_get_floors_by_alias( + floor_registry: fr.FloorRegistry, +) -> None: + """Make sure we can get the floors by alias.""" + floor1 = floor_registry.async_create("First floor", aliases=("alias_1", "alias_2")) + floor2 = floor_registry.async_create("Second floor", aliases=("alias_1", "alias_3")) + + alias1_list = floor_registry.async_get_floors_by_alias("A l i a s_1") + alias2_list = floor_registry.async_get_floors_by_alias("A l i a s_2") + alias3_list = floor_registry.async_get_floors_by_alias("A l i a s_3") + + assert len(alias1_list) == 2 + assert len(alias2_list) == 1 + assert len(alias3_list) == 1 + + assert floor1 in alias1_list + assert floor1 in alias2_list + assert floor2 in alias1_list + assert floor2 in alias3_list + + async def test_async_get_floor_by_name_not_found( floor_registry: fr.FloorRegistry, ) -> None: diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index fb98111fd42..e99db76dcbc 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -1,15 +1,17 @@ """Test the frame helper.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from typing import Any from unittest.mock import ANY, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import frame from homeassistant.loader import async_get_integration -from tests.common import extract_stack_to_frame +from tests.common import MockModule, extract_stack_to_frame, mock_integration async def test_extract_frame_integration( @@ -21,7 +23,7 @@ async def test_extract_frame_integration( custom_integration=False, frame=mock_integration_frame, integration="hue", - module=None, + module="homeassistant.components.hue.light", relative_filename="homeassistant/components/hue/light.py", ) @@ -34,8 +36,8 @@ async def test_get_integration_logger( assert logger.name == "homeassistant.components.hue" -@pytest.mark.usefixtures("enable_custom_integrations") -async def test_extract_frame_resolve_module(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("enable_custom_integrations", "hass") +async def test_extract_frame_resolve_module() -> None: """Test extracting the current frame from integration context.""" # pylint: disable-next=import-outside-toplevel from custom_components.test_integration_frame import call_get_integration_frame @@ -51,8 +53,8 @@ async def test_extract_frame_resolve_module(hass: HomeAssistant) -> None: ) -@pytest.mark.usefixtures("enable_custom_integrations") -async def test_get_integration_logger_resolve_module(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("enable_custom_integrations", "hass") +async def test_get_integration_logger_resolve_module() -> None: """Test getting the logger from integration context.""" # pylint: disable-next=import-outside-toplevel from custom_components.test_integration_frame import call_get_integration_logger @@ -159,99 +161,224 @@ async def test_get_integration_logger_no_integration( @pytest.mark.parametrize( - ("integration_frame_path", "keywords", "expected_error", "expected_log"), + ("integration_frame_path", "keywords", "expected_result", "expected_log"), [ pytest.param( "homeassistant/test_core", {}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 0, id="core default", ), pytest.param( "homeassistant/components/test_core_integration", {}, - False, + does_not_raise(), 1, id="core integration default", ), pytest.param( "custom_components/test_custom_integration", {}, - False, + does_not_raise(), 1, id="custom integration default", ), pytest.param( "custom_components/test_custom_integration", {"custom_integration_behavior": frame.ReportBehavior.IGNORE}, - False, + does_not_raise(), 0, id="custom integration ignore", ), pytest.param( "custom_components/test_custom_integration", {"custom_integration_behavior": frame.ReportBehavior.ERROR}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 1, id="custom integration error", ), pytest.param( "homeassistant/components/test_integration_frame", {"core_integration_behavior": frame.ReportBehavior.IGNORE}, - False, + does_not_raise(), 0, id="core_integration_behavior ignore", ), pytest.param( "homeassistant/components/test_integration_frame", {"core_integration_behavior": frame.ReportBehavior.ERROR}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 1, id="core_integration_behavior error", ), pytest.param( "homeassistant/test_integration_frame", {"core_behavior": frame.ReportBehavior.IGNORE}, - False, + does_not_raise(), 0, id="core_behavior ignore", ), pytest.param( "homeassistant/test_integration_frame", {"core_behavior": frame.ReportBehavior.LOG}, - False, + does_not_raise(), 1, id="core_behavior log", ), ], ) -@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_report_usage( caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, keywords: dict[str, Any], - expected_error: bool, + expected_result: AbstractContextManager, expected_log: int, ) -> None: - """Test report.""" + """Test report_usage. + + Note: This test doesn't set up mock integrations, so it will not + find the correct issue tracker URL, and we don't check for that. + """ what = "test_report_string" - errored = False - try: - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): - frame.report_usage(what, **keywords) - except RuntimeError: - errored = True - - assert errored == expected_error + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expected_result: + frame.report_usage(what, **keywords) assert caplog.text.count(what) == expected_log + reports = [ + rec.message for rec in caplog.records if rec.message.startswith("Detected") + ] + assert reports == snapshot -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_report_usage_no_hass() -> None: + """Test report_usage when frame helper is not set up.""" + + with pytest.raises(RuntimeError, match="Frame helper not set up"): + frame.report_usage("blablabla") + + +@pytest.mark.parametrize( + "integration_frame_path", + [ + pytest.param( + "homeassistant/test_core", + id="core", + ), + pytest.param( + "homeassistant/components/test_core_integration", + id="core integration", + ), + pytest.param( + "custom_components/test_custom_integration", + id="custom integration", + ), + pytest.param( + "custom_components/unknown_custom_integration", + id="unknown custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_report_usage_find_issue_tracker( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test report_usage finds the correct issue tracker. + + Note: The issue tracker is found by loader.async_suggest_report_issue, this + test is a sanity check to ensure async_suggest_report_issue is given the + right parameters. + """ + + what = "test_report_string" + mock_integration(hass, MockModule("test_core_integration")) + mock_integration( + hass, + MockModule( + "test_custom_integration", + partial_manifest={"issue_tracker": "https://blablabla.com"}, + ), + built_in=False, + ) + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) + + assert caplog.text.count(what) == 1 + reports = [ + rec.message for rec in caplog.records if rec.message.startswith("Detected") + ] + assert reports == snapshot + + +@pytest.mark.parametrize( + "integration_frame_path", + [ + pytest.param( + "homeassistant/test_core", + id="core", + ), + pytest.param( + "homeassistant/components/test_core_integration", + id="core integration", + ), + pytest.param( + "custom_components/test_custom_integration", + id="custom integration", + ), + pytest.param( + "custom_components/unknown_custom_integration", + id="unknown custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_report_usage_find_issue_tracker_other_thread( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test report_usage finds the correct issue tracker. + + In this test, we run the report_usage in a separate thread. + + Note: The issue tracker is found by loader.async_suggest_report_issue, this + test is a sanity check to ensure async_suggest_report_issue is given the + right parameters. + """ + + what = "test_report_string" + mock_integration(hass, MockModule("test_core_integration")) + mock_integration( + hass, + MockModule( + "test_custom_integration", + partial_manifest={"issue_tracker": "https://blablabla.com"}, + ), + built_in=False, + ) + + def sync_job() -> None: + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) + + await hass.async_add_executor_job(sync_job) + + assert caplog.text.count(what) == 1 + reports = [ + rec.message for rec in caplog.records if rec.message.startswith("Detected") + ] + assert reports == snapshot + + +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_prevent_flooding( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock + caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock ) -> None: """Test to ensure a report is only written once to the log.""" @@ -267,22 +394,22 @@ async def test_prevent_flooding( f"q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+{integration}%22" ) - frame.report(what, error_if_core=False) + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) assert expected_message in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1 caplog.clear() - frame.report(what, error_if_core=False) + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) assert expected_message not in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1 -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_breaks_in_ha_version( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock + caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock ) -> None: """Test to ensure a report is only written once to the log.""" @@ -302,6 +429,7 @@ async def test_breaks_in_ha_version( assert expected_message in caplog.text +@pytest.mark.usefixtures("hass") async def test_report_missing_integration_frame( caplog: pytest.LogCaptureFixture, ) -> None: @@ -312,19 +440,20 @@ async def test_report_missing_integration_frame( "homeassistant.helpers.frame.get_integration_frame", side_effect=frame.MissingIntegrationFrame, ): - frame.report(what, error_if_core=False) + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) assert what in caplog.text assert caplog.text.count(what) == 1 caplog.clear() - frame.report(what, error_if_core=False, log_custom_component_only=True) + frame.report_usage(what, core_behavior=frame.ReportBehavior.IGNORE) assert caplog.text == "" @pytest.mark.parametrize("run_count", [1, 2]) # Run this twice to make sure the flood check does not # kick in when error_if_integration=True +@pytest.mark.usefixtures("hass") async def test_report_error_if_integration( caplog: pytest.LogCaptureFixture, run_count: int ) -> None: @@ -361,99 +490,24 @@ async def test_report_error_if_integration( ), ), ): - frame.report("did a bad thing", error_if_integration=True) + frame.report_usage( + "did a bad thing", core_integration_behavior=frame.ReportBehavior.ERROR + ) @pytest.mark.parametrize( - ("integration_frame_path", "keywords", "expected_error", "expected_log"), - [ - pytest.param( - "homeassistant/test_core", - {}, - True, - 0, - id="core default", - ), - pytest.param( - "homeassistant/components/test_core_integration", - {}, - False, - 1, - id="core integration default", - ), - pytest.param( - "custom_components/test_custom_integration", - {}, - False, - 1, - id="custom integration default", - ), - pytest.param( - "custom_components/test_integration_frame", - {"log_custom_component_only": True}, - False, - 1, - id="log_custom_component_only with custom integration", - ), - pytest.param( - "homeassistant/components/test_integration_frame", - {"log_custom_component_only": True}, - False, - 0, - id="log_custom_component_only with core integration", - ), - pytest.param( - "homeassistant/test_integration_frame", - {"error_if_core": False}, - False, - 1, - id="disable error_if_core", - ), - pytest.param( - "custom_components/test_integration_frame", - {"error_if_integration": True}, - True, - 1, - id="error_if_integration with custom integration", - ), - pytest.param( - "homeassistant/components/test_integration_frame", - {"error_if_integration": True}, - True, - 1, - id="error_if_integration with core integration", - ), - ], -) -@pytest.mark.usefixtures("mock_integration_frame") -async def test_report( - caplog: pytest.LogCaptureFixture, - keywords: dict[str, Any], - expected_error: bool, - expected_log: int, -) -> None: - """Test report.""" - - what = "test_report_string" - - errored = False - try: - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): - frame.report(what, **keywords) - except RuntimeError: - errored = True - - assert errored == expected_error - - assert caplog.text.count(what) == expected_log - - -@pytest.mark.parametrize( - ("behavior", "integration_domain", "source", "logs_again"), + ( + "behavior", + "integration_domain", + "integration_frame_path", + "source", + "logs_again", + ), [ pytest.param( "core_behavior", None, + "homeassistant", "code that", True, id="core", @@ -461,6 +515,7 @@ async def test_report( pytest.param( "core_behavior", "unknown_integration", + "homeassistant", "code that", True, id="unknown integration", @@ -468,6 +523,7 @@ async def test_report( pytest.param( "core_integration_behavior", "sensor", + "homeassistant", "that integration 'sensor'", False, id="core integration", @@ -475,13 +531,32 @@ async def test_report( pytest.param( "custom_integration_behavior", "test_package", + "homeassistant", "that custom integration 'test_package'", False, id="custom integration", ), + # Assert integration_domain has priority over integration found in stack frame + pytest.param( + "core_integration_behavior", + "sensor", + "homeassistant/components/hue", + "that integration 'sensor'", + False, + id="core integration stack mismatch", + ), + # Assert integration_domain has priority over integration found in stack frame + pytest.param( + "custom_integration_behavior", + "test_package", + "custom_components/hue", + "that custom integration 'test_package'", + False, + id="custom integration stack mismatch", + ), ], ) -@pytest.mark.usefixtures("enable_custom_integrations") +@pytest.mark.usefixtures("enable_custom_integrations", "mock_integration_frame") async def test_report_integration_domain( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -490,7 +565,7 @@ async def test_report_integration_domain( source: str, logs_again: bool, ) -> None: - """Test report.""" + """Test report_usage when integration_domain is specified.""" await async_get_integration(hass, "sensor") await async_get_integration(hass, "test_package") diff --git a/tests/helpers/test_httpx_client.py b/tests/helpers/test_httpx_client.py index 4b9f2fa2bf6..c3b9c1f9de8 100644 --- a/tests/helpers/test_httpx_client.py +++ b/tests/helpers/test_httpx_client.py @@ -100,7 +100,6 @@ async def test_get_async_client_context_manager(hass: HomeAssistant) -> None: assert mock_aclose.call_count == 0 -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -144,7 +143,6 @@ async def test_warning_close_session_integration( ) in caplog.text -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_custom( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index bf0df305c35..aebd989c237 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -6,14 +6,14 @@ from unittest.mock import MagicMock, patch import pytest import voluptuous as vol -from homeassistant.components import conversation, light, switch +from homeassistant.components import light, switch from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, ) -from homeassistant.core import Context, HomeAssistant, State +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import ( area_registry as ar, config_validation as cv, @@ -615,25 +615,6 @@ def test_async_validate_slots_no_schema() -> None: } -async def test_cant_turn_on_lock(hass: HomeAssistant) -> None: - """Test that we can't turn on entities that don't support it.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) - assert await async_setup_component(hass, "intent", {}) - assert await async_setup_component(hass, "lock", {}) - - hass.states.async_set( - "lock.test", "123", attributes={ATTR_FRIENDLY_NAME: "Test Lock"} - ) - - result = await conversation.async_converse( - hass, "turn on test lock", None, Context(), None - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS - - def test_async_register(hass: HomeAssistant) -> None: """Test registering an intent and verifying it is stored correctly.""" handler = MagicMock() diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 630ed3f4fa1..1a9225c505b 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest import voluptuous as vol -from homeassistant.components import calendar +from homeassistant.components import calendar, todo 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 @@ -25,6 +25,7 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonObjectType from tests.common import MockConfigEntry, async_mock_service @@ -45,9 +46,12 @@ def llm_context() -> llm.LLMContext: class MyAPI(llm.API): """Test API.""" + prompt: str = "" + tools: list[llm.Tool] = [] + async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: """Return a list of tools.""" - return llm.APIInstance(self, "", [], llm_context) + return llm.APIInstance(self, self.prompt, llm_context, self.tools) async def test_get_api_no_existing( @@ -181,19 +185,19 @@ async def test_assist_api( assert len(llm.async_get_apis(hass)) == 1 api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 0 + assert [tool.name for tool in api.tools] == ["GetLiveContext"] # Match all intent_handler.platforms = None api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 1 + assert [tool.name for tool in api.tools] == ["test_intent", "GetLiveContext"] # Match specific domain intent_handler.platforms = {"light"} api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 1 + assert len(api.tools) == 2 tool = api.tools[0] assert tool.name == "test_intent" assert tool.description == "Execute Home Assistant test_intent intent" @@ -575,7 +579,11 @@ async def test_assist_api_prompt( suggested_area="Test Area 2", ) ) - exposed_entities_prompt = """An overview of the areas and the devices in this smart home: + exposed_entities_prompt = """Live Context: An overview of the areas and the devices in this smart home: +- names: '1' + domain: light + state: unavailable + areas: Test Area 2 - names: Kitchen domain: light state: 'on' @@ -590,18 +598,6 @@ async def test_assist_api_prompt( domain: light state: unavailable areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name - names: Test Device 2 domain: light state: unavailable @@ -614,13 +610,55 @@ async def test_assist_api_prompt( domain: light state: unavailable areas: Test Area 2 +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name - names: Unnamed Device domain: light state: unavailable areas: Test Area 2 +""" + stateless_exposed_entities_prompt = """Static Context: An overview of the areas and the devices in this smart home: - names: '1' domain: light - state: unavailable + areas: Test Area 2 +- names: Kitchen + domain: light +- names: Living Room + domain: light + areas: Test Area, Alternative name +- names: Test Device, my test light + domain: light + areas: Test Area, Alternative name +- names: Test Device 2 + domain: light + areas: Test Area 2 +- names: Test Device 3 + domain: light + areas: Test Area 2 +- names: Test Device 4 + domain: light + areas: Test Area 2 +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Unnamed Device + domain: light areas: Test Area 2 """ first_part_prompt = ( @@ -635,14 +673,36 @@ async def test_assist_api_prompt( "When a user asks to turn on all devices of a specific type, " "ask user to specify an area, unless there is only one device of that type." ) + dynamic_context_prompt = """You ARE equipped to answer questions about the current state of +the home using the `GetLiveContext` tool. This is a primary function. Do not state you lack the +functionality if the question requires live data. +If the user asks about device existence/type (e.g., "Do I have lights in the bedroom?"): Answer +from the static context below. +If the user asks about the CURRENT state, value, or mode (e.g., "Is the lock locked?", +"Is the fan on?", "What mode is the thermostat in?", "What is the temperature outside?"): + 1. Recognize this requires live data. + 2. You MUST call `GetLiveContext`. This tool will provide the needed real-time information (like temperature from the local weather, lock status, etc.). + 3. Use the tool's response** to answer the user accurately (e.g., "The temperature outside is [value from tool]."). +For general knowledge questions not about the home: Answer truthfully from internal knowledge. +""" api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{dynamic_context_prompt} +{stateless_exposed_entities_prompt}""" ) + # Verify that the GetLiveContext tool returns the same results as the exposed_entities_prompt + result = await api.async_call_tool( + llm.ToolInput(tool_name="GetLiveContext", tool_args={}) + ) + assert result == { + "success": True, + "result": exposed_entities_prompt, + } + # Fake that request is made from a specific device ID with an area llm_context.device_id = device.id area_prompt = ( @@ -654,7 +714,8 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{dynamic_context_prompt} +{stateless_exposed_entities_prompt}""" ) # Add floor @@ -669,7 +730,8 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{dynamic_context_prompt} +{stateless_exposed_entities_prompt}""" ) # Register device for timers @@ -680,7 +742,8 @@ async def test_assist_api_prompt( assert api.api_prompt == ( f"""{first_part_prompt} {area_prompt} -{exposed_entities_prompt}""" +{dynamic_context_prompt} +{stateless_exposed_entities_prompt}""" ) @@ -1267,3 +1330,185 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: "start_date_time": now, "end_date_time": dt_util.start_of_local_day() + timedelta(days=7), } + + +async def test_todo_get_items_tool(hass: HomeAssistant) -> None: + """Test the todo get items tool.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "todo", {}) + hass.states.async_set( + "todo.test_list", "0", {"friendly_name": "Mock Todo List Name"} + ) + async_expose_entity(hass, "conversation", "todo.test_list", 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) + tool = next((tool for tool in api.tools if tool.name == "todo_get_items"), None) + assert tool is not None + assert tool.parameters.schema["todo_list"].container == ["Mock Todo List Name"] + + calls = async_mock_service( + hass, + domain=todo.DOMAIN, + service=todo.TodoServices.GET_ITEMS, + schema=cv.make_entity_service_schema(todo.TODO_SERVICE_GET_ITEMS_SCHEMA), + response={ + "todo.test_list": { + "items": [ + { + "uid": "1234", + "summary": "Buy milk", + "status": "needs_action", + }, + { + "uid": "5678", + "summary": "Call mom", + "status": "needs_action", + "due": "2025-09-17", + "description": "Remember birthday", + }, + ] + } + }, + ) + + # Test without status filter (defaults to needs_action) + result = await tool.async_call( + hass, + llm.ToolInput("todo_get_items", {"todo_list": "Mock Todo List Name"}), + llm_context, + ) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": ["todo.test_list"], + "status": ["needs_action"], + } + assert result == { + "success": True, + "result": [ + { + "uid": "1234", + "status": "needs_action", + "summary": "Buy milk", + }, + { + "uid": "5678", + "status": "needs_action", + "summary": "Call mom", + "due": "2025-09-17", + "description": "Remember birthday", + }, + ], + } + + # Test that the status filter is passed correctly to the service call. + # We don't assert on the response since it is fixed above. + calls.clear() + result = await tool.async_call( + hass, + llm.ToolInput( + "todo_get_items", + {"todo_list": "Mock Todo List Name", "status": "completed"}, + ), + llm_context, + ) + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": ["todo.test_list"], + "status": ["completed"], + } + + # Test that the status filter is passed correctly to the service call. + # We don't assert on the response since it is fixed above. + calls.clear() + result = await tool.async_call( + hass, + llm.ToolInput( + "todo_get_items", + {"todo_list": "Mock Todo List Name", "status": "all"}, + ), + llm_context, + ) + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": ["todo.test_list"], + "status": ["needs_action", "completed"], + } + + +async def test_no_tools_exposed(hass: HomeAssistant) -> None: + """Test that tools are not exposed when no entities are exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) + 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 api.tools == [] + + +async def test_merged_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test an API instance that merges multiple llm apis.""" + + class MyTool(llm.Tool): + def __init__(self, name: str, description: str) -> None: + self.name = name + self.description = description + + async def async_call( + self, hass: HomeAssistant, tool_input: llm.ToolInput, _: llm.LLMContext + ) -> JsonObjectType: + return {"result": {tool_input.tool_name: tool_input.tool_args}} + + api1 = MyAPI(hass=hass, id="api-1", name="API 1") + api1.prompt = "This is prompt 1" + api1.tools = [MyTool(name="Tool_1", description="Description 1")] + llm.async_register_api(hass, api1) + + api2 = MyAPI(hass=hass, id="api-2", name="API 2") + api2.prompt = "This is prompt 2" + api2.tools = [MyTool(name="Tool_2", description="Description 2")] + llm.async_register_api(hass, api2) + + instance = await llm.async_get_api(hass, ["api-1", "api-2"], llm_context) + assert instance.api.id == "api-1|api-2" + + assert ( + instance.api_prompt + == """Follow these instructions for tools from "api-1": +This is prompt 1 + +Follow these instructions for tools from "api-2": +This is prompt 2 + +""" + ) + assert [(tool.name, tool.description) for tool in instance.tools] == [ + ("api-1.Tool_1", "Description 1"), + ("api-2.Tool_2", "Description 2"), + ] + + # The test tool returns back the provided arguments so we can verify + # the original tool is invoked with the correct tool name and args. + result = await instance.async_call_tool( + llm.ToolInput(tool_name="api-1.Tool_1", tool_args={"arg1": "value1"}) + ) + assert result == {"result": {"Tool_1": {"arg1": "value1"}}} + + result = await instance.async_call_tool( + llm.ToolInput(tool_name="api-2.Tool_2", tool_args={"arg2": "value2"}) + ) + assert result == {"result": {"Tool_2": {"arg2": "value2"}}} diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 3064b215f2f..46d84ea768d 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -538,7 +538,7 @@ async def test_get_url(hass: HomeAssistant) -> None: "homeassistant.helpers.network._get_request_host", return_value="example.com", ), - patch("homeassistant.components.http.current_request"), + patch("homeassistant.helpers.http.current_request"), ): assert get_url(hass, require_current_request=True) == "https://example.com" assert ( @@ -554,7 +554,7 @@ async def test_get_url(hass: HomeAssistant) -> None: "homeassistant.helpers.network._get_request_host", return_value="example.local", ), - patch("homeassistant.components.http.current_request"), + patch("homeassistant.helpers.http.current_request"), ): assert get_url(hass, require_current_request=True) == "http://example.local" @@ -592,7 +592,7 @@ async def test_get_request_host_with_port(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy( CIMultiDict({hdrs.HOST: "example.com:8123"}) @@ -609,7 +609,7 @@ async def test_get_request_host_without_port(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "example.com"})) mock_request.url = URL("http://example.com/test/request") @@ -624,7 +624,7 @@ async def test_get_request_ipv6_address(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "[::1]:8123"})) mock_request.url = URL("http://[::1]:8123/test/request") @@ -639,7 +639,7 @@ async def test_get_request_ipv6_address_without_port(hass: HomeAssistant) -> Non with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict({hdrs.HOST: "[::1]"})) mock_request.url = URL("http://[::1]/test/request") @@ -654,7 +654,7 @@ async def test_get_request_host_no_host_header(hass: HomeAssistant) -> None: with pytest.raises(NoURLAvailableError): _get_request_host() - with patch("homeassistant.components.http.current_request") as mock_request_context: + with patch("homeassistant.helpers.http.current_request") as mock_request_context: mock_request = Mock() mock_request.headers = CIMultiDictProxy(CIMultiDict()) mock_request.url = URL("/test/request") diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index df589a41daa..4a50cb9399f 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -494,7 +494,7 @@ async def test_calling_service_response_data_in_scopes(hass: HomeAssistant) -> N assert result.variables["my_response"] == expected_var expected_trace = { - "0": [{"variables": {"my_response": expected_var}}], + "0": [{"variables": {"my_response": expected_var, "state": "off"}}], "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/1": [ { @@ -1797,7 +1797,7 @@ async def test_wait_in_sequence(hass: HomeAssistant) -> None: assert result.variables["wait"] == expected_var expected_trace = { - "0": [{"variables": {"wait": expected_var}}], + "0": [{"variables": {"wait": expected_var, "state": "off"}}], "0/sequence/0": [{"variables": {"state": "off"}}], "0/sequence/1": [ { @@ -1840,7 +1840,7 @@ async def test_wait_in_parallel(hass: HomeAssistant) -> None: assert "wait" not in result.variables expected_trace = { - "0": [{}], + "0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/1": [ { @@ -5277,11 +5277,23 @@ async def test_set_variable( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test setting variables in scripts.""" - alias = "variables step" sequence = cv.SCRIPT_SCHEMA( [ - {"alias": alias, "variables": {"variable": "value"}}, - {"action": "test.script", "data": {"value": "{{ variable }}"}}, + {"alias": "variables", "variables": {"x": 1, "y": 1}}, + { + "alias": "scope", + "sequence": [ + {"variables": {"y": 3, "z": 3}}, + { + "action": "test.script", + "data": {"value": "x={{ x }}, y={{ y }}, z={{ z }}"}, + }, + ], + }, + { + "action": "test.script", + "data": {"value": "x={{ x }}, y={{ y }}, z={{ z }}"}, + }, ] ) script_obj = script.Script(hass, sequence, "test script", "test_domain") @@ -5291,18 +5303,36 @@ async def test_set_variable( await script_obj.async_run(context=Context()) await hass.async_block_till_done() - assert mock_calls[0].data["value"] == "value" - assert f"Executing step {alias}" in caplog.text + assert len(mock_calls) == 2 + assert mock_calls[0].data["value"] == "x=1, y=3, z=3" + assert mock_calls[1].data["value"] == "x=1, y=3, z=3" + + assert "Executing step variables" in caplog.text expected_trace = { - "0": [{"variables": {"variable": "value"}}], - "1": [ + "0": [{"variables": {"x": 1, "y": 1}}], + "1": [{"variables": {"y": 3, "z": 3}}], + "1/sequence/0": [{"variables": {"y": 3, "z": 3}}], + "1/sequence/1": [ { "result": { "params": { "domain": "test", "service": "script", - "service_data": {"value": "value"}, + "service_data": {"value": "x=1, y=3, z=3"}, + "target": {}, + }, + "running_script": False, + }, + } + ], + "2": [ + { + "result": { + "params": { + "domain": "test", + "service": "script", + "service_data": {"value": "x=1, y=3, z=3"}, "target": {}, }, "running_script": False, @@ -5823,14 +5853,16 @@ async def test_stop_action_subscript( ) +@pytest.mark.parametrize(("var", "response"), [(1, "If: Then"), (2, "Testing 123")]) @pytest.mark.parametrize( - ("var", "response"), - [(1, "If: Then"), (2, "Testing 123")], + ("script_mode", "max_runs"), [("single", 1), ("parallel", 2), ("queued", 2)] ) async def test_stop_action_response_variables( hass: HomeAssistant, var: int, response: str, + script_mode, + max_runs, ) -> None: """Test setting stop response_variable in a subscript.""" sequence = cv.SCRIPT_SCHEMA( @@ -5849,7 +5881,14 @@ async def test_stop_action_response_variables( {"stop": "In the name of love", "response_variable": "output"}, ] ) - script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + script_obj = script.Script( + hass, + sequence, + "Test Name", + "test_domain", + script_mode=script_mode, + max_runs=max_runs, + ) run_vars = MappingProxyType({"var": var}) result = await script_obj.async_run(run_vars, context=Context()) @@ -5899,7 +5938,9 @@ async def test_stop_action_nested_response_variables( "variables": {"var": var, "output": {"value": "Testing 123"}}, } ], - "1": [{"result": {"choice": choice}}], + "1": [ + {"result": {"choice": choice}, "variables": {"output": {"value": response}}} + ], "1/if": [{"result": {"result": if_result}}], "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], f"1/{choice}/0": [{"variables": {"output": {"value": response}}}], @@ -6617,3 +6658,41 @@ async def test_calling_service_backwards_compatible( ], } ) + + +async def test_enabled_sequence_in_parallel( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test to ensure sequence inside parallel follows enabled tag.""" + event = "test_event" + events = async_capture_events(hass, event) + sequence = cv.SCRIPT_SCHEMA( + { + "parallel": [ + { + "sequence": [{"event": event, "event_data": {"value": "disabled"}}], + "enabled": "false", + }, + { + "sequence": [{"event": event, "event_data": {"value": "enabled"}}], + "enabled": "true", + }, + ] + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data["value"] == "enabled" + + expected_trace = { + "0": [{"result": {"enabled": False}}], + "0/parallel/1/sequence/0": [ + {"result": {"event": "test_event", "event_data": {"value": "enabled"}}} + ], + } + assert_action_trace(expected_trace) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index d07bb7458e9..3ddbecaf48d 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -88,6 +88,7 @@ def _test_selector( ({"integration": "zha"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf"}, ("abc123",), (None,)), ({"model": "mock-model"}, ("abc123",), (None,)), + ({"model_id": "mock-model_id"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf", "model": "mock-model"}, ("abc123",), (None,)), ( {"integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model"}, @@ -979,6 +980,7 @@ def test_datetime_selector_schema(schema, valid_selections, invalid_selections) ("schema", "valid_selections", "invalid_selections"), [({}, ("abc123", "{{ now() }}"), (None, "{{ incomplete }", "{% if True %}Hi!"))], ) +@pytest.mark.usefixtures("hass") def test_template_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test template selector.""" _test_selector("template", schema, valid_selections, invalid_selections) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 142f7a23f81..38e7e1ae452 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -32,6 +32,7 @@ from homeassistant.core import ( HassJob, HomeAssistant, ServiceCall, + ServiceResponse, SupportsResponse, ) from homeassistant.helpers import ( @@ -49,6 +50,7 @@ from tests.common import ( MockEntity, MockModule, MockUser, + RegistryEntryWithDefaults, async_mock_service, mock_area_registry, mock_device_registry, @@ -158,94 +160,94 @@ def floor_area_mock(hass: HomeAssistant) -> None: }, ) - entity_in_own_area = er.RegistryEntry( + entity_in_own_area = RegistryEntryWithDefaults( entity_id="light.in_own_area", unique_id="in-own-area-id", platform="test", area_id="own-area", ) - config_entity_in_own_area = er.RegistryEntry( + config_entity_in_own_area = RegistryEntryWithDefaults( entity_id="light.config_in_own_area", unique_id="config-in-own-area-id", platform="test", area_id="own-area", entity_category=EntityCategory.CONFIG, ) - hidden_entity_in_own_area = er.RegistryEntry( + hidden_entity_in_own_area = RegistryEntryWithDefaults( entity_id="light.hidden_in_own_area", unique_id="hidden-in-own-area-id", platform="test", area_id="own-area", hidden_by=er.RegistryEntryHider.USER, ) - entity_in_area = er.RegistryEntry( + entity_in_area = RegistryEntryWithDefaults( entity_id="light.in_area", unique_id="in-area-id", platform="test", device_id=device_in_area.id, ) - config_entity_in_area = er.RegistryEntry( + config_entity_in_area = RegistryEntryWithDefaults( entity_id="light.config_in_area", unique_id="config-in-area-id", platform="test", device_id=device_in_area.id, entity_category=EntityCategory.CONFIG, ) - hidden_entity_in_area = er.RegistryEntry( + hidden_entity_in_area = RegistryEntryWithDefaults( entity_id="light.hidden_in_area", unique_id="hidden-in-area-id", platform="test", device_id=device_in_area.id, hidden_by=er.RegistryEntryHider.USER, ) - entity_in_other_area = er.RegistryEntry( + entity_in_other_area = RegistryEntryWithDefaults( entity_id="light.in_other_area", unique_id="in-area-a-id", platform="test", device_id=device_in_area.id, area_id="other-area", ) - entity_assigned_to_area = er.RegistryEntry( + entity_assigned_to_area = RegistryEntryWithDefaults( entity_id="light.assigned_to_area", unique_id="assigned-area-id", platform="test", device_id=device_in_area.id, area_id="test-area", ) - entity_no_area = er.RegistryEntry( + entity_no_area = RegistryEntryWithDefaults( entity_id="light.no_area", unique_id="no-area-id", platform="test", device_id=device_no_area.id, ) - config_entity_no_area = er.RegistryEntry( + config_entity_no_area = RegistryEntryWithDefaults( entity_id="light.config_no_area", unique_id="config-no-area-id", platform="test", device_id=device_no_area.id, entity_category=EntityCategory.CONFIG, ) - hidden_entity_no_area = er.RegistryEntry( + hidden_entity_no_area = RegistryEntryWithDefaults( entity_id="light.hidden_no_area", unique_id="hidden-no-area-id", platform="test", device_id=device_no_area.id, hidden_by=er.RegistryEntryHider.USER, ) - entity_diff_area = er.RegistryEntry( + entity_diff_area = RegistryEntryWithDefaults( entity_id="light.diff_area", unique_id="diff-area-id", platform="test", device_id=device_diff_area.id, ) - entity_in_area_a = er.RegistryEntry( + entity_in_area_a = RegistryEntryWithDefaults( entity_id="light.in_area_a", unique_id="in-area-a-id", platform="test", device_id=device_area_a.id, area_id="area-a", ) - entity_in_area_b = er.RegistryEntry( + entity_in_area_b = RegistryEntryWithDefaults( entity_id="light.in_area_b", unique_id="in-area-b-id", platform="test", @@ -329,53 +331,53 @@ def label_mock(hass: HomeAssistant) -> None: }, ) - entity_with_my_label = er.RegistryEntry( + entity_with_my_label = RegistryEntryWithDefaults( entity_id="light.with_my_label", unique_id="with_my_label", platform="test", labels={"my-label"}, ) - hidden_entity_with_my_label = er.RegistryEntry( + hidden_entity_with_my_label = RegistryEntryWithDefaults( entity_id="light.hidden_with_my_label", unique_id="hidden_with_my_label", platform="test", labels={"my-label"}, hidden_by=er.RegistryEntryHider.USER, ) - config_entity_with_my_label = er.RegistryEntry( + config_entity_with_my_label = RegistryEntryWithDefaults( entity_id="light.config_with_my_label", unique_id="config_with_my_label", platform="test", labels={"my-label"}, entity_category=EntityCategory.CONFIG, ) - entity_with_label1_from_device = er.RegistryEntry( + entity_with_label1_from_device = RegistryEntryWithDefaults( entity_id="light.with_label1_from_device", unique_id="with_label1_from_device", platform="test", device_id=device_has_label1.id, ) - entity_with_label1_from_device_and_different_area = er.RegistryEntry( + entity_with_label1_from_device_and_different_area = RegistryEntryWithDefaults( entity_id="light.with_label1_from_device_diff_area", unique_id="with_label1_from_device_diff_area", platform="test", device_id=device_has_label1.id, area_id=area_without_labels.id, ) - entity_with_label1_and_label2_from_device = er.RegistryEntry( + entity_with_label1_and_label2_from_device = RegistryEntryWithDefaults( entity_id="light.with_label1_and_label2_from_device", unique_id="with_label1_and_label2_from_device", platform="test", labels={"label1"}, device_id=device_has_label2.id, ) - entity_with_labels_from_device = er.RegistryEntry( + entity_with_labels_from_device = RegistryEntryWithDefaults( entity_id="light.with_labels_from_device", unique_id="with_labels_from_device", platform="test", device_id=device_has_labels.id, ) - entity_with_no_labels = er.RegistryEntry( + entity_with_no_labels = RegistryEntryWithDefaults( entity_id="light.no_labels", unique_id="no_labels", platform="test", @@ -961,7 +963,7 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None: side_effect=service._load_services_files, ) as proxy_load_services_files, patch( - "homeassistant.util.yaml.loader.load_yaml", + "annotatedyaml.loader.load_yaml", side_effect=load_yaml, ) as mock_load_yaml, ): @@ -1033,7 +1035,7 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: side_effect=service._load_services_files, ) as proxy_load_services_files, patch( - "homeassistant.util.yaml.loader.load_yaml", + "annotatedyaml.loader.load_yaml", side_effect=load_yaml, ) as mock_load_yaml, ): @@ -1647,6 +1649,33 @@ async def test_register_admin_service( assert calls[0].context.user_id == hass_admin_user.id +@pytest.mark.parametrize( + "supports_response", + [SupportsResponse.ONLY, SupportsResponse.OPTIONAL], +) +async def test_register_admin_service_return_response( + hass: HomeAssistant, supports_response: SupportsResponse +) -> None: + """Test the register admin service for a service that returns response data.""" + + async def mock_service(call: ServiceCall) -> ServiceResponse: + """Service handler coroutine.""" + assert call.return_response + return {"test-reply": "test-value1"} + + service.async_register_admin_service( + hass, "test", "test", mock_service, supports_response=supports_response + ) + result = await hass.services.async_call( + "test", + "test", + service_data={}, + blocking=True, + return_response=True, + ) + assert result == {"test-reply": "test-value1"} + + async def test_domain_control_not_async(hass: HomeAssistant, mock_entities) -> None: """Test domain verification in a service call with an unknown user.""" calls = [] @@ -1697,7 +1726,7 @@ async def test_domain_control_unauthorized( mock_registry( hass, { - "light.kitchen": er.RegistryEntry( + "light.kitchen": RegistryEntryWithDefaults( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", @@ -1738,7 +1767,7 @@ async def test_domain_control_admin( mock_registry( hass, { - "light.kitchen": er.RegistryEntry( + "light.kitchen": RegistryEntryWithDefaults( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", @@ -1776,7 +1805,7 @@ async def test_domain_control_no_user(hass: HomeAssistant) -> None: mock_registry( hass, { - "light.kitchen": er.RegistryEntry( + "light.kitchen": RegistryEntryWithDefaults( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b3a30806cbd..4de8d47cc16 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -15,7 +15,8 @@ from unittest.mock import patch from freezegun import freeze_time import orjson import pytest -from syrupy import SnapshotAssertion +from pytest_unordered import unordered +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant import config_entries @@ -149,6 +150,7 @@ async def test_template_render_info_collision(hass: HomeAssistant) -> None: template_obj.async_render_to_info() +@pytest.mark.usefixtures("hass") def test_template_equality() -> None: """Test template comparison and hashing.""" template_one = template.Template("{{ template_one }}") @@ -3885,6 +3887,66 @@ async def test_device_id( assert info.rate_limit is None +async def test_device_name( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test device_name function.""" + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + # Test non existing entity id + info = render_to_info(hass, "{{ device_name('sensor.fake') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing device id + info = render_to_info(hass, "{{ device_name('1234567890') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ device_name(56) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test device with single entity + 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")}, + name="A light", + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + info = render_to_info(hass, f"{{{{ device_name('{device_entry.id}') }}}}") + assert_result_info(info, device_entry.name) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ device_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, device_entry.name) + assert info.rate_limit is None + + # Test device after renaming + device_entry = device_registry.async_update_device( + device_entry.id, + name_by_user="My light", + ) + + info = render_to_info(hass, f"{{{{ device_name('{device_entry.id}') }}}}") + assert_result_info(info, device_entry.name_by_user) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ device_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, device_entry.name_by_user) + assert info.rate_limit is None + + async def test_device_attr( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -5166,6 +5228,7 @@ def test_iif(hass: HomeAssistant) -> None: assert tpl.async_render() == "no" +@pytest.mark.usefixtures("hass") async def test_cache_garbage_collection() -> None: """Test caching a template.""" template_string = ( @@ -5881,6 +5944,75 @@ async def test_floor_areas( assert info.rate_limit is None +async def test_floor_entities( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test floor_entities function.""" + + # Test non existing floor ID + info = render_to_info(hass, "{{ floor_entities('skyring') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'skyring' | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ floor_entities(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + floor = floor_registry.async_create("First floor") + area1 = area_registry.async_create("Living room") + area2 = area_registry.async_create("Dining room") + area_registry.async_update(area1.id, floor_id=floor.floor_id) + area_registry.async_update(area2.id, floor_id=floor.floor_id) + + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "living_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area1.id) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "dining_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area2.id) + + # Get entities by floor ID + expected = ["light.hue_living_room", "light.hue_dining_room"] + info = render_to_info(hass, f"{{{{ floor_entities('{floor.floor_id}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.floor_id}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + # Get entities by floor name + info = render_to_info(hass, f"{{{{ floor_entities('{floor.name}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.name}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + async def test_labels( hass: HomeAssistant, label_registry: lr.LabelRegistry, @@ -6601,3 +6733,384 @@ async def test_merge_response_not_mutate_original_object( tpl = template.Template(_template, hass) assert tpl.async_render() + + +def test_shuffle(hass: HomeAssistant) -> None: + """Test the shuffle function and filter.""" + assert list( + template.Template("{{ [1, 2, 3] | shuffle }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template("{{ shuffle([1, 2, 3]) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template("{{ shuffle(1, 2, 3) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list(template.Template("{{ shuffle([]) }}", hass).async_render()) == [] + + assert list(template.Template("{{ [] | shuffle }}", hass).async_render()) == [] + + # Testing using seed + assert list( + template.Template("{{ shuffle([1, 2, 3], 'seed') }}", hass).async_render() + ) == [2, 3, 1] + + assert list( + template.Template( + "{{ shuffle([1, 2, 3], seed='seed') }}", + hass, + ).async_render() + ) == [2, 3, 1] + + assert list( + template.Template( + "{{ [1, 2, 3] | shuffle('seed') }}", + hass, + ).async_render() + ) == [2, 3, 1] + + assert list( + template.Template( + "{{ [1, 2, 3] | shuffle(seed='seed') }}", + hass, + ).async_render() + ) == [2, 3, 1] + + with pytest.raises(TemplateError): + template.Template("{{ 1 | shuffle }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ shuffle() }}", hass).async_render() + + +def test_typeof(hass: HomeAssistant) -> None: + """Test the typeof debug filter/function.""" + assert template.Template("{{ True | typeof }}", hass).async_render() == "bool" + assert template.Template("{{ typeof(True) }}", hass).async_render() == "bool" + + assert template.Template("{{ [1, 2, 3] | typeof }}", hass).async_render() == "list" + assert template.Template("{{ typeof([1, 2, 3]) }}", hass).async_render() == "list" + + assert template.Template("{{ 1 | typeof }}", hass).async_render() == "int" + assert template.Template("{{ typeof(1) }}", hass).async_render() == "int" + + assert template.Template("{{ 1.1 | typeof }}", hass).async_render() == "float" + assert template.Template("{{ typeof(1.1) }}", hass).async_render() == "float" + + assert template.Template("{{ None | typeof }}", hass).async_render() == "NoneType" + assert template.Template("{{ typeof(None) }}", hass).async_render() == "NoneType" + + assert ( + template.Template("{{ 'Home Assistant' | typeof }}", hass).async_render() + == "str" + ) + assert ( + template.Template("{{ typeof('Home Assistant') }}", hass).async_render() + == "str" + ) + + +def test_flatten(hass: HomeAssistant) -> None: + """Test the flatten function and filter.""" + assert template.Template( + "{{ flatten([1, [2, [3]], 4, [5 , 6]]) }}", hass + ).async_render() == [1, 2, 3, 4, 5, 6] + + assert template.Template( + "{{ [1, [2, [3]], 4, [5 , 6]] | flatten }}", hass + ).async_render() == [1, 2, 3, 4, 5, 6] + + assert template.Template( + "{{ flatten([1, [2, [3]], 4, [5 , 6]], 1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template( + "{{ flatten([1, [2, [3]], 4, [5 , 6]], levels=1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template( + "{{ [1, [2, [3]], 4, [5 , 6]] | flatten(1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template( + "{{ [1, [2, [3]], 4, [5 , 6]] | flatten(levels=1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template("{{ flatten([]) }}", hass).async_render() == [] + + assert template.Template("{{ [] | flatten }}", hass).async_render() == [] + + with pytest.raises(TemplateError): + template.Template("{{ 'string' | flatten }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ flatten() }}", hass).async_render() + + +def test_intersect(hass: HomeAssistant) -> None: + """Test the intersect function and filter.""" + assert list( + template.Template( + "{{ intersect([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5]) + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | intersect([1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5]) + + assert list( + template.Template( + "{{ intersect(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["b", "c"]) + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | intersect(['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["b", "c"]) + + assert ( + template.Template("{{ intersect([], [1, 2, 3]) }}", hass).async_render() == [] + ) + + assert ( + template.Template("{{ [] | intersect([1, 2, 3]) }}", hass).async_render() == [] + ) + + with pytest.raises(TemplateError, match="intersect expected a list, got str"): + template.Template("{{ 'string' | intersect([1, 2, 3]) }}", hass).async_render() + + with pytest.raises(TemplateError, match="intersect expected a list, got str"): + template.Template("{{ [1, 2, 3] | intersect('string') }}", hass).async_render() + + +def test_difference(hass: HomeAssistant) -> None: + """Test the difference function and filter.""" + assert list( + template.Template( + "{{ difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == [10] + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | difference([1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == [10] + + assert list( + template.Template( + "{{ difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == ["a"] + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | difference(['b', 'c', 'd']) }}", hass + ).async_render() + ) == ["a"] + + assert ( + template.Template("{{ difference([], [1, 2, 3]) }}", hass).async_render() == [] + ) + + assert ( + template.Template("{{ [] | difference([1, 2, 3]) }}", hass).async_render() == [] + ) + + with pytest.raises(TemplateError, match="difference expected a list, got str"): + template.Template("{{ 'string' | difference([1, 2, 3]) }}", hass).async_render() + + with pytest.raises(TemplateError, match="difference expected a list, got str"): + template.Template("{{ [1, 2, 3] | difference('string') }}", hass).async_render() + + +def test_union(hass: HomeAssistant) -> None: + """Test the union function and filter.""" + assert list( + template.Template( + "{{ union([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5, 10, 11, 99]) + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | union([1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5, 10, 11, 99]) + + assert list( + template.Template( + "{{ union(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "b", "c", "d"]) + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | union(['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "b", "c", "d"]) + + assert list( + template.Template("{{ union([], [1, 2, 3]) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template("{{ [] | union([1, 2, 3]) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + with pytest.raises(TemplateError, match="union expected a list, got str"): + template.Template("{{ 'string' | union([1, 2, 3]) }}", hass).async_render() + + with pytest.raises(TemplateError, match="union expected a list, got str"): + template.Template("{{ [1, 2, 3] | union('string') }}", hass).async_render() + + +def test_symmetric_difference(hass: HomeAssistant) -> None: + """Test the symmetric_difference function and filter.""" + assert list( + template.Template( + "{{ symmetric_difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", + hass, + ).async_render() + ) == unordered([10, 11, 99]) + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | symmetric_difference([1, 2, 3, 4, 5, 11, 99]) }}", + hass, + ).async_render() + ) == unordered([10, 11, 99]) + + assert list( + template.Template( + "{{ symmetric_difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "d"]) + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | symmetric_difference(['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "d"]) + + assert list( + template.Template( + "{{ symmetric_difference([], [1, 2, 3]) }}", hass + ).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template( + "{{ [] | symmetric_difference([1, 2, 3]) }}", hass + ).async_render() + ) == unordered([1, 2, 3]) + + with pytest.raises( + TemplateError, match="symmetric_difference expected a list, got str" + ): + template.Template( + "{{ 'string' | symmetric_difference([1, 2, 3]) }}", hass + ).async_render() + + with pytest.raises( + TemplateError, match="symmetric_difference expected a list, got str" + ): + template.Template( + "{{ [1, 2, 3] | symmetric_difference('string') }}", hass + ).async_render() + + +def test_md5(hass: HomeAssistant) -> None: + """Test the md5 function and filter.""" + assert ( + template.Template("{{ md5('Home Assistant') }}", hass).async_render() + == "3d15e5c102c3413d0337393c3287e006" + ) + + assert ( + template.Template("{{ 'Home Assistant' | md5 }}", hass).async_render() + == "3d15e5c102c3413d0337393c3287e006" + ) + + +def test_sha1(hass: HomeAssistant) -> None: + """Test the sha1 function and filter.""" + assert ( + template.Template("{{ sha1('Home Assistant') }}", hass).async_render() + == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha1 }}", hass).async_render() + == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" + ) + + +def test_sha256(hass: HomeAssistant) -> None: + """Test the sha256 function and filter.""" + assert ( + template.Template("{{ sha256('Home Assistant') }}", hass).async_render() + == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha256 }}", hass).async_render() + == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" + ) + + +def test_sha512(hass: HomeAssistant) -> None: + """Test the sha512 function and filter.""" + assert ( + template.Template("{{ sha512('Home Assistant') }}", hass).async_render() + == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha512 }}", hass).async_render() + == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" + ) + + +def test_combine(hass: HomeAssistant) -> None: + """Test combine filter and function.""" + assert template.Template( + "{{ {'a': 1, 'b': 2} | combine({'b': 3, 'c': 4}) }}", hass + ).async_render() == {"a": 1, "b": 3, "c": 4} + + assert template.Template( + "{{ combine({'a': 1, 'b': 2}, {'b': 3, 'c': 4}) }}", hass + ).async_render() == {"a": 1, "b": 3, "c": 4} + + assert template.Template( + "{{ combine({'a': 1, 'b': {'x': 1}}, {'b': {'y': 2}, 'c': 4}, recursive=True) }}", + hass, + ).async_render() == {"a": 1, "b": {"x": 1, "y": 2}, "c": 4} + + # Test that recursive=False does not merge nested dictionaries + assert template.Template( + "{{ combine({'a': 1, 'b': {'x': 1}}, {'b': {'y': 2}, 'c': 4}, recursive=False) }}", + hass, + ).async_render() == {"a": 1, "b": {"y": 2}, "c": 4} + + # Test that None values are handled correctly in recursive merge + assert template.Template( + "{{ combine({'a': 1, 'b': none}, {'b': {'y': 2}, 'c': 4}, recursive=True) }}", + hass, + ).async_render() == {"a": 1, "b": {"y": 2}, "c": 4} + + with pytest.raises( + TemplateError, match="combine expected at least 1 argument, got 0" + ): + template.Template("{{ combine() }}", hass).async_render() + + with pytest.raises(TemplateError, match="combine expected a dict, got str"): + template.Template("{{ {'a': 1} | combine('not a dict') }}", hass).async_render() diff --git a/tests/helpers/test_trigger_template_entity.py b/tests/helpers/test_trigger_template_entity.py index a18827ecb4c..8389218054d 100644 --- a/tests/helpers/test_trigger_template_entity.py +++ b/tests/helpers/test_trigger_template_entity.py @@ -1,8 +1,82 @@ """Test template trigger entity.""" +from typing import Any + +import pytest + +from homeassistant.const import ( + CONF_ICON, + CONF_NAME, + CONF_STATE, + CONF_UNIQUE_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + CONF_ATTRIBUTES, + CONF_AVAILABILITY, + CONF_PICTURE, + ManualTriggerEntity, + ValueTemplate, +) + +_ICON_TEMPLATE = 'mdi:o{{ "n" if value=="on" else "ff" }}' +_PICTURE_TEMPLATE = '/local/picture_o{{ "n" if value=="on" else "ff" }}' + + +@pytest.mark.parametrize( + ("value", "test_template", "error_value", "expected", "error"), + [ + (1, "{{ value == 1 }}", None, "True", None), + (1, "1", None, "1", None), + ( + 1, + "{{ x - 4 }}", + None, + None, + "", + ), + ( + 1, + "{{ x - 4 }}", + template._SENTINEL, + template._SENTINEL, + "Error parsing value for test.entity: 'x' is undefined (value: 1, template: {{ x - 4 }})", + ), + ], +) +async def test_value_template_object( + hass: HomeAssistant, + value: Any, + test_template: str, + error_value: Any, + expected: Any, + error: str | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test ValueTemplate object.""" + entity = ManualTriggerEntity( + hass, + { + CONF_NAME: template.Template("test_entity", hass), + }, + ) + entity.entity_id = "test.entity" + + value_template = ValueTemplate.from_template(template.Template(test_template, hass)) + + variables = entity._template_variables_with_value(value) + result = value_template.async_render_as_value_template( + entity.entity_id, variables, error_value + ) + + assert result == expected + + if error is not None: + assert error in caplog.text async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: @@ -20,21 +94,197 @@ async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: entity = ManualTriggerEntity(hass, config) entity.entity_id = "test.entity" - hass.states.async_set("test.entity", "on") + hass.states.async_set("test.entity", STATE_ON) await entity.async_added_to_hass() - entity._process_manual_data("on") + variables = entity._template_variables_with_value(STATE_ON) + entity._process_manual_data(variables) await hass.async_block_till_done() assert entity.name == "test_entity" assert entity.icon == "mdi:on" assert entity.entity_picture == "/local/picture_on" - hass.states.async_set("test.entity", "off") + hass.states.async_set("test.entity", STATE_OFF) await entity.async_added_to_hass() - entity._process_manual_data("off") + + variables = entity._template_variables_with_value(STATE_OFF) + entity._process_manual_data(variables) await hass.async_block_till_done() assert entity.name == "test_entity" assert entity.icon == "mdi:off" assert entity.entity_picture == "/local/picture_off" + + +@pytest.mark.parametrize( + ("test_template", "test_entity_state", "expected"), + [ + ('{{ has_value("test.entity") }}', STATE_ON, True), + ('{{ has_value("test.entity") }}', STATE_OFF, True), + ('{{ has_value("test.entity") }}', STATE_UNKNOWN, False), + ('{{ "a" if has_value("test.entity") else "b" }}', STATE_ON, False), + ('{{ "something_not_boolean" }}', STATE_OFF, False), + ("{{ 1 }}", STATE_OFF, True), + ("{{ 0 }}", STATE_OFF, False), + ], +) +async def test_trigger_template_availability( + hass: HomeAssistant, + test_template: str, + test_entity_state: str, + expected: bool, +) -> None: + """Test manual trigger template entity availability template.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_AVAILABILITY: template.Template(test_template, hass), + CONF_UNIQUE_ID: "9961786c-f8c8-4ea0-ab1d-b9e922c39088", + } + + entity = ManualTriggerEntity(hass, config) + entity.entity_id = "test.entity" + hass.states.async_set("test.entity", test_entity_state) + await entity.async_added_to_hass() + + variables = entity._template_variables() + assert entity._render_availability_template(variables) is expected + await hass.async_block_till_done() + + assert entity.unique_id == "9961786c-f8c8-4ea0-ab1d-b9e922c39088" + assert entity.available is expected + + +async def test_trigger_no_availability_template( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability template isn't used.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ value == 'on' }}", hass), + } + + class TestEntity(ManualTriggerEntity): + """Test entity class.""" + + extra_template_keys = (CONF_STATE,) + + @property + def state(self) -> bool | None: + """Return extra attributes.""" + return self._rendered.get(CONF_STATE) + + entity = TestEntity(hass, config) + entity.entity_id = "test.entity" + variables = entity._template_variables_with_value(STATE_ON) + assert entity._render_availability_template(variables) is True + assert entity.available is True + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.state == "True" + assert entity.icon == "mdi:on" + assert entity.entity_picture == "/local/picture_on" + + variables = entity._template_variables_with_value(STATE_OFF) + assert entity._render_availability_template(variables) is True + assert entity.available is True + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.state == "False" + assert entity.icon == "mdi:off" + assert entity.entity_picture == "/local/picture_off" + + +async def test_trigger_template_availability_with_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability render fails.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_AVAILABILITY: template.Template("{{ incorrect ", hass), + } + + entity = ManualTriggerEntity(hass, config) + entity.entity_id = "test.entity" + + variables = entity._template_variables() + entity._render_availability_template(variables) + assert entity.available is True + + assert "Error rendering availability template for test.entity" in caplog.text + + +async def test_attribute_order( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability render fails.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ATTRIBUTES: { + "beer": template.Template("{{ value }}", hass), + "no_beer": template.Template("{{ sad - 1 }}", hass), + "more_beer": template.Template("{{ beer + 1 }}", hass), + }, + } + + entity = ManualTriggerEntity(hass, config) + entity.entity_id = "test.entity" + hass.states.async_set("test.entity", STATE_ON) + await entity.async_added_to_hass() + + variables = entity._template_variables_with_value(1) + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.extra_state_attributes == {"beer": 1, "more_beer": 2} + + assert ( + "Error rendering attributes.no_beer template for test.entity: UndefinedError: 'sad' is undefined" + in caplog.text + ) + + +async def test_trigger_template_complex(hass: HomeAssistant) -> None: + """Test manual trigger template entity complex template.""" + complex_template = """ + {% set d = {'test_key':'test_data'} %} + {{ dict(d) }} + +""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template( + '{% if value=="on" %} mdi:on {% else %} mdi:off {% endif %}', hass + ), + CONF_PICTURE: template.Template( + '{% if value=="on" %} /local/picture_on {% else %} /local/picture_off {% endif %}', + hass, + ), + CONF_AVAILABILITY: template.Template('{{ has_value("test.entity") }}', hass), + "other_key": template.Template(complex_template, hass), + } + + class TestEntity(ManualTriggerEntity): + """Test entity class.""" + + extra_template_keys_complex = ("other_key",) + + @property + def some_other_key(self) -> dict[str, Any] | None: + """Return extra attributes.""" + return self._rendered.get("other_key") + + entity = TestEntity(hass, config) + entity.entity_id = "test.entity" + hass.states.async_set("test.entity", STATE_ON) + await entity.async_added_to_hass() + + variables = entity._template_variables_with_value(STATE_ON) + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.some_other_key == {"test_key": "test_data"} diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 3ad5754dada..5fd9f9e39fd 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -19,7 +19,7 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) -from homeassistant.helpers import frame, update_coordinator +from homeassistant.helpers import update_coordinator from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -638,7 +638,6 @@ async def test_async_config_entry_first_refresh_invalid_state( @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_async_config_entry_first_refresh_invalid_state_in_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/non_packaged_scripts/test_alexa_locales.py b/tests/non_packaged_scripts/test_alexa_locales.py index ea139f7de8e..35a44fa74d4 100644 --- a/tests/non_packaged_scripts/test_alexa_locales.py +++ b/tests/non_packaged_scripts/test_alexa_locales.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest import requests_mock -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from script.alexa_locales import SITE, run_script diff --git a/tests/patch_json.py b/tests/patch_json.py new file mode 100644 index 00000000000..e741ba1a816 --- /dev/null +++ b/tests/patch_json.py @@ -0,0 +1,37 @@ +"""Patch JSON related functions.""" + +from __future__ import annotations + +import functools +from typing import Any +from unittest import mock + +import orjson + +from homeassistant.helpers import json as json_helper + +real_json_encoder_default = json_helper.json_encoder_default + +mock_objects = [] + + +def json_encoder_default(obj: Any) -> Any: + """Convert Home Assistant objects. + + Hand other objects to the original method. + """ + if isinstance(obj, mock.Base): + mock_objects.append(obj) + raise TypeError(f"Attempting to serialize mock object {obj}") + return real_json_encoder_default(obj) + + +json_helper.json_encoder_default = json_encoder_default +json_helper.json_bytes = functools.partial( + orjson.dumps, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default +) +json_helper.json_bytes_sorted = functools.partial( + orjson.dumps, + option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SORT_KEYS, + default=json_encoder_default, +) diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index efa3ca9523a..9179a545256 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable import re from types import ModuleType from unittest.mock import patch @@ -98,7 +99,7 @@ def test_regex_a_or_b( "code", [ """ - async def setup( #@ + async def async_turn_on( #@ arg1, arg2 ): pass @@ -114,7 +115,7 @@ def test_ignore_no_annotations( func_node = astroid.extract_node( code, - "homeassistant.components.pylint_test", + "homeassistant.components.pylint_test.light", ) type_hint_checker.visit_module(func_node.parent) @@ -375,12 +376,11 @@ def test_invalid_config_flow_step( type_hint_checker.visit_classdef(class_node) -def test_invalid_custom_config_flow_step( - linter: UnittestLinter, type_hint_checker: BaseChecker -) -> None: - """Ensure invalid hints are rejected for ConfigFlow step.""" - class_node, func_node, arg_node = astroid.extract_node( - """ +@pytest.mark.parametrize( + ("code", "expected_messages_fn"), + [ + ( + """ class FlowHandler(): pass @@ -392,34 +392,79 @@ def test_invalid_custom_config_flow_step( ): async def async_step_axis_specific( #@ self, - device_config: dict #@ + device_config: dict ): pass - """, +""", + lambda func_node: [ + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args=("ConfigFlowResult", "async_step_axis_specific"), + line=11, + col_offset=4, + end_line=11, + end_col_offset=38, + ), + ], + ), + ( + """ + class FlowHandler(): + pass + + class ConfigSubentryFlow(FlowHandler): + pass + + class CustomSubentryFlowHandler(ConfigSubentryFlow): #@ + async def async_step_user( #@ + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + pass +""", + lambda func_node: [ + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args=("SubentryFlowResult", "async_step_user"), + line=9, + col_offset=4, + end_line=9, + end_col_offset=29, + ), + ], + ), + ], + ids=[ + "Config flow", + "Config subentry flow", + ], +) +def test_invalid_flow_step( + linter: UnittestLinter, + type_hint_checker: BaseChecker, + code: str, + expected_messages_fn: Callable[ + [astroid.NodeNG], tuple[pylint.testutils.MessageTest, ...] + ], +) -> None: + """Ensure invalid hints are rejected for flow step.""" + class_node, func_node = astroid.extract_node( + code, "homeassistant.components.pylint_test.config_flow", ) type_hint_checker.visit_module(class_node.parent) with assert_adds_messages( linter, - pylint.testutils.MessageTest( - msg_id="hass-return-type", - node=func_node, - args=("ConfigFlowResult", "async_step_axis_specific"), - line=11, - col_offset=4, - end_line=11, - end_col_offset=38, - ), + *expected_messages_fn(func_node), ): type_hint_checker.visit_classdef(class_node) -def test_valid_config_flow_step( - linter: UnittestLinter, type_hint_checker: BaseChecker -) -> None: - """Ensure valid hints are accepted for ConfigFlow step.""" - class_node = astroid.extract_node( +@pytest.mark.parametrize( + "code", + [ """ class FlowHandler(): pass @@ -436,6 +481,33 @@ def test_valid_config_flow_step( ) -> ConfigFlowResult: pass """, + """ + class FlowHandler(): + pass + + class ConfigSubentryFlow(FlowHandler): + pass + + class CustomSubentryFlowHandler(ConfigSubentryFlow): #@ + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + pass +""", + ], + ids=[ + "Config flow", + "Config subentry flow", + ], +) +def test_valid_flow_step( + linter: UnittestLinter, + type_hint_checker: BaseChecker, + code: str, +) -> None: + """Ensure valid hints are accepted for flow step.""" + class_node = astroid.extract_node( + code, "homeassistant.components.pylint_test.config_flow", ) type_hint_checker.visit_module(class_node.parent) diff --git a/tests/ruff.toml b/tests/ruff.toml index c56b8f68ffc..b22f39f1525 100644 --- a/tests/ruff.toml +++ b/tests/ruff.toml @@ -13,6 +13,7 @@ extend-ignore = [ [lint.flake8-tidy-imports.banned-api] "async_timeout".msg = "use asyncio.timeout instead" "pytz".msg = "use zoneinfo instead" +"syrupy.SnapshotAssertion".msg = "use syrupy.assertion.SnapshotAssertion instead" [lint.isort] known-first-party = [ diff --git a/tests/script/test_gen_requirements_all.py b/tests/script/test_gen_requirements_all.py index 519a5c21855..b667bdd3ddf 100644 --- a/tests/script/test_gen_requirements_all.py +++ b/tests/script/test_gen_requirements_all.py @@ -41,9 +41,9 @@ def test_requirement_override_markers() -> None: ): assert ( gen_requirements_all.process_action_requirement( - "env-canada==0.7.2", "pytest" + "env-canada==0.8.0", "pytest" ) - == "env-canada==0.7.2;python_version<'3.13'" + == "env-canada==0.8.0;python_version<'3.13'" ) assert ( gen_requirements_all.process_action_requirement("other==1.0", "pytest") diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index e52a2cc6567..e9b6f4f718f 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -26,12 +26,10 @@ def reset_log_level() -> Generator[None]: @pytest.fixture -def provider(hass: HomeAssistant) -> hass_auth.HassAuthProvider: +async def provider(hass: HomeAssistant) -> hass_auth.HassAuthProvider: """Home Assistant auth provider.""" - provider = hass.loop.run_until_complete( - register_auth_provider(hass, {"type": "homeassistant"}) - ) - hass.loop.run_until_complete(provider.async_initialize()) + provider = await register_auth_provider(hass, {"type": "homeassistant"}) + await provider.async_initialize() return provider diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 6fcbce7d8d6..7531bf5a663 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -434,7 +434,7 @@ # name: test_yaml_error[basic] ''' mapping values are not allowed here - in "configuration.yaml", line 4, column 14 + in "/fixtures/core/config/yaml_errors/basic/configuration.yaml", line 4, column 14 ''' # --- # name: test_yaml_error[basic].1 @@ -448,7 +448,7 @@ # name: test_yaml_error[basic_include] ''' mapping values are not allowed here - in "integrations/iot_domain.yaml", line 3, column 12 + in "/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml", line 3, column 12 ''' # --- # name: test_yaml_error[basic_include].1 @@ -462,7 +462,7 @@ # name: test_yaml_error[include_dir_list] ''' mapping values are not allowed here - in "iot_domain/iot_domain_1.yaml", line 3, column 10 + in "/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml", line 3, column 10 ''' # --- # name: test_yaml_error[include_dir_list].1 @@ -476,7 +476,7 @@ # name: test_yaml_error[include_dir_merge_list] ''' mapping values are not allowed here - in "iot_domain/iot_domain_1.yaml", line 3, column 12 + in "/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml", line 3, column 12 ''' # --- # name: test_yaml_error[include_dir_merge_list].1 @@ -490,7 +490,7 @@ # name: test_yaml_error[packages_include_dir_named] ''' mapping values are not allowed here - in "integrations/adr_0007_1.yaml", line 4, column 9 + in "/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml", line 4, column 9 ''' # --- # name: test_yaml_error[packages_include_dir_named].1 diff --git a/tests/syrupy.py b/tests/syrupy.py index 3c8e398f0f8..e028d5839cb 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -109,7 +109,11 @@ 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"}: + elif isinstance(data, dict) and set(data) == { + "conversation_id", + "response", + "continue_conversation", + }: serializable_data = cls._serializable_conversation_result(data) elif isinstance(data, vol.Schema): serializable_data = voluptuous_serialize.convert(data) diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 4c6bc930667..7efe25c8428 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -15,7 +15,7 @@ from .common import get_test_config_dir @pytest.fixture(autouse=True) -def remove_restore_result_file() -> Generator[None, Any, Any]: +def remove_restore_result_file() -> Generator[None]: """Remove the restore result file.""" yield Path(get_test_config_dir(".HA_RESTORE_RESULT")).unlink(missing_ok=True) diff --git a/tests/test_block_async_io.py b/tests/test_block_async_io.py index f42fbb9f4ef..337e5500718 100644 --- a/tests/test_block_async_io.py +++ b/tests/test_block_async_io.py @@ -459,3 +459,14 @@ async def test_open_calls_ignored_in_tests(caplog: pytest.LogCaptureFixture) -> pass assert "Detected blocking call to open with args" not in caplog.text + + +async def test_protect_loop_set_default_verify_paths( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test SSLContext.set_default_verify_paths calls in the loop are logged.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + context = ssl.create_default_context() + context.set_default_verify_paths() + assert "Detected blocking call to set_default_verify_paths" in caplog.text diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 0d7c8614c6f..ebfc6b81e00 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -252,8 +252,8 @@ async def test_setup_after_deps_all_present(hass: HomeAssistant) -> None: @pytest.mark.parametrize("load_registries", [False]) -async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: - """Test after_dependencies are ignored in stage 1.""" +async def test_setup_after_deps_in_stage_1(hass: HomeAssistant) -> None: + """Test after_dependencies are promoted in stage 1.""" # This test relies on this assert "cloud" in bootstrap.STAGE_1_INTEGRATIONS order = [] @@ -295,7 +295,7 @@ async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: assert "normal_integration" in hass.config.components assert "cloud" in hass.config.components - assert order == ["cloud", "an_after_dep", "normal_integration"] + assert order == ["an_after_dep", "normal_integration", "cloud"] @pytest.mark.parametrize("load_registries", [False]) @@ -304,7 +304,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( ) -> None: """Ensure we preload manifests for after deps even if they are not setup. - Its important that we preload the after dep manifests even if they are not setup + It's important that we preload the after dep manifests even if they are not setup since we will always have to check their requirements since any integration that lists an after dep may import it and we have to ensure requirements are up to date before the after dep can be imported. @@ -371,7 +371,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( assert "an_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep_of_after_dep" not in hass.config.components - assert order == ["cloud", "normal_integration"] + assert order == ["normal_integration", "cloud"] assert loader.async_get_loaded_integration(hass, "an_after_dep") is not None assert ( loader.async_get_loaded_integration(hass, "an_after_dep_of_after_dep") @@ -456,9 +456,9 @@ async def test_setup_frontend_before_recorder(hass: HomeAssistant) -> None: assert order == [ "http", + "an_after_dep", "frontend", "recorder", - "an_after_dep", "normal_integration", ] @@ -572,7 +572,7 @@ async def test_setup_after_deps_not_present(hass: HomeAssistant) -> None: MockModule( domain="second_dep", async_setup=gen_domain_setup("second_dep"), - partial_manifest={"after_dependencies": ["first_dep"]}, + partial_manifest={"after_dependencies": ["first_dep", "root"]}, ), ) @@ -703,8 +703,8 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( return True with ( - patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.1), - patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.05), + patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.005), + patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.005), patch( "homeassistant.components.frontend.async_setup", side_effect=_async_setup_that_blocks_startup, @@ -924,7 +924,7 @@ async def test_setup_hass_invalid_core_config( "external_url": "https://abcdef.ui.nabu.casa", }, "map": {}, - "person": {"invalid": True}, + "frontend": {"invalid": True}, } ], ) @@ -1169,6 +1169,7 @@ async def test_bootstrap_is_cancellation_safe( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test cancellation during async_setup_component does not cancel bootstrap.""" + mock_integration(hass, MockModule(domain="cancel_integration")) with patch.object( bootstrap, "async_setup_component", side_effect=asyncio.CancelledError ): @@ -1185,6 +1186,18 @@ async def test_bootstrap_empty_integrations(hass: HomeAssistant) -> None: await hass.async_block_till_done() +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_log_already_setup_stage( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test logging when all integrations in a stage were already setup.""" + with patch.object(bootstrap, "STAGE_1_INTEGRATIONS", {"frontend"}): + await bootstrap._async_set_up_integrations(hass, {}) + await hass.async_block_till_done() + + assert "Already set up stage 1: {'frontend'}" in caplog.text + + @pytest.fixture(name="mock_mqtt_config_flow") def mock_mqtt_config_flow_fixture() -> Generator[None]: """Mock MQTT config flow.""" @@ -1528,3 +1541,80 @@ def test_should_rollover_is_always_false() -> None: ).shouldRollover(Mock()) is False ) + + +async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> None: + """Verify stage 0 not load base platforms before recorder. + + If a stage 0 integration implements base platforms or has a base + platform in its dependencies and it loads before the recorder, + because of platform-based YAML schema, it may inadvertently + load integrations that expect the recorder to already be loaded. + We need to ensure that doesn't happen. + """ + IGNORE_BASE_PLATFORM_FILES = { + # config/scene.py is not a platform + "config": {"scene.py"}, + # websocket_api/sensor.py is using the platform YAML schema + # we must not migrate it to an integration key until + # we remove the platform YAML schema support for sensors + "websocket_api": {"sensor.py"}, + } + # person is a special case because it is a base platform + # in the sense that it creates entities in its namespace + # but its not used by other integrations to create entities + # so we want to make sure it is not loaded before the recorder + base_platforms = BASE_PLATFORMS | {"person"} + + integrations_before_recorder: set[str] = set() + for _, integrations, _ in bootstrap.STAGE_0_INTEGRATIONS: + integrations_before_recorder |= integrations + if "recorder" in integrations: + break + else: + pytest.fail("recorder not in stage 0") + + integrations_or_excs = await loader.async_get_integrations( + hass, integrations_before_recorder + ) + integrations: dict[str, Integration] = {} + for domain, integration in integrations_or_excs.items(): + assert not isinstance(integrations_or_excs, Exception) + integrations[domain] = integration + + integrations_all_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, integrations.values(), ignore_exceptions=True + ) + ) + all_integrations = integrations.copy() + all_integrations.update( + (domain, loader.async_get_loaded_integration(hass, domain)) + for domains in integrations_all_dependencies.values() + for domain in domains + ) + + problems: dict[str, set[str]] = {} + for domain in integrations: + domain_with_base_platforms_deps = ( + integrations_all_dependencies[domain] & base_platforms + ) + if domain_with_base_platforms_deps: + problems[domain] = domain_with_base_platforms_deps + assert not problems, ( + f"Integrations that are setup before recorder have base platforms in their dependencies: {problems}" + ) + + base_platform_py_files = {f"{base_platform}.py" for base_platform in base_platforms} + + for domain, integration in all_integrations.items(): + integration_base_platforms_files = ( + integration._top_level_files & base_platform_py_files + ) + if ignore := IGNORE_BASE_PLATFORM_FILES.get(domain): + integration_base_platforms_files -= ignore + if integration_base_platforms_files: + problems[domain] = integration_base_platforms_files + assert not problems, ( + f"Integrations that are setup before recorder implement base platforms: {problems}" + ) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 7066417bfee..ffff19f2c46 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -57,7 +57,6 @@ from .common import ( MockPlatform, async_capture_events, async_fire_time_changed, - async_get_persistent_notifications, flush_store, mock_config_flow, mock_integration, @@ -696,7 +695,7 @@ async def test_remove_entry_cancels_reauth( manager: config_entries.ConfigEntries, issue_registry: ir.IssueRegistry, ) -> None: - """Tests that removing a config entry, also aborts existing reauth flows.""" + """Tests that removing a config entry also aborts existing reauth flows.""" entry = MockConfigEntry(title="test_title", domain="test") mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) @@ -723,6 +722,40 @@ async def test_remove_entry_cancels_reauth( assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) +async def test_reload_entry_cancels_reauth( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: + """Tests that reloading a config entry also aborts existing reauth flows.""" + entry = MockConfigEntry(title="test_title", domain="test") + + mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + entry.add_to_hass(hass) + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler("test") + assert len(flows) == 1 + assert flows[0]["context"]["entry_id"] == entry.entry_id + assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH + assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + issue_id = f"config_entry_reauth_test_{entry.entry_id}" + assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + + mock_setup_entry.return_value = True + mock_setup_entry.side_effect = None + await manager.async_reload(entry.entry_id) + + flows = hass.config_entries.flow.async_progress_by_handler("test") + assert len(flows) == 0 + assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + + async def test_remove_entry_handles_callback_error( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -1332,177 +1365,42 @@ async def test_forward_entry_does_not_setup_entry_if_setup_fails( assert len(mock_setup_entry.mock_calls) == 0 -async def test_async_forward_entry_setup_deprecated( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test async_forward_entry_setup is deprecated.""" - entry = MockConfigEntry( - domain="original", state=config_entries.ConfigEntryState.LOADED - ) - - mock_original_setup_entry = AsyncMock(return_value=True) - integration = mock_integration( - hass, MockModule("original", async_setup_entry=mock_original_setup_entry) - ) - - mock_setup = AsyncMock(return_value=False) - mock_setup_entry = AsyncMock() - mock_integration( - hass, - MockModule( - "forwarded", async_setup=mock_setup, async_setup_entry=mock_setup_entry - ), - ) - - entry_id = entry.entry_id - caplog.clear() - with patch.object(integration, "async_get_platforms"): - async with entry.setup_lock: - await hass.config_entries.async_forward_entry_setup(entry, "forwarded") - - assert ( - "Detected code that calls async_forward_entry_setup for integration, " - f"original with title: Mock Title and entry_id: {entry_id}, " - "which is deprecated, await async_forward_entry_setups instead. " - "This will stop working in Home Assistant 2025.6, please report this issue" - ) in caplog.text - - -async def test_discovery_notification( - hass: HomeAssistant, manager: config_entries.ConfigEntries -) -> None: - """Test that we create/dismiss a notification when source is discovery.""" - mock_integration(hass, MockModule("test")) - mock_platform(hass, "test.config_flow", None) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 5 - - async def async_step_discovery(self, discovery_info): - """Test discovery step.""" - return self.async_show_form(step_id="discovery_confirm") - - async def async_step_discovery_confirm(self, discovery_info): - """Test discovery confirm step.""" - return self.async_create_entry(title="Test Title", data={"token": "abcd"}) - - with mock_config_flow("test", TestFlow): - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - - # Start first discovery flow to assert that discovery notification fires - flow1 = await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - - # Start a second discovery flow so we can finish the first and assert that - # the discovery notification persists until the second one is complete - flow2 = await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - - flow1 = await hass.config_entries.flow.async_configure(flow1["flow_id"], {}) - assert flow1["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - - flow2 = await hass.config_entries.flow.async_configure(flow2["flow_id"], {}) - assert flow2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - - -async def test_reauth_notification(hass: HomeAssistant) -> None: - """Test that we create/dismiss a notification when source is reauth.""" - mock_integration(hass, MockModule("test")) - mock_platform(hass, "test.config_flow", None) - - entry = MockConfigEntry(title="test_title", domain="test") - entry.add_to_hass(hass) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 5 - - async def async_step_user(self, user_input): - """Test user step.""" - return self.async_show_form(step_id="user_confirm") - - async def async_step_user_confirm(self, user_input): - """Test user confirm step.""" - return self.async_show_form(step_id="user_confirm") - - async def async_step_reauth(self, user_input): - """Test reauth step.""" - return self.async_show_form(step_id="reauth_confirm") - - async def async_step_reauth_confirm(self, user_input): - """Test reauth confirm step.""" - return self.async_abort(reason="test") - - with mock_config_flow("test", TestFlow): - # Start user flow to assert that reconfigure notification doesn't fire - await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_USER} - ) - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" not in notifications - - # Start first reauth flow to assert that reconfigure notification fires - flow1 = await hass.config_entries.flow.async_init( - "test", - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": entry.entry_id, - }, - ) - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" in notifications - - # Start a second reauth flow so we can finish the first and assert that - # the reconfigure notification persists until the second one is complete - flow2 = await hass.config_entries.flow.async_init( - "test", - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": entry.entry_id, - }, - ) - - flow1 = await hass.config_entries.flow.async_configure(flow1["flow_id"], {}) - assert flow1["type"] == data_entry_flow.FlowResultType.ABORT - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" in notifications - - flow2 = await hass.config_entries.flow.async_configure(flow2["flow_id"], {}) - assert flow2["type"] == data_entry_flow.FlowResultType.ABORT - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" not in notifications - - -async def test_reauth_issue( +async def test_reauth_issue_flow_returns_abort( hass: HomeAssistant, manager: config_entries.ConfigEntries, issue_registry: ir.IssueRegistry, ) -> None: + """Test that we create/delete an issue when source is reauth. + + In this test, the reauth flow returns abort. + """ + issue = await _test_reauth_issue(hass, manager, issue_registry) + + result = await manager.flow.async_configure(issue.data["flow_id"], {}) + assert result["type"] == FlowResultType.ABORT + assert len(issue_registry.issues) == 0 + + +async def test_reauth_issue_flow_aborted( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that we create/delete an issue when source is reauth. + + In this test, the reauth flow is aborted. + """ + issue = await _test_reauth_issue(hass, manager, issue_registry) + + manager.flow.async_abort(issue.data["flow_id"]) + assert len(issue_registry.issues) == 0 + + +async def _test_reauth_issue( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> ir.IssueEntry: """Test that we create/delete an issue when source is reauth.""" assert len(issue_registry.issues) == 0 @@ -1538,34 +1436,7 @@ async def test_reauth_issue( translation_key="config_entry_reauth", translation_placeholders={"name": "test_title"}, ) - - result = await hass.config_entries.flow.async_configure(issue.data["flow_id"], {}) - assert result["type"] == FlowResultType.ABORT - assert len(issue_registry.issues) == 0 - - -async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: - """Test that we not create a notification when discovery is aborted.""" - mock_integration(hass, MockModule("test")) - mock_platform(hass, "test.config_flow", None) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 5 - - async def async_step_discovery(self, discovery_info): - """Test discovery step.""" - return self.async_abort(reason="test") - - with mock_config_flow("test", TestFlow): - await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_DISCOVERY} - ) - - await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is None + return issue async def test_loading_default_config(hass: HomeAssistant) -> None: @@ -3352,7 +3223,9 @@ async def test_unique_id_update_existing_entry_without_reload( """Test user step.""" await self.async_set_unique_id("mock-unique-id") self._abort_if_unique_id_configured( - updates={"host": "1.1.1.1"}, reload_on_update=False + updates={"host": "1.1.1.1"}, + reload_on_update=False, + description_placeholders={"title": "Other device"}, ) with ( @@ -3368,6 +3241,7 @@ async def test_unique_id_update_existing_entry_without_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "1.1.1.1" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 0 @@ -3402,7 +3276,9 @@ async def test_unique_id_update_existing_entry_with_reload( """Test user step.""" await self.async_set_unique_id("mock-unique-id") await self._abort_if_unique_id_configured( - updates=updates, reload_on_update=True + updates=updates, + reload_on_update=True, + description_placeholders={"title": "Other device"}, ) with ( @@ -3418,6 +3294,7 @@ async def test_unique_id_update_existing_entry_with_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "1.1.1.1" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 1 @@ -3438,6 +3315,7 @@ async def test_unique_id_update_existing_entry_with_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "2.2.2.2" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 0 @@ -3566,37 +3444,97 @@ async def test_unique_id_not_update_existing_entry( assert len(async_reload.mock_calls) == 0 +ABORT_IN_PROGRESS = { + "type": data_entry_flow.FlowResultType.ABORT, + "reason": "already_in_progress", +} + + +@pytest.mark.parametrize( + ("existing_flow_source", "expected_result"), + # Test all sources except SOURCE_IGNORE + [ + (config_entries.SOURCE_BLUETOOTH, ABORT_IN_PROGRESS), + (config_entries.SOURCE_DHCP, ABORT_IN_PROGRESS), + (config_entries.SOURCE_DISCOVERY, ABORT_IN_PROGRESS), + (config_entries.SOURCE_HARDWARE, ABORT_IN_PROGRESS), + (config_entries.SOURCE_HASSIO, ABORT_IN_PROGRESS), + (config_entries.SOURCE_HOMEKIT, ABORT_IN_PROGRESS), + (config_entries.SOURCE_IMPORT, ABORT_IN_PROGRESS), + (config_entries.SOURCE_INTEGRATION_DISCOVERY, ABORT_IN_PROGRESS), + (config_entries.SOURCE_MQTT, ABORT_IN_PROGRESS), + (config_entries.SOURCE_REAUTH, {"type": data_entry_flow.FlowResultType.FORM}), + (config_entries.SOURCE_RECONFIGURE, ABORT_IN_PROGRESS), + (config_entries.SOURCE_SSDP, ABORT_IN_PROGRESS), + (config_entries.SOURCE_SYSTEM, ABORT_IN_PROGRESS), + (config_entries.SOURCE_USB, ABORT_IN_PROGRESS), + (config_entries.SOURCE_USER, ABORT_IN_PROGRESS), + (config_entries.SOURCE_ZEROCONF, ABORT_IN_PROGRESS), + ], +) async def test_unique_id_in_progress( - hass: HomeAssistant, manager: config_entries.ConfigEntries + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + existing_flow_source: str, + expected_result: dict, ) -> None: """Test that we abort if there is already a flow in progress with same unique id.""" mock_integration(hass, MockModule("comp")) mock_platform(hass, "comp.config_flow", None) + entry = MockConfigEntry(domain="comp") + entry.add_to_hass(hass) class TestFlow(config_entries.ConfigFlow): """Test flow.""" VERSION = 1 + async def _async_step_discovery_without_unique_id(self): + """Handle a flow initialized by discovery.""" + return await self._async_step() + + async def async_step_hardware(self, user_input=None): + """Test hardware step.""" + return await self._async_step() + + async def async_step_import(self, user_input=None): + """Test import step.""" + return await self._async_step() + + async def async_step_reauth(self, user_input=None): + """Test reauth step.""" + return await self._async_step() + + async def async_step_reconfigure(self, user_input=None): + """Test reconfigure step.""" + return await self._async_step() + + async def async_step_system(self, user_input=None): + """Test system step.""" + return await self._async_step() + async def async_step_user(self, user_input=None): """Test user step.""" + return await self._async_step() + + async def _async_step(self, user_input=None): + """Test step.""" await self.async_set_unique_id("mock-unique-id") return self.async_show_form(step_id="discovery") with mock_config_flow("comp", TestFlow): # Create one to be in progress result = await manager.flow.async_init( - "comp", context={"source": config_entries.SOURCE_USER} + "comp", context={"source": existing_flow_source, "entry_id": entry.entry_id} ) assert result["type"] == data_entry_flow.FlowResultType.FORM - # Will be canceled result2 = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_USER} ) - assert result2["type"] == data_entry_flow.FlowResultType.ABORT - assert result2["reason"] == "already_in_progress" + for k, v in expected_result.items(): + assert result2[k] == v async def test_finish_flow_aborts_progress( @@ -4205,10 +4143,6 @@ async def test_partial_flows_hidden( # While it's blocked it shouldn't be visible or trigger discovery notifications assert len(hass.config_entries.flow.async_progress()) == 0 - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" not in notifications - # Let the flow init complete pause_discovery.set() @@ -4218,10 +4152,6 @@ async def test_partial_flows_hidden( assert result["type"] == data_entry_flow.FlowResultType.FORM assert len(hass.config_entries.flow.async_progress()) == 1 - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_discovery" in notifications - async def test_async_setup_init_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries @@ -5338,6 +5268,52 @@ async def test_async_abort_entries_match( assert result["reason"] == reason +@pytest.mark.parametrize( + ("matchers", "reason"), + [ + ({"host": "3.4.5.6", "ip": "1.2.3.4", "port": 23}, "no_match"), + ], +) +async def test_async_abort_entries_match_context( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + matchers: dict[str, str], + reason: str, +) -> None: + """Test aborting if matching config entries exist.""" + entry = MockConfigEntry( + domain="comp", data={"ip": "1.2.3.4", "host": "3.4.5.6", "port": 23} + ) + entry.add_to_hass(hass) + + mock_setup_entry = AsyncMock(return_value=True) + mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_reconfigure(self, user_input=None): + """Test user step.""" + self._async_abort_entries_match(matchers) + return self.async_abort(reason="no_match") + + with mock_config_flow("comp", TestFlow), mock_config_flow("invalid_flow", 5): + result = await manager.flow.async_init( + "comp", + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + @pytest.mark.parametrize( ("matchers", "reason"), [ @@ -5763,7 +5739,6 @@ async def test_reauth_reconfigure_missing_entry( @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @pytest.mark.parametrize( "source", [config_entries.SOURCE_REAUTH, config_entries.SOURCE_RECONFIGURE] ) @@ -5995,7 +5970,7 @@ async def test_async_wait_component_startup(hass: HomeAssistant) -> None: "integration_frame_path", ["homeassistant/components/my_integration", "homeassistant.core"], ) -@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_options_flow_with_config_entry_core() -> None: """Test that OptionsFlowWithConfigEntry cannot be used in core.""" entry = MockConfigEntry( @@ -6009,8 +5984,7 @@ async def test_options_flow_with_config_entry_core() -> None: @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) -@pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) -> None: """Test that OptionsFlowWithConfigEntry doesn't mutate entry options.""" entry = MockConfigEntry( @@ -6508,7 +6482,7 @@ async def test_update_subentry_and_abort( class SubentryFlowHandler(config_entries.ConfigSubentryFlow): async def async_step_reconfigure(self, user_input=None): return self.async_update_and_abort( - self._get_reconfigure_entry(), + self._get_entry(), self._get_reconfigure_subentry(), **kwargs, ) @@ -7422,78 +7396,6 @@ async def test_non_awaited_async_forward_entry_setups( ) in caplog.text -async def test_non_awaited_async_forward_entry_setup( - hass: HomeAssistant, - manager: config_entries.ConfigEntries, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test async_forward_entry_setup not being awaited.""" - forward_event = asyncio.Event() - task: asyncio.Task | None = None - - async def mock_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry - ) -> bool: - """Mock setting up entry.""" - # Call async_forward_entry_setup without awaiting it - # This is not allowed and will raise a warning - nonlocal task - task = create_eager_task( - hass.config_entries.async_forward_entry_setup(entry, "light") - ) - return True - - async def mock_unload_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry - ) -> bool: - """Mock unloading an entry.""" - result = await hass.config_entries.async_unload_platforms(entry, ["light"]) - assert result - return result - - mock_remove_entry = AsyncMock(return_value=None) - - async def mock_setup_entry_platform( - hass: HomeAssistant, - entry: config_entries.ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, - ) -> None: - """Mock setting up platform.""" - await forward_event.wait() - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=mock_setup_entry, - async_unload_entry=mock_unload_entry, - async_remove_entry=mock_remove_entry, - ), - ) - mock_platform( - hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) - ) - mock_platform(hass, "test.config_flow", None) - - entry = MockConfigEntry(domain="test", entry_id="test2") - entry.add_to_manager(manager) - - # Setup entry - await manager.async_setup(entry.entry_id) - await hass.async_block_till_done() - forward_event.set() - await hass.async_block_till_done() - await task - - assert ( - "Detected code that calls async_forward_entry_setup for integration " - "test with title: Mock Title and entry_id: test2, during setup without " - "awaiting async_forward_entry_setup, which can cause the setup lock " - "to be released before the setup is done. This will stop working in " - "Home Assistant 2025.1, please report this issue" - ) in caplog.text - - async def test_config_entry_unloaded_during_platform_setup( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -7512,7 +7414,7 @@ async def test_config_entry_unloaded_during_platform_setup( def _late_setup(): nonlocal task task = asyncio.create_task( - hass.config_entries.async_forward_entry_setup(entry, "light") + hass.config_entries.async_forward_entry_setups(entry, ["light"]) ) hass.loop.call_soon(_late_setup) @@ -7563,7 +7465,7 @@ async def test_config_entry_unloaded_during_platform_setup( assert ( "OperationNotAllowed: The config entry 'Mock Title' (test) with " - "entry_id 'test2' cannot forward setup for light because it is " + "entry_id 'test2' cannot forward setup for ['light'] because it is " "in state ConfigEntryState.NOT_LOADED, but needs to be in the " "ConfigEntryState.LOADED state" ) in caplog.text @@ -7587,7 +7489,7 @@ async def test_config_entry_late_platform_setup( def _late_setup(): nonlocal task task = asyncio.create_task( - hass.config_entries.async_forward_entry_setup(entry, "light") + hass.config_entries.async_forward_entry_setups(entry, ["light"]) ) hass.loop.call_soon(_late_setup) @@ -8100,10 +8002,10 @@ async def test_get_reconfigure_entry( assert result["reason"] == "Source is user, expected reconfigure: -" -async def test_subentry_get_reconfigure_entry( +async def test_subentry_get_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: - """Test subentry _get_reconfigure_entry and _get_reconfigure_subentry behavior.""" + """Test subentry _get_entry and _get_reconfigure_subentry behavior.""" subentry_id = "mock_subentry_id" entry = MockConfigEntry( data={}, @@ -8140,13 +8042,13 @@ async def test_subentry_get_reconfigure_entry( async def _async_step_confirm(self): """Confirm input.""" try: - entry = self._get_reconfigure_entry() + entry = self._get_entry() except ValueError as err: reason = str(err) else: reason = f"Found entry {entry.title}" try: - entry_id = self._reconfigure_entry_id + entry_id = self._entry_id except ValueError: reason = f"{reason}: -" else: @@ -8175,7 +8077,7 @@ async def test_subentry_get_reconfigure_entry( ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: return {"test": TestFlow.SubentryFlowHandler} - # A reconfigure flow finds the config entry + # A reconfigure flow finds the config entry and subentry with mock_config_flow("test", TestFlow): result = await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) assert ( @@ -8197,14 +8099,14 @@ async def test_subentry_get_reconfigure_entry( == "Found entry entry_title: mock_entry_id/Subentry not found: 01JRemoved" ) - # A user flow does not have access to the config entry or subentry + # A user flow finds the config entry but not the subentry with mock_config_flow("test", TestFlow): result = await manager.subentries.async_init( (entry.entry_id, "test"), context={"source": config_entries.SOURCE_USER} ) assert ( result["reason"] - == "Source is user, expected reconfigure: -/Source is user, expected reconfigure: -" + == "Found entry entry_title: mock_entry_id/Source is user, expected reconfigure: -" ) @@ -8501,41 +8403,6 @@ async def test_async_update_entry_unique_id_collision( assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) -@pytest.mark.parametrize("domain", ["flipr"]) -async def test_async_update_entry_unique_id_collision_allowed_domain( - hass: HomeAssistant, - manager: config_entries.ConfigEntries, - caplog: pytest.LogCaptureFixture, - issue_registry: ir.IssueRegistry, - domain: str, -) -> None: - """Test we warn when async_update_entry creates a unique_id collision. - - This tests we don't warn and don't create issues for domains which have - their own migration path. - """ - assert len(issue_registry.issues) == 0 - - entry1 = MockConfigEntry(domain=domain, unique_id=None) - entry2 = MockConfigEntry(domain=domain, unique_id="not none") - entry3 = MockConfigEntry(domain=domain, unique_id="very unique") - entry4 = MockConfigEntry(domain=domain, unique_id="also very unique") - entry1.add_to_manager(manager) - entry2.add_to_manager(manager) - entry3.add_to_manager(manager) - entry4.add_to_manager(manager) - - manager.async_update_entry(entry2, unique_id=None) - assert len(issue_registry.issues) == 0 - assert len(caplog.record_tuples) == 0 - - manager.async_update_entry(entry4, unique_id="very unique") - assert len(issue_registry.issues) == 0 - assert len(caplog.record_tuples) == 0 - - assert ("already in use") not in caplog.text - - async def test_unique_id_collision_issues( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -8789,7 +8656,6 @@ async def test_options_flow_config_entry( @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_deprecated_config_entry_setter( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -8849,7 +8715,8 @@ async def test_options_flow_deprecated_config_entry_setter( "config_entry explicitly, which is deprecated at " "custom_components/my_integration/light.py, line 23: " "self.light.is_on. This will stop working in Home Assistant 2025.12, please " - "create a bug report at " in caplog.text + "report it to the author of the 'my_integration' custom integration" + in caplog.text ) @@ -8899,3 +8766,66 @@ async def test_add_description_placeholder_automatically_not_overwrites( result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], None) assert result["type"] == FlowResultType.FORM assert result["description_placeholders"] == {"name": "Custom title"} + + +@pytest.mark.parametrize( + ("domain", "source", "expected_log"), + [ + ("some_integration", config_entries.SOURCE_USER, True), + ("some_integration", config_entries.SOURCE_IGNORE, False), + ("mobile_app", config_entries.SOURCE_USER, False), + ], +) +async def test_create_entry_existing_unique_id( + hass: HomeAssistant, + domain: str, + source: str, + expected_log: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test to highlight unexpected behavior on create_entry.""" + entry = MockConfigEntry( + title="From config flow", + domain=domain, + entry_id="01J915Q6T9F6G5V0QJX6HBC94T", + data={"host": "any", "port": 123}, + unique_id="mock-unique-id", + source=source, + ) + entry.add_to_hass(hass) + + assert len(hass.config_entries.async_entries(domain)) == 1 + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule(domain, async_setup_entry=mock_setup_entry)) + mock_platform(hass, f"{domain}.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + await self.async_set_unique_id("mock-unique-id") + return self.async_create_entry(title="mock-title", data={}) + + with ( + mock_config_flow(domain, TestFlow), + patch.object(frame, "_REPORTED_INTEGRATIONS", set()), + ): + 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.CREATE_ENTRY + + assert len(hass.config_entries.async_entries(domain)) == 1 + + log_text = ( + f"Detected that integration '{domain}' creates a config entry " + "when another entry with the same unique ID exists. Please " + "create a bug report at https:" + ) + assert (log_text in caplog.text) == expected_log diff --git a/tests/test_core.py b/tests/test_core.py index ceab3ce327c..50f7f92727b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -35,6 +35,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, EVENT_STATE_REPORTED, MATCH_ALL, + STATE_UNKNOWN, ) from homeassistant.core import ( CoreState, @@ -1368,9 +1369,6 @@ def test_state_init() -> None: with pytest.raises(InvalidEntityFormatError): ha.State("invalid_entity_format", "test_state") - with pytest.raises(InvalidStateError): - ha.State("domain.long_state", "t" * 256) - def test_state_domain() -> None: """Test domain.""" @@ -1440,6 +1438,38 @@ def test_state_repr() -> None: ) +async def test_statemachine_async_set_invalid_state(hass: HomeAssistant) -> None: + """Test setting an invalid state with the async_set method.""" + with pytest.raises( + InvalidStateError, + match="Invalid state with length 256. State max length is 255 characters.", + ): + hass.states.async_set("light.bowl", "o" * 256, {}) + + +async def test_statemachine_async_set_internal_invalid_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setting an invalid state with the async_set_internal method.""" + long_state = "o" * 256 + hass.states.async_set_internal( + "light.bowl", + long_state, + {}, + force_update=False, + context=None, + state_info=None, + timestamp=time.time(), + ) + assert hass.states.get("light.bowl").state == STATE_UNKNOWN + assert ( + "homeassistant.core", + logging.ERROR, + f"State {long_state} for light.bowl is longer than 255, " + f"falling back to {STATE_UNKNOWN}", + ) in caplog.record_tuples + + async def test_statemachine_is_state(hass: HomeAssistant) -> None: """Test is_state method.""" hass.states.async_set("light.bowl", "on", {}) diff --git a/tests/test_core_config.py b/tests/test_core_config.py index 2723c8e7196..7fbd10db206 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -5,7 +5,6 @@ from collections import OrderedDict import copy import os from pathlib import Path -import re from tempfile import TemporaryDirectory from typing import Any from unittest.mock import Mock, PropertyMock, patch @@ -1072,18 +1071,6 @@ async def test_debug_mode_defaults_to_off(hass: HomeAssistant) -> None: assert not hass.config.debug -async def test_set_time_zone_deprecated(hass: HomeAssistant) -> None: - """Test set_time_zone is deprecated.""" - with pytest.raises( - RuntimeError, - match=re.escape( - "Detected code that sets the time zone using set_time_zone instead of " - "async_set_time_zone. Please report this issue" - ), - ): - await hass.config.set_time_zone("America/New_York") - - async def test_core_config_schema_imperial_unit( hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 74a55cb4989..961afd69c2d 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -133,6 +133,61 @@ async def test_show_form(manager: MockFlowManager) -> None: assert form["errors"] == {"username": "Should be unique."} +async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) -> None: + """Test that we can show a form with suggested values.""" + schema = vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + vol.Required("section_1"): data_entry_flow.section( + vol.Schema( + { + vol.Optional("full_name"): str, + } + ), + {"collapsed": False}, + ), + } + ) + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + data_schema = self.add_suggested_values_to_schema( + schema, + { + "username": "doej", + "password": "verySecret1", + "section_1": {"full_name": "John Doe"}, + }, + ) + return self.async_show_form( + step_id="init", + data_schema=data_schema, + ) + + form = await manager.async_init("test") + assert form["type"] == data_entry_flow.FlowResultType.FORM + assert form["data_schema"].schema == schema.schema + markers = list(form["data_schema"].schema) + assert len(markers) == 3 + assert markers[0] == "username" + assert markers[0].description == {"suggested_value": "doej"} + assert markers[1] == "password" + assert markers[1].description == {"suggested_value": "verySecret1"} + assert markers[2] == "section_1" + section_validator = form["data_schema"].schema["section_1"] + assert isinstance(section_validator, data_entry_flow.section) + # The section class was not replaced + assert section_validator is schema.schema["section_1"] + # The section schema was not replaced + assert section_validator.schema is schema.schema["section_1"].schema + section_markers = list(section_validator.schema.schema) + assert len(section_markers) == 1 + assert section_markers[0] == "full_name" + assert section_markers[0].description == {"suggested_value": "John Doe"} + + async def test_abort_removes_instance(manager: MockFlowManager) -> None: """Test that abort removes the flow from progress.""" @@ -155,6 +210,21 @@ async def test_abort_removes_instance(manager: MockFlowManager) -> None: assert len(manager.mock_created_entries) == 0 +async def test_abort_aborted_flow(manager: MockFlowManager) -> None: + """Test return abort from aborted flow.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + manager.async_abort(self.flow_id) + return self.async_abort(reason="blah") + + form = await manager.async_init("test") + assert form["reason"] == "blah" + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + + async def test_abort_calls_async_remove(manager: MockFlowManager) -> None: """Test abort calling the async_remove FlowHandler method.""" @@ -173,6 +243,23 @@ async def test_abort_calls_async_remove(manager: MockFlowManager) -> None: assert len(manager.mock_created_entries) == 0 +async def test_abort_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test abort calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_abort(reason="reason") + + manager.async_flow_removed = Mock() + await manager.async_init("test") + + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + + async def test_abort_calls_async_remove_with_exception( manager: MockFlowManager, caplog: pytest.LogCaptureFixture ) -> None: @@ -217,6 +304,42 @@ async def test_create_saves_data(manager: MockFlowManager) -> None: assert entry["source"] is None +async def test_create_aborted_flow(manager: MockFlowManager) -> None: + """Test return create_entry from aborted flow.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_init(self, user_input=None): + manager.async_abort(self.flow_id) + return self.async_create_entry(title="Test Title", data="Test Data") + + with pytest.raises(data_entry_flow.UnknownFlow): + await manager.async_init("test") + assert len(manager.async_progress()) == 0 + + # No entry should be created if the flow is aborted + assert len(manager.mock_created_entries) == 0 + + +async def test_create_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test create calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_create_entry(title="Test Title", data="Test Data") + + manager.async_flow_removed = Mock() + await manager.async_init("test") + + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + + async def test_discovery_init_flow(manager: MockFlowManager) -> None: """Test a flow initialized by discovery.""" @@ -341,6 +464,9 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N """Test show progress logic.""" manager.hass = hass events = [] + progress_update_events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) task_one_evt = asyncio.Event() task_two_evt = asyncio.Event() event_received_evt = asyncio.Event() @@ -363,7 +489,9 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N await task_one_evt.wait() async def long_running_job_two() -> None: + self.async_update_progress(0.25) await task_two_evt.wait() + self.async_update_progress(0.75) self.data = {"title": "Hello"} uncompleted_task: asyncio.Task[None] | None = None @@ -422,6 +550,12 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N result = await manager.async_configure(result["flow_id"]) assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "task_two" + assert len(progress_update_events) == 1 + assert progress_update_events[0].data == { + "handler": "test", + "flow_id": result["flow_id"], + "progress": 0.25, + } # Set task two done and wait for event task_two_evt.set() @@ -433,6 +567,12 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N "flow_id": result["flow_id"], "refresh": True, } + assert len(progress_update_events) == 2 + assert progress_update_events[1].data == { + "handler": "test", + "flow_id": result["flow_id"], + "progress": 0.75, + } # Frontend refreshes the flow result = await manager.async_configure(result["flow_id"]) @@ -829,12 +969,34 @@ async def test_configure_raises_unknown_flow_if_not_in_progress( await manager.async_configure("wrong_flow_id") -async def test_abort_raises_unknown_flow_if_not_in_progress( +async def test_manager_abort_raises_unknown_flow_if_not_in_progress( manager: MockFlowManager, ) -> None: """Test abort raises UnknownFlow if the flow is not in progress.""" with pytest.raises(data_entry_flow.UnknownFlow): - await manager.async_abort("wrong_flow_id") + manager.async_abort("wrong_flow_id") + + +async def test_manager_abort_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test abort calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_show_form(step_id="init") + + manager.async_flow_removed = Mock() + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + manager.async_flow_removed.assert_not_called() + + manager.async_abort(result["flow_id"]) + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 @pytest.mark.parametrize( diff --git a/tests/test_loader.py b/tests/test_loader.py index 8afe800144c..16515cbd4e6 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -12,14 +12,13 @@ from awesomeversion import AwesomeVersion import pytest from homeassistant import loader -from homeassistant.components import http, hue +from homeassistant.components import hue from homeassistant.components.hue import light as hue_light -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import frame +from homeassistant.core import HomeAssistant from homeassistant.helpers.json import json_dumps from homeassistant.util.json import json_loads -from .common import MockModule, async_get_persistent_notifications, mock_integration +from .common import MockModule, mock_integration async def test_circular_component_dependencies(hass: HomeAssistant) -> None: @@ -28,82 +27,91 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) mock_integration(hass, MockModule("mod3", dependencies=["mod1"])) mod_4 = mock_integration(hass, MockModule("mod4", dependencies=["mod2", "mod3"])) + all_domains = {"mod1", "mod2", "mod3", "mod4"} - deps = await loader._async_component_dependencies(hass, mod_4) - assert deps == {"mod1", "mod2", "mod3", "mod4"} + deps = await loader._resolve_integration_dependencies(mod_4, cache={}) + assert deps == {"mod1", "mod2", "mod3"} # Create a circular dependency mock_integration(hass, MockModule("mod1", dependencies=["mod4"])) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._resolve_integration_dependencies(mod_4, cache={}) # Create a different circular dependency mock_integration(hass, MockModule("mod1", dependencies=["mod3"])) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._resolve_integration_dependencies(mod_4, cache={}) # Create a circular after_dependency mock_integration( hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) ) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._resolve_integration_dependencies( + mod_4, + cache={}, + possible_after_dependencies=all_domains, + ) # Create a different circular after_dependency mock_integration( hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod3"]}) ) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._resolve_integration_dependencies( + mod_4, + cache={}, + possible_after_dependencies=all_domains, + ) + + # Create a circular after_dependency without a hard dependency + mock_integration( + hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) + ) + mod_4 = mock_integration( + hass, MockModule("mod4", partial_manifest={"after_dependencies": ["mod2"]}) + ) + with pytest.raises(loader.CircularDependency): + await loader._resolve_integration_dependencies( + mod_4, + cache={}, + possible_after_dependencies=all_domains, + ) + + result = await loader.resolve_integrations_after_dependencies(hass, (mod_4,)) + assert result == {} + result = await loader.resolve_integrations_after_dependencies( + hass, (mod_4,), ignore_exceptions=True + ) + assert result["mod4"] == {"mod4", "mod2", "mod1"} async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: """Test if we can detect nonexistent dependencies of components.""" mod_1 = mock_integration(hass, MockModule("mod1", dependencies=["nonexistent"])) + mod_2 = mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) + + assert await mod_2.resolve_dependencies() is None + assert mod_2.all_dependencies_resolved with pytest.raises(loader.IntegrationNotFound): - await loader._async_component_dependencies(hass, mod_1) + mod_2.all_dependencies # noqa: B018 + assert mod_1.all_dependencies_resolved + assert await mod_1.resolve_dependencies() is None + with pytest.raises(loader.IntegrationNotFound): + mod_1.all_dependencies # noqa: B018 -def test_component_loader(hass: HomeAssistant) -> None: - """Test loading components.""" - components = loader.Components(hass) - assert components.http.CONFIG_SCHEMA is http.CONFIG_SCHEMA - assert hass.components.http.CONFIG_SCHEMA is http.CONFIG_SCHEMA + result = await loader.resolve_integrations_dependencies(hass, (mod_2, mod_1)) + assert result == {} + mod_1 = mock_integration( + hass, + MockModule("mod1", partial_manifest={"after_dependencies": ["non.existent"]}), + ) + mod_2 = mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) -def test_component_loader_non_existing(hass: HomeAssistant) -> None: - """Test loading components.""" - components = loader.Components(hass) - with pytest.raises(ImportError): - _ = components.non_existing - - -async def test_component_wrapper(hass: HomeAssistant) -> None: - """Test component wrapper.""" - components = loader.Components(hass) - components.persistent_notification.async_create("message") - - notifications = async_get_persistent_notifications(hass) - assert len(notifications) - - -async def test_helpers_wrapper(hass: HomeAssistant) -> None: - """Test helpers wrapper.""" - helpers = loader.Helpers(hass) - - result = [] - - @callback - def discovery_callback(service, discovered): - """Handle discovery callback.""" - result.append(discovered) - - helpers.discovery.async_listen("service_name", discovery_callback) - - await helpers.discovery.async_discover("service_name", "hello", None, {}) - await hass.async_block_till_done() - - assert result == ["hello"] + result = await loader.resolve_integrations_after_dependencies(hass, (mod_2, mod_1)) + assert result == {} @pytest.mark.usefixtures("enable_custom_integrations") @@ -118,10 +126,6 @@ async def test_custom_component_name(hass: HomeAssistant) -> None: assert int_comp.__name__ == "custom_components.test_package" assert int_comp.__package__ == "custom_components.test_package" - comp = hass.components.test_package - assert comp.__name__ == "custom_components.test_package" - assert comp.__package__ == "custom_components.test_package" - integration = await loader.async_get_integration(hass, "test") platform = integration.get_platform("light") assert integration.get_platform_cached("light") is platform @@ -1299,43 +1303,6 @@ async def test_config_folder_not_in_path() -> None: import tests.testing_config.check_config_not_in_path # noqa: F401 -@pytest.mark.parametrize( - ("integration_frame_path", "expected"), - [ - pytest.param( - "custom_components/test_integration_frame", True, id="custom integration" - ), - pytest.param( - "homeassistant/components/test_integration_frame", - False, - id="core integration", - ), - pytest.param("homeassistant/test_integration_frame", False, id="core"), - ], -) -@pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) -async def test_hass_components_use_reported( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - expected: bool, -) -> None: - """Test whether use of hass.components is reported.""" - with ( - patch( - "homeassistant.components.http.start_http_server_and_save_config", - return_value=None, - ), - ): - await hass.components.http.start_http_server_and_save_config(hass, [], None) - - reported = ( - "Detected that custom integration 'test_integration_frame'" - " accesses hass.components.http, which should be updated" - ) in caplog.text - assert reported == expected - - async def test_async_get_component_preloads_config_and_config_flow( hass: HomeAssistant, ) -> None: @@ -1995,43 +1962,6 @@ async def test_has_services(hass: HomeAssistant) -> None: assert integration.has_services is True -@pytest.mark.parametrize( - ("integration_frame_path", "expected"), - [ - pytest.param( - "custom_components/test_integration_frame", True, id="custom integration" - ), - pytest.param( - "homeassistant/components/test_integration_frame", - False, - id="core integration", - ), - pytest.param("homeassistant/test_integration_frame", False, id="core"), - ], -) -@pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) -async def test_hass_helpers_use_reported( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - expected: bool, -) -> None: - """Test whether use of hass.helpers is reported.""" - with ( - patch( - "homeassistant.helpers.aiohttp_client.async_get_clientsession", - return_value=None, - ), - ): - hass.helpers.aiohttp_client.async_get_clientsession() - - reported = ( - "Detected that custom integration 'test_integration_frame' " - "accesses hass.helpers.aiohttp_client, which should be updated" - ) in caplog.text - assert reported == expected - - async def test_manifest_json_fragment_round_trip(hass: HomeAssistant) -> None: """Test json_fragment roundtrip.""" integration = await loader.async_get_integration(hass, "hue") diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 191e1b7368c..9fcb84beec6 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -655,5 +655,5 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: ) as mock_process: await async_get_integration_with_requirements(hass, "comp") - assert len(mock_process.mock_calls) == 1 # dhcp does not depend on http + assert len(mock_process.mock_calls) == 2 # dhcp does not depend on http assert mock_process.mock_calls[0][1][1] == dhcp.requirements diff --git a/tests/test_setup.py b/tests/test_setup.py index bb221c7cb4c..96a13017430 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -57,21 +57,21 @@ async def test_validate_component_config(hass: HomeAssistant) -> None: with assert_setup_component(0): assert not await setup.async_setup_component(hass, "comp_conf", {}) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( hass, "comp_conf", {"comp_conf": None} ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( hass, "comp_conf", {"comp_conf": {}} ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( @@ -80,7 +80,7 @@ async def test_validate_component_config(hass: HomeAssistant) -> None: {"comp_conf": {"hello": "world", "invalid": "extra"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(1): assert await setup.async_setup_component( @@ -111,7 +111,7 @@ async def test_validate_platform_config( {"platform_conf": {"platform": "not_existing", "hello": "world"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") with assert_setup_component(1): @@ -121,7 +121,7 @@ async def test_validate_platform_config( {"platform_conf": {"platform": "whatever", "hello": "world"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") with assert_setup_component(1): @@ -131,7 +131,7 @@ async def test_validate_platform_config( {"platform_conf": [{"platform": "whatever", "hello": "world"}]}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") # Any falsey platform config will be ignored (None, {}, etc) @@ -240,7 +240,7 @@ async def test_validate_platform_config_4(hass: HomeAssistant) -> None: }, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") @@ -345,7 +345,7 @@ async def test_component_not_setup_missing_dependencies(hass: HomeAssistant) -> assert not await setup.async_setup_component(hass, "comp", {}) assert "comp" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration(hass, MockModule("comp2", dependencies=deps)) mock_integration(hass, MockModule("maybe_existing")) @@ -353,6 +353,76 @@ async def test_component_not_setup_missing_dependencies(hass: HomeAssistant) -> assert await setup.async_setup_component(hass, "comp2", {}) +async def test_component_not_setup_already_setup_dependencies( + hass: HomeAssistant, +) -> None: + """Test we do not set up component dependencies if they are already set up.""" + mock_integration( + hass, + MockModule( + "comp", + dependencies=["dep1"], + partial_manifest={"after_dependencies": ["dep2"]}, + ), + ) + mock_integration(hass, MockModule("dep1")) + mock_integration(hass, MockModule("dep2")) + + setup.async_set_domains_to_be_loaded(hass, {"comp", "dep2"}) + + hass.config.components.add("dep1") + hass.config.components.add("dep2") + + with patch( + "homeassistant.setup.async_setup_component", + side_effect=setup.async_setup_component, + ) as mock_setup: + await mock_setup(hass, "comp", {}) + + assert mock_setup.call_count == 1 + + +@pytest.mark.usefixtures("mock_handlers") +async def test_component_setup_dependencies_with_config_entry( + hass: HomeAssistant, +) -> None: + """Test we wait for a dependency with config entry.""" + calls: list[str] = [] + + async def mock_async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + await asyncio.sleep(0) + calls.append("entry") + return True + + mock_integration(hass, MockModule("comp", async_setup_entry=mock_async_setup_entry)) + mock_platform(hass, "comp.config_flow", None) + MockConfigEntry(domain="comp").add_to_hass(hass) + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + calls.append("comp") + return True + + mock_integration( + hass, + MockModule("comp2", dependencies=["comp"], async_setup=mock_async_setup), + ) + mock_integration( + hass, + MockModule("comp3", dependencies=["comp"], async_setup=mock_async_setup), + ) + + await asyncio.gather( + setup.async_setup_component(hass, "comp2", {}), + setup.async_setup_component(hass, "comp3", {}), + ) + + assert "comp" in hass.config.components + assert "comp2" in hass.config.components + assert "comp3" in hass.config.components + + assert calls == ["entry", "comp", "comp"] + + async def test_component_failing_setup(hass: HomeAssistant) -> None: """Test component that fails setup.""" mock_integration(hass, MockModule("comp", setup=lambda hass, config: False)) @@ -373,8 +443,8 @@ async def test_component_exception_setup(hass: HomeAssistant) -> None: mock_integration(hass, MockModule(domain, setup=exception_setup)) assert not await setup.async_setup_component(hass, domain, {}) - assert domain in hass.data[setup.DATA_SETUP] - assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain in hass.data[setup._DATA_SETUP] + assert domain not in hass.data[setup._DATA_SETUP_DONE] assert domain not in hass.config.components @@ -393,8 +463,8 @@ async def test_component_base_exception_setup(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, "comp", {}) assert str(exc_info.value) == "fail!" - assert domain in hass.data[setup.DATA_SETUP] - assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain in hass.data[setup._DATA_SETUP] + assert domain not in hass.data[setup._DATA_SETUP_DONE] assert domain not in hass.config.components @@ -407,12 +477,12 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: domains = {domain_good, domain_bad, domain_exception, domain_base_exception} setup.async_set_domains_to_be_loaded(hass, domains) - assert set(hass.data[setup.DATA_SETUP_DONE]) == domains - setup_done = dict(hass.data[setup.DATA_SETUP_DONE]) + assert set(hass.data[setup._DATA_SETUP_DONE]) == domains + setup_done = dict(hass.data[setup._DATA_SETUP_DONE]) # Calling async_set_domains_to_be_loaded again should not create new futures setup.async_set_domains_to_be_loaded(hass, domains) - assert setup_done == hass.data[setup.DATA_SETUP_DONE] + assert setup_done == hass.data[setup._DATA_SETUP_DONE] def good_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Success.""" @@ -445,8 +515,8 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, domain_base_exception, {}) # Check the result of the setup - assert not hass.data[setup.DATA_SETUP_DONE] - assert set(hass.data[setup.DATA_SETUP]) == { + assert not hass.data[setup._DATA_SETUP_DONE] + assert set(hass.data[setup._DATA_SETUP]) == { domain_bad, domain_exception, domain_base_exception, @@ -455,7 +525,30 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: # Calling async_set_domains_to_be_loaded again should not create any new futures setup.async_set_domains_to_be_loaded(hass, domains) - assert not hass.data[setup.DATA_SETUP_DONE] + assert not hass.data[setup._DATA_SETUP_DONE] + + +async def test_component_setup_after_dependencies(hass: HomeAssistant) -> None: + """Test that after dependencies are set up before the component.""" + mock_integration(hass, MockModule("dep")) + mock_integration( + hass, MockModule("comp", partial_manifest={"after_dependencies": ["dep"]}) + ) + mock_integration( + hass, MockModule("comp2", partial_manifest={"after_dependencies": ["dep"]}) + ) + + setup.async_set_domains_to_be_loaded(hass, {"comp"}) + + assert await setup.async_setup_component(hass, "comp", {}) + assert "comp" in hass.config.components + assert "dep" not in hass.config.components + + setup.async_set_domains_to_be_loaded(hass, {"comp2", "dep"}) + + assert await setup.async_setup_component(hass, "comp2", {}) + assert "comp2" in hass.config.components + assert "dep" in hass.config.components async def test_component_setup_with_validation_and_dependency( @@ -515,7 +608,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: assert mock_setup.call_count == 0 assert len(mock_notify.mock_calls) == 1 - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("switch") with ( @@ -537,7 +630,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: assert mock_setup.call_count == 0 assert len(mock_notify.mock_calls) == 1 - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("switch") with ( @@ -563,7 +656,7 @@ async def test_disable_component_if_invalid_return(hass: HomeAssistant) -> None: assert not await setup.async_setup_component(hass, "disabled_component", {}) assert "disabled_component" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration( hass, MockModule("disabled_component", setup=lambda hass, config: False), @@ -572,7 +665,7 @@ async def test_disable_component_if_invalid_return(hass: HomeAssistant) -> None: assert not await setup.async_setup_component(hass, "disabled_component", {}) assert "disabled_component" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration( hass, MockModule("disabled_component", setup=lambda hass, config: True) ) @@ -846,7 +939,7 @@ async def test_integration_only_setup_entry(hass: HomeAssistant) -> None: async def test_async_start_setup_running(hass: HomeAssistant) -> None: """Test setup started context manager does nothing when running.""" assert hass.state is CoreState.running - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) with setup.async_start_setup( hass, integration="august", phase=setup.SetupPhases.SETUP @@ -859,7 +952,7 @@ async def test_async_start_setup_config_entry( ) -> None: """Test setup started keeps track of setup times with a config entry.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -969,7 +1062,7 @@ async def test_async_start_setup_config_entry_late_platform( ) -> None: """Test setup started tracks config entry time with a late platform load.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1023,7 +1116,7 @@ async def test_async_start_setup_config_entry_platform_wait( ) -> None: """Test setup started tracks wait time when a platform loads inside of config entry setup.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1065,7 +1158,7 @@ async def test_async_start_setup_config_entry_platform_wait( async def test_async_start_setup_top_level_yaml(hass: HomeAssistant) -> None: """Test setup started context manager keeps track of setup times with modern yaml.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1081,7 +1174,7 @@ async def test_async_start_setup_top_level_yaml(hass: HomeAssistant) -> None: async def test_async_start_setup_platform_integration(hass: HomeAssistant) -> None: """Test setup started keeps track of setup times a platform integration.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1115,7 +1208,7 @@ async def test_async_start_setup_legacy_platform_integration( ) -> None: """Test setup started keeps track of setup times for a legacy platform integration.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1237,7 +1330,7 @@ async def test_setup_config_entry_from_yaml( assert await setup.async_setup_component(hass, "test_integration_only_entry", {}) assert expected_warning not in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1246,7 +1339,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1255,7 +1348,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1266,7 +1359,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") @@ -1315,3 +1408,42 @@ async def test_async_prepare_setup_platform( await setup.async_prepare_setup_platform(hass, {}, "button", "test") is None ) assert button_platform is not None + + +async def test_async_wait_component(hass: HomeAssistant) -> None: + """Test async_wait_component.""" + setup_stall = asyncio.Event() + setup_started = asyncio.Event() + + async def mock_setup(hass: HomeAssistant, _) -> bool: + setup_started.set() + await setup_stall.wait() + return True + + mock_integration(hass, MockModule("test", async_setup=mock_setup)) + + # The integration not loaded, and is also not scheduled to load + assert await setup.async_wait_component(hass, "test") is False + + # Mark the component as scheduled to be loaded + setup.async_set_domains_to_be_loaded(hass, {"test"}) + + # Start loading the component, including its config entries + hass.async_create_task(setup.async_setup_component(hass, "test", {})) + await setup_started.wait() + + # The component is not yet loaded + assert "test" not in hass.config.components + + # Allow setup to proceed + setup_stall.set() + + # The component is scheduled to load, this will block until the config entry is loaded + assert await setup.async_wait_component(hass, "test") is True + + # The component has been loaded + assert "test" in hass.config.components + + # Clear the event, then call again to make sure we don't block + setup_stall.clear() + assert await setup.async_wait_component(hass, "test") is True diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index 0b8fd20a7c0..0bada601a3b 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -9,9 +9,9 @@ from aiohttp import web import pytest import pytest_socket -from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.helpers import translation +from homeassistant.helpers.http import HomeAssistantView from homeassistant.setup import async_setup_component from .common import MockModule, mock_integration diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 633f98dc5b3..9207ba0904b 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -110,6 +110,10 @@ class AiohttpClientMocker: """Register a mock patch request.""" self.request("patch", *args, **kwargs) + def head(self, *args, **kwargs): + """Register a mock head request.""" + self.request("head", *args, **kwargs) + @property def call_count(self): """Return the number of requests made.""" diff --git a/tests/testing_config/blueprints/template/test_event_sensor.yaml b/tests/testing_config/blueprints/template/test_event_sensor.yaml new file mode 100644 index 00000000000..2ce8519c8e9 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_event_sensor.yaml @@ -0,0 +1,27 @@ +blueprint: + name: Create Sensor from Event + description: Creates a timestamp sensor from an event + domain: template + source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/template/blueprints/event_sensor.yaml + input: + event_type: + name: Name of the event_type + description: The event_type for the event trigger + selector: + text: + event_data: + name: The data for the event + description: The event_data for the event trigger + selector: + object: +triggers: + - trigger: event + event_type: !input event_type + event_data: !input event_data +variables: + event_data: "{{ trigger.event.data }}" +sensor: + state: "{{ now() }}" + device_class: timestamp + attributes: + data: "{{ event_data }}" diff --git a/tests/testing_config/blueprints/template/test_event_sensor_legacy_schema.yaml b/tests/testing_config/blueprints/template/test_event_sensor_legacy_schema.yaml new file mode 100644 index 00000000000..8b615eb90ba --- /dev/null +++ b/tests/testing_config/blueprints/template/test_event_sensor_legacy_schema.yaml @@ -0,0 +1,27 @@ +blueprint: + name: Create Sensor from Event + description: Creates a timestamp sensor from an event + domain: template + source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/template/blueprints/event_sensor.yaml + input: + event_type: + name: Name of the event_type + description: The event_type for the event trigger + selector: + text: + event_data: + name: The data for the event + description: The event_data for the event trigger + selector: + object: +trigger: + - trigger: event + event_type: !input event_type + event_data: !input event_data +variables: + event_data: "{{ trigger.event.data }}" +sensor: + state: "{{ now() }}" + device_class: timestamp + attributes: + data: "{{ event_data }}" diff --git a/tests/testing_config/custom_components/test/camera.py b/tests/testing_config/custom_components/test/camera.py deleted file mode 100644 index b2aa1bbc53b..00000000000 --- a/tests/testing_config/custom_components/test/camera.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Provide a mock remote platform. - -Call init before using it in your tests to ensure clean test data. -""" - -from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities_callback: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Return mock entities.""" - async_add_entities_callback( - [AttrFrontendStreamTypeCamera(), PropertyFrontendStreamTypeCamera()] - ) - - -class AttrFrontendStreamTypeCamera(Camera): - """attr frontend stream type Camera.""" - - _attr_name = "attr frontend stream type" - _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - _attr_frontend_stream_type: StreamType = StreamType.WEB_RTC - - -class PropertyFrontendStreamTypeCamera(Camera): - """property frontend stream type Camera.""" - - _attr_name = "property frontend stream type" - _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the stream type of the camera.""" - return StreamType.WEB_RTC diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 96ba8d0a325..3f288962009 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -298,6 +298,10 @@ def test_parse_time_expression() -> None: assert list(range(0, 60, 5)) == dt_util.parse_time_expression("/5", 0, 59) + assert dt_util.parse_time_expression("/4", 5, 20) == [8, 12, 16, 20] + assert dt_util.parse_time_expression("/10", 10, 30) == [10, 20, 30] + assert dt_util.parse_time_expression("/3", 4, 29) == [6, 9, 12, 15, 18, 21, 24, 27] + assert dt_util.parse_time_expression([2, 1, 3], 0, 59) == [1, 2, 3] assert list(range(24)) == dt_util.parse_time_expression("*", 0, 23) diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index e5b85f35693..ba473ee0c58 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -6,6 +6,7 @@ import logging import queue from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.core import ( @@ -17,6 +18,13 @@ from homeassistant.core import ( from homeassistant.util import logging as logging_util +async def empty_log_queue() -> None: + """Empty the log queue.""" + log_queue: queue.SimpleQueue = logging.root.handlers[0].queue + while not log_queue.empty(): + await asyncio.sleep(0) + + async def test_logging_with_queue_handler() -> None: """Test logging with HomeAssistantQueueHandler.""" @@ -149,3 +157,150 @@ async def test_catch_log_exception_catches_and_logs() -> None: func("failure sync passed") assert saved_args == [("failure sync passed",)] + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 5) +@patch( + "homeassistant.util.logging.HomeAssistantQueueListener.EXCLUDED_LOG_COUNT_MODULES", + ["excluded"], +) +@pytest.mark.parametrize( + ( + "logger1_count", + "logger1_expected_notices", + "logger2_count", + "logger2_expected_notices", + ), + [(4, 0, 0, 0), (5, 1, 1, 0), (11, 1, 5, 1), (20, 1, 20, 1)], +) +async def test_noisy_loggers( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + logger1_count: int, + logger1_expected_notices: int, + logger2_count: int, + logger2_expected_notices: int, +) -> None: + """Test that noisy loggers all logged as warnings.""" + + logging_util.async_activate_log_queue_handler(hass) + logger1 = logging.getLogger("noisy1") + logger2 = logging.getLogger("noisy2.module") + logger_excluded = logging.getLogger("excluded.module") + + for _ in range(logger1_count): + logger1.info("This is a log") + + for _ in range(logger2_count): + logger2.info("This is another log") + + for _ in range(logging_util.HomeAssistantQueueListener.MAX_LOGS_COUNT + 1): + logger_excluded.info("This log should not trigger a warning") + + await empty_log_queue() + + assert ( + caplog.text.count( + "Module noisy1 is logging too frequently. 5 messages since last count" + ) + == logger1_expected_notices + ) + assert ( + caplog.text.count( + "Module noisy2.module is logging too frequently. 5 messages since last count" + ) + == logger2_expected_notices + ) + # Ensure that the excluded module did not trigger a warning + assert ( + caplog.text.count("is logging too frequently") + == logger1_expected_notices + logger2_expected_notices + ) + + # close the handler so the queue thread stops + logging.root.handlers[0].close() + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 1) +async def test_noisy_loggers_ignores_self( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that the noisy loggers warning does not trigger a warning for its own module.""" + + logging_util.async_activate_log_queue_handler(hass) + logger1 = logging.getLogger("noisy_module1") + logger2 = logging.getLogger("noisy_module2") + logger3 = logging.getLogger("noisy_module3") + + logger1.info("This is a log") + logger2.info("This is a log") + logger3.info("This is a log") + + await empty_log_queue() + assert caplog.text.count("logging too frequently") == 3 + + # close the handler so the queue thread stops + logging.root.handlers[0].close() + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 5) +async def test_noisy_loggers_ignores_lower_than_info( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that noisy loggers all logged as warnings, except for levels lower than INFO.""" + + logging_util.async_activate_log_queue_handler(hass) + logger = logging.getLogger("noisy_module") + + for _ in range(5): + logger.debug("This is a log") + + await empty_log_queue() + expected_warning = "Module noisy_module is logging too frequently" + assert caplog.text.count(expected_warning) == 0 + + logger.info("This is a log") + logger.info("This is a log") + logger.warning("This is a log") + logger.error("This is a log") + logger.critical("This is a log") + + await empty_log_queue() + assert caplog.text.count(expected_warning) == 1 + + # close the handler so the queue thread stops + logging.root.handlers[0].close() + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 3) +async def test_noisy_loggers_counters_reset( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that noisy logger counters reset periodically.""" + + logging_util.async_activate_log_queue_handler(hass) + logger = logging.getLogger("noisy_module") + + expected_warning = "Module noisy_module is logging too frequently" + + # Do multiple iterations to ensure the reset is periodic + for _ in range(logging_util.HomeAssistantQueueListener.MAX_LOGS_COUNT * 2): + logger.info("This is log 0") + await empty_log_queue() + + freezer.tick( + logging_util.HomeAssistantQueueListener.LOG_COUNTS_RESET_INTERVAL + 1 + ) + + logger.info("This is log 1") + await empty_log_queue() + assert caplog.text.count(expected_warning) == 0 + + logger.info("This is log 2") + logger.info("This is log 3") + await empty_log_queue() + assert caplog.text.count(expected_warning) == 1 + # close the handler so the queue thread stops + logging.root.handlers[0].close() diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py index c0cd2fdba10..0cef48e0d84 100644 --- a/tests/util/test_ssl.py +++ b/tests/util/test_ssl.py @@ -7,6 +7,7 @@ import pytest from homeassistant.util.ssl import ( SSLCipherList, client_context, + create_client_context, create_no_verify_ssl_context, ) @@ -56,3 +57,28 @@ def test_ssl_context_caching() -> None: assert create_no_verify_ssl_context() is create_no_verify_ssl_context( SSLCipherList.PYTHON_DEFAULT ) + + +def test_create_client_context(mock_sslcontext) -> None: + """Test create client context.""" + with patch("homeassistant.util.ssl.ssl.SSLContext", return_value=mock_sslcontext): + client_context() + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.MODERN) + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.INTERMEDIATE) + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.INSECURE) + mock_sslcontext.set_ciphers.assert_not_called() + + +def test_create_client_context_independent() -> None: + """Test create_client_context independence.""" + shared_context = client_context() + independent_context_1 = create_client_context() + independent_context_2 = create_client_context() + assert shared_context is not independent_context_1 + assert independent_context_1 is not independent_context_2 diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index aeea4ad9a5a..885757b7eb4 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -24,6 +24,7 @@ from homeassistant.const import ( UnitOfMass, UnitOfPower, UnitOfPressure, + UnitOfReactiveEnergy, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -49,6 +50,7 @@ from homeassistant.util.unit_conversion import ( MassConverter, PowerConverter, PressureConverter, + ReactiveEnergyConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -78,6 +80,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { MassConverter, PowerConverter, PressureConverter, + ReactiveEnergyConverter, SpeedConverter, TemperatureConverter, UnitlessRatioConverter, @@ -127,6 +130,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo MassConverter: (UnitOfMass.STONES, UnitOfMass.KILOGRAMS, 0.157473), PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), PressureConverter: (UnitOfPressure.HPA, UnitOfPressure.INHG, 33.86389), + ReactiveEnergyConverter: ( + UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + 1000, + ), SpeedConverter: ( UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR, @@ -622,6 +630,20 @@ _CONVERTED_VALUE: dict[ (30, UnitOfPressure.MMHG, 1.181102, UnitOfPressure.INHG), (5, UnitOfPressure.BAR, 72.51887, UnitOfPressure.PSI), ], + ReactiveEnergyConverter: [ + ( + 5, + UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + 5000, + UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + ), + ( + 5, + UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + 0.005, + UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + ), + ], SpeedConverter: [ # 5 km/h / 1.609 km/mi = 3.10686 mi/h (5, UnitOfSpeed.KILOMETERS_PER_HOUR, 3.106856, UnitOfSpeed.MILES_PER_HOUR), @@ -806,12 +828,30 @@ _CONVERTED_VALUE: dict[ 2500, UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_SECOND, + 3600, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_SECOND, + 3600000, + UnitOfVolumeFlowRate.LITERS_PER_HOUR, + ), ( 3, UnitOfVolumeFlowRate.LITERS_PER_MINUTE, 50, UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND, ), + ( + 3.6, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + 1, + UnitOfVolumeFlowRate.LITERS_PER_SECOND, + ), ], } @@ -902,8 +942,8 @@ def test_convert_nonnumeric_value( ("converter", "from_unit", "to_unit", "expected"), [ # Process all items in _GET_UNIT_RATIO - (converter, item[0], item[1], item[2]) - for converter, item in _GET_UNIT_RATIO.items() + (converter, from_unit, to_unit, expected) + for converter, (from_unit, to_unit, expected) in _GET_UNIT_RATIO.items() ], ) def test_get_unit_ratio( @@ -915,13 +955,34 @@ def test_get_unit_ratio( assert converter.get_unit_ratio(to_unit, from_unit) == pytest.approx(1 / ratio) +@pytest.mark.parametrize( + ("converter", "from_unit", "to_unit", "expected"), + [ + # Process all items in _GET_UNIT_RATIO + (converter, from_unit, to_unit, expected) + for converter, (from_unit, to_unit, expected) in _GET_UNIT_RATIO.items() + ], +) +def get_unit_floored_log_ratio( + converter: type[BaseUnitConverter], from_unit: str, to_unit: str, expected: float +) -> None: + """Test floored log unit ratio. + + Should not use pytest.approx since we are checking these + values are exact. + """ + ratio = converter.get_unit_floored_log_ratio(from_unit, to_unit) + assert ratio == expected + assert converter.get_unit_floored_log_ratio(to_unit, from_unit) == 1 / ratio + + @pytest.mark.parametrize( ("converter", "value", "from_unit", "expected", "to_unit"), [ # Process all items in _CONVERTED_VALUE - (converter, list_item[0], list_item[1], list_item[2], list_item[3]) + (converter, value, from_unit, expected, to_unit) for converter, item in _CONVERTED_VALUE.items() - for list_item in item + for value, from_unit, expected, to_unit in item ], ) def test_unit_conversion( diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index ddefe92de42..87a9729700e 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -434,6 +434,7 @@ def test_get_unit_system_invalid(key: str) -> None: UnitOfVolume.CUBIC_METERS, ), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), + (SensorDeviceClass.GAS, UnitOfVolume.LITERS, None), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_METERS, None), (SensorDeviceClass.GAS, "very_much", None), # Test precipitation conversion @@ -573,7 +574,10 @@ UNCONVERTED_UNITS_METRIC_SYSTEM = { UnitOfLength.METERS, UnitOfLength.MILLIMETERS, ), - SensorDeviceClass.GAS: (UnitOfVolume.CUBIC_METERS,), + SensorDeviceClass.GAS: ( + UnitOfVolume.CUBIC_METERS, + UnitOfVolume.LITERS, + ), SensorDeviceClass.PRECIPITATION: ( UnitOfLength.CENTIMETERS, UnitOfLength.MILLIMETERS, @@ -687,6 +691,7 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: # Test gas meter conversion (SensorDeviceClass.GAS, UnitOfVolume.CENTUM_CUBIC_FEET, None), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET), + (SensorDeviceClass.GAS, UnitOfVolume.LITERS, UnitOfVolume.CUBIC_FEET), (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_FEET, None), (SensorDeviceClass.GAS, "very_much", None), # Test precipitation conversion diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 0346e21044f..dacbd2c1247 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -374,7 +374,7 @@ def test_include_dir_merge_named_recursive(mock_walk: Mock) -> None: } -@patch("homeassistant.util.yaml.loader.open", create=True) +@patch("annotatedyaml.loader.open", create=True) @pytest.mark.usefixtures("try_both_loaders") def test_load_yaml_encoding_error(mock_open: Mock) -> None: """Test raising a UnicodeDecodeError.""" @@ -598,7 +598,7 @@ def test_load_yaml_wrap_oserror( ) -> None: """Test load_yaml wraps OSError in HomeAssistantError.""" with ( - patch("homeassistant.util.yaml.loader.open", side_effect=open_exception), + patch("annotatedyaml.loader.open", side_effect=open_exception), pytest.raises(load_yaml_exception), ): yaml_loader.load_yaml("bla")